🚗

【前編】マリオカート自動集計Botを作成した話

2024/12/11に公開

はじめに

株式会社ハックツ エンジニアのあいでんです。
私は普段マリオカート8DXでよく遊んでいるのですが、今回はそのために開発した『マリオカート自動集計Bot』を紹介いたします!

今回はOBSから配信画面を取得し、文字起こしを行い、描画するところまでをご紹介します。配信画面がリザルトを表示しているかを制御する部分などは【後編】にてご紹介します。

https://github.com/keisuke071411/mk8dx-bot

要約

マリオカート8DXを遊びながら、レース結果をリアルタイムで集計できます📈
(実際のゲーム画面に表示されているわけではなく、OBS上にゲーム画面と集計画面が表示されています)

補足

マリオカート8DXとは

任天堂から販売されているNintendo Switch用ソフトで、『マリオカート』シリーズの最新作です。
2017年4月28日から販売されており、国内累計販売本数が600万本を超える人気作品です。

そんなマリオカート8DXには、『ラウンジ』というガチ勢用の非公式レーティングシステムが存在します。ラウンジでは1vs1、2vs2、3vs3、4vs4の対戦形式があり、チームごとの点数状況を知りたくなる時がよくあります。

これまでもチームごとの点数状況を知るための方法は存在していたのですが、そのほとんどがスプレッドシートなどを活用した手動によるものでした。
最近ではマリオカート配信をする方達により自動集計Bot(と、ここでは呼称)が誕生しており、自分もそれに倣って開発してみようと思い、今回に至りました。

ラウンジのシステムについて

ラウンジでは1ゲームあたり12人で行われ、12コースの合計点を競います。
各レースの順位ごとにポイントが付与され、ポイントテーブルは以下のようになっています。

1位 2位 3位 4位 5位 6位 7位 8位 9位 10位 11位 12位
15Pts 12Pts 10Pts 9Pts 8Pts 7Pts 6Pts 5Pts 4Pts 3Pts 2Pts 1Pts

また、チーム戦(2vs2など)の場合は表示されるユーザー名にチーム名(以下、「タグ」)をつけることがルール化されています。そのため、タグごとにプレイヤーを識別することでチームの合計点を算出できます。

今回は各レースごとにプレイヤーの順位を検出してポイントテーブルに準じたポイントを付与し、タグごとに合計点を算出できるアプリの完成を目指します。

概要

今回のゴール

  • 配信画面をOBSから取得する
  • ChatGPTで画像から文字を起こす
  • 集計結果を描画する

完成イメージ

OBS WebSocketでゲーム画面を取得する

フロントはNext.jsで開発しました。
Pythonで作成するテンプレートマッチング機能をAPI化した時に、RouterHandlerを使った方が開発が楽だなっていうのが選定理由です。

また、今回はゲーム画面の取得にOBS WebSocket、文字起こしにChatGPTを採用しました。
まずはOBSを使いためのパッケージインストールから行います。

pnpm install obs-websocket-js 

WebSocket設定ウィンドウ」を開いて、「WebSocketサーバーを有効にする」にチェックを入れます。次に「接続情報を表示」を押下し、WebSocket接続情報を控えます。この値はenvファイルなどから渡します。

OBS WebSocketにアクセスできる準備が整ったので、現在表示されているゲーム画面を取得します。
取得したものをpng形式で返却し、テンプレートマッチングや文字起こしの素材として活用します。

/api/obs/route.ts
import type { NextRequest } from "next/server";
import { OBSWebSocket } from "obs-websocket-js";

