1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code のフックで RPG の町を動かす Yushario 開発日記

1
Last updated at Posted at 2026-10-22

はじめに

AI コーディングエージェントを勇者にして、ドット絵の町で眺めるアプリ Yushario を作りました。使い方は紹介記事に書いたので、こちらは作った側の記録です。

リポジトリの最初のコミットは 2026-10-09 で、この記事を書いている時点で約140コミット、約2週間です。

og-1280x640.png

この記事で書くことは次の通りです。

  • 方針が変わった経緯(Live2D → ドットの部屋 → RPG の町)
  • 「エージェントを作らない」という設計の線引き
  • フックのイベントを町の動きに変える core の実装
  • ドット絵をどう作って揃えたか
  • 公開前にやったこと

構成の全体像

yushario-architecture.png

Claude Code・Codex・pi は、いつものターミナルのまま動かします。町は、そこから届く知らせだけで描いています。

図の流れを、順に説明します。

  • CLI エージェント: Claude Code・Codex・pi を、そのまま node-pty の PTY で動かします。入出力は server が中継します
  • フック / 拡張: 各 CLI のフックや拡張が POST /event で状態を知らせます。server が検証して、履歴に保存します
  • server: Node 標準の http と ws だけで書いた1プロセスです。127.0.0.1 で待ち受けます
  • ブラウザ: server からの WebSocket(/ws がイベント、/pty が端末)で、町と埋め込みターミナルを更新します。web は Vite でフレームワーク無し、町は Canvas、端末は xterm.js です
  • core: 活動の分類や、戦う相手の決定は、DOM にも Node にも依存しない純粋関数です。第3章で中身を見ます

1. 方針はどう変わったか

最初から RPG の町を作ったわけではありません。docs/OBSERVATION.md に当時の選択肢が残っています。

案 内容 結果
A. Live2D を続ける 1体に集中して深める 途中で削除
B. ドットの部屋 複数エージェントをドットキャラで1部屋に表示 採用
C. 両方 全体はドット、1体だけ Live2D 廃止

最初は Live2D のキャラ1体を動かす構成で、表情を決める Decider という部品もありました。それをやめて、複数のエージェントを1つの部屋に並べるドットの表示に切り替えています。その部屋が、さらに「作業は戦闘、休憩は宿屋」という RPG の町になりました。リポジトリの docs/PLATFORM.md には、Live2D を削除した段階の記録が残っています。

いま私が開発をするとき、複数のエージェントに並行して作業させるのは欠かせません。その様子を Live2D の1体で表現するのが難しく、複数のエージェントが同時にいる場面を見せられる形を探しました。

2. 設計の線引き:エージェントを作らない

Yushario は、エージェント本体(ハーネス)ではありません。docs/ARCHITECTURE.md に、機能を足す時のルールを6つ書いています。

  1. 自分で LLM を呼ばない
  2. ツールの実行や許可を、エージェントの代わりに行わない
  3. エージェントに送る文は、人が書いたか、人が選んだ定型文だけ
  4. 仕事の分け方は、人か、人が指揮役に任せた勇者が決める
  5. CLI とは公式の差し込み口(フック・拡張・起動引数)だけでつなぐ
  6. 機能を特定の CLI に縛らない

これを先に決めたのは、町の見た目が賑やかになるほど「もっと便利にしたい」という誘惑が出るからです。許可要求を自動で通せば確かに楽ですが、それはもう別のアプリだと割り切りました。

許可要求は、人の判断をフックへ返すだけにしています。画面での回答の待ち時間は120秒で、過ぎたらサーバーが「ターミナルで答える」を返します。自動で許可はしません。

3. フックを町の動きに変える

Claude Code のフックは、PreToolUse などのイベント名と、ツール名・入力を JSON で渡してくれます。これを共通の AgentEvent に直し、そこから「いま何をしているか」を求めます。

// src/core/activity.ts(抜粋)
export type Activity =
  | "think" | "read" | "search" | "edit" | "test" | "git" | "run" | "web"
  | "plan" | "delegate" | "ask" | "tool" | "rest" | "idle";

