/** * A bullet (or plasma bolt) tracked in world space. Unlike classic Wolf3D * hitscan, shots fly at the weapon's bulletSpeed and only deal damage when * they actually collide with a wall, an enemy, or the player. Shots deviate * from the aim direction by the weapon's spread (scaled up for low-skill * enemies), so accuracy is emergent: a wide shot simply misses. Weapons with * an `explosion` def (rocket launcher) also splash-damage everything around * their impact point — see explode(). * * Quacks like a renderable for SpriteRenderer: { x, y, active, isEnemy, * def: { tex, scale, anchor: 'center' } } — drawn as a small billboard at * eye height, occluded by walls via the zBuffer like any sprite. */ /** Max cells a projectile may advance per collision sample. Smaller than the * hit radii and any wall tile, so fast bullets can't tunnel through either. */ const MAX_STEP = 0.25; /** Collision radii (cells): entities are torso-sized, the player a bit * slimmer so incoming fire is dodgeable. */ const ENEMY_HIT_RADIUS = 0.35; const PLAYER_HIT_RADIUS = 0.3; // --- Skill (1–10) → shooting-quality helpers. One source of truth, used by // --- Entity (enemy fire) and available to tests. /** Spread multiplier: skill 10 shoots at the weapon's listed spread, * skill 1 at ~4.15×. */ export function skillSpreadMult(skill) { return 1 + (10 - skill) * 0.35; } /** Delay between spotting the player and the first shot. */ export function skillReactionMs(skill) { return (11 - skill) * 120; } /** Low-skill enemies also fire slower than the weapon allows. */ export function skillCooldownMs(weapon, skill) { return weapon.cooldownMs * (1 + (10 - skill) * 0.15); } /** Rotate the unit vector (dirX, dirY) by a uniform random angle within * ±spreadDeg × spreadMult degrees. */ export function spreadDir(dirX, dirY, spreadDeg, spreadMult = 1) { const maxRad = (spreadDeg * spreadMult * Math.PI) / 180; if (maxRad === 0) return { x: dirX, y: dirY }; const a = (Math.random() * 2 - 1) * maxRad; const c = Math.cos(a); const s = Math.sin(a); return { x: dirX * c - dirY * s, y: dirX * s + dirY * c }; } export class Projectile { /** owner is the shooter (the Player or an Entity): it selects the target * set — player bullets hit enemies, enemy bullets hit the player — which * also inherently prevents self-hits and enemy friendly fire. */ constructor({ x, y, dirX, dirY, weapon, owner, spreadMult = 1, spreadDeg = weapon.spreadDeg }) { const dir = spreadDir(dirX, dirY, spreadDeg, spreadMult); // Muzzle offset along the true aim so the bolt doesn't clip the shooter. this.x = x + dirX * 0.3; this.y = y + dirY * 0.3; this.dx = dir.x; this.dy = dir.y; this.speed = weapon.bulletSpeed; this.damage = weapon.damage; this.rangeLeft = weapon.range; this.owner = owner; this.fromPlayer = !!owner?.cam; this.active = true; // Splash spec ({ radius, damage } or null) and the blast art, both from // the weapon def. Explosive shots detonate wherever they stop. this.explosion = weapon.explosion ?? null; this.explosionTex = weapon.explosionTex ?? null; // SpriteRenderer contract (anchor 'center' = eye height, not floor). this.isEnemy = false; this.def = { tex: weapon.projTex, scale: weapon.projectile?.size ?? 0.08, anchor: 'center' }; // Set when this projectile hits something, for GameScene to react to. // Arrays/totals because one explosion can kill several enemies and hurt // the player (splash ignores ownership — even the shooter isn't safe). this.kills = []; // enemies killed by this shot, for score credit this.playerDamage = 0; // total damage dealt to the player this.exploded = null; // { x, y, radius } once detonated, for the blast VFX } /** Advance by dt seconds through `world` = { map, entities, player }, * substepped so no sample exceeds MAX_STEP cells. */ update(dt, world) { if (!this.active) return; const travel = Math.min(this.speed * dt, this.rangeLeft); const steps = Math.max(1, Math.ceil(travel / MAX_STEP)); const stepLen = travel / steps; for (let i = 0; i < steps; i++) { this.x += this.dx * stepLen; this.y += this.dy * stepLen; this.rangeLeft -= stepLen; if (world.map.isWall(this.x, this.y)) { // Back out of the wall so a detonation isn't buried inside the cell // (hasLineOfSight from within a wall would block all its splash). this.x -= this.dx * stepLen; this.y -= this.dy * stepLen; this.impact(world); return; } if (this.fromPlayer) { for (const e of world.entities) { if (!e.alive) continue; // corpses and dying enemies don't soak hits if (Math.hypot(e.x - this.x, e.y - this.y) >= ENEMY_HIT_RADIUS) continue; if (e.damage(this.damage)) this.kills.push(e); this.impact(world); return; } } else { const { cam } = world.player; if (Math.hypot(cam.posX - this.x, cam.posY - this.y) < PLAYER_HIT_RADIUS) { this.playerDamage += this.damage; this.impact(world); return; } } } if (this.rangeLeft <= 0) this.impact(world); } /** Stop here; direct-hit damage was already applied by the caller. Explosive * warheads add their splash on top (a direct hit deals both). */ impact(world) { this.active = false; if (this.explosion) this.explode(world); } /** Splash damage to everything alive within the blast radius — enemies and * the player alike, shooter included — falling off linearly from full * damage at the center to zero at the edge. Walls (and closed doors) * shield anything without line of sight to the blast. */ explode(world) { const { radius, damage } = this.explosion; this.exploded = { x: this.x, y: this.y, radius }; const splash = (tx, ty) => { const d = Math.hypot(tx - this.x, ty - this.y); if (d >= radius || !world.map.hasLineOfSight(this.x, this.y, tx, ty)) return 0; return Math.round(damage * (1 - d / radius)); }; for (const e of world.entities) { if (!e.alive) continue; const dmg = splash(e.x, e.y); if (dmg > 0 && e.damage(dmg)) this.kills.push(e); } const { cam } = world.player; this.playerDamage += splash(cam.posX, cam.posY); } }