マガジン

「保存」を作り込む:端末保存からSDK経由のクラウド保存まで

遊びをつくる

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

遊んだ記録を示すカード列と、途中の位置を残した迷路盤を並べた写真風イメージ。
記事のテーマをもとに制作したAI生成イラスト。

クリックすると数字が増えるだけの小さなゲームがあるとします。ページを閉じて開き直したとき、数字が残っているでしょうか。残っていたとして、別の端末で開いたときも同じ数字が出るでしょうか。この二つの問いは、保存機能の難しさの別々の層にあたります。前者は「端末に書いて、壊れずに読み戻す」問題で、後者は「持ち主を識別し、複数の端末の間で食い違いを解く」問題です。 この記事では、保存を一度に完成させようとせず、三段階で育てます。段階1では端末保存を壊れにくくします。段階2では保存形式を変えても古いデータを読めるようにします。段階3ではクラウド保存を差し込める形にします。最後に、自分で実行できる確認項目を並べます。 アソバースでは、アップロードしたゲームにSDKが自動で追加されます。URLで投稿したゲームは、作者がSDKを読み込むと、作品専用のプレイヤーIDとクラウドセーブ(作品ごとにJSONで128KiBまで)が使えます。一方で、SDKの関数名や呼び出し方はこの記事では扱いません。そこは現在の案内で確認してください。代わりに、どんな保存先にも対応できる骨組みを先に作ります。

この記事の目次
  1. 土台の考え方:保存先を「差し替え口」にしておく
  2. 段階1:端末保存を壊れにくく作る
  3. 段階2:形式を変えても古い保存を読めるようにする
  4. 段階3:クラウドを差し込む
  5. 自分で確かめるチェックリスト
  6. 次の行動
  7. 参考資料

土台の考え方:保存先を「差し替え口」にしておく

保存機能が育ちにくくなるのは、ゲーム本体のあちこちに保存の呼び出しが散らばるときです。次の三つを分けておくと、後から段階を足しても書き換える場所が少なく済みます。

  • 状態:保存したい中身(回数、最高記録、解放済みステージなど)。
  • 形式:状態を文字列にする規則と、バージョン番号。
  • 保存先:端末、クラウドなど、文字列を置く場所。

アソバースの公式作品を見ると、保存があるのは「余白の庭」「WAVE / WAVE」「かたちのあいだ」の3作品で、他の10作品の記録や録音は画面を開いている間だけです。また、古い保存(version1)は新しい形式(version2)へ移行すると案内されています。保存するものを絞り、形式にバージョンを持たせている点は、設計から読み取れることです。各作品で何が保存されるかは、作品を開いて確かめてください。

段階1:端末保存を壊れにくく作る

最初の段階では、書く・読むを安全にします。次の手順で進めてください。

  1. 保存する値を書き出します。「再開に必要な最小の値」だけにしてください。画面の見た目や、計算で再現できる値は入れません。
  2. 状態の先頭に version を持たせます。形式を変える日は必ず来るので、最初から入れておきます。
  3. 読み書きを local.read / local.write の2関数に閉じ込めます。localStorage が使えない環境では例外が出ることがあるため、try/catch で包みます。
  4. 読み込みは「文字列を JSON に変換→検証→採用」の順にします。失敗したら初期状態で始めます。壊れたデータでゲームが起動しなくなるのが最悪の結果です。
  5. 数値は読み込み時に必ず検証します。負数や数値でない値を受け入れない関数を一つ作ってください。

ここで注意したいのは、アソバースでアップロードした作品の中の localStorage / sessionStorage は、一時メモリーの互換処理だと案内されている点です。端末への永続保存を前提にした設計にしないでください。再読み込みで値が残るかどうかも含めて、自分の作品を実際に開いて確かめる項目にします。ゲストのときの扱いは、現在の案内で確認してください。

段階2:形式を変えても古い保存を読めるようにする

作り込みの核心は、保存形式は必ず変わるという前提を持つことです。たとえば最初は score という1つの値だけを保存していて、後から「現在値」と「最高値」を分けたくなる場面を考えてみます。この変更で version を持っていなければ、古いデータを見分けられません。

次のコードは、この記事の全段階を通した最小構成です。version: 1 は score のみ、version: 2 は count と best を持つという、説明用の仮の形式です。1ファイルのHTMLとして保存して開けば動きます。

html
<button id="add">+1</button> <span id="out"></span>
<script>
const KEY = "mygame-save", DIRTY = KEY + "-dirty", VERSION = 2, LIMIT = 128 * 1024;
let state = { version: VERSION, count: 0, best: 0 };
let cloud = null; // SDKの案内に合わせて read()/write(text) を持つ物を入れる

const local = {
  read(k) { try { return localStorage.getItem(k); } catch { return null; } },
  write(k, t) { try { localStorage.setItem(k, t); } catch {} }
};
function migrate(d) {
  if (!d || typeof d !== "object") return null;
  if (d.version === 1) d = { version: 2, count: d.score, best: d.score };
  if (d.version !== 2) return null;
  const n = v => (Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0);
  return { version: 2, count: n(d.count), best: n(d.best) };
}
function parse(text) { try { return migrate(JSON.parse(text)); } catch { return null; } }
async function pushCloud(text) {
  if (!cloud || new Blob([text]).size > LIMIT) return;
  try { await cloud.write(text); local.write(DIRTY, "0"); } catch {}
}
function save() {
  const text = JSON.stringify(state);
  local.write(KEY, text); local.write(DIRTY, "1");
  pushCloud(text);
}
async function init() {
  state = parse(local.read(KEY)) || state;
  if (cloud && local.read(DIRTY) !== "1") {
    try { state = parse(await cloud.read()) || state; } catch {}
  } else pushCloud(JSON.stringify(state));
  render();
}
function render() { out.textContent = `回数 ${state.count} / 最高 ${state.best}`; }
add.onclick = () => { state.count++; state.best = Math.max(state.best, state.count); save(); render(); };
init();
</script>

