セーブデータ管理

スタート画面・セーブ選択画面のモックを作る過程で、単なる画面設計を 超えてセーブデータの持ち方自体に関わる決定が必要になったため、ここに集約する。 画面構成そのものは StartScreen.md を参照。

保存先

ブラウザの localStorage を採用する。本作はサーバーを持たないブラウザ単体のゲームであり (package.json の依存に見るとおり Phaser + Vite の静的ビルドのみ)、複数端末間の同期や クラウド保存は現時点でスコープ外(詳細は「スコープ外」節)。

セーブデータのスキーマ

セーブデータ本体は概ね次の項目を持つ 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)。ほかの欄と違ってこの欄は開いてよいかの判定に使うので、読めた 分だけを残すと、実際とは違う並びと一致して別の島のスロットが開く。

新規ゲーム作成時の入力とランダム生成

ワールド状態の保存

ワールド状態(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。

スロットの空き・削除

死ねばスロットごと消える(ハードコアモード)

キャラクタが死んだら、そのスロットを消す。 続きから始められる状態を残さないので、 セーブの作法に選択肢が無く、いわゆるハードコアモードしか存在しない。難易度の段 (ContentSkeleton.md 2.3.2 節)とは別の話で、あちらは開始地点の 厳しさに後から付く名前であり、プレイヤーが選ぶものではない。

削除の入口はユーザの操作(上の節)と死で、消し方はどちらも同じ——スロットのキーを消すだけ。 死は取り消せる操作ではないので、削除と違って確認は挟まず、死亡ダイアログ (VitalsSystem.md 6 節)を閉じた時点で消してセーブ選択画面へ戻る。

テスト用シナリオはスロットを使わないため、死んでも消えるものが無い。

島を出てもスロットは消える(GameEndings.md 3 節)。周回が終わる点は 死と同じで、違いは棚(後述)に物が並ぶかどうかだけ。消す経路も同じで、島を出たことを伝えるダイアログを 閉じた時点で消える。

棚(周回をまたいで残る唯一のもの)

持ち帰ったアーティファクトの棚(GameEndings.md 6 節、 Voyage.md 5 節)は、セーブスロットとは別のキー(unmapped-island:shelf)に 置く。周回そのものが消えても棚は残る——消えるなら、棚は周回を終わらせる理由にならない。

実装は src/save/Shelf.ts、画面は src/game/ShelfScene.ts。

スコープ外