320 lines
13 KiB
JavaScript
320 lines
13 KiB
JavaScript
// ---- projectile simulation (player bullets + enemy bullets) ----
|
|
|
|
const PROJECTILE_DRAW_SIZE = {
|
|
bullet_bone: 16, bullet_saw: 24, bullet_cleaver: 24,
|
|
bullet_harpoon: 32, bullet_eye: 24, bullet_syringe: 24, bullet_gore: 24,
|
|
bullet_steam: 24,
|
|
};
|
|
|
|
function nearestEnemy(x, y, maxD) {
|
|
let best = null, bd = maxD * maxD;
|
|
for (const e of G.enemies) {
|
|
if (e.phased) continue;
|
|
const d = dist2(x, y, e.x, e.y);
|
|
if (d < bd) { bd = d; best = e; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function updateBullets(dt) {
|
|
const p = G.player;
|
|
if (G.bullets.length > 520) G.bullets.splice(0, G.bullets.length - 520);
|
|
if (G.ebullets.length > 640) G.ebullets.splice(0, G.ebullets.length - 640);
|
|
for (let i = G.bullets.length - 1; i >= 0; i--) {
|
|
const b = G.bullets[i];
|
|
// Sweep the first frame from the player's pivot to the per-weapon muzzle,
|
|
// then sweep between simulation positions so long/fast weapons cannot skip
|
|
// enemies standing inside the visible barrel or between frames.
|
|
const prevX = b.spawnX === undefined ? b.x : b.spawnX;
|
|
const prevY = b.spawnY === undefined ? b.y : b.spawnY;
|
|
delete b.spawnX; delete b.spawnY;
|
|
b.t += dt;
|
|
let dead = false;
|
|
|
|
// homing steer
|
|
if (b.homing) {
|
|
const e = nearestEnemy(b.x, b.y, 320);
|
|
if (e) {
|
|
const want = angleTo(b.x, b.y, e.x, e.y);
|
|
const cur = Math.atan2(b.vy, b.vx);
|
|
const na = angleLerp(cur, want, clamp(b.homing * dt, 0, 1));
|
|
const sp = Math.hypot(b.vx, b.vy);
|
|
b.vx = Math.cos(na) * sp; b.vy = Math.sin(na) * sp;
|
|
}
|
|
}
|
|
|
|
// boomerang: decelerate then fly back to player
|
|
if (b.behavior === 'boomerang') {
|
|
b.phase += dt;
|
|
if (b.phase > 0.45) {
|
|
const a = angleTo(b.x, b.y, p.x, p.y);
|
|
const sp = Math.min(Math.hypot(b.vx, b.vy) + 900 * dt, 520);
|
|
b.vx = Math.cos(a) * sp; b.vy = Math.sin(a) * sp;
|
|
if (dist2(b.x, b.y, p.x, p.y) < 400) dead = true; // caught
|
|
} else {
|
|
b.vx *= 0.985; b.vy *= 0.985;
|
|
}
|
|
b.ang += 14 * dt;
|
|
}
|
|
|
|
b.x += b.vx * dt; b.y += b.vy * dt;
|
|
if (b.behavior === 'bounce' || b.behavior === 'pierce_drag') b.ang += 12 * dt;
|
|
|
|
// walls
|
|
const outL = b.x < G.arena.x0 + 4, outR = b.x > G.arena.x1 - 4;
|
|
const outU = b.y < G.arena.y0 + 4, outD = b.y > G.arena.y1 - 4;
|
|
if (outL || outR || outU || outD) {
|
|
const bouncesLeft = (b.bounces || 0) + (b.bounce || 0);
|
|
if (bouncesLeft > 0) {
|
|
if (outL || outR) b.vx = -b.vx;
|
|
if (outU || outD) b.vy = -b.vy;
|
|
b.x = clamp(b.x, G.arena.x0 + 4, G.arena.x1 - 4);
|
|
b.y = clamp(b.y, G.arena.y0 + 4, G.arena.y1 - 4);
|
|
if (b.bounces > 0) b.bounces--; else if (b.bounce > 0) b.bounce--;
|
|
b.ang = Math.atan2(b.vy, b.vx);
|
|
spawnSpark(b.x, b.y, b.ang);
|
|
Sfx.ricochet({ x: b.x, y: b.y });
|
|
} else if (b.lobbed) {
|
|
dead = true; // fizzles on wall
|
|
} else {
|
|
spawnSpark(b.x, b.y, Math.atan2(b.vy, b.vx));
|
|
Sfx.wallHit({ x: b.x, y: b.y });
|
|
dead = true;
|
|
}
|
|
}
|
|
|
|
// expired
|
|
if (!dead && b.t >= b.life) {
|
|
dead = true;
|
|
// bile congeals into acid pools
|
|
if (b.behavior === 'cone' && chance(0.35)) {
|
|
G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 2.5, t: 0, dps: b.dmg * 0.8 });
|
|
}
|
|
// Sawbone Coil: bullets split into 2 shards on expiry
|
|
if (p.stats.sawboneCoil > 0 && !b.shard && b.behavior !== 'lob' && b.behavior !== 'lob_trap' && b.behavior !== 'lob_swarm') {
|
|
for (let k = 0; k < 2; k++) {
|
|
const sa = rand(0, TAU);
|
|
G.bullets.push({
|
|
x: b.x, y: b.y, ang: sa,
|
|
vx: Math.cos(sa) * 260 * p.stats.shotSpeedMul, vy: Math.sin(sa) * 260 * p.stats.shotSpeedMul,
|
|
r: 3 * p.stats.sizeMul, dmg: b.dmg * 0.4, pierce: 0, bounce: p.stats.bounce,
|
|
life: 0.4 * p.stats.rangeMul, t: 0, behavior: 'bullet', sprite: 'bullet_bone',
|
|
homing: p.stats.homing ? 1.6 + p.stats.homing * 0.7 : 0, shard: true, sizeMul: p.stats.sizeMul,
|
|
});
|
|
}
|
|
spawnSpark(b.x, b.y, rand(0, TAU));
|
|
}
|
|
}
|
|
|
|
// lobbed projectiles trigger on expiry (reached target)
|
|
if (dead && b.lobbed && !b._triggered) {
|
|
b._triggered = true;
|
|
lobTrigger(b);
|
|
}
|
|
|
|
// enemy collision (lobbed projectiles arc overhead and only trigger at
|
|
// their target point — or on a wall — never mid-flight)
|
|
if (!dead && !b.lobbed) {
|
|
for (const e of G.enemies) {
|
|
if (e.hp <= 0 || e.phased || (b.hit && b.hit.has(e))) continue;
|
|
if (e === b.dragTarget) continue;
|
|
if (distToSegment(e.x, e.y, prevX, prevY, b.x, b.y) < b.r + e.r) {
|
|
const a = Math.atan2(b.vy, b.vx);
|
|
const killed = damageEnemy(e, b.dmg, a, true, { source: 'player', bullet: b });
|
|
if (!b.hit) b.hit = new Set();
|
|
b.hit.add(e);
|
|
spawnBlood(b.x, b.y, a, 3);
|
|
if (b.behavior === 'flame') { e.burnT = Math.max(e.burnT, 1.5); e.burnDps = Math.max(e.burnDps || 0, b.dmg * 0.8); }
|
|
if (b.behavior === 'cone') {
|
|
e.slowT = Math.max(e.slowT, 1.6);
|
|
if (G.hazards.length < 80 && chance(0.45)) G.hazards.push({ kind: 'acid', x: b.x, y: b.y, r: 24 * (b.sizeMul || 1), life: 2.5, t: 0, dps: b.dmg * 0.8 });
|
|
}
|
|
// Hemophage remains an on-hit drain, naturally limited by its fire interval.
|
|
if (b.lifesteal && p.hp < p.stats.maxHp && chance(b.lifesteal * 0.25)) lifestealPlayer(1);
|
|
if (b.behavior === 'pierce_drag' && !b.dragTarget && !e.boss) {
|
|
b.dragTarget = e; e.dragged = b;
|
|
}
|
|
if (b.pierce > 0) { b.pierce--; }
|
|
else if (b.behavior === 'boomerang' || b.behavior === 'pierce_drag') { /* pass through */ }
|
|
// Sawblades ricochet through meat the same way they ricochet off
|
|
// walls: each enemy hit spends one bounce, shared with wall bounces.
|
|
else if (b.behavior === 'bounce' && ((b.bounces || 0) + (b.bounce || 0)) > 0) {
|
|
if (b.bounces > 0) b.bounces--; else b.bounce--;
|
|
spawnSpark(b.x, b.y, a + Math.PI);
|
|
}
|
|
else {
|
|
dead = true; break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// drag victim along with the harpoon
|
|
if (b.dragTarget) {
|
|
if (b.dragTarget.hp <= 0 || dead) b.dragTarget.dragged = null, b.dragTarget = null;
|
|
else {
|
|
b.dragTarget.x = b.x; b.dragTarget.y = b.y;
|
|
if (b.t >= b.life) { damageEnemy(b.dragTarget, b.dmg * 0.5, b.ang, true); b.dragTarget.dragged = null; }
|
|
}
|
|
}
|
|
|
|
if (dead) G.bullets.splice(i, 1);
|
|
}
|
|
|
|
// ---- enemy bullets ----
|
|
for (let i = G.ebullets.length - 1; i >= 0; i--) {
|
|
const b = G.ebullets[i];
|
|
b.t += dt;
|
|
if (b.behavior === 'orbit') {
|
|
if (!b.anchor || b.anchor.hp <= 0) {
|
|
b.behavior = null;
|
|
const releaseAng = (b.orbA || 0) + Math.sign(b.orbSpd || 1) * Math.PI / 2;
|
|
const releaseSpd = b.releaseSpd || 280;
|
|
b.vx = Math.cos(releaseAng) * releaseSpd;
|
|
b.vy = Math.sin(releaseAng) * releaseSpd;
|
|
b.anchor = null;
|
|
} else {
|
|
b.orbA += b.orbSpd * dt;
|
|
if (b.orbTargetR !== undefined) b.orbR = lerp(b.orbR, b.orbTargetR, clamp(dt * 5, 0, 1));
|
|
b.x = b.anchor.x + Math.cos(b.orbA) * b.orbR;
|
|
b.y = b.anchor.y + Math.sin(b.orbA) * b.orbR;
|
|
}
|
|
}
|
|
if (b.behavior !== 'orbit') {
|
|
if (b.homing) {
|
|
const want = angleTo(b.x, b.y, p.x, p.y);
|
|
const cur = Math.atan2(b.vy, b.vx);
|
|
const ang = angleLerp(cur, want, clamp(b.homing * dt, 0, 1));
|
|
const sp = Math.hypot(b.vx, b.vy);
|
|
b.vx = Math.cos(ang) * sp; b.vy = Math.sin(ang) * sp;
|
|
}
|
|
if (b.accel) {
|
|
const sp = Math.max(0, Math.hypot(b.vx, b.vy) + b.accel * dt);
|
|
const ang = Math.atan2(b.vy, b.vx);
|
|
b.vx = Math.cos(ang) * sp; b.vy = Math.sin(ang) * sp;
|
|
}
|
|
b.x += b.vx * dt; b.y += b.vy * dt;
|
|
}
|
|
let dead = b.t >= b.life;
|
|
if (b.behavior !== 'orbit') {
|
|
const outL = b.x < G.arena.x0 + 4, outR = b.x > G.arena.x1 - 4;
|
|
const outU = b.y < G.arena.y0 + 4, outD = b.y > G.arena.y1 - 4;
|
|
if (outL || outR || outU || outD) {
|
|
if (b.bounces > 0) {
|
|
if (outL || outR) b.vx = -b.vx;
|
|
if (outU || outD) b.vy = -b.vy;
|
|
b.x = clamp(b.x, G.arena.x0 + 4, G.arena.x1 - 4);
|
|
b.y = clamp(b.y, G.arena.y0 + 4, G.arena.y1 - 4);
|
|
b.bounces--;
|
|
} else dead = true;
|
|
}
|
|
}
|
|
if (!dead && p.hp > 0 && dist2(b.x, b.y, p.x, p.y) < (b.r + p.r) * (b.r + p.r)) {
|
|
hurtPlayer(b.dmg, Math.atan2(b.vy, b.vx));
|
|
dead = true;
|
|
}
|
|
if (dead) G.ebullets.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
function lobTrigger(b) {
|
|
if (b.behavior === 'lob') { // Flesh Masher: meat bomb
|
|
const radius = 85 * (b.rangeMul || 1) * Math.sqrt(b.sizeMul || 1);
|
|
areaDamage(b.x, b.y, radius, b.dmg, true, { source: 'player' });
|
|
spawnExplosionFx(b.x, b.y, radius);
|
|
spawnBlood(b.x, b.y, 0, 16, true);
|
|
Sfx.explode({ x: b.x, y: b.y });
|
|
} else if (b.behavior === 'lob_trap') { // Trap Queen
|
|
G.hazards.push({ kind: 'trap', x: b.x, y: b.y, r: 16 * (b.sizeMul || 1), life: 12 * (b.rangeMul || 1), t: 0, dmg: b.dmg, charges: 3 });
|
|
Sfx.trapSet({ x: b.x, y: b.y });
|
|
} else if (b.behavior === 'lob_swarm') { // Swarm Jar: homing maggots
|
|
const st = G.player.stats;
|
|
for (let i = 0; i < 6 + st.split + st.fan; i++) {
|
|
const a = rand(0, TAU);
|
|
G.bullets.push({
|
|
x: b.x, y: b.y, ang: a,
|
|
vx: Math.cos(a) * 260, vy: Math.sin(a) * 260,
|
|
r: 4 * st.sizeMul, dmg: b.dmg, pierce: st.pierce, bounce: st.bounce, life: 2.2 * st.rangeMul, t: 0,
|
|
behavior: 'maggot', sprite: 'bullet_gore', homing: 8 + st.homing * 0.7, sizeMul: st.sizeMul,
|
|
});
|
|
}
|
|
spawnBlood(b.x, b.y, 0, 8);
|
|
Sfx.spit({ x: b.x, y: b.y });
|
|
}
|
|
}
|
|
|
|
function drawBullets(ctx) {
|
|
for (const b of G.bullets) {
|
|
let rot = b.ang;
|
|
const sp = Math.hypot(b.vx || 0, b.vy || 0);
|
|
if (sp > 20 && b.behavior !== 'boomerang') {
|
|
const nx = b.vx / sp, ny = b.vy / sp;
|
|
ctx.globalAlpha = 0.18;
|
|
ctx.strokeStyle = b.behavior === 'cone' ? '#8ee84a' :
|
|
(b.behavior === 'flame' ? '#ff8a2a' :
|
|
(b.behavior === 'pierce_drag' ? '#b35b42' :
|
|
(b.behavior === 'homing' ? '#df6b73' : '#f4dfbc')));
|
|
ctx.lineWidth = Math.max(2, b.r * 0.7);
|
|
ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x - nx * Math.min(22, sp * 0.04), b.y - ny * Math.min(22, sp * 0.04)); ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
}
|
|
if (b.behavior === 'flame') {
|
|
const life = 1 - b.t / b.life;
|
|
ctx.globalAlpha = 0.24 * life;
|
|
ctx.fillStyle = '#b01818'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 7, 0, TAU); ctx.fill();
|
|
ctx.globalAlpha = 0.8 * life;
|
|
ctx.fillStyle = '#ff8020'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 3, 0, TAU); ctx.fill();
|
|
ctx.fillStyle = '#fff0a0'; ctx.beginPath(); ctx.arc(b.x, b.y, Math.max(2, b.r * 0.45), 0, TAU); ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
continue;
|
|
}
|
|
if (b.behavior === 'cone') {
|
|
const life = 1 - b.t / b.life;
|
|
ctx.globalAlpha = 0.2 * life;
|
|
ctx.fillStyle = '#b9ec55'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 6, 0, TAU); ctx.fill();
|
|
ctx.globalAlpha = 0.75 * life;
|
|
ctx.fillStyle = '#70c030'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r + 2, 0, TAU); ctx.fill();
|
|
ctx.fillStyle = '#d6ff78'; ctx.beginPath(); ctx.arc(b.x - 2, b.y - 2, 1.6, 0, TAU); ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
continue;
|
|
}
|
|
if (b.behavior === 'maggot') {
|
|
ctx.fillStyle = '#d0d8b0';
|
|
ctx.save(); ctx.translate(b.x, b.y); ctx.rotate(Math.atan2(b.vy, b.vx));
|
|
ctx.beginPath(); ctx.ellipse(0, 0, 4.5, 2.4, 0, 0, TAU); ctx.fill();
|
|
ctx.fillStyle = '#6b4934'; ctx.fillRect(2.5, -1, 2, 2); ctx.restore();
|
|
continue;
|
|
}
|
|
if (b.sprite === 'bullet_saw') rot = b.t * 18;
|
|
Sprites.draw(ctx, b.sprite, b.x, b.y, rot, (PROJECTILE_DRAW_SIZE[b.sprite] || 24) * Math.sqrt(b.sizeMul || 1));
|
|
if (b.sprite === 'bullet_saw') {
|
|
ctx.globalAlpha = 0.35;
|
|
ctx.strokeStyle = '#ffd67d'; ctx.lineWidth = 1;
|
|
ctx.beginPath(); ctx.arc(b.x, b.y, 11 + Math.sin(b.t * 28) * 2, 0, TAU); ctx.stroke();
|
|
ctx.globalAlpha = 1;
|
|
} else if (b.sprite === 'bullet_harpoon' && sp > 20) {
|
|
const nx = b.vx / sp, ny = b.vy / sp;
|
|
ctx.globalAlpha = 0.5; ctx.strokeStyle = '#6f4b3d'; ctx.lineWidth = 2;
|
|
ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(b.x - nx * 7, b.y - ny * 7); ctx.lineTo(b.x - nx * 30, b.y - ny * 30); ctx.stroke();
|
|
ctx.setLineDash([]); ctx.globalAlpha = 1;
|
|
} else if (b.sprite === 'bullet_cleaver') {
|
|
ctx.globalAlpha = 0.28; ctx.strokeStyle = '#f0c8b2'; ctx.lineWidth = 2;
|
|
ctx.beginPath(); ctx.arc(b.x, b.y, 13, rot - 1.1, rot + 0.4); ctx.stroke(); ctx.globalAlpha = 1;
|
|
}
|
|
}
|
|
for (const b of G.ebullets) {
|
|
if (b.behavior === 'orbit' && b.anchor && b.anchor.hp > 0) {
|
|
ctx.globalAlpha = 0.38;
|
|
ctx.strokeStyle = '#7b5141'; ctx.lineWidth = 2;
|
|
ctx.setLineDash([4, 3]);
|
|
ctx.beginPath(); ctx.moveTo(b.anchor.x, b.anchor.y); ctx.lineTo(b.x, b.y); ctx.stroke();
|
|
ctx.setLineDash([]); ctx.globalAlpha = 1;
|
|
}
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.fillStyle = '#ff3650'; ctx.beginPath(); ctx.arc(b.x, b.y, b.r * 1.9, 0, TAU); ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
const sprite = b.sprite || 'bullet_gore';
|
|
Sprites.draw(ctx, sprite, b.x, b.y, b.t * 6, PROJECTILE_DRAW_SIZE[sprite] || 24);
|
|
}
|
|
}
|