File size: 7,354 Bytes
020d2dc
 
94ca139
6742ef0
020d2dc
71eae78
020d2dc
 
6742ef0
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
020d2dc
 
 
 
 
71eae78
 
 
 
 
020d2dc
 
 
94ca139
7d93bf0
020d2dc
 
 
94ca139
7d93bf0
020d2dc
 
 
 
 
 
 
 
 
71eae78
020d2dc
71eae78
 
020d2dc
71eae78
94ca139
 
 
 
835b445
 
db17af1
94e856b
 
14ad8f2
94e856b
 
d16591c
152c254
 
94ca139
 
 
 
 
 
 
71eae78
 
7d93bf0
71eae78
020d2dc
71eae78
020d2dc
 
 
 
ec98146
 
 
 
 
 
 
7d93bf0
 
 
 
835b445
 
 
 
45cc183
6f7f7b5
ec98146
 
020d2dc
 
71eae78
020d2dc
 
 
 
 
 
71eae78
 
 
020d2dc
 
 
 
6742ef0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54c8b9e
6742ef0
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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 } };