マガジン

保存を最後に実装すると何が起きるか:後付けの保存の難しさ

遊びをつくる

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

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

ゲームがほとんど完成した段階で、「閉じて開いても続きから遊べるようにしたい」と思い立つ場面を考えてみます。コインを数える変数があり、畑のマスは見た目のクラス名で育ち具合を表し、育成の残り時間は setInterval の中の変数が持っている。ここまで保存を一度も意識せずに作っていたとします。保存は「localStorage に書くだけ」に見えても、実際には何を書くのか、どこから読み出すのか、読み出したあと画面をどう戻すのかを、すべて決め直すことになりかねません。 この記事は結果を報告するものではありません。「後付けの保存で、設計のどの部分を変えることになるのか」を、読者が自分のゲームで数えて確かめるための実験の設計書です。核にしたいのは、保存を足す作業で変更が必要になった部分そのものです。変更行数だけでなく、変更の理由を分類して記録すると、自分の設計の癖が見えてきます。

この記事の目次
  1. 問いを測れる形にする
  2. 準備するもの
  3. 手順
  4. 記録の取り方
  5. 結果の読み方
  6. 起こりやすい誤り
  7. 次の行動

問いを測れる形にする

「保存は難しいか」のままでは測れません。次の三つの問いに分けます。

  1. 保存機能を足すために、既存のコードのうち何か所を書き換える必要があったか。
  2. その書き換えは、保存のための「追加」だったか、元の仕組みの「作り直し」だったか。
  3. 保存して復元したあと、元の状態と食い違った項目はいくつあったか。

比べる相手として、同じ仕様のゲームを二通りの作り方で用意します。

  • A(散在版):状態を意識せず、作りやすい形で作ります。コインは変数、畑の育ち具合はDOM要素のクラス名、育成タイマーは関数内の変数で持ちます。
  • B(集約版):状態を state という一つのオブジェクトに集めます。画面は render(state) が state から作り、操作は state を書き換えてから render を呼びます。

AとBのどちらにも、保存は最後まで作らせません。違いは状態の持ち方だけにします。こうすると、「後付けの難しさ」のうち状態の散らばり方が占める分を切り出せます。

題材は小さな畑のゲームを仮定します。3×3の畑に種をまき、水をやり、時間が経つと育ち、収穫するとコインが増えます。道具は「種」「水」「収穫」の三つから選びます。自分で作りかけのゲームがあれば、それを使っても構いません。その場合はBに相当する書き直し版を別に作り、同じ仕様で比べてください。

準備するもの

  • 同じ仕様の小さなゲームを二つ(AとB)。それぞれ200行以内に収めると、比べやすくなります。
  • バージョン管理(git など)、または保存前後のファイルのコピー。変更箇所の数を後から数えるために使います。
  • 保存要件を一文で書いたメモ。今回は「ブラウザを閉じて開き直しても、コインの数、畑9マスの育ち具合、選択中の道具が同じ」とします。
  • 記録用の表(後述)。
  • ストップウォッチ。作業時間は参考値として扱います。

保存要件は、AとBの実装を始める前に決めておきます。保存を足す直前に初めて要件を見ると、後付けに近い状況を再現できます。ただし、ゲームの仕様を作る段階で保存要件を知っていると、無意識にBのような構造へ寄ってしまいます。Aの実装中は要件を伏せておく、あるいは別の人に実装してもらうのが理想です。

アソバースに投稿する前提なら、保存先も先に確認しておきます。アップロードした作品の中の localStorage/sessionStorage は一時メモリー互換の処理で、クラウドセーブはSDK経由で作品ごとにJSONで128KiBまで使えます。ログイン中のプレイヤーが対象で、URL投稿の場合は作者がSDKを読み込む必要もあります。SDKの具体的な呼び出し方は、現在の案内で確認してください。ここで押さえたいのは、保存の中身が「JSONにできる一つの状態」であれば、保存先が変わっても差し替えるのは入口と出口だけで済むということです。

手順

1. AとBを保存なしで完成させる

