マガジン

「記録」を作り込む:最速・最高・前回の三つの見せ方

遊びをつくる

執筆にClaudeを使用しています。

遊んだ記録を示すカード列と、途中の位置を残した迷路盤を並べた写真風イメージ。
記事のテーマをもとに制作したAI生成イラスト。

1回遊び終えた画面に「スコア 1200」とだけ出る場面を考えてみます。遊んだ人は、その数字が良いのか悪いのか、前より進んだのかを判断できません。そこで多くのゲームは「ハイスコア」を足しますが、それだけでは足りないことが多いです。「最高」は到達点、「最速」は技の精度、「前回」は直近の手応えを示します。三つは別々の問いに答えており、更新したときに見せるべきものも変わります。 この記事で確かめるのは、「記録を3種類に分けて持ち、更新の種類ごとに見せ方を変えると、1回の結果が読みやすくなるか」という問いです。結果の良し悪しは作品ごとに変わるため、効果の数字は書きません。代わりに、記録データの形、更新の判定、演出の設計、確認の手順を順番に作ります。コードは1つのHTMLに収まる最小構成で、外部ライブラリもサーバーも使いません。

この記事の目次
  1. 1. 三つの記録に役割を割り振る
  2. 2. 手順1:記録データの形を決める
  3. 3. 手順2:判定と確定を分けて書く
  4. 4. 手順3:更新の種類ごとに演出を設計する
  5. 5. 手順4:自分で確かめる
  6. 6. 手順5:記録を残す範囲を決める
  7. 次の行動

1. 三つの記録に役割を割り振る

実装の前に、3つの記録がそれぞれ何に答えるかを決めます。

  • 最高、答える問い:これまでで一番良かったのは、比べる相手:過去すべての最大値、更新の条件:今回が超えたとき
  • 最速、答える問い:一番うまくやれたのは、比べる相手:過去すべての最小値、更新の条件:クリアして、かつ超えたとき
  • 前回、答える問い:直前より良くなったか、比べる相手:直前の1回だけ、更新の条件:毎回上書き

最高と最速は更新されないことも多く、更新されたときだけ演出する価値があります。前回は毎回更新されるので、演出はせず、差を静かに示すのが向いています。

ここで一つ決めておくことがあります。最速は、クリアした回だけが候補です。途中で失敗した回の経過時間を最速にすると、すぐに終わった失敗が「最速」になってしまうためです。

公式作品を開くと、記録の種類が作品ごとに違うことがわかります。

  • 「小さな軌道」には最速記録があり、前回の軌道が細い実線で表示されます。
  • 「月夜の配達」では各夜の最少区間数を記録します。
  • 「プリズムの道」には最少回転の記録があります。
  • 「こだまの森」には最高記録があります。

遊びの性質に合った記録を選んでいる、と設計から読み取れます。更新時の見せ方の細部は、実際に作品を開いて確かめてください。

2. 手順1:記録データの形を決める

最初に、記録を入れる入れ物を決めます。次の3つで足ります。

  1. 最高スコア(まだなければ未設定)
  2. 最速タイム(まだなければ未設定)
  3. 前回の結果(スコア、タイム、クリアしたか)

「未設定」を0で表すと、最速タイムが0秒の記録になってしまい、誰も超えられなくなります。未設定は null で表し、比較のときに「記録がなければ必ず更新」と扱います。

また、1回の結果は { score, ms, cleared } の形にそろえます。ms はミリ秒の整数です。小数の秒で持つと、表示の丸めと比較の結果がずれることがあります。比較は整数のミリ秒で行い、表示のときだけ秒にします。

3. 手順2:判定と確定を分けて書く

判定と確定は別の関数に分けます。判定は「何が更新されたか」を返すだけで、記録は書き換えません。確定は判定の後に記録を書き換えます。分ける理由は、演出の文言を作るときに「更新前の値」が必要だからです。先に上書きすると、「前回比」や「何点上回ったか」が計算できなくなります。

次のコードを record-demo.html として保存し、ブラウザで開くと動きます。ボタンを押すたびにランダムな1回分の結果を作り、判定して表示します。

html
<!doctype html>
<meta charset="utf-8">
<p id="msg">結果を出してみます</p>
<button id="go">1回遊ぶ(ランダム結果)</button>
<script>
const rec = { bestScore: null, fastestMs: null, last: null };
const calm = matchMedia("(prefers-reduced-motion: reduce)").matches;

function judge(run) {
  const out = { first: rec.last === null, tags: [] };
  if (run.score > (rec.bestScore ?? -Infinity)) out.tags.push("score");
  else if (run.score === rec.bestScore) out.tags.push("scoreTie");
  if (run.cleared) {
    if (run.ms < (rec.fastestMs ?? Infinity)) out.tags.push("time");
    else if (run.ms === rec.fastestMs) out.tags.push("timeTie");
  }
  out.dScore = out.first ? null : run.score - rec.last.score;
  return out;
}

function commit(run, j) {
  if (j.tags.includes("score")) rec.bestScore = run.score;
  if (j.tags.includes("time")) rec.fastestMs = run.ms;
  rec.last = run;
}

function words(j) {
  if (j.first) return "最初の記録ができました。次回はこれと比べます。";
  const p = [];
  if (j.tags.includes("score")) p.push("最高スコア更新");
  if (j.tags.includes("time")) p.push("最速タイム更新");
  if (j.tags.includes("scoreTie")) p.push("最高スコアに並びました");
  p.push("前回比 " + (j.dScore >= 0 ? "+" : "") + j.dScore + "点");
  return p.join(" / ");
}

