Update 3D game 2026-07-10 21:20
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
import { createHunterSave, DEFAULT_COLLECTIONS } from "./data";
|
||||
import { createClassInventory } from "../game/healers";
|
||||
import type { HealerClassId } from "../game/types";
|
||||
import type { HunterSave, SaveSlotId, SaveSlotState } from "./types";
|
||||
|
||||
export interface StorageAdapter {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
type SaveMap = Partial<Record<SaveSlotId, HunterSave>>;
|
||||
|
||||
const LOCAL_KEY = "i-want-to-heal:saves:local:v1";
|
||||
const CLOUD_KEY = (accountId: string) => `i-want-to-heal:saves:cloud:v1:${accountId.toLowerCase()}`;
|
||||
const SLOT_IDS: SaveSlotId[] = [1, 2, 3];
|
||||
|
||||
const fallbackMemory = new Map<string, string>();
|
||||
const fallbackStorage: StorageAdapter = {
|
||||
getItem: (key) => fallbackMemory.get(key) ?? null,
|
||||
setItem: (key, value) => { fallbackMemory.set(key, value); },
|
||||
};
|
||||
|
||||
function browserStorage(): StorageAdapter {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") return localStorage;
|
||||
} catch {
|
||||
// Android WebView can deny storage before its host is ready.
|
||||
}
|
||||
return fallbackStorage;
|
||||
}
|
||||
|
||||
interface LegacyHunterSave extends Omit<HunterSave, "schemaVersion" | "activeClassId" | "healers"> {
|
||||
schemaVersion: 1;
|
||||
level: number;
|
||||
}
|
||||
|
||||
const HEALER_IDS: HealerClassId[] = ["priest", "druid", "shaman"];
|
||||
|
||||
function normalizeCollections(collections: HunterSave["collections"] | undefined) {
|
||||
const source = collections ?? [];
|
||||
const knownIds = new Set(DEFAULT_COLLECTIONS.map((boss) => boss.bossId));
|
||||
const current = DEFAULT_COLLECTIONS.map((fallback) => source.find((boss) => boss.bossId === fallback.bossId) ?? structuredClone(fallback));
|
||||
return [...current, ...source.filter((boss) => !knownIds.has(boss.bossId))];
|
||||
}
|
||||
|
||||
function normalizeSave(value: unknown): HunterSave | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Partial<HunterSave> & Partial<LegacyHunterSave>;
|
||||
if (!candidate.slotId || !candidate.hunterName) return null;
|
||||
|
||||
if (candidate.schemaVersion === 2 && candidate.healers) {
|
||||
const activeClassId = HEALER_IDS.includes(candidate.activeClassId as HealerClassId) ? candidate.activeClassId as HealerClassId : "priest";
|
||||
return {
|
||||
...(candidate as HunterSave),
|
||||
activeClassId,
|
||||
collections: normalizeCollections(candidate.collections),
|
||||
healers: Object.fromEntries(HEALER_IDS.map((classId) => [classId, {
|
||||
level: Math.max(1, candidate.healers?.[classId]?.level ?? 1),
|
||||
inventory: candidate.healers?.[classId]?.inventory ?? createClassInventory(classId),
|
||||
}])) as HunterSave["healers"],
|
||||
};
|
||||
}
|
||||
|
||||
const legacy = candidate as LegacyHunterSave;
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
slotId: legacy.slotId,
|
||||
hunterName: legacy.hunterName,
|
||||
activeClassId: "priest",
|
||||
healers: {
|
||||
priest: { level: Math.max(1, legacy.level || 1), inventory: createClassInventory("priest") },
|
||||
druid: { level: 1, inventory: createClassInventory("druid") },
|
||||
shaman: { level: 1, inventory: createClassInventory("shaman") },
|
||||
},
|
||||
location: legacy.location,
|
||||
playSeconds: legacy.playSeconds,
|
||||
updatedAt: legacy.updatedAt,
|
||||
stats: legacy.stats,
|
||||
collections: normalizeCollections(legacy.collections),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSaveMap(raw: string | null): SaveMap {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
return Object.fromEntries(Object.entries(parsed).flatMap(([id, value]) => {
|
||||
const save = normalizeSave(value);
|
||||
return save ? [[id, save]] : [];
|
||||
})) as SaveMap;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function cloneSave(save: HunterSave): HunterSave {
|
||||
return structuredClone(save);
|
||||
}
|
||||
|
||||
export class SaveRepository {
|
||||
constructor(
|
||||
private readonly storage: StorageAdapter = browserStorage(),
|
||||
private readonly now: () => string = () => new Date().toISOString(),
|
||||
) {}
|
||||
|
||||
list(accountId: string | null): SaveSlotState[] {
|
||||
const local = this.read(LOCAL_KEY);
|
||||
const online = accountId ? this.read(CLOUD_KEY(accountId)) : {};
|
||||
return SLOT_IDS.map((id) => ({ id, local: local[id] ?? null, online: online[id] ?? null }));
|
||||
}
|
||||
|
||||
create(slotId: SaveSlotId, hunterName: string): HunterSave {
|
||||
const save = createHunterSave(slotId, this.now(), hunterName);
|
||||
this.setLocal(save);
|
||||
return save;
|
||||
}
|
||||
|
||||
touch(slotId: SaveSlotId): HunterSave | null {
|
||||
return this.updateLocal(slotId, (save) => ({ ...save, updatedAt: this.now() }));
|
||||
}
|
||||
|
||||
updateLocal(slotId: SaveSlotId, update: (save: HunterSave) => HunterSave): HunterSave | null {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
const source = saves[slotId];
|
||||
if (!source) return null;
|
||||
const next = { ...update(cloneSave(source)), slotId, updatedAt: this.now() };
|
||||
saves[slotId] = next;
|
||||
this.write(LOCAL_KEY, saves);
|
||||
return next;
|
||||
}
|
||||
|
||||
deleteLocal(slotId: SaveSlotId): void {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
delete saves[slotId];
|
||||
this.write(LOCAL_KEY, saves);
|
||||
}
|
||||
|
||||
copyLocal(sourceId: SaveSlotId, targetId: SaveSlotId): HunterSave | null {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
const source = saves[sourceId];
|
||||
if (!source || sourceId === targetId) return null;
|
||||
const copy = { ...cloneSave(source), slotId: targetId, updatedAt: this.now() };
|
||||
saves[targetId] = copy;
|
||||
this.write(LOCAL_KEY, saves);
|
||||
return copy;
|
||||
}
|
||||
|
||||
upload(slotId: SaveSlotId, accountId: string): HunterSave | null {
|
||||
const local = this.read(LOCAL_KEY)[slotId];
|
||||
if (!local) return null;
|
||||
const cloud = this.read(CLOUD_KEY(accountId));
|
||||
const uploaded = { ...cloneSave(local), updatedAt: this.now() };
|
||||
cloud[slotId] = uploaded;
|
||||
this.write(CLOUD_KEY(accountId), cloud);
|
||||
this.setLocal(uploaded);
|
||||
return uploaded;
|
||||
}
|
||||
|
||||
download(slotId: SaveSlotId, accountId: string): HunterSave | null {
|
||||
const cloud = this.read(CLOUD_KEY(accountId))[slotId];
|
||||
if (!cloud) return null;
|
||||
const downloaded = { ...cloneSave(cloud), slotId, updatedAt: this.now() };
|
||||
this.setLocal(downloaded);
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
private setLocal(save: HunterSave): void {
|
||||
const saves = this.read(LOCAL_KEY);
|
||||
saves[save.slotId] = save;
|
||||
this.write(LOCAL_KEY, saves);
|
||||
}
|
||||
|
||||
private read(key: string): SaveMap {
|
||||
return parseSaveMap(this.storage.getItem(key));
|
||||
}
|
||||
|
||||
private write(key: string, saves: SaveMap): void {
|
||||
this.storage.setItem(key, JSON.stringify(saves));
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSaveTimestamp(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "Unknown time";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatPlayTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
}
|
||||
Reference in New Issue
Block a user