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