NEON SURVIVORS v1.1 — full-featured bullet-heaven survivor game
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)
This commit is contained in:
+100
@@ -0,0 +1,100 @@
|
||||
'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); }
|
||||
};
|
||||
Reference in New Issue
Block a user