なぜ要るか
いまのゲームは、閉じると全部消えます。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に置くなら「誰のものか」が必要になります
ここが見落とされやすい点です。DBに置くと決めた瞬間、本人確認がセットで必要になります。
「どの端末からでも同じ続きが出る」を実現するには、その人を見分ける必要があるからです。だからログインが要る。だからメールアドレスかアカウントが要る。
このサイト(一忍)自体がその例です。閲覧記録はブラウザに置いていますが、会員の情報は Cloudflare の D1 というデータベースに置いています。だから登録にメールアドレスをもらっています。メールアドレスをもらう理由は、そこにあります。
「DBに保存したい」は、実は「アカウントを作りたい」とほぼ同じ意味です。 ここを分かっていると、8章で公開するときの判断が速くなります。
実例: CNPトレカアプリの対戦記録
僕のCNPトレカアプリでは、対戦の記録をサーバーのDB(MongoDB)に置いています。
形はこうなっています。
- 1対戦が1件のドキュメント。
duelresultsという置き場に入る - 手が進むたびに記録を追記する。上書きしない
- だからURLで他人に見せられる(
/replay/対戦IDのような形) - 勝率は、溜まった記録を数えて出す。別に持たない
ブラウザに置いていたら、どれもできません。 他人に見せられないし、端末を変えたら消えます。
逆に言えば、この4つが要らないなら localStorage で十分です。次の術で記録を扱うので、そこでもう一度出てきます。
いま作るものを決めます
この術では localStorage を使います。理由を明示します。
- 保存するのは対戦の途中の状態だけ。他人に見せない
- 別の端末で続きから遊ぶ必要がない
- サーバーを立てると、最初の1本の範囲を超える
そして混ぜないことを決めます。
| 保存するもの | 正はどこか |
|---|---|
| 対戦の途中の状態 | ブラウザ(端末ごと) |
| 会員の持ち物・購入履歴 | サーバー(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で label と id を分けた効果がここに出ます。表示を「壱」に変えても、セーブデータは壊れません。
そして盤面の札に turn が付いています。 何手目に置いたかを覚えています。いまの判定では使っていませんが、6-6で同点の的の扱いを変えるときに必要になります。
これは偶然ではありません。「あとで要りそうな事実」は、保存の形に先に入れておきます。 保存の形を後から変えると、それまでのセーブデータが全部読めなくなるからです。逆に、その場で計算できるもの(合計、勝った的の数)は入れません。事実は残す、計算結果は残さない。 これが保存の形を決める基準です。
壊れたデータを読んでも落ちないようにする
ここが本題です。セーブは必ず壊れます。
- 前の版のデータが残っている
- 手で編集された(開発者ツールから誰でも触れます)
- 途中で書き込みが切れた
- 札を削ったのに、そのidが保存に残っている
だから読むときに疑います。
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件も出ないことです。エラーが出ていると、その回は動いても次の変更で落ちます。
ここを確かめずに出すと、いつか「開けなくなったゲーム」を配ることになります。しかも壊れたセーブは消せません。 遊ぶ人のブラウザの中にあるからです。
やってみる
保存する場所と形を決める
上の
save()を足します。まとめて頼むならこう言います。claude -p "index.html に localStorage への保存を足して。保存するのは手札・盤面・何手目かの3つだけで、合計や勝った的の数のような計算できるものは入れないで。版の番号 v を持たせて、読むときは v が違う・形が違う・知らない札が入っている場合は黙って捨てて新しい対戦を始めて。エラーで止めないで。決着したら保存を消して。"「エラーで止めないで」を必ず入れてください。 これが無いと、壊れたセーブを読んだ瞬間に画面が真っ白になる作りが返ってきます。保存は、失敗しても遊べる方が正しい部品です。
1手打つたびに保存する
置いた瞬間ではなく、両者の札がひらいた後に保存します。
turn++; save(); // 伏せた札が全部ひらいてから保存する打つたびに保存します。 「終了時に保存」はうまくいきません。ブラウザを閉じる操作は、こちらから確実に捕まえられないからです。
伏せている途中で保存しないのは、中途半端な状態を残さないためです。開いた直後なら、いつ復帰しても盤面の意味が変わりません。
起動時に読む
if (load()) { render('前回の続きからです。札をえらんでください。'); } else { newGame(); render('札をえらんでください。'); }「前回の続きから」と言うのが親切です。 黙って途中から始まると、遊ぶ人は自分の記憶を疑います。
やり直す口を付ける
el('resetBtn').addEventListener('click', function () { clearSave(); newGame(); render('札をえらんでください。'); });function clearSave() { localStorage.removeItem(STATE_KEY); }addEventListener('click', ...)は「このボタンが押されたら、この処理をする」という登録です。ここでは保存を消し、新しい対戦を作り、画面を描き直しています。removeItemは置いてあるものを名前ごと捨てる指示です。セーブを入れたら、必ず消す口も付けます。 途中で詰まった人が抜け出せなくなります。
決着したときも消します。終わった勝負を「続き」として復元しても意味がないからです。
function finish() { finished = true; clearSave(); ... }実際に閉じて開き直す
open index.html2〜3手打ってタブを閉じ、もう一度開いてください。続きから始まれば成功です。
壊して確かめる
ブラウザの開発者ツールから、保存を壊してみてください。開発者ツールは、画面を右クリックして「検証」を選ぶと開きます。その中の「コンソール」という入力欄へ、次の1行を貼って実行します。
localStorage.setItem('mitsunomato:state:v1', '{壊れている');開き直して普通に新しい対戦が始まれば正しいです。真っ白になったら
load()の守りが足りていません。保存する
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章の判断が速くなります。
続きから遊べるようになりました。次は対戦の記録を残して、あとから見返せるようにします!
ここまでの三つの的
この講座は、見本ゲーム「三つの的」を術ごとにすこしずつ育てながら進みます。下にあるのはこの術を終えた時点の実物です。そのまま遊べます。