Files
game-forest/src/game/persistence.ts
T
2026-09-09 11:49:24 +03:00

88 lines
3.3 KiB
TypeScript

import type { Inventory, RabbitData, ResourceDropData, ResourceNodeData, Vec2 } from './types';
import type { UpgradeId } from './upgrades';
const STORAGE_KEY = 'forest-save-v1';
export interface SaveData {
version: 3;
inventory: Inventory;
upgradeLevels: Record<UpgradeId, number>;
/** World state (character/nodes/drops) is optional so older saves without it still load fine —
* progress is restored either way, just with a freshly spawned world. */
character?: Vec2;
nodes?: ResourceNodeData[];
resourceDrops?: ResourceDropData[];
/** Absent on saves from before rabbits existed, which get a freshly spawned batch instead. */
rabbits?: RabbitData[];
/** Absent on older saves, which are treated as day 1. */
day?: number;
/** In-game clock hour (0-24). Absent on older saves, which are treated as noon. */
hour?: number;
/** Absent on saves from before the character had hp, which default to a full characterMaxHp. */
characterHp?: number;
/** This life's run-summary stats (shown on the death screen) — absent on older saves or a
* fresh game, which default to 0. */
distanceTraveled?: number;
treesChoppedCount?: number;
bushesChoppedCount?: number;
rabbitsCaughtCount?: number;
/** Cumulative coins earned from converting resources — unlike inventory.coins, this never goes
* down when coins are spent on upgrades. */
totalCoinsEarned?: number;
/** Real seconds elapsed this life (ticked deltaTime, not in-game clock time) — stops
* accumulating once dead, same as the other life stats above. */
playTimeSeconds?: number;
}
function isValidSave(value: unknown): value is SaveData {
if (!value || typeof value !== 'object')
return false;
const data = value as Partial<SaveData>;
return (
data.version === 3
&& typeof data.inventory === 'object' && data.inventory !== null
&& typeof data.inventory.logs === 'number'
&& typeof data.inventory.twigs === 'number'
&& typeof data.inventory.coins === 'number'
&& typeof data.upgradeLevels === 'object' && data.upgradeLevels !== null
);
}
/** Reads the saved game from localStorage, or null if there's none / it's corrupted (including a
* save from an older, incompatible schema version — that's treated the same as no save). */
export function loadSave(): SaveData | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw)
return null;
const parsed: unknown = JSON.parse(raw);
return isValidSave(parsed) ? parsed : null;
} catch {
// localStorage unavailable (private mode, disabled) or corrupted JSON — treat as no save.
return null;
}
}
/** Writes the save to localStorage. Best-effort: silently no-ops if storage is unavailable/full. */
export function writeSave(data: SaveData): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {
// Ignore — e.g. private browsing mode or quota exceeded.
}
}
/** Deletes the saved game, if any — used when starting a brand new game. */
export function clearSave(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// Ignore — e.g. private browsing mode.
}
}
/** Whether a valid, resumable save currently exists (used to enable/disable "Continue" in the menu). */
export function hasSave(): boolean {
return loadSave() !== null;
}