export async function POST(request: NextRequest) {
  const obs = new OBSWebSocket();

  try {
    const body = await request.json();

    await obs.connect(`http://${body.localIp}:4455`, body.password);
    console.info("Connected to OBS WebSocket");

    // 現在のプレビューを取得してスクリーンショットを保存
    const response = await obs.call("GetSourceScreenshot", {
      sourceName: "game", // OBSでゲーム画面を表示しているソース名に変更
      imageFormat: "png",
    });

    return new Response(
      JSON.stringify({ success: true, screenshot: response.imageData }),
      {
        status: 200,
        headers: {
          "Content-Type": "application/json",
        },
      },
    );
  } catch (error) {
    console.error("Error connecting to OBS WebSocket:", error);
    return new Response(JSON.stringify({ success: false, error: error }), {
      status: 500,
      headers: {
        "Content-Type": "application/json",
      },
    });
  } finally {
    obs.disconnect();
  }
}

ChatGPTで画像から文字を起こす

次に画像からテキストの文字起こしを行います。

当初はCloud Vision APIOCRで画像からテキストを検出しようとしていましたが、コースごとに精度のばらつきが出てしまいました。背景に文字が含まれていると、それら全てをテキストとして検出してしまうため期待する結果を得にくくなります。

グレースケールや二値化なども試したのですが、コースによってはその処理が返って精度を低くする原因になることがありました。マリオカート8DXは96コースも存在しているため、それら全てに対応することは難しいと考え、採用を見送りました。

精度が高くなる画像 精度が低くなる画像

代替手段として、今回はChatGPTを使うことにしました。
ChatGPTを使うことで高い精度で文字起こしをできるだけではなく、プロンプトを調整することで自分が期待する形式にフォーマットして値を返却することができます。

Cloud Vision APIよりもコストが高いことが難点ですが、比較的簡単な設定で高い精度の文字起こしができるという点で、今回の構成としてはピッタリだと判断しました。
API KEYの発行等々は別記事をご参照ください🙇

pnpm install openai
/infra/opneAi.ts
import OpenAI from "openai";

export const openAi = new OpenAI({
  organization: process.env.OPEN_AI_ORGANIZATION,
  project: process.env.OPEN_AI_PROJECT,
  apiKey: process.env.OPEN_AI_API_KEY,
});

