Spaces:
Running
Running
Download src/main.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 7.35 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/main.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/main.js
-
curl -L -o main.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/main.js
7.35 kB
| 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 } }; | |