第3回:Next.js × Phaser 3 で本格ブラウザゲームを作る

マーケットクエスト ゲーム画面

はじめに — プロトタイプからプロダクションへ

第1回ではゲームの構想とHTML5プロトタイプの試行錯誤を、第2回ではPythonデータパイプラインによるニュース・マクロ指標・株価シミュレーションの構築を振り返りました。

この第3回では、いよいよゲーム本体の設計と実装に入ります。

技術スタックの最終形は以下の通りです。

レイヤー技術役割
ゲームロジックTypeScript(game-core)フレームワーク非依存のゲームエンジン
ゲーム描画Phaser 3ボード、アニメーション、サイコロ
Web UINext.js 14(App Router)画面構成、認証、ランキング
スタイリングTailwind CSSUIコンポーネント
認証・DBSupabaseGoogle OAuth、スコア管理
デプロイVercel自動ビルド・CDN配信

game-core:フレームワーク非依存のゲームロジック

設計上の最も重要な決断は、ゲームのロジックをフレームワークから完全に分離することでした。

/game-core          ← 純粋TypeScript、UIへの依存なし
  ├─ types.ts       ← 型定義(40以上の型・インターフェース)
  ├─ engine.ts      ← ゲームエンジン(ターン管理、イベント処理)
  ├─ board.ts       ← ボード生成、マス配置
  ├─ market.ts      ← 株価計算、売買処理
  └─ events.ts      ← イベント処理(ニュース、チャンス、配当等)

なぜ分離するのか。理由は3つあります。

  1. テスタビリティ — ブラウザやUIフレームワークなしで、Node.jsだけでユニットテストが書ける
  2. 移植性 — 将来React Nativeやその他のプラットフォームに移植する際、ロジック部分はそのまま使える
  3. 関心の分離 — 「ゲームのルール」と「ゲームの見た目」を明確に分ける

game-coreは19個のテストすべてにパスしています。

types.ts — 型で仕様を語る

TypeScriptの型定義は、そのまま仕様書として機能します。例えば、マスの種類は CellType という union type で定義されています。

export type CellType =
  | 'START'       // スタート
  | 'GOAL'        // ゴール
  | 'NEWS'        // ニュースマス(歴史イベント)
  | 'DIVIDEND'    // 配当マス(インカムゲイン)
  | 'BANKRUPTCY'  // 倒産マス(特定銘柄が紙くずに)
  | 'IPO'         // IPOマス(ロック銘柄を解放)
  | 'BONUS'       // ボーナスマス(臨時収入)
  | 'TAX'         // 税金マス(支出)
  | 'CHANCE';     // チャンスマス(BOOM/CRASHイベント)

株式の型も同様に、ゲーム内の全銘柄を型安全に管理します。

export interface Stock {
  type: StockType;
  name: string;
  industry?: IndustryType;
  price: number;           // 現在価格
  basePrice: number;       // 基準価格
  volatility: number;      // 変動性(0-1)
  canBankrupt: boolean;    // 倒産可能か
  isLocked: boolean;       // ロック状態(IPOで解放)
  color: string;           // 表示色
  icon: string;            // 絵文字アイコン
}

isLocked フラグは、IPOマスに止まるまで購入できない銘柄を表現するための仕組みです。ピコピコ・ゲームズとキラキラ・ファッションの2銘柄がロック状態でスタートし、IPOマスで解放されると「ご祝儀相場」として+5%の上昇が発生します。

export function unlockStock(stocks: Stock[], stockType: StockType) {
  const unlockedStock: Stock = {
    ...stock,
    isLocked: false,
    price: Math.floor(stock.price * 1.05), // ご祝儀相場+5%
  };
  return { stocks: newStocks, unlockedStock };
}

engine.ts — 周回制すごろくエンジン

ゲームエンジンの設計で最も工夫した点は、周回制の採用です。

従来のすごろくは「スタートからゴールまで一本道」ですが、MarketQuestでは12マスの円形ボードを周回する方式を採用しました。1周するたびに1年が経過し、新しいマス構成のボードが生成されます。