/api/chatgpt/route.ts
import { PROMPT } from "@/constants/prompt";
import { openAi } from "@/infra/openAi";
import type { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();

    const response = await openAi.chat.completions.create({
      model: "gpt-4o-mini-2024-07-18",
      messages: [
        {
          role: "user",
          content: [
            {
              type: "text",
              text: PROMPT,
            },
            {
              type: "image_url",
              image_url: {
                url: body.imageUrl,
              },
            },
          ],
        },
      ],
    });

    if (!response.choices[0].message.content) {
      return new Response(
        JSON.stringify({ message: "chatGpt Error: No response content" }),
        {
          status: 500,
          headers: { "Content-Type": "application/json" },
        },
      );
    }

    const json = JSON.stringify({
      success: true,
      response: JSON.parse(
        response.choices[0].message.content
          .replaceAll("\n", "")
          .replaceAll("```", "")
          .replace("json", ""),
      ),
    });

    return new Response(json, {
      status: 200,
      headers: {
        "Content-Type": "application/json",
      },
    });
  } catch (error) {
    console.error("chatGpt Error:", error);
    return new Response(JSON.stringify({ success: false, error: error }), {
      status: 500,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }
}

プロンプトは以下のような内容になっています。
順位によるポイントの配布はフロント側で行うため、ここでは順位とユーザー名、チームを検出してもらいます。

ラウンジではチーム名を先頭、または末尾につけるルールになっています。
末尾まで対応しようとすると、プロンプトが膨大かつ複雑になる割に精度が低かったことや、末尾タグを使う方はあまり多くないことなどを考慮し、先頭だけを対応することにしました。

また、チーム名の先頭1文字目が他のチームの先頭1文字目と重複することは禁止されているので、最初の一文字目のアルファベットだけを検出することで処理を軽くしています。

/constants/prompt.ts
export const PROMPT = `
  ## instruction ##
  ゲームの結果画面を解析して全員分の順位とユーザー名、チームを出力してください。

  結果画面には左から以下の項目が書いてあります。
  1. 順位
  2. ユーザー名
  3. 得点

  ## restriction ##
  - [rank]は順位です。
  - [rank]は1〜12までの整数です。
  - [name]はユーザー名です。
  - [name]はそのまま出力してください。
  - [team]は[name]の最初の一文字目のアルファベットです。
  - [team]は大文字で出力してください。

  ## Output Format ##
  {
    "results": [
      {
        rank: [rank],
        name: [name],
        team: [team],
      },
    ]
  }
`;

ChatGPTをAPI経由で叩くと、画像からテキストを文字起こしした時のレスポンスは以下のような形になっています。このままではparseなどもできず使いづらいので、response.choices[0].message.content.replaceAll("\n", "").replaceAll("```", "").replace("json", "")を行っています。

```json\n{\n  \"results\": [\n    {\n      \"rank\": 1,\n      \"name\": \"MZM\",\n      \"team\": \"M\"\n    },\n    {\n      \"rank\": 2,\n      \"name\": \"R\",\n      \"team\": \"R\"\n    },\n    {\n      \"rank\": 3,\n      \"name\": \"R★\",\n      \"team\": \"R\"\n    },\n    {\n      \"rank\": 4,\n      \"name\": \"JJ\",\n      \"team\": \"J\"\n    },\n    {\n      \"rank\": 5,\n      \"name\": \"DP☆パルキア\",\n      \"team\": \"D\"\n    },\n    {\n      \"rank\": 6,\n      \"name\": \"DP\",\n      \"team\": \"D\"\n    },\n    {\n      \"rank\": 7,\n      \"name\": \"Jay\",\n      \"team\": \"J\"\n    },\n    {\n      \"rank\": 8,\n      \"name\": \"VX\",\n      \"team\": \"V\"\n    },\n    {\n      \"rank\": 9,\n      \"name\": \"A★\",\n      \"team\": \"A\"\n    },\n    {\n      \"rank\": 10,\n      \"name\": \"AAさくら\",\n      \"team\": \"A\"\n    },\n    {\n      \"rank\": 11,\n      \"name\": \"VX\",\n      \"team\": \"V\"\n    },\n    {\n      \"rank\": 12,\n      \"name\": \"Moin\",\n      \"team\": \"M\"\n    }\n  ]\n}\n```

これによりプロンプトにも記載しているような期待した形式にフォーマットできます。

フォーマット後
{
        "results": [
            {
                "rank": 1,
                "name": "MZM",
                "team": "M"
            },
            {
                "rank": 2,
                "name": "R",
                "team": "R"
            },
            {
                "rank": 3,
                "name": "R★",
                "team": "R"
            },
            {
                "rank": 4,
                "name": "JJ",
                "team": "J"
            },
            {
                "rank": 5,
                "name": "DP☆パルキア",
                "team": "D"
            },
            {
                "rank": 6,
                "name": "DP",
                "team": "D"
            },
            {
                "rank": 7,
                "name": "Jay",
                "team": "J"
            },
            {
                "rank": 8,
                "name": "VX",
                "team": "V"
            },
            {
                "rank": 9,
                "name": "A★",
                "team": "A"
            },
            {
                "rank": 10,
                "name": "AAさくら",
                "team": "A"
            },
            {
                "rank": 11,
                "name": "VX",
                "team": "V"
            },
            {
                "rank": 12,
                "name": "Moin",
                "team": "M"
            }
        ]
    }

リザルトを表示する

最後に文字起こしした内容を表示していきます。
チーム名のつけ忘れなどのヒューマンエラー、文字起こしの結果にもムラがあるので、フロント側からリザルトを修正できるようにしました。

import { Input } from "@/components/ui/input";
import type { TeamScore } from "@/types";
import { AnimatePresence, Reorder } from "framer-motion";
import { Diff } from "lucide-react";
import { type Dispatch, type SetStateAction, useEffect, useRef } from "react";
import { useFieldArray, useForm } from "react-hook-form";

type FormProps = {
  teamScoreList: TeamScore[];
  setTeamScoreList: Dispatch<SetStateAction<TeamScore[]>>;
};

type FormValues = {
  results: TeamScore[];
};

export const Form = ({ teamScoreList, setTeamScoreList }: FormProps) => {
  const { register, handleSubmit, control, reset, setValue } =
    useForm<FormValues>({
      defaultValues: {
        results: teamScoreList.sort((a, b) => b.score - a.score),
      },
    });

  const { fields } = useFieldArray({
    control,
    name: "results",
  });

  // 外部のteamScoreList更新に対する反映フラグ
  const isUpdatingFromTeamScoreList = useRef(false);

  // teamScoreListが変更された時にフォームをリセットして更新
  useEffect(() => {
    if (!isUpdatingFromTeamScoreList.current) {
      reset({
        results: teamScoreList.sort((a, b) => b.score - a.score),
      });
    }
    isUpdatingFromTeamScoreList.current = false;
  }, [teamScoreList, reset]);

  const onSubmit = (data: FormValues) => {
    console.info("フォーム送信:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="pr-20">
      <div className="w-fit ml-auto flex flex-col gap-2">
        <AnimatePresence>
          <Reorder.Group
            axis="y"
            values={fields}
            onReorder={(newOrder) => setValue("results", newOrder)}
          >
            {fields.map((item, index) => (
              <Reorder.Item key={item.id} value={item.id} drag={false}>
                <div
                  key={item.id}
                  className="h-16 flex items-center gap-2 text-white relative"
                >
                  <Input
                    {...register(`results.${index}.team`, {
                      onChange: (e) => {
                        // labelが空ならフィールドを削除
                        if (e.target.value === "") {
                          setTeamScoreList((prev) =>
                            prev.filter(
                              (teamItem) => teamItem.team !== item.team,
                            ),
                          );
                        }
                      },
                    })}
                    defaultValue={item.team}
                    className="w-24 h-full text-2xl rounded-l-lg text-center bg-primary [appearance:textfield] [&::-webkit-outer-spin-button] [&::-webkit-inner-spin-button]"
                  />
                  <Input
                    {...register(`results.${index}.score`, {
                      valueAsNumber: true, // 入力を数値として扱う
                      // scoreが変更された時にteamScoreListを更新
                      onBlur: (e) => {
                        setTeamScoreList((prev) =>
                          prev.map((teamItem) =>
                            teamItem.team === fields[index].team
                              ? { ...teamItem, score: e.target.valueAsNumber }
                              : teamItem,
                          ),
                        );
                      },
                    })}
                    defaultValue={item.score}
                    type="number"
                    className="w-24 h-full text-2xl rounded-r-lg text-center bg-primary [appearance:textfield] [&::-webkit-outer-spin-button] [&::-webkit-inner-spin-button]"
                  />
                  {fields.length !== index + 1 && (
                    <p className="flex items-center min-w-12 text-white drop-shadow-[0_1.2px_1.2px_rgba(0,0,0,1)] text-2xl text-center font-bold self-end absolute -right-16 -bottom-5">
                      <Diff />
                      {fields[index].score - fields[index + 1].score}
                    </p>
                  )}
                </div>
              </Reorder.Item>
            ))}
          </Reorder.Group>
        </AnimatePresence>
      </div>
    </form>
  );
};

このような画面が表示されるはずです!
framer-motionを使って順位が入れ替わった時にアニメーションを導入しようとした形跡が見て取れますが、多分できなくて途中で諦めてますね 🫠

おわりに

本記事では、OBS WebSocketChatGPTを活用することで、ゲーム画面を取得して文字起こしを行う機能を紹介しました。こういったことを簡単に実現できるようになったのも、AIの進歩があってこそですよね!

後編ではこの機能の完全自動化を目指して、テンプレートマッチングを導入した事例を紹介いたします。
是非ご覧ください!

参考文献

https://qiita.com/kurata04/items/a10bdc44cc0d1e62dad3

Hackz Inc.

Discussion