'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; } }