Vanilla JS + Canvas, zero dependencies, offline-first PWA. Gameplay: - 11 weapons x8 levels + 11 evolutions (chest-based), incl. timed mines - 13 passives, crit system with directional hit-sparks & hit-stop - 10 characters w/ unique mods + unlock conditions, gold cosmetic skins - 3 biomes (Neon Graveyard / Frozen Hollow / Magma Rift) each with own spawn tables, boss plans and music flavor; Endless mode + surges; 4 difficulty grades; breakable crystal-lamp props - Elite random affixes (Swift/Sturdy/Volatile), 4 bosses, win flow - Achievements (23) w/ gold rewards, run history, daily seeded challenge Tech: - Cinematic canvas main-menu scene, game-feel FX suite (trails, muzzle, status tints, low-HP pulse), viewport culling + particle pooling - WebAudio synth SFX + generative per-biome soundtrack - Gamepad support, remappable keys, touch joystick, fullscreen - i18n VI/EN, localStorage saves w/ export-import codes - Cloudflare Workers leaderboard scaffold (KV) w/ signed submits - Headless integrity test-suite (node test/integrity.js)
101 lines
3.2 KiB
JavaScript
101 lines
3.2 KiB
JavaScript
'use strict';
|
|
/* ============================================================
|
|
NEON SURVIVORS — save.js : localStorage persistence
|
|
Meta progression (gold, permanent upgrades, unlocks) + settings.
|
|
============================================================ */
|
|
|
|
const SAVE_KEY = 'neon_survivors_v1';
|
|
|
|
const DEFAULT_SAVE = {
|
|
gold: 0,
|
|
meta: {}, // metaId -> rank purchased
|
|
chars: ['kaito'], // unlocked character ids
|
|
flags: { boss1: false, time10: false, time15: false },
|
|
totals: { kills: 0, gold: 0, best: 0, wins: 0, runs: 0, evos: 0, props: 0 },
|
|
progress: { stages: ['graveyard'], bestPerStage: {}, endlessBest: 0, stageWins: {}, gradeWins: {} },
|
|
skinOwned: {}, // 'charId:skinKey' -> 1
|
|
skinSel: {}, // charId -> skinKey
|
|
lbName: '', // leaderboard display name
|
|
daily: { date: '', best: 0 },
|
|
ach: {},
|
|
history: [],
|
|
settings: {
|
|
lang: 'vi',
|
|
volMaster: 0.9,
|
|
volMusic: 0.6,
|
|
volSfx: 0.85,
|
|
shake: true,
|
|
dmgNum: true,
|
|
particles: 'high', // low | med | high
|
|
fps: false,
|
|
autoPause: true,
|
|
dev: false, // developer mode tools
|
|
keybinds: { up: 'KeyW', down: 'KeyS', left: 'KeyA', right: 'KeyD' }
|
|
}
|
|
};
|
|
|
|
const Store = {
|
|
data: null,
|
|
|
|
load() {
|
|
this.data = JSON.parse(JSON.stringify(DEFAULT_SAVE));
|
|
try {
|
|
const raw = localStorage.getItem(SAVE_KEY);
|
|
if (raw) {
|
|
const parsed = JSON.parse(raw);
|
|
// Deep-merge known sections so old saves survive schema additions.
|
|
for (const sec of ['flags', 'totals', 'settings', 'progress', 'daily', 'ach']) {
|
|
if (parsed[sec]) Object.assign(this.data[sec], parsed[sec]);
|
|
}
|
|
if (parsed.skinOwned) Object.assign(this.data.skinOwned, parsed.skinOwned);
|
|
if (parsed.skinSel) Object.assign(this.data.skinSel, parsed.skinSel);
|
|
if (!this.data.lbName && typeof parsed.lbName === 'string') this.data.lbName = parsed.lbName;
|
|
if (Array.isArray(parsed.history)) this.data.history = parsed.history.slice(0, 20);
|
|
if (typeof parsed.gold === 'number') this.data.gold = parsed.gold;
|
|
if (parsed.meta && typeof parsed.meta === 'object') this.data.meta = parsed.meta;
|
|
if (Array.isArray(parsed.chars)) this.data.chars = parsed.chars;
|
|
}
|
|
} catch (e) { console.warn('Save load failed', e); }
|
|
return this.data;
|
|
},
|
|
|
|
save() {
|
|
try { localStorage.setItem(SAVE_KEY, JSON.stringify(this.data)); }
|
|
catch (e) { console.warn('Save write failed', e); }
|
|
},
|
|
|
|
reset() {
|
|
this.data = JSON.parse(JSON.stringify(DEFAULT_SAVE));
|
|
this.save();
|
|
},
|
|
|
|
s() { return this.data.settings; },
|
|
|
|
addGold(n) {
|
|
this.data.gold += n;
|
|
this.data.totals.gold += Math.max(0, n);
|
|
// lazy-save; frequent small writes are cheap enough here
|
|
this.save();
|
|
},
|
|
|
|
spendGold(n) {
|
|
if (this.data.gold < n) return false;
|
|
this.data.gold -= n;
|
|
this.save();
|
|
return true;
|
|
},
|
|
|
|
rank(metaId) { return this.data.meta[metaId] || 0; },
|
|
|
|
unlockChar(id) {
|
|
if (!this.data.chars.includes(id)) {
|
|
this.data.chars.push(id);
|
|
this.save();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
charUnlocked(id) { return this.data.chars.includes(id); }
|
|
};
|