「森の精霊」は、四体の精霊が光る順番を見て、同じ順番でタップする記憶ゲームです。正解するたびに列が一つ長くなり、得点とコンボが伸び、森へ四季が戻っていきます。制限時間は60秒。ルールだけなら、昔からあるシーケンス再生型ゲームに近いものです。

しかし実際に作ると、「光らせる」「入力を受ける」という二つの処理だけでは成立しません。見本の途中で押せてしまう、ポーズ後に古いアニメーションが再開する、失敗のたびに難易度が上がり続ける、色だけでは精霊を区別できない、といった問題が次々に現れます。

この記事では、初めてゲームロジックを読む人にも分かるように、まず人が何を覚えているのかを整理します。そのうえで、配列によるシーケンス生成、有限状態機械、非同期処理のキャンセル、得点式、キーボード対応、セーブブリッジ、テストまで専門的な実装へ進みます。

記憶ゲームの難しさは、列の長さだけでは決まりません。提示速度、間隔、見分けやすさ、失敗後の戻り方をまとめて設計する必要があります。

プレイヤーが覚えるのは「色」ではなく出来事

四体の精霊には、それぞれ位置、色、形の細部、音程があります。プレイヤーは「緑、青、黄」と言葉に置き換えるかもしれませんし、「左上、右、下」と場所で覚えるかもしれません。音が得意な人は、短いメロディーとして保持するかもしれません。

このように複数の手掛かりを用意する理由は、好みだけではありません。色覚、画面の明るさ、周囲の音、端末の大きさなど、プレイ条件が違っても同じルールへ参加できるようにするためです。色だけを正解の条件にすると、色を区別しにくい人や屋外で暗い画面を見る人が不利になります。

提示時には対象の精霊を大きく明るくし、同時に固有の音を鳴らします。入力時には押せる状態を見た目と disabled 属性の両方で切り替えます。正解なら短い明色、誤りなら別の揺れと低音を返し、結果を一つの感覚だけに依存させません。

一ラウンドを状態の流れとして描く

待機、見本提示、入力、成功または再挑戦へ分岐する森の精霊の状態図

左から、待機、精霊が順に光る提示、プレイヤー入力、成功して葉が育つ場合と失敗して短い列へ戻る場合を表します。

画面を眺めると、精霊は同じ場所に立ち続けています。しかし内部では readyshowinginputpausedended という五つの状態を移動しています。

  • ready: 開始前。遊び方を表示する
  • showing: 見本を再生中。精霊の入力は無効
  • input: プレイヤーが順番を再現する
  • paused: 時計と進行を止める
  • ended: 結果を確定し、再挑戦を待つ

この区別がないと、見本を再生している最中のタップを回答として数えたり、時間切れ後の最後の入力が得点へ入ったりします。入力関数の最初で状態を確認するだけでも、多くの競合を防げます。

function chooseSpirit(index) {
  // 回答受付中でなければ、入力をゲーム状態へ反映しません。
  if (phase !== "input") return;
 
  if (sequence[inputIndex] !== index) {
    resetAfterMistake();
    return;
  }
 
  inputIndex += 1;
  if (inputIndex === sequence.length) completeSequence();
}

ここで sequence は正解の配列、inputIndex は次に照合する位置です。プレイヤーの回答を別の配列へすべて保存してから比較する必要はありません。一つ押すたびに現在位置だけ比較すれば、誤りをすぐ返せて、保持する状態も少なくなります。計算量は一入力あたり定数時間、つまりシーケンスが長くなっても一回の判定コストはほぼ変わりません。

配列を一つずつ育てるアルゴリズム

開始時は、0から3までの整数を三つ並べます。それぞれが四体の精霊を表します。正解したら末尾へ乱数を一つ追加し、最大七つまで伸ばします。

function randomSpirit() {
  return Math.floor(Math.random() * 4);
}
 
function freshSequence(length = 3) {
  return Array.from({ length }, randomSpirit);
}
 
if (sequence.length < 7) {
  sequence.push(randomSpirit());
} else {
  sequence = freshSequence(7);
}

