escape/src/engine/Projectile.js

162 lines
6.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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