document.getElementById("go").onclick = () => {
  const run = {
    score: Math.floor(Math.random() * 5) * 100,
    ms: 20000 + Math.floor(Math.random() * 5) * 1000,
    cleared: Math.random() > 0.3
  };
  const j = judge(run);
  const el = document.getElementById("msg");
  el.textContent = words(j);
  commit(run, j);
  if (!calm && j.tags.length && !j.first)
    el.animate([{ transform: "scale(1.15)" }, { transform: "scale(1)" }], { duration: 400 });
};
</script>

このコードには、3つの判断が入っています。

  • 初回は全項目が「更新」になりますが、祝いません。比べる相手がいないので、「基準ができました」と伝えるだけにしています。
  • 同じ値は更新にせず、「並びました」という別の扱いにしています。同点を更新扱いにすると、更新の価値が薄れます。
  • 最速は cleared が真のときだけ候補にしています。

ランダムの値の刻みを粗くしているのは、同点や未クリアを短い試行で出やすくするためです。実際のゲームでは、ここを自作の結果に置き換えます。

4. 手順3:更新の種類ごとに演出を設計する

判定が tags で返るようになったら、種類ごとに見せ方を決めます。作る前に、次の3点を紙に書き出すと迷いません。

  1. 画面のどこに出すか(結果画面の中央、スコア表示の横など)
  2. どれくらい目立たせるか(拡大、色、静止した文言のどれか)
  3. 何秒見せるか(次の操作を妨げないか)

目安として、更新の価値が高いものほど、短く、はっきり出します。

  • 最高の更新:スコアの数字そのものを強調します。数字が主役の記録なので、数字の近くで、拡大や色の変化を一度だけ起こします。
  • 最速の更新:タイムの横に差を添えます。「0.8秒短縮」のように、前の最速との差を出すと、速さの実感が伝わります。そのためには、確定の前に前の最速を取り出しておく必要があります。
  • 前回との比較:演出なしの小さな文字で、符号つきの差を出します。毎回出るので、目立たせると更新の演出が埋もれます。前回との差が悪化したときも、責める言い方は避け、数字だけを淡々と出します。
  • 並んだとき:静かな一言にとどめます。更新と同じ演出にしてはいけません。

前回との比較は、数字以外でも見せられます。公式作品の「小さな軌道」は、前回の軌道を細い実線で重ねて見せる作りです。数字の差ではなく、形の差で比べられます。自分のゲームで数字以外に見せられるものがないか、考えてみます。

動きを抑える設定への対応

演出は、動きを減らしたい人にも配慮します。コードの calm は、OSやブラウザの「動きを減らす」設定を読み取っています。この設定が有効な人には、拡大などの動きを出さず、文言と色だけで伝えます。公式の13作品も「動きを抑える」設定に対応しています。更新の瞬間を動きだけに頼ると、設定が有効な人には伝わりません。文言は必ず出し、動きは補助にします。

5. 手順4:自分で確かめる

作ったあとは、次の手順で動作を確かめます。結果を記録する表を紙かメモに用意し、デモを動かしてください。

  1. ページを開き直して、初回の1回を出します。「最初の記録ができました」と表示され、更新の演出が出ないことを確認します。
  2. 続けて20回ほど押します。1回ごとに、表示された文言と、判定が正しいかを表に書き写します。書く項目は、今回のスコア、クリアしたか、表示された文言の3つです。
  3. 表を上から見て、次の3つが成り立っているかを確かめます。
  • 最高スコアは、過去の最大値を超えた回だけに表示される。
  • 未クリアの回に、最速の更新が出ていない。
  • 前回比の符号が、直前の行との差と一致している。
  1. 一つでも合わない行があれば、その行の前後で judge と commit に渡した値を出力して調べます。

起こりやすい誤りは、次のとおりです。

  • 確定を先にしてしまう:判定の前に記録を上書きすると、差が常に0になります。表示される差がいつも0なら、これを疑います。
  • 未設定を0にする:最速タイムが永遠に更新されなくなります。
  • 失敗した回を最速に含める:短時間で終わった失敗が最速として残ります。
  • 同点を更新扱いにする:更新の演出が頻発して、価値が薄れます。
  • 前回が未クリアのときのタイム比較:未クリアの前回タイムとの差は意味を持たないので、前回比のタイムは出さないか、「前回は未クリア」と書きます。
  • 演出が次の操作を止める:結果画面で演出が終わるまでボタンが押せないと、繰り返し遊ぶ人には負担です。演出中も次の操作を受け付けるようにします。

6. 手順5:記録を残す範囲を決める

最後に、記録をどこまで残すかを決めます。このコードの rec は変数なので、ページを閉じると消えます。

アソバースにアップロードした作品内の localStorage と sessionStorage は、一時メモリーとしての互換処理です。画面を開いている間の記録なら、これで足ります。公式作品でも、13本のうち保存があるのは「余白の庭」「WAVE / WAVE」「かたちのあいだ」の3本で、最速や最高の記録は画面を開いている間だけという作品が多くあります。

閉じても残したい場合は、アップロード作品に自動で追加されるSDKのクラウドセーブを検討します。作品ごとにJSONで128KiBまで保存できます。記録の3項目なら、容量に困る大きさではありません。SDKの使い方と、ログインの有無による扱いの違いは、現在の案内で確認してください。なお、ゲーム内スコアの共通ランキングは現時点では提供されていません。作ろうとしている記録は、プレイした本人が自分と比べるためのものとして設計します。

次の行動

まず上のコードを保存して20回ほど動かし、判定の表を作ってみます。次に、自分のゲームで「最高」「最速」「前回」のどれが遊びに合うかを一つだけ選び、更新時の見せ方を紙に書いてください。選ぶ記録は、一つで構いません。