ゲームを10本作って、共通化してよかったもの・しなくてよかったもの

3本目までは各ゲームに書き散らしていた処理を、どの順番で共通化していったか。逆に、似ているのに共通化しなかった部分についても。

このサイトには現在10本のゲームがあります。全部同じ構成で作っていて、共通部分は src/games/shared/ にまとめてあります。

ただし、最初からこの形だったわけではありません。3本目くらいまでは各ゲームに同じようなコードを書き散らしていて、そこから少しずつ引き剥がしていきました。

何をどの順番で共通化したか、そして似ているのに共通化しなかったものについて書きます。

最初に共通化したもの:ゲームの起動

いちばん最初に切り出したのが、Phaser の初期化です。全ゲームで完全に同じでした。

export function createGame(canvas: HTMLCanvasElement, scenes: SceneType[]) {
  const parent = canvas.parentElement;
  const w = parent?.clientWidth || window.innerWidth;
  const h = parent?.clientHeight || window.innerHeight;

  const game = new Phaser.Game({
    type: Phaser.CANVAS,
    canvas,
    width: w,
    height: h,
    backgroundColor: '#1a1a2e',
    scene: scenes,
    scale: {
      mode: Phaser.Scale.RESIZE,
      autoCenter: Phaser.Scale.CENTER_BOTH,
    },
  });

  return { destroy: (removeCanvas: boolean) => game.destroy(removeCanvas) };
}

おかげで、各ゲームの入口は3行になりました。

import { createGame } from '../shared/createGame';
import { MainScene } from './scenes/MainScene';

export const startGame = (canvas: HTMLCanvasElement) =>
  createGame(canvas, [MainScene]);

同じ設定を10か所に書かないというだけの話ですが、効果は大きいものでした。画面サイズの扱いを直したいとき、1か所を直せば全ゲームに反映されます。

次に共通化したもの:演出

2番目に切り出したのが、effects.ts にまとめた演出まわりです。

  • punchScale — 要素をぽんと拡大して戻す
  • shakeText — テキストを左右に揺らす
  • shakeCamera — 画面全体を揺らす
  • spawnBurst — 指定位置に粒子を飛ばす
  • spawnConfetti — 紙吹雪を降らせる
  • getLivesDisplay — 残機を「♥♥♡」の形式で表示する

これらはゲームのルールとは無関係です。正解したら弾ける、ミスしたら揺れる。題材が計算だろうが色だろうが、やることは同じです。

共通化して分かったのは、演出が揃うとサイト全体に統一感が出るということでした。別々に書いていた頃は、ゲームごとに揺れ方の強さや粒子の数が微妙に違っていて、それが「作りかけ感」につながっていました。

3番目:ランク判定

スコアから称号を求める処理も共通化しました。ただし共通化したのは仕組みだけで、中身は各ゲームが持っています

export function getRank(value: number, ranks: RankDef[]): RankInfo {
  for (const r of ranks) {
    if (value >= r.min) return { title: r.title, color: r.color };
  }
  return ranks[ranks.length - 1];
}

判定ロジックは10行足らずですが、共通化する価値がありました。閾値を降順に並べて上から探すという前提を、全ゲームで揃えられるからです。

各ゲームは、自分のランク表を渡すだけです。

const RANKS: RankDef[] = [
  { min: 5000, title: '計算の神', color: '#ff0000' },
  { min: 3500, title: '暗算マスター', color: '#8b5cf6' },
  // ...
];

称号やスコアの刻みは、ゲームによってまったく違います。ここを無理に共通化しようとすると、設定項目だらけの汎用関数ができあがって、かえって読みにくくなります。

**「処理は共通、データは個別」**という切り分けが、いちばん素直でした。

共通化しなかったもの:問題の生成

いっぽう、共通化を検討してやめたものもあります。

計算ラッシュカラーパニック方向パニックは、構造がとてもよく似ています。1問出して、2〜6択で答えて、正誤を判定して、次へ進む。制限時間の計算式にいたっては完全に同一です。

そこで「共通の出題エンジン」を作ろうとしました。やめました。

理由は、似ているのは骨格だけで、中身が全然違ったからです。

  • 計算ラッシュ:正解と誤答のに意味がある(±1〜9に収める必要がある)
  • カラーパニック:文字の意味と表示色が必ず異なる必要がある
  • 方向パニック:逆転フラグという第2の軸がある

これらを1つの関数で扱おうとすると、引数だらけの何かができあがります。しかも新しいゲームを足すたびに、その関数に条件が増えていく。共通部分を守るために、全員が不便になる構図です。

代わりに、ドキュメントで揃えることにしました。ライフは3、初期制限時間は10秒、下限2秒、1回答ごとに1秒減、難度ジャンプは20問。この数値と、各シーンが持つべきメソッド名までを設計ガイドラインとして文書化してあります。

コードの共有ではなく、設計の共有です。実装はゲームごとに書きますが、形は揃う。この距離感がちょうどよかったと思っています。

共通化しなかったもの:シーンの構成

各ゲームは TitleScene / PlayScene / ResultScene の3つを持ちます。名前も役割も揃っています。

ここも基底クラスを作れそうに見えますが、作りませんでした。

PlaySceneそのゲームの本体そのものです。共通の親クラスを置くと、親の都合で子が制約されます。「この処理は親のどこで呼ばれるんだっけ」を毎回追いかけることにもなります。

継承より、同じ形をしているだけの別物として並べておくほうが、10本くらいの規模では読みやすいという判断でした。

例外的に共通化したもの:盤面のユーティリティ

無限ぽこぽこ無限リバーシは、どちらも不定形の盤面を使います。この2本だけが使う小さなモジュールを、shared/boardUtils.ts に置いてあります。

  • cellKey(row, col) — 座標を "3,5" のような文字列にする
  • parseKey(key) — 文字列を座標に戻す
  • createRng(seed) — 再現可能な乱数を作る
  • generateSeed() — 新しい種を作る

2本でしか使わないものを共通化するのは早すぎる気もしましたが、盤面の座標をどう表すかは統一しておきたかったのです。片方が "3,5"、もう片方が {r: 3, c: 5} だと、あとで読み返すときに混乱します。

createRng を用意したのは、盤面を種から再現できるようにするためです。同じ種を渡せば同じ盤面が生成されるので、不具合の再現やテストがしやすくなります。

振り返って

10本作って見えてきた基準は、こうでした。

共通化してよかったものは、ゲームのルールと無関係なものでした。起動処理、演出、保存、共有、ランク判定の仕組み。これらは「どのゲームか」に依存しません。

共通化しなくてよかったものは、ゲームの個性そのものでした。出題ロジック、スコア計算、シーンの中身。ここを共通化しようとすると、抽象化のコストがゲームを作るコストを上回ります。

判断に迷ったときは、**「このゲームらしさはどこにあるか」**を考えるようにしました。そこは触らない。それ以外は積極的にまとめる。

3本目で共通化を始めたのは、たぶん適切なタイミングでした。1本目で共通化しようとしていたら、何が共通なのか分からないまま間違った境界を引いていたと思います。2〜3回同じものを書いてから、初めて共通部分が見えてきます

広告