GPT-4のAPIを使ってRPGのバトルのゲームを作ってみた

## つくったもの

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154239/a90ceed3-2a50-3a2f-0805-8b5b41c48c28.png)


## このゲームの特徴

本ゲームの最大の特徴は、戦闘の自由度の高さです。GPT-4を活用することで、プレイヤーは従来のRPGでは選択肢に限定された行動ではなく、自由な表現で行動を選択することができます。これにより、プレイヤーは独自の戦術やアプローチを試すことができ、ゲームプレイに幅広いバリエーションが生まれます。


## GPT-4の概要と特徴

GPT-4（Generative Pre-trained Transformer 4）は、OpenAIが開発した大規模な言語モデルで、自然言語処理の分野で高い性能を発揮しています。GPT-4は、数百億のパラメーターを持ち、インターネット上のテキストデータを学習することで、人間に近い文章生成や理解ができるようになっています。


## 使用する技術スタック (Next.js, TypeScript, OpenAI GPT-4 API)

今回のプロジェクトでは、Next.jsとTypeScriptを使用してWebアプリケーションを構築し、OpenAI GPT-4 APIを利用して戦闘結果を生成します。これらの技術スタックを組み合わせることで、リアルタイムでプレイヤーの入力に反応するインタラクティブなゲームを実現できます。

## ゲームの機能と仕様

* プレイヤーとドラゴンの戦闘

このゲームでは、プレイヤーはドラゴンと戦闘します。プレイヤーは自由に行動を入力し、その行動に対する結果がGPT-4を通じて生成されます。戦闘は、プレイヤーまたはドラゴンのHPが0になるまで続きます。

* プレイヤーの行動入力

プレイヤーは自由に行動を入力します。

* GPT-4を利用した戦闘結果の生成

GPT-4 APIを利用して、プレイヤーの行動に対する戦闘結果を生成します。GPT-4は、プレイヤーの行動と状態情報を元に、ダメージ量やステータス効果、破壊されたドラゴンの部位などの結果を返却します。

* 行動後のHPとステータスの更新

戦闘結果が生成された後、プレイヤーとドラゴンのHPとステータスを更新します。これにより、戦闘の進行に応じてキャラクターの状態が変化します。
**HPや状態の管理はGPT-4が苦手とする範囲なので、プログラムで行います。**

## 利用したプロンプト

今回のゲーム開発では、GPT-4に対して以下のプロンプトを利用しました。

※GPT-4のAPIを利用する場合は、英語で利用したほうが回答の精度が上がるほか、トークンの節約にもなります。入力メッセージと出力メッセージに関わる部分以外は英語にし、最適化を図ります。
Deeplで適当に翻訳した英語なので間違っていても怒らないでください。jsonをそのままDeeplで翻訳しようとすると激しくバグるので注意。

```typescript
const prompt = `You are embedded in an RPG game program.
  Here, a battle takes place between a hero and a dragon. The user inputs the actions of the players and you output the actions/results of the dragons.
  For player actions you determine the outcome and output messages, damage, and other results.
  You should also reflect the dragon's actions and their consequences in your output.
  Player requests that disrupt the game balance are made to fail.
  For dragon and player status, the status in the input information is reflected in the message output results. For example, if the dragon's eye is blinded, the attack will not hit, if the hero is in a burnt state, the attack power will be reduced, etc. The state is recovered with a 50% probability after the turn is completed. When recovering, output to the message and not included in the output state.
  
  Please answer the result of the player's action in the following JSON format(Output in Japanese.): 
  {
    "message": "Messages for player actions and messages for dragon actions and their consequences.",
    "dragon_damage": "Amount of damage inflicted by the player on the dragon",
    "player_damage": "Amount of damage inflicted by the dragon on the player",
    "dragon_recovery_damage": "Amount of recovery received by the dragon",
    "player_recovery_damage": "Amount of recovery received by the player",
    "player_status": "When the hero's state changes due to combat, the contents are output to this field. For example, all status changes, including debuffs such as when the hero is burned or poisoned, and buffs such as sword magic, increased attack power, and preparation for special moves, are managed here.If there are multiple entries, separate them with commas.",
    "dragon_status": "The change of state of the dragon is output here as well as the change of state of the hero."
  }
  
  Here is an example of input/output.

