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)
281 lines
8.3 KiB
JavaScript
281 lines
8.3 KiB
JavaScript
'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;
|
|
}
|
|
}
|