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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+204
@@ -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;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user