第6章 ゲーム内容の作り込み/4 本目 / 全 10/約25分

セーブして続きから遊べるようにする

こより

残すのは事実だけ。壊れた保存を読んでも、黙って新しい勝負を始められる形にしてみて。

案内役 こより
この術でできるようになること

閉じても続きから遊べる

なぜ要るか

いまのゲームは、閉じると全部消えます。3手目まで進んでいても、開き直せば1手目からです。

6-2で4手勝負にした瞬間、これが痛くなります。 途中で用事ができたら、その対戦は無かったことになります。

セーブを入れます。サーバーは要りません。 ブラウザに置き場があります。

その置き場が localStorage です。ブラウザが端末の中に持っている小さな保管庫で、名前を付けて文字を入れておくと、タブを閉じても消えません。

使う道具: なし

AIプロンプト

この術の作業をAIへ任せるときは、これをそのまま貼ってください。

index.html に localStorage への保存を足して。保存するのは手札・盤面・何手目かの3つだけで、合計や勝った的の数のような計算できるものは入れないで。版の番号 v を持たせて、読むときは v が違う・形が違う・知らない札が入っている場合は黙って捨てて新しい対戦を始めて。エラーで止めないで。決着したら保存を消して。

手札・盤面・何手目かの部分は、自分のゲームで「無いと困る事実」に置き換えてください。

置き場は4つあります

「セーブ」と聞くと1つのやり方を思い浮かべがちですが、置き場は選べます。 先に全部並べます。

置き場 残る範囲 要るもの 向いている用途
localStorage その端末のそのブラウザ なし 1人用の続き、設定
IndexedDB 同じ(容量が大きい) なし 画像や大量の記録
ファイル書き出し 利用者が置いた場所 なし 手動セーブ、受け渡し
サーバーのDB どの端末からでも サーバー・DB・本人確認 共有、順位表、対戦履歴

上の3つはブラウザの中、いちばん下だけが外です。 この線が今回いちばん大事な区別です。

この術で作るのは localStorage です。でも「なぜそれを選んだか」を言えるようにしておきます。 選択肢を知らずに1つだけ使うのと、比べて選ぶのは別のことです。

ブラウザに置く場合とDBに置く場合の違い

DBはデータベースの略で、自分たちが用意したサーバー側にデータを溜めておく置き場です。遊ぶ人の端末ではなく、こちら側に置くという点がブラウザとの決定的な違いです。

4点で違います。

観点 ブラウザ(localStorage) サーバーのDB
誰のものか 端末のもの。同じ人の別端末は別扱い アカウントのもの。どの端末でも同じ
消えるか 消える。キャッシュ削除、別ブラウザ、シークレットモード 残る。こちらが消すまで
改変できるか できる。 開発者ツールから誰でも書き換えられる サーバー側で守れる。順位表や課金はここが必須
費用と手間 ゼロ。3行で書ける サーバー代・DB代。本人確認の仕組みも要る

「改変できるか」が実務ではいちばん効きます。

自分の続きを保存するだけなら、書き換えられても困りません。困るのは他人と比べる数字です。順位表、勝率、報酬。ここをブラウザに置くと、書き換えた人が1位になります。

つまり判断は単純です。

DBに置くなら「誰のものか」が必要になります

ここが見落とされやすい点です。DBに置くと決めた瞬間、本人確認がセットで必要になります。

「どの端末からでも同じ続きが出る」を実現するには、その人を見分ける必要があるからです。だからログインが要る。だからメールアドレスかアカウントが要る。

このサイト(一忍)自体がその例です。閲覧記録はブラウザに置いていますが、会員の情報は Cloudflare の D1 というデータベースに置いています。だから登録にメールアドレスをもらっています。メールアドレスをもらう理由は、そこにあります。

「DBに保存したい」は、実は「アカウントを作りたい」とほぼ同じ意味です。 ここを分かっていると、8章で公開するときの判断が速くなります。

実例: CNPトレカアプリの対戦記録

僕のCNPトレカアプリでは、対戦の記録をサーバーのDB(MongoDB)に置いています。

https://tcg.kenty.app

形はこうなっています。

