LINE×生成AI：プロンプト戦闘ゲーム

今回は、「LINE LIFFアプリを活用して、プロンプトでキャラクター同士を戦闘させるゲーム」を作成しました。現在もLINEで登録して遊べるので友達登録して遊んでみてください。


![友達登録.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3787969/390cc2ca-106a-ab76-8615-3439b22edfdb.png)
ども！Qiita初投稿の龍と申します。普段は「[SIOS Tech lab](https://tech-lab.sios.jp/archives/author/9b1b1a156ceff7662e6166d5982674ebca52e103)」というところでブログを書いています。
一応デモ動画を撮影しているので覗いてみてください。

<iframe width="560" height="315" src="https://www.youtube.com/embed/5ZP3TdRIi3M?si=lVgiKHjXkKJ6KVqt" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
このブログでは、実装面の苦労含めて投稿にまとめていこうと思います。

# ゲームコンセプト
![ゲームコンセプト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3787969/63a8e6ba-ed2e-9adc-5383-505b6bb6945d.png)
ユーザが送信する情報としては、キャラクター名・情報の二つになります。残りは「審判AI」が戦闘描写や勝敗を決定して出力してくれます。大事な処理に関しては「生成AI」に丸投げして生成してもらっています。


## なぜ？LINEをプラットフォームとして選択したのか？

自然とLINEを使う方向で進みました。プラットフォームとしてLINEを使用した理由として、大きく3つ挙げられます。

**お手軽なデモ**
デモを披露するための荷物は少なければ少ないほど理想的です。LINEのアカウントであれば、普段持ち歩いている端末のみでデモを見せることができます。また、友達登録してもらえれば相手の端末でアクセスすることができます。LINEは広く普及しているので、直感的操作が可能です。

**認証処理の手軽さ**
LIFFアプリの場合は、認証処理がLINE内で完結可能です。認証のハードルを最大限下げることができていると考えています。ゲームを作る場合は、個人特定をすることができたほうが表現の幅が広がります。これは開発者目線の話ですが、LIFFアプリの認証面の情報はリファレンスにも丁寧に記載されているので実装しやすいです。

**作ってみたかったからや**
まぁうだうだと書いていますが、結論はこれですね。高尚な理由なんてものは一つもないです。なんとなく実装してみたかったら作ってみたんです。

## アーキテクト

実装した環境を図にしています。

![アーキテクト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3787969/2d852a6e-e5a9-311f-3a72-1548e531f684.png)


|内容|リンク|
| --- | --- |
| フロントエンド | [Azure Static Web Apps](https://azure.microsoft.com/ja-jp/products/app-service/static) |
| バックエンド | [Azure Web Apps](https://azure.microsoft.com/ja-jp/products/app-service/web) |
| データベース | [Firebase Firestore](https://firebase.google.com/docs/firestore?hl=ja) |
| AIサービス | [Azure OpenAI Service](https://azure.microsoft.com/ja-jp/products/ai-services/openai-service)  |

デプロイ先としては、Azure上にまとめています。データベースのみFirestoreを活用して実装しています（手軽さ優先です）。ソース管理とデプロイは一貫してGitHubを使用しています。リポジトリは公開しているので興味がある方は覗いてみてください。
|内容|リンク|
| --- | --- |
| フロントエンド | https://github.com/Ryunosuke-Tanaka-sti/2024-line-liff-app-frontend |
| バックエンド | https://github.com/Ryunosuke-Tanaka-sti/2024-line-liff-app-backend |

フロントエンドはReactで環境構築しています。nodeの環境であれば動きます。バックエンドはnest.jsで実装しており、Dockerで環境構築しています。開発環境ではdevcontainerを活用して開発しています。


今回の実装では、先にAOAIでプロンプト検証を進めてからアプリケーション実装を始めました。プロンプト・データベース・アプリケーションの三段階に分けて解説を進めていきます。

## プロンプト

プロンプトは2段階に分けて実装しています。理由としてはAPIの返答を作成するためのJSON出力が、「GPT-4o」モデルでは確実に出力することができないからです。

https://tech-lab.sios.jp/archives/42584

新規の情報作成ではなく、「入力値から特定のフォーマットに変換する」というシンプルなタスクを「GPT-3.5-Turbo」にお任せすることで「GPT-4o」の不得意な部分をカバーしています。

### 戦闘描写プロンプト

モデルベースとしては、「GPT-4o」を利用しています。

![審判プロンプト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3787969/63559350-3241-2515-1031-d730a369749b.png)

こちらのプロンプトが目標としているのは、主に以下の2つになります。

- ユーザーとAI側のキャラクターの勝敗を判定する
- 戦闘の記録をダイナミックに脚色する

事前入力としては「AI側のキャラクター情報」を与えます。また、ここでJSON形式のひな形を作成します。返答としては、脚色された情報とJSONのような文字列が返答されます。

JSONの返答例を与えることで、JSON形式の返答率は上がります。ですが、GPT-4oはJSONでの出力を守ってくれないという悪癖があります。なので、ここでは文字列の情報としてJSON情報を受け取ります。

プロンプトの全文です。

```bash
  あなたは決闘の審判です。二つのキャラクターの戦闘を見守り、勝敗までの流れを判定してください。
  AI側がチャンピオン、ユーザー側が挑戦者です。
  次の内容は必ず守ってください「チャンピオンのキャラクターが勝利した場合はsystem、挑戦者が勝利した場合はuserと明記してください。」
  ---
  ${enemyPrompt}
  ---
  以下のType出力を守った内容を最後に付録として記載してください。
  ---
  {
    "combatLogs": {
      "round":number,
      "combatLog":string
    }[]
  }
  ---
  例は以下のようになります。combatLogは小説家のように過大に脚色して演出してください。決闘の勝者を明確にしてください。
  ---
  {
    "combatLogs": [
      {
        "round": 1,
        "combatLog": "訓練場の教官が鉄の剣で攻撃しました"
      },
      {
        "round": 2,
        "combatLog": "訓練場の教官が鉄の盾で防御しました"
      }
    ]
  }
  ---
```

### JSON整形プロンプト

モデルベースとしては、「GPT-3.5-Turbo」を利用しています。
![JSON正規プロンプト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3787969/38ccb0c7-5127-8261-677f-573d218a7981.png)

こちらのプロンプトが目標としているのは、「APIのレスポンスとして返すためのJSON作成」になります。

入力としては、「審判プロンプト」で作成された結果を入力します。また、文章中から勝者を判定してJSONの形式に出力を制限しています。

プロンプトの全文です。

```bash
- 出力をJSON形式にしてフォーマットとしては以下のサンプルに従ってください。
- 以下の形式のJSON以外は出力しないでください。
- AI側がチャンピオン、ユーザー側が挑戦者です。
- winnerには挑戦者が買った場合は「user」、チャンピオン側が買った場合は「system」を入力してください。
- winnerには「user」か「system」しか入力しないでください。
{
    "winner":"user"|"system",
    "combatLogs": {
      "round":number,
      "combatLog":string
    }[]
}
```

## データベース・キャラクター情報

データベースのテーブルは3つで構成しています。個人戦績・ログに関しては、アプリを継続的に使用することでデータが増加していきます。敵キャラクターに関しては、事前に生成したデータのみで情報が増えることはありません。

### テーブル：敵キャラクター

敵キャラクターに関しては、すべて生成AIを活用して作成しています。意外とポップな感じで出力してくれているのでかわいさとカッコよさが混在していますね。

![敵キャラ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3787969/da41135d-d8f4-9f06-06d1-0ca4b74e2ee5.png)

情報としては、以下の形式で保存されています。

- ID：敵キャラクターID
- name：敵キャラクター名
- prompt：キャラクタープロンプト（戦い方・武器）
- originalContentUrl：10MB以下の画像URL
- previewImageUrl：1MB以下の画像URL

将来的にMessagingAPIを利用することを想定して、圧縮用と非圧縮の画像用URLを二つ発行しています。

### テーブル：個人戦績

個人戦績を保存するテーブルになります。こちらは、ユーザーのモチベを保つために戦績を保存しています。

- ID：ユーザーID
- hotStreak：連勝数
- lossCount：敗戦数
- winCount：勝利数

### テーブル：ログ

生成AIの処理の結果を保存するテーブルになります。デバックとして使えるようにユーザーが入力した内容と生成AIが出力した内容を日時とともに保存しています。

- ID：自動割り振りID
- createdAt：アクセス日時
- hasError：エラーboolan
- prompt：ユーザ投稿prompt
- responseMessage：生成AI出力

## アプリケーション

今回実装したアプリケーションのページとAPIの構成としては以下になります。実装に関してすべて書いていくのは大変なので、特徴的な実装に関してだけ記載していきます。

**フロントエンド**

| ページ名 | 説明 |
| --- | --- |
| キャラクター入力画面 | 敵キャラクター情報（名前・画像）表示/入力フォーム：「名前・特徴」/ユーザー戦績表示 |
| バトル結果表示画面 | 敵キャラクター情報（名前・画像）表示/勝敗表示/戦闘描写表示/ボタン：「次の戦い」 |
| LINEアカウント宣伝画面 | QRと説明文 |

**バックエンド**

| API名 | 説明 |
| --- | --- |
| GET：ユーザー情報取得 | 「テーブル：個人戦績」から情報を取得して返答 |
| GET：敵情報取得 | 「テーブル：敵キャラクター」からランダムに情報を取得して返答 |
| POST：戦闘描写作成 | 敵キャラクターIDとユーザー入力のキャラクター情報をリクエストとして取得・プロンプトで戦闘描写と勝敗を作成して返答 |

### 検証・ユーザー情報取得：フロント→バックエンド

バックエンドにフロントエンドで取得したユーザー情報を送信するのは、リファレンスで警告されています。フロントで取得したAccessToken用いてバックエンド側でユーザー情報を取得する方法が推奨されています。

https://developers.line.biz/ja/docs/liff/using-user-profile/#use-user-info-on-server

今回の実装では、個人を特定するID（uesrId）を使用しています。userIdを取得するまでは以下の流れで処理をしています。

LIFFアプリからのアクセスであることを確認するため、すべてのAPIルートにnest.jsのGuardとして保護をしています。「GET：ユーザー情報取得」と「POST：戦闘描写作成」では、userIdの取得を行っています。

:::note info
検証・ユーザー情報取得までの流れ

- フロントエンド
    - AccessTokenを取得して、axiosのInterceptorを使用してヘッダーにトークンを埋め込む
- バックエンド
    - AccessTokenを用いてトークン検証を行う：[APIリファレンス](https://developers.line.biz/ja/reference/line-login/#verify-access-token)
    - AccessTokenを用いてユーザー情報を取得する：[APIリファレンス](https://developers.line.biz/ja/reference/line-login/#get-user-profile)
    - userIdを使用する
:::



**参考記事**

https://tech-lab.sios.jp/archives/33155

https://tech-lab.sios.jp/archives/38738

https://developers.line.biz/ja/reference/line-login/#get-user-profile

### フロントエンド：ローディング

実装後に露呈したのですが、今回の実装では待機時間が長めに発生している場所があります。

- LIFFアプリの初期化・初期情報取得：5s
- POST：戦闘描写作成：5s~20s

画面を固めておくわけにもいかないので、ローディングアニメーションを挟み込んでいます。自前で実装するのは大変なので、[Lottie](https://lottiefiles.com/jp/)を使用してクオリティの高いローディングを挟み込んでいます。Reactでは、[Lottieのプレイヤー](https://github.com/LottieFiles/lottie-react#readme)があるのでさくっと実装することができます。

### フロントエンド：LIFFアプリ以外からのアクセスの場合

APIへのアクセスにはAccessTokenが必要になります。そのため、初期化が完了するまでに3つの画面を用意しています。

- 初期化が未完了：Loading画面
- 初期化が完了
    - LIFFブラウザ：ゲーム画面
    - LIFFブラウザ以外：LINEアプリへの誘導画面

以下に実装に使用したコードを出しておきます。

```tsx
import { LoadingComponent } from "@components/common/LoadingComponent";
import liff from "@line/liff";
import { NotLineClientPage } from "@pages/NotLineClientPage";
import { useEffect, useState } from "react";
import { useErrorBoundary } from "react-error-boundary";

type Props = {
  children: React.ReactNode;
};

export const LiffInit = (props: Props) => {
  const { children } = props;
  const { showBoundary } = useErrorBoundary();
  const [isInLineClient, setIsInLineClient] = useState<boolean>(false);
  const [liffInit, setLiffInit] = useState<boolean>(false);

  useEffect(() => {
    liff
      .init({
        liffId: import.meta.env.VITE_LIFF_ID,
      })
      .then(() => {
        setLiffInit(true);
        setIsInLineClient(liff.isInClient());
        console.log("LIFF init succeeded.");
      })
      .catch((e: Error) => {
        setLiffInit(false);
        showBoundary(e);
      });
  });
  if (!liffInit) {
    return <LoadingComponent />;
  }
  if (!isInLineClient) {
    return <NotLineClientPage />;
  }
  return <>{children}</>;
};

```

# 課題

アプリケーションは動いているので、実装面での反省と課題を考えておこうと思います。

- **APIの返答時間が長い**
「POST：戦闘描写作成」では、最大20sとリクエストの処理に時間がかかっています。プロンプトを2個叩いていることも影響しているかと思います。プロンプトを一つにまとめるか、Azure側の設定を見直す必要があるかもしれません。
- **大規模リクエスト**
同時のリクエストの際に処理時間が長くなるかもしれません。現在のフォロワーが40名程度ですが、障害報告は出ていない認識です。（今回の投稿で出たらうれしいな！）ですが、同時に大量のリクエストを処理できるかはわからないので出たら勉強して対応しようと思います。
- **キャラクターの拡充**
キャラクターは事前に生成AIを利用して作成しています。画像生成AIなども活用して投稿できるようにしたいのですが、画像生成AIが著作権をガン無視してくる可能性があります。ポ〇モンとか平気で出力してきますね。この辺の課題をどのように解消するのか、継続的に調べていきます。

# 今後の展望

勢いで作りましたが、これからもLINEを使った開発を進めていきたいと考えています。長期的な目線ではなく短期的な目線で考えておきます。

- **世界観・設定を変更したバージョン違い**
プロンプトの設定や世界観を変更することでゲームの設定を柔軟に変更することができます。プロンプトのバリエーションをこれからも貯めていければ、様々なゲームを作れそうです。
- **MessagingAPIを活用**
LIFFアプリを作成しましたが、チャット機能が余っています。ここでも何かしらの機能を作れたらよいと考えています。

# 終わり

ここまで読んでくれてありがとうございます！このゲームを作るのすごく楽しかったので、みんなにも遊んでもらえたら嬉しいです。面白そうだなって思った人は、ぜひLINEで友達登録してみてください！一緒にキャラクター同士を戦わせましょう！ご意見めちゃくちゃお待ちしています。