SOURCE / PINNED RELEASE
Made of little things.
Freeze Frame Response
- Release
- 3e45de6048a3…
- Author-recorded commit
- d04885dad630…
- License
- LICENSE
- Author’s source reference
- nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-ba52e30d5f2
Archive hash verified: e747ab4f9e35a11f…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import {
GRAVITY,
GROUND_Y,
WORLD_HEIGHT,
WORLD_WIDTH,
type BodyKind,
type BodySeed,
type DeviceType,
type Level,
type Material,
} from './levels.js';
export type Outcome =
| 'air'
| 'saved'
| 'sorted'
| 'contaminated'
| 'missed'
| 'at-large'
| 'exploded'
| 'gone';
export interface Body extends BodySeed {
id: number;
active: boolean;
outcome: Outcome;
angle: number;
spin: number;
fuse: number | null;
binId: string | null;
}
export interface Device {
id: number;
type: DeviceType;
x: number;
y: number;
angle: number;
length: number;
}
export interface Blast {
x: number;
y: number;
age: number;
strength: number;
}
export interface Mark {
x: number;
y: number;
text: string;
tone: 'good' | 'bad' | 'warn';
age: number;
}
export interface Stats {
pedsTotal: number;
pedsSaved: number;
pedsLost: number;
debrisTotal: number;
debrisSorted: number;
debrisContaminated: number;
debrisMissed: number;
volatiles: number;
volatileBlasts: number;
devicesUsed: number;
allResolved: boolean;
contamination: number;
}
export const DEVICE_LENGTH: Record<DeviceType, number> = {
trampoline: 112,
funnel: 132,
cushion: 92,
};
export const DEVICE_LABEL: Record<DeviceType, string> = {
trampoline: 'Trampoline',
funnel: 'Funnel',
cushion: 'Sticky cushion',
};
const MAX_STEP = 1 / 120;
const VOLATILE_TRIGGER = 235;
export class IncidentEngine {
readonly level: Level;
readonly bodies: Body[] = [];
readonly devices: Device[] = [];
readonly blasts: Blast[] = [];
readonly marks: Mark[] = [];
elapsed = 0;
liveElapsed = 0;
shake = 0;
volatileBlasts = 0;
private nextBodyId = 1;
private nextDeviceId = 1;
private live = false;
constructor(level: Level) {
this.level = level;
this.bodies = level.bodies.map((seed) => ({
...seed,
id: this.nextBodyId++,
active: true,
outcome: 'air' as Outcome,
angle: 0,
spin: (seed.vx > 0 ? 1 : -1) * ((seed.radius % 4) + 2),
fuse: null,
binId: null,
}));
this.blasts.push({
x: level.source.x,
y: level.source.y,
age: 0,
strength: level.source.power,
});
}
preSim(): void {
const target = this.level.freezeSeconds;
while (this.elapsed < target - 0.0001) {
this.step(Math.min(MAX_STEP, target - this.elapsed), false);
}
}
beginLive(): void {
this.live = true;
this.liveElapsed = 0;
}
isLive(): boolean {
return this.live;
}
advance(dt: number): void {
if (!this.live) return;
let remaining = Math.min(dt, 0.05);
while (remaining > 0) {
const step = Math.min(MAX_STEP, remaining);
this.step(step, true);
remaining -= step;
}
this.shake = Math.max(0, this.shake - dt * 28);
}
placeDevice(type: DeviceType, x: number, y: number, angle: number): Device {
const device: Device = {
id: this.nextDeviceId++,
type,
x: clamp(x, 20, WORLD_WIDTH - 20),
y: clamp(y, 30, GROUND_Y - 6),
angle,
length: DEVICE_LENGTH[type],
};
this.devices.push(device);
return device;
}
removeLastDevice(): Device | null {
return this.devices.pop() ?? null;
}
clearDevices(): Device[] {
return this.devices.splice(0, this.devices.length);
}
countDevices(type: DeviceType): number {
return this.devices.filter((device) => device.type === type).length;
}
getStats(): Stats {
let pedsTotal = 0;
let pedsSaved = 0;
let pedsLost = 0;
let debrisTotal = 0;
let debrisSorted = 0;
let debrisContaminated = 0;
let debrisMissed = 0;
let volatiles = 0;
let allResolved = true;
for (const body of this.bodies) {
if (body.kind === 'pedestrian') {
pedsTotal += 1;
if (body.outcome === 'saved') pedsSaved += 1;
else if (body.outcome === 'at-large' || body.outcome === 'gone') pedsLost += 1;
if (body.active) allResolved = false;
} else if (body.kind === 'debris') {
debrisTotal += 1;
if (body.outcome === 'sorted') debrisSorted += 1;
else if (body.outcome === 'contaminated') debrisContaminated += 1;
else if (body.outcome === 'missed' || body.outcome === 'gone') debrisMissed += 1;
if (body.active) allResolved = false;
} else {
volatiles += 1;
if (body.active) {
allResolved = false;
} else if (body.outcome === 'exploded') {
// counted below through volatileBlasts
}
}
}
return {
pedsTotal,
pedsSaved,
pedsLost,
debrisTotal,
debrisSorted,
debrisContaminated,
debrisMissed,
volatiles,
volatileBlasts: this.volatileBlasts,
devicesUsed: this.devices.length,
allResolved,
contamination: debrisContaminated,
};
}
private step(dt: number, live: boolean): void {
this.elapsed += dt;
if (live) this.liveElapsed += dt;
for (const blast of this.blasts) blast.age += dt;
for (const mark of this.marks) mark.age += dt;
if (live) {
for (const body of this.bodies) {
if (!body.active) continue;
if (body.fuse !== null) {
body.fuse -= dt;
if (body.fuse <= 0) {
this.detonate(body);
continue;
}
}
body.vy += GRAVITY * dt;
body.x += body.vx * dt;
body.y += body.vy * dt;
body.angle += body.spin * dt;
this.collideDevices(body);
this.collideGround(body);
if (body.active) this.checkTargets(body);
if (body.active) this.settleBody(body);
if (body.active) this.checkBounds(body);
if (body.kind === 'volatile' && body.active) {
const slow = Math.abs(body.vx) < 24 && Math.abs(body.vy) < 34;
if (body.fuse === null && slow && body.y + body.radius >= GROUND_Y - 2) {
body.active = false;
body.outcome = 'gone';
}
}
}
if (this.liveElapsed > 8.2) {
for (const body of this.bodies) {
if (!body.active) continue;
body.active = false;
if (body.kind === 'pedestrian') body.outcome = 'at-large';
else if (body.kind === 'debris') body.outcome = 'missed';
else body.outcome = 'gone';
}
}
} else {
// Frozen pre-roll: build the mid-air tableau without resolving outcomes.
for (const body of this.bodies) {
if (!body.active) continue;
body.vy += GRAVITY * dt;
body.x += body.vx * dt;
body.y += body.vy * dt;
body.angle += body.spin * dt;
this.collideDevices(body);
this.collideGround(body);
if (body.x < -80 || body.x > WORLD_WIDTH + 80 || body.y > WORLD_HEIGHT + 120) {
body.active = false;
body.outcome = 'gone';
}
}
}
}
private collideGround(body: Body): void {
if (body.y + body.radius < GROUND_Y) return;
const impact = body.vy;
body.y = GROUND_Y - body.radius;
if (this.live && body.kind === 'volatile' && body.fuse === null && impact > VOLATILE_TRIGGER) {
body.fuse = 0.12;
this.addMark(body.x, body.y - 24, 'PRESSURE!', 'warn');
}
if (impact > 0) {
const restitution = body.kind === 'pedestrian' ? 0.22 : 0.16;
body.vy = impact > 55 ? -impact * restitution : 0;
}
body.vx *= body.kind === 'pedestrian' ? 0.82 : 0.8;
if (Math.abs(body.vx) < 5) body.vx = 0;
body.spin *= 0.86;
}
private settleBody(body: Body): void {
if (!this.live) return;
const onGround = body.y + body.radius >= GROUND_Y - 2;
if (!onGround) return;
if (body.kind === 'pedestrian') {
if (Math.abs(body.vx) < 18 && Math.abs(body.vy) < 22) {
body.active = false;
body.outcome = 'at-large';
this.addMark(body.x, body.y - 26, 'AT LARGE', 'bad');
}
} else if (body.kind === 'debris') {
if (Math.abs(body.vx) < 14 && Math.abs(body.vy) < 20) {
body.active = false;
body.outcome = 'missed';
this.addMark(body.x, body.y - 22, 'MISSED BIN', 'bad');
}
}
}
private collideDevices(body: Body): void {
for (const device of this.devices) {
const half = device.length / 2;
const cos = Math.cos(device.angle);
const sin = Math.sin(device.angle);
const x1 = device.x - cos * half;
const y1 = device.y - sin * half;
const x2 = device.x + cos * half;
const y2 = device.y + sin * half;
const ex = x2 - x1;
const ey = y2 - y1;
const lengthSq = ex * ex + ey * ey;
if (lengthSq === 0) continue;
const t = clamp(((body.x - x1) * ex + (body.y - y1) * ey) / lengthSq, 0, 1);
const cx = x1 + ex * t;
const cy = y1 + ey * t;
let dx = body.x - cx;
let dy = body.y - cy;
let dist = Math.hypot(dx, dy);
const thickness = device.type === 'cushion' ? 8 : 4;
const reach = body.radius + thickness;
if (dist >= reach) continue;
if (dist < 0.001) {
dx = -sin;
dy = cos;
dist = 1;
}
const nx = dx / dist;
const ny = dy / dist;
body.x += nx * (reach - dist);
body.y += ny * (reach - dist);
const vn = body.vx * nx + body.vy * ny;
if (vn >= 0) continue;
const tx = cos;
const ty = sin;
const vt = body.vx * tx + body.vy * ty;
const restitution =
device.type === 'trampoline' ? 0.94 : device.type === 'funnel' ? 0.14 : 0.02;
const friction =
device.type === 'trampoline' ? 0.985 : device.type === 'funnel' ? 0.993 : 0.42;
body.vx = tx * vt * friction - nx * vn * restitution;
body.vy = ty * vt * friction - ny * vn * restitution;
body.spin += device.type === 'trampoline' ? vn * 0.0015 : -vn * 0.001;
if (
this.live &&
body.kind === 'volatile' &&
body.fuse === null &&
-vn > VOLATILE_TRIGGER
) {
body.fuse = 0.12;
this.addMark(body.x, body.y - 24, 'PRESSURE!', 'warn');
}
}
}
private checkTargets(body: Body): void {
const atFloor = body.y + body.radius >= GROUND_Y - 8;
if (body.kind === 'pedestrian') {
for (const mattress of this.level.mattresses) {
if (body.x >= mattress.x1 && body.x <= mattress.x2 && atFloor) {
body.active = false;
body.outcome = 'saved';
body.y = GROUND_Y - 40;
this.addMark(body.x, body.y - 28, 'SOFT LANDING', 'good');
return;
}
}
} else if (body.kind === 'debris') {
for (const bin of this.level.bins) {
if (body.x >= bin.x1 && body.x <= bin.x2 && atFloor) {
body.active = false;
if (bin.material === body.material) {
body.outcome = 'sorted';
body.binId = bin.id;
this.addMark(body.x, body.y - 24, 'SORTED', 'good');
} else {
body.outcome = 'contaminated';
body.binId = bin.id;
this.addMark(body.x, body.y - 24, 'WRONG BIN', 'bad');
}
return;
}
}
}
}
private checkBounds(body: Body): void {
if (body.x > -60 && body.x < WORLD_WIDTH + 60 && body.y < WORLD_HEIGHT + 140) return;
body.active = false;
if (body.kind === 'pedestrian') body.outcome = 'gone';
else if (body.kind === 'debris') body.outcome = 'gone';
else body.outcome = 'gone';
this.addMark(
clamp(body.x, 20, WORLD_WIDTH - 20),
clamp(body.y, 40, GROUND_Y - 30),
body.kind === 'volatile' ? 'CANISTER LOST' : 'OFF SITE',
'warn',
);
}
private detonate(body: Body): void {
body.fuse = null;
body.active = false;
body.outcome = 'exploded';
this.volatileBlasts += 1;
this.explodeAt(body.x, body.y, 470, true);
}
explodeAt(x: number, y: number, power: number, chain: boolean): void {
this.blasts.push({ x, y, age: 0, strength: power });
this.shake = Math.max(this.shake, chain ? 12 : 6);
this.addMark(x, y - 30, chain ? 'CHAIN BLAST' : 'BLAST', 'warn');
for (const other of this.bodies) {
if (!other.active || other === null) continue;
const dx = other.x - x;
const dy = other.y - y;
const distance = Math.hypot(dx, dy);
if (distance > 285 || distance < 0.01) continue;
const falloff = 1 - distance / 285;
const nx = dx / distance;
const ny = dy / distance;
const impulse = (chain ? 520 : 300) * falloff + 50;
other.vx += nx * impulse;
other.vy += ny * impulse - 70 * falloff;
other.spin += (nx > 0 ? 1 : -1) * impulse * 0.01;
if (other.kind === 'volatile' && other.fuse === null && distance < 175) {
other.fuse = 0.1 + distance / 2200;
}
}
}
private addMark(x: number, y: number, text: string, tone: Mark['tone']): void {
this.marks.push({ x, y, text, tone, age: 0 });
if (this.marks.length > 18) this.marks.shift();
}
}
export function deviceEndpoints(device: Device): {
x1: number;
y1: number;
x2: number;
y2: number;
} {
const half = device.length / 2;
const cos = Math.cos(device.angle);
const sin = Math.sin(device.angle);
return {
x1: device.x - cos * half,
y1: device.y - sin * half,
x2: device.x + cos * half,
y2: device.y + sin * half,
};
}
export interface ProjectionPoint {
x: number;
y: number;
}
export function projectBody(body: Body, horizon = 1.6): ProjectionPoint[] {
if (!body.active) return [];
const points: ProjectionPoint[] = [];
let x = body.x;
let y = body.y;
let vy = body.vy;
const vx = body.vx;
const step = 0.06;
for (let t = step; t <= horizon; t += step) {
vy += GRAVITY * step;
x += vx * step;
y += vy * step;
if (y > GROUND_Y - body.radius) {
points.push({ x, y: GROUND_Y - body.radius });
break;
}
points.push({ x, y });
}
return points;
}
export function materialName(material: Material | undefined): string {
if (material === 'masonry') return 'masonry';
if (material === 'furniture') return 'furniture';
if (material === 'glass') return 'glass';
return 'material';
}
export function bodyKindName(kind: BodyKind): string {
return kind === 'pedestrian' ? 'pedestrian' : kind === 'volatile' ? 'gas canister' : 'debris';
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
