第6章 ゲーム内容の作り込み/5 本目 / 全 10/約30分

対戦の記録を残して見返す

こより

残すのは、起きたこと。順番まで覚えておけば、あの勝負はもう一度なぞれるよ。

案内役 こより
この術でできるようになること

過去の対戦を再生できる

画像は押すと原寸で開きます。

なぜ要るか

前の術で「いまの状態」を保存しました。でもそれだけでは、何が起きたかは分かりません。

盤面の最終形から分かるのは結果だけです。どの札を、どの的に、どの順で置いて、そうなったのかは消えています。特にこのゲームでは順番が意味を持ちます。 同じ的に同じ札が並んでいても、先に置いたか後に置いたかで話が変わります。

これを残します。効くのは3つの場面です。

場面 効き方
遊ぶ人が振り返る 「あそこで5を蔵の的に置いたのが失敗だった」
作る人が調べる 「この状況で相手が変な手を出す」を再現できる
バランスを見る 次の6-6で、記録が判断材料になる

3番目が本命です。 記録が無いと、バランス調整が勘になります。

使う道具: なし

AIプロンプト

この術の作業をAIへ任せるときは、これをそのまま貼ってください。

index.html に対戦の記録を足して。1手ごとに、何手目・自分がどの札をどの的へ・相手がどの札をどの的へ、の3つだけ残して。勝敗は残さないで、記録から数え直せるようにして。画面の下に一覧で出して、記録が空のときは見出しごと隠して。

札や的の部分は、自分のゲームで1手ごとに起きることに置き換えてください。

「状態」ではなく「起きたこと」を残します

ここが今回いちばん大事な考え方です。

保存には2つのやり方があります。

やり方 残すもの
状態を残す いまどうなっているか 手札、勝敗の数
起きたことを残す 何をしたか 「1回目に1を出して負けた」

起きたことを残しておけば、状態はそこから作り直せます。 逆はできません。状態から「どうやってそこに至ったか」は復元できません。

だから記録の形はこうします。

log.push({ turn: turn + 1, mine: myMove, theirs: cpuMove });

push は、配列の末尾へ1つ足す書き方です。myMovecpuMove はそれぞれ「どの札をどの的へ置いたか」を持っているので、この1行で1手ぶんの記録が末尾に積まれます。

1手ぶんが1行です。実際に溜まるとこうなります。

[
  { "turn": 1, "mine": { "card": "c5", "target": "t2" }, "theirs": { "card": "c4", "target": "t2" } },
  { "turn": 2, "mine": { "card": "c3", "target": "t1" }, "theirs": { "card": "c3", "target": "t3" } },
  { "turn": 3, "mine": { "card": "c1", "target": "t3" }, "theirs": { "card": "c2", "target": "t1" } }
]

札も的も id で残します。 ここも6-1で分けた効果です。表示を「壱」や「門の的」に変えても、記録は読めます。

そして勝敗を残していません。 ここは6-4の保存と同じ判断です。残すのは事実だけで、計算できるものは残しません。 どの的を誰が取ったかは、この3行から何度でも出せます。

これには効く場面があります。ルールを変えたとき、昔の記録を新しいルールで数え直せます。 6-6で同点の的の扱いを変えますが、そのとき過去の記録がそのまま使えます。勝敗を焼き付けていたら、古い判定が残ってしまいます。

見返す画面を作ります

記録があっても、見る口が無ければ意味がありません。画面の下に出します。

<div class="log" id="logBox" hidden><h2>この勝負の記録</h2><ol id="logList"></ol></div>
el('logBox').hidden = log.length === 0;
el('logList').innerHTML = log.map(function (r) {
  return '<li>自分: ' + card(r.mine.card).label + ' → ' + targetName(r.mine.target) +
    ' / 相手: ' + card(r.theirs.card).label + ' → ' + targetName(r.theirs.target) + '</li>';
}).join('');

HTML側の hidden は「この箱を表示しない」という印で、最初は隠しておきます。JS側では、記録1行ずつを map<li> の文に変え、join('') で1本につないで innerHTML に入れています。innerHTML はその箱の中身をまるごと差し替える指定です。

出るとこうなります。

ゲーム画面の下に「この勝負の記録」として、自分: 5 → 蔵の的 / 相手: 4 → 蔵の的 などが並んでいる

idlabelname に戻して表示しているのが要点です。 記録の中は c5t2 ですが、画面には「5」と「蔵の的」と出ます。

そして開閉のボタンを付けていません。 記録は1手ごとに1行しか増えず、3〜4行で終わるからです。隠す価値がないものに開閉を付けると、押す手間だけが増えます。

代わりに空のときは箱ごと消します。

