マガジン

一手戻すはどう実装されているか:状態を積む考え方

遊びをつくる

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

迷路の一角から出口までを段階的に照らす三つの灯りと、小さな旅人の駒
記事のテーマをもとに制作したAI生成イラスト。

パズルで間違えた手を「一手戻す」で取り消す場面を考えてみます。ボタンを押すと盤面が一つ前に戻ります。続けて「やり直す」を押すと、取り消した手が復活します。ところが、戻したあとで別の手を打つと、やり直すは押せなくなります。ゲームを作る人なら、この三つの挙動が同じ仕組みから出ていることに気づけると便利です。 この仕組みの中心は、履歴を入れる「二つの山」です。山は、最後に入れたものを最初に取り出す入れ物(スタック)のことです。一つは戻すための山、もう一つはやり直すための山です。この記事では、この二つの山を小さなコードで組み立て、何を積むべきかの判断基準まで整理します。 公式作品の内部実装は、公開されている確認済みの情報には含まれていません。そのため、作品については「仕様から読み取れること」にとどめます。内部の作りは、作品を開いて挙動で確かめる方法を示します。

この記事の目次
  1. 履歴は状態を積んだ山です
  2. 山が二つ必要な理由
  3. 最小の実装を動かして確かめる
  4. 何を積むか:丸ごとか、手だけか
  5. 公式作品の仕様から読み取れること
  6. 次の行動

履歴は状態を積んだ山です

一手戻すの一番素朴な作り方は、手を打つ直前の状態を保存しておくことです。三目並べなら、打つ前の九マスの配列を一つ保存します。もう一手打つ前にも保存します。保存したものを重ねたものが、戻すための山です。

戻す操作は、山の一番上を取り出して、それを現在の盤面にするだけです。山は後入れ先出しなので、直前の状態から順に過去へさかのぼれます。「何手前まで戻れるか」は、山に何個入っているかで決まります。

この考え方には、見落としやすい前提があります。山に入れるのは「手」ではなく「状態」です。手は「ここに置いた」という出来事で、状態は「置いたあとの盤面全体」です。何を入れるかで実装の難しさが変わります。これは後半で比べます。

山が二つ必要な理由

戻す山だけでも、一手戻すは作れます。しかし「やり直す」を付けたとたん、戻した瞬間の盤面をどこかに退避する必要が出てきます。退避先がもう一つの山です。

動きを整理すると、次の三つです。

  • 新しい手を打つとき、現在の状態を戻す山に積み、新しい状態を現在にします。やり直す山は空にします。
  • 戻すとき、現在の状態をやり直す山に積み、戻す山の一番上を現在にします。
  • やり直すとき、現在の状態を戻す山に積み、やり直す山の一番上を現在にします。

「新しい手でやり直す山を空にする」という規則が、冒頭の「別の手を打つとやり直せなくなる」挙動の正体です。戻したあとで別の手を打つと、時間の流れが枝分かれします。取り消した側の未来は、もう現在の続きではありません。そこで、やり直す山を空にして矛盾を防ぎます。

テキストエディタの取り消しとやり直しも、多くが同じ考え方です。一手戻すを作れる人は、やり直すも少しの追加で作れます。

最小の実装を動かして確かめる

次のコードは、外部ライブラリなしで動く最小構成です。ブラウザの開発者ツールのコンソールか、Node.jsに貼り付けて実行できます。

js
function createHistory(initial) {
  let present = initial;
  const undoStack = [];
  const redoStack = [];
  return {
    get state() { return present; },
    push(next) {
      undoStack.push(present);
      present = next;
      redoStack.length = 0; // 新しい手で未来を捨てる
    },
    undo() {
      if (undoStack.length === 0) return false;
      redoStack.push(present);
      present = undoStack.pop();
      return true;
    },
    redo() {
      if (redoStack.length === 0) return false;
      undoStack.push(present);
      present = redoStack.pop();
      return true;
    },
  };
}

const h = createHistory({ board: [0, 0, 0] });
function put(index, value) {
  const board = h.state.board.slice(); // 配列をコピーする
  board[index] = value;
  h.push({ board });
}

put(0, 1);
put(1, 2);
h.undo();
console.log(h.state.board); // 期待: [1, 0, 0]
h.redo();
console.log(h.state.board); // 期待: [1, 2, 0]
h.undo();
put(2, 3);                  // 戻した後の新しい手
console.log(h.redo());      // 期待: false
console.log(h.state.board); // 期待: [1, 0, 3]

コメントの「期待」は、手でたどった場合の値です。実際に動かして、表示が一致するか確かめてください。一致しなければ、どの行で山の中身がずれたかを、undoStack と redoStack を console.log で出して追うと原因が見つかります。

この形で特に起こりやすい誤りは、次の三つです。

  • 配列やオブジェクトをコピーせずに積む:put の中の slice() を外してみてください。山の中のすべての状態が同じ配列を指すため、戻しても盤面が変わらなくなります。参照が共有されることが原因です。
  • やり直す山を空にし忘れる:redoStack.length = 0 を消すと、別の手を打ったあとに、無関係な過去の未来へ飛べてしまいます。盤面として矛盾した状態になります。
  • 山が無限に伸びる:長く遊べるゲームでは、山の個数に上限を決めるかどうかの判断が必要です。上限を超えたら一番古い状態を捨てるのが一般的です。

