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