ターン制戦闘をフィールドに統合する 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

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