なぜ要るか
前の術で「いまの状態」を保存しました。でもそれだけでは、何が起きたかは分かりません。
盤面の最終形から分かるのは結果だけです。どの札を、どの的に、どの順で置いて、そうなったのかは消えています。特にこのゲームでは順番が意味を持ちます。 同じ的に同じ札が並んでいても、先に置いたか後に置いたかで話が変わります。
これを残します。効くのは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つ足す書き方です。myMove と cpuMove はそれぞれ「どの札をどの的へ置いたか」を持っているので、この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 はその箱の中身をまるごと差し替える指定です。
出るとこうなります。
id を label と name に戻して表示しているのが要点です。 記録の中は c5 と t2 ですが、画面には「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トレカアプリには、対戦を再生する機能が入っています。
土台の考え方はいまやったことと同じで、起きたことを順番に積むだけです。そのうえで、本気で作るとこういう形になります。いま作る必要はありませんが、「先に何があるか」を知っておくと設計の迷いが減ります。
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 |
いま起きているイベント |
「エラーも出ないのに値がおかしい」ときは、名前の衝突を疑ってください。
やってみる
記録を積む
reveal()の中で、両者の札がひらいた直後に1行足します。log.push({ turn: turn + 1, mine: myMove, theirs: cpuMove });まとめて頼むならこう言います。
claude -p "index.html に対戦の記録を足して。1手ごとに、何手目・自分がどの札をどの的へ・相手がどの札をどの的へ、の3つだけ残して。勝敗は残さないで、記録から数え直せるようにして。画面の下に一覧で出して、記録が空のときは見出しごと隠して。"「勝敗は残さないで」を必ず入れてください。 言わないと、親切に結果まで書き込まれます。結果を焼き付けた記録は、ルールを変えた瞬間に嘘になります。
セーブに含める
6-4の
save()にlog: logを入れます。閉じても記録が残ります。 保存の中身へ記録も一緒に書き込む、という意味の1行です。これを忘れると、開き直したときに盤面だけ戻って記録が空になります。見る口を作る
上のコードを
render()の中に足します。3手打って見る
open index.html3手打ってください。画面の下に3行出れば成功です。
保存する
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回とも「弱い札から順に出して」記録を見てください。
そして次に、強い札から順に出した記録と見比べてください。同じ相手なのに結果が違います。
その差が、次の術で測るものです。 記録があると「たまたまか、いつもか」を確かめられます。ここまで来て、ようやくバランスの話ができます!
ここまでの三つの的
この講座は、見本ゲーム「三つの的」を術ごとにすこしずつ育てながら進みます。下にあるのはこの術を終えた時点の実物です。そのまま遊べます。
