「一手戻す」を作り込む:履歴・やり直し・上限の設計
遊びをつくる執筆にClaudeを使用しています。

パズルやボードゲームを作っていて、「一手戻す」ボタンを付けようとする場面を考えてみます。最初は「前の状態に戻すだけ」と思えます。ところが実装を始めると、次々と問いが出てきます。戻したあとに別の手を打ったら、戻す前の手はどうなるのでしょうか。何手まで戻せるようにするのでしょうか。手数やヒントの回数も一緒に戻すのでしょうか。 この記事では、これらの問いを段階的に解いていきます。まず十数行の最小実装を作り、次にやり直し(redo)と上限を足し、最後に「何を戻して何を戻さないか」を決めます。題材は「+1を押すだけのカウンター」です。ゲームの盤面に置き換えやすい最小の例なので、自作ゲームの状態に読み替えながら進めてください。 アソバースの公式作品にも、取り消しの機能を持つものがあります。たとえば「かたちのあいだ」の「一手戻す」は、手数は減らない仕様です。「余白の庭」には、Z/Yで操作する戻す/やり直すがあります。最後に、これらを手がかりに設計の違いを考えます。
手順1:スナップショットを積む最小実装
最も単純な方法は、手を打つ直前の状態を丸ごと配列に積み、戻すときに取り出す方式です。この「状態の写し」をスナップショットと呼びます。
let state = { n: 0 };
const undoStack = [];
function commit(next) {
undoStack.push(state);
state = next;
}
function undo() {
if (undoStack.length === 0) return;
state = undoStack.pop();
}ポイントは commit の使い方です。手を打つときは、state.n += 1 のように今の状態を直接書き換えてはいけません。commit({ n: state.n + 1 }) のように、新しい状態を作って渡します。直接書き換えると、配列に積んだスナップショットも同じ物を指しているため、戻したときに内容がおかしくなります。
盤面が配列やオブジェクトの入れ子なら、新しい状態を作るときにコピーが必要です。ブラウザ標準の structuredClone(state) で深いコピーが作れます。コピーを忘れて履歴が壊れる失敗は、取り消し機能で最も起こりやすい誤りです。
手順2:やり直しと上限を足す
戻したあとに「やっぱり進めたい」と思う場面は頻繁にあります。そこで、戻した状態を別の配列に取っておきます。ここでは戻せる履歴を past、進み直せる履歴を future と呼びます。
動きの規則は三つだけです。
- 手を打つ(commit):今の状態を past に積み、future を空にする。
- 戻す(undo):今の状態を future に積み、past から取り出して今の状態にする。
- やり直す(redo):今の状態を past に積み、future から取り出して今の状態にする。
規則1で future を空にする点が重要です。戻したあとに別の手を打ったら、もとの流れには戻れません。分岐した履歴を残すと管理が複雑になるので、まずは捨てる設計が安全です。
上限も同時に入れます。past が上限を超えたら、最も古いものを shift() で捨てます。以下が、ブラウザで開いて動かせる完成形です。上限は動作を確かめやすいよう5にしています。
<button id="add">+1</button>
<button id="bu">一手戻す</button>
<button id="br">やり直す</button>
<p id="view"></p>
<script>
const LIMIT = 5;
let state = { n: 0, moves: 0 };
const past = [];
let future = [];
const $ = (id) => document.getElementById(id);
function commit(next) {
past.push(state);
if (past.length > LIMIT) past.shift();
future = [];
state = next;
render();
}
function undo() {
if (!past.length) return;
future.push(state);
state = past.pop();
render();
}
function redo() {
if (!future.length) return;
past.push(state);
state = future.pop();
render();
}
function render() {
$("view").textContent = `値 ${state.n} / 手数 ${state.moves} / 戻せる ${past.length} / 進める ${future.length}`;
$("bu").disabled = !past.length;
$("br").disabled = !future.length;
}
$("add").onclick = () => commit({ n: state.n + 1, moves: state.moves + 1 });
$("bu").onclick = undo;
$("br").onclick = redo;
document.addEventListener("keydown", (e) => {
if (e.key === "z") undo();
if (e.key === "y") redo();
});
render();
</script>このコードを1つの .html ファイルとして保存し、ブラウザで開けば動きます。外部ライブラリは使っていません。
render の中でボタンの disabled を切り替えているのは、「もう戻れない」状態を利用者に見せるためです。上限で古い履歴が消えたとき、ボタンが押せなくなる挙動が、その合図になります。
手順3:何を戻して何を戻さないかを決める
最小実装で動くようになったら、設計の本題に入ります。状態に入れたものは、すべて戻ります。逆に、状態の外に置いたものは戻りません。この性質を使って、項目ごとに「戻す」「戻さない」を決めます。
上のコードでは moves(手数)を state の中に入れたので、戻すと手数も減ります。手数を戻さない仕様にしたいなら、moves を state の外の変数に出します。公式作品の「かたちのあいだ」は、一手戻しても手数は減らない仕様です。この設計から読み取れるのは、戻す操作そのものを一手として数える考え方です。戻して試し直すことを、無料の巻き戻しにしない設計だと考えられます。
判断のしかたを表にまとめます。
- 手数・スコア、戻す場合:「なかったこと」にできて気軽に試せる、戻さない場合:戻すことにコストが生まれ、慎重さが残る
- ヒントの使用回数、戻す場合:戻すとヒントが使い放題になりやすい、戻さない場合:回数制のヒントが成り立つ
- 経過時間、戻す場合:時間制限の意味が薄れる、戻さない場合:制限のあるゲームでは戻さないのが自然
- 乱数の結果、戻す場合:戻して引き直せてしまう、戻さない場合:結果を固定すれば引き直しを防げる
特に乱数は落とし穴です。手を打つたびに乱数で敵の動きや新しいタイルの位置を決めるゲームでは、戻して打ち直すたびに結果が変わります。これを防ぎたいなら、乱数の結果を状態に含めるか、シード(乱数の元になる数値)を状態に持たせます。どちらにするかは、「戻して引き直す」ことをゲームの遊びとして許すかどうかで決まります。
保存との関係も決めておきます。アップロード作品内の localStorage は、アソバースでは一時メモリーの互換処理として扱われます。作品ごとのクラウドセーブは、JSONで128KiBまでです。履歴まで含めて保存すると、サイズが膨らむおそれがあります。現在の状態だけを保存し、再開時は履歴を空から始める設計が、まず取りやすい選択です。
対戦でも扱いが変わります。公式作品の「潮目のオセロ」では、ヒントと一手戻すはひとり対局のみです。相手のいる対戦では、自分だけが手を取り消す操作の扱いが難しくなるため、機能を使える範囲を分けるのは自然な判断と言えます。それぞれの作品で細部がどうなっているかは、作品を開いて確かめてください。
手順4:壊れ方を確かめる
作り終えたら、次の順で動作を確認します。ここに書いたのは確認の手順です。結果は、実際に操作して見てください。
- 「+1」を3回押してから「一手戻す」を3回押し、値と手数がどうなるかを見る。
- 続けて「やり直す」を2回押し、値が期待どおりか確かめる。
- やり直せる状態で「+1」を押し、「やり直す」が押せなくなることを確かめる(分岐した履歴を捨てる動作)。
- 「+1」を上限より多く、たとえば10回押し、「一手戻す」を押せるだけ押す。何回戻ったところでボタンが止まるかを数える。上限の設定値と一致するかを見る。
- 履歴がない最初の状態で、Zキーを押しても何も壊れないかを見る。
4の結果は、上限の仕様を決める材料になります。戻れる回数が少なすぎると感じたら、LIMIT を増やします。ただし増やすほど、保持するスナップショットも増えます。盤面が小さいオセロ程度の状態なら、全体を丸ごと保存する方式で十分なことが多いでしょう。状態が大きいゲームでは、スナップショットではなく「手」の情報(どこに何を置いたか)だけを積み、逆の操作で戻す方式を検討します。この方式は実装が難しくなるため、状態が小さいうちはスナップショット方式のままで構いません。
よく起こる誤りを三つ挙げます。
- 状態を直接書き換えて、履歴まで変わってしまう(手順1のコピーの問題)。
- 戻す・進むの操作中に描画を更新し忘れ、画面と内部状態がずれる。
- アニメーションの途中で戻す操作を受け付け、状態が中途半端になる。アニメーション中は入力を止めるか、先に完了させる設計にします。
次の行動
まず上のHTMLを保存して開き、手順4の1〜5を順に試してみてください。そのうえで、自作ゲームの状態を一覧にして、項目ごとに「戻す」「戻さない」を決めてみましょう。決めたら、LIMIT の値と、上限に達したときの見せ方(ボタンの無効化など)も合わせて決めます。公式作品の「かたちのあいだ」「余白の庭」「潮目のオセロ」を開いて、取り消しの挙動を見比べるのも参考になります。