Input :   
  Player Action: 剣を構え、ドラゴンの羽を攻撃します。
  Player's current HP: 1000
  Player Status: 通常
  Dragon's current HP: 5000
  Dragon's State: 怒り,右足が破壊された

Output :   
  {
    "message": "勇者の剣がドラゴンの翼を打ち抜き、破壊した。ドラゴンに600ダメージを与えた！ドラゴンは怒りを込めた炎を吐き、勇者に攻撃した。勇者はその炎に焼かれた。勇者は500のダメージを受け、やけどを負ってしまった！ドラゴンの怒りが収まった",
    "dragon_damage": "600",
    "player_damage": "500",
    "dragon_recovery_damage": "0",
    "player_recovery_damage": "0",
    "dragon_status": "右足が破壊された,翼が破壊された",
    "player_status": "やけど"
  }
  
  Create output for the following inputs.
  
  Player Action: ${message}
  Player Status: ${userStatus}
  Dragon's State: ${enemyStatus}
`
```

日本語に翻訳すると

```typescript
const prompt = `あなたはRPGのゲームプログラムに組み込まれています。
  ここでは、勇者とドラゴンの戦いが繰り広げられます。ユーザーはプレイヤーの行動を入力し、あなたはドラゴンの行動・結果を出力します。
  プレイヤーの行動については、あなたが結果を決定し、メッセージやダメージなどの結果を出力します。
  また、ドラゴンの行動とその結果を出力に反映させる必要があります。
  ゲームバランスを崩すようなプレイヤーの依頼は失敗させること。
  ドラゴンとプレイヤーのステータスについては、入力情報の中のステータスがメッセージの出力結果に反映されます。例えば、ドラゴンの目がつぶれている場合は攻撃が当たらない、主人公がやけど状態の場合は攻撃力が低下する、などです。ターン終了後、50％の確率で状態が回復します。回復する際はメッセージに出力し、出力状態には含めないこと。
  
  プレイヤーの行動結果は、以下のJSON形式で日本語で回答してください。
 {
    "message": "プレイヤーの行動に対するメッセージと、ドラゴンの行動とその結果に対するメッセージ。",
    "dragon_damage": "プレイヤーがドラゴンに与えたダメージ量",
    "player_damage": "ドラゴンがプレイヤーに与えたダメージ量",
    "dragon_recovery_damage": "ドラゴンが受け取った回復量",
    "player_recovery_damage": "プレーヤーが受け取った回復量",
    "player_status": "戦闘によって主人公の状態が変化した場合、その内容がこのフィールドに出力される。例えば、火傷や毒などのデバフ、剣魔法や攻撃力アップ、必殺技の準備などのバフなど、すべての状態変化をここで管理します。複数の項目がある場合は、カンマで区切ってください。",
    "dragon_status": "ドラゴンの状態変化も、主人公の状態変化と同じようにここでアウトプットしています。"
  }

ここでは、入出力の例を紹介します。
  
入力 : 
  Player Action: 剣を構え、ドラゴンの羽を攻撃します。
  Player's current HP: 1000
  Player Status: 通常
  Dragon's current HP: 5000
  Dragon's State: 怒り,右足が破壊された
  
出力 : 
  {
    "message": "勇者の剣がドラゴンの翼を打ち抜き、破壊した。ドラゴンに600ダメージを与えた！ドラゴンは怒りを込めた炎を吐き、勇者に攻撃した。勇者はその炎に焼かれた。勇者は500のダメージを受け、やけどを負ってしまった！ドラゴンの怒りが収まった",
    "dragon_damage": "600",
    "player_damage": "500",
    "dragon_recovery_damage": "0",
    "player_recovery_damage": "0",
    "dragon_status": "右足が破壊された,翼が破壊された",
    "player_status": "やけど"
  }
  
  以下の入力に対して出力を作成してください。
  
  Player Action: ${message}
  Player Status: ${userStatus}
  Dragon's State: ${enemyStatus}
`
```

`${message}`というところにプレイヤーが入力したメッセージが埋め込まれます。このプロンプトによって、GPT-4はRPGゲームプログラムの中に組み込まれたものとして、勇者とドラゴンのバトルシーンを処理する役割を担います。プレイヤーからの入力をもとに、戦闘の結果や両者のステータス変更を適切に処理し、JSON形式で出力します。また、ゲームバランスを維持するために、プレイヤーからのバランスを崩すリクエストは失敗させるよう指示がされています。