操作が仕様どおりに動くところまで作ります。保存のための「ついで」の準備はしません。たとえばBの state に、保存を見越した項目を足してはいけません。

2. 保存すべき値を、コードを見る前に書き出す

保存要件のメモを読み、「何を保存すれば画面を復元できるか」を紙に列挙します。そのあとでコードを読み、実際に状態を持っている場所を洗い出します。この二つの差が、最初の記録になります。

  • 列挙したのにコードのどこにあるか見つからない値
  • コード中にあるのに、列挙から漏れていた値(タイマーの残り時間、選択中の道具、アニメーションの途中経過など)

3. 育成途中の時間をどう扱うか決める

ここが後付けで設計判断が発生しやすい点です。次の二案のどちらにするかを決め、決めるまでにかかった時間と、決めた理由をメモします。

  • 案1:残り秒数を保存し、再開時にそこから数え直す。
  • 案2:保存した時刻を持ち、再開時に現在時刻との差で育ち具合を計算する。

どちらを選んでも構いません。記録したいのは、この判断がゲームの仕様(閉じている間は育つのか止まるのか)に関わることです。保存を足す作業が、保存の話にとどまらず仕様の決定にもなる、という点を確認します。

4. 保存と復元を実装する

AとBそれぞれに、save() と load() を足します。保存先はまず localStorage で構いません。実装中、既存コードに手を入れるたびに、後述の表に1行ずつ追記します。

5. 復元テストを5種類の状況で行う

「保存して、再読み込みして、見た目と挙動が同じか」を、次の5つの状況で確認します。

  1. 何も操作していない初期状態
  2. 種をまいた直後
  3. 育成の途中(水をやった後、育ち切る前)
  4. 収穫して、コインが増えた直後
  5. 保存データが存在しない状態、または空の状態から開く場合

5番目は忘れられがちです。初回プレイでは保存データがなく、load() が空を読む状況になります。

記録の取り方

AとBで同じ項目を記録します。それぞれ3回ずつ(異なる日に作り直す、または別の人が行う)行えると、偶然のばらつきを見分けやすくなります。1回しか行えない場合は、結果を「1回の観察」として扱い、一般化しないでください。

  • 変更した関数の数、単位:個、記録の仕方:保存要件の追加前後の差分から数える
  • 変更した行数、単位:行、記録の仕方:追加と削除を分けて数える
  • 変更の種類、単位:分類、記録の仕方:「追加」(既存を壊さず足した)か「作り直し」(既存の構造を変えた)か
  • 最初の列挙で漏れた状態、単位:個、記録の仕方:手順2の差
  • 復元後に食い違った項目、単位:個、記録の仕方:手順5の各状況で数える
  • 保存形式の決定にかかった時間、単位:分、記録の仕方:手順3の判断を含む

「変更の種類」が最も重要です。行数が少なくても、DOMのクラス名から状態を読む処理を state に置き換えるような変更は、実質的に設計の作り直しです。1か所ごとに「保存のためにだけ足したのか、保存しやすくするために元の仕組みを変えたのか」を判断して付けてください。

食い違いの検出には、保存前の状態をJSONに往復させて差を調べる次の小さなコードが使えます。Bのように状態が一つのオブジェクトなら、そのまま渡せます。Aの場合は、洗い出した値を手でオブジェクトにまとめて渡します。この「まとめる作業」自体が記録の対象です。

js
function roundTrip(s) { return JSON.parse(JSON.stringify(s)); }

function diff(a, b, path) {
  if (a instanceof Map || a instanceof Set || typeof a === "function") {
    return [path + "(型が失われる)"];
  }
  if (typeof a !== "object" || a === null || typeof b !== "object" || b === null) {
    return Object.is(a, b) ? [] : [path];
  }
  const out = [];
  for (const k of Object.keys(a)) out.push(...diff(a[k], b[k], path + "." + k));
  return out;
}

function check(state) {
  try { return diff(state, roundTrip(state), "state"); }
  catch (e) { return ["state(循環参照などで変換できない)"]; }
}