el('logBox').hidden = log.length === 0;

見出しだけ残っていると、壊れているように見えます。中身が無いなら、見出しも出しません。

再生できる形になっています

いまの記録から、対戦をもう一度なぞれます。新しい仕組みは要りません。

function replay(records) {
  var b = {};
  TARGETS.forEach(function (t) { b[t.id] = { mine: [], theirs: [] }; });
  records.forEach(function (r) {
    b[r.mine.target].mine.push({ card: r.mine.card, turn: r.turn - 1 });
    b[r.theirs.target].theirs.push({ card: r.theirs.card, turn: r.turn - 1 });
  });
  return b;
}

やっていることは2段です。まず的の数だけ空の置き場を作り、次に記録を上から順に読んで、書いてある的へ札を置き直しています。これは実際の対戦をもう一度なぞるのと同じ手順です。

記録だけから盤面が作り直せます。 そして作り直した盤面は、6-1の takerOf() にそのまま渡せます。判定の関数が盤面のデータしか見ていないからです。

var b = replay(log);
TARGETS.forEach(function (t) { console.log(t.name, takerOf(b[t.id])); });

console.log は、開発者ツールの中へ結果を出す書き方です。遊ぶ人の画面には出ないので、確かめたいことを気軽に覗けます。

これが「起きたことを残す」の価値です。 状態だけ保存していたら、この関数は書けません。そして判定を1箇所に寄せておいたから、再生用の判定を別に書かずに済んでいます。 6-1と6-5は別の術ですが、効果はここで合流します。

実物を見る ── CNPトレカアプリのリプレイ

僕のCNPトレカアプリには、対戦を再生する機能が入っています。

https://tcg.kenty.app

土台の考え方はいまやったことと同じで、起きたことを順番に積むだけです。そのうえで、本気で作るとこういう形になります。いま作る必要はありませんが、「先に何があるか」を知っておくと設計の迷いが減ります。

1. 記録は追記する。上書きしない

対戦1件が1件のデータで、手が進むたびに末尾へ足していきます。

duelresults に 1対戦 = 1件
  actions          手の記録(追記)
  uiMessages       画面に出た文(追記)
  replaySnapshots  節目の盤面(追記)

上書きしないのが要点です。 追記だけなら、途中で失敗しても前の記録は無傷です。

2. 「人の1手」と「自動で起きたこと」をまとめる

ここが実際に作ると必ず出る問題です。カードを1枚出すと、その後ろで自動的に何十個も処理が走ります。 効果が発動し、数値が変わり、カードが移動する。

これを全部そのまま再生すると、見ている人には何が起きたか分かりません。だからまとめます。

1ステップ = 人が起こした1手 + それによって自動で起きたこと

生の記録はそのまま持っておいて、見せる単位だけを別に組み立てます。 「戻る」を押したときに戻る先は、この1ステップです。

記録の粒度と、見せる粒度は別物という考え方です。

3. 節目の盤面も保存して、再生結果と照合する

これが個人的にいちばん効いている仕組みです。

記録から組み立て直した盤面が、当時の盤面と一致しているかを機械で比べます。 ズレたら「どの項目が、いま何で、当時は何だったか」を出します。

なぜ要るかというと、再生は静かに間違うからです。ルールを直したあとに昔の記録を再生すると、当時と違う結果になることがあります。照合していないと、間違った再生を正しいものとして見せてしまいます。

細かい工夫もあります。手札やユニットのように並び順に意味がない場所は、順序を無視して比べる。ここを厳密にやると、意味のないズレで警告が出続けます。

4. 再生の操作は7つ

先頭へ / 1手戻る / 再生・停止 / 1手進む / 最後へ / 速度変更 / スライダーで飛ぶ。動画プレイヤーと同じ形にすると、説明なしで使えます。

5. 誰が見られるかを決める

対戦記録には相手が写っています。だから参加者本人、または観戦を許可した対戦だけ見られるようにしています。

記録を残すと、必ず「誰に見せるか」が発生します。 URLを知っていれば誰でも見られる形にするのか、本人だけか。ここは機能ではなく方針の話なので、残すと決めた時点で一緒に決めてください。

6. 記録の形を変えても、昔の記録を読み続ける

作っているうちに、記録の形は変わります。項目が増える、名前が変わる、扱いが変わる。

そのたびに昔の記録が読めなくなると、リプレイは価値を失います。 だから「古い形も読める」変換をいくつも抱えています。

6-4で版の番号を入れたのと同じ話です。ただしセーブは捨てられますが、リプレイは捨てられません。 だからこちらの方が面倒になります。

最初から完璧な形を狙うより、版を付けて変換を足していく方が現実的です。

変数名でハマった話