同じ精霊が二回続くことも許しています。連続を禁止すると見た目には整いますが、プレイヤーが「同じものは続かない」と推測でき、純粋な記憶以外の規則が生まれます。完全に均等な乱数も短い一回では偏って見えるため、将来分析するなら各精霊の出現回数だけでなく、連続回数や位置の偏りも記録します。

最大長を七にしたのは、配列がそれ以上持てないからではありません。JavaScriptの配列はさらに長くできます。上限は認知負荷と60秒のテンポから決めたゲームデザイン上の制約です。提示には一体あたり約660ミリ秒かかるため、十体以上に伸ばすと見ている時間が長く、回答できる回数が減ります。

七つへ達した後は七つの新しい列を作ります。同じ長い列へ一つずつ足し続けないので、暗記済みの前半を機械的に繰り返す時間を抑え、新しい並びを覚える挑戦へ戻せます。

難易度を記憶負荷として段階化する

三つ、五つ、七つへと精霊の列が伸び、失敗時には短い列へ戻る難易度図

左から短い導入、中央の反復、右の長い列と、誤りから短い列へ戻る経路を表しています。

記憶負荷を上げる要素には、列の長さ、提示速度、刺激同士の間隔、選択肢の数、妨害情報があります。このゲームでは、選択肢を四体に固定し、提示速度も一体480ミリ秒の点灯と180ミリ秒の間隔で固定しました。主に列の長さだけを三から七へ変えます。

一度に複数の軸を変えないことで、プレイヤーは「一つ長くなった」と理解できます。開発側も、難しすぎた原因を特定しやすくなります。列が長くなると同時に点灯まで速くすると、失敗が記憶容量によるものか視認時間によるものか分かりません。

失敗したときは、コンボを0にし、列を三つへ戻します。得点は減らしません。これは負のフィードバック制御に近い考え方です。成功が続くと負荷が上がり、失敗すると負荷が下がるため、プレイヤーの現在の能力付近へ自然に戻ります。

成功時の休止は760ミリ秒、失敗時は800ミリ秒です。結果を読み取る時間を確保しつつ、次の見本まで待たせすぎない値を実機で調整しました。認知負荷を扱うゲームでは、問題そのものだけでなく、問題と問題の間にある「呼吸」も難易度の一部です。

非同期の見本再生を安全に止める

見本は、待つ、光らせる、待つ、消す、という非同期処理の列です。async / await を使うと読みやすく書けますが、途中でポーズや時間切れが起きる問題が残ります。setTimeoutawait wait() は、状態が変わっても自動ではキャンセルされません。

そこで roundToken という世代番号を使います。見本を始めるたびに番号を増やし、ローカル変数へ控えます。ポーズ、終了、新しい見本の開始でも番号を増やします。待機から戻ったとき、控えた番号と現在の番号が違えば、古い処理として終了します。

async function showSequence() {
  phase = "showing";
  const token = ++roundToken;
 
  for (const index of sequence) {
    // 待機中に別ラウンドへ移った場合は、古い再生を中止します。
    if (token !== roundToken || phase !== "showing") return;
 
    lightSpirit(index);
    await wait(480);
    clearSpirit(index);
    await wait(180);
  }
 
  if (token !== roundToken || phase !== "showing") return;
  phase = "input";
  disableSpirits(false);
}

これはキャンセルトークンの簡易版です。すべてのタイマーIDを配列で保持して解除する方法より、await の流れを保ったまま古い処理の副作用を止めやすくなります。ただし待機そのものは指定時間まで残るため、大量の処理を高頻度で起動する用途では AbortController やキャンセル可能なスケジューラーも検討します。

ポーズから再開したときは、途中の位置から続けず、列を最初から見せ直します。これは利便性だけでなく公平性の判断です。中断前にどこまで見たかが曖昧なまま入力させるより、残り時間だけ保持して提示を再開する方が失敗理由を明確にできます。

得点式で成功の意味を見せる

得点は 列の長さ × 60 × 倍率 です。倍率は連続成功数に合わせて1から4まで上がります。

