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/render.ts
import {
  GROUND_Y,
  WORLD_HEIGHT,
  WORLD_WIDTH,
  type DeviceType,
  type Material,
} from './levels.js';
import {
  deviceEndpoints,
  projectBody,
  type Body,
  type Device,
  type IncidentEngine,
  type Mark,
} from './physics.js';

export type SceneMode = 'frozen' | 'running' | 'report';

export interface RenderOptions {
  mode: SceneMode;
  selected: DeviceType;
  angle: number;
  ghost: { x: number; y: number } | null;
  showHints: boolean;
  now: number;
}

interface View {
  scale: number;
  ox: number;
  oy: number;
}

const COLORS = {
  skyA: '#16222c',
  skyB: '#35444d',
  skyC: '#5b625e',
  far: '#23323b',
  mid: '#1b2931',
  near: '#20303a',
  window: '#d6b46d',
  windowCold: '#7fb9bd',
  street: '#303b42',
  sidewalk: '#46535a',
  curb: '#71808a',
  cream: '#efe6d0',
  ink: '#14191c',
  hazard: '#f2b52f',
  freeze: '#8ce6e8',
  rust: '#cf6d44',
  masonry: '#c97146',
  furniture: '#5e90c8',
  glass: '#5fae8c',
  mattress: '#e9dfc7',
  mattressEdge: '#b7a883',
  ped: '#e7be72',
  coatA: '#c75a40',
  coatB: '#5b7fa8',
  coatC: '#7a9d5b',
  gas: '#d84e35',
  cushion: '#c96f9a',
  funnel: '#e2a13b',
};

function view(w: number, h: number): View {
  const scale = Math.min(w / WORLD_WIDTH, h / WORLD_HEIGHT);
  return {
    scale,
    ox: (w - WORLD_WIDTH * scale) / 2,
    oy: (h - WORLD_HEIGHT * scale) / 2,
  };
}

export function worldFromClient(
  x: number,
  y: number,
  width: number,
  height: number,
): { x: number; y: number } {
  const v = view(width, height);
  return { x: (x - v.ox) / v.scale, y: (y - v.oy) / v.scale };
}

export function renderScene(
  ctx: CanvasRenderingContext2D,
  engine: IncidentEngine,
  width: number,
  height: number,
  pixelRatio: number,
  options: RenderOptions,
): void {
  const v = view(width, height);
  ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
  ctx.clearRect(0, 0, width, height);

  const backdrop = ctx.createLinearGradient(0, 0, 0, height);
  backdrop.addColorStop(0, '#111a21');
  backdrop.addColorStop(1, '#2a353b');
  ctx.fillStyle = backdrop;
  ctx.fillRect(0, 0, width, height);

  const shake = engine.shake;
  const sx = Math.sin(options.now * 0.09) * shake;
  const sy = Math.cos(options.now * 0.11) * shake;

  ctx.save();
  ctx.translate(v.ox + sx, v.oy + sy);
  ctx.scale(v.scale, v.scale);
  ctx.beginPath();
  ctx.rect(0, 0, WORLD_WIDTH, WORLD_HEIGHT);
  ctx.clip();

  drawSky(ctx, options);
  drawSkyline(ctx);
  drawStreet(ctx);
  drawTargets(ctx, engine);
  drawDust(ctx, engine, options);
  drawBlasts(ctx, engine, options);
  if (options.mode === 'frozen' && options.ghost) {
    drawGhost(ctx, options.selected, options.ghost.x, options.ghost.y, options.angle);
  }
  drawBodies(ctx, engine, options);
  drawDevices(ctx, engine.devices, true);
  drawMarks(ctx, engine.marks);
  drawFrozenOverlay(ctx, options);

  ctx.restore();
}

