「星あつめ」は、画面に現れた星を45秒間集めるだけの小さなブラウザゲームです。ルールを一文で説明できるほど単純ですが、単純なゲームほど、操作した瞬間の手触りをごまかせません。星を押したのに反応が遅い、成功したのか分からない、最初から速すぎる――そのどれか一つだけでも、プレイヤーは次のタップをやめてしまいます。

そこで開発では、機能の数ではなく「見る → 押す → 返事が来る → 次を探す」という短い循環を何度も調整しました。この記事では、ゲームを初めて作る人にも追えるように感覚的な狙いから始め、後半では状態機械、時間計測、入力イベント、Web Audio API、iframe越しのセーブまで踏み込みます。

中毒性は、プレイヤーを長く拘束する仕組みではなく、「自分の操作で状況がよくなった」と短い間隔で理解できる設計から生まれます。

まず「一回の気持ちよさ」を定義する

最初に決めた成功体験は、「星を見つけ、狙い、押し、光と音で成功を知る」ことです。この一回が楽しくなければ、特殊アイテムやランキングを足しても土台は改善しません。反対に、一回が気持ちよければ、同じ操作の繰り返しにもリズムが生まれます。

一回の操作に対して、ゲームは複数の層で返事をします。

  • 星がその場から消え、次の星が短い間を置いて現れる
  • 画面全体へ短い発光パルスが走る
  • コンボ数と倍率が更新される
  • 成功が続くほど効果音の音程が上がる
  • 通常星、タイム星、ボム星で色と結果が変わる

重要なのは、派手さよりも因果関係です。入力から視覚変化までが近く、同時に音と数値も変わるため、「今のタップで成功した」と迷わず理解できます。一方、空振りでは低い音、短い失敗パルス、コンボのリセットを同時に返します。成功と失敗を色だけで区別しないので、色の見え方や音量設定が異なる人にも結果が伝わります。

フィードバックを四つの輪として考える

入力、即時反応、コンボ、記録が循環する星あつめのフィードバック図

上から時計回りに、星への入力、光による即時反応、連続成功、記録への蓄積を表しています。

ゲームの反応は、すべて同じ速さで返す必要はありません。むしろ時間の違う四つの輪に分けると、何を実装すべきか整理しやすくなります。

最短は数十ミリ秒の「操作の返事」です。星を隠し、光と音を出します。次は数秒の「連続成功」で、コンボと倍率が上がります。その外側には45秒の「一回の挑戦」があり、終了時にスコアを確定します。最も長い輪はセッションを越える「ベスト記録」です。

この構造には、失敗の被害を局所化する利点があります。星を一つ逃しても失うのはコンボだけで、現在スコアは減りません。45秒が終わってもベスト記録は残ります。つまり短い失敗は痛みとして伝わる一方、それまでの努力まで取り上げません。

実装でも、役割を小さな関数へ分けています。

// 1回の成功だけを処理し、次の出現予約まで完了させます。
function collect() {
  if (phase !== "playing") return;
 
  clearTarget();
  combo += 1;
  const multiplier = Math.min(5, 1 + Math.floor(combo / 4));
  score += 10 * multiplier;
 
  updateHud();
  pulse("is-hit");
  targetTimer = setTimeout(spawnTarget, 105);
}

collectが描画ループの細部まで抱え込まず、「状態を変える → 表示を同期する → 次を予約する」という順番だけを持つのがポイントです。後から演出を変えても、得点計算の意味が崩れにくくなります。

状態を分けて二重入力を防ぐ

小さなゲームでも、開始前、プレイ中、ポーズ中、終了後という状態があります。画面上にボタンが見えているだけで判定すると、ポーズ直後のタップが星の取得にも使われたり、終了後にタイマーがもう一度走ったりします。

「星あつめ」では phasereadyplayingpausedended のいずれかに限定し、入力関数の入口で検査します。これは有限状態機械の最小形です。専門的には、許される遷移を明示し、不正なイベントを状態境界で捨てていると説明できます。

