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 {
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();
}