export function processTurn(state: GameState, diceRoll: DiceValue) {
  // プレイヤーを移動(周回チェック付き)
  const { newState: movedState, looped } = movePlayer(state, diceRoll);

  let newYear = state.year;
  let newBoard = state.board;
  let yearChanged = false;

  // 周回した場合、年を進めてボードを再生成
  if (looped && state.year < state.config.endYear) {
    newYear = state.year + 1;
    newBoard = createBoardForYear(newYear);
    yearChanged = true;
    newPhase = getEraPhase(newYear);
  }

  // マスのイベントを取得・適用
  const event = getCurrentCellEvent(newState);
  // ...
}

この周回制により、プレイヤーは1980年代のバブル景気から2020年のコロナショックまで、日本経済の40年間をタイムスリップしながら体験できます。

ゲームの長さは3段階から選択可能です。

export const GAME_LENGTH_CONFIG = {
  short: {    // お試し:2011-2020(10年間)
    turns: 10, startYear: 2011, endYear: 2020,
  },
  medium: {   // 通常:2001-2020(20年間)
    turns: 20, startYear: 2001, endYear: 2020,
  },
  long: {     // フル:1980-2020(41年間)
    turns: 41, startYear: 1980, endYear: 2020,
  },
};

board.ts — 年代別マス構成

ボード生成で面白いのは、年代によってマスの出現率が変わることです。

const ERA_CONFIGS: EraConfig[] = [
  {
    eraKey: '1985-1989',    // バブル期
    phase: 'GOOD',
    distribution: {
      NEWS: 20, DIVIDEND: 20, BANKRUPTCY: 5,
      IPO: 15, BONUS: 20, TAX: 5, CHANCE: 15,
    },
  },
  {
    eraKey: '1990-1994',    // バブル崩壊後
    phase: 'BAD',
    distribution: {
      NEWS: 30, DIVIDEND: 10, BANKRUPTCY: 15,
      IPO: 5, BONUS: 5, TAX: 15, CHANCE: 20,
    },
  },
];

バブル期(1985-1989)は配当マスとボーナスマスが多く、IPOも活発。一方、バブル崩壊後(1990-1994)は倒産マスと税金マスの割合が増え、ニュースマスも多くなります。年代の空気感がマス構成に反映されるわけです。

さらに、各マスタイプには上限・下限が設けられ、ゲームバランスが崩れないように調整されています。

const CELL_LIMITS_BY_PHASE = {
  GOOD: {
    BANKRUPTCY: { min: 0, max: 0 },  // 好景気は倒産なし
    DIVIDEND: { min: 2, max: 3 },     // 配当が多い
    CHANCE: { min: 2, max: 4 },
  },
  BAD: {
    BANKRUPTCY: { min: 1, max: 2 },  // 不景気は倒産あり
    DIVIDEND: { min: 0, max: 1 },     // 配当が少ない
    CHANCE: { min: 2, max: 4 },
  },
};

market.ts — 株価と資産の計算

株式の売買処理は、平均取得価格の計算を含む本格的な実装です。

export function buyStock(
  player: PlayerState, stocks: Stock[],
  stockType: StockType, units: number
) {
  const stock = stocks.find(s => s.type === stockType);
  if (stock.isLocked) {
    return { success: false, message: 'この銘柄はまだIPOされていません' };
  }

  const totalCost = stock.price * units;
  if (player.cash < totalCost) {
    return { success: false, message: '現金が足りません' };
  }

  // 平均取得価格を計算
  const currentHolding = portfolio[stockType] || { units: 0, avgBuyPrice: 0 };
  const totalUnits = currentHolding.units + units;
  const totalCostBasis = currentHolding.units * currentHolding.avgBuyPrice + totalCost;
  const newAvgPrice = Math.floor(totalCostBasis / totalUnits);

  return { success: true, player: updatedPlayer };
}

ナンピン買い(追加購入)した場合の平均取得価格が正しく計算されるため、子供でも「安いときに買い増すと平均取得価格が下がる」ことを体感できます。

配当金受け取り — 株を持っていると良いことがある!

events.ts — チャンスマスの景気連動