function resumeGame() {
  if (phase !== "paused") return;
 
  phase = "playing";
  deadline = performance.now() + timeLeft * 1000;
  clockTimer = setInterval(tick, 100);
  spawnTarget();
}

残り時間を単純に毎秒1ずつ減らしていない点にも注目してください。ブラウザのタイマーは、別タブへ移ったときや端末が重いときに遅れます。そこで終了予定時刻 deadline と、高精度な単調増加時計 performance.now() の差から残り時間を再計算します。表示更新が少し遅れても、ゲーム全体の45秒は伸びません。

ページが非表示になった場合は visibilitychange で自動ポーズします。非表示中に星だけが消えてコンボが切れる不公平を避けるためです。再開時には新しい deadline を作るので、休んだ時間は制限時間へ含まれません。

マウス、タッチ、キーボードを同じルールへ通す

ブラウザゲームでは、PCのクリック、スマートフォンのタップ、キーボード操作が混在します。入力方法ごとに得点処理を書くと、片方だけコンボが増えない、といった差が生まれます。そこで、どの入口から来ても最後は collect() へ集約します。

星そのものは button として扱うため、クリックだけでなくフォーカスと決定キーもブラウザ標準で利用できます。出現時には focus({ preventScroll: true }) を呼び、キーボード利用者が毎回Tabキーで星を探さなくてもよいようにしました。preventScroll はフォーカス移動によるページ全体の跳ねを防ぎます。

背景の空振り判定には pointerdown を使います。Pointer Eventsはマウス、指、ペンを共通化できます。ただし星を押したイベントが背景へ伝わると、成功直後に空振りにもなってしまうため、星側のクリックでは伝播を止めます。

ui.target.addEventListener("click", (event) => {
  event.stopPropagation();
  collect();
});
 
ui.sky.addEventListener("pointerdown", (event) => {
  if (event.target === ui.sky) miss("空振り! コンボリセット");
});

Pキーでポーズ、Mキーでミュート、Hキーでヘルプ、終了後のRキーで再挑戦も用意しました。これは上級者向けの近道であると同時に、細かなポインター操作が難しい状況への代替手段でもあります。

難易度は「速さ」ではなく余裕の配分

序盤から終盤へ星の速度と種類が増える一方、判別可能な大きさを保つ難易度図

左から右へ、最初の大きく遅い星、コンボ中の連続星、タイム星とボム星が混ざる終盤を表しています。

難しくする最も簡単な方法は、星が消えるまでの時間を短くすることです。しかし最初から速いとルールを理解する前に失敗し、無制限に速くすると端末性能や身体条件の差を測るゲームになります。そこで、最初は1.5秒の猶予を用意し、コンボが続いたときだけ28ミリ秒ずつ短くします。下限は780ミリ秒です。

// 初回の成功体験を守りつつ、連続成功時だけ反応速度を要求します。
const lifetime = Math.max(780, 1500 - combo * 28);
targetTimer = setTimeout(
  () => miss("星を逃した! コンボリセット"),
  lifetime,
);

この式は線形補間の簡単な例です。combo = 0 では1500ミリ秒、10連続なら1220ミリ秒、十分に続くと780ミリ秒で止まります。下限が「公平さの柵」です。調整では平均コンボだけでなく、初回成功率、スマートフォンでの誤タップ率、星を見つけてから押すまでの反応時間分布を見ます。

出現位置にも安全域があります。星の幅を測り、左右10ピクセル、下側58ピクセルを除いた範囲から乱数を選びます。これにより画面外へ半分隠れたり、下部の案内と重なったりしません。完全な乱数よりも、プレイヤーが「自分のせいで逃した」と納得できる制約付き乱数の方が、ゲームには向いています。

