マガジン

「棋譜」を作り込む:記録・表示・再生

遊びをつくる

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

机上の二つの端末の間に、小さなゲームの駒と記録カードが並ぶイラスト。
記事のテーマをもとに制作したAI生成イメージ。実際のゲーム画面ではありません。

対局が終わったあとに「あの一手が分かれ目だったのでは」と見返したくなるゲームは、棋譜(対局の記録)を持っています。プレイヤーが振り返れるだけでなく、作り手にとっても、バグ報告の再現や難易度調整の手がかりになります。この記事で確かめる問いは、棋譜機能は最低限どこまで作れば「見返せる」と言えるのかです。 結論の骨組みは先に示します。棋譜は「記録」「表示」「再生」の3つに分けると、それぞれが小さく作れます。しかも3つとも、元になるデータは「打った手を順に並べた配列」ひとつです。盤面そのものを毎回保存する必要はありません。 ここでは三目並べを題材に、1つのHTMLファイルで3つを通して作ります。コードは単独で動き、外部ライブラリもサーバーも使いません。オセロやチェスに移すときの判断基準も後半で整理します。

この記事の目次
  1. 棋譜に残すのは盤面ではなく「手」です
  2. 記録・表示・再生を1ファイルで作ります
  3. 手順で動作を確かめます
  4. 作り込みの判断基準と、起こりやすい誤り
  5. 起こりやすい失敗
  6. 次の行動

棋譜に残すのは盤面ではなく「手」です

棋譜の形は、大きく分けて2通りあります。毎手ごとの盤面をまるごと保存する方法と、打った手だけを保存して盤面は必要なときに作り直す方法です。

  • 手だけ、保存するもの:手の並び(例:セル番号の配列)、長所:データが小さい。読みやすい、短所:盤面を見るたびに先頭から適用し直す
  • 盤面の連続、保存するもの:各手のあとの盤面すべて、長所:任意の手数へすぐ飛べる、短所:大きい。手と盤面の食い違いが起きうる
  • 両方、保存するもの:手と、一定間隔の盤面、長所:長い対局でも速い、短所:実装が増える

小さな盤面のゲームなら、手だけを保存する方式から始めるのが無難です。盤面は「0手目から n 手目まで順に適用した結果」と定義できるので、記録と表示のあいだに食い違いが起きません。一方で、1手ごとに複雑な計算が走るゲームでは、先頭からの再適用が重くなります。その場合に初めて「両方」を検討してください。

もう一つ、手の表し方も決めておきます。内部では「セル番号 0〜8」のような扱いやすい数値にし、画面には「A1」「B2」のような人が読める表記を出す、と分けておくと、あとで表示だけを差し替えられます。

記録・表示・再生を1ファイルで作ります

次のコードを kifu.html という名前で保存し、ブラウザで開いてください。3つの機能が入っています。

html
<!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 から導かれる」という一方向の関係を守ると、棋譜と盤面が食い違うバグが入り込みにくくなります。

手順で動作を確かめます

作ったものを、次の順に自分で操作して確かめてください。結果はあなたの画面で判断できるように、期待する動きだけを書きます。

  1. 盤の好きなマスを5つほどクリックして、手を打ちます。手のリストに「○ A1」のような行が、打った順に増えていくことを確認します。
  2. 「◀」を押して、盤面が1手前に戻ることを確認します。リストの太字の行も同時に1つ前へ動くはずです。
  3. リストの途中の行をクリックします。その手を打った直後の盤面になるかを確認します。
  4. 過去の局面でマスをクリックします。何も起きないのが、このコードの仕様です。
  5. 「自動再生」を押します。0手目から約0.6秒ごとに1手ずつ進み、最後の手で止まることを確認します。
  6. 再生が終わった直後にマスをクリックします。続きの手が打てれば、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 にしておくと、あとで形式を変えても読み直せます。