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 './napplet-settings.js';
import { storage } from '@napplet/sdk';
import { runtimeHasDomain } from './domain-availability.js';
import { DEVICE_LABEL, IncidentEngine, type Stats } from './physics.js';
import { GROUND_Y, LEVELS, WORLD_WIDTH, type DeviceType } from './levels.js';
import { renderScene, worldFromClient, type SceneMode } from './render.js';
import './styles.css';
const RECORD_KEY = 'ffr-service-record-v1';
const PAD = (value: number): string => String(value).padStart(2, '0');
interface Point {
x: number;
y: number;
}
interface DragState {
x: number;
y: number;
currentX: number;
currentY: number;
moved: boolean;
}
interface SavedRecord {
damage: number;
perfect: boolean;
}
const elements = {
caseNo: requireElement<HTMLElement>('#caseNo'),
caseName: requireElement<HTMLElement>('#caseName'),
pedStat: requireElement<HTMLElement>('#pedStat'),
debrisStat: requireElement<HTMLElement>('#debrisStat'),
kitStat: requireElement<HTMLElement>('#kitStat'),
canvas: requireElement<HTMLCanvasElement>('#scene'),
freezeBadge: requireElement<HTMLElement>('#freezeBadge'),
freezeLabel: requireElement<HTMLElement>('#freezeLabel'),
freezeClock: requireElement<HTMLElement>('#freezeClock'),
toast: requireElement<HTMLElement>('#toast'),
overlay: requireElement<HTMLElement>('#overlay'),
briefButton: requireElement<HTMLButtonElement>('#briefButton'),
retryButton: requireElement<HTMLButtonElement>('#retryButton'),
tiltButton: requireElement<HTMLButtonElement>('#tiltButton'),
tiltValue: requireElement<HTMLElement>('#tiltValue'),
undoButton: requireElement<HTMLButtonElement>('#undoButton'),
resumeButton: requireElement<HTMLButtonElement>('#resumeButton'),
live: requireElement<HTMLElement>('#live'),
};
function getContext2d(): CanvasRenderingContext2D {
const ctx = elements.canvas.getContext('2d');
if (!ctx) throw new Error('Canvas is unavailable');
return ctx;
}
const context = getContext2d();
let engine: IncidentEngine;
let levelIndex = 0;
let phase: SceneMode = 'frozen';
let selected: DeviceType = 'trampoline';
let angle = 0;
let hover: Point | null = null;
let drag: DragState | null = null;
let keyboardMode = false;
let keyCursor: Point = { x: 480, y: 380 };
let overlayOpen = false;
let reportShown = false;
let pixelRatio = 1;
let lastFrame = performance.now();
let lastHud = 0;
let toastTimer = 0;
let records: Record<string, SavedRecord> = {};
let hasPlayed = false;
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
function requireElement<T extends HTMLElement>(selector: string): T {
const element = document.querySelector<T>(selector);
if (!element) throw new Error(`Missing required element: ${selector}`);
return element;
}
function createEngine(index: number): IncidentEngine {
const next = new IncidentEngine(LEVELS[index]);
next.preSim();
return next;
}
function remaining(type: DeviceType): number {
return Math.max(0, LEVELS[levelIndex].kit[type] - engine.countDevices(type));
}
function firstAvailable(): DeviceType {
const order: DeviceType[] = ['trampoline', 'funnel', 'cushion'];
return order.find((type) => remaining(type) > 0) ?? 'trampoline';
}
function snap(value: number): number {
const step = Math.PI / 12;
return Math.round(value / step) * step;
}
function degrees(): string {
const value = Math.round((angle * 180) / Math.PI);
const normal = ((value % 360) + 540) % 360 - 180;
return `${normal}°`;
}
function insideWorld(point: Point): boolean {
return (
point.x > 4 &&
point.x < WORLD_WIDTH - 4 &&
point.y > 8 &&
point.y < GROUND_Y - 4
);
}
function toast(message: string): void {
elements.toast.textContent = message;
elements.toast.classList.add('show');
elements.live.textContent = message;
window.clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => elements.toast.classList.remove('show'), 2400);
}
function syncTools(): void {
const order: DeviceType[] = ['trampoline', 'funnel', 'cushion'];
for (const type of order) {
const button = requireElement<HTMLButtonElement>(`.tool[data-tool="${type}"]`);
const count = requireElement<HTMLElement>(`[data-count="${type}"]`);
const left = remaining(type);
count.textContent = String(left);
button.disabled = phase !== 'frozen' || left <= 0;
button.setAttribute('aria-pressed', String(type === selected));
button.classList.toggle('selected', type === selected);
}
const canEdit = phase === 'frozen';
elements.tiltButton.disabled = !canEdit;
elements.undoButton.disabled = !canEdit || engine.devices.length === 0;
elements.resumeButton.disabled = phase !== 'frozen';
elements.tiltValue.textContent = degrees();
elements.canvas.classList.toggle('placing', canEdit && remaining(selected) > 0);
}
function updateHud(): void {
const stats = engine.getStats();
const level = LEVELS[levelIndex];
elements.caseNo.textContent = `CASE ${PAD(levelIndex + 1)} / ${PAD(LEVELS.length)}`;
elements.caseName.textContent = level.name;
elements.pedStat.textContent = `${stats.pedsSaved}/${stats.pedsTotal}`;
elements.debrisStat.textContent = `${stats.debrisSorted}/${stats.debrisTotal}`;
const kit = remaining('trampoline') + remaining('funnel') + remaining('cushion');
elements.kitStat.textContent = String(kit);
}
function syncPhase(): void {
if (phase === 'frozen') {
elements.freezeBadge.className = 'freeze-badge frozen';
elements.freezeLabel.textContent = 'TIME SUSPENDED';
elements.freezeBadge.hidden = false;
} else if (phase === 'running') {
elements.freezeBadge.className = 'freeze-badge running';
elements.freezeLabel.textContent = 'TIME RUNNING';
elements.freezeBadge.hidden = false;
} else {
elements.freezeBadge.className = 'freeze-badge settled';
elements.freezeLabel.textContent = 'INCIDENT SETTLED';
elements.freezeBadge.hidden = false;
}
syncTools();
updateHud();
}
function selectTool(type: DeviceType, announce = false): void {
if (remaining(type) <= 0) {
toast(`${DEVICE_LABEL[type]} stock is empty. Undo a placement or try another item.`);
return;
}
selected = type;
if (announce) toast(`${DEVICE_LABEL[type]} selected. ${remaining(type)} in the van.`);
syncTools();
}
function placeDevice(type: DeviceType, x: number, y: number): boolean {
if (phase !== 'frozen') {
toast('Time is already moving. Retry the case to re-plan.');
return false;
}
if (remaining(type) <= 0) {
toast(`${DEVICE_LABEL[type]} stock is empty.`);
return false;
}
if (!insideWorld({ x, y })) {
toast('Outside the frozen frame. The paperwork does not cover that.');
return false;
}
engine.placeDevice(type, x, y, angle);
const left = remaining(type);
toast(`${DEVICE_LABEL[type]} placed at ${degrees()}. ${left} left.`);
if (left <= 0) selected = firstAvailable();
syncTools();
updateHud();
return true;
}
function tilt(): void {
if (phase !== 'frozen') return;
angle = snap(angle + Math.PI / 12);
elements.tiltValue.textContent = degrees();
elements.live.textContent = `Placement tilt ${degrees()}.`;
}
function undo(): void {
if (phase !== 'frozen') return;
const removed = engine.removeLastDevice();
if (!removed) {
toast('Nothing to recover. The van is still full.');
return;
}
selected = removed.type;
toast(`${DEVICE_LABEL[removed.type]} recovered.`);
syncTools();
updateHud();
}
function resume(): void {
if (phase !== 'frozen') return;
drag = null;
hover = null;
phase = 'running';
hasPlayed = true;
engine.beginLive();
syncPhase();
toast('Time resumed. Please do not cheer.');
}
function startLevel(index: number, withBrief = false): void {
levelIndex = ((index % LEVELS.length) + LEVELS.length) % LEVELS.length;
engine = createEngine(levelIndex);
phase = 'frozen';
reportShown = false;
drag = null;
hover = null;
keyboardMode = false;
keyCursor = { x: 480, y: 380 };
angle = 0;
selected = firstAvailable();
closeOverlay();
syncPhase();
toast(`Case ${PAD(levelIndex + 1)}: ${LEVELS[levelIndex].name}. Time is suspended.`);
if (withBrief) showBrief();
}
function briefHtml(): string {
const level = LEVELS[levelIndex];
const closed = Object.values(records).filter((record) => record.perfect).length;
const recordText =
closed > 0
? `Service record: ${closed} of ${LEVELS.length} cases closed.`
: 'No closed cases on file. The shredder is ready.';
return `
<article class="card brief-card">
<p class="card-kicker">FROZEN ASSETS DIVISION · CASE ${PAD(levelIndex + 1)} / ${PAD(LEVELS.length)}</p>
<h2>${level.name}</h2>
<p class="tagline">${level.tagline}</p>
<p class="brief-copy">${level.briefing}</p>
<div class="legend">
<span><i class="swatch mattress"></i>Mattress · pedestrians</span>
<span><i class="swatch masonry"></i>Masonry bin</span>
<span><i class="swatch furniture"></i>Furniture bin</span>
<span><i class="swatch glass"></i>Glass bin</span>
</div>
<div class="howto"><b>Frozen protocol</b> Pick a kit item. Tap the scene to place it, drag from the anchor to aim, or steer the cursor with the arrow keys and press <kbd>Enter</kbd>. <kbd>R</kbd> tilts, <kbd>Z</kbd> undoes, <kbd>Space</kbd> resumes. Dotted lines show where objects are already going.</div>
<p class="hint">${level.hint}</p>
<div class="card-actions">
<span class="service-record">${recordText}</span>
<button type="button" class="primary-btn" data-action="begin">${hasPlayed ? 'Back to the scene' : 'Begin inspection'}</button>
</div>
</article>`;
}
function showBrief(): void {
if (phase === 'running') {
toast('The incident is already in motion. Let the report finish.');
return;
}
if (phase === 'report') return;
elements.overlay.innerHTML = briefHtml();
elements.overlay.hidden = false;
overlayOpen = true;
const begin = elements.overlay.querySelector<HTMLButtonElement>('[data-action="begin"]');
begin?.addEventListener('click', () => {
closeOverlay();
toast('Frame held. Place kit, then resume time.');
});
begin?.focus();
}
function closeOverlay(): void {
elements.overlay.hidden = true;
elements.overlay.innerHTML = '';
overlayOpen = false;
}
function damageEstimate(stats: Stats): number {
const raw =
stats.debrisMissed * 1850 +
stats.debrisContaminated * 3200 +
stats.pedsLost * 14500 +
stats.volatileBlasts * 6800 +
stats.devicesUsed * 120;
return Math.max(250, Math.round(raw / 50) * 50);
}
function reportNotes(stats: Stats, perfect: boolean): string[] {
const notes: string[] = [];
if (stats.pedsLost === 0) {
notes.push('All pedestrians delivered to soft furnishings. Nobody signed the waiver.');
} else {
notes.push(
`${stats.pedsLost} pedestrian(s) remain at large and are listed as "taking a walk".`,
);
}
if (stats.debrisMissed === 0 && stats.debrisContaminated === 0) {
notes.push('Recycling targets met. The bins have been wiped down with pride.');
} else {
if (stats.debrisMissed > 0) {
notes.push(`${stats.debrisMissed} item(s) missed the bins entirely.`);
}
if (stats.debrisContaminated > 0) {
notes.push(`${stats.debrisContaminated} item(s) contaminated a material stream.`);
}
}
if (stats.volatileBlasts > 0) {
notes.push(
`${stats.volatileBlasts} gas canister(s) joined the meeting. Chain-reaction forms are in triplicate.`,
);
} else if (stats.volatiles > 0) {
notes.push(`All ${stats.volatiles} gas canister(s) remained unopinionated.`);
}
if (stats.devicesUsed === 0) {
notes.push('No equipment was deployed. The department respects the confidence.');
}
if (perfect) {
notes.push('Commendation filed directly into the shredder.');
} else if (stats.pedsSaved > 0) {
notes.push('At least one mattress justified its budget.');
}
return notes;
}
function reportHtml(stats: Stats, perfect: boolean, damage: number): string {
const level = LEVELS[levelIndex];
const status = perfect
? 'CASE CLOSED'
: stats.pedsSaved === stats.pedsTotal
? 'ACCEPTABLE LOSS'
: 'REVIEW REQUIRED';
const stampClass = perfect ? 'closed' : stats.pedsSaved === stats.pedsTotal ? 'partial' : 'review';
const notes = reportNotes(stats, perfect)
.map((note) => `<li>${note}</li>`)
.join('');
const money = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(damage);
const nextIsRestart = levelIndex + 1 >= LEVELS.length;
return `
<article class="card report-card">
<p class="card-kicker">INCIDENT REPORT · CASE ${PAD(levelIndex + 1)} / ${PAD(LEVELS.length)}</p>
<div class="report-head">
<h2>${level.name}</h2>
<span class="stamp ${stampClass}">${status}</span>
</div>
<div class="report-grid">
<span>Pedestrians delivered</span><b>${stats.pedsSaved} / ${stats.pedsTotal}</b>
<span>Debris processed</span><b>${stats.debrisSorted} / ${stats.debrisTotal}</b>
<span>Contaminated streams</span><b>${stats.debrisContaminated}</b>
<span>Pedestrians unaccounted</span><b>${stats.pedsLost}</b>
<span>Canister detonations</span><b>${stats.volatileBlasts}</b>
<span>Equipment deployed</span><b>${stats.devicesUsed}</b>
</div>
<div class="damage">
<span>Estimated damage</span>
<strong>${money}</strong>
</div>
<ul class="report-notes">${notes}</ul>
<p id="reportSaveNote" class="save-note">Filing result with the local service record…</p>
<div class="card-actions">
<button type="button" class="small-btn" data-action="retry">Retry case</button>
<button type="button" class="primary-btn" data-action="next">${nextIsRestart ? 'Restart case load' : 'Next case file'}</button>
</div>
</article>`;
}
function finalizeReport(): void {
if (reportShown) return;
reportShown = true;
phase = 'report';
syncPhase();
const stats = engine.getStats();
const perfect =
stats.pedsSaved === stats.pedsTotal &&
stats.debrisSorted === stats.debrisTotal &&
stats.debrisContaminated === 0;
const damage = damageEstimate(stats);
elements.overlay.innerHTML = reportHtml(stats, perfect, damage);
elements.overlay.hidden = false;
overlayOpen = true;
const retry = elements.overlay.querySelector<HTMLButtonElement>('[data-action="retry"]');
const next = elements.overlay.querySelector<HTMLButtonElement>('[data-action="next"]');
retry?.addEventListener('click', () => startLevel(levelIndex));
next?.addEventListener('click', () => startLevel(levelIndex + 1, true));
next?.focus();
void fileResult(levelIndex, damage, perfect).then((note) => {
if (phase !== 'report') return;
const target = elements.overlay.querySelector<HTMLElement>('#reportSaveNote');
if (target) target.textContent = note;
});
}
async function fileResult(index: number, damage: number, perfect: boolean): Promise<string> {
if (!runtimeHasDomain('storage')) {
return 'Local service record unavailable in this runtime. The result exists only in this form.';
}
try {
const raw = await storage.getItem(RECORD_KEY);
let stored: Record<string, SavedRecord> = {};
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object') stored = parsed as Record<string, SavedRecord>;
}
const key = LEVELS[index].id;
const existing = stored[key];
const better = !existing || damage < existing.damage || (perfect && !existing.perfect);
if (better) {
stored[key] = { damage, perfect };
await storage.setItem(RECORD_KEY, JSON.stringify(stored));
records = stored;
return 'Result filed. The service record has been updated.';
}
records = stored;
return 'Previous attempt had lower damage. Kept the original form.';
} catch {
return 'Record could not be filed. The form remains optimistic.';
}
}
async function loadRecords(): Promise<void> {
if (!runtimeHasDomain('storage')) return;
try {
const raw = await storage.getItem(RECORD_KEY);
if (!raw) return;
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
records = parsed as Record<string, SavedRecord>;
}
} catch {
records = {};
}
}
function pointFromEvent(event: PointerEvent): Point | null {
const rect = elements.canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
return worldFromClient(
event.clientX - rect.left,
event.clientY - rect.top,
rect.width,
rect.height,
);
}
elements.canvas.addEventListener('pointerdown', (event) => {
if (phase !== 'frozen' || overlayOpen) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
const point = pointFromEvent(event);
if (!point || !insideWorld(point)) return;
if (remaining(selected) <= 0) {
toast(`No ${DEVICE_LABEL[selected]} left. Undo a placement or choose another item.`);
return;
}
event.preventDefault();
keyboardMode = false;
drag = {
x: point.x,
y: point.y,
currentX: point.x,
currentY: point.y,
moved: false,
};
hover = point;
elements.canvas.setPointerCapture(event.pointerId);
});
elements.canvas.addEventListener('pointermove', (event) => {
const point = pointFromEvent(event);
hover = point && insideWorld(point) ? point : null;
if (point) keyboardMode = false;
if (!drag || !point) return;
drag.currentX = point.x;
drag.currentY = point.y;
const dx = point.x - drag.x;
const dy = point.y - drag.y;
if (Math.hypot(dx, dy) > 16) {
drag.moved = true;
angle = snap(Math.atan2(dy, dx));
elements.tiltValue.textContent = degrees();
}
event.preventDefault();
});
function finishPointer(event: PointerEvent): void {
if (!drag) return;
const point = pointFromEvent(event);
const anchor = drag.moved ? { x: drag.x, y: drag.y } : point;
if (anchor) placeDevice(selected, anchor.x, anchor.y);
drag = null;
elements.canvas.classList.remove('placing');
if (elements.canvas.hasPointerCapture(event.pointerId)) {
elements.canvas.releasePointerCapture(event.pointerId);
}
syncTools();
}
elements.canvas.addEventListener('pointerup', finishPointer);
elements.canvas.addEventListener('pointercancel', () => {
drag = null;
elements.canvas.classList.remove('placing');
});
document.querySelectorAll<HTMLButtonElement>('.tool').forEach((button) => {
button.addEventListener('click', () => {
const type = button.dataset.tool as DeviceType | undefined;
if (!type) return;
selectTool(type, true);
});
});
elements.tiltButton.addEventListener('click', tilt);
elements.undoButton.addEventListener('click', undo);
elements.resumeButton.addEventListener('click', resume);
elements.retryButton.addEventListener('click', () => startLevel(levelIndex));
elements.briefButton.addEventListener('click', showBrief);
window.addEventListener('keydown', (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const key = event.key.toLowerCase();
if (overlayOpen) {
if (key === 'escape' && phase !== 'report') closeOverlay();
return;
}
if (phase === 'report') return;
if (key.startsWith('arrow')) {
event.preventDefault();
keyboardMode = true;
hover = null;
const step = event.shiftKey ? 6 : 20;
if (key === 'arrowleft') keyCursor.x -= step;
else if (key === 'arrowright') keyCursor.x += step;
else if (key === 'arrowup') keyCursor.y -= step;
else if (key === 'arrowdown') keyCursor.y += step;
keyCursor.x = Math.min(WORLD_WIDTH - 10, Math.max(10, keyCursor.x));
keyCursor.y = Math.min(GROUND_Y - 10, Math.max(20, keyCursor.y));
elements.live.textContent = `Placement cursor at ${Math.round(keyCursor.x)}, ${Math.round(keyCursor.y)}.`;
return;
}
if (key === '1') selectTool('trampoline');
else if (key === '2') selectTool('funnel');
else if (key === '3') selectTool('cushion');
else if (key === 'r') tilt();
else if (key === 'z') undo();
else if (key === 'enter') {
event.preventDefault();
if (phase === 'frozen') {
keyboardMode = true;
placeDevice(selected, keyCursor.x, keyCursor.y);
}
} else if (key === ' ') {
event.preventDefault();
resume();
} else if (key === 'escape') {
keyboardMode = false;
}
});
function resizeCanvas(): void {
const rect = elements.canvas.getBoundingClientRect();
const dpr = Math.min(2, window.devicePixelRatio || 1);
const width = Math.max(1, Math.round(rect.width * dpr));
const height = Math.max(1, Math.round(rect.height * dpr));
if (elements.canvas.width !== width || elements.canvas.height !== height) {
elements.canvas.width = width;
elements.canvas.height = height;
}
pixelRatio = dpr;
}
function draw(now: number): void {
const rect = elements.canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const ghostPoint =
phase === 'frozen' && !overlayOpen
? drag
? { x: drag.x, y: drag.y }
: keyboardMode
? keyCursor
: hover
: null;
renderScene(context, engine, rect.width, rect.height, pixelRatio, {
mode: phase,
selected,
angle,
ghost: ghostPoint && remaining(selected) > 0 ? ghostPoint : null,
showHints: document.documentElement.dataset.trajectoryHints !== 'false',
now: motionQuery.matches ? 0 : now,
});
}
function frame(now: number): void {
const dt = Math.min(0.05, Math.max(0, (now - lastFrame) / 1000));
lastFrame = now;
if (phase === 'running') {
engine.advance(dt);
const stats = engine.getStats();
if (!reportShown && stats.allResolved && engine.liveElapsed > 0.95) {
finalizeReport();
}
}
if (now - lastHud > 120) {
updateHud();
lastHud = now;
}
if (phase === 'frozen') {
elements.freezeClock.textContent = `T+${engine.elapsed.toFixed(2)}`;
} else if (phase === 'running') {
elements.freezeClock.textContent = `T+${engine.liveElapsed.toFixed(2)}`;
} else {
elements.freezeClock.textContent = 'FILED';
}
draw(now);
window.requestAnimationFrame(frame);
}
elements.canvas.tabIndex = 0;
engine = createEngine(0);
selected = firstAvailable();
const observer = new ResizeObserver(() => resizeCanvas());
observer.observe(elements.canvas);
resizeCanvas();
void loadRecords().then(() => {
const service = elements.overlay.querySelector<HTMLElement>('.service-record');
if (service) {
const closed = Object.values(records).filter((record) => record.perfect).length;
service.textContent =
closed > 0
? `Service record: ${closed} of ${LEVELS.length} cases closed.`
: 'No closed cases on file. The shredder is ready.';
}
});
syncPhase();
showBrief();
window.addEventListener('resize', resizeCanvas);
window.addEventListener('beforeunload', () => observer.disconnect());
window.requestAnimationFrame(frame);
