Reducer パターンだけでゲーム状態を管理する:副作用ゼロのアーキテクチャ

Reducer パターンだけでゲーム状態を管理する:副作用ゼロのアーキテクチャ 概要 現代のフロントエンド開発において、Redux や React の useReducer を通じて「Reducer パターン」は広く浸透しました。しかし、このパターンを本格的なゲーム開発、特に複雑なロジックが絡み合う RPG やシミュレーションゲームに適用しようとすると、多くの開発者が「副作用」の壁にぶつかります。 「ダメージ計算に乱数を使いたい」「ゲーム内の経過時間を管理したい」「マスターデータを参照したい」……。 これらの要素を素直に実装すると、Reducer の外側にある状態や関数に依存してしまい、純粋関数としての美しさとテストのしやすさが失われてしまいます。 本記事では、すべての副作用を引数として注入し、gameReducer(state, action, masters, random) という純粋関数のみでゲームの全ロジックを完結させる「副作用ゼロ」のアーキテクチャについて解説します。 課題:なぜゲーム開発で Reducer は敬遠されるのか 一般的な Web アプリケーションの Reducer は単純です。「ボタンを押したらフラグを反転させる」「入力された文字列を状態に保存する」といった操作がメインだからです。 しかし、ゲームでは以下のような「非決定的な要素」や「巨大な静的データ」が頻繁に登場します。 1. 乱数 (Randomness) の扱い クリティカルヒットの判定、モンスターのドロップアイテム、マップの自動生成など、ゲームは乱数の塊です。Reducer の中で Math.random() を呼んだ瞬間、その関数は「同じ入力に対して同じ出力を返す」という純粋性を失います。 2. 静的データ (Master Data) の参照 モンスターのステータス表、アイテムの定義、スキル効果など、ゲームには膨大な「変わらないデータ」があります。これを Reducer の外にあるグローバル変数から参照すると、テスト時にそのグローバル変数の状態も気にしなければならなくなります。 3. 時刻 (Time) と経過の管理 「3時間経過したらスタミナが回復する」「夜になるとモンスターが強くなる」といった時間依存の処理です。new Date() を Reducer で使うのは、乱数と同様に純粋性を破壊します。 4. 非同期処理 (Async/Fetch) サーバーから最新のイベント情報を取得したり、セーブデータをロードしたりする処理です。Reducer は同期的に実行される必要があるため、非同期処理をそのまま中に書くことはできません。 OOP(オブジェクト指向)との比較 クラスベースの OOP では、player.attack(monster) のようにメソッドを呼び出します。これは直感的ですが、内部で this.hp -= damage のように状態を直接書き換えます(ミュータブル)。 小規模なら良いですが、プロジェクトが大きくなり「攻撃時にスキルが発動し、その効果で回復し、さらにログに記録し……」と連鎖が始まると、どこで何が起きたかを追跡するのが不可能になります。 設計:副作用を「引数」に封じ込める 副作用を排除するための解決策はシンプルです。「必要なものはすべて外から渡す」、つまり依存性の注入 (DI) です。 ...

March 28, 2026 · 4 min

TypeScript npm workspaces でゲームロジックを UI から完全分離する

