SOURCE / PINNED RELEASE
Made of little things.
Big Rig Rush
- Release
- ec9b6549f752…
- Author-recorded commit
- 74b729258b1a…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-0a99f0cae40
Archive hash verified: 900e22810340ccb1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import type { City } from './city.js';
import type { Effects } from './effects.js';
import { angleDiff, type Vec } from './geometry.js';
import type { Body } from './physics.js';
import { inView, type View } from './traffic.js';
interface Missile {
x: number;
y: number;
angle: number;
speed: number;
layer: number;
life: number;
/** What it's homing on, if anything was in its sights at launch. */
target: Body | null;
/** Fired from the air: it flies over walls for its first moments as it dives. */
overfly: number;
trail: number;
}
/** Something a missile can hit: a car, a wreck or a service unit. */
export interface MissileTarget {
body: Body;
layer: number;
}
const LAUNCH = 230;
const TOP = 380;
const LIFE = 2.2;
const TURN = 2.6;
/**
* The sports car's missiles: launched from the nose, they lock on to whatever is in a
* narrow cone ahead, home in a little, and blow up on contact with a vehicle or a wall
* (or when the motor burns out).
*/
export class Missiles {
private list: Missile[] = [];
private cooldown = 0;
/** Boom: the game handles the blast (damage, shockwave, sound). */
onExplode: ((x: number, y: number, layer: number) => void) | null = null;
constructor(
private readonly city: City,
private readonly effects: Effects,
) {}
clear(): void {
this.list = [];
this.cooldown = 0;
}
get ready(): boolean {
return this.cooldown <= 0;
}
/** Fire from a launcher at (x, y) heading `angle`, inheriting the car's speed. Returns false while reloading. */
fire(x: number, y: number, angle: number, carSpeed: number, layer: number, targets: MissileTarget[], air = false): boolean {
if (this.cooldown > 0) return false;
this.cooldown = air ? 0.35 : 0.55;
// Lock on to the nearest thing in a 20° cone within range.
let target: Body | null = null;
let best = air ? 480 : 320;
for (const t of targets) {
if (t.layer !== layer) continue;
const dx = t.body.x - x;
const dy = t.body.y - y;
const d = Math.hypot(dx, dy);
if (d < 8 || d > best || Math.abs(angleDiff(Math.atan2(dy, dx), angle)) > 0.35) continue;
best = d;
target = t.body;
}
this.list.push({ x, y, angle, speed: Math.max(0, carSpeed) + LAUNCH, layer, life: LIFE, target, trail: 0, overfly: air ? (target ? Infinity : 0.6) : 0 });
for (let i = 0; i < 4; i++) this.effects.puff(x - Math.cos(angle) * 3, y - Math.sin(angle) * 3, false, true, 0.8);
this.effects.puff(x - Math.cos(angle) * 4, y - Math.sin(angle) * 4, true, false, 1.4);
return true;
}
update(dt: number, targets: MissileTarget[], view: View): void {
this.cooldown -= dt;
for (const m of this.list) {
m.life -= dt;
m.overfly -= dt;
// A locked air-to-ground missile dives straight onto its target.
if (m.overfly === Infinity && m.target && Math.hypot(m.target.x - m.x, m.target.y - m.y) < 30) m.overfly = 0;
m.speed = Math.min(TOP, m.speed + 300 * dt);
if (m.target) {
const want = Math.atan2(m.target.y - m.y, m.target.x - m.x);
const d = angleDiff(want, m.angle);
m.angle += Math.max(-TURN * dt, Math.min(TURN * dt, d));
}
// Move in small steps so it can't tunnel through a car or a thin wall.
const steps = Math.max(1, Math.ceil((m.speed * dt) / 3));
for (let i = 0; i < steps && m.life > 0; i++) {
m.x += (Math.cos(m.angle) * m.speed * dt) / steps;
m.y += (Math.sin(m.angle) * m.speed * dt) / steps;
if (this.hits(m, targets)) m.life = 0;
}
// Smoke trail.
m.trail -= dt;
if (m.trail <= 0 && inView(m.x, m.y, view, 60)) {
m.trail = 0.025;
this.effects.puff(m.x - Math.cos(m.angle) * 2.5, m.y - Math.sin(m.angle) * 2.5, false, false, 0.7);
}
}
for (const m of this.list) if (m.life <= 0) this.onExplode?.(m.x, m.y, m.layer);
this.list = this.list.filter((m) => m.life > 0);
}
private hits(m: Missile, targets: MissileTarget[]): boolean {
if (m.overfly <= 0) {
const wall = this.city.resolveCircle(m.x, m.y, 1.2, m.layer);
if (wall.x || wall.y) return true;
}
for (const t of targets) {
const b = t.body;
if (t.layer !== m.layer || Math.abs(b.x - m.x) > b.halfL + 2 || Math.abs(b.y - m.y) > b.halfL + 2) continue;
const dx = m.x - b.x;
const dy = m.y - b.y;
const c = Math.cos(b.angle);
const s = Math.sin(b.angle);
if (Math.abs(dx * c + dy * s) <= b.halfL + 1 && Math.abs(-dx * s + dy * c) <= b.halfW + 1) return true;
}
return false;
}
/** Missiles in flight: a dark body, a red nose and a rocket flame. */
draw(ctx: CanvasRenderingContext2D, view: View, time: number, layer: number): void {
for (const m of this.list) {
if (m.layer !== layer || !inView(m.x, m.y, view, 20)) continue;
ctx.save();
ctx.translate(m.x, m.y);
ctx.rotate(m.angle);
const flicker = 2.5 + Math.sin(time * 60) * 0.8;
ctx.fillStyle = 'rgba(255, 200, 60, 0.9)';
ctx.beginPath();
ctx.moveTo(-2.2, -0.8);
ctx.lineTo(-2.2 - flicker, 0);
ctx.lineTo(-2.2, 0.8);
ctx.fill();
ctx.fillStyle = '#2b2b2e';
ctx.fillRect(-2.4, -0.7, 4.2, 1.4);
ctx.fillStyle = '#d93a3a';
ctx.beginPath();
ctx.moveTo(1.8, -0.7);
ctx.lineTo(3, 0);
ctx.lineTo(1.8, 0.7);
ctx.fill();
ctx.fillStyle = '#8d9096';
ctx.fillRect(-2.4, -1.3, 1, 2.6);
ctx.restore();
}
}
/** Rocket flames for the night overlay. */
lights(view: View): Vec[] {
return this.list.filter((m) => inView(m.x, m.y, view, 30));
}
}
