Files
neon-survivors/js/systems.js
T
neon-survivors-dev 796880375e 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)
2026-08-23 07:01:22 +00:00

1263 lines
44 KiB
JavaScript

'use strict';
/* ============================================================
NEON SURVIVORS — systems.js : spawning, AI, combat, pickups,
leveling, chests, boss AI. All operate on a game object `G`.
============================================================ */
const Sys = {
/* ================= SPAWNING ================= */
ringPos(G, dist) {
const a = rand(TAU);
return {
x: G.player.x + Math.cos(a) * dist,
y: G.player.y + Math.sin(a) * dist
};
},
spawnTick(G, dt) {
const P = G.player;
const stage = G.stage || STAGES.graveyard;
const rm = Math.floor(G.time / 60); // real minute (unbounded, endless)
const m = Math.min(19, rm); // table row
const dm = G.diffMul || 1;
const maxE = { low: 130, med: 180, high: 250 }[Store.s().particles] || 200;
// trickle spawns
const rate = 0.9 + m * 0.55;
G.spawnAcc += dt * rate;
while (G.spawnAcc >= 1) {
G.spawnAcc -= 1;
if (G.enemies.length < maxE) {
const tid = pick(stage.table[m]);
const pos = this.ringPos(G, rand(560, 720));
const e = new Enemy(tid, pos.x + rand(-40, 40), pos.y + rand(-40, 40));
e.scaleTo(rm, dm);
G.enemies.push(e);
}
}
// breakable props — a few lamps scattered around the field
G.propAcc += dt;
if (G.propAcc > 9) {
G.propAcc = 0;
let props = 0;
for (const e of G.enemies) if (e.def.prop) props++;
if (props < 4 && G.enemies.length < maxE) {
const pos = this.ringPos(G, rand(380, 700));
const e = new Enemy('lamp', pos.x, pos.y);
e.scaleTo(Math.max(1, rm * 0.5), 1);
G.enemies.push(e);
}
}
// minute bursts
const minBoundary = Math.floor(G.time / 60);
if (minBoundary > G.lastBurstMinute && G.time > 3) {
G.lastBurstMinute = minBoundary;
const n = 10 + minBoundary * 4;
const tid = pick(stage.table[m]);
const center = this.ringPos(G, 640);
for (let i = 0; i < n; i++) {
if (G.enemies.length >= maxE + 30) break;
const ang = rand(TAU);
const rad = rand(0, 140);
const e = new Enemy(tid, center.x + Math.cos(ang) * rad, center.y + Math.sin(ang) * rad);
e.scaleTo(rm, dm);
G.enemies.push(e);
}
}
// endless surges — every 2 minutes past the finish line
if (G.endless && G.time > stage.length) {
const sm = Math.floor((G.time - stage.length) / 120);
if (sm > (G.lastSurge !== undefined ? G.lastSurge : -1)) {
G.lastSurge = sm;
UI.toast(tr('toast_surge'), 'warn');
Snd.play('roar', true);
this.spawnElite(G);
this.spawnElite(G);
const center = this.ringPos(G, 620);
for (let i = 0; i < 14; i++) {
if (G.enemies.length >= maxE + 30) break;
const ang = (i / 14) * TAU;
const e = new Enemy(pick(stage.table[m]), center.x + Math.cos(ang) * 150, center.y + Math.sin(ang) * 150);
e.scaleTo(rm, dm);
G.enemies.push(e);
}
}
}
// elites -> chests
while (G.eliteIdx < ELITE_TIMES.length && G.time >= ELITE_TIMES[G.eliteIdx]) {
G.eliteIdx++;
this.spawnElite(G);
}
// bosses
while (G.bossIdx < stage.plan.length && G.time >= stage.plan[G.bossIdx].t) {
const plan = stage.plan[G.bossIdx++];
const pos = this.ringPos(G, 520);
const b = new Enemy(plan.id, pos.x, pos.y);
const mm = Math.floor(G.time / 60);
b.hp *= (1 + mm * 0.06) * (1 + ((dm || 1) - 1) * 0.5); b.maxhp = b.hp;
G.enemies.push(b);
G.boss = b;
UI.toast(tr('toast_boss'), 'warn');
UI.showBossBar(b);
Snd.play('roar', true);
G.shakeIt(8);
if (!Store.data.flags.tutBoss) {
Store.data.flags.tutBoss = true;
Store.save();
UI.showTut('tut_boss');
setTimeout(() => UI.hideTut(), 5000);
}
}
},
spawnElite(G) {
const stage = G.stage || STAGES.graveyard;
const m = Math.min(19, Math.floor(G.time / 60));
const tid = pick(stage.table[m]);
const pos = this.ringPos(G, 600);
const e = new Enemy(tid, pos.x, pos.y);
e.scaleTo(Math.max(1, Math.floor(G.time / 120)), G.diffMul || 1);
e.applyElite();
// random modifier
if (chance(0.65)) {
const key = pick(Object.keys(ELITE_AFFIXES));
const af = ELITE_AFFIXES[key];
e.affix = key;
e.affixCol = af.col;
af.apply(e);
if (Store.s().dmgNum) {
G.texts.push(new FText(e.x, e.y - e.r - 22,
af.icon + ' ' + (currentLang() === 'en' ? af.en : af.vi),
af.col, true, { life: 1.2, vy: 20, pop: 1.7 }));
}
}
G.enemies.push(e);
UI.toast(tr('toast_elite'), 'warn');
Snd.play('roar');
return e;
},
/* ================= ENEMY UPDATE ================= */
updEnemies(G, dt) {
const P = G.player;
const grid = G.grid;
grid.clear();
for (const e of G.enemies) grid.insert(e);
const near = [];
for (let i = G.enemies.length - 1; i >= 0; i--) {
const e = G.enemies[i];
if (e.dead) { removeItem(G.enemies, i); continue; }
// status timers
if (e.slowT > 0) e.slowT -= dt;
if (e.freezeT > 0) e.freezeT -= dt;
if (e.stunT > 0) e.stunT -= dt;
if (e.flash > 0) e.flash -= dt;
if (e.burnT > 0) {
e.burnT -= dt;
this.damageEnemy(G, e, e.burnDps * dt, { silent: true });
if (e.dead) { removeItem(G.enemies, i); continue; }
}
if (chance(dt * 8) && (e.burnT > 0)) {
part(G, e.x + rand(-6, 6), e.y + rand(-6, 6), '#ff8c42', { sp: 20, life: .4, size: 3 });
}
// far recycle (keeps pressure, skips elites/bosses/props)
const dpx = e.x - P.x, dpy = e.y - P.y;
const dp2 = dpx * dpx + dpy * dpy;
if (!e.elite && !e.boss && !e.def.prop && dp2 > 1500 * 1500) {
const np = this.ringPos(G, 800);
e.x = np.x; e.y = np.y;
}
if (e.stunT <= 0 && e.freezeT <= 0) {
if (e.boss) this._bossAI(G, e, dt);
else this._enemyAI(G, e, dt, Math.sqrt(dp2));
} else {
e.state = e.state === 2 ? 0 : e.state; // cancel dashes
}
// knockback decay
e.kbx *= Math.exp(-8 * dt); e.kby *= Math.exp(-8 * dt);
// separation (skip ghosts & props)
if (e.def.ai !== 'ghost' && !e.def.prop) {
grid.query(e.x, e.y, e.r + 26, near);
let pushX = 0, pushY = 0;
for (const o of near) {
if (o === e || o.def.ai === 'ghost' || o.def.prop) continue;
const dx = e.x - o.x, dy = e.y - o.y;
const rr = e.r + o.r;
const d2 = dx * dx + dy * dy;
if (d2 < rr * rr && d2 > 0.01) {
const d = Math.sqrt(d2);
pushX += (dx / d) * (rr - d);
pushY += (dy / d) * (rr - d);
}
}
const sepF = e.boss ? 0 : 14 * dt;
e.x += pushX * sepF; e.y += pushY * sepF;
}
e.x += e.kbx * dt; e.y += e.kby * dt;
// contact damage (props never hurt)
const pr = P.r + e.r;
if (!e.def.prop && P.invuln <= 0 && dp2 < pr * pr && G.time - e.lastTouch > 0.65) {
e.lastTouch = G.time;
this.hurtPlayer(G, e.dmg);
}
}
},
_enemyAI(G, e, dt, dist) {
const P = G.player;
const sf = e.speedFactor;
const dx = P.x - e.x, dy = P.y - e.y;
const d = Math.max(1, dist);
const ux = dx / d, uy = dy / d;
let mvx = 0, mvy = 0;
switch (e.def.ai) {
case 'prop': // breakable lamp: never moves, never attacks
return;
case 'chase':
mvx = ux; mvy = uy;
if (e.def.wiggle) {
const w = Math.sin(G.time * e.def.wiggle + e.wob) * 0.45;
mvx += -uy * w; mvy += ux * w;
}
break;
case 'ghost': {
mvx = ux; mvy = uy;
const w = Math.sin(G.time * 1.8 + e.wob) * 0.6;
mvx += -uy * w; mvy += ux * w;
break;
}
case 'shooter': {
const range = e.def.range;
if (dist > range) { mvx = ux; mvy = uy; }
else if (dist < range * 0.55) { mvx = -ux; mvy = -uy; }
else { const s = Math.sin(G.time * 0.8 + e.wob) > 0 ? 1 : -1; mvx = -uy * s * 0.6; mvy = ux * s * 0.6; }
e.shootT -= dt * sf;
if (e.shootT <= 0 && dist < range * 1.35) {
e.shootT = e.def.bcd * rand(0.85, 1.2);
const cnt = e.def.spread || 1;
const baseA = Math.atan2(dy, dx);
for (let k = 0; k < cnt; k++) {
const a = baseA + (k - (cnt - 1) / 2) * 0.22;
G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a) * e.def.bspd, Math.sin(a) * e.def.bspd, e.dmg, e.col));
}
}
break;
}
case 'charger': {
// 0 approach, 1 windup, 2 dash, 3 recover
if (e.state === 0) {
mvx = ux; mvy = uy;
if (dist < 340) { e.state = 1; e.aiT = 0.55; }
} else if (e.state === 1) {
e.aiT -= dt;
e.rot += dt * 10;
if (e.aiT <= 0) {
e.state = 2; e.aiT = 0.5;
e.cx = ux; e.cy = uy;
}
} else if (e.state === 2) {
e.aiT -= dt;
mvx = e.cx * 3.4; mvy = e.cy * 3.4;
if (e.aiT <= 0) { e.state = 3; e.aiT = 0.8; }
} else {
e.aiT -= dt;
if (e.aiT <= 0) e.state = 0;
}
break;
}
}
e.x += mvx * e.spd * sf * dt;
e.y += mvy * e.spd * sf * dt;
},
_bossAI(G, e, dt) {
const P = G.player;
const dx = P.x - e.x, dy = P.y - e.y;
const d = Math.max(1, len2(dx, dy));
const ux = dx / d, uy = dy / d;
switch (e.def.ai) {
case 'boss_wolf':
e.aiT -= dt;
if (e.state === 0) { // stalk
e.x += ux * e.spd * dt; e.y += uy * e.spd * dt;
if (e.aiT <= 0 && d < 520) { e.state = 1; e.aiT = 0.6; }
} else if (e.state === 1) { // windup
e.rot += dt * 12;
if (e.aiT <= 0) { e.state = 2; e.aiT = 0.62; e.cx = ux; e.cy = uy; Snd.play('roar'); }
} else if (e.state === 2) { // dash
e.x += e.cx * e.spd * 3.6 * dt; e.y += e.cy * e.spd * 3.6 * dt;
if (e.aiT <= 0) { e.state = 0; e.aiT = rand(1.6, 2.4); }
}
break;
case 'boss_lich': {
const want = 300;
if (d > want + 40) { e.x += ux * e.spd * dt; e.y += uy * e.spd * dt; }
else if (d < want - 60) { e.x -= ux * e.spd * dt; e.y -= uy * e.spd * dt; }
else { e.x += -uy * e.spd * 0.5 * dt; e.y += ux * e.spd * 0.5 * dt; }
e.shootT -= dt;
if (e.shootT <= 0) {
e.shootT = 3.1;
const n = 14, off = rand(TAU);
for (let k = 0; k < n; k++) {
const a = off + k * TAU / n;
G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a) * 190, Math.sin(a) * 190, e.dmg * 0.6, '#b388ff'));
}
Snd.play('zap');
}
e.aiT -= dt;
if (e.aiT <= 0) {
e.aiT = 6.5;
for (let k = 0; k < 3; k++) {
const a = rand(TAU);
const s = new Enemy('bat', e.x + Math.cos(a) * 60, e.y + Math.sin(a) * 60);
s.scaleTo(Math.floor(G.time / 60));
G.enemies.push(s);
}
}
break;
}
case 'boss_beho':
e.x += ux * e.spd * dt; e.y += uy * e.spd * dt;
e.aiT -= dt;
if (e.aiT <= 0) {
e.aiT = 4.6;
G.effects.push(new Effect('shock', e.x, e.y, 1.1, { maxR: 300, dmg: e.dmg * 0.9, hit: new Set(), col: '#ff5c5c' }));
Snd.play('boom');
G.shakeIt(6);
}
break;
case 'boss_death':
e.aiT -= dt;
if (e.state === 0) {
e.x += ux * e.spd * dt; e.y += uy * e.spd * dt;
if (e.aiT <= 0) {
e.state = 1; e.aiT = 1.4;
// blink next to player
G.effects.push(new Effect('nova', e.x, e.y, 0.35, { r: 46, col: '#cfcfff' }));
const a = rand(TAU);
e.x = P.x + Math.cos(a) * 190; e.y = P.y + Math.sin(a) * 190;
G.effects.push(new Effect('nova', e.x, e.y, 0.35, { r: 46, col: '#cfcfff' }));
Snd.play('zap');
}
} else { // scythe spiral
e.shootT -= dt;
e.rot += dt * 18;
if (e.shootT <= 0) {
e.shootT = 0.07;
const a = G.time * 9;
G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a) * 230, Math.sin(a) * 230, e.dmg * 0.45, '#e6e6ff'));
G.ebullets.push(new EBullet(e.x, e.y, Math.cos(a + Math.PI) * 230, Math.sin(a + Math.PI) * 230, e.dmg * 0.45, '#e6e6ff'));
}
if (e.aiT <= 0) { e.state = 0; e.aiT = rand(2.0, 3.0); }
}
break;
}
},
/* ================= WEAPONS ================= */
fireWeapons(G, dt) {
const P = G.player;
for (const w of P.weapons) {
const st = wstats(w.id, w.lvl);
const cd = st.cd * P.cdrMul;
// orbit orbs are persistent — sync them outside the timer
if (w.id === 'orb' || w.id === 'e_orb') { this._syncOrbs(G, w, st); continue; }
// aura ticks continuously
if (w.id === 'garlic' || w.id === 'e_garlic') {
w.timer -= dt;
if (w.timer <= 0) { w.timer = cd; this._garlicTick(G, w, st); }
continue;
}
w.timer -= dt;
if (w.timer > 0) continue;
w.timer = cd;
switch (w.id) {
case 'wand': case 'e_wand': this._fireWand(G, w, st); break;
case 'whip': case 'e_whip': this._fireWhip(G, w, st); break;
case 'knife': case 'e_knife': this._fireKnife(G, w, st); break;
case 'axe': case 'e_axe': this._fireAxe(G, w, st); break;
case 'light': case 'e_light': this._fireLight(G, w, st); break;
case 'fire': case 'e_fire': this._fireFire(G, w, st); break;
case 'frost': case 'e_frost': this._fireFrost(G, w, st); break;
case 'cross': case 'e_cross': this._fireCross(G, w, st); break;
case 'mine': case 'e_mine': this._fireMine(G, w, st); break;
}
}
},
_nearestEnemies(G, x, y, count, maxR) {
const arr = [];
for (const e of G.enemies) {
const d2 = dist2(x, y, e.x, e.y);
if (d2 < maxR * maxR) arr.push({ e, d2 });
}
arr.sort((a, b) => a.d2 - b.d2);
return arr.slice(0, count).map(o => o.e);
},
/** Small flash at the barrel when a shot goes out. */
_muzzle(G, x, y, ang) {
if (G.effects.length < 70) {
G.effects.push(new Effect('muzzle', x, y, 0.12, { ang }));
}
},
_aimDirRandomEnemy(G) {
const vis = G.enemies.filter(e => dist2(G.player.x, G.player.y, e.x, e.y) < 700 * 700);
if (vis.length) {
const t = pick(vis);
const d = Math.max(1, len2(t.x - G.player.x, t.y - G.player.y));
return { x: (t.x - G.player.x) / d, y: (t.y - G.player.y) / d };
}
return { x: G.player.faceX, y: G.player.faceY };
},
_fireWand(G, w, st) {
const P = G.player;
const targets = this._nearestEnemies(G, P.x, P.y, st.n, 760);
if (targets.length) {
const d = Math.max(1, len2(targets[0].x - P.x, targets[0].y - P.y));
this._muzzle(G, P.x + (targets[0].x - P.x) / d * 15,
P.y + (targets[0].y - P.y) / d * 15,
Math.atan2(targets[0].y - P.y, targets[0].x - P.x));
} else {
this._muzzle(G, P.x + P.faceX * 15, P.y + P.faceY * 15, Math.atan2(P.faceY, P.faceX));
}
for (let i = 0; i < st.n; i++) {
let dx, dy;
if (targets[i % Math.max(1, targets.length)] && targets.length) {
const t = targets[i % targets.length];
const d = Math.max(1, len2(t.x - P.x, t.y - P.y));
dx = (t.x - P.x) / d; dy = (t.y - P.y) / d;
} else { dx = P.faceX; dy = P.faceY; }
G.projs.push(new Proj({
kind: w.id, mode: 'straight',
x: P.x, y: P.y, vx: dx * st.spd * P.pspdMul, vy: dy * st.spd * P.pspdMul,
dmg: st.dmg * P.might, pierce: st.pierce, r: 6 * st.area * P.areaMul,
life: st.dur * P.durMul, kb: st.kb, col: wdef(w.id).col
}));
}
Snd.play('shoot');
},
_fireWhip(G, w, st) {
const P = G.player;
// --- auto-aim: swing toward the nearest enemy in range ---
let ax = P.faceX, ay = P.faceY;
let best = null, bd = 480 * 480;
for (const e of G.enemies) {
const d2 = dist2(P.x, P.y, e.x, e.y);
if (d2 < bd) { bd = d2; best = e; }
}
if (best) {
const d = Math.max(1, len2(best.x - P.x, best.y - P.y));
ax = (best.x - P.x) / d;
ay = (best.y - P.y) / d;
}
const both = st.n >= 2; // evolved / high level hits front+back
const sides = both ? [1, -1] : [w.side || 1];
w.side = -(w.side || 1);
const L = 165 * st.area * P.areaMul, W = 70 * st.area * P.areaMul;
for (const s of sides) {
const dirX = ax * s, dirY = ay * s;
const ang = Math.atan2(dirY, dirX);
const cx = P.x + dirX * (L / 2 + 22), cy = P.y + dirY * (L / 2 + 22);
G.effects.push(new Effect('slash', cx, cy, 0.22, { w: L, h: W, ang, col: wdef(w.id).col }));
// oriented box test: project onto swing axis
for (const e of G.enemies) {
const dx = e.x - P.x, dy = e.y - P.y;
const along = dx * dirX + dy * dirY;
const perp = dx * -dirY + dy * dirX;
if (along > -e.r && along < L + e.r && Math.abs(perp) < W / 2 + e.r) {
this.hitEnemy(G, e, st.dmg * P.might, {
kb: { x: dirX, y: dirY, f: st.kb }, lifesteal: st.lifesteal || 0
});
}
}
}
Snd.play('hit');
},
_fireKnife(G, w, st) {
const P = G.player;
const baseA = Math.atan2(P.faceY, P.faceX);
this._muzzle(G, P.x + Math.cos(baseA) * 15, P.y + Math.sin(baseA) * 15, baseA);
const spread = 0.11;
for (let i = 0; i < st.n; i++) {
const a = baseA + (i - (st.n - 1) / 2) * spread;
G.projs.push(new Proj({
kind: w.id, mode: 'straight',
x: P.x, y: P.y, vx: Math.cos(a) * st.spd * P.pspdMul, vy: Math.sin(a) * st.spd * P.pspdMul,
dmg: st.dmg * P.might, pierce: st.pierce, r: 5 * st.area * P.areaMul,
life: st.dur * P.durMul, kb: st.kb, col: wdef(w.id).col
}));
}
Snd.play('shoot');
},
_syncOrbs(G, w, st) {
const P = G.player;
let count = 0;
for (const p of G.projs) if (p.mode === 'orbit' && p.wid === w.id) count++;
while (count < st.n) {
G.projs.push(new Proj({
kind: w.id, mode: 'orbit', wid: w.id,
x: P.x, y: P.y, ang: rand(TAU),
orbR: 78 * st.area * P.areaMul, av: st.spd,
dmg: st.dmg * P.might, r: 11 * st.area * P.areaMul,
hitCd: st.hitCd, life: Infinity, kb: 60, col: wdef(w.id).col
}));
count++;
}
// remove extras (after downgrade never happens, but safe)
for (let i = G.projs.length - 1; i >= 0 && count > st.n; i--) {
const p = G.projs[i];
if (p.mode === 'orbit' && p.wid === w.id) { removeItem(G.projs, i); count--; }
}
},
_garlicTick(G, w, st) {
const P = G.player;
const R = (st.radius || 95) * st.area * P.areaMul;
for (const e of G.enemies) {
if (dist2(P.x, P.y, e.x, e.y) < (R + e.r) * (R + e.r)) {
this.hitEnemy(G, e, st.dmg * P.might, {
kb: { x: e.x - P.x, y: e.y - P.y, f: st.kb }, silent: true
});
}
}
G.effects.push(new Effect('aura', P.x, P.y, st.cd * P.cdrMul, { r: R, col: wdef(w.id).col }));
},
_fireAxe(G, w, st) {
const P = G.player;
for (let i = 0; i < st.n; i++) {
const dir = chance(0.5) ? 1 : -1;
const spin = w.id === 'e_axe';
G.projs.push(new Proj({
kind: w.id, mode: spin ? 'spiral' : 'lob',
x: P.x, y: P.y,
vx: dir * rand(st.spd * 0.5, st.spd) * P.pspdMul,
vy: spin ? rand(-80, 80) : -rand(430, 560),
grav: st.grav || 900,
dmg: st.dmg * P.might, pierce: st.pierce, r: 13 * st.area * P.areaMul,
life: spin ? 2.6 : 3, rot: rand(TAU), spin: rand(8, 12) * (spin ? 2 : 1),
kb: st.kb, col: wdef(w.id).col
}));
}
Snd.play('shoot');
},
_fireLight(G, w, st) {
const P = G.player;
const cands = G.enemies.filter(e => dist2(P.x, P.y, e.x, e.y) < 680 * 680);
if (!cands.length) { w.timer = 0.25; return; } // retry soon, don't waste cd
const n = Math.min(st.n, cands.length);
for (let i = 0; i < n; i++) {
const t = pick(cands);
this._strikeLightning(G, t, st);
}
},
_strikeLightning(G, target, st, dmgMul) {
const P = G.player;
const mul = dmgMul || 1;
const R = st.blastR * st.area * P.areaMul;
G.effects.push(new Effect('bolt', target.x, target.y - 320, 0.28, { ty: target.y, col: '#dffcff' }));
for (const e of G.enemies) {
if (dist2(target.x, target.y, e.x, e.y) < (R + e.r) * (R + e.r)) {
this.hitEnemy(G, e, st.dmg * P.might * mul, { kb: { x: 0, y: -1, f: 60 } });
if (st.stun && !e.dead) e.stunT = Math.max(e.stunT, st.stun);
}
}
G.effects.push(new Effect('ring', target.x, target.y, 0.3, { r: R, col: '#9bf6ff' }));
Snd.play('zap');
// evolution chain
if (st.chain) {
let from = target, hops = st.chain;
const chained = new Set([target.id]);
while (hops-- > 0) {
let best = null, bd = 220 * 220;
for (const e of G.enemies) {
if (chained.has(e.id)) continue;
const d2 = dist2(from.x, from.y, e.x, e.y);
if (d2 < bd) { bd = d2; best = e; }
}
if (!best) break;
chained.add(best.id);
G.effects.push(new Effect('arc', from.x, from.y, 0.2, { tx: best.x, ty: best.y, col: '#9bf6ff' }));
this.hitEnemy(G, best, st.dmg * P.might * 0.65, {});
if (st.stun && !best.dead) best.stunT = Math.max(best.stunT, st.stun * 0.8);
from = best;
}
}
},
_fireFire(G, w, st) {
const P = G.player;
let firstDir = null;
for (let i = 0; i < st.n; i++) {
const dir = this._aimDirRandomEnemy(G);
if (!firstDir) {
firstDir = dir;
this._muzzle(G, P.x + dir.x * 15, P.y + dir.y * 15, Math.atan2(dir.y, dir.x));
}
G.projs.push(new Proj({
kind: w.id, mode: 'straight',
x: P.x, y: P.y,
vx: dir.x * st.spd * P.pspdMul, vy: dir.y * st.spd * P.pspdMul,
dmg: st.dmg * P.might, pierce: 0, r: 9 * st.area * P.areaMul,
life: st.dur ? st.dur * P.durMul : 1.6, kb: st.kb, col: wdef(w.id).col,
boom: {
r: st.blastR * st.area * P.areaMul, dmg: st.dmg * P.might,
burnDps: (st.burnDps || 0) * P.might, burnDur: st.burnDur || 0,
groundDps: st.groundDps || 0, groundDur: st.groundDur || 0
}
}));
}
Snd.play('shoot');
},
_fireFrost(G, w, st) {
const P = G.player;
let firstDir = null;
for (let i = 0; i < st.n; i++) {
const dir = this._aimDirRandomEnemy(G);
if (!firstDir) {
firstDir = dir;
this._muzzle(G, P.x + dir.x * 15, P.y + dir.y * 15, Math.atan2(dir.y, dir.x));
}
const jitter = (i - (st.n - 1) / 2) * 0.15;
const ca = Math.cos(jitter), sa = Math.sin(jitter);
const dx = dir.x * ca - dir.y * sa, dy = dir.x * sa + dir.y * ca;
G.projs.push(new Proj({
kind: w.id, mode: 'straight',
x: P.x, y: P.y, vx: dx * st.spd * P.pspdMul, vy: dy * st.spd * P.pspdMul,
dmg: st.dmg * P.might, pierce: st.pierce, r: 6 * st.area * P.areaMul,
life: st.dur * P.durMul, kb: st.kb, col: wdef(w.id).col,
slow: st.slow, slowDur: st.slowDur * P.durMul, freeze: st.freeze || 0
}));
}
Snd.play('freeze');
},
_fireCross(G, w, st) {
const P = G.player;
for (let i = 0; i < st.n; i++) {
const dir = this._aimDirRandomEnemy(G);
G.projs.push(new Proj({
kind: w.id, mode: 'boomerang',
x: P.x, y: P.y, ox: P.x, oy: P.y,
dx: dir.x, dy: dir.y,
spd: st.spd * P.pspdMul,
dmg: st.dmg * P.might, pierce: st.pierce, r: 14 * st.area * P.areaMul,
life: st.dur * P.durMul, rot: 0, spin: 10, kb: st.kb,
rehit: st.rehit || 0.4, col: wdef(w.id).col
}));
}
Snd.play('shoot');
},
/** Timed mines: dropped at the player's feet, detonate on contact or expiry. */
_fireMine(G, w, st) {
const P = G.player;
for (let i = 0; i < st.n; i++) {
G.projs.push(new Proj({
kind: 'mine', mode: 'straight',
x: P.x + rand(-30, 30), y: P.y + rand(-20, 20),
vx: 0, vy: 0,
r: 8 * st.area * P.areaMul,
dmg: st.dmg * P.might, pierce: 0,
life: st.dur * P.durMul, rot: 0, spin: 1.2, kb: st.kb,
boom: { r: st.blastR * st.area * P.areaMul, dmg: st.dmg * P.might },
col: wdef(w.id).col
}));
}
},
/* ================= PROJECTILES ================= */
updProjectiles(G, dt) {
const P = G.player;
const near = [];
for (let i = G.projs.length - 1; i >= 0; i--) {
const p = G.projs[i];
p.t += dt;
if (p.mode === 'orbit') {
p.ang += p.av * dt;
p.x = P.x + Math.cos(p.ang) * p.orbR;
p.y = P.y + Math.sin(p.ang) * p.orbR;
G.grid.query(p.x, p.y, p.r + 30, near);
for (const e of near) {
const rr = p.r + e.r;
if (dist2(p.x, p.y, e.x, e.y) < rr * rr) {
const lt = p.hitTimes.get(e.id) || -99;
if (G.time - lt >= p.hitCd) {
p.hitTimes.set(e.id, G.time);
this.hitEnemy(G, e, p.dmg, { kb: { x: e.x - P.x, y: e.y - P.y, f: p.kb } });
}
}
}
continue;
}
if (p.mode === 'boomerang') {
const phase = p.t / (p.maxLife || 1);
const v = Math.cos(Math.PI * clamp(phase, 0, 1)) * p.spd;
p.vx = p.dx * v; p.vy = p.dy * v;
p.x += p.vx * dt; p.y += p.vy * dt;
p.rot += p.spin * dt;
} else {
if (p.grav) p.vy += p.grav * dt;
if (p.mode === 'spiral') {
// evolved axe: sweeping horizontal arcs while falling
if (p.v0x === undefined) p.v0x = Math.abs(p.vx) + 60;
p.vx = Math.cos(p.t * 5) * p.v0x;
p.vy += p.grav * dt * 0.35;
}
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.spin) p.rot += p.spin * dt;
}
p.life -= dt;
// collision
G.grid.query(p.x, p.y, p.r + 34, near);
for (const e of near) {
const key = p.rehit !== undefined ? p.rehit : -1;
const rr = p.r + e.r;
if (dist2(p.x, p.y, e.x, e.y) >= rr * rr) continue;
if (p.mode === 'boomerang') {
const lt = p.hitTimes.get(e.id) || -99;
if (G.time - lt < p.rehit) continue;
p.hitTimes.set(e.id, G.time);
} else {
if (p.hits.has(e.id)) continue;
p.hits.add(e.id);
}
this.hitEnemy(G, e, p.dmg, { kb: { x: p.vx, y: p.vy, f: p.kb } });
if (p.slow) { e.slowT = Math.max(e.slowT, p.slowDur); e.slowF = p.slow; }
if (p.freeze) { e.freezeT = Math.max(e.freezeT, p.freeze); Snd.play('freeze'); G.effects.push(new Effect('nova', e.x, e.y, .3, { r: e.r + 12, col: '#cdf5ff' })); }
if (p.boom) { this._detonate(G, p); break; }
if (p.pierce-- <= 0) { p.life = -1; break; }
}
if (p.life <= 0 || p.y > P.y + 700) {
if (p.boom && p.life <= 0) this._detonate(G, p);
removeItem(G.projs, i);
}
}
},
_detonate(G, p) {
if (p.boomed) return;
p.boomed = true;
this.explode(G, p.x, p.y, p.boom.r, p.boom.dmg, '#ff8c42', p.boom);
p.life = -1;
},
/** AoE explosion with optional burn + burning ground. */
explode(G, x, y, r, dmg, col, boomData) {
G.effects.push(new Effect('explosion', x, y, 0.4, { r, col: col || '#ff8c42' }));
for (const e of G.enemies) {
if (dist2(x, y, e.x, e.y) < (r + e.r) * (r + e.r)) {
this.hitEnemy(G, e, dmg, { kb: { x: e.x - x, y: e.y - y, f: 160 } });
if (boomData && boomData.burnDps && !e.dead) { e.burnT = boomData.burnDur; e.burnDps = boomData.burnDps; }
}
}
if (boomData && boomData.groundDps > 0) {
G.effects.push(new Effect('zone', x, y, (boomData.groundDur || 3) , {
r: r * 0.85, dps: boomData.groundDps, tickT: 0, col: '#ff6b35'
}));
}
Snd.play('boom');
G.shakeIt(3);
},
/* ================= EFFECTS (incl. damaging zones/shockwaves) ================= */
updEffects(G, dt) {
const P = G.player;
for (let i = G.effects.length - 1; i >= 0; i--) {
const ef = G.effects[i];
ef.t += dt;
if (ef.type === 'zone') {
ef.data.tickT -= dt;
if (ef.data.tickT <= 0) {
ef.data.tickT = 0.3;
for (const e of G.enemies) {
if (dist2(ef.x, ef.y, e.x, e.y) < (ef.data.r + e.r) * (ef.data.r + e.r)) {
this.hitEnemy(G, e, ef.data.dps * 0.3, { silent: true });
if (!e.dead && chance(0.3)) { e.burnT = 1; e.burnDps = ef.data.dps * 0.5; }
}
}
}
}
if (ef.type === 'shock') {
const prog = ef.t / ef.dur;
ef.data.r = ef.data.maxR * prog;
const d = Math.sqrt(dist2(ef.x, ef.y, P.x, P.y));
if (Math.abs(d - ef.data.r) < 18 && !ef.data.hit.has('P')) {
ef.data.hit.add('P');
this.hurtPlayer(G, ef.data.dmg);
}
}
if (ef.t >= ef.dur) removeItem(G.effects, i);
}
},
/* ================= ENEMY BULLETS ================= */
updEBullets(G, dt) {
const P = G.player;
for (let i = G.ebullets.length - 1; i >= 0; i--) {
const b = G.ebullets[i];
b.x += b.vx * dt; b.y += b.vy * dt;
b.life -= dt;
const rr = b.r + P.r;
if (dist2(b.x, b.y, P.x, P.y) < rr * rr) {
this.hurtPlayer(G, b.dmg);
removeItem(G.ebullets, i);
continue;
}
if (b.life <= 0) removeItem(G.ebullets, i);
}
},
/* ================= DAMAGE ================= */
hitEnemy(G, e, rawDmg, opts) {
opts = opts || {};
const P = G.player;
let dmg = rawDmg;
if (G.devOneHit) dmg = Math.max(dmg, 1e7);
let crit = false;
if (!opts.silent && chance(P.critC)) { dmg *= P.critM; crit = true; }
// frost evolution shatter bonus
if (e.freezeT > 0) dmg *= 1.3;
e.hp -= dmg;
e.flash = crit ? 0.14 : 0.09;
if (opts.lifesteal) {
P.hp = Math.min(P.maxhp, P.hp + dmg * opts.lifesteal);
}
if (opts.kb && opts.kb.f && !(e.def.kbRes) && !e.boss) {
const d = Math.max(1, len2(opts.kb.x, opts.kb.y));
const res = e.def.kbRes ? 1 - e.def.kbRes : 1;
e.kbx += (opts.kb.x / d) * opts.kb.f * 10 * res;
e.kby += (opts.kb.y / d) * opts.kb.f * 10 * res;
}
if (!opts.silent) {
Snd.play('hit');
if (crit) {
Snd.play('crit');
this.shakeFeedback(G, 1.2);
// impact ring at contact point
if (G.effects.length < 80) {
const kx = opts.kb && opts.kb.f ? -(opts.kb.x / Math.max(1, len2(opts.kb.x, opts.kb.y))) : rand(-1, 1);
const ky = opts.kb && opts.kb.f ? -(opts.kb.y / Math.max(1, len2(opts.kb.x, opts.kb.y))) : rand(-1, 1);
G.effects.push(new Effect('impact', e.x - kx * e.r * .4, e.y - ky * e.r * .4, 0.18,
{ r: 8 + Math.min(14, dmg / 40), ang: Math.atan2(ky, kx), col: '#ffd24a' }));
}
}
if (Store.s().dmgNum) {
G.texts.push(new FText(e.x, e.y - e.r - 4, String(Math.round(dmg)),
crit ? '#ffd24a' : '#ffffff', crit,
{ life: crit ? 0.9 : 0.7, pop: crit ? 2.0 : 1.15 }));
}
// directional sparks along the impact axis
if (G.parts.length < G.partCap) {
let sx = rand(-1, 1), sy = rand(-1, 1);
if (opts.kb && opts.kb.f) {
const dl = Math.max(1, len2(opts.kb.x, opts.kb.y));
sx = -opts.kb.x / dl; sy = -opts.kb.y / dl; // sparks fly back toward the shooter
}
const baseA = Math.atan2(sy, sx);
const cnt = crit ? 6 : 3;
for (let k = 0; k < cnt; k++) {
const a = baseA + rand(-.7, .7);
const sp = rand(90, 260);
part(G, e.x - sx * e.r * .5, e.y - sy * e.r * .5,
crit ? '#ffe066' : (chance(.5) ? '#ffffff' : e.col),
{ ang: a, sp, life: rand(.16, .32), size: crit ? 3.4 : 2.4, drag: .88 });
}
}
}
if (e.hp <= 0) this.killEnemy(G, e);
},
shakeFeedback(G, amt) { if (G.shakeIt) G.shakeIt(amt); },
damageEnemy(G, e, dmg, opts) { this.hitEnemy(G, e, dmg, Object.assign({ silent: true }, opts)); },
killEnemy(G, e) {
if (e.dead) return;
e.dead = true;
const P = G.player;
P.kills++;
// garlic evolution heals on kill
if (P.weaponById('e_garlic')) P.hp = Math.min(P.maxhp, P.hp + 1);
// ---- breakable props: coins + maybe chicken, no XP ----
if (e.def.prop) {
Store.data.totals.props = (Store.data.totals.props || 0) + 1; // achievement counter
const gm = G.goldMul || 1;
G.effects.push(new Effect('nova', e.x, e.y, 0.22, { r: e.r * 2.2, col: '#8fdcff' }));
for (let k = 0; k < 8; k++)
part(G, e.x, e.y, chance(.5) ? '#bfeaff' : '#ffffff', { sp: rand(60, 220), life: rand(.25, .5), size: 2.6 });
const n = randi(3, 6);
for (let i = 0; i < n; i++)
G.pickups.push(new Pickup('coin', e.x + rand(-26, 26), e.y + rand(-26, 26), Math.max(1, Math.round(randi(2, 5) * gm))));
if (chance(0.18)) G.pickups.push(new Pickup('chicken', e.x, e.y));
Snd.play('boom');
this.shakeFeedback(G, 1);
return;
}
// ---- death juice ----
Snd.play('kill');
const big = e.elite || e.boss;
G.effects.push(new Effect('nova', e.x, e.y, big ? 0.34 : 0.2,
{ r: e.r * (big ? 3.2 : 1.9), col: big ? '#ffd24a' : '#ffffff' }));
const n = big ? 22 : Math.min(10, 4 + ((e.r / 4) | 0));
for (let k = 0; k < n; k++)
part(G, e.x, e.y, chance(.35) ? '#ffffff' : e.col,
{ sp: rand(big ? 90 : 50, big ? 320 : 210), life: rand(.3, .6), size: big ? 3.5 : 2.6, big });
if (big) {
G.freezeT = Math.max(G.freezeT || 0, e.boss ? 0.26 : 0.12); // hit-stop
this.shakeFeedback(G, e.boss ? 6 : 2.5);
if (Store.s().dmgNum)
G.texts.push(new FText(e.x, e.y - e.r - 14, e.boss ? '☠☠☠' : '★ ★ ★', '#ffd24a', true,
{ life: 1, vy: 20, pop: 2.2 }));
} else {
this.shakeFeedback(G, 0.35);
}
// volatile elites detonate on death — keep your distance
if (e.blastOnDeath) {
G.effects.push(new Effect('nova', e.x, e.y, 0.3, { r: 130, col: '#ff8c42' }));
Snd.play('boom');
this.shakeFeedback(G, 3);
if (!G.over && dist2(P.x, P.y, e.x, e.y) < 115 * 115) {
this.hurtPlayer(G, Math.round(10 + (G.time / 60) * 1.5));
}
}
// drops
const gm = G.goldMul || 1;
G.pickups.push(new Pickup('gem', e.x, e.y, e.xpVal));
const luck = P.luck;
if (e.boss) {
G.pickups.push(new Pickup('chest', e.x + 14, e.y));
for (let i = 0; i < 8; i++) G.pickups.push(new Pickup('coin', e.x + rand(-40, 40), e.y + rand(-40, 40), Math.max(1, Math.round(randi(4, 9) * gm))));
G.pickups.push(new Pickup('chicken', e.x - 30, e.y));
G.onBossDead(e);
} else if (e.elite) {
G.pickups.push(new Pickup('chest', e.x, e.y));
for (let i = 0; i < 3; i++) G.pickups.push(new Pickup('coin', e.x + rand(-24, 24), e.y + rand(-24, 24), Math.max(1, Math.round(randi(2, 5) * gm))));
} else {
const roll = Math.random();
const coinP = 0.07 + luck * 0.12;
const chickP = coinP + 0.008 + luck * 0.01;
const magP = chickP + 0.002 + luck * 0.004;
const bombP = magP + 0.0015 + luck * 0.003;
if (roll < coinP) G.pickups.push(new Pickup('coin', e.x, e.y, Math.max(1, Math.round(randi(1, 3) * gm))));
else if (roll < chickP) G.pickups.push(new Pickup('chicken', e.x, e.y));
else if (roll < magP) G.pickups.push(new Pickup('magnet', e.x, e.y));
else if (roll < bombP) G.pickups.push(new Pickup('bomb', e.x, e.y));
}
// merge gems when too many
if (G.pickups.length > 320) {
let oldest = null;
for (const pk of G.pickups) if (pk.type === 'gem' && (!oldest || pk.t < oldest.t)) oldest = pk;
if (oldest) {
const gem = G.pickups[G.pickups.length - 1];
if (gem !== oldest && gem.type === 'gem') { gem.v += oldest.v; oldest.dead = true; }
}
G.pickups = G.pickups.filter(pk => !pk.dead);
}
Snd.play('hit');
},
hurtPlayer(G, amount) {
const P = G.player;
if (G.devGod || P.invuln > 0 || G.over) return;
const dmg = Math.max(1, amount - P.armor);
P.hp -= dmg;
P.invuln = 0.4;
G.hurtFlash = 0.25;
G.shakeIt(4);
Snd.play('hurt', true);
G.effects.push(new Effect('nova', P.x, P.y, 0.28, { r: 34, col: '#ff5f7a' }));
for (let k = 0; k < 6; k++)
part(G, P.x, P.y, '#ff5f7a', { sp: rand(60, 190), life: .3 });
if (Store.s().dmgNum)
G.texts.push(new FText(P.x, P.y - 18, '-' + Math.round(dmg), '#ff5f7a', true,
{ life: .8, pop: 1.6 }));
if (P.hp <= 0) G.onPlayerDown();
},
/* ================= PICKUPS ================= */
updPickups(G, dt) {
const P = G.player;
const mag2 = P.magR * P.magR;
for (let i = G.pickups.length - 1; i >= 0; i--) {
const pk = G.pickups[i];
pk.t += dt;
const d2 = dist2(pk.x, pk.y, P.x, P.y);
if (pk.pull || d2 < mag2) {
const d = Math.sqrt(Math.max(1, d2));
const pullSpd = pk.pull ? 620 : clamp(360 - d * 0.4, 90, 360);
pk.x += (P.x - pk.x) / d * pullSpd * dt;
pk.y += (P.y - pk.y) / d * pullSpd * dt;
}
const touchR = pk.r + P.r + 4;
if (d2 < touchR * touchR) {
this.collect(G, pk);
removeItem(G.pickups, i);
}
}
},
collect(G, pk) {
const P = G.player;
switch (pk.type) {
case 'gem': {
const amt = Math.max(1, Math.round(pk.v * (1 + P.growth)));
const ups = P.addXp(amt);
if (ups > 0) {
G.queueLevelUps += ups;
// level-up burst around the player
G.effects.push(new Effect('nova', P.x, P.y, 0.5, { r: 90, col: '#ffe066' }));
G.effects.push(new Effect('ring', P.x, P.y, 0.45, { r: 120, col: '#fff3b0' }));
for (let k = 0; k < 14; k++)
part(G, P.x, P.y, chance(.5) ? '#ffe066' : '#7dfaff',
{ sp: rand(70, 240), life: rand(.35, .6), size: 3 });
Snd.play('levelup');
}
Snd.play('xp');
break;
}
case 'coin': {
const g = Math.max(1, Math.round(pk.v * (1 + P.greed)));
P.gold += g; G.goldEarned += g;
Snd.play('coin');
if (Store.s().dmgNum && chance(.5))
G.texts.push(new FText(pk.x, pk.y - 10, '+' + g, '#ffd24a', false, { life: .55, vy: 42 }));
break;
}
case 'chicken':
P.hp = Math.min(P.maxhp, P.hp + P.maxhp * 0.3);
Snd.play('heal');
if (Store.s().dmgNum) G.texts.push(new FText(P.x, P.y - 18, '+HP', '#7dff9e'));
break;
case 'magnet':
for (const q of G.pickups) if (q.type === 'gem') q.pull = true;
Snd.play('heal');
break;
case 'bomb': {
G.effects.push(new Effect('nova', P.x, P.y, 0.5, { r: 640, col: '#ffd24a' }));
for (const e of G.enemies) {
if (dist2(P.x, P.y, e.x, e.y) < 640 * 640) {
this.damageEnemy(G, e, e.boss ? 350 : 9999, {});
}
}
Snd.play('boom');
G.shakeIt(8);
break;
}
case 'chest':
G.pendingChests++;
break;
}
},
/* ================= LEVEL UP CHOICES ================= */
makeChoices(G) {
const P = G.player;
const pool = [];
for (const w of P.weapons) {
if (EVOLVED[w.id]) continue;
if (w.lvl < WEAPONS[w.id].max) pool.push({ type: 'w', id: w.id, wt: 2 });
}
if (P.weapons.length < 6) {
for (const id in WEAPONS) if (P.canOfferWeapon(id)) pool.push({ type: 'nw', id, wt: 1.2 });
}
for (const id in PASSIVES) if (P.canOfferPassive(id)) pool.push({ type: 'p', id, wt: 1.4 });
if (!pool.length) {
return [
{ type: 'heal', label: '🍗', col: '#7dff9e' },
{ type: 'gold', label: '🪙', col: '#ffd24a' }
];
}
const out = [];
const used = new Set();
const want = P.level % 7 === 0 ? 4 : 3;
while (out.length < want && used.size < pool.length) {
const c = weightedPick(pool.filter(x => !used.has(x)), x => x.wt);
used.add(c);
out.push(c);
}
return out;
},
describeChoice(c) {
const P = GAME ? GAME.player : null;
if (c.type === 'w' || c.type === 'nw') {
const isEvolvedBase = c.type === 'nw';
const curLvl = c.type === 'w' ? GAME.player.weaponById(c.id).lvl : 0;
const nextLvl = curLvl + 1;
const nameKey = EVOLVED[c.id] ? c.id : 'w_' + c.id; // base weapons use w_ prefix in i18n
const sym = wdef(c.id).sym;
const col = wdef(c.id).col;
let title = tr(nameKey);
let desc;
if (isEvolvedBase) desc = tr(nameKey + '_d');
else {
const cur = wstats(c.id, curLvl), nxt = wstats(c.id, nextLvl);
const parts = [];
if (nxt.dmg > cur.dmg) parts.push(tr('up_dmg', { n: Math.round(nxt.dmg - cur.dmg) }));
if ((nxt.n || 0) > (cur.n || 0)) parts.push(tr('up_n', { n: nxt.n - cur.n }));
if (nxt.cd < cur.cd) parts.push(tr('up_cd', { n: (cur.cd - nxt.cd).toFixed(2) }));
if ((nxt.pierce || 0) > (cur.pierce || 0)) parts.push(tr('up_pierce', { n: nxt.pierce - cur.pierce }));
if ((nxt.spd || 0) > (cur.spd || 0) && c.id !== 'orb' && c.id !== 'e_orb') parts.push(tr('up_spd'));
if ((nxt.area || 0) > (cur.area || 0)) parts.push(tr('up_area'));
if ((nxt.slow || 0) > (cur.slow || 0)) parts.push('+slow');
if (!parts.length) parts.push(tr('up_misc'));
title += ' Lv.' + nextLvl;
desc = parts.join(' · ');
}
return { title, desc, sym, col, isNew: isEvolvedBase };
}
if (c.type === 'p') {
const cur = (GAME.player.passives[c.id] || 0) + 1;
return {
title: tr(c.id) + (cur > 1 ? ' Lv.' + cur : ''),
desc: tr(c.id + '_d'),
sym: PASSIVES[c.id].sym, col: PASSIVES[c.id].col,
isNew: cur === 1
};
}
if (c.type === 'heal') return { title: '🍗 ' + tr('p_regen'), desc: '+40 HP', sym: '🍗', col: '#7dff9e' };
return { title: '🪙 ' + tr('ms_greed'), desc: '+25 🪙', sym: '🪙', col: '#ffd24a' };
},
applyChoice(G, c) {
const P = G.player;
switch (c.type) {
case 'w': P.weaponById(c.id).lvl++; break;
case 'nw': P.weapons.push({ id: c.id, lvl: 1, timer: 0.3 }); break;
case 'p':
P.passives[c.id] = (P.passives[c.id] || 0) + 1;
P.recompute();
break;
case 'heal': P.hp = Math.min(P.maxhp, P.hp + 40); break;
case 'gold': P.gold += 25; G.goldEarned += 25; break;
}
Snd.play('levelup');
},
/** Resolve a chest: prefer evolution. Returns modal data. */
chestReward(G) {
const P = G.player;
// evolution candidates
const evoReady = [];
for (const w of P.weapons) {
if (EVOLVED[w.id]) continue;
const def = WEAPONS[w.id];
if (w.lvl >= def.max && P.passives[def.evo.need] && !P.weaponById(def.evo.into)) {
evoReady.push(w);
}
}
if (evoReady.length) {
const w = pick(evoReady);
const fromId = w.id, intoId = WEAPONS[fromId].evo.into;
w.id = intoId; w.lvl = 1; w.timer = 0.2;
G.projs = G.projs.filter(p => p.wid !== fromId);
const goldBonus = 50;
P.gold += goldBonus; G.goldEarned += goldBonus;
Store.data.totals.evos = (Store.data.totals.evos || 0) + 1; // achievement counter
Snd.play('evolve', true);
G.shakeIt(6);
return { evolve: true, from: fromId, into: intoId, gold: goldBonus, lines: [] };
}
// fallback: 1-3 upgrade levels + gold
const pool = [];
for (const w of P.weapons) if (!EVOLVED[w.id] && w.lvl < WEAPONS[w.id].max) pool.push({ type: 'w', id: w.id });
for (const id in PASSIVES) if (P.canOfferPassive(id)) pool.push({ type: 'p', id });
const lines = [];
const nGain = 1 + ((chance(0.3 + P.luck * 0.2)) ? 1 : 0) + ((chance(0.12 + P.luck * 0.2)) ? 1 : 0);
if (pool.length) {
for (const c of sampleN(pool, nGain)) {
this.applyChoice(G, c);
const d = this.describeChoice(c);
lines.push(d.title);
}
}
const goldBonus = 25 + Math.round(rand(0, 20) * (1 + P.luck));
P.gold += goldBonus; G.goldEarned += goldBonus;
Snd.play('chest', true);
return { evolve: false, gold: goldBonus, lines };
},
/** Music intensity from threat level. */
musicIntensity(G) {
let near = 0;
for (const e of G.enemies) {
if (dist2(G.player.x, G.player.y, e.x, e.y) < 420 * 420) near++;
}
let v = clamp(near / 40, 0, 0.7) + clamp(G.time / ((G.stage && G.stage.length) || RUN_LENGTH), 0, 0.2);
if (G.boss && !G.boss.dead) v = Math.max(v, 0.85);
return clamp(v, 0.15, 1);
}
};