TypeScript npm workspaces でゲームロジックを UI から完全分離する 概要 モダンなフロントエンド開発、特にゲーム開発において、「ロジック」と「表示(UI)」の分離は永遠の課題です。React や Vue などのフレームワークにロジックが密結合してしまうと、テストが困難になり、将来的に別のプラットフォーム(例えば Web から React Native や CLI ツールへ)に展開する際の大きな障害となります。 本記事では、TypeScript npm workspaces を活用して、ゲームロジックを独立したパッケージ (packages/core) として切り出し、React UI (apps/client) から完全に分離する設計手法を解説します。また、外部 I/O や非決定的な処理(乱数など)を抽象化する Port / Adapter パターンについても触れます。 課題:なぜロジックが UI に染み出すのか? 多くのプロジェクトでは、気づかないうちにロジックが React コンポーネントや Hooks の中に漏れ出していきます。 // 密結合な例 const PlayerStats = () => { const [hp, setHp] = useState(100); const handleAttack = () => { // UI の中で計算ロジックが動いている const damage = Math.floor(Math.random() * 10) + 5; setHp(prev => Math.max(0, prev - damage)); }; return <button onClick={handleAttack}>攻撃を受ける</button>; }; このような設計には以下の課題があります: テストの困難さ: Math.random() が直接使われているため、結果が不安定でユニットテストが書きにくい。 再利用性の欠如: この「ダメージ計算ロジック」を、サーバーサイドや別の UI フレームワークで使い回すことができない。 依存の混入: ロジックを動かすために React の実行環境(レンダリングサイクル)が必要になる。 設計:npm workspaces による物理的隔離 ロジックを「物理的に」隔離するために、以下の monorepo 構成を採用します。 ...

March 28, 2026 · 3 min

TypeScript Union 型でスキルのレンジ種別とダメージ計算を型安全に実装する

TypeScript Union 型でスキルのレンジ種別とダメージ計算を型安全に実装する ゲーム開発、特に RPG やタクティカルゲームにおいて、スキルの「射程(レンジ)」や「効果範囲(AOE: Area of Effect)」の実装は非常に複雑になりがちです。単体攻撃、直線、周囲、円形、さらには自分自身を対象とするものまで、そのバリエーションは多岐にわたります。 これらを string 型の ID や、大量の if 文、あるいは複雑なクラス継承で管理しようとすると、いつか必ず「新しいレンジを追加したのに、判定処理を書き忘れた」あるいは「このスキルタイプには不要なはずのパラメータが混入している」といったバグに直面します。 TypeScript の Union 型(特に Discriminated Union / 判別可能な共用体) を活用すれば、こうしたロジックをコンパイルレベルで安全に保護し、設計意図をコードに焼き付けることができます。本記事では、架空のゲームを題材に、any や as を一切使わずに、メンテナンス性の高いスキルシステムを構築する手法を徹底解説します。 1. Union 型でスキル種別を表現する まず、スキルの「レンジ(範囲)」を型として定義します。ここでのポイントは、単に名前を列挙するのではなく、「そのレンジを計算するために最低限必要なデータ」をセットにすることです。 なぜこの設計にするのか(データ構造の純粋性) オブジェクト指向的な発想では、BaseRange クラスを継承して AreaRange クラスを作る、といった方法が取られがちです。しかし、ゲームデータ(特に JSON などでシリアライズされるデータ)を扱う場合、純粋なオブジェクト(POJO)として扱える方がシリアリアライズの相性が良く、ロジックを分離(疎結合)しやすくなります。 // 1. 各レンジの個別定義 type MeleeRange = { type: 'melee' }; // 隣接マス(1マス) type RangedRange = { type: 'ranged'; distance: number }; // 遠距離(単体指定)。最大射程が必要。 type LineRange = { type: 'line'; length: number; width?: number; // オプションで太さを持たせることも可能 }; // 直線。貫通距離が必要。 type AreaRange = { type: 'area'; radius: number; excludeCenter?: boolean }; // 円形または四角形範囲。半径が必要。 type SurroundRange = { type: 'surround' }; // 自分の周囲8マス。パラメータ不要。 type SelfRange = { type: 'self' }; // 自分自身。パラメータ不要。 // 2. これらを統合した Union 型(Discriminated Union) export type SkillRange = | MeleeRange | RangedRange | LineRange | AreaRange | SurroundRange | SelfRange; // 3. スキルの全体定義 export interface Skill { id: string; name: string; description: string; range: SkillRange; // 型安全なレンジ定義 baseDamage: number; scalingStat: 'STR' | 'INT' | 'DEX'; // どのステータスに依存するか manaCost: number; } このように type という「タグ(判別子)」を持たせることで、TypeScript は「type が 'area' なら radius プロパティが確実に存在する」と認識します。逆に、'melee' の時には radius にアクセスしようとするとコンパイルエラーになります。これにより、不必要なデータへの依存を完全に排除できます。 ...