combo += 1;
const multiplier = Math.min(4, combo);
const earned = sequence.length * 60 * multiplier;
score += earned;

三体を最初に成功すると180点、次の四体を2倍で成功すると480点になります。長い列は覚える情報が多く、提示と入力にも時間がかかるため、その両方を得点へ反映しています。倍率に上限を置くのは、上級者の一回だけが極端に膨らみ、再挑戦の目標が遠くなりすぎるのを避けるためです。

成功するたびに、画面上では春、夏、秋、冬の印が一つずつ点灯します。スコアは正確ですが抽象的です。四季の回復は「あと何回で景色が変わるか」を直感的に示します。二回成功した時点で背景を回復後の場面へ変えるため、得点だけでなく世界への影響も報酬になります。

一方、失敗でスコアを没収しないのは重要です。記憶ゲームでは、一つの誤りが列全体の誤りになります。そこへ大きな減点まで重ねると、情報量に対して罰が強くなります。列を三つへ戻すだけで次の得点効率が下がるため、十分なペナルティとして機能します。

押せることをHTMLと見た目の両方で伝える

精霊はすべてネイティブの button 要素です。提示中は disabled = true、回答中は false にします。CSSの pointer-events だけで無効化すると、キーボードや支援技術からは押せるように見える場合があります。HTML属性と表示を一致させることで、ブラウザの標準動作も利用できます。

回答開始時には最初の精霊へフォーカスを移し、1から4の数字キーでも直接選べます。キー入力は event.repeat を無視し、押しっぱなしで同じ回答が連続しないようにします。

addEventListener("keydown", (event) => {
  if (event.repeat) return;
 
  if (/^Digit[1-4]$/.test(event.code)) {
    event.preventDefault();
    chooseSpirit(Number(event.code.at(-1)) - 1);
  }
});

フォーカス輪郭は消さず、タッチ端末では十分な押下領域を確保します。音は個別にミュート可能で、動きを減らすOS設定では大きな移動アニメーションを抑えます。それでも点灯、数値、案内文は残るため、演出を弱めてもゲームの情報は失われません。

セーブと検証をゲームの外側まで考える

永続化するのはベストスコアだけです。進行中の列や残り時間まで保存すると、再読込時の復元条件が増え、60秒ゲームの手軽さを損ないます。何を保存しないかもセーブ設計です。

記録はiframe内のLocal Storageではなく、親サイトのセーブブリッジへ postMessage で依頼します。ゲームIDは forest-spirits、形式版は1です。受信時には親のオリジン、送信元ウィンドウ、プロトコル名、要求IDを照合します。配信先を別のPagesプロジェクトへ移しても、親サイトが同じゲームIDを扱えば記録を継続できます。

検証では、単に正解できるかだけでなく、状態の境界を重点的に試します。

  • 見本提示中にタップしても回答へ入らないか
  • 同じ精霊が連続する列を正しく再生できるか
  • 三体から七体まで一つずつ増えるか
  • 誤答後に三体へ戻り、コンボだけが切れるか
  • 提示中と回答中のどちらでも安全にポーズできるか
  • 別タブへ移ると自動停止し、復帰後に最初から見本を確認できるか
  • 数字キー、ポインター、タッチで同じ得点になるか
  • ミュートや動きを減らす設定でも成功と失敗を判別できるか
  • 再読込後にベストスコアだけが戻るか

自動テストはファイル構成やセーブプロトコルの退行を見つけます。実ブラウザの確認は、フォーカス、音を鳴らすためのユーザー操作、スマートフォン幅、非表示タブの挙動を見つけます。そして初見の人の観察は、「何を覚えればよいか分かったか」「失敗後にもう一度試す気になったか」を教えてくれます。三つは代替関係ではなく、それぞれ違う種類の不具合を発見します。

「森の精霊」は短い配列と数個の状態からできています。それでも、人の記憶、非同期処理、入力手段、公平な失敗、永続化という多くの層が重なります。小さなルールを丁寧に分解し、状態の境界を明確にすれば、仕組みは専門的でも、遊ぶ人には迷いのない体験として届きます。