ブラウザに置いていたら、どれもできません。 他人に見せられないし、端末を変えたら消えます。

逆に言えば、この4つが要らないなら localStorage で十分です。次の術で記録を扱うので、そこでもう一度出てきます。

いま作るものを決めます

この術では localStorage を使います。理由を明示します。

そして混ぜないことを決めます。

保存するもの 正はどこか
対戦の途中の状態 ブラウザ(端末ごと)
会員の持ち物・購入履歴 サーバー(8章で扱う)

同じ意味のものを2箇所に置かないでください。 両方に持つと、どちらが本当か分からなくなります。移すなら移す、片方を捨てる。併存させるのが一番危ないです。

保存する形を決めます

大事なのは版の番号を入れることです。

var STATE_KEY = 'mitsunomato:state:v1';

function save() {
  localStorage.setItem(STATE_KEY, JSON.stringify({
    v: 1, myHand: myHand, cpuHand: cpuHand, board: board, turn: turn, log: log,
  }));
}

JSON.stringify は、プログラムの中のデータを1本の文字にまとめる変換です。localStorage には文字しか置けないので、この変換を通します。setItem は「この名前でこの文字を置く」という指示で、STATE_KEY がその名前にあたります。

保存されたものを実際に見るとこうなっています。

{
  "v": 1,
  "myHand": ["c1"],
  "cpuHand": ["c2"],
  "board": {
    "t1": { "mine": [{ "card": "c3", "turn": 1 }], "theirs": [{ "card": "c4", "turn": 0 }] },
    "t2": { "mine": [{ "card": "c5", "turn": 0 }], "theirs": [] },
    "t3": { "mine": [], "theirs": [{ "card": "c3", "turn": 1 }] }
  },
  "turn": 2
}

札は id で入っています。 6-1で labelid を分けた効果がここに出ます。表示を「壱」に変えても、セーブデータは壊れません。

そして盤面の札に turn が付いています。 何手目に置いたかを覚えています。いまの判定では使っていませんが、6-6で同点の的の扱いを変えるときに必要になります。

これは偶然ではありません。「あとで要りそうな事実」は、保存の形に先に入れておきます。 保存の形を後から変えると、それまでのセーブデータが全部読めなくなるからです。逆に、その場で計算できるもの(合計、勝った的の数)は入れません。事実は残す、計算結果は残さない。 これが保存の形を決める基準です。

壊れたデータを読んでも落ちないようにする

ここが本題です。セーブは必ず壊れます。

だから読むときに疑います。

function load() {
  try {
    var data = JSON.parse(localStorage.getItem(STATE_KEY));
    if (!data || data.v !== 1) return false;                    // 形が変わったら捨てる
    if (!Array.isArray(data.myHand) || !Array.isArray(data.cpuHand)) return false;
    data.myHand.concat(data.cpuHand).forEach(function (id) { card(id); });  // 知らない札なら投げる
    myHand = data.myHand; cpuHand = data.cpuHand; board = data.board;
    turn = data.turn; log = data.log;
    return true;
  } catch (e) { return false; }                                 // 壊れていたら捨てる
}

出てくる書き方を3つだけ説明します。JSON.parse は保存した文字をデータへ戻す変換です。try { ... } catch (e) { ... } は「中で失敗しても止まらず、catch の側へ来る」書き方で、壊れた文字を読んでも画面が落ちません。Array.isArray は「それが配列になっているか」を確かめる問いです。

false を返したら、新しい対戦を始めます。 エラーを出して止めません。遊びたい人にとって、途中の対戦より「遊べること」が大事です。

知らない札の確認が1行で済んでいるのに注目してください。

data.myHand.concat(data.cpuHand).forEach(function (id) { card(id); });

呼ぶだけで、戻り値を使っていません。 6-1で card() を「見つからなければ投げる」形にしたので、ここは呼ぶだけで検査になります。投げられたら外側の catch が受けて false になります。

片方の判断が、もう片方を短くしています。 「見つからなければ null を返す」形にしていたら、ここで戻り値を1つずつ確かめる必要がありました。設計の判断はこうやって連鎖します。