function drawSky(ctx: CanvasRenderingContext2D, options: RenderOptions): void {
  const gradient = ctx.createLinearGradient(0, 0, 0, GROUND_Y);
  gradient.addColorStop(0, COLORS.skyA);
  gradient.addColorStop(0.62, COLORS.skyB);
  gradient.addColorStop(1, COLORS.skyC);
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, WORLD_WIDTH, GROUND_Y);

  ctx.fillStyle = 'rgba(240, 231, 206, 0.72)';
  ctx.beginPath();
  ctx.arc(812, 78, 26, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = 'rgba(240, 231, 206, 0.08)';
  ctx.beginPath();
  ctx.arc(812, 78, 58, 0, Math.PI * 2);
  ctx.fill();

  ctx.strokeStyle = 'rgba(140, 230, 232, 0.16)';
  ctx.lineWidth = 1;
  for (let i = 0; i < 9; i += 1) {
    const y = 60 + i * 38;
    ctx.beginPath();
    ctx.moveTo(0, y);
    ctx.lineTo(WORLD_WIDTH, y - 24);
    ctx.stroke();
  }

  if (options.mode === 'frozen' || options.mode === 'report') {
    ctx.fillStyle = 'rgba(140, 230, 232, 0.05)';
    ctx.fillRect(0, 0, WORLD_WIDTH, WORLD_HEIGHT);
  }
}

function hash(n: number): number {
  const value = Math.sin(n * 127.1 + 311.7) * 43758.5453;
  return value - Math.floor(value);
}

function drawSkyline(ctx: CanvasRenderingContext2D): void {
  ctx.fillStyle = COLORS.far;
  for (let i = 0; i < 16; i += 1) {
    const width = 42 + hash(i + 3) * 40;
    const x = i * 68 - 22;
    const height = 110 + hash(i + 8) * 170;
    ctx.fillRect(x, GROUND_Y - height, width, height);
    ctx.fillStyle = 'rgba(214, 180, 109, 0.16)';
    for (let row = 0; row < 3; row += 1) {
      for (let col = 0; col < 2; col += 1) {
        if (hash(i * 17 + row * 3 + col) > 0.45) {
          ctx.fillRect(x + 8 + col * 18, GROUND_Y - height + 22 + row * 28, 7, 10);
        }
      }
    }
    ctx.fillStyle = COLORS.far;
  }

  ctx.fillStyle = COLORS.mid;
  ctx.fillRect(0, 292, 154, 228);
  ctx.fillRect(806, 250, 154, 270);

  ctx.fillStyle = 'rgba(214, 180, 109, 0.28)';
  for (let row = 0; row < 5; row += 1) {
    for (let col = 0; col < 3; col += 1) {
      if (hash(row * 7 + col + 40) > 0.3) {
        ctx.fillRect(20 + col * 42, 326 + row * 38, 18, 20);
      }
      if (hash(row * 9 + col + 70) > 0.35) {
        ctx.fillRect(830 + col * 42, 286 + row * 38, 18, 20);
      }
    }
  }

  ctx.strokeStyle = 'rgba(140, 230, 232, 0.18)';
  ctx.lineWidth = 2;
  ctx.strokeRect(0.5, 292, 154, 228);
  ctx.strokeRect(806, 250, 154, 270);

  ctx.fillStyle = 'rgba(20, 25, 28, 0.65)';
  ctx.fillRect(28, 282, 108, 12);
  ctx.fillStyle = COLORS.cream;
  ctx.font = '700 13px ui-monospace, monospace';
  ctx.textAlign = 'center';
  ctx.fillText('MUNICIPAL WORKS', 82, 292);
  ctx.fillStyle = COLORS.hazard;
  ctx.fillRect(0, 514, 154, 6);
  ctx.fillRect(806, 514, 154, 6);
}

