「棋譜」を作り込む:記録・表示・再生
遊びをつくる執筆にClaudeを使用しています。

対局が終わったあとに「あの一手が分かれ目だったのでは」と見返したくなるゲームは、棋譜(対局の記録)を持っています。プレイヤーが振り返れるだけでなく、作り手にとっても、バグ報告の再現や難易度調整の手がかりになります。この記事で確かめる問いは、棋譜機能は最低限どこまで作れば「見返せる」と言えるのかです。 結論の骨組みは先に示します。棋譜は「記録」「表示」「再生」の3つに分けると、それぞれが小さく作れます。しかも3つとも、元になるデータは「打った手を順に並べた配列」ひとつです。盤面そのものを毎回保存する必要はありません。 ここでは三目並べを題材に、1つのHTMLファイルで3つを通して作ります。コードは単独で動き、外部ライブラリもサーバーも使いません。オセロやチェスに移すときの判断基準も後半で整理します。
棋譜に残すのは盤面ではなく「手」です
棋譜の形は、大きく分けて2通りあります。毎手ごとの盤面をまるごと保存する方法と、打った手だけを保存して盤面は必要なときに作り直す方法です。
- 手だけ、保存するもの:手の並び(例:セル番号の配列)、長所:データが小さい。読みやすい、短所:盤面を見るたびに先頭から適用し直す
- 盤面の連続、保存するもの:各手のあとの盤面すべて、長所:任意の手数へすぐ飛べる、短所:大きい。手と盤面の食い違いが起きうる
- 両方、保存するもの:手と、一定間隔の盤面、長所:長い対局でも速い、短所:実装が増える
小さな盤面のゲームなら、手だけを保存する方式から始めるのが無難です。盤面は「0手目から n 手目まで順に適用した結果」と定義できるので、記録と表示のあいだに食い違いが起きません。一方で、1手ごとに複雑な計算が走るゲームでは、先頭からの再適用が重くなります。その場合に初めて「両方」を検討してください。
もう一つ、手の表し方も決めておきます。内部では「セル番号 0〜8」のような扱いやすい数値にし、画面には「A1」「B2」のような人が読める表記を出す、と分けておくと、あとで表示だけを差し替えられます。
記録・表示・再生を1ファイルで作ります
次のコードを kifu.html という名前で保存し、ブラウザで開いてください。3つの機能が入っています。
<!doctype html>
<meta charset="utf-8">
<div id="board" style="display:grid;grid-template-columns:repeat(3,60px);gap:4px"></div>
<p><button id="prev">◀</button> <button id="next">▶</button> <button id="play">自動再生</button></p>
<ol id="log"></ol>
<script>
const moves = []; // 記録: セル番号(0-8)を打った順に並べる
let cursor = 0; // 表示中の手数
let timer = null;
const mark = i => (i % 2 === 0 ? "○" : "×");
const cellName = c => "ABC"[c % 3] + (Math.floor(c / 3) + 1);
function boardAt(n) { // 再生の核: 先頭からn手だけ適用
const b = Array(9).fill("");
for (let i = 0; i < n; i++) b[moves[i]] = mark(i);
return b;
}
function render() {
board.innerHTML = "";
boardAt(cursor).forEach((v, c) => {
const d = document.createElement("button");
d.textContent = v; d.style.height = "60px";
d.onclick = () => put(c);
board.append(d);
});
log.innerHTML = "";
moves.forEach((c, i) => {
const li = document.createElement("li");
li.textContent = mark(i) + " " + cellName(c);
li.style.cursor = "pointer";
if (i + 1 === cursor) li.style.fontWeight = "bold";
li.onclick = () => { cursor = i + 1; render(); };
log.append(li);
});
}
function put(c) {
if (cursor !== moves.length) return; // 過去の局面では打てない
if (boardAt(cursor)[c] !== "") return; // 埋まったマスは不可
moves.push(c); cursor = moves.length; render();
}
prev.onclick = () => { cursor = Math.max(0, cursor - 1); render(); };
next.onclick = () => { cursor = Math.min(moves.length, cursor + 1); render(); };
play.onclick = () => {
clearInterval(timer); cursor = 0; render();
timer = setInterval(() => {
if (cursor >= moves.length) return clearInterval(timer);
cursor++; render();
}, 600);
};
render();
</script>三目並べとしての勝敗判定は入れていません。棋譜の仕組みだけを見やすくするためです。
3つの機能の対応
- 記録:put が moves に手を積みます。ここで合法手かどうかを確認してから積むのが原則です。
- 表示:render が、盤面と手のリストを cursor の位置に合わせて描き直します。
- 再生:boardAt(n) が先頭から n 手だけを適用します。「戻る」「進む」「自動再生」「リストの手をクリック」は、すべて cursor を動かして render を呼ぶだけです。
「盤面は moves と cursor から導かれる」という一方向の関係を守ると、棋譜と盤面が食い違うバグが入り込みにくくなります。
手順で動作を確かめます
作ったものを、次の順に自分で操作して確かめてください。結果はあなたの画面で判断できるように、期待する動きだけを書きます。
- 盤の好きなマスを5つほどクリックして、手を打ちます。手のリストに「○ A1」のような行が、打った順に増えていくことを確認します。
- 「◀」を押して、盤面が1手前に戻ることを確認します。リストの太字の行も同時に1つ前へ動くはずです。
- リストの途中の行をクリックします。その手を打った直後の盤面になるかを確認します。
- 過去の局面でマスをクリックします。何も起きないのが、このコードの仕様です。
- 「自動再生」を押します。0手目から約0.6秒ごとに1手ずつ進み、最後の手で止まることを確認します。
- 再生が終わった直後にマスをクリックします。続きの手が打てれば、cursor が末尾に戻っています。
どれかが期待と違うときは、cursor を更新したあとに render() を呼び忘れていないかをまず疑ってください。
作り込みの判断基準と、起こりやすい誤り
最小構成が動いたら、次の4点を順に検討します。すべてを入れる必要はありません。ゲームの性質で選んでください。
過去の局面で打ったらどうするか
上のコードは禁止にしています。実際には、次の3つの選択肢があります。
- 禁止する(最も単純。誤操作で棋譜が壊れません)。
- その手以降を切り捨てて打ち直させる(「一手戻す」に近い体験になりますが、切り捨てた手は失われます)。
- 分岐として別の棋譜を持つ(検討用の機能。データは木構造になり、実装が一気に重くなります)。
プレイヤーが「対局の記録」を求めているのか「検討」を求めているのかで選びます。公式作品でいえば「潮目のオセロ」には、ひとり対局で使える「一手戻す」があります。これは「戻って打ち直す」体験で、棋譜を見返す機能とは目的が別です。両方を持つ場合は、戻したときに棋譜を切り詰めるのか、別に残すのかを決めておく必要があります。
終局の判定と、棋譜の締めくくり
棋譜には、勝敗や終局の理由(詰み、投了、引き分けなど)を最後に1行添えると、見返したときに意味が通ります。「灯りのチェス」は、標準の終局判定に対応し、取った駒と全棋譜を表示します。また判定エンジンとして chess.js を同梱しています。ここから読み取れるのは、盤面のルール判定は専用のライブラリに任せ、画面側は結果を並べる役に徹する、という分担の仕方です。自作ゲームでも、ルール判定と棋譜表示を別の部品にしておくと、あとで差し替えやすくなります。細かな表示の見え方は、作品を開いて確かめてください。
保存するかどうか
moves は画面を閉じれば消えます。保存したいなら、手の配列を JSON にして残すのが素直です。たとえば {"version":1,"moves":[4,0,8]} のように、形式の版を示す項目を付けておきます。あとで手の表し方を変えても、古い記録を新しい形式へ移せるからです。
アソバースでの保存先について、確認済みの範囲で整理します。
- アップロードしたゲーム内の localStorage / sessionStorage は、一時メモリーの互換処理です。永続保存の手段として頼らないでください。
- ログイン中のクラウドセーブは、作品ごとに JSON で 128KiB までです。SDK はアップロード作品には自動で追加され、URL 投稿のゲームでは作者が読み込む必要があります。呼び出し方は現在の案内で確認してください。
- ゲストの進行は端末内に保存され、ログイン後のアカウントへは自動では混ざりません。
128KiB は 131,072 バイトです。1手を数字とカンマで約2バイトとして単純に割れば、棋譜だけで数万手を入れられる計算になります。ただし他の保存項目と同居するので、実際の余裕は自分のデータで見積もってください。サイズを気にするより、版番号と読み込み失敗時の扱いに時間を使うほうが実用的です。
再生の速さと操作
自動再生は、速さを選べると見返しやすくなります。0.6秒はあくまで例なので、手ごとの意味が重いゲームでは間隔を広げます。「動きを抑える」設定が有効な利用者には、自動再生を最初から止め、手動で進めるようにするのも配慮のひとつです。アソバースの公式作品は、この設定に対応しています。
起こりやすい失敗
- 盤面を別に持ってしまう:moves と別に「現在の盤面」を更新し続けると、いつか両者がずれます。盤面は moves から導くと決めてください。
- 不正な手を記録する:合法手かどうかの確認より先に push すると、再生時に盤面が壊れます。確認してから積みます。
- 手の表記を内部と画面で兼ねる:「A1」を内部の値にすると、表示の変更が保存形式の変更になります。内部は数値、画面は表記と分けます。
- 途中のクリックで再生が二重に走る:play を押すたびに clearInterval で前のタイマーを止めているのは、このためです。
次の行動
まず kifu.html を開き、上の6つの手順で動作を確かめてください。次に、自作のゲームの「1手」が何で表せるかを一行で書き出し、その配列から盤面を作り直す関数 boardAt に相当するものを作ってみましょう。保存まで進める場合は、版番号つきの JSON にしておくと、あとで形式を変えても読み直せます。