March 28, 2026 · 4 min

ターン制戦闘をフィールドに統合する vs 専用画面に分ける:設計比較と判断基準

ターン制戦闘をフィールドに統合する vs 専用画面に分ける:設計比較と判断基準 ターン制RPGを開発する際、エンジニアが最初に直面する大きな設計判断の一つが「戦闘をどこで行うか」です。具体的には、不思議のダンジョンのように**フィールド上でそのまま戦う(フィールド統合型)のか、ドラゴンクエストのように専用の戦闘画面に遷移する(専用画面型)**のか、という選択です。 この選択は単なるビジュアルの違いに留まらず、状態管理(State Management)、当たり判定、AIの実装、そしてスケーラビリティに決定的な影響を与えます。本稿では、TypeScriptを用いた架空のゲームの実装例を交えながら、両アプローチの設計思想と判断基準を深く掘り下げます。 1. 2つのアプローチの比較 まずは、それぞれの特性をトレードオフの観点から整理します。 項目 フィールド統合型 (Seamless) 専用画面型 (Isolated) UXの印象 テンポが良い、空間の繋がりを感じる 演出が豪華、戦略に集中できる 状態管理 非常に複雑(フィールド+戦闘の混合) 比較的単純(画面ごとにStateを全入れ替え) 位置情報の意味 極めて重要(射程、視線、逃走経路) 抽象的(前衛・後衛、ターゲット選択) AIの実装コスト 高い(地形考慮、パス検索が必要) 低い(コマンド選択アルゴリズムに集中) 拡張性 難しい(新しいギミックが戦闘に影響する) 容易(戦闘専用の特殊ルールを作りやすい) 2. フィールド統合型の実装:空間と時間の同期 フィールド統合型(ローグライク方式)では、「移動」と「攻撃」が同じタイムライン上で扱われます。 なぜその設計にするか プレイヤーが「一歩動く」ことと「剣を振る」ことが同等のコスト(1ターン)を持つため、戦略が空間的になります。壁を背にする、通路に誘い込むといった地形利用が自然にゲームプレイに組み込まれるのが最大のメリットです。 アクション設計の例 TypeScriptでのアクション定義は以下のようになります。 type FieldAction = | { type: 'FIELD_PLAYER_MOVE'; direction: Vector2 } | { type: 'FIELD_PLAYER_ATTACK'; targetId: string } | { type: 'FIELD_MONSTER_TURN_START' } | { type: 'FIELD_DAMAGE_ENTITY'; entityId: string; amount: number } | { type: 'FIELD_KILL_MONSTER'; monsterId: string }; interface FieldState { player: Player; monsters: Record<string, Monster>; tiles: TileMap; turnOwner: 'PLAYER' | 'MONSTER'; animations: AnimationQueue; } 実装のポイント この形式では、Reducer が非常に巨大になりがちです。なぜなら、「移動した結果、トラップを踏み、そのダメージでHPが0になり、死亡処理が走る」という一連の連鎖(Side Effects)を、同一のグリッド座標系で計算しなければならないからです。 const fieldReducer = (state: FieldState, action: FieldAction): FieldState => { switch (action.type) { case 'FIELD_PLAYER_ATTACK': const monster = state.monsters[action.targetId]; if (!monster) return state; // 距離計算が必須 const dist = calculateDistance(state.player.pos, monster.pos); if (dist > state.player.range) return state; return { ...state, // 戦闘結果を直接フィールドの状態に反映 monsters: { ...state.monsters, [action.targetId]: { ...monster, hp: monster.hp - state.player.atk } }, animations: [...state.animations, { type: 'SLASH', pos: monster.pos }] }; // ... } }; 3. 専用画面型の実装:コンテキストの分離 専用画面型(エンカウント方式)では、戦闘が開始された瞬間にフィールドのコンテキストがシリアライズされ、独立した「戦闘エンジン」に制御が移ります。 ...

March 28, 2026 · 2 min

乱数を interface で抽象化してゲームロジックをテスタブルにする

乱数を interface で抽象化してゲームロジックをテスタブルにする 概要 ゲーム開発において、「運」の要素は面白さを生む不可欠なスパイスです。クリティカルヒット、レアアイテムのドロップ、ダンジョンの自動生成など、多くの場面で乱数が使われます。 しかし、プログラミングの文脈において、乱数は「不確実性」そのものであり、ユニットテストの天敵です。本記事では、TypeScript を用いて乱数をインターフェースで抽象化し、テスト時に決定論的な(結果が予測可能な)挙動をさせる設計パターンについて解説します。 課題:Math.random() がもたらす「テスト不能」という病 もっとも素浦に実装すると、ゲームロジックの中で直接 Math.random() を呼び出すことになります。 // 直接 Math.random() を使う例 export class CombatService { calculateDamage(baseDamage: number, critRate: number): number { // 運が悪ければテストが落ちる if (Math.random() < critRate) { return baseDamage * 2; } return baseDamage; } } このコードをテストしようとすると、以下の問題に直面します。 非決定的 (Non-deterministic) なテスト: 同じ入力に対して、実行するたびに結果が変わる可能性があります。 境界値のテストが困難: 「クリティカル率 5% のとき、0.049 を引いたらクリティカル、0.051 を引いたら通常攻撃」という境界値の検証が運任せになります。 モックの乱立: vi.spyOn(Math, 'random') などでグローバルなオブジェクトを書き換える手法もありますが、並列テストで干渉したり、クリーンアップを忘れると他のテストに影響を与えたりと、脆いテストになりがちです。 設計:Port / Adapter パターンによる抽象化 この問題を解決するために、Dependency Inversion Principle (依存性逆転の原則) を適用します。 ロジックが「具体的な乱数生成器」に依存するのではなく、抽象的な「乱数提供インターフェース」に依存するように設計を変更します。 1. Port (Interface) の定義 ロジックが必要とする「乱数を得るための窓口」を定義します。 2. Adapter (Implementation) の実装 Production Adapter: 本番環境では Math.random() を使う。 Test Adapter: テスト環境では、事前に定義した値を返したり、シード値に基づいた再現性のある乱数を返す。 この設計にすることで、ロジック側は「誰がどうやって乱数を作っているか」を気にせず、「乱数がもらえること」だけを約束された状態になります。 ...

March 28, 2026 · 3 min

EmacsでGoのLsp補完が死んだときの調査記録

症状 Emacsで .go ファイルを開くと以下のログが無限ループする。 [eglot] Asking EGLOT (mydns/(go-ts-mode go-mod-ts-mode)) politely to terminate [jsonrpc] Server exited with status 2 [eglot] Reconnected! [eglot] Connected! Server 'gopls' now managing '(go-ts-mode go-mod-ts-mode)' buffers in project 'mydns'. [jsonrpc] (warning) Sentinel for EGLOT (...) still hasn't run, deleting it! [jsonrpc] Server exited with status 9 [eglot] Reconnected! [2 times] Error running timer: (error "Selecting deleted buffer") status 9 は SIGKILL。gopls が起動→即死→reconnect を繰り返し、補完が一切効かない状態。 fmt. と打っても候補が出ない、もしくは関係のないゴミ候補が出る。手動で M-x completion-at-point を叩いても No match。 ...

March 21, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その6

はじめに Part5でUIまで実装が終わった。機能として動くものはできた。テストも15件、全パス。 ここで一度立ち止まって、なぜこの設計になったのかを振り返る。 正直に言う。最初の要件はこうだった。 「クリーンアーキテクチャっぽく、テスタブルにしたい」 それだけだった。クリーンアーキテクチャを理解して設計したわけじゃない。 結果的にクリーンアーキテクチャを遂行したのは生成AIだ。 そしてPart3以降、自分はほとんど手を動かさなくなった。 設計ドキュメントをAIに渡したら、実装サイクルがほぼ自動で回るようになったからだ。 途中でこう思った。 「俺いる必要なくね?」 この記事はその問いに向き合いながら、設計を改めて言語化したものだ。 このシリーズ自体がTDDだった 書き終えてから気づいたことがある。 このシリーズのサイクルはこうだった。 AIに実装させる ↓ 動かす・読む・会話する(認識のズレを検出) ↓ ズレを言語化してAIにフィードバック ↓ 納得したら記事にする ソフトウェアのTDDは「Red → Green → Refactor」だけど、 自分がやっていたのはこれだ。 Red → 認識がズレていると感じる Green → 会話して納得する Refactor → 記事として言語化する 人間がテストケースになっていた。 TDDが「仕様をテストで表現する」なら、自分がやっていたのは「理解をフィードバックループで検証する」だ。構造は同じだと思う。 誤っていた認識たち 振り返ると、理解がズレていた箇所がいくつかあった。 「PolicyはVOと1-1になる」と思っていた 最初、ReadingStatusPolicyがReadingStatusに対応しているのを見て、PolicyはVOと対になるものだと思っていた。 違う。今回たまたま1-1になっているだけだ。 Policyの本質は複数のEntityやVOをまたいだ条件判定だ。たとえば「同じ本に同じユーザーが2回レビューできない」というルールは、Book・User・Review[]をまたぐ。これをPolicyに出す。 VOに閉じるルールならVOに書けばいい。Entityをまたぐ判定が必要になったときにPolicyの出番だ。 「Domainは外部を知らない」という捉え方が逆だった 「DomainはDBを知らない」という言い方をよくするが、正確には逆だ。 外部がDomainを知っている。 向きの問題だ。Domainが何かを避けているのではなく、依存の矢印がすべてDomainに向かって刺さっている。 Route Handler ↓ Service ↓ Repository interface ↓ Domain(Entity / ValueObject / Policy) この向きがわかって初めて、Serviceの役割も見えた。 Serviceが何をするのかわかっていなかった 依存の向きが腑に落ちるまで、Serviceが何者なのかずっと曖昧だった。 答えはシンプルだった。フローだけ持つ接着剤。 async startReading(id: string): Promise<Book> { const book = await this.repo.findById(id); // 取得 if (!book) throw new NotFoundError(...); book.changeStatus(ReadingStatus.Reading); // Entityのルールに従う await this.repo.save(book); // 保存 return book; } ServiceはDomainのルールを自分で判定しない。 canTransitionを自分で呼ばない。book.changeStatus()に委ねるだけだ。 判定はEntity・VO・Policyが持っている。Serviceはその結果を使ってフローを組む。 ...

March 5, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その1

なぜこの記事を書いたか 動くものをまず作って、そこにテストを後付けしようとした。しかし、できなかった。 具体的にはこういう壁にぶつかった。 ModelなどのDB接続の切り替えがうまくいかない Controllerをテストしようにもいろいろおかしくなる 悩んだ結果、気づいたことがある。そもそもそんなものは単体テストじゃない。 ロジックをControllerやModelに全部書いていたのが悪かった。 「じゃあ最初からそう書けばいいじゃないか」という話だが、それが難しい。雑魚プログラマにいきなりクリーンな設計はできない。 なので発想を逆にした。テスト前提でコードを書く。 テストが書けない場所にはロジックを書かない。それを体で覚えるために、今回のハンズオンを始めた。 方針 生成AIに実装ヒントと次のステップを教えてもらいながら、コードは自分で書く。 正直、コード自体はAIに書かせてもいいと思っている。ただ、設計の考え方は頭に入れる必要があるので、写経しながら理解を深めている。 テストする場所の原則はシンプルだ。 フレームワークで用意された便利クラスはテストしない DBやAPIなど外界と接する場所はテストしない 自分で書いた純粋なTS部分だけをテストする 作るもの 読書レビューサイト。Todoアプリはビジネスロジックがほぼ存在しないのでテストの練習に向いていない。読書レビューサイトなら本の状態遷移(積読→読中→読了)などのルールが自然に生まれるので、テストの旨味がある。 技術スタック Next.js SQLite + Prisma vitest ディレクトリ構成 src/ domain/ entity/ valueobject/ policy/ service/ repository/ この構成の考え方は以下の通り。 層 役割 entity 概念そのもの(Book, User) valueobject 値の制約を持つクラス(ISBN, Rating) policy ビジネスルールを切り出したクラス service 複数クラスをまたぐフロー repository DBとのアダプタ(副作用をここに閉じ込める) repositoryはCI4でいうModelに近い立ち位置だ。 ただし決定的な違いがある。CI4のModelはActiveRecordパターンでクラス自身がDBを知っているため切り離せないが、repositoryはInterfaceと実装を分けることで差し替え可能にする。これがDI(依存性の注入)の肝で、テスト時にMockに差し替えられる。 vitestのセットアップ npm install -D vitest @vitejs/plugin-react vite-tsconfig-paths npm install -D @testing-library/react @testing-library/jest-dom vitest.config.ts import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' import tsconfigPaths from 'vite-tsconfig-paths' export default defineConfig({ plugins: [react(), tsconfigPaths()], test: { environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], }, }) package.jsonのscriptsに追加。 ...

March 4, 2026 · 2 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その2

前回のおさらい その1では ValueObject・Policy・Entity を作った。ポイントは「フレームワークを一切使わないので、テストがただの関数呼び出しになる」という体験だった。 今回はいよいよ Service 層に入る。ここが設計の核心だ。 複数のクラスをまたぐフローを、DI(依存性の注入)を使って DB から切り離す。 今回追加したもの src/ domain/ entity/ User.ts # 追加 Review.ts # 追加 valueobject/ UserName.ts # 追加 ReviewComment.ts # 追加 repository/ BookRepository.ts # 追加(Interface) service/ BookShelfService.ts # 追加 User と Review を追加する まず Entity の準備。今回は単純なので ValueObject から作る。 UserName export class UserName { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("UserNameは空にできない"); } if (value.length > 50) { throw new Error("UserNameは50文字以内"); } } } trim() してから空チェックしているのがポイント。スペースだけのユーザー名を弾く。 ReviewComment export class ReviewComment { constructor(public readonly value: string) { if (!value || value.trim().length === 0) { throw new Error("ReviewCommentは空にできない"); } if (value.length > 1000) { throw new Error("ReviewCommentは1000文字以内"); } } } User Entity import { UserName } from "../valueobject/UserName"; export class User { constructor( public readonly id: string, public readonly name: UserName, ) {} } User 自体はシンプルだ。ロジックがない。ビジネスルールが増えれば changeXxx() メソッドが生える設計だが、今は id と name を持つだけでいい。 ...

March 4, 2026 · 4 min

テスト前提で設計したWebアプリのハンズオン - 読書管理アプリ その3

はじめに Part1 でValueObject・Policy・Entityを作り、Part2 でServiceをDI+Mockテストで固めた。 累計13テスト、全パスの状態だ。 Part3ではいよいよDBを繋ぐ。やることは3つ。 Prismaセットアップ+スキーマ定義 PrismaBookRepository 実装(ドメインオブジェクトへの変換) Route HandlerでDIを組み立てる そして最後に「テストを書かない層を意図的に決める」という話をする。 1. Prismaセットアップ npm install prisma @prisma/client npx prisma init --datasource-provider sqlite prisma/schema.prisma に Bookモデルを定義する。 generator client { provider = "prisma-client-js" } datasource db { provider = "sqlite" url = env("DATABASE_URL") } model Book { id String @id title String isbn String status String rating Int? } User と Review はまだDBに持たない。Book の CRUD が動けば Part3 のゴール。 なぜ status を String で持つのか Prismaは SQLiteで enum をネイティブサポートしていない。 そのため status String で持ち、取り出し時に as ReadingStatus でキャストする。 ...

March 4, 2026 · 4 min