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:
neon-survivors-dev
2026-08-23 07:01:22 +00:00
commit 796880375e
26 changed files with 7447 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
'use strict';
/* ============================================================
NEON SURVIVORS — achievements.js
Declarative achievement list + evaluator. Names are embedded
bilingual strings (kept out of i18n dicts on purpose).
cond(save, G) -> boolean against persisted progress only.
============================================================ */
const ACHS = [
// ---- combat milestones ----
{ id: 'kill100', icon: '⚔️', vi: 'Thợ Săn Mới', en: 'Rookie Hunter',
dvi: 'Diệt 100 quái', den: 'Kill 100 enemies', cond: s => s.totals.kills >= 100 },
{ id: 'kill500', icon: '🗡️', vi: 'Kẻ Trừng Phạt', en: 'Executioner',
dvi: 'Diệt 500 quái', den: 'Kill 500 enemies', cond: s => s.totals.kills >= 500 },
{ id: 'kill2500', icon: '💀', vi: 'Tử Thần Nhỏ', en: 'Petite Reaper',
dvi: 'Diệt 2.500 quái', den: 'Kill 2,500 enemies', cond: s => s.totals.kills >= 2500 },
{ id: 'kill10k', icon: '☠️', vi: 'Tai Họa Đi Bộ', en: 'Walking Calamity',
dvi: 'Diệt 10.000 quái', den: 'Kill 10,000 enemies', cond: s => s.totals.kills >= 10000 },
// ---- wealth ----
{ id: 'gold1k', icon: '🪙', vi: 'Giàu Có', en: 'Coin Purse',
dvi: 'Kiếm 1.000 vàng lifetime', den: 'Earn 1,000 gold lifetime', cond: s => s.totals.gold >= 1000 },
{ id: 'gold10k', icon: '💰', vi: 'Đại Gia Vàng', en: 'Gold Baron',
dvi: 'Kiếm 10.000 vàng lifetime', den: 'Earn 10,000 gold lifetime', cond: s => s.totals.gold >= 10000 },
// ---- survival time ----
{ id: 'time5', icon: '⏱️', vi: 'Làm quen', en: 'Warmed Up',
dvi: 'Sống sót 5 phút', den: 'Survive 5 minutes', cond: s => s.totals.best >= 300 },
{ id: 'time10', icon: '⏳', vi: 'Bền Bỉ', en: 'Tenacious',
dvi: 'Sống sót 10 phút', den: 'Survive 10 minutes', cond: s => s.totals.best >= 600 },
{ id: 'time15', icon: '🌙', vi: 'Dòng Chảy', en: 'In The Zone',
dvi: 'Sống sót 15 phút', den: 'Survive 15 minutes', cond: s => s.totals.best >= 900 },
{ id: 'time20', icon: '🌅', vi: 'Bình Minh', en: 'Daybreak',
dvi: 'Sống sót đủ 20 phút (thắng)', den: 'Survive the full 20 minutes (win)', cond: s => s.totals.best >= 1200 || s.totals.wins >= 1 },
// ---- victories & difficulty ----
{ id: 'win1', icon: '👑', vi: 'Kết Thúc Cuộc Ngủ', en: 'Wake The Dead',
dvi: 'Hạ Tử Thần — thắng 1 ván', den: 'Slay Death — win a run', cond: s => s.totals.wins >= 1 },
{ id: 'win5', icon: '🏆', vi: 'Vua Sống Sót', en: 'Survivor King',
dvi: 'Thắng 5 ván', den: 'Win 5 runs', cond: s => s.totals.wins >= 5 },
{ id: 'grade3', icon: '💀', vi: 'Vô Cầu Vị', en: 'No Mercy',
dvi: 'Thắng ở Độ khó 💀 (III+)', den: 'Win on Grade 💀 (III+)', cond: s => (s.progress.gradeWins[2] || 0) + (s.progress.gradeWins[3] || 0) >= 1 },
// ---- stages & endless ----
{ id: 'st_frost', icon: '❄️', vi: 'Phá Băng', en: 'Ice Breaker',
dvi: 'Thắng Hang Băng', den: 'Win Frozen Hollow', cond: s => !!s.progress.stageWins.frostcave },
{ id: 'st_magma', icon: '🌋', vi: 'Chúa Tể Núi Lửa', en: 'Volcano Lord',
dvi: 'Thắng Vực Magma', den: 'Win Magma Rift', cond: s => !!s.progress.stageWins.magmalands },
{ id: 'endless25', icon: '∞', vi: 'Không Điểm Dừng', en: 'No Stop Sign',
dvi: 'Endless đạt 25 phút', den: 'Reach minute 25 in Endless', cond: s => (s.progress.endlessBest || 0) >= 1500 },
// ---- collection ----
{ id: 'evo1', icon: '🌟', vi: 'Tiến Hóa!', en: 'Evolve!',
dvi: 'Tiến hóa 1 vũ khí', den: 'Evolve a weapon', cond: s => (s.totals.evos || 0) >= 1 },
{ id: 'evo6', icon: '✨', vi: 'Nhà Sưu Tầm', en: 'Collector',
dvi: 'Tiến hóa 6 lần (tổng mọi ván)', den: 'Evolve 6 times (across runs)', cond: s => (s.totals.evos || 0) >= 6 },
{ id: 'chars_all', icon: '🎭', vi: 'Full Dàn Sao', en: 'Full Roster',
dvi: 'Mở khóa tất cả nhân vật', den: 'Unlock every character', cond: s => (s.chars || []).length >= Object.keys(CHARS).length },
{ id: 'meta10', icon: '🔧', vi: 'Nâng Cấp Giỏi', en: 'Well Oiled',
dvi: 'Mua tổng cộng 10 cấp cửa hàng', den: 'Buy 10 total meta-shop ranks', cond: s => Object.values(s.meta || {}).reduce((a, b) => a + b, 0) >= 10 },
// ---- misc ----
{ id: 'props50', icon: '💎', vi: 'Phá Phách', en: 'Smasher',
dvi: 'Phá 50 đèn pha lê', den: 'Break 50 crystal lamps', cond: s => (s.totals.props || 0) >= 50 },
{ id: 'daily1', icon: '⚡', vi: 'Người Của Hôm Nay', en: 'Daily Daredevil',
dvi: 'Hoàn thành 1 Thử thách hôm nay', den: 'Finish a Daily Challenge', cond: s => (s.daily.best || 0) > 0 }
];
/** Evaluate all; unlock new ones into save.ach and return their defs. */
function checkAchievements() {
const save = Store.data;
const newly = [];
for (const a of ACHS) {
if (!save.ach[a.id] && _safe(a.cond, save)) {
save.ach[a.id] = Date.now();
newly.push(a);
}
}
if (newly.length) Store.save();
return newly;
}
function _safe(fn, save) { try { return !!fn(save); } catch (e) { return false; } }
/** Achievement count helper for menu/codex badges. */
function achProgress() {
let n = 0;
for (const a of ACHS) if (Store.data.ach[a.id]) n++;
return { n, total: ACHS.length };
}
+195
View File
@@ -0,0 +1,195 @@
'use strict';
/* ============================================================
NEON SURVIVORS — audio.js : WebAudio synth SFX + generative music
No audio files needed; everything is synthesized.
============================================================ */
const Snd = {
ctx: null,
master: null,
musicG: null,
sfxG: null,
// music sequencer state
_step: 0,
_nextTime: 0,
_timer: null,
intensity: 0.2, // 0..1 — drives music layers
_xpCombo: 0,
_xpComboT: 0,
init() {
if (this.ctx) { if (this.ctx.state === 'suspended') this.ctx.resume(); return; }
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.musicG = this.ctx.createGain();
this.sfxG = this.ctx.createGain();
this.master.connect(this.ctx.destination);
this.musicG.connect(this.master);
this.sfxG.connect(this.master);
this.applyVolumes();
this._startMusic();
},
applyVolumes() {
if (!this.ctx) return;
const s = Store.s();
this.master.gain.value = s.volMaster;
this.musicG.gain.value = s.volMusic * 0.9;
this.sfxG.gain.value = s.volSfx;
},
midi(n) { return 440 * Math.pow(2, (n - 69) / 12); },
/** Generic oscillator blip. */
tone(o) {
if (!this.ctx) return;
const t0 = (o.delay || 0) + this.ctx.currentTime;
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = o.type || 'square';
osc.frequency.setValueAtTime(o.f || 440, t0);
if (o.f2) osc.frequency.exponentialRampToValueAtTime(Math.max(1, o.f2), t0 + (o.dur || .1));
const vol = o.vol !== undefined ? o.vol : .2;
const dur = o.dur || .1;
g.gain.setValueAtTime(0.0001, t0);
g.gain.linearRampToValueAtTime(vol, t0 + (o.attack || .004));
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(g); g.connect(o.dest || this.sfxG);
osc.start(t0); osc.stop(t0 + dur + .05);
},
/** Filtered noise burst. */
noise(o) {
if (!this.ctx) return;
const dur = o.dur || .15;
const t0 = (o.delay || 0) + this.ctx.currentTime;
const len = Math.max(1, Math.floor(this.ctx.sampleRate * dur));
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
const src = this.ctx.createBufferSource();
src.buffer = buf;
const flt = this.ctx.createBiquadFilter();
flt.type = o.hp ? 'highpass' : 'lowpass';
flt.frequency.setValueAtTime(o.f || 1200, t0);
if (o.f2) flt.frequency.exponentialRampToValueAtTime(Math.max(20, o.f2), t0 + dur);
const g = this.ctx.createGain();
g.gain.setValueAtTime(o.vol !== undefined ? o.vol : .2, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
src.connect(flt); flt.connect(g); g.connect(this.sfxG);
src.start(t0); src.stop(t0 + dur + .02);
},
/** Play named SFX (throttled per name). */
play(name, force) {
if (!this.ctx) return;
if (!force && !throttled('sfx_' + name, 45)) return;
switch (name) {
case 'shoot': this.tone({ f: rand(640, 760), f2: 260, type: 'triangle', dur: .06, vol: .05 }); break;
case 'hit': this.noise({ dur: .05, vol: .09, f: 1900 }); break;
case 'crit': this.tone({ f: 1568, type: 'sine', dur: .06, vol: .12 });
this.tone({ f: 2093, type: 'sine', dur: .09, vol: .1, delay: .045 }); break;
case 'kill': this.noise({ dur: .08, vol: .12, f: 520, f2: 160 });
this.tone({ f: 300, f2: 90, type: 'sine', dur: .08, vol: .11 }); break;
case 'hurt': this.tone({ f: 170, f2: 70, type: 'sawtooth', dur: .22, vol: .3 });
this.noise({ dur: .12, vol: .18, f: 700 }); break;
case 'xp': {
const now = performance.now();
if (now - this._xpComboT > 900) this._xpCombo = 0;
this._xpComboT = now;
this._xpCombo = (this._xpCombo + 1) % 8;
this.tone({ f: 660 * Math.pow(2, this._xpCombo / 12), type: 'sine', dur: .07, vol: .09 }); break;
}
case 'coin': this.tone({ f: 1318, type: 'square', dur: .05, vol: .08 });
this.tone({ f: 1760, type: 'square', dur: .09, vol: .08, delay: .055 }); break;
case 'heal': [523, 659, 784].forEach((f, i) => this.tone({ f, type: 'sine', dur: .16, vol: .12, delay: i * .07 })); break;
case 'levelup':[392, 494, 587, 784].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .22, vol: .18, delay: i * .09 })); break;
case 'chest': [659, 830, 988, 1318].forEach((f, i) => this.tone({ f, type: 'sine', dur: .2, vol: .14, delay: i * .08 }));
this.noise({ dur: .4, vol: .06, f: 6000, hp: true }); break;
case 'evolve': this.tone({ f: 300, f2: 1400, type: 'sawtooth', dur: .5, vol: .16 });
[523, 659, 784, 1046].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .3, vol: .14, delay: .25 + i * .07 })); break;
case 'boom': this.noise({ dur: .38, vol: .4, f: 900, f2: 90 });
this.tone({ f: 130, f2: 36, type: 'sine', dur: .32, vol: .35 }); break;
case 'zap': this.tone({ f: 1500, f2: 90, type: 'sawtooth', dur: .13, vol: .16 }); break;
case 'freeze': this.noise({ dur: .14, vol: .12, f: 4200, hp: true });
this.tone({ f: 1800, f2: 2400, type: 'sine', dur: .1, vol: .06 }); break;
case 'roar': this.tone({ f: 58, type: 'sawtooth', dur: .7, vol: .34 });
this.tone({ f: 61, type: 'sawtooth', dur: .7, vol: .3 });
this.noise({ dur: .5, vol: .15, f: 300 }); break;
case 'click': this.tone({ f: 900, type: 'square', dur: .03, vol: .1 }); break;
case 'death': this.tone({ f: 220, f2: 40, type: 'sawtooth', dur: .9, vol: .35 }); break;
case 'win': [523, 659, 784, 1046, 1318].forEach((f, i) => this.tone({ f, type: 'triangle', dur: .35, vol: .18, delay: i * .12 })); break;
}
},
/* ---------------- generative dark-synthwave loop ---------------- */
_startMusic() {
if (this._timer) clearInterval(this._timer);
this._step = 0;
this._nextTime = this.ctx.currentTime + .1;
this._timer = setInterval(() => this._schedule(), 30);
},
_schedule() {
if (!this.ctx || document.hidden) return;
const fl = this.stageFlavor || {};
const spb = 60 / (118 * (fl.tempo || 1)) / 4; // 16th-note @118bpm × stage tempo
while (this._nextTime < this.ctx.currentTime + .14) {
this._playStep(this._step, this._nextTime);
this._step = (this._step + 1) % 64;
this._nextTime += spb;
}
},
/** Per-stage musical flavor: semitone shift + tempo multiplier. */
setFlavor(f) { this.stageFlavor = f || null; },
_mnote(freq, when, dur, vol, type, dest) {
const osc = this.ctx.createOscillator();
const g = this.ctx.createGain();
osc.type = type; osc.frequency.value = freq;
g.gain.setValueAtTime(0.0001, when);
g.gain.linearRampToValueAtTime(vol, when + .01);
g.gain.exponentialRampToValueAtTime(0.0001, when + dur);
osc.connect(g); g.connect(dest);
osc.start(when); osc.stop(when + dur + .03);
},
_playStep(step, when) {
const I = this.intensity;
const fl = this.stageFlavor || {};
const chords = [45, 41, 48, 43].map(c => c + (fl.shift || 0)); // Am F C G, stage-shifted
const root = chords[(step >> 4) & 3];
// Bass on syncopated steps
if (step % 8 === 0 || step % 8 === 3 || step % 8 === 6) {
this._mnote(this.midi(root - 12), when, .17, .16, 'square', this.musicG);
}
// Kick
if (I > .25 && step % 8 === 0) this._mnote(110, when, .12, .22 * I, 'sine', this.musicG);
// Hats
if (I > .15 && step % 2 === 1) {
// cheap hat via short high sine tick
this._mnote(8000, when, .02, .04 * Math.min(1, I * 1.6), 'square', this.musicG);
}
// Lead pentatonic random walk
const pent = [0, 3, 5, 7, 10];
const leadDensity = .18 + I * .5;
if (Math.random() < leadDensity && step % 2 === 0) {
const oct = chance(.25) ? 24 : 12;
const note = root + oct + pick(pent);
this._mnote(this.midi(note), when, .14, .07 + I * .05, 'triangle', this.musicG);
}
// Pad chord swell each bar
if (step % 16 === 0) {
[0, 3, 7].forEach(iv => this._mnote(this.midi(root + iv), when, 1.6, .028, 'sawtooth', this.musicG));
}
// Boss tension drone
if (I > .75 && step % 16 === 8) this._mnote(this.midi(root - 24), when, .3, .12, 'sawtooth', this.musicG);
},
setIntensity(v) { this.intensity = clamp(v, 0, 1); }
};
+388
View File
@@ -0,0 +1,388 @@
'use strict';
/* ============================================================
NEON SURVIVORS — data.js : all game content definitions
Weapons / Evolutions / Passives / Characters / Enemies /
Bosses / Spawn timeline / Meta shop.
============================================================ */
/* ---------------- WEAPONS ----------------
Stat model: effective stat = base + sum(per[0..level-2])
dmg damage per hit cd seconds between activations
n projectile/count spd projectile speed (px/s)
pierce extra enemies pierced dur lifetime (s)
area size multiplier kb knockback impulse
*/
const WEAPONS = {
wand: {
sym: '🪄', col: '#b39dff', max: 8,
base: { dmg: 12, cd: 1.1, n: 1, spd: 430, pierce: 0, dur: 1.6, area: 1, kb: 40 },
per: [
{ dmg: 5 }, { n: 1 }, { cd: -0.15 }, { dmg: 6 },
{ n: 1, spd: 60 }, { pierce: 1, dmg: 4 }, { n: 1 }, { cd: -0.15, dmg: 8 }
],
evo: { into: 'e_wand', need: 'p_cd' }
},
whip: {
sym: '🩹', col: '#ff7a9c', max: 8,
base: { dmg: 14, cd: 1.35, n: 1, area: 1.1, kb: 140 },
per: [
{ dmg: 6 }, { area: 0.15 }, { dmg: 8 }, { n: 1 },
{ dmg: 10 }, { area: 0.2 }, { cd: -0.15 }, { dmg: 12 }
],
evo: { into: 'e_whip', need: 'p_hp' }
},
knife: {
sym: '🔪', col: '#cfe8ff', max: 8,
base: { dmg: 8, cd: 0.9, n: 2, spd: 520, pierce: 0, dur: 1.1, kb: 20 },
per: [
{ n: 1 }, { dmg: 4 }, { n: 2 }, { dmg: 4 },
{ pierce: 1 }, { n: 2 }, { dmg: 6 }, { n: 2, spd: 80 }
],
evo: { into: 'e_knife', need: 'p_spd' }
},
orb: {
sym: '🔮', col: '#7dfaff', max: 8,
base: { dmg: 10, cd: 1.0, n: 2, spd: 2.4, area: 1, hitCd: 0.5 },
per: [
{ n: 1 }, { spd: 0.4 }, { dmg: 5 }, { n: 1 },
{ area: 0.15 }, { dmg: 6 }, { n: 1, spd: 0.4 }, { dmg: 8, area: 0.15 }
],
evo: { into: 'e_orb', need: 'p_area' }
},
axe: {
sym: '🪓', col: '#ffb347', max: 8,
base: { dmg: 26, cd: 1.7, n: 1, spd: 240, pierce: 99, area: 1, kb: 200, grav: 900 },
per: [
{ dmg: 12 }, { n: 1 }, { dmg: 12 }, { n: 1 },
{ area: 0.25 }, { dmg: 16 }, { n: 1, cd: -0.2 }, { dmg: 20 }
],
evo: { into: 'e_axe', need: 'p_might' }
},
garlic: {
sym: '💡', col: '#fff3b0', max: 8,
base: { dmg: 5, cd: 0.45, area: 1, kb: 90, radius: 95 },
per: [
{ area: 0.18 }, { dmg: 3 }, { area: 0.18 }, { kb: 40 },
{ dmg: 4 }, { area: 0.22 }, { dmg: 5, cd: -0.05 }, { area: 0.25, dmg: 6 }
],
evo: { into: 'e_garlic', need: 'p_regen' }
},
light: {
sym: '⚡', col: '#9bf6ff', max: 8,
base: { dmg: 30, cd: 2.2, n: 2, area: 1, blastR: 70 },
per: [
{ n: 1 }, { dmg: 12 }, { n: 1 }, { area: 0.2 },
{ dmg: 16 }, { n: 1 }, { cd: -0.3 }, { dmg: 20, n: 1 }
],
evo: { into: 'e_light', need: 'p_dur' }
},
fire: {
sym: '🔥', col: '#ff8c42', max: 8,
base: { dmg: 20, cd: 2.0, n: 1, spd: 300, area: 1, blastR: 85, burnDps: 8, burnDur: 2, kb: 60 },
per: [
{ dmg: 8 }, { n: 1 }, { dmg: 10 }, { area: 0.25 },
{ n: 1 }, { dmg: 12 }, { cd: -0.3 }, { area: 0.3, dmg: 14 }
],
evo: { into: 'e_fire', need: 'p_luck' }
},
frost: {
sym: '❄️', col: '#a0e7ff', max: 8,
base: { dmg: 10, cd: 1.2, n: 1, spd: 480, pierce: 2, dur: 1.4, slow: 0.35, slowDur: 2, kb: 30 },
per: [
{ n: 1 }, { dmg: 5 }, { slow: 0.1 }, { pierce: 2 },
{ dmg: 6 }, { n: 1 }, { slow: 0.1, dmg: 6 }, { n: 2, dmg: 8 }
],
evo: { into: 'e_frost', need: 'p_mag' }
},
cross: {
sym: '✝️', col: '#ffffff', max: 8,
base: { dmg: 16, cd: 1.9, n: 1, spd: 420, pierce: 99, dur: 2.2, area: 1, rehit: 0.45, kb: 80 },
per: [
{ dmg: 8 }, { n: 1 }, { dmg: 8 }, { spd: 60 },
{ n: 1 }, { dmg: 10 }, { dur: 0.4, area: 0.15 }, { n: 1, dmg: 12 }
],
evo: { into: 'e_cross', need: 'p_move' }
},
mine: {
sym: '🧨', col: '#ff6b9d', max: 8,
base: { dmg: 26, cd: 2.6, n: 1, dur: 10, blastR: 85, kb: 140, pierce: 0 },
per: [
{ dmg: 10 }, { dur: 2 }, { blastR: 15 }, { n: 1 },
{ dmg: 14 }, { cd: -0.4 }, { blastR: 20, dmg: 12 }, { n: 1, dmg: 18 }
],
evo: { into: 'e_mine', need: 'p_area' }
}
};
/* ---------------- EVOLVED WEAPONS (max level 1, from chests) ---------------- */
const EVOLVED = {
e_mine: { sym: '💣', col: '#ff4d88', base: { dmg: 55, cd: 1.5, n: 3, dur: 14, blastR: 150, kb: 220 } },
e_wand: { sym: '🌟', col: '#ffe066', base: { dmg: 30, cd: 0.55, n: 6, spd: 560, pierce: 2, dur: 1.8, kb: 60 } },
e_whip: { sym: '🩸', col: '#ff4d6d', base: { dmg: 44, cd: 1.0, n: 2, area: 1.5, kb: 180, lifesteal: 0.08 } },
e_knife: { sym: '🗡️', col: '#e8fbff', base: { dmg: 18, cd: 0.45, n: 8, spd: 640, pierce: 3, dur: 1.2, kb: 20 } },
e_orb: { sym: '💫', col: '#7dfaff', base: { dmg: 28, cd: 0.35, n: 6, spd: 3.4, area: 1.5, hitCd: 0.4 } },
e_axe: { sym: '🌀', col: '#ffd166', base: { dmg: 70, cd: 1.2, n: 4, spd: 320, pierce: 999, area: 1.6, kb: 260, grav: 700 } },
e_garlic:{ sym: '😇', col: '#fffbe0', base: { dmg: 16, cd: 0.3, area: 1.9, kb: 160, radius: 175, healOnKill: 1 } },
e_light: { sym: '🌩️', col: '#d0fbff', base: { dmg: 65, cd: 1.3, n: 5, area: 1.4, blastR: 100, chain: 2, stun: 1.0 } },
e_fire: { sym: '☄️', col: '#ff5c33', base: { dmg: 50, cd: 1.4, n: 2, spd: 340, area: 1.7, blastR: 130, burnDps: 30, burnDur: 4, groundDur: 3.5, groundDps: 35, kb: 90 } },
e_frost: { sym: '💠', col: '#cdf5ff', base: { dmg: 34, cd: 0.9, n: 3, spd: 560, pierce: 99, dur: 1.6, slow: 0.6, slowDur: 2.5, freeze: 1.4, kb: 40 } },
e_cross: { sym: '⚜️', col: '#ffffff', base: { dmg: 60, cd: 1.5, n: 2, spd: 500, pierce: 999, dur: 3.2, area: 1.8, rehit: 0.4, kb: 120 } }
};
/** Unified weapon definition accessor. */
function wdef(id) { return EVOLVED[id] || WEAPONS[id]; }
/** Compute effective stats for a weapon at a given level. */
function wstats(id, lvl) {
const ev = EVOLVED[id];
if (ev) return Object.assign({}, ev.base);
const d = WEAPONS[id];
const s = Object.assign({}, d.base);
for (let i = 0; i < Math.min(lvl - 1, d.per.length); i++) {
const add = d.per[i];
for (const k in add) s[k] = (s[k] || 0) + add[k];
}
s.cd = Math.max(0.12, s.cd);
return s;
}
/* ---------------- PASSIVES ---------------- */
const PASSIVES = {
p_might: { sym: '💪', col: '#ff7a7a', max: 5, val: 0.10 },
p_armor: { sym: '🛡️', col: '#9db4ff', max: 5, val: 1 },
p_hp: { sym: '❤️', col: '#ff5f7a', max: 5, val: 0.20 },
p_regen: { sym: '🌿', col: '#7dff9e', max: 5, val: 0.6 },
p_cd: { sym: '⏱️', col: '#7dd0ff', max: 5, val: 0.08 },
p_area: { sym: '🌐', col: '#c07dff', max: 5, val: 0.12 },
p_spd: { sym: '🏹', col: '#ffd57d', max: 5, val: 0.15 },
p_dur: { sym: '⌛', col: '#ffc7ec', max: 5, val: 0.15 },
p_move: { sym: '👟', col: '#9effd0', max: 5, val: 0.10 },
p_mag: { sym: '🧲', col: '#ffa8f6', max: 5, val: 0.30 },
p_luck: { sym: '🍀', col: '#8aff6b', max: 5, val: 0.10 },
p_greed: { sym: '💰', col: '#ffd24a', max: 5, val: 0.20 },
p_growth: { sym: '📘', col: '#7db8ff', max: 5, val: 0.10 }
};
/** Passive name/desc keys derive from id: p_might -> 'p_might'/'p_might_d'. */
/* ---------------- CHARACTERS ---------------- */
const CHARS = {
kaito: { sym: '🧙', col: '#9b8cff', weapon: 'wand',
mods: {},
cond: { key: 'cond_free', fn: () => true } },
mira: { sym: '🛡️', col: '#ff9db0', weapon: 'whip',
mods: { armorFlat: 1, hpMul: 0.10 },
cond: { key: 'cond_kills500', fn: () => Store.data.totals.kills >= 500 } },
bolt: { sym: '⚡', col: '#ffe066', weapon: 'knife',
mods: { moveMul: 0.12, pspdMul: 0.15 },
cond: { key: 'cond_time10', fn: () => Store.data.totals.best >= 600 } },
luna: { sym: '🌙', col: '#b8a9ff', weapon: 'orb',
mods: { areaMul: 0.18, cdrMul: 0.10 },
cond: { key: 'cond_gold2000', fn: () => Store.data.totals.gold >= 2000 } },
rook: { sym: '🪓', col: '#ffb347', weapon: 'axe',
mods: { dmgMul: 0.20, moveMul: -0.05 },
cond: { key: 'cond_boss1', fn: () => !!Store.data.flags.boss1 } },
nyx: { sym: '🧛', col: '#ff6b81', weapon: 'garlic',
mods: { regenFlat: 0.4, magMul: 0.40 },
cond: { key: 'cond_time15', fn: () => Store.data.totals.best >= 900 } },
sera: { sym: '🌩️', col: '#7dfaff', weapon: 'light',
mods: { cdrMul: 0.12, luckFlat: 0.05 },
cond: { key: 'cond_win', fn: () => Store.data.totals.wins >= 1 } },
vane: { sym: '✝️', col: '#e8e8ff', weapon: 'cross',
mods: { luckFlat: 0.15, greedMul: 0.10 },
cond: { key: 'cond_kills5000', fn: () => Store.data.totals.kills >= 5000 } },
hana: { sym: '🏹', col: '#7dff9e', weapon: 'mine',
mods: { critC: 0.15, critM: 0.3 },
cond: { key: 'cond_time15', fn: () => Store.data.totals.best >= 900 } },
bram: { sym: '🧱', col: '#c96f4a', weapon: 'fire',
mods: { areaMul: 0.20, moveMul: -0.05, hpMul: 0.10 },
cond: { key: 'cond_gold2000', fn: () => Store.data.totals.gold >= 2000 } }
};
/* ---------------- COSMETIC SKINS ----------------
* Deterministic per-character palettes; purchasable with gold.
*/
const SKIN_POOL = [
{ key: 'ember', col: '#ff8c42', vi: 'Lửa', en: 'Ember', price: 300 },
{ key: 'frost', col: '#7fdcff', vi: 'Băng', en: 'Frost', price: 300 },
{ key: 'toxic', col: '#a4f94d', vi: 'Độc', en: 'Toxic', price: 450 },
{ key: 'rose', col: '#ff9db0', vi: 'Hồng', en: 'Rose', price: 450 },
{ key: 'gold', col: '#ffd24a', vi: 'Vàng', en: 'Gold', price: 800 }
];
function _strHash(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h * 16777619) >>> 0; }
return h;
}
/** Two extra skins per character, chosen deterministically from the pool. */
function skinsFor(charId) {
const h = _strHash(charId);
const a = SKIN_POOL[h % SKIN_POOL.length];
let b = SKIN_POOL[(h + 2) % SKIN_POOL.length];
if (b.key === a.key) b = SKIN_POOL[(h + 4) % SKIN_POOL.length];
return [a, b];
}
/* ---------------- ENEMIES ---------------- */
const ENEMIES = {
bat: { nk: 'en_bat', hp: 8, spd: 100, dmg: 6, xp: 1, r: 10, col: '#b06bff', shape: 'bat', ai: 'chase', wiggle: 1.6 },
zombie: { nk: 'en_zombie', hp: 18, spd: 68, dmg: 10, xp: 1, r: 13, col: '#6bd66b', shape: 'zombie', ai: 'chase' },
imp: { nk: 'en_imp', hp: 11, spd: 118, dmg: 8, xp: 1, r: 10, col: '#ff8c42', shape: 'imp', ai: 'chase', wiggle: 2.6 },
arch: { nk: 'en_arch', hp: 24, spd: 76, dmg: 10, xp: 2, r: 13, col: '#e8e8e8', shape: 'archer', ai: 'shooter', range: 270, bspd: 235, bcd: 2.3 },
brute: { nk: 'en_brute', hp: 135, spd: 46, dmg: 22, xp: 5, r: 24, col: '#c96f4a', shape: 'brute', ai: 'chase', kbRes: 0.75 },
ghost: { nk: 'en_ghost', hp: 32, spd: 82, dmg: 12, xp: 2, r: 13, col: '#9ad5ff', shape: 'ghost', ai: 'ghost' },
spit: { nk: 'en_spit', hp: 38, spd: 62, dmg: 12, xp: 3, r: 15, col: '#a4f94d', shape: 'spit', ai: 'shooter', range: 250, bspd: 210, bcd: 2.8, spread: 3 },
garg: { nk: 'en_garg', hp: 95, spd: 60, dmg: 18, xp: 4, r: 17, col: '#8fa3b8', shape: 'garg', ai: 'charger', chargeSpd: 340 },
/* breakable prop — no contact damage, drops coins when smashed */
lamp: { nk: 'en_lamp', hp: 26, spd: 0, dmg: 0, xp: 0, r: 15, col: '#8fd0ff', shape: 'prop', ai: 'prop', prop: true }
};
/* Difficulty multipliers by elapsed minute. */
function diffScale(minute) {
return {
hp: 1 + minute * 0.5 + minute * minute * 0.035,
dmg: 1 + minute * 0.08,
spd: Math.min(1 + minute * 0.012, 1.3)
};
}
/* Which enemy types can appear at a given minute (weighted evenly). */
const SPAWN_TABLE = [
/*00*/ ['bat'],
/*01*/ ['bat', 'zombie'],
/*02*/ ['zombie', 'bat', 'imp'],
/*03*/ ['zombie', 'imp', 'bat'],
/*04*/ ['zombie', 'imp', 'arch'],
/*05*/ ['imp', 'arch', 'zombie', 'bat'],
/*06*/ ['arch', 'imp', 'ghost'],
/*07*/ ['ghost', 'arch', 'brute'],
/*08*/ ['brute', 'ghost', 'imp', 'arch'],
/*09*/ ['brute', 'spit', 'ghost'],
/*10*/ ['spit', 'brute', 'garg', 'imp'],
/*11*/ ['garg', 'spit', 'ghost', 'brute'],
/*12*/ ['garg', 'brute', 'spit', 'bat'],
/*13*/ ['garg', 'spit', 'brute', 'ghost'],
/*14*/ ['garg', 'brute', 'spit', 'garg'],
/*15*/ ['garg', 'brute', 'spit', 'ghost'],
/*16*/ ['garg', 'brute', 'spit', 'imp'],
/*17*/ ['garg', 'brute', 'garg', 'spit'],
/*18*/ ['garg', 'brute', 'spit', 'garg'],
/*19*/ ['garg', 'brute', 'spit', 'garg']
];
/* Elite spawn schedule (seconds). Elites drop treasure chests. */
const ELITE_TIMES = [70, 145, 220, 295, 370, 445, 520, 595, 670, 745, 820, 895, 970, 1045, 1120];
/* Random elite modifiers — one may roll on spawn (65%). Bilingual inline. */
const ELITE_AFFIXES = {
haste: { icon: '⚡', vi: 'Tốc Độ', en: 'Swift', col: '#ffe066',
apply(e) { e.spd *= 1.45; } },
tank: { icon: '🛡️', vi: 'Cứng Cáp', en: 'Sturdy', col: '#9db4ff',
apply(e) { e.hp *= 1.6; e.maxhp = e.hp; e.dmg *= 1.15;
e.kbRes = Math.min(0.92, (e.def.kbRes || 0) + 0.55); } },
blast: { icon: '💥', vi: 'Nổ Tung', en: 'Volatile', col: '#ff8c42',
apply(e) { e.blastOnDeath = true; } }
};
/* Boss plan — killing the final boss wins the run. */
const RUN_LENGTH = 1200;
const BOSS_PLAN = [
{ t: 300, id: 'b_wolf' },
{ t: 600, id: 'b_lich' },
{ t: 900, id: 'b_behemoth' },
{ t: 1140, id: 'b_death' }
];
const BOSSES = {
b_wolf: { nk: 'bn_wolf', hp: 850, spd: 165, dmg: 25, r: 30, col: '#8a5cff', shape: 'wolf', ai: 'boss_wolf', xp: 60 },
b_lich: { nk: 'bn_lich', hp: 2400, spd: 58, dmg: 28, r: 30, col: '#c9f0ff', shape: 'lich', ai: 'boss_lich', xp: 100 },
b_behemoth: { nk: 'bn_behemoth', hp: 6500, spd: 42, dmg: 34, r: 44, col: '#ff5c5c', shape: 'behemoth', ai: 'boss_beho', xp: 160 },
b_death: { nk: 'bn_death', hp: 15000, spd: 195, dmg: 45, r: 34, col: '#ddddff', shape: 'death', ai: 'boss_death', xp: 400 }
};
/* ---------------- STAGES ----------------
* Each biome swaps the spawn table / boss plan / palette and applies
* hp/gold multipliers. Killing the plan's final boss wins (unless Endless).
*/
const STAGES = {
graveyard: {
id: 'graveyard', icon: '🪦', unlock: () => true,
music: { shift: 0, tempo: 1 },
bg: { grid: 'rgba(90,90,160,0.10)', mote: ['#5a4d9c', '#3f7fb0'] },
table: SPAWN_TABLE,
plan: [
{ t: 300, id: 'b_wolf' },
{ t: 600, id: 'b_lich' },
{ t: 900, id: 'b_behemoth' },
{ t: 1140, id: 'b_death', final: true }
],
length: RUN_LENGTH,
hpMul: 1, goldMul: 1
},
frostcave: {
id: 'frostcave', icon: '❄️', unlock: s => s.flags.time10,
music: { shift: -3, tempo: 0.92 }, // darker, slower
lockKey: 'st_lock_frost',
bg: { grid: 'rgba(120,190,255,0.10)', mote: ['#7fc8ff', '#b0e8ff'] },
table: (() => {
// colder mix: ghosts early, brutes mid, gargs sooner
const t = SPAWN_TABLE.map(r => r.slice());
t[2] = ['ghost', 'zombie', 'bat'];
t[6] = ['ghost', 'arch', 'imp'];
t[9] = ['brute', 'garg', 'spit'];
t[12] = ['garg', 'garg', 'spit', 'brute'];
return t;
})(),
plan: [
{ t: 240, id: 'b_lich' },
{ t: 540, id: 'b_wolf' },
{ t: 870, id: 'b_behemoth' },
{ t: 1140, id: 'b_death', final: true }
],
length: RUN_LENGTH,
hpMul: 1.12, goldMul: 1.25
},
magmalands: {
id: 'magmalands', icon: '🌋', unlock: s => s.totals.wins >= 1,
music: { shift: 2, tempo: 1.08 }, // hotter, faster
lockKey: 'st_lock_magma',
bg: { grid: 'rgba(255,120,60,0.09)', mote: ['#ff8c42', '#ffd166'] },
table: (() => {
// violent mix: imps early, spitters sooner, heavy late
const t = SPAWN_TABLE.map(r => r.slice());
t[1] = ['bat', 'imp'];
t[4] = ['imp', 'spit', 'zombie'];
t[7] = ['spit', 'brute', 'garg'];
t[10] = ['garg', 'brute', 'brute', 'spit'];
t[16] = ['garg', 'garg', 'brute', 'brute'];
return t;
})(),
plan: [
{ t: 210, id: 'b_behemoth' },
{ t: 480, id: 'b_lich' },
{ t: 780, id: 'b_wolf' },
{ t: 1080, id: 'b_death', final: true }
],
length: RUN_LENGTH,
hpMul: 1.28, goldMul: 1.5
}
};
/* ---------------- META SHOP ---------------- */
const META_SHOP = [
{ id: 'might', sym: '💪', base: 100, max: 5 },
{ id: 'hp', sym: '❤️', base: 100, max: 5 },
{ id: 'armor', sym: '🛡️', base: 120, max: 5 },
{ id: 'regen', sym: '🌿', base: 150, max: 5 },
{ id: 'spd', sym: '👟', base: 120, max: 5 },
{ id: 'cd', sym: '⏱️', base: 180, max: 5 },
{ id: 'growth', sym: '📘', base: 130, max: 5 },
{ id: 'greed', sym: '💰', base: 130, max: 5 },
{ id: 'luck', sym: '🍀', base: 140, max: 5 },
{ id: 'magnet', sym: '🧲', base: 110, max: 5 },
{ id: 'reroll', sym: '🎲', base: 150, max: 3 },
{ id: 'revive', sym: '🕊️', base: 350, max: 2 }
];
function metaCost(def, rank) { return Math.round(def.base * Math.pow(1.7, rank)); }
+280
View File
@@ -0,0 +1,280 @@
'use strict';
/* ============================================================
NEON SURVIVORS — entities.js : entity classes & factories
============================================================ */
let _eid = 1;
/* ================= PLAYER ================= */
class Player {
constructor(charId) {
const c = CHARS[charId];
this.charId = charId;
this.sym = c.sym;
this.col = c.col;
// equipped cosmetic skin (owned only)
try {
const sel = Store.data.skinSel[charId];
if (sel && Store.data.skinOwned[charId + ':' + sel]) {
const def = skinsFor(charId).find(s => s.key === sel);
if (def) this.col = def.col;
}
} catch (e) { /* headless tests / legacy saves */ }
this.x = 0; this.y = 0;
this.r = 13;
this.faceX = 1; this.faceY = 0;
this.level = 1;
this.xp = 0;
this.xpNext = this._xpFor(1);
this.gold = 0;
this.kills = 0;
this.weapons = [{ id: c.weapon, lvl: 1, timer: 0.35 }];
this.passives = {};
this.rerollMax = 0;
this.regenAcc = 0;
this.invuln = 0;
this.revives = 0;
this.recompute();
this.hp = this.maxhp;
}
_xpFor(level) {
return Math.floor(10 + (level - 1) * 9 + Math.pow(level - 1, 1.9));
}
/** Recompute derived stats from meta shop + passives + character mods. */
recompute() {
const m = Store.data.meta;
const rank = id => m[id] || 0;
let dmgPct = rank('might') * 0.05;
let hpPct = rank('hp') * 0.10;
let armor = rank('armor');
let regen = rank('regen') * 0.3;
let movePct = rank('spd') * 0.04;
let cdr = rank('cd') * 0.04;
let growth = rank('growth') * 0.06;
let greed = rank('greed') * 0.15;
let luck = rank('luck') * 0.10;
let magMul = 1 + rank('magnet') * 0.20;
this.rerollMax = rank('reroll');
// passives
const pl = this.passives;
dmgPct += (pl.p_might || 0) * PASSIVES.p_might.val;
armor += (pl.p_armor || 0) * PASSIVES.p_armor.val;
hpPct += (pl.p_hp || 0) * PASSIVES.p_hp.val;
regen += (pl.p_regen || 0) * PASSIVES.p_regen.val;
cdr += (pl.p_cd || 0) * PASSIVES.p_cd.val;
let areaPct = (pl.p_area || 0) * PASSIVES.p_area.val;
let pspdPct = (pl.p_spd || 0) * PASSIVES.p_spd.val;
let durPct = (pl.p_dur || 0) * PASSIVES.p_dur.val;
movePct += (pl.p_move || 0) * PASSIVES.p_move.val;
let magPct = (pl.p_mag || 0) * PASSIVES.p_mag.val;
luck += (pl.p_luck || 0) * PASSIVES.p_luck.val;
greed += (pl.p_greed || 0) * PASSIVES.p_greed.val;
growth += (pl.p_growth || 0) * PASSIVES.p_growth.val;
// character modifiers
const cm = CHARS[this.charId].mods;
dmgPct += cm.dmgMul || 0;
hpPct += cm.hpMul || 0;
armor += cm.armorFlat || 0;
regen += cm.regenFlat || 0;
movePct += cm.moveMul || 0;
cdr += cm.cdrMul || 0;
areaPct += cm.areaMul || 0;
pspdPct += cm.pspdMul || 0;
magPct += cm.magMul || 0;
luck += cm.luckFlat || 0;
greed += cm.greedMul || 0;
this.might = Math.max(0.1, 1 + dmgPct);
this.maxhp = Math.round(100 * (1 + hpPct));
this.armor = armor;
this.regen = regen;
this.spd = 175 * (1 + movePct);
this.cdrMul = Math.max(0.40, 1 - cdr);
this.areaMul = Math.max(0.2, 1 + areaPct);
this.pspdMul = Math.max(0.2, 1 + pspdPct);
this.durMul = Math.max(0.2, 1 + durPct);
this.magR = 60 * magMul * (1 + magPct);
this.luck = Math.max(0, luck);
this.greed = Math.max(0, greed);
this.growth = Math.max(0, growth);
this.critC = 0.05 + (pl.p_luck ? pl.p_luck * 0.02 : 0) + (cm.critC || 0);
this.critM = 2.0 + (cm.critM || 0);
if (this.hp > this.maxhp) this.hp = this.maxhp;
}
weaponById(id) { return this.weapons.find(w => w.id === id); }
canOfferWeapon(id) {
// not owned yet and there is a free slot
return !this.weaponById(id) && this.weapons.length < 6 && !this.hasEvolvedBase(id);
}
hasEvolvedBase(baseId) {
const into = WEAPONS[baseId] && WEAPONS[baseId].evo.into;
return !!this.weaponById(into);
}
canLevelWeapon(id) {
const w = this.weaponById(id);
return w && w.lvl < WEAPONS[id].max;
}
canOfferPassive(id) {
const cur = this.passives[id] || 0;
if (cur >= PASSIVES[id].max) return false;
if (cur > 0) return true; // already owned
return Object.keys(this.passives).length < 6; // free slot
}
addXp(amount) {
this.xp += amount;
let ups = 0;
while (this.xp >= this.xpNext) {
this.xp -= this.xpNext;
this.level++;
this.xpNext = this._xpFor(this.level);
ups++;
}
return ups;
}
}
/* ================= ENEMY ================= */
class Enemy {
constructor(tid, x, y) {
const d = BOSSES[tid] || ENEMIES[tid];
this.id = _eid++;
this.tid = tid;
this.boss = !!BOSSES[tid];
this.def = d;
this.nk = d.nk;
this.x = x; this.y = y;
this.r = d.r;
this.spd = d.spd;
this.dmg = d.dmg;
this.xpVal = d.xp;
this.col = d.col;
this.hp = d.hp; this.maxhp = d.hp;
this.elite = false;
this.slowT = 0; this.slowF = 1;
this.freezeT = 0;
this.burnT = 0; this.burnDps = 0;
this.stunT = 0;
this.kbx = 0; this.kby = 0;
this.flash = 0;
this.lastTouch = -99;
this.shootT = rand(0.5, 1.8);
this.aiT = rand(0, 2);
this.state = 0; // generic AI state
this.wob = rand(TAU); // wiggle phase
this.rot = 0;
this.dead = false;
}
get speedFactor() {
if (this.freezeT > 0) return 0;
return this.slowT > 0 ? 1 - this.slowF : 1;
}
applyElite(minute) {
this.elite = true;
this.hp *= 9; this.maxhp = this.hp;
this.dmg *= 1.6;
this.spd *= 0.92;
this.r *= 1.55;
this.xpVal *= 8;
}
scaleTo(minute, mult) {
const s = diffScale(minute);
const m = mult || 1;
this.hp *= s.hp * m; this.maxhp = this.hp;
this.dmg *= s.dmg * Math.sqrt(m);
this.spd *= s.spd;
}
}
/* ================= PROJECTILE ================= */
class Proj {
constructor(o) { Object.assign(this, o); this.hits = new Set(); this.hitTimes = new Map(); this.t = 0; this.maxLife = this.life || 1; }
}
/* ================= ENEMY BULLET ================= */
class EBullet {
constructor(x, y, vx, vy, dmg, col) {
this.x = x; this.y = y; this.vx = vx; this.vy = vy;
this.dmg = dmg; this.r = 6; this.life = 5; this.col = col || '#ffd24a';
}
}
/* ================= PICKUP ================= */
class Pickup {
constructor(type, x, y, val) {
this.type = type; // gem | coin | chicken | magnet | bomb | chest
this.x = x; this.y = y;
this.v = val || 1;
this.r = type === 'chest' ? 16 : type === 'chicken' ? 12 : 7;
this.vx = 0; this.vy = 0;
this.t = rand(TAU);
this.pull = false;
this.dead = false;
switch (type) {
case 'gem': this.col = val >= 25 ? '#ff5fd0' : val >= 5 ? '#57e8ff' : '#69ff8c'; break;
case 'coin': this.col = '#ffd24a'; break;
case 'chicken': this.col = '#ff9d5c'; break;
default: this.col = '#ffffff';
}
}
}
/* ================= PARTICLE / FLOATING TEXT / EFFECT ================= */
/** Resettable particle — pooled via part(G,...) to avoid GC churn. */
class Particle {
constructor(x, y, col, opts) { this.set(x, y, col, opts || {}); }
set(x, y, col, o) {
this.x = x; this.y = y;
const a = o.ang !== undefined ? o.ang : rand(TAU);
const sp = o.sp !== undefined ? o.sp : rand(30, 160);
this.vx = Math.cos(a) * sp; this.vy = Math.sin(a) * sp;
this.life = this.maxLife = o.life || rand(0.25, 0.6);
this.size = o.size || rand(2, 4.5);
this.col = col;
this.grav = o.grav || 0;
this.drag = o.drag !== undefined ? o.drag : 0.94;
return this;
}
}
class FText {
/**
* Floating combat text.
* extra: { life, vy, pop } — pop = scale-punch on spawn (crits, heals).
*/
constructor(x, y, txt, col, big, extra) {
this.x = x + rand(-6, 6); this.y = y;
this.txt = txt; this.col = col || '#fff';
this.big = big || false;
extra = extra || {};
this.life = extra.life || 0.75;
this.maxLife = this.life;
this.vy = extra.vy !== undefined ? extra.vy : 34;
this.pop = extra.pop || (big ? 1.7 : 1);
this.t = 0;
}
}
class Effect {
constructor(type, x, y, dur, data) {
this.type = type; this.x = x; this.y = y;
this.t = 0; this.dur = dur;
this.data = data || {};
this.dead = false;
}
}
+739
View File
@@ -0,0 +1,739 @@
'use strict';
/* ============================================================
NEON SURVIVORS — game.js : Game class, loop, states, input
============================================================ */
const ST = { MENU: 'menu', RUN: 'run', PAUSE: 'pause', LEVEL: 'level', CHEST: 'chest', OVER: 'over' };
class Game {
constructor(canvas) {
this.cv = canvas;
this.ctx = canvas.getContext('2d');
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.state = ST.MENU;
// world
this.player = null;
this.enemies = [];
this.projs = [];
this.ebullets = [];
this.pickups = [];
this.parts = [];
this.partPool = [];
this.texts = [];
this.effects = [];
this.grid = new SpatialGrid(96);
this.cam = { x: 0, y: 0 };
this.time = 0;
this.lastDt = 0.016;
this.shake = 0;
this.hurtFlash = 0;
this.partCap = 600;
this.over = false;
this.playerHidden = false;
// flow
this.queueLevelUps = 0;
this.pendingChests = 0;
this.currentChoices = [];
this.rerollsLeft = 0;
this.goldEarned = 0;
this.charId = 'kaito';
this._screenFrom = 'scr-menu';
// developer mode (session-scoped)
this.timeScale = 1;
this.devGod = false;
this.devOneHit = false;
this.freezeT = 0; // hit-stop / slow-mo on big kills
// director state
this.spawnAcc = 0;
this.lastBurstMinute = -1;
this.eliteIdx = 0;
this.bossIdx = 0;
this.boss = null;
// input
this.keys = new Set();
this.touchVec = { x: 0, y: 0 };
this._bindInput();
this._resize();
window.addEventListener('resize', () => this._resize());
document.addEventListener('visibilitychange', () => {
if (document.hidden && this.state === ST.RUN && Store.s().autoPause) this.togglePause(true);
});
this.applyQuality();
this._last = performance.now();
const loop = (t) => {
const dt = Math.min(0.05, (t - this._last) / 1000);
this._last = t;
this.lastDt = dt;
this.frame(dt);
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
/* ================= setup ================= */
_resize() {
this.dpr = Math.min(window.devicePixelRatio || 1, 2);
this.cv.width = innerWidth * this.dpr;
this.cv.height = innerHeight * this.dpr;
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
R._vig = null;
}
applyQuality() {
const q = Store.s().particles;
this.partCap = q === 'low' ? 120 : q === 'med' ? 320 : 650;
}
applySettings() {
Snd.applyVolumes();
UI.els.fpsMeter.classList.toggle('hidden', !Store.s().fps);
this.applyQuality();
}
/* ================= run lifecycle ================= */
startRun(charId, setup) {
setup = setup || {};
this.setup = setup;
// deterministic seed for Daily Challenge; null restores Math.random
seedRng(setup.seed !== undefined ? setup.seed : null);
this.charId = charId || this._charSelDefault();
const stageId = STAGES[setup.stage] ? setup.stage : 'graveyard';
this.stage = STAGES[stageId];
this.endless = !!setup.endless;
this.grade = setup.grade | 0;
this.daily = !!setup.daily;
this.diffMul = (this.stage.hpMul || 1) * (1 + this.grade * 0.32);
this.goldMul = (this.stage.goldMul || 1) * (1 + this.grade * 0.15);
this.finalBossId = this.stage.plan[this.stage.plan.length - 1].id;
this.propAcc = 0;
this.lastSurge = -1;
// tutorial state (skipped once completed)
this._tutStep = Store.data.flags.tutDone ? -1 : (setup.daily ? -1 : 0);
this._tutMoved = 0;
this.player = new Player(this.charId);
this.enemies.length = 0;
this.projs.length = 0;
this.ebullets.length = 0;
this.pickups.length = 0;
this.effects.length = 0;
this.texts.length = 0;
this.time = 0;
this.over = false;
this.playerHidden = false;
this.spawnAcc = 0;
this.lastBurstMinute = -1;
this.eliteIdx = 0;
this.bossIdx = 0;
this.boss = null;
this.queueLevelUps = 0;
this.pendingChests = 0;
this.goldEarned = 0;
this.rerollsLeft = this.player.rerollMax;
this.cam.x = 0; this.cam.y = 0;
this.shake = 0;
this.hurtFlash = 0;
this.state = ST.RUN;
UI.showScreen(null);
UI.showHud(true);
UI.hideBossBar();
Snd.setFlavor(this.stage.music || null);
UI.toast(tr('tip_move'));
Snd.init();
}
_charSelDefault() { return UI._charSel || 'kaito'; }
frame(dt) {
// developer tools visibility (cheap toggle)
UI.els.devBtn.classList.toggle('hidden',
!(Store.s().dev && (this.state === ST.RUN || this.state === ST.PAUSE ||
this.state === ST.LEVEL || this.state === ST.CHEST)));
switch (this.state) {
case ST.RUN:
this.update(dt * (this.timeScale || 1));
R.draw(this);
UI.hudUpdate(this, dt);
break;
case ST.PAUSE:
case ST.LEVEL:
case ST.CHEST:
R.draw(this); // frozen world behind modal
break;
case ST.OVER:
R.draw(this);
break;
default:
this._renderMenuBg(dt);
}
}
update(dt) {
// hit-stop: brief slow-mo window after elite/boss kills
if (this.freezeT > 0) {
this.freezeT -= dt;
dt *= 0.12;
}
const P = this.player;
this.time += dt;
// ---- movement input ----
let mx = 0, my = 0;
const kb = Store.s().keybinds;
if (this.keys.has(kb.up) || this.keys.has('ArrowUp')) my -= 1;
if (this.keys.has(kb.down) || this.keys.has('ArrowDown')) my += 1;
if (this.keys.has(kb.left) || this.keys.has('ArrowLeft')) mx -= 1;
if (this.keys.has(kb.right) || this.keys.has('ArrowRight')) mx += 1;
// gamepad: left stick + d-pad + edge-triggered A/Start
if (typeof navigator !== 'undefined' && navigator.getGamepads) {
let gp = null;
try {
for (const p of navigator.getGamepads()) if (p && p.connected) { gp = p; break; }
} catch (e) { /* some browsers throw when no permission */ }
if (gp) {
const dz = v => (Math.abs(v) > 0.22 ? v : 0);
mx += dz(gp.axes[0] || 0);
my += dz(gp.axes[1] || 0);
const btn = i => !!(gp.buttons[i] && gp.buttons[i].pressed);
if (btn(12)) my -= 1;
if (btn(13)) my += 1;
if (btn(14)) mx -= 1;
if (btn(15)) mx += 1;
const prev = this._gpPrev || (this._gpPrev = {});
const now0 = btn(0), nowStart = btn(9) || btn(8);
if (now0 && !prev.b0) {
if (this.state === ST.LEVEL) this.chooseUpgrade(0);
else if (this.state === ST.CHEST) this.closeChest();
}
if (nowStart && !prev.start) {
if (this.state === ST.RUN || this.state === ST.PAUSE) this.togglePause();
}
prev.b0 = now0; prev.start = nowStart;
}
}
mx += this.touchVec.x; my += this.touchVec.y;
const ml = len2(mx, my);
if (ml > 1) { mx /= ml; my /= ml; }
P.x += mx * P.spd * dt;
P.y += my * P.spd * dt;
if (Math.abs(mx) + Math.abs(my) > 0.01) {
const fl = Math.max(0.001, len2(mx, my));
P.faceX = mx / fl; P.faceY = my / fl;
}
// tutorial: step 0 — learn to move; step 1 — make a level-up choice
if (this._tutStep === 0) {
UI.showTut('tut_move');
this._tutMoved += len2(mx, my) * P.spd * dt;
if (this._tutMoved > 260) { this._tutStep = 1; UI.hideTut(); }
} else if (this._tutStep === 1 && this.player.level >= 2) {
UI.showTut('tut_choose');
}
// regen / timers
if (P.regen > 0) P.hp = Math.min(P.maxhp, P.hp + P.regen * dt);
if (P.invuln > 0) P.invuln -= dt;
if (this.hurtFlash > 0) this.hurtFlash -= dt;
if (this.shake > 0) this.shake = Math.max(0, this.shake - dt * 26);
// systems
Sys.fireWeapons(this, dt);
Sys.spawnTick(this, dt);
Sys.updEnemies(this, dt);
Sys.updProjectiles(this, dt);
Sys.updEffects(this, dt);
Sys.updEBullets(this, dt);
Sys.updPickups(this, dt);
// particles (pooled)
for (let i = this.parts.length - 1; i >= 0; i--) {
const p = this.parts[i];
p.life -= dt;
if (p.life <= 0) {
removeItem(this.parts, i);
if (this.partPool.length < 500) this.partPool.push(p);
continue;
}
p.x += p.vx * dt; p.y += p.vy * dt;
p.vy += p.grav * dt;
const dr = Math.pow(p.drag, dt * 60);
p.vx *= dr; p.vy *= dr;
}
// camera
const k = 1 - Math.exp(-8 * dt);
this.cam.x += (P.x - this.cam.x) * k;
this.cam.y += (P.y - this.cam.y) * k;
// music intensity
Snd.setIntensity(Sys.musicIntensity(this));
// ---- flow: chests first, then level ups ----
if (this.state === ST.RUN) {
if (this.pendingChests > 0) {
this.pendingChests--;
this._openChestFlow();
return;
}
if (this.queueLevelUps > 0) {
this.queueLevelUps--;
this.currentChoices = Sys.makeChoices(this);
this.state = ST.LEVEL;
UI.hideModal('md-chest');
UI.openLevelUp(this.currentChoices, this.rerollsLeft);
return;
}
}
}
/* ================= flow helpers ================= */
chooseUpgrade(idx) {
if (this.state !== ST.LEVEL) return;
const c = this.currentChoices[idx];
if (!c) return;
Sys.applyChoice(this, c);
this._tutComplete();
this._afterModal();
}
/** Finish the tutorial once the player makes their first upgrade choice. */
_tutComplete() {
if (this._tutStep === 1 || !Store.data.flags.tutDone) {
Store.data.flags.tutDone = true;
Store.save();
UI.hideTut();
}
this._tutStep = -1;
}
skipLevelUp() {
if (this.state !== ST.LEVEL) return;
this.player.gold += 15;
this.goldEarned += 15;
this._afterModal();
}
rerollChoices() {
if (this.state !== ST.LEVEL || this.rerollsLeft <= 0) return;
this.rerollsLeft--;
Snd.play('click');
this.currentChoices = Sys.makeChoices(this);
UI.hideModal('md-level');
UI.openLevelUp(this.currentChoices, this.rerollsLeft);
}
_afterModal() {
UI.hideModal('md-level');
if (this.pendingChests > 0) {
this.pendingChests--;
this._openChestFlow();
return;
}
if (this.queueLevelUps > 0) {
this.queueLevelUps--;
this.currentChoices = Sys.makeChoices(this);
this.state = ST.LEVEL;
UI.openLevelUp(this.currentChoices, this.rerollsLeft);
return;
}
this.state = ST.RUN;
}
_openChestFlow() {
const data = Sys.chestReward(this);
this.state = ST.CHEST;
UI.hideModal('md-level');
UI.openChest(data);
}
closeChest() {
UI.hideModal('md-chest');
if (this.queueLevelUps > 0) {
this.queueLevelUps--;
this.currentChoices = Sys.makeChoices(this);
this.state = ST.LEVEL;
UI.openLevelUp(this.currentChoices, this.rerollsLeft);
return;
}
if (this.pendingChests > 0) {
this.pendingChests--;
this._openChestFlow();
return;
}
this.state = ST.RUN;
}
/* ================= events ================= */
onBossDead(e) {
if (e.tid === 'b_wolf' && !Store.data.flags.boss1) {
Store.data.flags.boss1 = true;
Store.save();
UI.toast(tr('toast_unlock', { c: tr('c_rook') }), 'gold');
}
if (this.boss === e) {
this.boss = null;
UI.hideBossBar();
}
if (e.tid === this.finalBossId && !this.endless) {
Snd.play('win', true);
this.gameOver(true);
} else {
if (e.tid === this.finalBossId && this.endless) {
UI.toast(tr('toast_endless_on'), 'gold');
}
this.shakeIt(10);
Snd.play('boom', true);
}
}
onPlayerDown() {
const P = this.player;
if (P.revives > 0) {
P.revives--;
P.hp = Math.round(P.maxhp * 0.6);
P.invuln = 2.2;
this.effects.push(new Effect('nova', P.x, P.y, 0.6, { r: 300, col: '#ffe066' }));
for (const e of this.enemies) {
if (!e.boss && dist2(P.x, P.y, e.x, e.y) < 280 * 280) {
Sys.damageEnemy(this, e, 9999, {});
}
}
UI.toast(tr('toast_revive'), 'gold');
Snd.play('evolve', true);
return;
}
Snd.play('death', true);
this.gameOver(false);
}
gameOver(win) {
if (this.over) return;
this.over = true;
this.state = ST.OVER;
const P = this.player;
// persist progression
Store.data.totals.kills += P.kills;
Store.data.totals.runs++;
Store.addGold(P.gold);
let newRecord = false;
if (this.time > Store.data.totals.best && this.time >= 30) {
Store.data.totals.best = Math.floor(this.time);
newRecord = true;
}
if (this.time >= 600) Store.data.flags.time10 = true;
if (this.time >= 900) Store.data.flags.time15 = true;
if (win) Store.data.totals.wins++;
// stage / mode records + run history
const sec = Math.floor(this.time);
const prog = Store.data.progress;
if (win) {
prog.stageWins[this.stage.id] = (prog.stageWins[this.stage.id] || 0) + 1;
prog.gradeWins[this.grade] = (prog.gradeWins[this.grade] || 0) + 1;
}
if (win || sec > (prog.bestPerStage[this.stage.id] || 0)) {
prog.bestPerStage[this.stage.id] = Math.max(prog.bestPerStage[this.stage.id] || 0, win ? this.stage.length : sec);
}
if (this.endless && sec > (prog.endlessBest || 0)) { prog.endlessBest = sec; newRecord = true; }
Store.data.history.unshift({
d: Date.now(), ch: this.charId, st: this.stage.id,
en: this.endless ? 1 : 0, g: this.grade,
t: sec, k: P.kills, gold: P.gold, lv: P.level, w: win ? 1 : 0
});
if (Store.data.history.length > 20) Store.data.history.length = 20;
// daily challenge record
if (this.daily) {
const today = dailyKey();
const d = Store.data.daily;
if (d.date !== today) { d.date = today; d.best = 0; }
if (sec > (d.best || 0)) {
d.best = sec;
UI.toast(tr('toast_daily_record'), 'gold');
}
}
Store.save();
UI.checkAndToastAch();
this._lbSubmit(win, sec);
setTimeout(() => {
UI.openOver(win, {
time: this.time,
kills: P.kills,
goldEarned: P.gold,
level: P.level,
best: Store.data.totals.best,
newRecord
});
}, win ? 400 : 700);
if (win) this.shakeIt(12);
}
/** Best-effort leaderboard submit (silent when no server configured). */
_lbSubmit(win, sec) {
const s = Store.s();
if (!s.lbUrl || sec < 30) return;
try {
const payload = {
name: (Store.data.lbName || 'Anon').slice(0, 16),
time: sec, kills: this.player.kills, gold: this.player.gold,
lv: this.player.level, stageId: this.stage.id, grade: this.grade,
endless: !!this.endless, daily: !!this.daily, win: !!win,
day: new Date().toISOString().slice(0, 10).replace(/-/g, '')
};
payload.sig = lbSig(payload);
fetch(s.lbUrl.replace(/\/$/, '') + '/submit', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true
}).then(r => { if (r.ok) UI.toast(tr('lb_sent'), 'gold'); })
.catch(() => {});
} catch (e) { /* offline — ignore */ }
}
togglePause(forcePause) {
if (this.state === ST.RUN) {
this.state = ST.PAUSE;
UI.openPause(this);
} else if (this.state === ST.PAUSE && !forcePause) {
this.state = ST.RUN;
UI.hideModal('md-pause');
}
}
quitToMenu() {
Snd.setFlavor(null);
// abandoning a live run still banks gold/kills/time records
if (this.player && !this.over && this.time > 5) {
const P = this.player;
Store.data.totals.kills += P.kills;
Store.data.totals.runs++;
Store.addGold(P.gold);
if (this.time > Store.data.totals.best && this.time >= 30)
Store.data.totals.best = Math.floor(this.time);
if (this.time >= 600) Store.data.flags.time10 = true;
if (this.time >= 900) Store.data.flags.time15 = true;
this.over = true;
}
Store.save();
this.state = ST.MENU;
UI.hideModal('md-pause');
UI.hideModal('md-over');
UI.showHud(false);
UI.showScreen('scr-menu');
Snd.setIntensity(0.2);
}
restart() {
UI.hideModal('md-over');
this.startRun(this.charId);
}
onBackFromScreen() {
// returning from options/codex/shop/chars/stage screens
const visible = ['scr-menu', 'scr-chars', 'scr-stage', 'scr-shop', 'scr-codex', 'scr-options']
.find(id => !UI.els[id].classList.contains('hidden'));
if (this.state === ST.MENU || visible === 'scr-menu') {
UI.showScreen('scr-menu');
} else if (visible === 'scr-options' && this.state === ST.PAUSE) {
UI.showScreen(null);
UI.openPause(this);
} else {
// browsing menus pre-run
if (visible === 'scr-stage') { UI.buildChars(); UI.showScreen('scr-chars'); }
else UI.showScreen('scr-menu');
}
}
buyMeta(id) {
const def = META_SHOP.find(m => m.id === id);
if (!def) return;
const rank = Store.rank(id);
if (rank >= def.max) return;
const cost = metaCost(def, rank);
if (!Store.spendGold(cost)) return;
Store.data.meta[id] = rank + 1;
Store.save();
if (this.player) this.player.recompute();
UI.buildShop();
UI.refreshHeadGold();
UI.checkAndToastAch();
}
/* ================= developer mode ================= */
devAddGold(n) {
const P = this.player;
P.gold += n; this.goldEarned += Math.max(0, n);
Snd.play('coin');
}
devSetGold(v) {
v = Math.max(0, Math.floor(v) || 0);
this.goldEarned += Math.max(0, v - this.player.gold);
this.player.gold = v;
}
devAddLevels(n) {
const P = this.player;
for (let i = 0; i < n; i++) {
this.queueLevelUps += P.addXp(P.xpNext);
}
}
devGiveWeapon(id, maxIt) {
const P = this.player;
let w = P.weaponById(id);
if (EVOLVED[id]) {
if (!w) P.weapons.push({ id, lvl: 1, timer: 0.2 });
} else if (w) {
w.lvl = maxIt ? WEAPONS[id].max : Math.min(WEAPONS[id].max, w.lvl + 1);
} else {
P.weapons.push({ id, lvl: maxIt ? WEAPONS[id].max : 1, timer: 0.2 });
}
Snd.play('chest');
}
devGivePassive(id, maxIt) {
const P = this.player;
P.passives[id] = maxIt ? PASSIVES[id].max
: Math.min(PASSIVES[id].max, (P.passives[id] || 0) + 1);
P.recompute();
Snd.play('levelup');
}
devHeal() { this.player.hp = this.player.maxhp; Snd.play('heal'); }
devMagnetAll() {
for (const pk of this.pickups) pk.pull = true;
Snd.play('heal');
}
devNuke(radius) {
const P = this.player;
const r = radius || 900;
this.effects.push(new Effect('nova', P.x, P.y, 0.5, { r, col: '#ffd24a' }));
for (const e of this.enemies) {
if (dist2(P.x, P.y, e.x, e.y) < r * r) Sys.damageEnemy(this, e, 9999999, {});
}
Snd.play('boom');
this.shakeIt(10);
}
devSkipTime(sec) { this.time += sec; }
devSpawnElite() { Sys.spawnElite(this); }
devSpawnBoss(id) {
if (!BOSSES[id]) return;
const pos = Sys.ringPos(this, 520);
const b = new Enemy(id, pos.x, pos.y);
b.hp *= 1 + Math.floor(this.time / 60) * 0.06;
b.maxhp = b.hp;
this.enemies.push(b);
this.boss = b;
UI.showBossBar(b);
UI.toast(tr('toast_boss'), 'warn');
Snd.play('roar', true);
this.shakeIt(8);
}
devUnlockAllChars() {
for (const id in CHARS) Store.unlockChar(id);
Store.save();
UI.toast('🔓 ' + tr('dev_unlockall'), 'gold');
}
shakeIt(a) {
if (Store.s().shake) this.shake = Math.min(14, this.shake + a);
}
/* ================= input ================= */
_bindInput() {
window.addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
this.keys.add(e.code);
Snd.init();
// developer mode shortcuts
if (Store.s().dev && e.code === 'Backquote') {
UI.toggleDevPanel();
return;
}
if (e.code === 'Escape' && !UI.els['md-dev'].classList.contains('hidden')) {
UI.hideModal('md-dev');
return;
}
if ((e.code === 'Escape' || e.code === 'KeyP')) {
if (this.state === ST.RUN || this.state === ST.PAUSE) this.togglePause();
else if (this.state === ST.LEVEL) this.skipLevelUp();
else if (this.state === ST.CHEST) this.closeChest();
}
if (e.code === 'Digit1') this.chooseUpgrade(0);
if (e.code === 'Digit2') this.chooseUpgrade(1);
if (e.code === 'Digit3') this.chooseUpgrade(2);
if (e.code === 'Digit4') this.chooseUpgrade(3);
});
window.addEventListener('keyup', (e) => this.keys.delete(e.code));
// ---- touch joystick ----
const layer = document.getElementById('touchLayer');
const base = document.getElementById('stickBase');
const knob = document.getElementById('stickKnob');
let tid = null, bx = 0, by = 0;
const isTouch = ('ontouchstart' in window);
window.addEventListener('touchstart', (e) => {
Snd.init();
if (!isTouch) return;
layer.classList.remove('hidden');
const t = e.changedTouches[0];
tid = t.identifier;
bx = t.clientX; by = t.clientY;
base.style.display = 'block';
base.style.left = (bx - 55) + 'px';
base.style.top = (by - 55) + 'px';
knob.style.transform = 'translate(-50%,-50%)';
}, { passive: true });
window.addEventListener('touchmove', (e) => {
for (const t of e.changedTouches) {
if (t.identifier !== tid) continue;
let dx = t.clientX - bx, dy = t.clientY - by;
const d = len2(dx, dy);
const max = 46;
if (d > max) { dx = dx / d * max; dy = dy / d * max; }
knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
this.touchVec.x = dx / max;
this.touchVec.y = dy / max;
}
}, { passive: true });
const endTouch = (e) => {
for (const t of e.changedTouches) {
if (t.identifier !== tid) continue;
tid = null;
this.touchVec.x = 0; this.touchVec.y = 0;
base.style.display = 'none';
}
};
window.addEventListener('touchend', endTouch);
window.addEventListener('touchcancel', endTouch);
}
/* ================= menu backdrop ================= */
_renderMenuBg(dt) {
R.drawMenuScene(this, dt);
}
}
+486
View File
@@ -0,0 +1,486 @@
'use strict';
/* ============================================================
NEON SURVIVORS — i18n.js : Vietnamese / English dictionaries
============================================================ */
const I18N = {
/* ============================ TIẾNG VIỆT ============================ */
vi: {
tagline: 'Sống sót giữa biển quái vật. Nâng cấp vũ khí. Tiến hóa. Hạ Tử Thần.',
btn_play: 'CHƠI NGAY',
btn_codex: 'Bách Khoa Toàn Thư',
btn_options: 'Tùy Chọn',
btn_resume: 'Tiếp Tục',
btn_restart: 'Chơi Lại',
btn_quitmenu: 'Về Menu Chính',
btn_continue: 'Tiếp Tục',
btn_start: 'Vào Trận',
scr_chars: 'Chọn Nhân Vật',
st_select: 'Chọn Màn Chơi',
st_endless: 'Chế độ Vô tận',
st_grade: 'Độ khó',
st_best: 'Kỷ lục',
st_graveyard: 'Nghĩa Địa Neon',
st_graveyard_d: 'Nơi bắt đầu. Nhịp quái chuẩn.',
st_frostcave: 'Hang Băng Vĩnh Cửu',
st_frostcave_d: 'Bóng ma sớm hơn, trùm dày hơn. HP +12%, vàng +25%.',
st_magmalands: 'Vực Magma',
st_magmalands_d: 'Địa ngục thật sự. HP +28%, vàng +50%.',
st_lock_frost: 'Mở khi đạt mốc 10 phút',
st_lock_magma: 'Mở khi thắng 1 ván',
toast_surge: '⚡ CƠN THỊNH NỘ ĐANG ĐẾN!',
toast_endless_on: '∞ Vô tận tiếp tục…',
prop_break: '+Vàng!',
en_lamp: 'Đèn Pha Lê',
cx_ach: 'Thành Tựu',
btn_daily: 'Thử Thách Hôm Nay',
btn_history: 'Lịch Sử',
btn_close: 'Đóng',
daily_best: 'Kỷ lục hôm nay',
toast_daily_record: '⚡ Kỷ lục daily mới!',
hist_empty: 'Chưa có ván nào — ra trận thôi!',
hist_run: 'Ván',
hist_win: 'THẮNG',
save_title: 'Chuyển Save',
save_copy: '📋 Sao chép',
save_import: '📥 Nhập',
save_bad: 'Mã không hợp lệ!',
save_ok: 'Đã nạp save! Đang tải lại…',
tut_move: 'Di chuyển để né — vũ khí tự bắn. Diệt quái để hút XP!',
tut_choose: 'Lên cấp! Chọn một nâng cấp bên dưới 👇',
tut_boss: '⚠️ TRÙM! Rất trâu — giữ khoảng cách và né đòn!',
scr_pause: 'TẠM DỪNG',
scr_over: 'BẠN ĐÃ GỤC NGÃ',
scr_win: 'CHIẾN THẮNG!',
tip_move: 'WASD / Phím mũi tên để di chuyển — vũ khí tấn công tự động.',
// Level up
lv_title: 'LÊN CẤP!',
lv_choose: 'Chọn một nâng cấp',
lv_reroll: '🎲 Bốc Lại ({n})',
lv_skip: 'Bỏ Qua (+{n} 🪙)',
// Chest
ch_evolved_title: '⚡ TIẾN HÓA! ⚡',
ch_loot_title: '🎁 RƯƠNG BÁU VẬT',
ch_gold: '+{n} vàng',
// End screen rows
es_time: 'Thời gian sống sót',
es_kills: 'Quái đã diệt',
es_gold: 'Vàng kiếm được',
es_level: 'Cấp độ đạt được',
es_best: 'Kỷ lục của bạn',
es_newrec: '✦ KỶ LỤC MỚI ✦',
// Options
opt_audio: 'Âm thanh',
opt_video: 'Hình ảnh',
opt_gameplay: 'Lối chơi',
opt_language: 'Ngôn ngữ / Language',
opt_controls: 'Điều khiển',
bind_up: 'Di chuyển lên', bind_down: 'Di chuyển xuống',
bind_left: 'Sang trái', bind_right: 'Sang phải',
bind_press: 'Nhấn phím…',
fullscreen: 'Toàn màn hình',
master: 'Âm lượng chung', music: 'Nhạc nền', sfx: 'Hiệu ứng âm thanh',
shake: 'Rung màn hình', dmgnum: 'Số sát thương nổi',
particles: 'Hiệu ứng hạt', fps: 'Hiện FPS',
autopause: 'Tự động tạm dừng khi mất tiêu điểm',
lang: 'Ngôn ngữ',
reset: '🗑️ Xóa toàn bộ dữ liệu lưu',
reset_confirm: 'Xóa TOÀN BỘ tiến trình, vàng và mở khóa?',
yes: 'Xóa hết!', no: 'Thôi',
q_low: 'Thấp', q_med: 'Vừa', q_high: 'Cao',
// Codex
cx_weapons: '⚔ Vũ Khí', cx_passives: '🔮 Vật Phẩm', cx_enemies: '👹 Quái Vật', cx_chars: '🧙 Nhân Vật',
cx_locked: 'Chưa mở khóa',
cx_maxed: 'Đã tối đa',
cx_evo: 'Tiến hóa khi đầy cấp + có "{p}", mở rương để kích hoạt',
cx_stats: 'HP {hp} · Sát thương {dm} · Tốc {sp}',
// Char stats
cstat_hp: 'HP', cstat_spd: 'Tốc độ', cstat_dmg: 'ST',
cond_free: 'Luôn sẵn sàng',
cond_kills500: 'Diệt tổng cộng 500 quái',
cond_time10: 'Sống sót đến phút 10',
cond_gold2000: 'Kiếm tổng cộng 2.000 vàng',
cond_boss1: 'Hạ trùm đầu tiên',
cond_time15: 'Sống sót đến phút 15',
cond_win: 'Thắng 1 ván bất kỳ',
cond_kills5000: 'Diệt tổng cộng 5.000 quái',
// Meta shop
ms_title: 'Nâng Cường Vĩnh Viễn',
ms_cost: 'Mua · {c} 🪙',
ms_max: 'ĐÃ TỐI ĐA',
ms_might: 'Cuồng Nộ', ms_might_d: '+5% sát thương mỗi cấp.',
ms_armor: 'Giáp Thân', ms_armor_d: '+1 giáp mỗi cấp (giảm sát thương nhận vào).',
ms_hp: 'Sinh Lực', ms_hp_d: '+10% HP tối đa mỗi cấp.',
ms_regen: 'Tái Tạo', ms_regen_d: '+0.3 HP/giây mỗi cấp.',
ms_spd: 'Thần Tốc', ms_spd_d: '+4% tốc chạy mỗi cấp.',
ms_luck: 'May Mắn', ms_luck_d: '+10% may mắn mỗi cấp (quái rơi đồ tốt hơn).',
ms_growth: 'Tuệ Tăng', ms_growth_d: '+6% kinh nghiệm mỗi cấp.',
ms_greed: 'Tham Lam', ms_greed_d: '+15% vàng nhặt được mỗi cấp.',
ms_cd: 'Định Độ', ms_cd_d: '-4% thời gian hồi chiêu mỗi cấp.',
ms_revive: 'Hồi Sinh', ms_revive_d: 'Hồi sinh 1 lần khi gục ngã, mỗi cấp.',
ms_reroll: 'Bốc Lại', ms_reroll_d: '+1 lượt bốc lại nâng cấp mỗi trận, mỗi cấp.',
ms_magnet: 'Nam Châm', ms_magnet_d: '+20% bán kính hút vật phẩm mỗi cấp.',
// Toasts
toast_boss: '☠ TRÙM XUẤT HIỆN ☠',
toast_elite: '⚠ Quái Tinh Anh xuất hiện!',
toast_revive: '✨ HỒI SINH! Đứng dậy và chiến đấu!',
toast_unlock: '🏆 Nhân vật mới: {c}',
// Developer mode
opt_dev: 'Chế độ nhà phát triển',
dev_hint: 'Bật để thấy nút 🔧 trong trận đấu.',
dev_title: 'CÔNG CỤ NHÀ PHÁT TRIỂN',
dev_gold: '💰 Vàng',
dev_level: '⬆️ Cấp độ',
dev_weapons: '⚔️ Vũ khí',
dev_passives: '🔮 Vật phẩm',
dev_cheats: '⚡ Gian lận',
dev_world: '🌍 Thế giới',
dev_meta: '👤 Meta',
dev_god: 'Bất tử',
dev_onehit: 'Một hit nổ',
dev_speed: 'Tốc độ game',
dev_nuke: '💥 Quét màn hình',
dev_magnet: '🧲 Hút hết đồ',
dev_heal: '❤️ Hồi đầy máu',
dev_killall: '💀 Diệt quái gần',
dev_skip1: '+1 phút',
dev_skip5: '+5 phút',
dev_spawn_elite: '👹 Spawn Tinh Anh',
dev_spawn_boss: '☠ Spawn Trùm:',
dev_unlockall: 'Mở khóa mọi nhân vật',
dev_add: '+1',
dev_maxb: 'MAX',
dev_get: 'NHẬN',
dev_set: 'Đặt',
// Weapon upgrade lines
up_dmg: '+{n} sát thương', up_n: '+{n} viên/diện tác động', up_cd: '{n}s hồi chiêu',
up_pierce: '+{n} lực xuyên', up_spd: 'Tăng tốc độ bay', up_area: 'Tăng diện tích',
up_dur: 'Tăng thời gian tồn tại', up_kb: 'Tăng đẩy lùi', up_misc: 'Nâng cấp sức mạnh',
new_weapon: 'Vũ khí mới!', new_passive: 'Vật phẩm mới!',
// ---- Weapons ----
w_wand: 'Đũa Phép', w_wand_d: 'Tự động bắn phép vào kẻ địch gần nhất.',
e_wand: 'Bão Thần Tín', e_wand_d: 'Trận mưa phép liên hồi, xuyên phá mọi thứ.',
w_whip: 'Roi Da', w_whip_d: 'Tự động quất roi về phía kẻ địch gần nhất.',
e_whip: 'Roi Máu', e_whip_d: 'Quất đồng thời cả hai phía, hút máu kẻ địch.',
w_knife: 'Phi Đao', w_knife_d: 'Phóng dao nhanh theo hướng di chuyển.',
e_knife: 'Ngàn Lưỡi Dao', e_knife_d: 'Vũ bão lưỡi dao xuyên phá hàng loạt.',
w_orb: 'Quả Cầu Bảo Vệ', w_orb_d: 'Các quả cầu quay quanh người bạn.',
e_orb: 'Vành Tân Tinh', e_orb_d: 'Vành đai cầu lớn, va chạm nổ tán xạ.',
w_axe: 'Rìu Chiến', w_axe_d: 'Ném rìu vòng cung cao, xuyên xuyên suốt, sát thương khủng.',
e_axe: 'Phán Quyết', e_axe_d: 'Mưa rìu khổng lồ xoáy tròn như lốc xoáy chết chóc.',
w_garlic: 'Hào Quang Thánh', w_garlic_d: 'Vùng thánh ánh sáng thiêu đốt quái vật xung quanh.',
e_garlic: 'Nuốt Linh Hồn', e_garlic_d: 'Hào quang khổng lồ, mỗi mạng quái hồi máu bạn.',
w_light: 'Thiên Lôi', w_light_d: 'Sét đánh ngẫu nhiên xuống kẻ địch.',
e_light: 'Cuồng Lôi Chiến', e_light_d: 'Sét dây chuyền, làm choáng cả vùng trời.',
w_fire: 'Cầu Lửa', w_fire_d: 'Quả cầu lửa nổ lan, để lại vệt cháy.',
e_fire: 'Địa Ngục Hỏa', e_fire_d: 'Đại nổ địa ngục, mặt đất cháy âm ỉ lâu dài.',
w_frost: 'Băng Tinh', w_frost_d: 'Mảnh băng xuyên qua, làm chậm kẻ địch.',
e_frost: 'Tuyệt Đối Không', e_frost_d: 'Đóng băng kẻ địch thành băng, vỡ tan thành nghìn mảnh.',
w_cross: 'Thánh Giá', w_cross_d: 'Chiếu bay ra rồi quay về, cứa mọi thứ trên đường.',
e_cross: 'Thiên Kiếm', e_cross_d: 'Thánh kiếm khổng lồ xoay tít, vô hạn xuyên phá.',
w_mine: 'Mìn Hẹn Giờ', w_mine_d: 'Rơi mìn phía sau; chạm vào hoặc hết giờ là NỔ TO.',
e_mine: 'Vườn Pháo Hoa', e_mine_d: 'Rải cả cụm mìn siêu lớn — mỗi quả một màn pháo.',
c_hana: 'Hana', c_hana_t: '+15% tỉ lệ chí mạng, +30% sát thương chí mạng',
c_bram: 'Bram', c_bram_t: '+20% diện tích, +10% HP, đi chậm hơn chút',
skin_base: 'Gốc',
skin_cycle: 'Đổi màu đã sở hữu',
ms_need_gold: 'Không đủ vàng!',
btn_board: 'Bảng Xếp Hạng',
lb_title: '🥇 Bảng Xếp Hạng',
lb_name: 'Tên hiển thị',
lb_server: 'Máy chủ (URL Worker)',
lb_empty: 'Chưa có điểm nào.',
lb_off: 'Chưa cấu hình máy chủ trong Tuỳ chọn.',
lb_sent: '📤 Đã gửi điểm!',
lb_fail: '⚠️ Gửi thất bại.',
lb_you: 'Bạn',
lb_scope_daily: '⚡ Hôm nay',
lb_time: 'Thời gian',
lb_score: 'Điểm',
// ---- Passives ----
p_might: 'Sức Mạnh', p_might_d: '+10% sát thương mỗi cấp.',
p_armor: 'Giáp Trận', p_armor_d: '-1 sát thương nhận vào mỗi cấp.',
p_hp: 'Tim Khổng Lồ', p_hp_d: '+20% HP tối đa mỗi cấp.',
p_regen: 'Hồi Phục', p_regen_d: '+0.6 HP/giây mỗi cấp.',
p_cd: 'Siêu Dẫn', p_cd_d: '-8% thời gian hồi chiêu mỗi cấp.',
p_area: 'Mở Rộng', p_area_d: '+12% tầm và diện tích mỗi cấp.',
p_spd: 'Viên Đạn Nhanh', p_spd_d: '+15% tốc độ đạn mỗi cấp.',
p_dur: 'Bền Bỉ', p_dur_d: '+15% thời gian hiệu ứng mỗi cấp.',
p_move: 'Giày Gió', p_move_d: '+10% tốc chạy mỗi cấp.',
p_mag: 'Nam Châm', p_mag_d: '+30% bán kính hút mỗi cấp.',
p_luck: 'May Mắn', p_luck_d: '+10% may mắn, +2% chí mạng mỗi cấp.',
p_greed: 'Tham Lam', p_greed_d: '+20% vàng nhặt được mỗi cấp.',
p_growth: 'Tuệ Tăng', p_growth_d: '+10% kinh nghiệm mỗi cấp.',
// ---- Characters ----
c_kaito: 'Kaito', c_kaito_t: 'Nhà mạo hiểm cân bằng: không buff, không điểm yếu.',
c_mira: 'Mira', c_mira_t: '+1 Giáp · +10% HP',
c_bolt: 'Bolt', c_bolt_t: '+12% tốc chạy · +15% tốc đạn',
c_luna: 'Luna', c_luna_t: '+18% diện tích · +10% hồi chiêu',
c_rook: 'Rook', c_rook_t: '+20% sát thương · -5% tốc chạy',
c_nyx: 'Nyx', c_nyx_t: '+0.4 HP/s · +40% nam châm',
c_sera: 'Sera', c_sera_t: '-12% hồi chiêu · +5% may mắn',
c_vane: 'Vane', c_vane_t: '+15% may mắn · +10% vàng',
// ---- Enemies ----
en_bat: 'Dơi Máu', en_zombie: 'Thây Ma', en_imp: 'Tiểu Quỷ',
en_arch: 'Cung Thủ Xương', en_brute: 'Cao Bồi Da Gai', en_ghost: 'Bóng Ma',
en_spit: 'Xạ Hỏa Quỷ', en_garg: 'Gar-gô-yl',
bn_wolf: '🐺 Sói Đầu Lớn', bn_lich: '💀 Pháp Sư Xương',
bn_behemoth: '👹 Quái Vật Địa Ngục', bn_death: '⚰ TỬ THẦN',
},
/* ============================ ENGLISH ============================ */
en: {
tagline: 'Survive the horde. Upgrade weapons. Evolve. Slay Death itself.',
btn_play: 'PLAY',
btn_codex: 'Codex',
btn_options: 'Options',
btn_resume: 'Resume',
btn_restart: 'Play Again',
btn_quitmenu: 'Main Menu',
btn_continue: 'Continue',
btn_start: 'START',
scr_chars: 'Choose Character',
st_select: 'Select Stage',
st_endless: 'Endless Mode',
st_grade: 'Difficulty Grade',
st_best: 'Best',
st_graveyard: 'Neon Graveyard',
st_graveyard_d: 'Where it all begins. Standard pace.',
st_frostcave: 'Frozen Hollow',
st_frostcave_d: 'Early ghosts, denser bosses. +12% HP, +25% gold.',
st_magmalands: 'Magma Rift',
st_magmalands_d: 'True hell. +28% HP, +50% gold.',
st_lock_frost: 'Unlocks at the 10-minute mark',
st_lock_magma: 'Unlocks after your first win',
toast_surge: '⚡ A SURGE IS COMING!',
toast_endless_on: '∞ Endless continues…',
prop_break: '+Gold!',
en_lamp: 'Crystal Lamp',
cx_ach: 'Achievements',
btn_daily: 'Daily Challenge',
btn_history: 'History',
btn_close: 'Close',
daily_best: "Today's best",
toast_daily_record: "⚡ New daily record!",
hist_empty: 'No runs yet — get out there!',
hist_run: 'Run',
hist_win: 'WIN',
save_title: 'Save Transfer',
save_copy: '📋 Copy',
save_import: '📥 Import',
save_bad: 'Invalid code!',
save_ok: 'Save loaded! Reloading…',
tut_move: 'Move to dodge — weapons fire automatically. Kill enemies to collect XP!',
tut_choose: 'Level up! Pick one upgrade below 👇',
tut_boss: '⚠️ BOSS! Very tanky — dodge and keep your distance!',
scr_pause: 'PAUSED',
scr_over: 'YOU HAVE FALLEN',
scr_win: 'VICTORY!',
tip_move: 'Move with WASD / Arrow keys — weapons fire automatically.',
lv_title: 'LEVEL UP!',
lv_choose: 'Pick one upgrade',
lv_reroll: '🎲 Reroll ({n})',
lv_skip: 'Skip (+{n} 🪙)',
ch_evolved_title: '⚡ EVOLUTION! ⚡',
ch_loot_title: '🎁 TREASURE CHEST',
ch_gold: '+{n} gold',
es_time: 'Time survived',
es_kills: 'Enemies slain',
es_gold: 'Gold earned',
es_level: 'Level reached',
es_best: 'Your best',
es_newrec: '✦ NEW RECORD ✦',
opt_audio: 'Audio',
opt_video: 'Video',
opt_gameplay: 'Gameplay',
opt_language: 'Language / Ngôn ngữ',
opt_controls: 'Controls',
bind_up: 'Move up', bind_down: 'Move down',
bind_left: 'Move left', bind_right: 'Move right',
bind_press: 'Press a key…',
fullscreen: 'Fullscreen',
master: 'Master volume', music: 'Music volume', sfx: 'SFX volume',
shake: 'Screen shake', dmgnum: 'Floating damage numbers',
particles: 'Particle effects', fps: 'Show FPS',
autopause: 'Auto-pause on focus loss',
lang: 'Language',
reset: '🗑️ Erase all saved data',
reset_confirm: 'Erase ALL progress, gold and unlocks?',
yes: 'Erase it!', no: 'Cancel',
q_low: 'Low', q_med: 'Med', q_high: 'High',
cx_weapons: '⚔ Weapons', cx_passives: '🔮 Items', cx_enemies: '👹 Monsters', cx_chars: '🧙 Heroes',
cx_locked: 'Locked',
cx_maxed: 'Maxed out',
cx_evo: 'Evolves at max level + owning "{p}". Open a chest to trigger',
cx_stats: 'HP {hp} · DMG {dm} · SPD {sp}',
cstat_hp: 'HP', cstat_spd: 'Speed', cstat_dmg: 'DMG',
cond_free: 'Always available',
cond_kills500: 'Slay 500 total enemies',
cond_time10: 'Survive to minute 10',
cond_gold2000: 'Earn 2,000 total gold',
cond_boss1: 'Defeat the first boss',
cond_time15: 'Survive to minute 15',
cond_win: 'Win any run',
cond_kills5000: 'Slay 5,000 total enemies',
ms_title: 'Permanent Upgrades',
ms_cost: 'Buy · {c} 🪙',
ms_max: 'MAXED OUT',
ms_might: 'Might', ms_might_d: '+5% damage per rank.',
ms_armor: 'Plating', ms_armor_d: '+1 armor per rank (reduces damage taken).',
ms_hp: 'Vitality', ms_hp_d: '+10% max HP per rank.',
ms_regen: 'Renewal', ms_regen_d: '+0.3 HP/sec per rank.',
ms_spd: 'Fleet Foot', ms_spd_d: '+4% move speed per rank.',
ms_luck: 'Fortune', ms_luck_d: '+10% luck per rank (better drops).',
ms_growth: 'Insight', ms_growth_d: '+6% experience per rank.',
ms_greed: 'Greed', ms_greed_d: '+15% gold picked up per rank.',
ms_cd: 'Overclock', ms_cd_d: '-4% weapon cooldown per rank.',
ms_revive: 'Second Wind', ms_revive_d: 'Revive once on death, per rank.',
ms_reroll: 'Rerolls', ms_reroll_d: '+1 upgrade reroll per run, per rank.',
ms_magnet: 'Attractor', ms_magnet_d: '+20% pickup radius per rank.',
toast_boss: '☠ BOSS INCOMING ☠',
toast_elite: '⚠ Elite monster appeared!',
toast_revive: '✨ REVIVED! Back to the fight!',
toast_unlock: '🏆 New character: {c}',
// Developer mode
opt_dev: 'Developer mode',
dev_hint: 'Enable to reveal the 🔧 tool in runs.',
dev_title: 'DEVELOPER TOOLS',
dev_gold: '💰 Gold',
dev_level: '⬆️ Level',
dev_weapons: '⚔️ Weapons',
dev_passives: '🔮 Items',
dev_cheats: '⚡ Cheats',
dev_world: '🌍 World',
dev_meta: '👤 Meta',
dev_god: 'God mode',
dev_onehit: 'One-hit kill',
dev_speed: 'Game speed',
dev_nuke: '💥 Screen nuke',
dev_magnet: '🧲 Vacuum pickups',
dev_heal: '❤️ Full heal',
dev_killall: '💀 Kill nearby',
dev_skip1: '+1 minute',
dev_skip5: '+5 minutes',
dev_spawn_elite: '👹 Spawn Elite',
dev_spawn_boss: '☠ Spawn Boss:',
dev_unlockall: 'Unlock all characters',
dev_add: '+1',
dev_maxb: 'MAX',
dev_get: 'GET',
dev_set: 'Set',
up_dmg: '+{n} damage', up_n: '+{n} projectile/instance', up_cd: '{n}s cooldown',
up_pierce: '+{n} pierce', up_spd: 'Faster projectiles', up_area: 'Bigger area',
up_dur: 'Longer duration', up_kb: 'More knockback', up_misc: 'Power improved',
new_weapon: 'New weapon!', new_passive: 'New item!',
w_wand: 'Magic Wand', w_wand_d: 'Fires bolts at the nearest enemy.',
e_wand: 'Holy Storm', e_wand_d: 'Relentless piercing bolt barrage.',
w_whip: 'Leather Whip', w_whip_d: 'Auto-swings toward the nearest enemy.',
e_whip: 'Bloodthirster', e_whip_d: 'Strikes both sides at once and lifesteals.',
w_knife: 'Thrown Knife', w_knife_d: 'Flings fast knives where you move.',
e_knife: 'Thousand Blades', e_knife_d: 'A storm of piercing blades.',
w_orb: 'Guard Orbs', w_orb_d: 'Orbs orbit around you.',
e_orb: 'Nova Ring', e_orb_d: 'Ring of huge orbs that burst into shrapnel.',
w_axe: 'Battle Axe', w_axe_d: 'High-arcing throw, pierces everything, huge damage.',
e_axe: 'Executioner', e_axe_d: 'A whirlwind of giant spinning axes.',
w_garlic: 'Holy Aura', w_garlic_d: 'Sacred light burns nearby monsters.',
e_garlic: 'Soul Eater', e_garlic_d: 'Massive aura; each kill heals you.',
w_light: 'Thunderbolt', w_light_d: 'Lightning strikes random enemies.',
e_light: 'Warstorm', e_light_d: 'Chain lightning that stuns the battlefield.',
w_fire: 'Fireball', w_fire_d: 'Exploding fireball leaving burning ground.',
e_fire: 'Hellfire', e_fire_d: 'Hellish blast; ground scorched far longer.',
w_frost: 'Frost Shard', w_frost_d: 'Piercing shard that slows enemies.',
e_frost: 'Absolute Zero', e_frost_d: 'Freezes enemies solid, shattering for bonus damage.',
w_cross: 'Holy Cross', w_cross_d: 'Flies out then returns, cutting everything en route.',
e_cross: 'Heaven\'s Edge', e_cross_d: 'A colossal spinning blade with infinite pierce.',
w_mine: 'Fuse Mines', w_mine_d: 'Drops mines behind you; contact or timer = BIG boom.',
e_mine: 'Firework Garden', e_mine_d: 'Scatters clusters of huge mines — each one a show.',
c_hana: 'Hana', c_hana_t: '+15% crit chance, +30% crit damage',
c_bram: 'Bram', c_bram_t: '+20% area, +10% HP, slightly slower',
skin_base: 'Default',
skin_cycle: 'Cycle owned colors',
ms_need_gold: 'Not enough gold!',
btn_board: 'Leaderboard',
lb_title: '🥇 Leaderboard',
lb_name: 'Display name',
lb_server: 'Server (Worker URL)',
lb_empty: 'No scores yet.',
lb_off: 'Server not configured in Options.',
lb_sent: '📤 Score submitted!',
lb_fail: '⚠️ Submit failed.',
lb_you: 'You',
lb_scope_daily: '⚡ Today',
lb_time: 'Time',
lb_score: 'Score',
p_might: 'Strength', p_might_d: '+10% damage per level.',
p_armor: 'Armor', p_armor_d: '-1 damage taken per level.',
p_hp: 'Giant Heart', p_hp_d: '+20% max HP per level.',
p_regen: 'Recovery', p_regen_d: '+0.6 HP/sec per level.',
p_cd: 'Superconductor', p_cd_d: '-8% weapon cooldown per level.',
p_area: 'Expansion', p_area_d: '+12% range and area per level.',
p_spd: 'Hypersonic', p_spd_d: '+15% projectile speed per level.',
p_dur: 'Persistence', p_dur_d: '+15% effect duration per level.',
p_move: 'Wind Shoes', p_move_d: '+10% move speed per level.',
p_mag: 'Magnet', p_mag_d: '+30% pickup radius per level.',
p_luck: 'Luck', p_luck_d: '+10% luck, +2% crit chance per level.',
p_greed: 'Greed', p_greed_d: '+20% gold gained per level.',
p_growth: 'Growth', p_growth_d: '+10% experience per level.',
c_kaito: 'Kaito', c_kaito_t: 'The adventurer — no buffs, no weakness.',
c_mira: 'Mira', c_mira_t: '+1 Armor · +10% HP',
c_bolt: 'Bolt', c_bolt_t: '+12% move speed · +15% proj speed',
c_luna: 'Luna', c_luna_t: '+18% area · +10% cooldown',
c_rook: 'Rook', c_rook_t: '+20% damage · -5% move speed',
c_nyx: 'Nyx', c_nyx_t: '+0.4 HP/s · +40% magnet',
c_sera: 'Sera', c_sera_t: '-12% cooldown · +5% luck',
c_vane: 'Vane', c_vane_t: '+15% luck · +10% gold',
en_bat: 'Blood Bat', en_zombie: 'Zombie', en_imp: 'Imp',
en_arch: 'Bone Archer', en_brute: 'Thug Brute', en_ghost: 'Wraith',
en_spit: 'Spitter', en_garg: 'Gargoyle',
bn_wolf: '🐺 Alpha Werewolf', bn_lich: '💀 Bone Lich',
bn_behemoth: '👹 Hell Behemoth', bn_death: '⚰ DEATH',
}};
/** Translate key with {var} substitution. */
function tr(key, vars) {
let lang = 'vi';
if (typeof Store !== 'undefined' && Store.data) lang = Store.data.settings.lang || 'vi';
const dict = I18N[lang] || I18N.vi;
let s = dict[key] || I18N.vi[key] || I18N.en[key] || key;
if (vars) for (const k in vars) s = s.split('{' + k + '}').join(String(vars[k]));
return s;
}
function currentLang() {
return (typeof Store !== 'undefined' && Store.data) ? (Store.data.settings.lang || 'vi') : 'vi';
}
+33
View File
@@ -0,0 +1,33 @@
'use strict';
/* ============================================================
NEON SURVIVORS — main.js : bootstrap
============================================================ */
(function boot() {
Store.load();
const canvas = document.getElementById('game');
window.GAME = new Game(canvas);
UI.init(GAME);
GAME.applySettings();
// leave the splash
setTimeout(() => {
UI.showScreen('scr-menu');
}, 450);
// unlock WebAudio on first user gesture
const kick = () => { Snd.init(); };
window.addEventListener('pointerdown', kick);
window.addEventListener('keydown', kick);
// PWA service worker (https only; plain http still runs without SW)
if ('serviceWorker' in navigator && /^https:/.test(location.protocol)) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('sw.js').catch(() => {});
});
}
// block context menu / double-tap zoom on mobile
window.addEventListener('contextmenu', e => e.preventDefault());
})();
+983
View File
@@ -0,0 +1,983 @@
'use strict';
/* ============================================================
NEON SURVIVORS — render.js : all canvas drawing
============================================================ */
const R = {
_vig: null,
draw(G) {
const cv = G.cv, ctx = G.ctx;
const W = cv.width / G.dpr, H = cv.height / G.dpr;
const s = Store.s();
const q = s.particles;
// ---- background ----
ctx.fillStyle = '#05050c';
ctx.fillRect(0, 0, W, H);
// camera + shake
let sx = 0, sy = 0;
if (G.shake > 0.2 && s.shake) {
sx = rand(-G.shake, G.shake);
sy = rand(-G.shake, G.shake);
}
const camX = G.cam.x - W / 2 + sx;
const camY = G.cam.y - H / 2 + sy;
// viewport bounds for culling (margin covers glow/auras)
this._vx0 = camX - 70; this._vy0 = camY - 70;
this._vx1 = camX + W + 70; this._vy1 = camY + H + 70;
this._bg(ctx, camX, camY, W, H);
ctx.save();
ctx.translate(-camX, -camY);
this._pickups(G, ctx);
this._enemies(G, ctx, q);
this._projectiles(G, ctx, q);
this._ebullets(G, ctx);
if (!G.playerHidden) this._player(G, ctx);
this._effects(G, ctx);
this._particles(G, ctx, q);
ctx.restore();
// ---- screen-space overlays ----
this._texts(G, ctx, camX, camY);
this._bossArrow(G, ctx, W, H);
if (G.hurtFlash > 0) {
ctx.fillStyle = `rgba(255,40,70,${G.hurtFlash * 0.55})`;
ctx.fillRect(0, 0, W, H);
}
this._lowHp(G, ctx, W, H);
this._vignette(ctx, W, H);
},
/** Pulsing red edge when the player drops below 30% HP. */
_lowHp(G, ctx, W, H) {
const P = G.player;
if (!P || P.maxhp <= 0) return;
const ratio = clamp(P.hp / P.maxhp, 0, 1);
if (ratio >= 0.3) return;
if (!this._low || this._lowW !== W || this._lowH !== H) {
const c = document.createElement('canvas');
c.width = W; c.height = H;
const cc = c.getContext('2d');
const g = cc.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .32, W / 2, H / 2, Math.max(W, H) * .7);
g.addColorStop(0, 'rgba(255,30,60,0)');
g.addColorStop(1, 'rgba(255,20,50,0.55)');
cc.fillStyle = g;
cc.fillRect(0, 0, W, H);
this._low = c; this._lowW = W; this._lowH = H;
}
const pulse = 0.45 + 0.45 * Math.sin(G.time * 7);
ctx.globalAlpha = clamp((0.3 - ratio) / 0.3, 0, 1) * (0.35 + pulse * 0.5);
ctx.drawImage(this._low, 0, 0);
ctx.globalAlpha = 1;
},
/* ---------- background ---------- */
_bg(ctx, camX, camY, W, H) {
const bg = (window.GAME && GAME.stage && GAME.stage.bg) || {};
// grid
const step = 140;
ctx.strokeStyle = bg.grid || 'rgba(90,90,160,0.10)';
ctx.lineWidth = 1;
const x0 = Math.floor(camX / step) * step, y0 = Math.floor(camY / step) * step;
ctx.beginPath();
for (let x = x0; x < camX + W + step; x += step) {
ctx.moveTo(x - camX, 0); ctx.lineTo(x - camX, H);
}
for (let y = y0; y < camY + H + step; y += step) {
ctx.moveTo(0, y - camY); ctx.lineTo(W, y - camY);
}
ctx.stroke();
// deterministic glowing motes (parallax feel via world-anchored cells)
const rgbOf = h => {
if (typeof h !== 'string' || h[0] !== '#') return h || '124,92,255';
const n = parseInt(h.slice(1), 16);
return ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255);
};
const m1 = rgbOf(bg.mote && bg.mote[0]);
const m2 = rgbOf(bg.mote && bg.mote[1]);
const cstep = 320;
const cx0 = Math.floor(camX / cstep), cy0 = Math.floor(camY / cstep);
for (let cy = cy0; cy < cy0 + H / cstep + 2; cy++) {
for (let cx = cx0; cx < cx0 + W / cstep + 2; cx++) {
const hsh = ((cx * 374761393 + cy * 668265263) ^ 1274126177) >>> 0;
if (hsh % 3 !== 0) continue;
const px = cx * cstep + (hsh % cstep) - camX;
const py = cy * cstep + ((hsh >> 8) % cstep) - camY;
const r = 1.5 + (hsh % 3);
ctx.fillStyle = hsh % 7 === 0 ? `rgba(${m2},0.20)` : `rgba(${m1},0.16)`;
ctx.beginPath();
ctx.arc(px, py, r, 0, TAU);
ctx.fill();
}
}
},
/* ---------- pickups ---------- */
_pickups(G, ctx) {
for (const pk of G.pickups) {
if (pk.x < this._vx0 || pk.x > this._vx1 || pk.y < this._vy0 || pk.y > this._vy1) continue;
const bob = Math.sin(pk.t * 4) * 2;
if (pk.type === 'gem') {
ctx.save();
ctx.translate(pk.x, pk.y + bob);
ctx.rotate(pk.t * 1.5);
ctx.shadowColor = pk.col; ctx.shadowBlur = 8;
ctx.fillStyle = pk.col;
const s = pk.v >= 25 ? 9 : pk.v >= 5 ? 7.5 : 6;
ctx.beginPath();
ctx.moveTo(0, -s); ctx.lineTo(s * .7, 0); ctx.lineTo(0, s); ctx.lineTo(-s * .7, 0);
ctx.closePath(); ctx.fill();
ctx.restore();
} else if (pk.type === 'coin') {
ctx.save();
ctx.translate(pk.x, pk.y + bob);
ctx.shadowColor = '#ffd24a'; ctx.shadowBlur = 7;
ctx.fillStyle = '#ffd24a';
ctx.beginPath(); ctx.arc(0, 0, 6, 0, TAU); ctx.fill();
ctx.fillStyle = '#a67400';
ctx.beginPath(); ctx.arc(0, 0, 3, 0, TAU); ctx.fill();
ctx.restore();
} else {
ctx.save();
ctx.translate(pk.x, pk.y + bob * 1.5);
ctx.font = '20px serif';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const icon = { chicken: '🍗', magnet: '🧲', bomb: '💣', chest: '🎁' }[pk.type] || '❓';
if (pk.type === 'chest') {
const sc = 1 + Math.sin(pk.t * 6) * 0.08;
ctx.scale(sc, sc);
ctx.shadowColor = '#ffd24a'; ctx.shadowBlur = 14;
}
ctx.fillText(icon, 0, 0);
ctx.restore();
}
}
},
/* ---------- enemies ---------- */
_enemies(G, ctx, q) {
for (const e of G.enemies) {
if (e.x < this._vx0 - e.r || e.x > this._vx1 + e.r || e.y < this._vy0 - e.r || e.y > this._vy1 + e.r) continue;
ctx.save();
ctx.translate(e.x, e.y);
const flash = e.flash > 0;
const col = flash ? '#ffffff' : e.col;
if (q !== 'low' && !flash) {
ctx.shadowColor = e.boss ? col : 'transparent';
ctx.shadowBlur = e.boss ? 22 : 0;
}
if (e.elite || e.boss) {
ctx.strokeStyle = e.boss
? 'rgba(255,80,110,.9)'
: (e.affixCol ? colA(e.affixCol, .95) : 'rgba(255,210,74,.95)');
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 0, e.r + 5 + Math.sin(G.time * 5) * 1.5, 0, TAU);
ctx.stroke();
}
const shape = e.def.shape;
ctx.fillStyle = col;
switch (shape) {
case 'bat': {
const flap = Math.sin(G.time * 14 + e.wob) * 0.5;
ctx.beginPath(); ctx.arc(0, 0, e.r * .62, 0, TAU); ctx.fill();
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .4, 0);
ctx.lineTo(d * e.r * 1.5, -e.r * (.5 + flap));
ctx.lineTo(d * e.r * 1.2, e.r * .45);
ctx.closePath(); ctx.fill();
}
break;
}
case 'zombie': {
this._roundRect(ctx, -e.r * .75, -e.r * .9, e.r * 1.5, e.r * 1.8, 4); ctx.fill();
ctx.beginPath(); ctx.arc(0, -e.r * 1.05, e.r * .52, 0, TAU); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,.35)';
ctx.fillRect(-e.r * .55, -e.r * .15, e.r * 1.1, 3);
break;
}
case 'imp': {
ctx.beginPath();
ctx.moveTo(0, -e.r); ctx.lineTo(e.r * .85, e.r * .75); ctx.lineTo(-e.r * .85, e.r * .75);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#ffe066';
ctx.fillRect(-e.r * .5, -e.r * .85, 2.5, 5);
ctx.fillRect(e.r * .3, -e.r * .85, 2.5, 5);
break;
}
case 'archer': {
ctx.beginPath(); ctx.arc(0, 0, e.r * .72, 0, TAU); ctx.fill();
ctx.strokeStyle = '#caa96b'; ctx.lineWidth = 2.5;
ctx.beginPath(); ctx.arc(e.r * .9, 0, e.r * .85, -Math.PI / 2.4, Math.PI / 2.4); ctx.stroke();
break;
}
case 'brute': {
this._roundRect(ctx, -e.r, -e.r * .95, e.r * 2, e.r * 1.9, 7); ctx.fill();
ctx.fillStyle = 'rgba(0,0,0,.3)';
this._roundRect(ctx, -e.r * .6, -e.r * .5, e.r * 1.2, e.r * .28, 3); ctx.fill();
break;
}
case 'ghost': {
ctx.globalAlpha = 0.65 + Math.sin(G.time * 3 + e.wob) * 0.25;
ctx.beginPath();
ctx.arc(0, -e.r * .25, e.r * .8, Math.PI, 0);
const bot = e.r * .75;
ctx.lineTo(e.r * .8, bot);
for (let k = 3; k >= -3; k--) {
ctx.lineTo(k / 3 * e.r * .8, bot + (k % 2 ? 5 : -3));
}
ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
break;
}
case 'spit': {
ctx.beginPath(); ctx.arc(0, 0, e.r * .8, 0, TAU); ctx.fill();
ctx.fillStyle = '#3f7a00';
ctx.beginPath(); ctx.arc(0, -e.r * .2, e.r * .32, 0, TAU); ctx.fill();
break;
}
case 'garg': {
ctx.rotate(Math.sin(G.time * 2 + e.wob) * .06);
this._roundRect(ctx, -e.r * .7, -e.r * .8, e.r * 1.4, e.r * 1.6, 5); ctx.fill();
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .5, -e.r * .6);
ctx.lineTo(d * e.r * 1.35, -e.r * 1.05);
ctx.lineTo(d * e.r * .75, e.r * .1);
ctx.closePath(); ctx.fill();
}
if (e.state === 1) {
ctx.fillStyle = '#ff4d4d';
ctx.fillRect(-e.r * .38, -e.r * .45, 4, 4);
ctx.fillRect(e.r * .18, -e.r * .45, 4, 4);
}
break;
}
/* ---- bosses ---- */
case 'wolf': {
ctx.rotate(e.state === 2 ? 0 : Math.sin(G.time * 6) * .05);
if (e.state === 1 && flash === false) ctx.fillStyle = Math.floor(G.time * 12) % 2 ? '#ff8899' : col;
ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill();
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .45, -e.r * .8);
ctx.lineTo(d * e.r * .75, -e.r * 1.5);
ctx.lineTo(d * e.r * .95, -e.r * .6);
ctx.closePath(); ctx.fill();
}
ctx.fillStyle = '#ffef5c';
ctx.fillRect(-e.r * .5, -e.r * .3, 6, 6);
ctx.fillRect(e.r * .22, -e.r * .3, 6, 6);
break;
}
case 'lich': {
ctx.beginPath();
ctx.moveTo(0, -e.r * 1.15);
ctx.lineTo(e.r * .95, e.r); ctx.lineTo(-e.r * .95, e.r);
ctx.closePath(); ctx.fill();
ctx.fillStyle = '#22223a';
ctx.beginPath(); ctx.arc(0, -e.r * .45, e.r * .42, 0, TAU); ctx.fill();
ctx.fillStyle = '#b388ff';
ctx.fillRect(-e.r * .3, -e.r * .55, 5, 7);
ctx.fillRect(e.r * .12, -e.r * .55, 5, 7);
ctx.fillStyle = '#8a5cff';
ctx.beginPath(); ctx.arc(Math.sin(G.time * 3) * e.r, -e.r * 1.3, 5, 0, TAU); ctx.fill();
break;
}
case 'behemoth': {
ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill();
ctx.strokeStyle = col; ctx.lineWidth = 7; ctx.lineCap = 'round';
for (const d of [-1, 1]) {
ctx.beginPath();
ctx.moveTo(d * e.r * .55, -e.r * .75);
ctx.quadraticCurveTo(d * e.r * 1.25, -e.r * 1.35, d * e.r * .8, -e.r * 1.7);
ctx.stroke();
}
ctx.fillStyle = '#ffe066';
ctx.beginPath(); ctx.arc(-e.r * .3, -e.r * .2, 5, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(e.r * .3, -e.r * .2, 5, 0, TAU); ctx.fill();
break;
}
case 'death': {
ctx.beginPath();
ctx.moveTo(0, -e.r * 1.25);
ctx.lineTo(e.r, e.r * 1.05); ctx.lineTo(-e.r, e.r * 1.05);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#cfcfff'; ctx.lineWidth = 3.5;
ctx.beginPath(); ctx.arc(e.r * 1.15, -e.r * .2, e.r * .8, -2.4, -0.4); ctx.stroke();
ctx.fillStyle = '#ff2740';
ctx.beginPath(); ctx.arc(0, -e.r * .62, e.r * .3, 0, TAU); ctx.fill();
break;
}
case 'prop': {
// floating crystal lamp on a stone base
const pulse = 0.78 + 0.22 * Math.sin(G.time * 3 + e.wob);
ctx.fillStyle = 'rgba(16,24,44,0.95)';
ctx.beginPath();
ctx.ellipse(0, e.r * 0.9, e.r * 0.85, e.r * 0.34, 0, 0, TAU);
ctx.fill();
if (q !== 'low') { ctx.shadowColor = '#8fdcff'; ctx.shadowBlur = 14; }
const s = e.r * pulse;
const bob = Math.sin(G.time * 2.2 + e.wob) * 2.5;
ctx.translate(0, bob);
ctx.fillStyle = '#bfeaff';
ctx.beginPath();
ctx.moveTo(0, -s); ctx.lineTo(s * 0.62, 0);
ctx.lineTo(0, s); ctx.lineTo(-s * 0.62, 0);
ctx.closePath(); ctx.fill();
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 1.3;
ctx.stroke();
break;
}
default: {
ctx.beginPath(); ctx.arc(0, 0, e.r, 0, TAU); ctx.fill();
}
}
// ---- status feedback ----
if (e.freezeT > 0) {
ctx.globalAlpha = .5;
ctx.fillStyle = '#9fdcff';
ctx.beginPath(); ctx.arc(0, 0, e.r * .95, 0, TAU); ctx.fill();
ctx.globalAlpha = .9;
ctx.strokeStyle = '#e8fbff'; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(-e.r * .5, -e.r * .5); ctx.lineTo(e.r * .5, e.r * .5);
ctx.moveTo(e.r * .5, -e.r * .5); ctx.lineTo(-e.r * .5, e.r * .5);
ctx.stroke();
ctx.globalAlpha = 1;
} else if (e.slowT > 0) {
ctx.globalAlpha = .25;
ctx.fillStyle = '#8fd0ff';
ctx.beginPath(); ctx.arc(0, 0, e.r * 1.05, 0, TAU); ctx.fill();
ctx.globalAlpha = 1;
}
if (e.burnT > 0) {
ctx.globalCompositeOperation = 'lighter';
ctx.globalAlpha = .15 + Math.sin(G.time * 20 + e.wob) * .07;
ctx.fillStyle = '#ff7b39';
ctx.beginPath(); ctx.arc(0, 0, e.r * 1.15, 0, TAU); ctx.fill();
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
if (e.stunT > 0) {
const sa = G.time * 8 + e.wob;
ctx.fillStyle = '#ffe066';
for (let k = 0; k < 3; k++) {
const aa = sa + k * TAU / 3;
ctx.beginPath();
ctx.arc(Math.cos(aa) * e.r * .55, -e.r - 7 + Math.sin(aa) * 2.5, 1.8, 0, TAU);
ctx.fill();
}
}
// hp bar for elites/bosses
if ((e.elite || e.boss) && e.hp < e.maxhp) {
const bw = e.r * 2.4;
ctx.fillStyle = 'rgba(0,0,0,.6)';
ctx.fillRect(-bw / 2, -e.r - 16, bw, 5);
ctx.fillStyle = e.boss ? '#ff2740' : '#ffd24a';
ctx.fillRect(-bw / 2, -e.r - 16, bw * clamp(e.hp / e.maxhp, 0, 1), 5);
}
ctx.restore();
}
},
/* ---------- projectiles ---------- */
_projectiles(G, ctx, q) {
for (const p of G.projs) {
if (p.x < this._vx0 || p.x > this._vx1 || p.y < this._vy0 || p.y > this._vy1) continue;
ctx.save();
ctx.translate(p.x, p.y);
// motion trail for fast straight shots
const spd = len2(p.vx || 0, p.vy || 0);
if (p.mode === 'straight' && spd > 60) {
const tx = -p.vx / spd, ty = -p.vy / spd;
const tl = Math.min(30, spd * 0.05);
const tg = ctx.createLinearGradient(0, 0, tx * tl, ty * tl);
tg.addColorStop(0, colA(p.col, 0.75));
tg.addColorStop(1, colA(p.col, 0));
ctx.strokeStyle = tg;
ctx.lineWidth = p.r * 1.3;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(tx * tl, ty * tl);
ctx.stroke();
}
if (q !== 'low') { ctx.shadowColor = p.col; ctx.shadowBlur = 10; }
ctx.fillStyle = p.col;
switch (p.kind) {
case 'mine': case 'e_mine': {
// dark puck with a blinking fuse light
ctx.fillStyle = '#2a2033';
ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill();
ctx.strokeStyle = colA(p.col, .9);
ctx.lineWidth = 1.6;
ctx.stroke();
const blink = 0.5 + 0.5 * Math.sin(p.t * (p.life < 2 ? 18 : 6));
if (q !== 'low') { ctx.shadowColor = '#ff4d6d'; ctx.shadowBlur = 8 * blink + 2; }
ctx.fillStyle = blink > .5 ? '#ff4d6d' : '#7a2740';
ctx.beginPath(); ctx.arc(0, -p.r * .35, p.r * .34, 0, TAU); ctx.fill();
break;
}
case 'wand': case 'e_wand':
ctx.rotate(Math.atan2(p.vy, p.vx));
this._roundRect(ctx, -8, -2.5, 16, 5, 2.5); ctx.fill();
break;
case 'knife': case 'e_knife':
ctx.rotate(Math.atan2(p.vy, p.vx));
ctx.beginPath();
ctx.moveTo(9, 0); ctx.lineTo(-6, -3.5); ctx.lineTo(-3, 0); ctx.lineTo(-6, 3.5);
ctx.closePath(); ctx.fill();
break;
case 'orb': case 'e_orb': {
ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,.7)';
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, p.r * .55, 0, TAU); ctx.stroke();
break;
}
case 'axe': case 'e_axe':
ctx.rotate(p.rot);
ctx.fillRect(-2.5, -p.r * 1.1, 5, p.r * 2.2);
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.05);
ctx.quadraticCurveTo(p.r * 1.15, -p.r * .55, 0, p.r * .1);
ctx.closePath(); ctx.fill();
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.05);
ctx.quadraticCurveTo(-p.r * 1.15, -p.r * .55, 0, p.r * .1);
ctx.closePath(); ctx.fill();
break;
case 'fire': case 'e_fire': {
const g = ctx.createRadialGradient(0, 0, 1, 0, 0, p.r * 1.6);
g.addColorStop(0, '#fff3b0'); g.addColorStop(.5, p.col); g.addColorStop(1, 'rgba(255,60,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, 0, p.r * 1.6, 0, TAU); ctx.fill();
break;
}
case 'frost': case 'e_frost':
ctx.rotate(p.t * 9);
for (let k = 0; k < 3; k++) {
ctx.rotate(TAU / 3);
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.4); ctx.lineTo(2.5, 0); ctx.lineTo(-2.5, 0);
ctx.closePath(); ctx.fill();
}
break;
case 'cross': case 'e_cross':
ctx.rotate(p.rot);
const arm = p.r * 1.5, th = p.r * .48;
this._roundRect(ctx, -th / 2, -arm, th, arm * 2, 2); ctx.fill();
this._roundRect(ctx, -arm * .72, -arm * .38, arm * 1.44, th, 2); ctx.fill();
break;
default:
ctx.beginPath(); ctx.arc(0, 0, p.r, 0, TAU); ctx.fill();
}
ctx.restore();
}
},
_ebullets(G, ctx) {
for (const b of G.ebullets) {
if (b.x < this._vx0 || b.x > this._vx1 || b.y < this._vy0 || b.y > this._vy1) continue;
ctx.save();
ctx.translate(b.x, b.y);
ctx.shadowColor = b.col; ctx.shadowBlur = 8;
ctx.fillStyle = b.col;
ctx.beginPath(); ctx.arc(0, 0, b.r, 0, TAU); ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath(); ctx.arc(0, 0, b.r * .4, 0, TAU); ctx.fill();
ctx.restore();
}
},
/* ---------- player ---------- */
_player(G, ctx) {
const P = G.player;
ctx.save();
ctx.translate(P.x, P.y);
// magnet radius hint (subtle)
ctx.strokeStyle = 'rgba(124,92,255,0.10)';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.arc(0, 0, P.magR, 0, TAU); ctx.stroke();
// invulnerability flicker
if (P.invuln > 0 && Math.floor(G.time * 20) % 2 === 0) ctx.globalAlpha = 0.45;
if (Store.s().particles !== 'low') {
ctx.shadowColor = P.col; ctx.shadowBlur = 18;
}
// body
const g = ctx.createRadialGradient(-3, -4, 2, 0, 0, P.r + 2);
g.addColorStop(0, '#ffffff');
g.addColorStop(.35, P.col);
g.addColorStop(1, '#20203a');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, 0, P.r, 0, TAU); ctx.fill();
ctx.shadowBlur = 0;
// face direction
ctx.fillStyle = '#0b0b18';
const fx = P.faceX, fy = P.faceY;
ctx.beginPath(); ctx.arc(fx * 5 - fy * 3.2, fy * 5 + fx * 3.2 - 1, 2.1, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(fx * 5 + fy * 3.2, fy * 5 - fx * 3.2 - 1, 2.1, 0, TAU); ctx.fill();
ctx.restore();
// regen sparkle
if (P.regen > 0 && chance(0.08)) {
part(G, P.x + rand(-10, 10), P.y + rand(-10, 4), '#7dff9e', { sp: 24, life: .5, size: 2 });
}
},
/* ---------- effects ---------- */
_effects(G, ctx) {
for (const ef of G.effects) {
const prog = clamp(ef.t / ef.dur, 0, 1);
ctx.save();
ctx.translate(ef.x, ef.y);
switch (ef.type) {
case 'slash': {
ctx.globalAlpha = 1 - prog;
ctx.rotate(ef.data.ang || 0);
const grad = ctx.createLinearGradient(-ef.data.w / 2, 0, ef.data.w / 2, 0);
grad.addColorStop(0, 'rgba(255,255,255,0)');
grad.addColorStop(.5, ef.data.col);
grad.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = grad;
const h = ef.data.h * (0.6 + prog * 0.6);
this._roundRect(ctx, -ef.data.w / 2, -h / 2, ef.data.w, h, h / 2);
ctx.fill();
break;
}
case 'bolt': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 4 * (1 - prog) + 1;
ctx.shadowColor = ef.data.col; ctx.shadowBlur = 12;
ctx.beginPath();
let yy = 0;
ctx.moveTo(rand(-4, 4), yy);
while (yy < ef.data.ty - ef.y - 20) {
yy += rand(24, 46);
ctx.lineTo(rand(-16, 16), Math.min(yy, ef.data.ty - ef.y));
}
ctx.lineTo(0, ef.data.ty - ef.y);
ctx.stroke();
break;
}
case 'arc': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(0, 0);
const mx = (ef.data.tx - ef.x) / 2 + rand(-30, 30);
const my = (ef.data.ty - ef.y) / 2 + rand(-30, 30);
ctx.quadraticCurveTo(mx, my, ef.data.tx - ef.x, ef.data.ty - ef.y);
ctx.stroke();
break;
}
case 'ring': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 3 * (1 - prog) + 1;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r * (0.4 + prog * 0.6), 0, TAU); ctx.stroke();
break;
}
case 'nova': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 5 * (1 - prog) + 1;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r * prog, 0, TAU); ctx.stroke();
break;
}
case 'explosion': {
ctx.globalAlpha = (1 - prog) * 0.95;
const rr = ef.data.r * (0.35 + prog * 0.65);
const g = ctx.createRadialGradient(0, 0, rr * .15, 0, 0, rr);
g.addColorStop(0, '#fff8d6');
g.addColorStop(.55, ef.data.col);
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(0, 0, rr, 0, TAU); ctx.fill();
break;
}
case 'aura': {
ctx.globalAlpha = 0.13 + Math.sin(G.time * 6) * 0.03;
ctx.fillStyle = ef.data.col;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.fill();
ctx.globalAlpha = 0.5;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.stroke();
break;
}
case 'zone': {
ctx.globalAlpha = 0.28 * (1 - prog * 0.5);
ctx.fillStyle = '#ff6b35';
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.fill();
ctx.globalAlpha = 0.7 * (1 - prog);
ctx.fillStyle = '#ffd166';
for (let k = 0; k < 4; k++) {
const a = G.time * 2 + k * 1.7;
ctx.beginPath();
ctx.arc(Math.cos(a) * ef.data.r * .5, Math.sin(a * 1.3) * ef.data.r * .5, 3.5, 0, TAU);
ctx.fill();
}
break;
}
case 'shock': {
ctx.globalAlpha = 1 - prog;
ctx.strokeStyle = ef.data.col;
ctx.lineWidth = 10 * (1 - prog) + 2;
ctx.beginPath(); ctx.arc(0, 0, ef.data.r, 0, TAU); ctx.stroke();
break;
}
case 'impact': {
// crit starburst: spikes + white core
ctx.rotate(ef.data.ang || 0);
ctx.globalAlpha = 1 - prog;
const iR = (ef.data.r || 12) * (0.5 + prog * 0.8);
ctx.strokeStyle = ef.data.col || '#ffd24a';
ctx.lineWidth = 2.2 * (1 - prog) + .5;
ctx.lineCap = 'round';
ctx.beginPath();
for (let k = 0; k < 4; k++) {
const a = k * Math.PI / 2 + .4;
ctx.moveTo(Math.cos(a) * iR * .3, Math.sin(a) * iR * .3);
ctx.lineTo(Math.cos(a) * iR, Math.sin(a) * iR);
}
ctx.stroke();
ctx.globalAlpha = (1 - prog) * .9;
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.6;
ctx.beginPath(); ctx.arc(0, 0, iR * .42, 0, TAU); ctx.stroke();
break;
}
case 'muzzle': {
// weapon fire flash at the barrel
ctx.rotate(ef.data.ang || 0);
ctx.globalAlpha = 1 - prog;
ctx.fillStyle = '#fff8d6';
if (Store.s().particles !== 'low') { ctx.shadowColor = '#ffe066'; ctx.shadowBlur = 8; }
const mR = 13 * (1 - prog * 0.55);
ctx.beginPath();
for (let k = 0; k < 4; k++) {
const a = k * Math.PI / 2;
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a - .25) * mR * .45, Math.sin(a - .25) * mR * .45);
ctx.lineTo(Math.cos(a) * mR, Math.sin(a) * mR);
ctx.lineTo(Math.cos(a + .25) * mR * .45, Math.sin(a + .25) * mR * .45);
ctx.closePath();
}
ctx.fill();
break;
}
}
ctx.restore();
}
},
/* ---------- particles / floating text ---------- */
_particles(G, ctx, q) {
const maxDraw = q === 'low' ? 150 : q === 'med' ? 300 : 600;
const n = Math.min(G.parts.length, maxDraw);
for (let i = 0; i < n; i++) {
const p = G.parts[i];
ctx.globalAlpha = clamp(p.life / p.maxLife, 0, 1);
ctx.fillStyle = p.col;
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
}
ctx.globalAlpha = 1;
},
_texts(G, ctx, camX, camY) {
ctx.textAlign = 'center';
for (let i = G.texts.length - 1; i >= 0; i--) {
const ft = G.texts[i];
ft.life -= G.lastDt;
ft.y -= (ft.vy || 34) * G.lastDt;
if (ft.life <= 0) { removeItem(G.texts, i); continue; }
// scale punch on spawn (crits / heals / big events)
const age = ft.maxLife - ft.life;
let scale = 1;
if (ft.pop > 1 && age < 0.09) scale = ft.pop - (ft.pop - 1) * (age / 0.09);
else if (ft.pop > 1 && ft.life < 0.12) scale = 1 - (0.12 - ft.life); // shrink out
ctx.globalAlpha = clamp(ft.life / 0.35, 0, 1);
ctx.save();
ctx.translate(ft.x - camX, ft.y - camY);
ctx.scale(scale, scale);
ctx.font = (ft.big ? '800 17px' : '700 13px') + ' "Segoe UI",sans-serif';
ctx.strokeStyle = 'rgba(0,0,0,.85)';
ctx.lineWidth = 3;
ctx.strokeText(ft.txt, 0, 0);
ctx.fillStyle = ft.col;
ctx.fillText(ft.txt, 0, 0);
ctx.restore();
}
ctx.globalAlpha = 1;
},
/* ---------- offscreen boss indicator ---------- */
_bossArrow(G, ctx, W, H) {
const b = G.boss;
if (!b || b.dead) return;
const dx = b.x - G.cam.x, dy = b.y - G.cam.y;
if (Math.abs(dx) < W / 2 - 60 && Math.abs(dy) < H / 2 - 60) return;
const a = Math.atan2(dy, dx);
const ex = clamp(W / 2 + Math.cos(a) * W, 26, W - 26);
const ey = clamp(H / 2 + Math.sin(a) * H, 70, H - 26);
ctx.save();
ctx.translate(ex, ey);
ctx.rotate(a);
ctx.fillStyle = 'rgba(255,60,90,.9)';
ctx.beginPath();
ctx.moveTo(14, 0); ctx.lineTo(-8, -9); ctx.lineTo(-8, 9);
ctx.closePath(); ctx.fill();
ctx.restore();
},
/* ================= cinematic main-menu scene ================= */
drawMenuScene(G, dt) {
const ctx = G.ctx;
const W = G.cv.width / G.dpr, H = G.cv.height / G.dpr;
const M = G._menu || (G._menu = { t: 0, walkers: [], spawnAcc: 1.2, motes: null, sweep: 0 });
M.t += dt;
const t = M.t;
const horY = H * 0.66;
// ---- sky ----
let sky = this._menuSky || (this._menuSky = { h: 0 });
if (sky.h !== H || !this._menuSkyC) {
const c = document.createElement('canvas'); c.width = 2; c.height = 256;
const cc = c.getContext('2d');
const g = cc.createLinearGradient(0, 0, 0, 256);
g.addColorStop(0, '#04040b');
g.addColorStop(.55, '#0c0a24');
g.addColorStop(1, '#241a4e');
cc.fillStyle = g; cc.fillRect(0, 0, 2, 256);
this._menuSkyC = c;
}
ctx.drawImage(this._menuSkyC, 0, 0, 2, 256, 0, 0, W, H);
// nebula blobs
for (let i = 0; i < 2; i++) {
const nx = W * (0.22 + i * 0.55) + Math.sin(t * 0.05 + i * 3) * W * 0.05;
const ny = H * (0.18 + i * 0.16);
const nr = Math.min(W, H) * (0.42 - i * 0.1);
const ng = ctx.createRadialGradient(nx, ny, 0, nx, ny, nr);
ng.addColorStop(0, i ? 'rgba(0,229,255,0.07)' : 'rgba(124,92,255,0.10)');
ng.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = ng;
ctx.fillRect(nx - nr, ny - nr, nr * 2, nr * 2);
}
// ---- stars (deterministic twinkle) ----
ctx.fillStyle = '#fff';
for (let i = 0; i < 70; i++) {
const hsh = _strHash('star' + i);
const sx = (hsh % 1000) / 1000 * W;
const sy = ((hsh >> 10) % 1000) / 1000 * horY * 0.96;
const tw = 0.35 + 0.65 * Math.abs(Math.sin(t * (0.6 + (hsh % 5) * 0.25) + i));
ctx.globalAlpha = tw * 0.85;
const r = 0.5 + (hsh % 13) / 10;
ctx.fillRect(sx, sy, r, r);
}
ctx.globalAlpha = 1;
// ---- moon ----
const mr = Math.min(W, H) * 0.14;
const mx = W * 0.78, my = H * 0.20 + Math.sin(t * 0.3) * 6;
if (Store.s().particles !== 'low') {
ctx.shadowColor = 'rgba(199,180,255,.9)';
ctx.shadowBlur = 46;
}
const mg = ctx.createRadialGradient(mx - mr * .3, my - mr * .35, mr * .15, mx, my, mr);
mg.addColorStop(0, '#f3edff');
mg.addColorStop(.7, '#b9a8ff');
mg.addColorStop(1, '#6e57c9');
ctx.fillStyle = mg;
ctx.beginPath(); ctx.arc(mx, my, mr, 0, TAU); ctx.fill();
ctx.shadowBlur = 0;
// craters
ctx.fillStyle = 'rgba(80,60,150,.28)';
[[-.3,-.1,.18],[.22,.25,.12],[-.05,.38,.09],[.34,-.28,.08]].forEach(c => {
ctx.beginPath(); ctx.arc(mx + c[0] * mr, my + c[1] * mr, c[2] * mr, 0, TAU); ctx.fill();
});
// ---- skyline silhouettes ----
const bld = (layer, colr, seedOff, maxH) => {
ctx.fillStyle = colr;
const bw = W / 14;
for (let i = -1; i < 15; i++) {
const hs = _strHash('bld' + layer + '_' + i);
const bh = maxH * (0.35 + (hs % 100) / 100 * 0.65);
const x = i * bw - bw * 0.5 + ((hs >> 7) % 20) - 10;
ctx.fillRect(x, horY - bh, bw * (0.62 + (hs % 30) / 100), bh + 4);
if (layer === 1 && (hs % 3 === 0)) {
// a few lit windows
ctx.fillStyle = 'rgba(255,209,102,' + (0.25 + 0.2 * Math.sin(t * 2 + i)) + ')';
for (let wy = 0; wy < 3; wy++)
ctx.fillRect(x + bw * 0.2, horY - bh + 8 + wy * 9, 2.5, 3.5);
ctx.fillStyle = colr;
}
}
};
bld(0, '#191238', 0, H * 0.11);
bld(1, '#0d0a20', 50, H * 0.075);
// horizon glow band
const hg = ctx.createLinearGradient(0, horY - 26, 0, horY + 26);
hg.addColorStop(0, 'rgba(124,92,255,0)');
hg.addColorStop(.5, 'rgba(160,120,255,0.55)');
hg.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = hg;
ctx.fillRect(0, horY - 26, W, 52);
// ---- perspective floor grid ----
ctx.strokeStyle = 'rgba(124,92,255,0.30)';
ctx.lineWidth = 1;
const vpx = W / 2;
ctx.beginPath();
for (let k = -14; k <= 14; k++) {
const xb = vpx + k * (W * 0.09);
ctx.moveTo(vpx + k * 8, horY);
ctx.lineTo(xb * 1.02 - vpx * 0.02 + (xb - vpx) * 0.0, H + 40);
}
ctx.stroke();
ctx.strokeStyle = 'rgba(0,229,255,0.20)';
ctx.beginPath();
const ROWS = 15;
for (let i = 0; i < ROWS; i++) {
const z = ((i + t * 2.0) % ROWS) / ROWS;
const y = horY + (H - horY) * Math.pow(z, 3);
ctx.moveTo(0, y); ctx.lineTo(W, y);
}
ctx.stroke();
// ---- marching silhouette walkers on the ground line ----
M.spawnAcc -= dt;
if (M.spawnAcc <= 0 && M.walkers.length < 7) {
M.spawnAcc = rand(1.3, 2.6);
const dir = chance(.5) ? 1 : -1;
const cols = ['#ff6b81', '#7dfaff', '#c07dff'];
M.walkers.push({
x: dir > 0 ? -40 : W + 40, dir,
y: horY + rand(16, 44),
r: rand(7, 11),
spd: rand(24, 46),
wob: rand(TAU),
col: pick(cols)
});
}
for (let i = M.walkers.length - 1; i >= 0; i--) {
const wk = M.walkers[i];
wk.x += wk.dir * wk.spd * dt;
if (wk.x < -60 || wk.x > W + 60) { M.walkers.splice(i, 1); continue; }
const bob = Math.abs(Math.sin(t * 6 + wk.wob)) * 3;
ctx.save();
ctx.translate(wk.x, wk.y - bob);
ctx.globalAlpha = 0.92;
// body blob
ctx.fillStyle = '#0b0817';
ctx.beginPath();
ctx.arc(0, 0, wk.r, Math.PI, TAU);
ctx.lineTo(wk.r * 0.9, wk.r * 0.9);
ctx.quadraticCurveTo(0, wk.r * 1.25, -wk.r * 0.9, wk.r * 0.9);
ctx.closePath(); ctx.fill();
// neon rim toward viewer
ctx.strokeStyle = colA(wk.col, .75);
ctx.lineWidth = 1.4;
ctx.stroke();
// eyes
ctx.fillStyle = wk.col;
const eo = wk.dir * wk.r * 0.28;
ctx.beginPath(); ctx.arc(-wk.r * .3 + eo, -wk.r * .15, 1.6, 0, TAU); ctx.fill();
ctx.beginPath(); ctx.arc(wk.r * .3 + eo, -wk.r * .15, 1.6, 0, TAU); ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
// ---- rising dust motes ----
if (!M.motes) {
M.motes = [];
for (let i = 0; i < 26; i++)
M.motes.push({ x: rand(0, 1), y: rand(0, 1), s: rand(1, 2.6), v: rand(8, 26), ph: rand(TAU) });
}
for (const m of M.motes) {
m.y -= m.v * dt / H;
if (m.y < -0.03) { m.y = 1.03; m.x = Math.random(); }
const px = (m.x + Math.sin(t * 0.6 + m.ph) * 0.01) * W;
ctx.globalAlpha = 0.16 + 0.14 * Math.sin(t * 2 + m.ph);
ctx.fillStyle = '#9ad5ff';
ctx.beginPath(); ctx.arc(px, m.y * H, m.s, 0, TAU); ctx.fill();
}
ctx.globalAlpha = 1;
// ---- periodic diagonal light sweep ----
M.sweep += dt;
const period = 8;
const sp = (M.sweep % period) / period;
if (sp < 0.22) {
const k = sp / 0.22;
const cx = -W * 0.3 + k * W * 1.6;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.translate(cx, H * 0.35);
ctx.rotate(-0.35);
const lg = ctx.createLinearGradient(-90, 0, 90, 0);
lg.addColorStop(0, 'rgba(160,140,255,0)');
lg.addColorStop(.5, 'rgba(190,170,255,0.06)');
lg.addColorStop(1, 'rgba(160,140,255,0)');
ctx.fillStyle = lg;
ctx.fillRect(-90, -H, 180, H * 2);
ctx.restore();
}
this._vignette(ctx, W, H);
},
_vignette(ctx, W, H) { if (!this._vig || this._vigW !== W || this._vigH !== H) {
const c = document.createElement('canvas');
c.width = W; c.height = H;
const cc = c.getContext('2d');
const g = cc.createRadialGradient(W / 2, H / 2, Math.min(W, H) * .45, W / 2, H / 2, Math.max(W, H) * .75);
g.addColorStop(0, 'rgba(0,0,0,0)');
g.addColorStop(1, 'rgba(0,0,10,0.42)');
cc.fillStyle = g;
cc.fillRect(0, 0, W, H);
this._vig = c; this._vigW = W; this._vigH = H;
}
ctx.drawImage(this._vig, 0, 0);
},
_roundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + w, y, x + w, y + h, r);
ctx.arcTo(x + w, y + h, x, y + h, r);
ctx.arcTo(x, y + h, x, y, r);
ctx.arcTo(x, y, x + w, y, r);
ctx.closePath();
}
};
+100
View File
@@ -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); }
};
+1262
View File
File diff suppressed because it is too large Load Diff
+1109
View File
File diff suppressed because it is too large Load Diff
+204
View File
@@ -0,0 +1,204 @@
'use strict';
/* ============================================================
NEON SURVIVORS — util.js : math helpers, RNG, spatial grid
============================================================ */
const TAU = Math.PI * 2;
/* ---- seeded RNG layer ----
* All game randomness flows through rng(). Normal runs use Math.random;
* Daily Challenges call seedRng(n) for a deterministic day seed.
*/
let __rngState = null;
function seedRng(seed) { __rngState = (seed === null || seed === undefined) ? null : mulberry32(seed >>> 0); }
function rng() { return __rngState ? __rngState() : Math.random(); }
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const rand = (a = 1, b) => b === undefined ? rng() * a : a + rng() * (b - a);
const randi = (a, b) => Math.floor(rand(a, b + 1));
const pick = (arr) => arr[(rng() * arr.length) | 0];
const chance = (p) => rng() < p;
const dist2 = (ax, ay, bx, by) => { const dx = ax - bx, dy = ay - by; return dx * dx + dy * dy; };
const len2 = (x, y) => Math.sqrt(x * x + y * y);
function shuffle(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = (rng() * (i + 1)) | 0;
const tmp = a[i]; a[i] = a[j]; a[j] = tmp;
}
return a;
}
/** Pick one item from [{...}] weighted by wf(item). */
function weightedPick(items, wf) {
let sum = 0;
for (const it of items) sum += wf(it);
let r = rng() * sum;
for (const it of items) { r -= wf(it); if (r <= 0) return it; }
return items[items.length - 1];
}
/** Pick up to k distinct items (shuffled copy). */
function sampleN(arr, k) {
const c = arr.slice();
shuffle(c);
return c.slice(0, Math.max(0, Math.min(k, c.length)));
}
function fmtTime(sec) {
sec = Math.max(0, Math.floor(sec));
const m = (sec / 60) | 0, s = sec % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
}
function fmtNum(n) {
n = Math.floor(n);
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e4) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return String(n);
}
/** Swap-remove: order not preserved, O(1). */
function removeItem(arr, i) {
arr[i] = arr[arr.length - 1];
arr.pop();
}
/** 'YYYYMMDD' in UTC — the Daily Challenge key. */
function dailyKey() {
const d = new Date();
return d.getUTCFullYear() * 10000 + (d.getUTCMonth() + 1) * 100 + d.getUTCDate();
}
/** Deterministic per-day setup for the Daily Challenge. */
function dailySetup() {
const k = dailyKey();
let h = (k * 2654435761) >>> 0;
h ^= h >>> 13; h = (h * 1274126177) >>> 0; h ^= h >>> 16;
const charIds = Object.keys(CHARS);
const stageIds = Object.keys(STAGES);
return {
seed: h,
char: charIds[h % charIds.length],
stage: stageIds[(h >> 3) % stageIds.length],
grade: 1,
endless: false,
daily: true
};
}
/**
* Leaderboard checksum — MUST mirror server/lb-worker.js lbSig().
* Two-pass FNV-1a over UTF-8 bytes with a shared salt.
*/
function lbSig(obj) {
const SALT = 'NSv1-lb-salt-v1';
const bytes = new TextEncoder().encode(SALT + JSON.stringify(obj));
let h = 2166136261 >>> 0;
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i];
h = Math.imul(h, 16777619) >>> 0;
}
h ^= h >>> 13; h = Math.imul(h, 1274126177) >>> 0; h ^= h >>> 16;
return ('0000000' + h.toString(36)).slice(-8);
}
/**
* Uniform-grid broadphase for circle collisions.
* Rebuild each frame: clear() -> insert(e) for each enemy -> query(x,y,r,out).
*/
class SpatialGrid {
constructor(cellSize = 96) { this.cs = cellSize; this.map = new Map(); }
clear() { this.map.clear(); }
_key(cx, cy) { return cx * 73856093 ^ cy * 19349663; }
insert(e) {
const cs = this.cs;
const x0 = Math.floor((e.x - e.r) / cs), x1 = Math.floor((e.x + e.r) / cs);
const y0 = Math.floor((e.y - e.r) / cs), y1 = Math.floor((e.y + e.r) / cs);
for (let cy = y0; cy <= y1; cy++)
for (let cx = x0; cx <= x1; cx++) {
const k = this._key(cx, cy);
let bucket = this.map.get(k);
if (!bucket) { bucket = []; this.map.set(k, bucket); }
bucket.push(e);
}
}
/**
* Fills `out` with entities whose cells overlap the query circle.
* Stamp dedupe is bumped PER CALL so back-to-back queries in the same
* frame never swallow results from each other.
*/
query(x, y, r, out) {
out.length = 0;
const cs = this.cs;
const stamp = ++SpatialGrid._stamp;
const x0 = Math.floor((x - r) / cs), x1 = Math.floor((x + r) / cs);
const y0 = Math.floor((y - r) / cs), y1 = Math.floor((y + r) / cs);
for (let cy = y0; cy <= y1; cy++)
for (let cx = x0; cx <= x1; cx++) {
const bucket = this.map.get(this._key(cx, cy));
if (!bucket) continue;
for (let i = 0; i < bucket.length; i++) {
const e = bucket[i];
if (e._stamp !== stamp) {
e._stamp = stamp;
out.push(e);
}
}
}
return out;
}
}
SpatialGrid._stamp = 0;
/** Simple throttle helper: returns true at most once per `ms` for a given slot. */
const throttleMap = new Map();
function throttled(slot, ms) {
const now = performance.now();
const last = throttleMap.get(slot) || -1e9;
if (now - last >= ms) { throttleMap.set(slot, now); return true; }
return false;
}
/** '#rrggbb' / '#rgb' -> 'rgba(r,g,b,a)' (returns input unchanged if not hex). */
function colA(hex, a) {
if (typeof hex !== 'string' || hex[0] !== '#') return hex;
if (hex.length === 4) {
const r = parseInt(hex[1] + hex[1], 16);
const g = parseInt(hex[2] + hex[2], 16);
const b = parseInt(hex[3] + hex[3], 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')';
}
if (hex.length !== 7) return hex;
const n = parseInt(hex.slice(1), 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
}
/**
* Pooled particle spawn. Uses G.partPool when available, respects G.partCap.
* opts.big grants overflow headroom for important bursts.
*/
function part(G, x, y, col, opts) {
opts = opts || {};
const cap = G.partCap !== undefined ? G.partCap : 600;
if (G.parts.length >= cap + (opts.big ? 60 : 0)) return null;
let p;
if (G.partPool && G.partPool.length) {
p = G.partPool.pop().set(x, y, col, opts);
} else {
p = new Particle(x, y, col, opts);
}
G.parts.push(p);
return p;
}
// Tiny seeded RNG (mulberry32) — used for deterministic tests.
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}