チャンスマスでは、景気フェーズに応じてBOOM(急騰)とCRASH(急落)の確率が変動します。

export function getChanceEvent(economyPhase: EconomyPhase, stocks: Stock[]) {
  const eventType = determineChanceEventType(economyPhase);
  const chanceEvent = getRandomChanceEvent(eventType);
  return convertChanceEventToGameEvent(chanceEvent, stocks);
}

さらに、個別株のイベントが発生した際には、インデックス連動係数によってインデックスファンドにも波及します。

export const INDEX_LINK_COEFFICIENT = 0.15; // 個別株変動の15%がインデックスに影響
export const INDEX_DAMPENING_FACTOR = 0.6;  // インデックスは60%の変動に抑える

例えば、モグモグ・フーズが30%暴落した場合、ミンナノ・インデックスには 30% × 15% = 4.5% の下落が波及します。ただしインデックス自体に全体イベント(NEWSマス)が発生した場合は、60%に抑制されます。これは分散投資のリスク低減効果を数値で表現したものです。


Phaser 3:ゲーム描画レイヤー

Phaser 3で描画されたすごろくボード

game-coreが「ルール」を担当するのに対し、Phaser 3は「見た目」を担当します。

Phaser 3のBoardSceneでは、以下を実装しました。

  • 12マスの円形ボードの描画
  • サイコロ演出(振る → 出目表示 → コマ移動)
  • コマ移動アニメーション(Tweenによる滑らかな移動)
  • イベントカットシーン(年代変更時のカットイン演出)
  • ニュースキャスター表示(ニュースマスでのチュートリアル的な解説)
  • BGM/効果音(Web Audio APIによるインタラクティブサウンド)

Next.jsとPhaser 3の統合では、Phaserのゲームインスタンスを useEffect で生成し、Reactのstateとゲームのstateを双方向に同期する仕組みを構築しています。


Supabase:認証とランキング

ゲームの永続化レイヤーにはSupabaseを採用しました。

Google OAuth認証

[ゲーム起動] → [Google OAuth] → [プロフィール作成/取得]

                              [display_name設定]
                              (UNIQUE制約あり)

ワンクリックでGoogleアカウントによるログインが可能です。管理者(is_admin = true)にはデバッグパネルが表示され、ゲームバランスの調整に使えるようにしています。

また、アカウントなしでも遊べるデモユーザー機能も実装しました。子供がアカウント作成のハードルで遊べなくなるのを防ぐためです。

ランキングシステム

ゲーム終了時のスコア(最終資産額)がSupabaseの scores テーブルに保存され、ゲーム内でTOP10のランキングが表示されます。


PWA対応:スマホでも遊べる

MarketQuestはPWA(Progressive Web App)として実装されています。

  • manifest.json によるホーム画面追加対応
  • Service Workerによるオフラインキャッシュ
  • iOS/Android両対応

親子でスマホを使って一緒に遊ぶことを想定しているため、スマホ対応は最優先事項でした。横画面での最適化、タッチ操作の改善、モーダルのz-index調整など、スマホ特有の課題にも対応しています。


i18n:3つの言語モード

教育ゲームとしての特徴の一つが、3つの言語モードです。

モード対象特徴
日本語(ja)大人・中高生標準的な日本語表記
キッズモード(ja-kids)小学生ひらがな中心、漢字を最小限に
英語(en)海外ユーザー全UIを英語化

キッズモードでは、「株式」を「かぶしき」、「配当」を「はいとう」のように、漢字をひらがなに置き換えます。7歳の子供が一人で遊べることを目指した結果、このモードが生まれました。

テキストは辞書ファイル(locales/[locale]/)で管理し、game-coreからは TextProvider インターフェースを通じて取得します。

export interface TextProvider {
  getNewsText(id: string): { title: string; summary: string } | null;
  getBoomText(id: string): { title: string; story: string } | null;
  getCrashText(id: string): { title: string; story: string } | null;
  getStockName(type: StockType): string;
  getDividendText(): { title: string; description: string };
  // ...
}

game-core自体は言語に依存せず、TextProvider を差し替えるだけで任意の言語に対応できる設計です。