この術を作っているとき、実際に踏んだ落とし穴を書きます。

僕は最初、記録の変数名をこうしていました。

var history = [];    // 各回の結果

動きませんでした。 しかもエラーが出ません。画面には「5 回目 / 全 5 回」と、いきなり最後の回が表示されました。

原因は、history がブラウザに最初からある名前だったことです。window.history はブラウザの履歴を指します。上書きしようとしても静かに失敗してhistory.length は「開いたページの数」を返していました。

var results = [];    // これで直った

対処は名前を変えるだけです。ただし気づくのに時間がかかります。 エラーが出ないので、コードを疑って探し回ることになります。

ぶつかりやすい名前を挙げておきます。

使わない方がいい名前 本来の意味
history ブラウザの履歴
location いまのURL
name ウィンドウの名前
status ブラウザの状態表示
top / parent / self ウィンドウの参照
event いま起きているイベント

「エラーも出ないのに値がおかしい」ときは、名前の衝突を疑ってください。

やってみる

  1. 記録を積む

    reveal() の中で、両者の札がひらいた直後に1行足します。

    log.push({ turn: turn + 1, mine: myMove, theirs: cpuMove });
    

    まとめて頼むならこう言います。

    claude -p "index.html に対戦の記録を足して。1手ごとに、何手目・自分がどの札をどの的へ・相手がどの札をどの的へ、の3つだけ残して。勝敗は残さないで、記録から数え直せるようにして。画面の下に一覧で出して、記録が空のときは見出しごと隠して。"
    

    「勝敗は残さないで」を必ず入れてください。 言わないと、親切に結果まで書き込まれます。結果を焼き付けた記録は、ルールを変えた瞬間に嘘になります。

  2. セーブに含める

    6-4の save()log: log を入れます。閉じても記録が残ります。 保存の中身へ記録も一緒に書き込む、という意味の1行です。これを忘れると、開き直したときに盤面だけ戻って記録が空になります。

  3. 見る口を作る

    上のコードを render() の中に足します。

  4. 3手打って見る

    open index.html
    

    3手打ってください。画面の下に3行出れば成功です。

  5. 保存する

    git add .
    git commit -m "対戦の記録を残して見返せるようにした"
    git push
    

溜め続けると重くなります

記録は増え続けます。上限を決めてください。

if (log.length > 200) log = log.slice(-200);   // 直近200手だけ残す

slice(-200) は「末尾から200件だけ切り出す」という書き方です。つまり古い記録から順に捨てて、直近の200手だけを持ち続けます。

いまの3手勝負なら関係ありませんが、対戦を何度も繰り返す形にすると効いてきます。localStorage は無限ではありません。

「無限に溜める」は、いつか必ず壊れます。 上限を決めるのは、機能を削ることではなく壊れ方を決めることです。

つまずきどころ

記録が毎回消える

newGame()log = [] にしているので、新しい対戦を始めると消えます。対戦をまたいで残したいなら、別のキーに分けて保存してください。

記録に出る札の名前が変わった

label を変えたのに、記録の中にも label を保存していた場合に起きます。記録には id だけを入れてください。

あとからルールを変えたら、昔の記録の勝敗が変わった

正常です。むしろ狙いどおりです。 勝敗を残さず記録から数え直しているので、新しいルールで判定されます。6-6でルールを変えたあと、昔の記録を数え直して「あのときの引き分けが、いまなら勝ちになる」と確かめられます。

当時の判定も残したいなら、記録とは別に保存してください。同じ場所に混ぜないのが要点です。

記録を見ても何も分からない

出している情報が足りていません。相手の残り手札まで残すと、「なぜその手を選んだか」が追えるようになります。ただし記録が重くなるので、必要になってからで構いません。

開発者ツールで記録を書き換えられた

書き換えられます。localStorage は遊ぶ人の手元にあるので、改変できない前提は置けません。 順位表などを作るなら、そこはサーバー側で持つ必要があります(8章)。

Windows の場合

コードは同じです。

次の一歩

5回とも「弱い札から順に出して」記録を見てください。

そして次に、強い札から順に出した記録と見比べてください。同じ相手なのに結果が違います。

その差が、次の術で測るものです。 記録があると「たまたまか、いつもか」を確かめられます。ここまで来て、ようやくバランスの話ができます!

ここまでの三つの的

この講座は、見本ゲーム「三つの的」を術ごとにすこしずつ育てながら進みます。下にあるのはこの術を終えた時点の実物です。そのまま遊べます。

別の画面でこのゲームを開く

術の一覧へ戻る

メルマガ(無料)忖度なしのAI事情を、メールでお届けします。ご登録の方に、プレゼントをお渡ししています