function drawStreet(ctx: CanvasRenderingContext2D): void {
  ctx.fillStyle = COLORS.street;
  ctx.fillRect(0, GROUND_Y, WORLD_WIDTH, WORLD_HEIGHT - GROUND_Y);
  ctx.fillStyle = COLORS.sidewalk;
  ctx.fillRect(0, GROUND_Y, WORLD_WIDTH, 10);
  ctx.fillStyle = COLORS.hazard;
  ctx.fillRect(0, GROUND_Y + 9, WORLD_WIDTH, 2);
  ctx.strokeStyle = 'rgba(240, 231, 206, 0.16)';
  ctx.lineWidth = 2;
  ctx.setLineDash([22, 18]);
  ctx.beginPath();
  ctx.moveTo(0, GROUND_Y + 58);
  ctx.lineTo(WORLD_WIDTH, GROUND_Y + 58);
  ctx.stroke();
  ctx.setLineDash([]);

  ctx.fillStyle = 'rgba(14, 20, 23, 0.45)';
  for (let i = 0; i < 9; i += 1) {
    ctx.fillRect(30 + i * 110, GROUND_Y + 12, 2, 4);
  }

  drawLamp(ctx, 214);
  drawLamp(ctx, 744);

  ctx.fillStyle = 'rgba(20, 25, 28, 0.6)';
  ctx.fillRect(424, GROUND_Y + 26, 112, 42);
  ctx.fillStyle = COLORS.cream;
  ctx.font = '700 11px ui-monospace, monospace';
  ctx.textAlign = 'center';
  ctx.fillText('FROZEN ASSETS', 480, GROUND_Y + 44);
  ctx.fillStyle = COLORS.hazard;
  ctx.fillText('DIVISION', 480, GROUND_Y + 58);
}