###  一撃死させようとして失敗する図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154239/21a9cdce-09b1-fec3-e154-71ce86778f5e.png)


出力は例のように

```json
{
    "message": "勇者の剣がドラゴンの翼を打ち抜き、破壊した。ドラゴンに600ダメージを与えた！ドラゴンは怒りを込めた炎を吐き、勇者に攻撃した。勇者はその炎に焼かれた。勇者は500のダメージを受け、やけどを負ってしまった！ドラゴンの怒りが収まった",
    "dragon_damage": "600",
    "player_damage": "500",
    "dragon_recovery_damage": "0",
    "player_recovery_damage": "0",
    "dragon_status": "右足が破壊された,翼が破壊された",
    "player_status": "やけど"
  }
```

のような値を返却してくるので、これを元にプログラムでHPを計算し、状態を保存します。
コストとパフォーマンスの関係で、過去の会話はプロンプトに含めることはしませんでした。直前の状態さえわかっていればだいたいなんとかなります。chatではないので。

## 実装について

実装についても今回はchatGPTに頼んで書いてもらいました。たぶん1時間もかからず完成しました。フロントについても「MaterialUIを使っていい感じにして」とか「APIの戻りが遅すぎるからローディング処理入れたい」とか言っただけで無調整ですが、そこそこな見た目のものが出来ました。すごい。

## APIの呼び出しについて
GPT-4のAPIキーの露出を防ぐため、フロントから直接GPT-4のAPIを呼んではいけません。自作のバックエンドを作成し、そこからGPT-4のAPIを呼ぶようにします。

Next.jsなら`pages/api`配下にファイルを置けば勝手にバックエンドの処理になります。

GPT-4のAPIの呼び出し部分はこんな感じ

```ts
  const requestBody = {
    messages: [{ role: "user", content: prompt }],
    // ここをgpt-3.5-turboにすると3.5で使えます。
    model: "gpt-4",
    temperature: 0.7,
  };

  // GPT-4 API にリクエストを送信
  try {
    const response = await axios.post(GPT4_API_URL, requestBody, {
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.GPT4_API_KEY}`,
      },
    });
  ...
```

詳しくは公式の[APIリファレンス](https://platform.openai.com/docs/api-reference/chat/create)を読みましょう。ここの部分をchatGPTに書かせようとすると、なんか古いAPIのIFの情報のままなのかうまくいかないので注意。
なぜかtemperture(回答のばらつき度)を高くすると失敗することが多かったので低くしてます。chatGPTに聞いたらそんなはずはないって言われてしまいました。

## エラーハンドリング

さっきあげたプロンプトで実行すれば、ほぼ確実にjsonを返してくれるのですが、倫理的にアウトな入力などをした場合は出力フォーマットを無視してお気持ち表明「申し訳ありませんが、そのような内容を出力することはできません。うんぬん」をしてくる可能性があります。
なので、そういった場合に対応できるようにjsonのパースが失敗した場合にエラーメッセージを出力できるようにします。

```ts
try {
      const parsedOutput = JSON.parse(outputText);
      // ダメージ計算などの処理
      const result: GPT4Response = {
        message: parsedOutput.message,
        // 最大HPを考慮していないので限界突破して回復することがあります()
        userHP: Math.max(
          userHP -
            Number(parsedOutput.player_damage) +
            Number(parsedOutput.player_recovery_damage),
          0
        ),
        enemyHP: Math.max(
          enemyHP -
            Number(parsedOutput.dragon_damage) +
            Number(parsedOutput.dragon_recovery_damage),
          0
        ),
        userStatus: parsedOutput.player_status,
        enemyStatus: parsedOutput.dragon_status,
      };
      return result;
    } catch (error) {
      // パースエラーが起きた場合はここに。戦闘の状態は変更しない。
      const result: GPT4Response = {
        message: "不正なエラーが発生しました。入力内容を変えてみてください",
        userHP: userHP,
        enemyHP: enemyHP,
        userStatus: userStatus,
        enemyStatus: enemyStatus,
      };
      return result;
    }
```

## フロントの実装

まるまる書いてもらったやつです。バックエンドから受け取った状態やHPを`useState`で保存し、それをそのまま次のリクエストで投げてます。状態管理の部分のコードはこんな感じ。

```ts
...