const TOOLS: Record<string, Activity> = {
  Read: "read", Grep: "search", Glob: "search",
  Edit: "edit", MultiEdit: "edit", Write: "edit",
  WebSearch: "web", WebFetch: "web",
  Agent: "delegate", Task: "delegate",
  AskUserQuestion: "ask",
};

export function toolActivity(tool: string, target?: string): Activity {
  if (tool === "Bash") return commandActivity(target);
  return TOOLS[tool] ?? "tool";
}

Bash だけは、コマンドの先頭を見て分類します。git や gh は「git」、vitest や tsc などは「テスト」、それ以外は「run」です。

const TEST_COMMAND = /^(?:(?:npm|pnpm|yarn|bun) (?:test|t|run (?:test|typecheck|lint|check)\S*)|npx (?:vitest|jest|tsc|eslint)|vitest|jest|pytest|tsc|eslint|go test|cargo test)(?: |$)/;

export function commandActivity(head: string | undefined): Activity {
  if (!head) return "run";
  if (/^(?:git|gh)(?: |$)/.test(head)) return "git";
  return TEST_COMMAND.test(head) ? "test" : "run";
}

活動が決まると、あとは対応表です。編集はスライム、コマンド実行はゴースト、テストはゴーレム、委任はドラゴンと戦います。

// src/core/story.ts(抜粋)
const BATTLES: Partial<Record<Activity, MonsterKind>> = {
  edit: "slime", run: "ghost", test: "golem", delegate: "dragon",
};

この辺りは全部、DOM にも Node API にも依存しない純粋関数にして src/core に置き、vitest でテストしています。描画や WebSocket をモックだらけにして試すのではなく、「このイベント列ならこの活動になる」を関数で確かめる形です。

「分からないことは推測しない」

Codex のフックには、道具の終了コードに当たる公式の定義がありません。PostToolUse の tool_response は、生の標準出力がそのまま入ることもある任意の JSON です。

そこで Yushario は、これをパースして成功・失敗を推測するのをやめました。

// src/core/codex.ts(抜粋)
// tool_response is arbitrary JSON (often raw stdout), NOT a documented exit status.
// Never parse its text, forward output, or send a completed command as check evidence.
if (name === "PostToolUse") event.detail = "Tool finished; result unknown";

テストが通ったかを推測して「合格」と表示すると、それは嘘になります。Codex の勇者では合格条件の自動証拠と MP が取れず、画面にも「未実行のまま」と出します。分からない部分を分からないと言える方が、後から困りません。

4. ドット絵の作り方

絵とロゴは OpenAI の画像生成(ChatGPT・Codex)で作りました。生成した絵は、次の流れで整えています。

  1. tools/art/components.json にコンポーネント(大きさ・コマ・指示文)を一覧で書く
  2. Codex に1コンポーネント1枚ずつ生成させる
  3. unfake でドットの大きさとグリッドを検出し、縮小とジャギー掃除をする
  4. 色を全部、共通のパレット Resurrect 64 に合わせる
  5. 緑の背景を透明にして、コマを同じ大きさのセルに並べ直す(足元をそろえる)

効いたのは次の2点です。

  • パレットを1つに固定する: 生成された絵は色がバラバラですが、全コンポーネントを同じ 64 色に寄せると、部品を並べても色がなじみます
  • 勇者の設定画を先に作る: hero-model を先に作り、各ポーズや仲間はそれを参照画像にして生成します。見た目のぶれが減りました

bg-battle-forest.png

戦闘背景の例(森)。

絵は組み合わせ用の単品部品なので、建物や小物の位置は JSON のデータで持っています。絵を差し替えてもコードは変わりません。ただしプラグインのようなコードを読み込む仕組みは、わざと作っていません。

勇者のデザイン遍歴

勇者のデザインは、一度作り直しています。最初の勇者が公開前の権利の監査で引っかかり、そこから描き直しが始まりました。

hero-history.png

描き直しの途中で並べた10案(正面・背中・横)。赤いマントに青い服、黒髪に赤い帯、銀髪、金髪のツンツン頭など、髪型と配色を変えて比べました。

最後の No.10(金髪のツンツン頭、青緑のマント)に決めました。決め手は、ほかのキャラクターと並べたときに違和感がないことと、ぱっと見たときのかっこよさです。

5. 作りすぎない