const sample = {
  score: 10,
  items: new Map([["key", 1]]),
  startedAt: new Date(),
  ratio: NaN,
  onDone() {}
};
console.log(check(sample));

ブラウザの開発者ツールのコンソールに貼ると動きます。sample の部分を自分のゲームの状態に置き換えて実行し、報告された項目を表の「食い違い」に転記します。報告が空であっても、画面と一致する保証にはなりません。この関数が調べるのはJSONで失われる値だけで、画面に戻す処理は手順5で目で確かめます。

結果の読み方

数え終えたら、次のように読み分けます。結果を見る前に、自分の予想を書いておくと、判断の癖がわかります。

  • AとBで変更箇所に大きな差がある場合:状態の散らばり方が、後付けの保存の難しさに効いている可能性があります。差の内訳が「作り直し」に偏っているかを確認してください。
  • 差が小さい場合:今回の題材では、状態が元から少なかったのかもしれません。畑の広さ、道具の種類、同時に進むタイマーの数を増やして、もう一度試す価値があります。
  • 漏れた状態が多い場合:列挙の段階で、画面の見た目やタイマーを「状態」と認識できていないことになります。次のゲームでは、作る前に状態の一覧を書くところから始めてください。
  • 食い違いが特定の状況に集中する場合:たとえば状況3(育成途中)だけに集まっていれば、時間の扱いが未整理です。手順3の判断を見直します。
  • Bでも作り直しが発生した場合:状態を集めただけでは足りず、render が state 以外の情報(現在時刻や乱数)にも依存していた可能性があります。

どの場合も、「Bの方が良い」と決めつけるための実験ではありません。自分の作り方が、どの種類の後付けに弱いかを知るための測定です。

起こりやすい誤り

実験の結果をゆがめる誤りと、保存の実装で踏みやすい落とし穴を挙げます。

  • AとBで実装者の経験が違う:Aを先に作った人は、Bのときに仕様を覚えています。順序を入れ替えるか、別の人に頼んでください。
  • 保存要件を先に知ってしまう:Aを作りながら「どうせ保存するから」と変数をまとめてしまうと、Aの意味が薄れます。
  • DOMが状態を持っている:クラス名や入力欄の値を状態の置き場にしていると、保存時にDOMから読み取る処理を足すことになります。復元時には、DOMを書き換える処理がもう一組必要です。
  • 関数内の変数が外から見えない:クロージャ(関数の中に閉じ込められた変数)は、外から読み出せません。保存のために公開すると、構造の変更になります。
  • Map、Set、Date、関数はJSONにそのまま入らない:上のコードで検出できます。配列やプレーンなオブジェクトに変換してから保存し、読み出し時に戻します。
  • 同じオブジェクトを複数の場所が参照している:JSONに往復すると、別々のコピーになります。IDで参照する形に直す必要が出ます。
  • 進行中のタイマーやアニメーション:保存できるのは数値や時刻だけで、setInterval 自体は保存できません。復元時に再開する処理が必要です。
  • 保存形式を一度で固定する:後から項目を増やすと、古い保存データが読めなくなります。保存データに version を持たせ、読み込み時に古い形式を新しい形式へ変換する層を用意するのが、一般的な備えです。アソバースの公式作品でも、古い保存(version1)が新しい形式(version2)へ移行する仕組みが案内されています。個々の作品の中身は、作品を開いて確かめてください。
  • 保存容量とタイミング:クラウドセーブは作品ごとにJSONで128KiBまでです。大きな配列を毎フレーム保存する設計は避け、保存のタイミング(操作ごとか、一定間隔か、離脱時か)を決めて記録に残します。

次の行動

自分のゲームのソースを開き、手順2の「保存すべき値の列挙」だけでも、コードを読む前に紙に書き出してみてください。列挙とコードの食い違いの数が、そのまま後付けの保存の難しさを示す最初の指標になります。余裕があれば、AとBを同じ仕様で作り、表の6項目を埋めてください。次に新しいゲームを作るときは、state の形を最初に決めるかどうかを、その記録をもとに判断できます。