僕が実際に壊して試した結果です。

壊れたJSONを読ませる          → 手札3枚から。JSエラー0件
版の番号が違う(v: 2)        → 手札3枚から。JSエラー0件
知らない札のid('zzz')       → 手札3枚から。JSエラー0件
手札が配列でない(文字列)     → 手札3枚から。JSエラー0件
正しいデータ                  → 手札2枚。「前回の続きからです」と出る

5つとも意図どおりに動きました。 見ているのは2つで、新しい対戦が始まることJSエラーが1件も出ないことです。エラーが出ていると、その回は動いても次の変更で落ちます。

ここを確かめずに出すと、いつか「開けなくなったゲーム」を配ることになります。しかも壊れたセーブは消せません。 遊ぶ人のブラウザの中にあるからです。

やってみる

  1. 保存する場所と形を決める

    上の save() を足します。まとめて頼むならこう言います。

    claude -p "index.html に localStorage への保存を足して。保存するのは手札・盤面・何手目かの3つだけで、合計や勝った的の数のような計算できるものは入れないで。版の番号 v を持たせて、読むときは v が違う・形が違う・知らない札が入っている場合は黙って捨てて新しい対戦を始めて。エラーで止めないで。決着したら保存を消して。"
    

    「エラーで止めないで」を必ず入れてください。 これが無いと、壊れたセーブを読んだ瞬間に画面が真っ白になる作りが返ってきます。保存は、失敗しても遊べる方が正しい部品です。

  2. 1手打つたびに保存する

    置いた瞬間ではなく、両者の札がひらいた後に保存します。

    turn++;
    save();      // 伏せた札が全部ひらいてから保存する
    

    打つたびに保存します。 「終了時に保存」はうまくいきません。ブラウザを閉じる操作は、こちらから確実に捕まえられないからです。

    伏せている途中で保存しないのは、中途半端な状態を残さないためです。開いた直後なら、いつ復帰しても盤面の意味が変わりません。

  3. 起動時に読む

    if (load()) {
      render('前回の続きからです。札をえらんでください。');
    } else {
      newGame();
      render('札をえらんでください。');
    }
    

    「前回の続きから」と言うのが親切です。 黙って途中から始まると、遊ぶ人は自分の記憶を疑います。

  4. やり直す口を付ける

    el('resetBtn').addEventListener('click', function () {
      clearSave(); newGame(); render('札をえらんでください。');
    });
    
    function clearSave() { localStorage.removeItem(STATE_KEY); }
    

    addEventListener('click', ...) は「このボタンが押されたら、この処理をする」という登録です。ここでは保存を消し、新しい対戦を作り、画面を描き直しています。removeItem は置いてあるものを名前ごと捨てる指示です。

    セーブを入れたら、必ず消す口も付けます。 途中で詰まった人が抜け出せなくなります。

    決着したときも消します。終わった勝負を「続き」として復元しても意味がないからです。

    function finish() {
      finished = true;
      clearSave();
      ...
    }
    
  5. 実際に閉じて開き直す

    open index.html
    

    2〜3手打ってタブを閉じ、もう一度開いてください。続きから始まれば成功です。

  6. 壊して確かめる

    ブラウザの開発者ツールから、保存を壊してみてください。開発者ツールは、画面を右クリックして「検証」を選ぶと開きます。その中の「コンソール」という入力欄へ、次の1行を貼って実行します。

    localStorage.setItem('mitsunomato:state:v1', '{壊れている');
    

    開き直して普通に新しい対戦が始まれば正しいです。真っ白になったら load() の守りが足りていません。

  7. 保存する

    git add .
    git commit -m "対戦の途中をセーブして続きから遊べるようにした"
    git push
    

あとでDBへ移すときにやること

いま localStorage で作っておくと、あとでDBへ移すのは難しくありません。 保存する形(JSON)が同じなら、置き場を差し替えるだけです。

// いま: ブラウザへ置く
localStorage.setItem(STATE_KEY, JSON.stringify(state));

// あと: サーバーへ送る
await fetch('/api/save', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(state),
});

