162 lines
6.3 KiB
JavaScript
162 lines
6.3 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|