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)
1263 lines
44 KiB
JavaScript
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);
|
|
}
|
|
};
|