Back to Freeze Frame Response
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.

src/physics.ts
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));
}