何を積むか:丸ごとか、手だけか

山に入れるものは、大きく二通りあります。

  • スナップショット、積むもの:手の前後の盤面全体、戻す方法:保存した盤面に置き換える、向く場面:盤面が小さい、実装を単純にしたい
  • 差分(手の記録)、積むもの:「何がどう変わったか」、戻す方法:逆の操作を適用する、向く場面:盤面が大きい、状態が重い

スナップショットは、先のコードの方式です。オセロなら、盤面の石の配置と手番を丸ごと保存します。一手で複数の石が返るため、「どの石が返ったか」を逆算する必要がなく、置き換えるだけで戻せます。

差分方式は、オセロなら「置いた場所と、返った石の座標の一覧」を積みます。戻すときは、置いた石を消し、返った石を元の色に戻します。保存する量は少なくて済みますが、逆操作を正しく書く必要があります。逆操作が一か所でも漏れると、戻すたびに盤面がずれていきます。

迷ったときの判断基準は次のとおりです。

  1. 盤面が数十マス程度で、一手ごとの変化が大きいなら、スナップショットから始めます。
  2. 保存すると重い状態(大量のオブジェクトや長い録音データなど)を持つなら、差分を検討します。
  3. 乱数や経過時間のように、手とは別に変わる値は、何を戻すかを先に決めます。たとえば、制限時間のあるゲームでは、一手戻しても時計を巻き戻すかどうかは設計の判断です。

もう一つ、見落としやすい点があります。「戻す対象に含めないもの」を決めておくことです。手数、スコア、ヒントの使用回数のような値を山に含めると、戻すたびにそれらも減ります。含めなければ減りません。どちらが正しいかは、ゲームの意図で決まります。

公式作品の仕様から読み取れること

アソバースの公式作品には、取り消しの機能を持つものが複数あります。確認済みの仕様の範囲で、設計から読み取れることを整理します。内部の実装方式は確認できていないため、以下はあくまで仕様からの推論です。

かたちのあいだ:一手戻すがあり、手数は減らないと案内されています。仮に手数が「戻す山の個数」から数えられているなら、戻すたびに手数も減るはずです。減らないということは、手数が履歴の長さとは別の値として管理されている、と読み取れます。前節で触れた「戻す対象に含めないもの」の例にあたります。ただし、現在の手数が何を数えているかは、作品を開いて確かめるのが確実です。

余白の庭:戻す/やり直すがあり、キーはZとYです。戻すとやり直すが対になっているので、先の二つの山の考え方がそのまま当てはまりそうな機能です。置ける数の最大が80個という仕様は、庭の状態の大きさに上限があることを示します。履歴の山の上限が決まっているかどうかは、案内にありません。

WAVE / WAVE、プリズムの道、月夜の配達、灯りのチェス:それぞれ「取り消し」「戻す」「一歩戻る」「一手戻す」を備えています。月夜の配達は燃料が有限のパズルですが、一歩戻れるという仕様から、戻したときに燃料を含めて直前の状態に戻る作りかどうかを、作品を開いて確かめられます。灯りのチェスは取った駒と全棋譜が表示されます。戻したときに棋譜の表示がどう変わるかを見れば、手の列の扱いのヒントが得られます。ただし、手の列から盤面を作り直しているのか、盤面そのものを保存しているのかは、画面からは分かりません。

潮目のオセロ:ヒントと一手戻すはひとり対局のみで、友達とのオンライン対局では使えません。一方で、オンライン対局では手番・合法手・勝敗をサーバーで確認することが確認済みです。仕様が明記しているのは「ひとり対局のみ」という点までで、理由は書かれていません。考えられる理由としては、両者の端末とサーバーで状態を一致させている対局では、片方だけが履歴を巻き戻すと、手番や合法手の判定が食い違う可能性があることです。これは一般論から考えた推測で、実際の判断理由は案内にありません。

次の手順で、作品を開いて自分で確かめられます。

  1. 3手進めてから3回戻し、続けて3回やり直します(やり直すがある作品の場合)。最初の盤面と最後の盤面が一致するかを見ます。
  2. 2手戻した状態で、別の手を打ちます。そのあとやり直すが押せなくなる、または何も起こらなくなるかを見ます。これが二つ目の山を空にする規則に当たります。
  3. 戻す前後で、手数・燃料・点数のような数値がどう動くかを記録します。戻る値と戻らない値が、設計の判断を表しています。
  4. 保存のある作品(余白の庭、WAVE / WAVE、かたちのあいだ)では、いくつか操作したあとに再読み込みして、戻すが使えるか確かめます。保存されるのが現在の状態だけなのか、履歴まで含むのかは、案内にないため、ここで見分けます。保存のない10作品では、記録や録音が画面を開いている間だけ残る仕様です。

次の行動

まず、先のコードをコンソールに貼って動かし、期待の値と一致するか確かめてください。そのあと、slice() を外す、redoStack.length = 0 を消す、のどちらかを試して、どんな不具合になるかを見てみましょう。次に、自分のゲームで「戻す対象に含めない値」を一つ決め、手数や点数を山の外に出す形に変えてみてください。