セーブデータ管理
スタート画面・セーブ選択画面のモックを作る過程で、単なる画面設計を 超えてセーブデータの持ち方自体に関わる決定が必要になったため、ここに集約する。 画面構成そのものは StartScreen.md を参照。
保存先
ブラウザの localStorage を採用する。本作はサーバーを持たないブラウザ単体のゲームであり
(package.json の依存に見るとおり Phaser + Vite の静的ビルドのみ)、複数端末間の同期や
クラウド保存は現時点でスコープ外(詳細は「スコープ外」節)。
- キーは
unmapped-island:{領域}[:{名前}]の形で、領域を挙げているのはStorageAreaのStorageAreaName1箇所だけ(save/settings/shelf)。名前空間の組み立てと、 壊れた値を未設定として読むこともそこが持つ。 - セーブスロットのキーは
unmapped-island:save:{slotIndex}(slotIndexは0〜3)。スロット数は4固定のため、 スロット一覧を得るための別キー(インデックス/サマリー一覧)は持たない。各スロットのキーを 直接読み、値が存在しない(null)スロットを空きスロットとして扱う。 - 一覧表示専用のサマリーをスロット本体と別に持たない。持ってしまうと「セーブ本体を更新したら サマリーも更新する」という手順を呼び出し側が覚える必要が生じ、 CLAUDE.md のカプセル化方針が禁じる状態になる。スロット一覧画面は すべてのセーブ本体を直接読んで表示に必要な項目(後述)を取り出すだけで十分に軽量なため、 サマリーの複製を持つ動機がない。
セーブデータのスキーマ
セーブデータ本体は概ね次の項目を持つ JSON とする(実際のワールド状態のスキーマは別途検討)。
| フィールド | 型 | 内容 |
|---|---|---|
schemaVersion |
number | セーブデータの形式バージョン。将来のフィールド追加・変更をマイグレーションで吸収するための起点(マイグレーション処理自体は本ドキュメントの対象外)。 |
islandName |
string | 新規ゲーム作成時に入力した島の名前。1〜20文字。 |
seed |
number | 乱数シード。地形生成が消費する Pcg32 の実装(seed >>> 0)に合わせ、0 以上 2^32 - 1 以下の符号なし32bit整数の範囲で扱う(詳細は後述)。 |
characterId |
string | 選択したキャラクターの object_def の識別子(Characters.md)。 |
createdAt |
number | 作成日時(UNIX時間・ミリ秒)。 |
elapsedDays |
number | 生存日数。ワールドの経過ゲーム内時間から求まる値で、セーブスロット一覧の表示のためだけに |
| 別途複製した値ではなく、セーブ本体(ワールド状態)が持つ経過時間をそのまま読む。 | ||
pinnedStatuses |
string[] | ユーザが固定表示にしたステータス(プロパティの識別子)。安全域でもステータスエリアへ出し続ける(StatusArea.md)。プレイ中に切り替えるたびスロットへ書き戻す。 |
mapCardPositions |
{site, x, y}[] | ユーザが地図ウィンドウで置いた土地カードの位置(Windows.md 7 節)。site は地形生成のサイト index(同じシードなら同じ土地を指し続ける)、x/y は画面に対する 0〜1 の正規化座標。カードを置くたびスロットへ書き戻す。 |
assetPacks |
{id, version}[] | 作ったときに入っていたアセットパックを、読み込んだ順に並べたもの(AssetPack.md 6.4節)。同梱ぶんだけで作ったセーブは空。今入っているものと食い違うスロットは開かない——パックは土地の型も生成の重みも足せるので、同じシードでも別の島が出る(見せ方は StartScreen.md)。 |
elapsedDays を「表示専用の複製」にしないのは、上記のサマリー非保持の方針と同じ理由による
(更新のたびに複数箇所を同期させる手順を呼び出し側に持たせない)。
pinnedStatuses は形式バージョン 2、mapCardPositions は形式バージョン 3、assetPacks は形式
バージョン 4 で足したフィールドで、それを持たない古い形式のセーブは「無し」(assetPacks なら
同梱ぶんだけで作られたもの)として読む。欠けたフィールドの補完は読み出しの1か所、
SaveData.toSaveData に集める。
assetPacks だけは、壊れた要素を落として読まない。 1つでも読めない要素があれば、そのスロットは
壊れたセーブとして扱う(undefined)。ほかの欄と違ってこの欄は開いてよいかの判定に使うので、読めた
分だけを残すと、実際とは違う並びと一致して別の島のスロットが開く。
新規ゲーム作成時の入力とランダム生成
- 島の名前: 自由入力(1〜20文字、空文字は不可)。ランダムボタンは形容語+名詞の2語を
組み合わせて生成する(例: 「霧深い」+「孤島」)。一意性は保証しない
(スロットごとの識別は名前ではなく
slotIndexで行うため、島名の重複を禁止する理由がない)。 - 乱数シード: 数字のみの自由入力、またはランダムボタンで生成する。欄の初期値は毎回引き直すが、
起動URLに
?seed=を付けた場合だけはその値になる(src/game/launchSeed.ts。同じURLから同じ島を 何度でも作るための口で、値の解釈は入力欄と同じ。数字以外・値域外は指定が無かったものとして扱う)。NewGame.startNewGame(src/domain/generation/NewGame.ts)はすでにseed: numberを受け取り、地形生成 (TerrainGenerator.generateIsland→Pcg32)が「同じシード→同じ島」を決定的に再現する。Pcg32のコンストラクタはseed >>> 0で符号なし32bit値として消費するため、シードの 値域は0以上2^32 - 1(4294967295)以下として扱う。WorldSession.rng(pick抽選・ 初期値ロール・開始時刻用)にも同じシードから作った決定的な実装(seededRng、src/domain/Rng.ts)を渡すため、開始状態は地形レイアウトを含めてシードだけで決まる。 地形生成とは別の列(Pcg32.forPurpose(seed, 'play'))を使うので、pick抽選の消費順序は地形に影響しない。 - 開始時刻: ゲーム内の開始時刻は 8:00〜12:00(両端を含む)の中から
minutes_per_tickの 1 tick(15分)刻みで1つ選ぶ(World.rollTimeOfDay)。日没までの猶予をゲームごとに 変えるための抽選で、WorldSession.rngが引くためシードから決まる。 - キャラクター選択: 選択肢は
characterタグを持つobject_defをそのまま並べたもので、 選んだ識別子がcharacterIdになる(Characters.md)。読み込み時に 未知の識別子だった場合は先頭のキャラクターで代替し、セーブが開けなくなることを避ける。 アセットパックのキャラクターがそうなることはない——パックが揃わないスロットは、代替する前に 開かれないため(AssetPack.md6.4節)。
ワールド状態の保存
ワールド状態(WorldObject のツリー)そのものを保存する仕組みはまだ無い。プレイ画面
(src/game/PlayScene.ts)は、新規作成でも既存スロットを開いた場合でも、セーブデータの
seed から世界を作り直して表示する。上記のとおり開始状態はシードだけで決まるため、
同じスロットを開き直せば常に同じ島・同じ漂着地になる(進行の保存は今後の課題)。
テスト用シナリオ
決まった開始状態からすぐ動作を試すための仕組み。タイトル画面の「テスト用シナリオ」から選ぶと、 セーブ選択を飛ばしてその状態でプレイ画面に入る。セーブスロットは使わず、書き戻しもしない。
シナリオは src/assets/scenarios/<名前>.yaml に置く。コード側への登録は要らず、一覧は
import.meta.glob がビルド時に作る(public/ 配下ではビルド時にも実行時にも一覧を得る手段が
無いため、同梱データは src/ に置く)。同梱シナリオが1つも無ければ、タイトル画面のボタンも出ない。
ワールド状態を保存する仕組みが無い(前節)ため、シナリオは状態そのものではなく シードと、その後に行う配置の手順を持つ。つまり「シードで世界を作り直し、object_defを 生成してスロットへ入れる」だけで、保存側の課題(インスタンスIDの参照、passiveの再登録、 乱数の位置)を一切踏まない。将来ロードを実装するときは、この配置手順をそのまま使える。
title: 編み籠と石 # 一覧に出す表示名(省略するとシナリオ名がそのまま出る)
seed: 3
player:
hand: [woven_basket] # キャラクターのhandスロットへ、並べた順に入れる
equipment: [] # 同じ名前が並んだ数だけ作られる
injuries: [] # 負った状態で始める怪我(docs/engine/InjurySystem.md)
props:
satiety: 12 # プロパティの上書き(実体値。数値かシンボル名)
location:
type: jungle # 開始地点にする土地のobject_def(省略すると通常の漂着地)
items: [stone x100, thick_branch] # `x個数` で同じものをまとめて指定できる(1〜1000)
fixtures: [raft]
inside: # 置いた設置物の中へ入れる(積荷を積んだ筏、Voyage.md 2節)
raft: [green_coconut x60]
world:
props:
weather: light_rain # worldのプロパティの上書き(天候・季節・時刻)
地形の種類はシードだけでは選べない(漂着地は選抜が砂浜から、砂浜の無い島では他の海岸から選ぶ。
ContentSkeleton.md 2.3節)ため、特定の土地から試したい
シナリオは location.type で開始地点を指定する。その土地がシードの島に無ければエラーにする。
その型が島に複数あるときは、同じ選抜が中から選ぶ——型で絞るのはシナリオの事情なので、
絞った先での選び方まで変える理由にはならない。
天候・季節も同じくシードでは選べないので、それらに依存する挙動(雨で水が溜まる、日差しで蒸発する)を
試すシナリオは world.props で直接置く。置いた天候はそのまま続くわけではなく、weather_remaining が
尽きた時点で通常の遷移(ClimateSystem.md 4節)に戻る。
配置は通常のスロット移動(WorldObject.moveToSlotOrRejection)そのもので、シナリオ専用の抜け道は
持たない。受け入れられない指定(手持ちの上限超過、綴り違いのobject_def)はその場でエラーに
する——黙って落とすと、試したかった状態と違う状態でゲームが始まってしまうため。
同梱シナリオが実際に適用できることは tests/scenario/scenario.test.ts が担保する。選択画面が
読み込みの失敗を扱わないのは、これが根拠。
読み込みは src/scenario/Scenario.ts、選択画面は src/game/ScenarioSelectScene.ts。
スロットの空き・削除
- 空き判定はキーの不在(
localStorage.getItemがnull)のみで行い、別途「空きフラグ」は持たない。 - 削除は該当スロットのキーを消すだけで、他スロットに影響しない。
- 削除は確認操作を必須とする(画面設計は StartScreen.md 参照)。 誤タップで進行データを失う事故を防ぐための最低限の防御であり、取り消し(アンドゥ)は スコープ外(「スコープ外」節)。
死ねばスロットごと消える(ハードコアモード)
キャラクタが死んだら、そのスロットを消す。 続きから始められる状態を残さないので、
セーブの作法に選択肢が無く、いわゆるハードコアモードしか存在しない。難易度の段
(ContentSkeleton.md 2.3.2 節)とは別の話で、あちらは開始地点の
厳しさに後から付く名前であり、プレイヤーが選ぶものではない。
削除の入口はユーザの操作(上の節)と死で、消し方はどちらも同じ——スロットのキーを消すだけ。
死は取り消せる操作ではないので、削除と違って確認は挟まず、死亡ダイアログ
(VitalsSystem.md 6 節)を閉じた時点で消してセーブ選択画面へ戻る。
テスト用シナリオはスロットを使わないため、死んでも消えるものが無い。
島を出てもスロットは消える(GameEndings.md 3 節)。周回が終わる点は
死と同じで、違いは棚(後述)に物が並ぶかどうかだけ。消す経路も同じで、島を出たことを伝えるダイアログを
閉じた時点で消える。
棚(周回をまたいで残る唯一のもの)
持ち帰ったアーティファクトの棚(GameEndings.md 6 節、
Voyage.md 5 節)は、セーブスロットとは別のキー(unmapped-island:shelf)に
置く。周回そのものが消えても棚は残る——消えるなら、棚は周回を終わらせる理由にならない。
- 中身は持ち帰ったobject_defの識別子の集合(JSONの配列)。同じ物を2度持ち帰っても枠は増えない。
- 壊れた値は「まだ何も無い」として読む(スロット本体と同じ扱い)。
- 削除の口は持たない。ユーザの操作で消せるのはスロットだけで、棚は積み上がる一方になる。
- 今は名前を引けない識別子も残す。 アセットパックの物を持ち帰った後にそのパックを外すと、
棚の識別子だけが残る(
AssetPack.md6.4節)。読めるものだけを並べ、読めない ぶんは触らずに置く——消すと、パックを一度外しただけで持ち帰りの記録が永久に失われる。
実装は src/save/Shelf.ts、画面は src/game/ShelfScene.ts。
スコープ外
- クラウド同期・複数端末間の共有
- セーブデータの暗号化・改ざん防止(ローカル単体プレイのみを想定するため)
- 削除の取り消し(アンドゥ)
- スキーマバージョン間のマイグレーション処理そのものの実装(
schemaVersionフィールドの用意まで)