import { createLoop } from './loop.js'; import { createInput } from './input.js'; import { createAudio } from './audio.js'; import { createChecks } from './checks.js'; import { createScene, W, H } from './scene.js'; import { createLevelScene } from './scene_level.js'; import { createRenderer } from './render.js'; import { loadManifest, loadClips } from './assets.js'; import { overrides, TUNING, DEPTH } from './tuning.js'; const canvas = document.getElementById('stage'); const ctx = canvas.getContext('2d', { alpha: false }); // Internal resolution is a fixed 1920x1080. The canvas is scaled to fit the // window and letterboxed; the backing store never changes size, so nothing in // the demo has to know how big the window is. function fit() { const scale = Math.min(window.innerWidth / W, window.innerHeight / H); canvas.style.width = `${Math.round(W * scale)}px`; canvas.style.height = `${Math.round(H * scale)}px`; } window.addEventListener('resize', fit); fit(); function message(text) { ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#8a8a86'; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#000'; ctx.font = '28px ui-monospace, Menlo, Consolas, monospace'; ctx.fillText(text, 60, 80); } message('loading…'); const params = new URLSearchParams(location.search); // ?manifest=… swaps the whole asset set without touching a line of code. This // is how the G6 swap test runs: manifest.swaptest.json points drained_walk at // the real reference drawing, at a different size, aspect ratio and pivot. const manifestUrl = params.get('manifest') || 'manifest.json'; // The level is the default. The fifteen-second demo stays reachable at // ?mode=demo, because it is what is deployed and being played, and it should // not go dark while the level is half-built. const mode = params.get('mode') === 'demo' ? 'demo' : 'level'; let clips; let report; let totalMs; let sounds; let letters; try { const manifest = await loadManifest(manifestUrl); ({ clips, report, totalMs } = await loadClips(manifest)); sounds = manifest.sounds ?? null; letters = manifest.letters ?? null; } catch (err) { message(`asset load failed: ${err.message}`); throw err; } // Silhouette generation runs once per frame of animation at load. The spec // budgets ~200ms for it; log the real number so a regression is obvious. console.log(`silhouettes generated in ${totalMs}ms`); console.table(report); if (Object.keys(overrides).length) console.log('tuning overridden from the URL:', overrides); // ?speed=3 makes the Drained cover ground three times faster. Demo only, for // playtesting — it changes nothing at the default of 1. const speedScale = Math.min(8, Math.max(0.25, Number(params.get('speed')) || 1)); // A browser keeps the audio clock suspended until the player has touched // something, and drops anything played before that WITHOUT an error. So the // first key or tap wakes it, and M mutes. const audio = createAudio(); audio.useRecording('footsteps', sounds?.footsteps); audio.useRecording('drainedWalk', sounds?.drainedWalk); audio.useRecording('thunder', sounds?.thunder); // One voice per kind of drained. Each falls back to its generated version if // the file has not decoded yet, which it will not have until a first gesture. for (const k of ['drainedSwing', 'drainedHaul', 'drainedBear', 'drainedRush', 'postThrust']) { audio.useRecording(k, sounds?.[k]); } for (const k of ['keyTake', 'torchFire', 'wake', 'gasp', 'villageBed']) { audio.useRecording(k, sounds?.[k]); } for (const ev of ['keydown', 'pointerdown', 'touchstart']) { window.addEventListener(ev, () => audio.resume(), { passive: true }); } window.addEventListener('keydown', (e) => { if (e.code === 'KeyM' && !e.repeat) audio.setEnabled(!audio.enabled); }); const scene = mode === 'demo' ? createScene(clips, { speedScale }) : await createLevelScene(clips, { audio, letters }); const renderer = createRenderer(ctx); const drawScene = mode === 'demo' ? renderer.drawDemo : renderer.drawLevel; const input = createInput(canvas, { onReset: () => scene.reset() }); const loop = createLoop({ step: 1 / 60, update: (dt) => { scene.update(dt, input.state); // TAKE on the touch pad only exists when there is something to take. On a // keyboard this is the "[E] take" line in the readout; a phone has no // readout worth reading, so the button itself has to say it. if (mode === 'level') { const st = scene.state; // TAKE doubles as MARK. The spec put marking on "the empty wheel slot", // which stopped existing when MAP took it — and it should not have its // own button anyway: TAKE is already the contextual one, and both acts // are "put this where it goes". So the button says which it is. // // EVERY OFFER THE SCENE MAKES, and the scene is the only thing that // decides. This used to re-run the ground scan itself, which was two // places that had to agree about what is within reach. input.setTakeAvailable?.(st.canMark || st.canTakeKey || st.canPlaceKey || st.canTake); } }, render: (alpha) => { const t0 = performance.now(); drawScene(scene, alpha, loop.stats); // Smoothed so the readout is legible rather than jittering every frame. loop.stats.drawMs += (performance.now() - t0 - loop.stats.drawMs) * 0.1; }, }); loop.stats.drawMs = 0; loop.stats.pass1Ms = 0; loop.stats.pass2Ms = 0; loop.stats.bgDrawn = 0; loop.start(); // Exposed for verification from the console and by the browser tooling. // Not used by the demo itself. // --- the depth experiment --------------------------------------------------- // X cycles: nothing, each cue on its own, then all of them. Judging these by // eye needs fast A/B, and typing five URLs is not fast. const DEPTH_STEPS = [ { name: 'none' }, { name: 'occlusion 3x', OCC: 3 }, { name: 'particles 400 soft', PART: 400 }, { name: 'particles 400 hard', PART: 400, PART_HARD: 1 }, { name: 'blur 4', BLUR: 4 }, { name: 'grain 0.05', GRAIN: 0.05 }, { name: 'shafts', SHAFT: 1 }, { name: 'ALL', OCC: 3, PART: 400, BLUR: 4, GRAIN: 0.05, SHAFT: 1 }, ]; let depthStep = 0; function applyDepth(step) { Object.assign(DEPTH, { OCC: 1, PART: 0, PART_HARD: 0, BLUR: 0, BLUR_SKY: 0, GRAIN: 0, SHAFT: 0 }); Object.assign(DEPTH, step); delete DEPTH.name; scene.state.background?.setBlur(Math.round(DEPTH.BLUR), Math.round(DEPTH.BLUR_SKY)); DEPTH.label = step.name; } // Whatever the URL asked for is step 0, so the cycle always returns to it. DEPTH_STEPS[0] = { name: 'url', OCC: DEPTH.OCC, PART: DEPTH.PART, PART_HARD: DEPTH.PART_HARD, BLUR: DEPTH.BLUR, BLUR_SKY: DEPTH.BLUR_SKY, GRAIN: DEPTH.GRAIN, SHAFT: DEPTH.SHAFT }; DEPTH.label = 'url'; window.addEventListener('keydown', (e) => { if (e.code !== 'KeyX' || e.repeat) return; depthStep = (depthStep + 1) % DEPTH_STEPS.length; applyDepth(DEPTH_STEPS[depthStep]); }); // The standing tests. Nothing runs them automatically; they are there so a // check is the same check every time instead of a script pasted into a console. const checks = mode === 'level' ? createChecks({ renderer, scene, canvas, clips, audio }) : null; window.__demo = { mode, loop, scene, input, renderer, canvas, clips, report, totalMs, audio, checks, depth: { DEPTH, steps: DEPTH_STEPS, apply: applyDepth } };