M3: модуль сохранения
По ARCHITECTURE.md: реестр независимых секций, JSON с ключом на секцию. Ключи — явные строковые константы, а не имена классов: релизная сборка минифицирует имена и молча потеряла бы все сохранения. - ISaveSection, SaveManager, IStorage + MemoryStorage - CocosStorage: единственный файл модуля, знающий про движок; при отказе хранилища уходит в память, а не роняет игру - StatsSection: сыграно/побед/поражений/ничьих, текущая и лучшая серия - терпимая загрузка: битый JSON, чужая версия, сломанная секция или отвалившееся хранилище не ломают остальное - проверка чистоты расширена на modules/ со списком адаптеров Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { sys } from 'cc';
|
||||
import { IStorage, MemoryStorage } from './IStorage';
|
||||
|
||||
/**
|
||||
* The one file in the save module that touches the engine.
|
||||
*
|
||||
* `sys.localStorage` is backed by the browser store on web and by a native file
|
||||
* on Android and iOS, so one implementation covers every target. It can still
|
||||
* fail — private browsing, a full quota, a locked profile — so every call is
|
||||
* guarded and falls back to an in-memory store, which keeps the session
|
||||
* consistent even when nothing can be persisted.
|
||||
*/
|
||||
export class CocosStorage implements IStorage {
|
||||
private readonly _fallback = new MemoryStorage();
|
||||
private _usingFallback = false;
|
||||
|
||||
/** True once a storage call has failed and the session went in-memory. */
|
||||
get degraded(): boolean {
|
||||
return this._usingFallback;
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
if (this._usingFallback) {
|
||||
return this._fallback.getItem(key);
|
||||
}
|
||||
try {
|
||||
return sys.localStorage.getItem(key);
|
||||
} catch (error) {
|
||||
this._degrade(error);
|
||||
return this._fallback.getItem(key);
|
||||
}
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
if (!this._usingFallback) {
|
||||
try {
|
||||
sys.localStorage.setItem(key, value);
|
||||
return;
|
||||
} catch (error) {
|
||||
this._degrade(error);
|
||||
}
|
||||
}
|
||||
this._fallback.setItem(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
if (!this._usingFallback) {
|
||||
try {
|
||||
sys.localStorage.removeItem(key);
|
||||
return;
|
||||
} catch (error) {
|
||||
this._degrade(error);
|
||||
}
|
||||
}
|
||||
this._fallback.removeItem(key);
|
||||
}
|
||||
|
||||
private _degrade(error: unknown): void {
|
||||
if (this._usingFallback) return;
|
||||
this._usingFallback = true;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[save] device storage is unavailable, progress will not persist: ${message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c355de0c-e0e7-4e52-8805-6a4fdacd5bef",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* One independently saved slice of player progress.
|
||||
*
|
||||
* ARCHITECTURE.md asks for a save built from several classes, each handling its
|
||||
* own part, keyed by the class. The key here is an explicit string rather than
|
||||
* `constructor.name` on purpose: release builds minify class names, and a
|
||||
* renamed class would silently orphan every existing save.
|
||||
*/
|
||||
export interface ISaveSection {
|
||||
/** Stable identifier. Never change it once a build has shipped. */
|
||||
readonly key: string;
|
||||
|
||||
/** Current contents as a JSON-safe value. */
|
||||
save(): unknown;
|
||||
|
||||
/**
|
||||
* Restores from a previously saved value. Must tolerate anything — an older
|
||||
* shape, a newer shape, or outright rubbish — by falling back to defaults
|
||||
* rather than throwing.
|
||||
*/
|
||||
load(data: unknown): void;
|
||||
|
||||
/** Returns to the state of a player who has never played. */
|
||||
reset(): void;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "48cc13db-e29f-4fa9-955f-fab6c05f76b4",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* The key-value store a save is written to.
|
||||
*
|
||||
* Kept behind an interface so the save logic itself has no engine dependency
|
||||
* and can be tested headlessly. `CocosStorage` is the real implementation;
|
||||
* `MemoryStorage` is the one tests use.
|
||||
*/
|
||||
export interface IStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/** An in-process store. Used by tests, and as a fallback when storage is unavailable. */
|
||||
export class MemoryStorage implements IStorage {
|
||||
private readonly _data = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
const value = this._data.get(key);
|
||||
return value === undefined ? null : value;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this._data.set(key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this._data.delete(key);
|
||||
}
|
||||
|
||||
/** Test helper: everything currently stored. */
|
||||
get entries(): ReadonlyMap<string, string> {
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "a51ebd26-39af-478f-ac08-a839af126032",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ISaveSection } from './ISaveSection';
|
||||
import { IStorage } from './IStorage';
|
||||
|
||||
/** Bumped only when the envelope around the sections changes, not the sections themselves. */
|
||||
export const SAVE_FORMAT_VERSION = 1;
|
||||
|
||||
const DEFAULT_SLOT = 'fool.save';
|
||||
|
||||
interface SaveEnvelope {
|
||||
version: number;
|
||||
sections: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SaveManagerOptions {
|
||||
/** Storage key the whole save lives under. */
|
||||
slot?: string;
|
||||
/** Where warnings go. Defaults to the console; tests pass their own. */
|
||||
warn?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects the registered sections into one JSON document and puts it back
|
||||
* again on load, exactly as ARCHITECTURE.md describes.
|
||||
*
|
||||
* Loading is deliberately forgiving. A save that cannot be parsed, or a single
|
||||
* section whose payload has gone bad, resets only what it has to and lets
|
||||
* everything else through — a bad release should not be able to brick a player's
|
||||
* progress, and a partial load beats no load at all.
|
||||
*/
|
||||
export class SaveManager {
|
||||
private readonly _storage: IStorage;
|
||||
private readonly _slot: string;
|
||||
private readonly _warn: (message: string) => void;
|
||||
private readonly _sections: ISaveSection[] = [];
|
||||
|
||||
constructor(storage: IStorage, options: SaveManagerOptions = {}) {
|
||||
this._storage = storage;
|
||||
this._slot = options.slot ?? DEFAULT_SLOT;
|
||||
this._warn = options.warn ?? ((m) => console.warn(`[save] ${m}`));
|
||||
}
|
||||
|
||||
/** Registers a section. Keys must be unique. */
|
||||
register(section: ISaveSection): void {
|
||||
if (this._sections.some((s) => s.key === section.key)) {
|
||||
throw new Error(`A save section with key '${section.key}' is already registered`);
|
||||
}
|
||||
this._sections.push(section);
|
||||
}
|
||||
|
||||
get sections(): readonly ISaveSection[] {
|
||||
return this._sections;
|
||||
}
|
||||
|
||||
/** Serialises every section and writes the result. */
|
||||
save(): void {
|
||||
const envelope: SaveEnvelope = { version: SAVE_FORMAT_VERSION, sections: {} };
|
||||
for (const section of this._sections) {
|
||||
try {
|
||||
envelope.sections[section.key] = section.save();
|
||||
} catch (error) {
|
||||
this._warn(`section '${section.key}' failed to save: ${describe(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this._storage.setItem(this._slot, JSON.stringify(envelope));
|
||||
} catch (error) {
|
||||
// A full or disabled store must not take the game down with it.
|
||||
this._warn(`could not write the save: ${describe(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the save and hands each section its own payload. Sections with no
|
||||
* stored data — a new section in a new build, or a brand new player — are
|
||||
* reset to their defaults.
|
||||
*/
|
||||
load(): void {
|
||||
const envelope = this._read();
|
||||
|
||||
for (const section of this._sections) {
|
||||
const data = envelope?.sections[section.key];
|
||||
if (data === undefined) {
|
||||
section.reset();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
section.load(data);
|
||||
} catch (error) {
|
||||
this._warn(`section '${section.key}' failed to load, resetting it: ${describe(error)}`);
|
||||
section.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets every section and removes the stored save. */
|
||||
reset(): void {
|
||||
for (const section of this._sections) {
|
||||
section.reset();
|
||||
}
|
||||
try {
|
||||
this._storage.removeItem(this._slot);
|
||||
} catch (error) {
|
||||
this._warn(`could not clear the save: ${describe(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private _read(): SaveEnvelope | null {
|
||||
let raw: string | null;
|
||||
try {
|
||||
raw = this._storage.getItem(this._slot);
|
||||
} catch (error) {
|
||||
this._warn(`could not read the save: ${describe(error)}`);
|
||||
return null;
|
||||
}
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
this._warn(`the save is not valid JSON, starting fresh: ${describe(error)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isRecord(parsed) || !isRecord(parsed.sections)) {
|
||||
this._warn('the save has an unrecognisable shape, starting fresh');
|
||||
return null;
|
||||
}
|
||||
|
||||
const version = typeof parsed.version === 'number' ? parsed.version : 0;
|
||||
if (version > SAVE_FORMAT_VERSION) {
|
||||
// Written by a newer build. Read what we can rather than wiping it.
|
||||
this._warn(`the save is from a newer version (${version}), reading it cautiously`);
|
||||
}
|
||||
|
||||
return { version, sections: parsed.sections };
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function describe(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "273eda8d-570a-4dee-8070-c03134c4568f",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ISaveSection } from './ISaveSection';
|
||||
|
||||
export type MatchOutcome = 'win' | 'loss' | 'draw';
|
||||
|
||||
interface StatsData {
|
||||
played: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
drawn: number;
|
||||
streak: number;
|
||||
bestStreak: number;
|
||||
}
|
||||
|
||||
function emptyStats(): StatsData {
|
||||
return { played: 0, won: 0, lost: 0, drawn: 0, streak: 0, bestStreak: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* How the player has been doing. The only section iteration 1 ships; currency
|
||||
* and level progress will join it later without touching `SaveManager`.
|
||||
*/
|
||||
export class StatsSection implements ISaveSection {
|
||||
static readonly KEY = 'stats';
|
||||
|
||||
readonly key = StatsSection.KEY;
|
||||
|
||||
private _data: StatsData = emptyStats();
|
||||
|
||||
get played(): number { return this._data.played; }
|
||||
get won(): number { return this._data.won; }
|
||||
get lost(): number { return this._data.lost; }
|
||||
get drawn(): number { return this._data.drawn; }
|
||||
/** Wins in a row right now. A loss resets it; a draw leaves it alone. */
|
||||
get streak(): number { return this._data.streak; }
|
||||
get bestStreak(): number { return this._data.bestStreak; }
|
||||
|
||||
recordResult(outcome: MatchOutcome): void {
|
||||
this._data.played += 1;
|
||||
switch (outcome) {
|
||||
case 'win':
|
||||
this._data.won += 1;
|
||||
this._data.streak += 1;
|
||||
this._data.bestStreak = Math.max(this._data.bestStreak, this._data.streak);
|
||||
break;
|
||||
case 'loss':
|
||||
this._data.lost += 1;
|
||||
this._data.streak = 0;
|
||||
break;
|
||||
case 'draw':
|
||||
this._data.drawn += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
save(): unknown {
|
||||
return { ...this._data };
|
||||
}
|
||||
|
||||
load(data: unknown): void {
|
||||
const source = (typeof data === 'object' && data !== null ? data : {}) as Record<string, unknown>;
|
||||
const defaults = emptyStats();
|
||||
this._data = {
|
||||
played: count(source.played, defaults.played),
|
||||
won: count(source.won, defaults.won),
|
||||
lost: count(source.lost, defaults.lost),
|
||||
drawn: count(source.drawn, defaults.drawn),
|
||||
streak: count(source.streak, defaults.streak),
|
||||
bestStreak: count(source.bestStreak, defaults.bestStreak),
|
||||
};
|
||||
// A save edited by hand should not be able to show a nonsense best.
|
||||
this._data.bestStreak = Math.max(this._data.bestStreak, this._data.streak);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._data = emptyStats();
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a non-negative whole number, or falls back. */
|
||||
function count(value: unknown, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
? Math.floor(value)
|
||||
: fallback;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "c9c5a957-f40b-4887-a88b-5697c3b37c0c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
Reference in New Issue
Block a user