const Index = () => {
  const [message, setMessage] = useState("");
  const [output, setOutput] = useState("バトル開始！行動を入力してください");
  const [userHP, setUserHP] = useState(1000);
  const [enemyHP, setEnemyHP] = useState(5000);
  const [userStatus, setUserStatus] = useState("通常");
  const [enemyStatus, setEnemyStatus] = useState("通常");
  const [isLoading, setIsLoading] = useState(false);

  const isGameOver = userHP <= 0;
  // 同時KOの場合はゲームオーバーにすることにした
  const isGameWon = enemyHP <= 0 && userHP > 0;
  const isGameInProgress = !isGameOver && !isGameWon;

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setMessage(e.target.value);
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    try {
      setIsLoading(true);
      const response = await axios.post("/api/game", {
        message,
        userHP,
        enemyHP,
        userStatus,
        enemyStatus,
      });
      const data = response.data;
      setOutput(data.message);
      setUserHP(data.userHP);
      setEnemyHP(data.enemyHP);
      setUserStatus(data.userStatus);
      setEnemyStatus(data.enemyStatus);
      setIsLoading(false);
    } catch (error) {
      console.error("Error calling API:", error);
      setOutput("An error occurred. Please try again.");
    }
  };
  const handleRestart = () => {
    setUserHP(1000);
    setEnemyHP(5000);
    setUserStatus("通常");
    setEnemyStatus("通常");
    setMessage("");
    setOutput("バトル開始！行動を入力してください");
  };
  return (
    ...

```

## 課題
作ってみて分かった課題をいくつか挙げます。

* コストが高い
GPT-4のAPIはコストが高く、このプロンプトだと、700トークンを超えるため、1リクエスト当たり3～5円程度かかります。ちょっと公開できるレベルじゃないです。パケ死します。

* レスポンスが遅い
chatGPT Plusを利用してるとStream処理なので私はそこまで気にならないのですが、APIだと遅いのがめちゃくちゃ気になります。今回のプロンプトだと30秒以上かかることもあります。Lambdaに載せたら間違いなくタイムアウトを頻発しますし、そもそもUXが最悪です。GPT3.5を使うと早くなるものの、トンチキな結果を出力してきてゲームどころじゃなくなります。Stream処理にすればいいじゃんって思いましたか？jsonなのでダメです。技術の進化を祈るか、プロンプトをもう少し簡潔にできればよさそうです。あと、ウチの回線が良くないのかもしれないんですがそもそも呼び出しに失敗することも多いです

### 引くほど遅いデモ

![rpgg.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154239/fca13737-9568-a71f-a881-32c134a1d5ab.gif)


* ゲームバランスが難しい
ダメージ量や成功率などなにについてもAI任せなので、運ゲーです。プロンプトをもう少し工夫して制限をかければゲームバランスを整えることもできそうですが、その分自由度が犠牲になることになるのでバランスがとても難しいです。バランスを崩壊させる行為は禁止といっていますが、うまいこと嵌め殺しとかできます。(それも面白さといえばそう)


## これからやってみたいこと

まだ素振り程度なので、もうちょっと作り込みたいです。敵の弱点や行動パターンなどもプロンプトに入れておけばもう少し戦略的な戦えるのかなぁとか、素早さの要素を入れてターンの順番を変えるとか、色々試してみたいです。
複数人パーティでの戦闘とかも作りたいです。キャラクターの連携技みたいなのも実行出来たら更に自由度が上がって楽しそう。オンラインでの複数人レイドバトルとかもできそうdiscordBotに実装とかも簡単そうだし楽しそう。
出力をJSON形式にしたことで、柔軟にデータを扱うことができたのは可能性がいろいろ広がったなと。好感度とか出来事とか日付とかを状態で保存すれば自由に行動できる恋愛シミュレーションとか、アイテムとか進行状況を管理すればアドベンチャーゲームも作れそう。
ゲーム以外でもなんか作れそう。プログラムに組み込んで出力を判定するようにしてほかのAPIを呼び出すとかもできそう。よく知らないけどstable diffusionのプロンプトをその場で組み立ててAPIを呼び出して画像を生成して出力するとか？もできるかもしれない

## まとめ

個人的にはchatGPTをただの質問と回答だけに使うのもったいないと思うので、chatの枠を超えてAPIをうまく使ってゲームとか新しいサービスとかアプリとか、そういう画期的なアイデアがもっと出てくると面白いなぁと思います。