チュートリアル画面 — はじめてのプレイヤーをガイド

ゲームバランスの調整

ゲームバランスの調整は、開発中で最も時間をかけた作業の一つです。

評価システム

ゲーム終了時の最終資産で、5段階の称号が付与されます。

export const RANK_THRESHOLDS = {
  S: { min: 3000000 },  // 投資の天才
  A: { min: 2000000 },  // 凄腕トレーダー
  B: { min: 1500000 },  // 堅実な投資家
  C: { min: 1000000 },  // まだまだこれから
  D: { min: 0 },        // 勉強中
};

初期資金100万円に対して、Sランク(300万円以上)を達成するには3倍のリターンが必要です。分散投資と景気の読みがうまくいって初めて到達できる難易度に設定しています。

投資ヒントの自動生成

ゲーム終了時には、プレイヤーの行動に基づいた投資のヒントが自動生成されます。

function generateTips(state: GameState): string[] {
  const tipKeys: string[] = [];

  // 保有銘柄が1つだけ → 分散投資を推奨
  const holdingTypes = Object.keys(state.player.portfolio).length;
  if (holdingTypes <= 1) {
    tipKeys.push('diversification');
  } else if (holdingTypes >= 4) {
    tipKeys.push('diversificationGood');
  }

  // 現金比率が高すぎる → 投資を推奨
  const cashRatio = state.player.cash / totalAssets;
  if (cashRatio > 0.7) {
    tipKeys.push('highCashRatio');
  }

  // インデックスを持っていない → 推奨
  if (!state.player.portfolio['INDEX']) {
    tipKeys.push('recommendIndex');
  }

  return tipKeys;
}

「1銘柄しか持っていなかったから分散投資を勧める」「現金を持ちすぎているから投資を勧める」など、パーソナライズされたアドバイスが表示されます。これにより、ゲームの結果から次のプレイへの学びが生まれます。


デプロイとスマホ対応の苦労

最終的なデプロイ先はVercelです。Next.jsとの親和性が高く、GitHubリポジトリへのpushで自動デプロイされます。

スマホ対応では、以下の課題に対処しました。

  • FIT+CENTER_BOTHスケーリング時の座標変換:Phaserのスケーリングモードとブラウザのビューポートのずれにより、タップ座標がずれる問題。BoardSceneで座標変換ロジックを実装して解決
  • モーダルのz-index階層:売買パネル、資産パネル、チュートリアルオーバーレイなど複数のモーダルが重なる際の制御。z-index 100〜120の階層を整理
  • 横画面レイアウト:すべてのモーダルを fixed inset-0 に変更し、横画面でもコンテンツが欠けない設計に

MarketQuestで遊ぶ

MarketQuestは無料で公開中です。サイコロを振って、日本経済の歴史を旅しながら、資産をどこまで増やせるか挑戦してみてください。

MarketQuestをプレイ

まとめ — 7年間の集大成

2015年の最初のアイデアスケッチから数えると、このプロジェクトは約7年の歳月をかけて形になりました。

  • 2015-2017年:すごろく形式のルール設計、紙でのプロトタイプ
  • 2024年:HTML5プロトタイプの試行錯誤(Canvas → Scene管理 → Semantic UI)
  • 2024-2025年:Pythonデータパイプラインの構築、Next.js + Phaser 3での本格開発
  • 2025年12月:Vercelデプロイ、スマホ対応、i18n実装

game-coreのテストは19個すべてパス。120件以上の歴史的ニュースイベント、6種類の銘柄、年代別のマス構成、3段階の景気フェーズ、3つの言語モード。これらすべてが組み合わさって、「7歳から親子で学ぶ投資すごろく」が完成しました。

このプロジェクトで最も大切にしたのは、子供が「投資は怖いもの」ではなく「仕組みを理解すれば活用できるもの」と感じられることです。技術的にどれほど精緻なシステムを作っても、子供が楽しめなければ意味がない。その原点を忘れずに、開発を続けてきました。


前回: 第1回「子供に投資をどう教えるか — ゲームという選択肢」

前回: 第2回「実在のニュースで景気変動を再現する」

この記事をシェア

関連記事