読み込みの流れは parse → migrate の2段です。migrate は古い形式を最新形式へ変換する関数で、形式を増やすたびに if (d.version === 2) の分岐を足していきます。保存時は常に最新形式で書くため、読み込みのたびに少しずつデータが新しくなります。

移行関数を足すときは、次の点を守ってください。

  1. 古い形式のサンプル文字列を、テスト用に手元へ残します。
  2. 変換後の値も、必ず検証関数を通します。
  3. 知らない version(未来の番号)は採用せず null を返します。上書き事故を防ぐためです。

段階3:クラウドを差し込む

コードの cloud は、最初は null です。この状態では、ゲームは端末保存だけで動きます。クラウド保存を使えるのは、アソバースではログイン中が前提です。SDKが提供する読み書きを cloud.read()(文字列または null を返す)と cloud.write(text)(失敗時に例外を投げる)という形で包んで代入します。ここは、SDKの案内に書かれた実際の呼び出し方に合わせて書き換えてください。包む関数の中身を創作せず、案内を見ながら書くのが確実です。

守るべきルールは次のとおりです。

  1. サイズを事前に測ります。コードの LIMIT は、作品ごとにJSONで128KiBまでというクラウドセーブの上限です。Blob でバイト数を測り、超える場合は送りません。文字数ではなくバイト数で測るのがポイントです。日本語は1文字が複数バイトになります。
  2. 送信が成功してから「未同期」の目印(DIRTY)を下ろします。write が例外を投げる前提にしているのはこのためです。
  3. 起動時に未同期の目印が残っていれば、端末の内容をクラウドへ再送します。残っていなければクラウドの内容を採用します。アソバースの案内でも、未同期の端末セーブはクラウドの古いデータより優先して再送するとされています。同じ考え方をゲーム側にも持たせると、通信が切れていた間の進行が消えません。
  4. ゲームにはFirebaseのUID・認証トークン・メールアドレスを渡さない前提で作ります。ゲーム側が識別に使うのは、SDKが提供する作品専用のプレイヤーIDまでです。

もう一つ、設計判断が必要な場面があります。ゲストとして遊んだ進行が端末内にあり、その後ログインした場合です。案内では、ゲストの履歴・進行は端末内に保存され、ログイン後のアカウントへ自動では混ざらないとされています。ゲーム側でも、混ぜるのか、どちらかを選ばせるのかを決めておいてください。迷うなら、片方を黙って上書きせず、両方の概要(回数や最高記録など)を見せて選ばせる設計が安全です。

自分で確かめるチェックリスト

結果を想像で済ませず、自分の環境で一つずつ実行して記録してください。各項目は1回ずつで構いません。結果は「期待どおり/違った」の二択でメモします。

  • 再読み込みで残るか、やり方:ボタンを数回押し、ページを再読み込みする、違ったときの見直し先:local の読み書き
  • 壊れたデータで起動するか、やり方:開発者ツールで保存値を {broken に書き換えて再読み込み、違ったときの見直し先:parse の例外処理
  • 古い形式を読めるか、やり方:保存値を {"version":1,"score":5} に書き換えて再読み込み、違ったときの見直し先:migrate
  • 負数や文字列が混ざったとき、やり方:count を -3 や "abc" に書き換えて再読み込み、違ったときの見直し先:検証関数 n
  • 未来の version、やり方:version を 3 にして再読み込み、違ったときの見直し先:migrate の末尾の拒否
  • 大きなデータ、やり方:配列を足して128KiB超の状態を作って保存、違ったときの見直し先:LIMIT の判定
  • 通信失敗、やり方:cloud.write を常に例外にして押す→戻して再読み込み、違ったときの見直し先:DIRTY の扱い

見るポイントは、どの異常でもゲームが起動し、画面に値が出ることです。値が初期状態に戻るのは仕様どおりで、起動しないことが問題です。

実際のアソバース上では、アップロードした作品として公開する前に、プレビュー表示で再読み込みを試してください。ログインの有無、別の端末、プレイ画面の拡大・言語変更など、セーブが残るかを確認する場面は多くあります。公式作品の共通仕様として、言語変更とプレイ画面の拡大では進行が保たれると案内されています。自作でも、同じ操作で進行が消えないかを試しておくと安心です。

起こりやすい誤りも挙げておきます。

  • 保存のたびに状態全体を作り直し、新しく足した項目が古い保存から抜け落ちる。
  • 検証せずに読み込み、NaN が画面に出る。
  • 通信の成否にかかわらず未同期の目印を下ろし、失敗した進行が消える。
  • 保存形式を変えたのに version を上げ忘れる。

次の行動

まず上のコードを1ファイルで保存し、チェックリストの最初の4項目を実行してください。次に、自分のゲームで保存すべき最小の値を3つ書き出し、version: 1 の形式を決めます。クラウドを使う段階では、アソバースの現在の案内でSDKの読み書きの呼び出し方を確認し、cloud に包んで接続してください。

参考資料

  • MDN Web Docs「Web Storage API」
  • アソバースの作品投稿に関する現在の案内(SDK・クラウドセーブ)