さらに通常星だけでなく、約12%ずつタイム星とボム星を混ぜました。タイム星は3秒を追加し、ボム星は高得点を返します。狙う対象が増えることで、同じタップでも小さな判断が発生します。ただし色だけでなく形と aria-label も変え、識別手段を重ねています。

音とアニメーションを短く、重ねて返す

効果音には音声ファイルではなくWeb Audio APIのオシレーターを使いました。ファイル読込を待たず、周波数と長さをその場で決められます。通常星ではコンボに応じて少しずつ音程を上げ、連続成功が耳でも分かるようにしています。

// 短い包絡線でクリック感を作り、鳴りっぱなしを防ぎます。
gain.gain.setValueAtTime(0.0001, audioContext.currentTime);
gain.gain.exponentialRampToValueAtTime(0.075, audioContext.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, audioContext.currentTime + duration);

音量を0から始めず 0.0001 にしているのは、指数補間が0を扱えないためです。10ミリ秒で立ち上げ、その後すぐ減衰させると、長く残らない軽い音になります。ボム星だけ波形を矩形波へ変え、低音と高音を重ねて特別感を出しました。

視覚効果も320ミリ秒程度で終えます。ゲーム中ずっと大きく揺らすのではなく、結果を伝える瞬間だけ発光させます。CSSの prefers-reduced-motion では移動量や反復を抑え、動きを減らす設定の利用者にも状態変化は色と数値で残します。

iframeの外へ安全に記録を預ける

ゲームは親サイト内のiframeで動きます。永続記録をiframe自身のLocal Storageへ保存すると、配信先のドメインを変えたときに記録を引き継ぎにくくなります。そのためベストスコアは、postMessage を使って親サイトのセーブブリッジへ依頼します。

送信データにはプロトコル名、版、ゲームID、要求ID、セーブ形式の版を含めます。要求IDは、複数の読込・保存が近いタイミングで発生しても返答を正しい処理へ戻すための相関IDです。受信時は event.originevent.source を確認し、期待する親ウィンドウ以外のメッセージを受け取りません。

parent.postMessage({
  protocol: "dream-games-save",
  protocolVersion: 1,
  type: "save:set",
  gameSlug: "star-catcher",
  requestId: crypto.randomUUID(),
  saveVersion: 1,
  data: { bestScore },
}, parentOrigin);

"*" を送信先に使わず、URLパラメーターで渡された親オリジンを正規化してから指定しています。これは単なる整理ではなく、別サイトへ意図せず記録を送らないための境界です。また応答が来ない場合は3秒で待機を終え、ゲーム本体は単体プレイとして続行します。保存機能の障害が、遊べない障害へ波及しない設計です。

数字と観察で「もう一回」を検証する

調整の最後は、開発者自身のハイスコアではなく、初見の人の行動を観察します。確認項目は次の通りです。

  • 説明を読まなくても最初の星を押せるか
  • 最初の5秒以内に一度は成功できるか
  • 空振りと時間切れの違いを理解できるか
  • 4コンボで倍率が上がったことに気づくか
  • タイム星を取ったとき、残り時間の増加を認識できるか
  • ポーズして戻ったあと、不利になっていないか
  • 再読込後にベストスコアが戻るか

自動テストでは、ゲーム登録、ビルドファイル、画像、セーブプロトコルの存在を検査します。ブラウザ上では実際に星を押し、スコア、コンボ、ポーズ表示、コンソールエラー、スマートフォン幅でのはみ出しを確認します。ただし「気持ちいいか」はスナップショットだけでは測れません。初見プレイの録画や、成功までに迷った秒数、リトライ率を合わせて見ます。

このゲームで目指したのは、無限に遊ばせる仕掛けではありません。45秒で気持ちよく区切り、失敗の理由が分かり、次は少し伸ばせそうだと思えることです。小さなゲームほど、入力、状態、時間、演出、保存の一つ一つがそのまま体験に現れます。だからこそ、短い一回を丁寧に設計することが、最も専門的で、最も効果の大きい仕事になります。