astara / src /main.js
system's picture
system HF Staff
77dd24d: A source check that checks, and a dev server that answers
45cc183 verified
Raw History Blame Contribute Delete
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 } };