function drawLamp(ctx: CanvasRenderingContext2D, x: number): void {
  ctx.strokeStyle = 'rgba(12, 18, 21, 0.8)';
  ctx.lineWidth = 5;
  ctx.beginPath();
  ctx.moveTo(x, GROUND_Y);
  ctx.lineTo(x, GROUND_Y - 118);
  ctx.quadraticCurveTo(x, GROUND_Y - 132, x + 22, GROUND_Y - 132);
  ctx.stroke();
  ctx.fillStyle = 'rgba(242, 181, 47, 0.14)';
  ctx.beginPath();
  ctx.arc(x + 22, GROUND_Y - 126, 26, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = COLORS.hazard;
  ctx.beginPath();
  ctx.arc(x + 22, GROUND_Y - 126, 5, 0, Math.PI * 2);
  ctx.fill();
}

function drawTargets(ctx: CanvasRenderingContext2D, engine: IncidentEngine): void {
  for (const mattress of engine.level.mattresses) {
    const w = mattress.x2 - mattress.x1;
    ctx.fillStyle = 'rgba(10, 14, 16, 0.35)';
    ctx.beginPath();
    ctx.ellipse((mattress.x1 + mattress.x2) / 2, GROUND_Y + 4, w / 2 + 6, 8, 0, 0, Math.PI * 2);
    ctx.fill();

    roundedRect(ctx, mattress.x1, GROUND_Y - 26, w, 26, 8);
    ctx.fillStyle = COLORS.mattress;
    ctx.fill();
    ctx.strokeStyle = COLORS.mattressEdge;
    ctx.lineWidth = 2;
    ctx.stroke();
    ctx.strokeStyle = 'rgba(183, 168, 131, 0.7)';
    ctx.lineWidth = 1;
    for (let x = mattress.x1 + 12; x < mattress.x2 - 6; x += 18) {
      ctx.beginPath();
      ctx.moveTo(x, GROUND_Y - 24);
      ctx.lineTo(x - 5, GROUND_Y - 2);
      ctx.stroke();
    }
    ctx.fillStyle = 'rgba(20, 25, 28, 0.72)';
    roundedRect(ctx, mattress.x1 + 4, GROUND_Y - 44, w - 8, 16, 4);
    ctx.fill();
    ctx.fillStyle = COLORS.cream;
    ctx.font = '700 10px ui-monospace, monospace';
    ctx.textAlign = 'center';
    ctx.fillText('SOFT LANDING', (mattress.x1 + mattress.x2) / 2, GROUND_Y - 32);

    for (const body of engine.bodies) {
      if (body.outcome === 'saved' && body.x >= mattress.x1 && body.x <= mattress.x2) {
        drawPedestrian(ctx, body, true);
      }
    }
  }

  for (const bin of engine.level.bins) {
    const w = bin.x2 - bin.x1;
    const color = COLORS[bin.material];
    ctx.fillStyle = 'rgba(10, 14, 16, 0.4)';
    ctx.beginPath();
    ctx.ellipse((bin.x1 + bin.x2) / 2, GROUND_Y + 4, w / 2 + 5, 7, 0, 0, Math.PI * 2);
    ctx.fill();

    ctx.fillStyle = color;
    roundedRect(ctx, bin.x1, GROUND_Y - 62, w, 62, 6);
    ctx.fill();
    ctx.fillStyle = 'rgba(14, 19, 22, 0.3)';
    roundedRect(ctx, bin.x1 + 5, GROUND_Y - 56, w - 10, 20, 3);
    ctx.fill();
    ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
    ctx.fillRect(bin.x1 + 8, GROUND_Y - 58, w - 16, 3);

    ctx.fillStyle = 'rgba(20, 25, 28, 0.82)';
    roundedRect(ctx, bin.x1 + 8, GROUND_Y - 31, w - 16, 22, 3);
    ctx.fill();
    ctx.fillStyle = COLORS.cream;
    ctx.font = '700 10px ui-monospace, monospace';
    ctx.textAlign = 'center';
    ctx.fillText(materialLabel(bin.material), (bin.x1 + bin.x2) / 2, GROUND_Y - 16);

    const sorted = engine.bodies.filter(
      (body) => body.outcome === 'sorted' && body.binId === bin.id,
    ).length;
    if (sorted > 0) {
      ctx.fillStyle = COLORS.hazard;
      for (let i = 0; i < sorted; i += 1) {
        ctx.fillRect(bin.x1 + 12 + i * 9, GROUND_Y - 66, 6, 4);
      }
    }
  }
}

function materialLabel(material: Material): string {
  if (material === 'masonry') return 'MASONRY';
  if (material === 'furniture') return 'FURNITURE';
  return 'GLASS';
}

function drawDust(
  ctx: CanvasRenderingContext2D,
  engine: IncidentEngine,
  options: RenderOptions,
): void {
  const source = engine.level.source;
  const count = options.mode === 'frozen' ? 30 : 14;
  for (let i = 0; i < count; i += 1) {
    const angle = hash(i + 1) * Math.PI * 2;
    const distance = 22 + hash(i + 31) * (options.mode === 'frozen' ? 235 : 120);
    const drift = options.mode === 'frozen' ? 0 : ((options.now * 0.03 + i * 13) % 90) - 45;
    const x = source.x + Math.cos(angle) * distance + drift;
    const y = source.y + Math.sin(angle) * distance * 0.7;
    const size = 1.5 + hash(i + 71) * 3;
    ctx.fillStyle =
      options.mode === 'frozen' ? 'rgba(140, 230, 232, 0.34)' : 'rgba(240, 181, 106, 0.18)';
    ctx.beginPath();
    ctx.arc(x, y, size, 0, Math.PI * 2);
    ctx.fill();
  }
}

function drawBlasts(
  ctx: CanvasRenderingContext2D,
  engine: IncidentEngine,
  options: RenderOptions,
): void {
  for (const [index, blast] of engine.blasts.entries()) {
    if (options.mode !== 'running' && index === 0) {
      drawBloom(ctx, blast.x, blast.y, 84 + Math.sin(options.now * 0.004) * 5);
      ctx.fillStyle = COLORS.hazard;
      ctx.font = '700 11px ui-monospace, monospace';
      ctx.textAlign = 'center';
      ctx.fillText('BLAST ORIGIN', blast.x, blast.y + 112);
      continue;
    }
    if (blast.age > 1.15) continue;
    const progress = blast.age / 1.15;
    const radius = 18 + progress * 235;
    const alpha = Math.max(0, 0.55 * (1 - progress));
    ctx.strokeStyle = `rgba(242, 181, 47, ${alpha})`;
    ctx.lineWidth = 7 * (1 - progress) + 1;
    ctx.beginPath();
    ctx.arc(blast.x, blast.y, radius, 0, Math.PI * 2);
    ctx.stroke();
    ctx.strokeStyle = `rgba(207, 109, 68, ${alpha * 0.8})`;
    ctx.lineWidth = 2;
    ctx.beginPath();
    ctx.arc(blast.x, blast.y, radius * 0.68, 0, Math.PI * 2);
    ctx.stroke();
    drawBloom(ctx, blast.x, blast.y, 58 * (1 - progress) + 18);
  }
}

function drawBloom(ctx: CanvasRenderingContext2D, x: number, y: number, radius: number): void {
  const gradient = ctx.createRadialGradient(x, y, 2, x, y, radius);
  gradient.addColorStop(0, 'rgba(255, 241, 197, 0.95)');
  gradient.addColorStop(0.35, 'rgba(242, 181, 47, 0.72)');
  gradient.addColorStop(0.72, 'rgba(216, 78, 53, 0.35)');
  gradient.addColorStop(1, 'rgba(216, 78, 53, 0)');
  ctx.fillStyle = gradient;
  ctx.beginPath();
  ctx.arc(x, y, radius, 0, Math.PI * 2);
  ctx.fill();

  ctx.strokeStyle = 'rgba(255, 241, 197, 0.5)';
  ctx.lineWidth = 2;
  for (let i = 0; i < 10; i += 1) {
    const angle = (i / 10) * Math.PI * 2;
    ctx.beginPath();
    ctx.moveTo(x + Math.cos(angle) * radius * 0.55, y + Math.sin(angle) * radius * 0.55);
    ctx.lineTo(x + Math.cos(angle) * radius * (1 + hash(i) * 0.25), y + Math.sin(angle) * radius);
    ctx.stroke();
  }
}

function drawBodies(
  ctx: CanvasRenderingContext2D,
  engine: IncidentEngine,
  options: RenderOptions,
): void {
  if (options.mode === 'frozen' && options.showHints) {
    for (const body of engine.bodies) {
      if (!body.active) continue;
      drawProjection(ctx, body);
    }
  }

  for (const body of engine.bodies) {
    if (!body.active) continue;
    const speed = Math.hypot(body.vx, body.vy);
    if (options.mode === 'frozen' && speed > 40) {
      const length = Math.min(70, speed * 0.1);
      const nx = body.vx / speed;
      const ny = body.vy / speed;
      const gradient = ctx.createLinearGradient(
        body.x - nx * length,
        body.y - ny * length,
        body.x,
        body.y,
      );
      gradient.addColorStop(0, 'rgba(140, 230, 232, 0)');
      gradient.addColorStop(1, 'rgba(140, 230, 232, 0.45)');
      ctx.strokeStyle = gradient;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(body.x - nx * length, body.y - ny * length);
      ctx.lineTo(body.x, body.y);
      ctx.stroke();
    }

    if (body.kind === 'pedestrian') {
      drawPedestrian(ctx, body, false);
    } else if (body.kind === 'debris') {
      drawDebris(ctx, body);
    } else {
      drawCanister(ctx, body, options.now);
    }

    if (options.mode === 'frozen' && body.active) {
      ctx.strokeStyle = 'rgba(140, 230, 232, 0.55)';
      ctx.lineWidth = 1.2;
      ctx.beginPath();
      ctx.arc(body.x, body.y, body.radius + 5, 0, Math.PI * 2);
      ctx.stroke();
    }
  }

  for (const body of engine.bodies) {
    if (body.active) continue;
    if (body.outcome === 'exploded' || body.outcome === 'gone' || body.outcome === 'sorted' || body.outcome === 'contaminated') {
      continue;
    }
    if (body.kind === 'pedestrian' && body.outcome === 'at-large') {
      drawPedestrian(ctx, body, true);
    }
  }
}

function drawProjection(ctx: CanvasRenderingContext2D, body: Body): void {
  const points = projectBody(body, 1.35);
  if (points.length === 0) return;
  ctx.fillStyle =
    body.kind === 'pedestrian'
      ? 'rgba(242, 181, 47, 0.55)'
      : body.kind === 'volatile'
        ? 'rgba(216, 78, 53, 0.65)'
        : 'rgba(140, 230, 232, 0.45)';
  for (let i = 2; i < points.length; i += 3) {
    const point = points[i];
    ctx.beginPath();
    ctx.arc(point.x, point.y, 1.6, 0, Math.PI * 2);
    ctx.fill();
  }
  const last = points[points.length - 1];
  ctx.strokeStyle = 'rgba(140, 230, 232, 0.5)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(last.x - 4, last.y);
  ctx.lineTo(last.x + 4, last.y);
  ctx.stroke();
}

function drawPedestrian(
  ctx: CanvasRenderingContext2D,
  body: Body,
  settled: boolean,
): void {
  const coat = [COLORS.coatA, COLORS.coatB, COLORS.coatC][body.id % 3];
  ctx.save();
  ctx.translate(body.x, body.y);
  if (!settled) ctx.rotate(body.angle);
  ctx.lineCap = 'round';

  ctx.strokeStyle = COLORS.ink;
  ctx.lineWidth = 3;
  ctx.beginPath();
  ctx.moveTo(-2, 2);
  ctx.lineTo(-7, 13);
  ctx.moveTo(2, 2);
  ctx.lineTo(7, 12);
  ctx.stroke();

  ctx.fillStyle = coat;
  roundedRect(ctx, -7, -5, 14, 18, 5);
  ctx.fill();
  ctx.fillStyle = COLORS.hazard;
  ctx.fillRect(-6, 2, 12, 3);

  ctx.strokeStyle = COLORS.ped;
  ctx.lineWidth = 2.5;
  ctx.beginPath();
  ctx.moveTo(-6, 0);
  ctx.lineTo(-12, settled ? 8 : -5);
  ctx.moveTo(6, 0);
  ctx.lineTo(12, settled ? 8 : -7);
  ctx.stroke();

  ctx.fillStyle = COLORS.ped;
  ctx.beginPath();
  ctx.arc(0, -10, 6.5, 0, Math.PI * 2);
  ctx.fill();
  ctx.fillStyle = COLORS.ink;
  ctx.beginPath();
  ctx.arc(-2, -11, 1, 0, Math.PI * 2);
  ctx.arc(2, -11, 1, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

function drawDebris(ctx: CanvasRenderingContext2D, body: Body): void {
  ctx.save();
  ctx.translate(body.x, body.y);
  ctx.rotate(body.angle);
  if (body.material === 'masonry') {
    ctx.fillStyle = 'rgba(10, 14, 16, 0.28)';
    roundedRect(ctx, -10, -6, 20, 14, 2);
    ctx.fill();
    ctx.fillStyle = COLORS.masonry;
    roundedRect(ctx, -11, -8, 20, 13, 2);
    ctx.fill();
    ctx.strokeStyle = 'rgba(20, 25, 28, 0.5)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(-4, -8);
    ctx.lineTo(-4, 5);
    ctx.moveTo(5, -8);
    ctx.lineTo(5, 5);
    ctx.stroke();
    ctx.fillStyle = COLORS.cream;
    ctx.fillRect(-11, -7, 20, 2);
  } else if (body.material === 'furniture') {
    ctx.fillStyle = COLORS.furniture;
    roundedRect(ctx, -10, -7, 20, 7, 2);
    ctx.fill();
    roundedRect(ctx, -10, -17, 6, 20, 2);
    ctx.fill();
    ctx.fillStyle = 'rgba(20, 25, 28, 0.55)';
    ctx.fillRect(-8, 0, 3, 8);
    ctx.fillRect(6, 0, 3, 8);
    ctx.fillStyle = 'rgba(255, 255, 255, 0.35)';
    ctx.fillRect(-8, -14, 3, 6);
  } else {
    ctx.fillStyle = COLORS.glass;
    roundedRect(ctx, -5, -12, 10, 20, 4);
    ctx.fill();
    ctx.fillRect(-2, -16, 4, 5);
    ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
    roundedRect(ctx, -2.5, -9, 2, 12, 1);
    ctx.fill();
  }
  ctx.restore();
}

function drawCanister(
  ctx: CanvasRenderingContext2D,
  body: Body,
  now: number,
): void {
  ctx.save();
  ctx.translate(body.x, body.y);
  ctx.rotate(body.angle);
  const pulse = body.fuse !== null ? 0.65 + Math.sin(now * 0.05) * 0.35 : 1;
  ctx.fillStyle = `rgba(216, 78, 53, ${pulse})`;
  roundedRect(ctx, -9, -12, 18, 24, 7);
  ctx.fill();
  ctx.strokeStyle = COLORS.ink;
  ctx.lineWidth = 2;
  ctx.stroke();
  ctx.fillStyle = COLORS.hazard;
  ctx.fillRect(-9, -3, 18, 6);
  ctx.fillStyle = COLORS.cream;
  ctx.font = '700 7px ui-monospace, monospace';
  ctx.textAlign = 'center';
  ctx.fillText('GAS', 0, 2);
  ctx.fillStyle = COLORS.ink;
  ctx.fillRect(-3, -16, 6, 4);
  ctx.restore();
}

function drawDevices(
  ctx: CanvasRenderingContext2D,
  devices: Device[],
  glow: boolean,
): void {
  for (const device of devices) {
    const endpoints = deviceEndpoints(device);
    ctx.save();
    if (glow) {
      ctx.shadowColor = 'rgba(140, 230, 232, 0.45)';
      ctx.shadowBlur = 8;
    }
    if (device.type === 'trampoline') {
      ctx.strokeStyle = COLORS.freeze;
      ctx.lineWidth = 5;
      ctx.beginPath();
      ctx.moveTo(endpoints.x1, endpoints.y1);
      ctx.lineTo(endpoints.x2, endpoints.y2);
      ctx.stroke();
      ctx.strokeStyle = 'rgba(20, 25, 28, 0.85)';
      ctx.lineWidth = 1;
      const segments = 8;
      for (let i = 1; i < segments; i += 1) {
        const t = i / segments;
        const x = endpoints.x1 + (endpoints.x2 - endpoints.x1) * t;
        const y = endpoints.y1 + (endpoints.y2 - endpoints.y1) * t;
        ctx.beginPath();
        ctx.moveTo(x - 1, y - 4);
        ctx.lineTo(x + 1, y + 4);
        ctx.stroke();
      }
      ctx.fillStyle = COLORS.hazard;
      for (const end of [endpoints, { x1: endpoints.x2, y1: endpoints.y2, x2: endpoints.x1, y2: endpoints.y1 }]) {
        ctx.beginPath();
        ctx.arc(end.x1, end.y1, 4, 0, Math.PI * 2);
        ctx.fill();
      }
    } else if (device.type === 'funnel') {
      ctx.strokeStyle = COLORS.funnel;
      ctx.lineWidth = 12;
      ctx.beginPath();
      ctx.moveTo(endpoints.x1, endpoints.y1);
      ctx.lineTo(endpoints.x2, endpoints.y2);
      ctx.stroke();
      ctx.strokeStyle = 'rgba(240, 231, 206, 0.7)';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(endpoints.x1, endpoints.y1);
      ctx.lineTo(endpoints.x2, endpoints.y2);
      ctx.stroke();
      ctx.fillStyle = COLORS.ink;
      for (let i = 1; i <= 3; i += 1) {
        const t = i / 4;
        const x = endpoints.x1 + (endpoints.x2 - endpoints.x1) * t;
        const y = endpoints.y1 + (endpoints.y2 - endpoints.y1) * t;
        ctx.beginPath();
        ctx.arc(x, y, 2, 0, Math.PI * 2);
        ctx.fill();
      }
    } else {
      ctx.strokeStyle = COLORS.cushion;
      ctx.lineWidth = 13;
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.moveTo(endpoints.x1, endpoints.y1);
      ctx.lineTo(endpoints.x2, endpoints.y2);
      ctx.stroke();
      ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(endpoints.x1 + 3, endpoints.y1 - 3);
      ctx.lineTo(endpoints.x2 - 3, endpoints.y2 - 3);
      ctx.stroke();
      ctx.fillStyle = COLORS.cushion;
      for (let i = 0; i < 3; i += 1) {
        const t = (i + 1) / 4;
        const x = endpoints.x1 + (endpoints.x2 - endpoints.x1) * t;
        const y = endpoints.y1 + (endpoints.y2 - endpoints.y1) * t;
        ctx.beginPath();
        ctx.arc(x, y + 9 + i, 2.2, 0, Math.PI * 2);
        ctx.fill();
      }
    }
    ctx.restore();
  }
}

function drawGhost(
  ctx: CanvasRenderingContext2D,
  type: DeviceType,
  x: number,
  y: number,
  angle: number,
): void {
  const length = type === 'trampoline' ? 112 : type === 'funnel' ? 132 : 92;
  const half = length / 2;
  const cos = Math.cos(angle);
  const sin = Math.sin(angle);
  ctx.save();
  ctx.globalAlpha = 0.65;
  ctx.setLineDash([8, 6]);
  ctx.strokeStyle =
    type === 'trampoline' ? COLORS.freeze : type === 'funnel' ? COLORS.funnel : COLORS.cushion;
  ctx.lineWidth = type === 'cushion' ? 14 : 7;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo(x - cos * half, y - sin * half);
  ctx.lineTo(x + cos * half, y + sin * half);
  ctx.stroke();
  ctx.setLineDash([]);
  ctx.globalAlpha = 0.9;
  ctx.fillStyle = COLORS.cream;
  ctx.beginPath();
  ctx.arc(x, y, 3, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();
}

function drawMarks(ctx: CanvasRenderingContext2D, marks: Mark[]): void {
  for (const mark of marks) {
    if (mark.age > 1.7) continue;
    const alpha = Math.min(1, Math.max(0, 1 - mark.age / 1.7));
    const y = mark.y - mark.age * 20;
    ctx.font = '700 12px ui-monospace, monospace';
    ctx.textAlign = 'center';
    const width = ctx.measureText(mark.text).width + 12;
    ctx.fillStyle = `rgba(20, 25, 28, ${alpha * 0.8})`;
    roundedRect(ctx, mark.x - width / 2, y - 12, width, 17, 3);
    ctx.fill();
    ctx.fillStyle =
      mark.tone === 'good'
        ? `rgba(140, 230, 232, ${alpha})`
        : mark.tone === 'bad'
          ? `rgba(240, 132, 109, ${alpha})`
          : `rgba(242, 181, 47, ${alpha})`;
    ctx.fillText(mark.text, mark.x, y);
  }
}

function drawFrozenOverlay(ctx: CanvasRenderingContext2D, options: RenderOptions): void {
  if (options.mode !== 'frozen') return;
  ctx.strokeStyle = 'rgba(140, 230, 232, 0.09)';
  ctx.lineWidth = 1;
  for (let i = -WORLD_HEIGHT; i < WORLD_WIDTH; i += 26) {
    ctx.beginPath();
    ctx.moveTo(i, 0);
    ctx.lineTo(i + WORLD_HEIGHT, WORLD_HEIGHT);
    ctx.stroke();
  }
  ctx.strokeStyle = 'rgba(140, 230, 232, 0.32)';
  ctx.lineWidth = 2;
  ctx.strokeRect(1, 1, WORLD_WIDTH - 2, WORLD_HEIGHT - 2);
  ctx.fillStyle = 'rgba(140, 230, 232, 0.8)';
  ctx.font = '700 11px ui-monospace, monospace';
  ctx.textAlign = 'left';
  ctx.fillText('FRAME HELD — PHYSICS SUSPENDED', 20, 26);
  ctx.textAlign = 'right';
  ctx.fillText('PLACE KIT / RESUME TIME', WORLD_WIDTH - 20, 26);
}

function roundedRect(
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  width: number,
  height: number,
  radius: number,
): void {
  const r = Math.min(radius, Math.abs(width) / 2, Math.abs(height) / 2);
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.arcTo(x + width, y, x + width, y + height, r);
  ctx.arcTo(x + width, y + height, x, y + height, r);
  ctx.arcTo(x, y + height, x, y, r);
  ctx.arcTo(x, y, x + width, y, r);
  ctx.closePath();
}