fetch は、サーバーへデータを送ったり取ってきたりする命令です。await は「返事が来るまでここで待つ」という印で、method: 'POST' は「送る」という意味の指定です。

ただし中身以外が変わります。 ここを見落とすと詰まります。

変わること 中身
時間がかかる 保存は一瞬で終わらない。押した直後に完了していない
失敗する 通信は切れる。失敗したときどうするかを決める必要がある
順番が乱れる 続けて2回送ると、後から出した方が先に着くことがある
正が移る サーバーが本当の値になる。ブラウザ側は写しになる

最後の1行がいちばん大事です。 DBへ移した時点で、ブラウザの中身は「写し」になります。写しを正として扱うと、別の端末で遊んだときに古い方が上書きしてきます。

だから移すときは、ブラウザ側の保存を消すか、明確に「一時的な写し」と決めます。 両方を正として残さないでください。

「保存しない方がいい」ものもあります

全部保存すると、逆に困ります。僕が保存していないものです。

保存しないもの 理由
音のON/OFFの一時状態 音は開くたびに「出す」から始める方が安全
演出の途中 途中から光っても意味がない
相手の思考の内部状態 毎回作り直せばいい

判断の基準は「無いと困るか」だけです。 途中の対戦は無いと困ります。演出の途中は困りません。

なお音のON/OFF自体は残す価値があります(5-2で書きました)。残すのは「設定」で、「いま鳴っているか」は残しません。 ここは分けて考えてください。

つまずきどころ

保存されない

localStorage が使えない状況があります。プライベートモード、容量いっぱい、ブラウザの設定。だから try で囲んで、失敗しても遊べるようにしてあります。

開き直したら途中なのに手札が全部ある

保存のタイミングが遅いです。reveal() の中、札をひらいた直後に save() を呼んでいるか確認してください。

札を減らしたら開けなくなった

load() の「知らない札」チェックが効くべき場面です。入っていなければ、消した札のidを持ったまま復元して、card(id)null を返して落ちます。

版の番号を上げ忘れた

保存する形を変えたら v を上げてください。上げ忘れると、古い形のデータを新しいコードで読もうとして中途半端に壊れます。 上げておけば、古いデータは静かに捨てられます。

複数のゲームで同じキー名を使ってしまった

localStorageサイト単位で共有されます。 同じ場所に置いた別のゲームとキー名がぶつかると混ざります。ゲーム名:state:v1 のように、ゲーム名から始めてください。

遊ぶ人が「セーブされている」と気づかない

「続きから始めます」を出してください。それでも気づかれない場合は、やり直しボタンの近くに小さく書くのが効きます。

開発者ツールから書き換えられた

書き換えられます。localStorage は遊ぶ人の手元にあるので、改変できない前提は置けません。 順位表や報酬のように改変されると困る数字は、サーバー側で持つ必要があります。

最初からDBにした方がいいのでは

共有しないなら要りません。 サーバーとDBを足すと、費用、本人確認、通信の失敗処理、個人情報の扱いが全部付いてきます。「どの端末でも」「他人に見せる」「他人と比べる」のどれかが必要になってからで間に合います。

逆に、最初からそれが目的(対戦履歴を公開したい、順位表を出したい)なら、遠回りせずDBから始めてください。判断の基準は好みではなく、その3つのどれかが要るかどうかです。

Windows の場合

コードは同じです。開発者ツールは F12 で開きます。

次の一歩

途中まで進めて、別のブラウザ(SafariとChromeなど)で同じファイルを開いてみてください。

続きになりません。 端末とブラウザごとに別の置き場だからです。これが localStorage の性質です。ここを超えたい場合はサーバーが必要になる、という境界を1回体験しておくと、8章の判断が速くなります。

続きから遊べるようになりました。次は対戦の記録を残して、あとから見返せるようにします!

ここまでの三つの的

この講座は、見本ゲーム「三つの的」を術ごとにすこしずつ育てながら進みます。下にあるのはこの術を終えた時点の実物です。そのまま遊べます。

別の画面でこのゲームを開く

術の一覧へ戻る

メルマガ(無料)忖度なしのAI事情を、メールでお届けします。ご登録の方に、プレゼントをお渡ししています