このリポジトリには AGENTS.md があり、実装の範囲を絞るルールを書いています。

  • 指示された範囲だけを実装する。「将来のため」の拡張点を足さない
  • ファクトリ、DI コンテナ、プラグイン機構、イベントバスを作らない
  • 状態管理ライブラリや UI フレームワーク(React など)を入れない
  • テストは src/core の振る舞いに絞る

依存も最小限で、実行時の依存は ws・node-pty・xterm.js(と fit アドオン)・フォント1つだけです。クエストの保存は Node 22.13 から組み込みの node:sqlite を使いました。

実装の範囲はこのルールで絞りましたが、勇者の動きの動線、細かい UI、足りない機能、大まかな動作フローは、実際に動かしながら自分でかなり見直しました。

機能を広げる時に参考になったのが、Orca でした。Yushario は、エージェントに仕事を任せ、確かめ、成果を採るための作業の場(ADE)としての機能を広げたい一方で、ドット絵 RPG の世界観も崩したくありません。この2つが両立するように、機能を RPG の言葉に置き換えました。

  • エージェントチームを作る機能 → パーティー編成。町にいる勇者を仲間に足し、書きかえる担当と作戦を決める、ドラクエ風の編成窓にしました
  • タスク管理 → クエスト。書く、勇者に任せる、指示を送る、報告と変更を見る、採用する、という流れの札にしました

6. 公開前にやったこと

  • 作業報告・レビュー・没案は、非公開の別リポジトリに分けて残しました(本体の履歴は squash していません)
  • 公開前に、文言や権利まわりを監査して直しました。たとえば README が動機を「効率化」寄りに書いていたので、「眺めて楽しい」に直しています
  • 絵とロゴは ChatGPT で作ったものとして、権利を主張しない旨を書きました。パレットとフォントのクレジットは NOTICE にあります
  • npm に公開し、npx yushario@latest で動くようにしました。CI でタグと GitHub Release も作ります
  • 紹介用の PV を作り、BGM はベートーヴェンの旋律(パブリックドメイン)を使った自作の 8bit 曲にしました

7. 紹介サイトを作る

アプリとは別に、yushario-website という紹介サイトのリポジトリを作りました(https://yushario.morimizu.dev/)。

  • 構成: Vite で静的ビルドし、Cloudflare Workers Static Assets で配信しています。アプリ本体のサーバーや CLI は要りません
  • 体験版: アプリの UI と描画処理をコピーして、API と WebSocket の応答をブラウザ内の架空データに差し替えました。CLI 起動、実コマンド、Git 操作は一切しません
  • 検査: npm run check が、ビルドのあとにリンク先、画像、ライセンスの同梱を検査します。デプロイもこのコマンドを通ります
  • メニュー: ホーム・冒険の様子・体験版・はじめ方・ヘルプなどの切り替えは、ARIA の tablist に対応し、URL のハッシュで直接リンクできます

世界観には時間をかけました。

  • ずかん: モンスター4種、仲間4職業、町の人3人のカードを作り、横・後ろ・攻撃・被弾のモーションを見られるようにしました。文章は読み物ですが、「何をするとそのモンスターが出るか」は、アプリの実際の動作(第3章の分類)に基づいて書いています
  • ギャラリー: 町5種、戦場3種、戦闘3種、屋内4種、部品を、アプリで実際に使っている絵のまま並べました。解像度も載せています
  • ガイド: 出陣から採用までを、実際の画面を撮った画像に番号を重ねて説明しています。撮影は、空の撮影専用環境で行い、名前やコードは架空です

サイトで一番こだわったのは世界観です。アプリの機能とは全く関係のない設定を、キャラクターやモンスターに足していきました。たとえばゴーレムは「バグに親を殺された魔法使いが作った、しれんの番人」で、図書館の司書は「しずかに」が口ぐせです。愛着が湧いて、作業がすごく楽しくなりました。

おわりに

「エージェントを作らず、公式の差し込み口だけで外から眺める」という線引きは、結果的に実装を小さく保ってくれました。LLM を呼ばないので、動かすのに追加の課金も要りません。

次にやりたいこと:

  • スマホアプリから、クエストの発行や鑑定ができる仕組みを作る予定です

紹介サイトとリポジトリはこちらです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?