@moood/render 0.0.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine/engine.js CHANGED
@@ -1,1456 +1,1456 @@
1
- // Living-scene engine: paints a scene spec (see ../scene-prompt.mjs) on a canvas,
2
- // animated. Everything is in device pixels.
3
- // The frame is always landscape: 4:3 to 21:9, centred in the canvas; it sets the composition, and
4
- // the canvas around it (a window narrower or wider) shows the scene going on, dimmed, the colour at
5
- // the frame's edge carrying on where nothing is painted. Its height is the measure: the scene is painted on a 16:9 stage
6
- // as tall as the frame (spec x is a fraction of the stage's width, y and sizes fractions of its
7
- // height), the stage centred in the frame — a wider frame sees more on both sides, a narrower
8
- // one less; nothing on it moves or stretches. What matters belongs in the stage's middle 4:3.
9
- const Moood = (() => {
10
- const STAGE = 16 / 9, NARROWEST = 4 / 3, WIDEST = 21 / 9;
11
- // ── utilities ──────────────────────────────────────────────────────────
12
- function seeded(seed) {
13
- return () => {
14
- seed = (seed + 0x6d2b79f5) | 0;
15
- let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
16
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
17
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
- };
19
- }
20
- function noise1D(seed) {
21
- const v = Array.from({ length: 512 }, seeded(seed));
22
- return x => {
23
- const i = Math.floor(x), f = x - i, k = f * f * (3 - 2 * f);
24
- return v[i & 511] + (v[(i + 1) & 511] - v[i & 511]) * k;
25
- };
26
- }
27
- function layer(w, h) {
28
- const c = document.createElement('canvas');
29
- c.width = Math.max(1, Math.round(w));
30
- c.height = Math.max(1, Math.round(h));
31
- return c;
32
- }
33
- function rgb(hex) {
34
- const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
35
- if (!m) throw new Error(`bad colour ${hex}`);
36
- const n = parseInt(m[1], 16);
37
- return `${n >> 16},${(n >> 8) & 255},${n & 255}`;
38
- }
39
- // Glows are the most frequent thing drawn (fireflies, lamps, fog, fire): one
40
- // pre-rendered radial sprite per colour, stretched and faded with globalAlpha —
41
- // no gradient objects created per frame.
42
- const glowSprites = new Map();
43
- function glowSprite(col) {
44
- let s = glowSprites.get(col);
45
- if (!s) {
46
- s = layer(256, 256);
47
- const g = s.getContext('2d'), gr = g.createRadialGradient(128, 128, 0, 128, 128, 128);
48
- gr.addColorStop(0, `rgba(${col},1)`);
49
- gr.addColorStop(0.25, `rgba(${col},0.4)`);
50
- gr.addColorStop(1, `rgba(${col},0)`);
51
- g.fillStyle = gr;
52
- g.fillRect(0, 0, 256, 256);
53
- glowSprites.set(col, s);
54
- }
55
- return s;
56
- }
57
- function glow(ctx, x, y, r, col, a) {
58
- if (r <= 0 || a <= 0) return;
59
- const prev = ctx.globalAlpha;
60
- ctx.globalAlpha = prev * Math.min(1, a);
61
- ctx.drawImage(glowSprite(col), x - r, y - r, 2 * r, 2 * r);
62
- ctx.globalAlpha = prev;
63
- }
64
- const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
65
- const hash = s => { let h = 2166136261; for (const ch of String(s)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619); return h >>> 0; };
66
-
67
- // Film grain as sparse light/dark specks with their own alpha, so it composites with
68
- // plain source-over (a full-screen 'overlay' blend every frame is expensive).
69
- const grainTile = (() => {
70
- const c = layer(160, 160), g = c.getContext('2d'), img = g.createImageData(160, 160);
71
- for (let i = 0; i < img.data.length; i += 4) {
72
- img.data[i] = img.data[i + 1] = img.data[i + 2] = Math.random() < 0.5 ? 0 : 255;
73
- img.data[i + 3] = Math.random() * 38;
74
- }
75
- g.putImageData(img, 0, 0);
76
- return c;
77
- })();
78
-
79
- // ── static layers ──────────────────────────────────────────────────────
80
- function ridge(L, W, H, seed) {
81
- const n1 = noise1D(seed), n2 = noise1D(seed + 1), n3 = noise1D(seed + 2);
82
- const base = L.y * H, h = (L.height ?? 0.2) * H, r = L.roughness ?? 0.5;
83
- return x => {
84
- const u = x / W;
85
- if (L.kind === 'mountains') {
86
- const sharp = 1 - Math.abs(2 * n1(u * 3 + 0.3) - 1);
87
- return base - h * (0.35 + 0.45 * sharp + r * 0.25 * n2(u * 12) + r * 0.08 * n3(u * 60));
88
- }
89
- if (L.kind === 'dunes') return base - h * (0.5 + 0.45 * Math.sin(u * 7 + n1(u * 2) * 4) * (0.6 + 0.4 * n2(u * 3)));
90
- if (L.kind === 'ground') return base - h * 0.15 * n1(u * 4);
91
- return base - h * (0.35 + 0.55 * n1(u * 2.5) + r * 0.15 * n2(u * 10)); // hills + base of forest/city
92
- };
93
- }
94
-
95
- function drawPine(g, x, base, h, color, rnd) {
96
- const w = h * (0.22 + rnd() * 0.08);
97
- g.fillStyle = color;
98
- g.fillRect(x - h * 0.012, base - h * 0.15, h * 0.024, h * 0.16);
99
- const tiers = 6 + Math.floor(rnd() * 3);
100
- for (let i = 0; i < tiers; i++) {
101
- const k = i / tiers, yb = base - h * (0.1 + k * 0.78), yt = yb - h * (0.17 + 0.05 * (1 - k));
102
- const wi = w * (1 - k) * (0.85 + rnd() * 0.3) + h * 0.02;
103
- g.beginPath();
104
- g.moveTo(x, yt);
105
- g.quadraticCurveTo(x - wi * 0.3, yb - (yb - yt) * 0.35, x - wi, yb + h * 0.01);
106
- g.quadraticCurveTo(x, yb - h * 0.025, x + wi, yb + h * 0.01);
107
- g.quadraticCurveTo(x + wi * 0.3, yb - (yb - yt) * 0.35, x, yt);
108
- g.fill();
109
- }
110
- }
111
-
112
- // Layers are painted wider and taller than the screen so the camera can pan and
113
- // zoom out: world x spans [-EXT·W, (1+EXT)·W], y spans [0, DEEP·H].
114
- const EXT = 0.4, DEEP = 1.3;
115
-
116
- // Paints a landscape layer. By default the whole layer at scale 1; `view` = { x, y, w, h, k }
117
- // paints only that region (layer coordinates) at scale k — for sharp close-ups.
118
- // Also returns, for each of TOP_STRIPS vertical strips, `tops`: the highest painted y, and
119
- // `solids`: the y below which the strip is solid colour. A renderer can skip the empty sky
120
- // above a layer and fill its body flat; only the band between needs a texture.
121
- // `view.canvas`, if given, is reused (cleared) instead of allocating a new canvas.
122
- const TOP_STRIPS = 64;
123
- function staticLayer(L, W, H, seed, view) {
124
- const x0 = -EXT * W, x1 = (1 + EXT) * W, bottom = DEEP * H;
125
- const v = view || { x: x0, y: 0, w: x1 - x0, h: bottom, k: 1 };
126
- const c = v.canvas || layer(v.w * v.k, v.h * v.k), g = c.getContext('2d'), rnd = seeded(seed), f = ridge(L, W, H, seed);
127
- if (v.canvas) { g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, c.width, c.height); }
128
- g.setTransform(v.k, 0, 0, v.k, -v.x * v.k, -v.y * v.k);
129
- const tops = new Array(TOP_STRIPS).fill(bottom), solids = new Array(TOP_STRIPS).fill(0), sw = (x1 - x0) / TOP_STRIPS;
130
- const mark = (xa, xb, y) => {
131
- for (let i = Math.max(0, Math.floor((xa - x0) / sw)); i <= Math.min(TOP_STRIPS - 1, Math.floor((xb - x0) / sw)); i++) tops[i] = Math.min(tops[i], y);
132
- };
133
- // finer outline steps when painting a close-up, but only across the painted region
134
- const step = Math.max(2, W / 400) / v.k;
135
- g.fillStyle = L.color;
136
- const top = (L.kind === 'forest' || L.kind === 'city')
137
- ? x => L.y * H - (L.y * H - f(x)) * 0.25 // a low base under the trees / buildings
138
- : f;
139
- g.beginPath();
140
- g.moveTo(x0, bottom);
141
- const coarse = Math.max(2, W / 400);
142
- for (let x = x0; x <= x1 + coarse; x += coarse) {
143
- const y = top(x);
144
- mark(x - coarse, x + coarse, y);
145
- const i = Math.min(TOP_STRIPS - 1, Math.max(0, Math.floor((x - x0) / sw)));
146
- solids[i] = Math.max(solids[i], y);
147
- }
148
- const pa = Math.max(x0, v.x - 2 * step), pb = Math.min(x1, v.x + v.w + 2 * step);
149
- g.lineTo(pa, bottom);
150
- for (let x = pa; x <= pb + step; x += step) g.lineTo(x, top(x));
151
- g.lineTo(pb + step, bottom);
152
- g.fill();
153
- const density = L.density ?? 0.5, h = (L.height ?? 0.2) * H;
154
- if (L.kind === 'forest') {
155
- const spacing = W * (0.035 - 0.028 * density);
156
- for (let x = x0 - spacing; x < x1 + spacing; x += spacing * (0.5 + rnd())) {
157
- const th = h * (0.55 + rnd() * 0.5), base = top(x) + h * 0.03;
158
- mark(x - th * 0.35, x + th * 0.35, base - th * 1.12);
159
- drawPine(g, x, base, th, L.color, rnd);
160
- }
161
- }
162
- const windows = [];
163
- if (L.kind === 'city') {
164
- let x = x0 - rnd() * W * 0.03;
165
- while (x < x1) {
166
- const bw = W * (0.03 + rnd() * 0.05) * (1.3 - density * 0.6), bh = h * (0.3 + rnd() * 0.7);
167
- const btop = L.y * H - bh;
168
- g.fillStyle = L.color;
169
- g.fillRect(x, btop, bw - W * 0.002, bh + bottom);
170
- mark(x, x + bw, btop - bh * 0.12);
171
- if (rnd() < 0.25) g.fillRect(x + bw * 0.4, btop - bh * 0.12, bw * 0.08, bh * 0.12);
172
- const cw = Math.max(2, W * 0.004), ch = Math.max(3, H * 0.009);
173
- for (let wy = btop + ch * 1.5; wy < L.y * H - ch; wy += ch * 2.2) {
174
- for (let wx = x + cw; wx < x + bw - cw * 2; wx += cw * 2.2) {
175
- if (rnd() < (L.lights ?? 0.2)) windows.push({ x: wx, y: wy, w: cw, h: ch, p: rnd() * 100 });
176
- }
177
- }
178
- x += bw;
179
- }
180
- }
181
- // margins: the outline is sampled, its true peaks and dips lie a little beyond the samples
182
- return { canvas: c, x0, windows, tops: tops.map(y => Math.max(0, y - 4 - H * 0.01)), solids: solids.map((y, i) => Math.min(bottom, Math.max(y, solids[i - 1] ?? y, solids[i + 1] ?? y) + 4 + H * 0.01)), stripW: sw };
183
- }
184
-
185
- // ── objects ────────────────────────────────────────────────────────────
186
- function figure(ctx, x, y, s, t, color, pose, flip) {
187
- ctx.save();
188
- ctx.translate(x, y);
189
- ctx.scale(flip ? -s : s, s);
190
- ctx.fillStyle = ctx.strokeStyle = color;
191
- ctx.lineCap = ctx.lineJoin = 'round';
192
- const L = (w, ...p) => { ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(p[0], p[1]); for (let i = 2; i < p.length; i += 2) ctx.lineTo(p[i], p[i + 1]); ctx.stroke(); };
193
- const C = (cx, cy, r) => { ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.fill(); };
194
- const b = Math.sin(t * 1.3) * 0.008;
195
- if (pose === 'sit') {
196
- L(0.16, 0, -0.08, -0.28, -0.4); L(0.11, -0.28, -0.4, -0.33, -0.05);
197
- ctx.beginPath(); ctx.moveTo(0.13, -0.02); ctx.bezierCurveTo(0.22, -0.22, 0.18, -0.46, 0.05, -0.6 - b);
198
- ctx.lineTo(-0.08, -0.57 - b); ctx.bezierCurveTo(-0.1, -0.4, -0.06, -0.2, -0.04, -0.06); ctx.fill();
199
- L(0.075, 0.02, -0.52, -0.16, -0.46, -0.31, -0.36); C(-0.13, -0.69 - b, 0.11);
200
- } else {
201
- const walk = pose === 'walk' ? Math.sin(t * 3) * 0.08 : 0;
202
- L(0.1, -0.05, -0.4, -0.06 + walk, -0.02); L(0.1, 0.05, -0.4, 0.06 - walk, -0.02);
203
- ctx.beginPath(); ctx.moveTo(-0.11, -0.76 - b); ctx.quadraticCurveTo(-0.17, -0.74, -0.17, -0.64);
204
- ctx.lineTo(-0.19, -0.34); ctx.lineTo(0.19, -0.34); ctx.lineTo(0.17, -0.64);
205
- ctx.quadraticCurveTo(0.17, -0.74, 0.11, -0.76 - b); ctx.fill();
206
- L(0.08, -0.16, -0.68, -0.2 - walk * 0.5, -0.42); L(0.08, 0.16, -0.68, 0.2 + walk * 0.5, -0.42);
207
- C(pose === 'back' ? 0 : 0.02, -0.87 - b, 0.11);
208
- }
209
- ctx.restore();
210
- }
211
-
212
- // W × H: the stage, in pixels
213
- function drawObject(ctx, o, W, H, t, lightsOut) {
214
- const x = o.x * W, y = o.y * H, s = o.size * H, col = o.color;
215
- const light = o.light ? rgb(o.light) : null, fl = 0.9 + 0.1 * Math.sin(t * 7 + x) * Math.sin(t * 3.1 + y);
216
- ctx.fillStyle = ctx.strokeStyle = col;
217
- ctx.lineCap = ctx.lineJoin = 'round';
218
- switch (o.kind) {
219
- case 'pine': drawPine(ctx, x, y, s, col, seeded(hash(o.x + ':' + o.y))); break;
220
- case 'tree': {
221
- const sw = Math.sin(t * 0.8 + x) * s * 0.01;
222
- ctx.fillRect(x - s * 0.03, y - s * 0.5, s * 0.06, s * 0.5);
223
- for (const [dx, dy, r] of [[0, -0.72, 0.28], [-0.18, -0.58, 0.2], [0.18, -0.6, 0.21], [0.05, -0.9, 0.18]]) {
224
- ctx.beginPath(); ctx.arc(x + dx * s + sw, y + dy * s, r * s, 0, 7); ctx.fill();
225
- }
226
- break;
227
- }
228
- case 'house': {
229
- const w = s * 0.9, h = s * 0.55;
230
- ctx.fillRect(x - w / 2, y - h, w, h);
231
- ctx.beginPath(); ctx.moveTo(x - w * 0.6, y - h); ctx.lineTo(x, y - s); ctx.lineTo(x + w * 0.6, y - h); ctx.fill();
232
- ctx.fillRect(x + w * 0.2, y - s * 0.92, w * 0.1, s * 0.2);
233
- if (light) lightsOut.push({ x: x - w * 0.15, y: y - h * 0.55, r: s * 0.9, col: light, a: 0.4 * fl, rect: [x - w * 0.27, y - h * 0.72, w * 0.22, h * 0.3] });
234
- break;
235
- }
236
- case 'tower': {
237
- const w = s * 0.22;
238
- ctx.fillRect(x - w / 2, y - s * 0.85, w, s * 0.85);
239
- ctx.beginPath(); ctx.moveTo(x - w * 0.7, y - s * 0.85); ctx.lineTo(x, y - s); ctx.lineTo(x + w * 0.7, y - s * 0.85); ctx.fill();
240
- if (light) lightsOut.push({ x, y: y - s * 0.7, r: s * 0.5, col: light, a: 0.4 * fl, rect: [x - w * 0.18, y - s * 0.75, w * 0.36, s * 0.08] });
241
- break;
242
- }
243
- case 'lamp': {
244
- ctx.lineWidth = s * 0.03;
245
- ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - s); ctx.quadraticCurveTo(x, y - s * 1.05, x + s * 0.12, y - s * 1.02); ctx.stroke();
246
- ctx.fillRect(x + s * 0.06, y - s * 1.02, s * 0.12, s * 0.05);
247
- if (light) lightsOut.push({ x: x + s * 0.12, y: y - s * 0.95, r: s * 1.4, col: light, a: 0.45 * fl, dot: s * 0.04, cone: { y, w: s * 0.6 } });
248
- break;
249
- }
250
- case 'lantern': {
251
- const sway = Math.sin(t * 1.2 + x) * s * 0.05;
252
- ctx.lineWidth = Math.max(1, s * 0.02);
253
- ctx.beginPath(); ctx.moveTo(x, y - s * 0.6); ctx.lineTo(x + sway, y - s * 0.15); ctx.stroke();
254
- ctx.fillRect(x + sway - s * 0.08, y - s * 0.15, s * 0.16, s * 0.2);
255
- if (light) lightsOut.push({ x: x + sway, y: y - s * 0.05, r: s * 1.2, col: light, a: 0.5 * fl, dot: s * 0.07 });
256
- break;
257
- }
258
- case 'campfire': {
259
- ctx.lineWidth = s * 0.08;
260
- ctx.beginPath(); ctx.moveTo(x - s * 0.3, y); ctx.lineTo(x + s * 0.25, y - s * 0.08); ctx.moveTo(x + s * 0.3, y); ctx.lineTo(x - s * 0.25, y - s * 0.08); ctx.stroke();
261
- const fc = light || '255,150,60';
262
- for (let i = 0; i < 3; i++) {
263
- const fh = s * (0.5 + 0.2 * Math.sin(t * 9 + i * 2)), fw = s * (0.16 - i * 0.03);
264
- ctx.fillStyle = `rgba(${i === 2 ? '255,230,170' : fc},${0.85 - i * 0.15})`;
265
- ctx.beginPath(); ctx.moveTo(x - fw, y - s * 0.05); ctx.quadraticCurveTo(x - fw * 0.6, y - fh * 0.6, x + Math.sin(t * 5 + i) * s * 0.05, y - fh);
266
- ctx.quadraticCurveTo(x + fw * 0.6, y - fh * 0.6, x + fw, y - s * 0.05); ctx.fill();
267
- }
268
- lightsOut.push({ x, y: y - s * 0.25, r: s * 3, col: fc, a: 0.4 * (0.8 + 0.2 * Math.sin(t * 11)) });
269
- break;
270
- }
271
- case 'window': {
272
- const w = s * 0.6;
273
- if (light) lightsOut.push({ x, y: y - s / 2, r: s * 1.3, col: light, a: 0.35 * fl, rect: [x - w / 2, y - s, w, s] });
274
- ctx.lineWidth = Math.max(2, s * 0.05);
275
- ctx.strokeRect(x - w / 2, y - s, w, s);
276
- ctx.beginPath(); ctx.moveTo(x, y - s); ctx.lineTo(x, y); ctx.moveTo(x - w / 2, y - s * 0.55); ctx.lineTo(x + w / 2, y - s * 0.55); ctx.stroke();
277
- break;
278
- }
279
- case 'boat': {
280
- const bob = Math.sin(t * 1.1 + x) * s * 0.03;
281
- ctx.beginPath(); ctx.moveTo(x - s * 0.6, y - s * 0.12 + bob); ctx.quadraticCurveTo(x, y + s * 0.08 + bob, x + s * 0.6, y - s * 0.12 + bob); ctx.lineTo(x - s * 0.6, y - s * 0.12 + bob); ctx.fill();
282
- ctx.lineWidth = s * 0.025;
283
- ctx.beginPath(); ctx.moveTo(x, y - s * 0.1 + bob); ctx.lineTo(x, y - s + bob); ctx.stroke();
284
- ctx.beginPath(); ctx.moveTo(x + s * 0.03, y - s * 0.95 + bob); ctx.lineTo(x + s * 0.4, y - s * 0.2 + bob); ctx.lineTo(x + s * 0.03, y - s * 0.2 + bob); ctx.fill();
285
- break;
286
- }
287
- case 'rock': {
288
- const r = seeded(hash(o.x + 'r' + o.y));
289
- ctx.beginPath(); ctx.moveTo(x - s * 0.7, y);
290
- for (let i = 0; i <= 6; i++) { const a = Math.PI * (1 - i / 6); ctx.lineTo(x + Math.cos(a) * s * (0.6 + r() * 0.2), y - Math.sin(a) * s * (0.7 + r() * 0.3)); }
291
- ctx.closePath(); ctx.fill();
292
- break;
293
- }
294
- case 'bench': {
295
- ctx.fillRect(x - s * 0.8, y - s * 0.45, s * 1.6, s * 0.08);
296
- ctx.fillRect(x - s * 0.8, y - s * 0.85, s * 1.6, s * 0.07);
297
- ctx.fillRect(x - s * 0.7, y - s * 0.45, s * 0.06, s * 0.45); ctx.fillRect(x + s * 0.64, y - s * 0.45, s * 0.06, s * 0.45);
298
- ctx.fillRect(x - s * 0.7, y - s * 0.85, s * 0.05, s * 0.4); ctx.fillRect(x + s * 0.65, y - s * 0.85, s * 0.05, s * 0.4);
299
- break;
300
- }
301
- case 'figure': figure(ctx, x, y, s, t, col, o.pose || 'stand', o.flip); break;
302
- case 'cat': {
303
- ctx.beginPath(); ctx.ellipse(x, y - s * 0.3, s * 0.22, s * 0.3, 0, 0, 7); ctx.fill();
304
- ctx.beginPath(); ctx.arc(x, y - s * 0.72, s * 0.15, 0, 7); ctx.fill();
305
- ctx.beginPath(); ctx.moveTo(x - s * 0.14, y - s * 0.78); ctx.lineTo(x - s * 0.1, y - s * 0.98); ctx.lineTo(x - s * 0.02, y - s * 0.84);
306
- ctx.moveTo(x + s * 0.14, y - s * 0.78); ctx.lineTo(x + s * 0.1, y - s * 0.98); ctx.lineTo(x + s * 0.02, y - s * 0.84); ctx.fill();
307
- ctx.lineWidth = s * 0.06;
308
- ctx.beginPath(); ctx.moveTo(x + s * 0.18, y - s * 0.05);
309
- ctx.quadraticCurveTo(x + s * 0.5, y - s * 0.05, x + s * (0.45 + Math.sin(t * 1.5) * 0.08), y - s * 0.4); ctx.stroke();
310
- break;
311
- }
312
- case 'bird': {
313
- const span = WIDEST * H + 4 * s, bx = ((x + t * s * 1.5 * (o.flip ? -1 : 1)) % span + span) % span + (W - WIDEST * H) / 2 - 2 * s;
314
- const by = y + Math.sin(t * 0.7 + x) * s * 0.4, fl2 = Math.sin(t * 8 + x) * s * 0.25;
315
- ctx.lineWidth = Math.max(1.5, s * 0.08);
316
- ctx.beginPath(); ctx.moveTo(bx - s * 0.5, by - fl2); ctx.quadraticCurveTo(bx - s * 0.2, by - s * 0.15, bx, by);
317
- ctx.quadraticCurveTo(bx + s * 0.2, by - s * 0.15, bx + s * 0.5, by - fl2); ctx.stroke();
318
- break;
319
- }
320
- case 'flower': {
321
- const sw = Math.sin(t * 1.4 + x) * s * 0.06;
322
- ctx.lineWidth = Math.max(1, s * 0.04);
323
- ctx.beginPath(); ctx.moveTo(x, y); ctx.quadraticCurveTo(x, y - s * 0.5, x + sw, y - s * 0.9); ctx.stroke();
324
- ctx.fillStyle = light ? `rgb(${light})` : col;
325
- for (let i = 0; i < 5; i++) { const a = i / 5 * Math.PI * 2 + t * 0.2; ctx.beginPath(); ctx.ellipse(x + sw + Math.cos(a) * s * 0.1, y - s * 0.9 + Math.sin(a) * s * 0.1, s * 0.09, s * 0.05, a, 0, 7); ctx.fill(); }
326
- break;
327
- }
328
- case 'bridge': {
329
- ctx.lineWidth = s * 0.08;
330
- ctx.beginPath(); ctx.moveTo(x - s * 1.5, y); ctx.quadraticCurveTo(x, y - s * 0.9, x + s * 1.5, y); ctx.stroke();
331
- ctx.lineWidth = s * 0.03;
332
- ctx.beginPath(); ctx.moveTo(x - s * 1.5, y - s * 0.2); ctx.quadraticCurveTo(x, y - s * 1.15, x + s * 1.5, y - s * 0.2); ctx.stroke();
333
- for (let i = -4; i <= 4; i++) { const u = i / 4.5, yy = y - s * 0.45 * (1 - u * u) * 1.0; ctx.beginPath(); ctx.moveTo(x + u * s * 1.4, yy); ctx.lineTo(x + u * s * 1.4, yy - s * 0.22); ctx.stroke(); }
334
- break;
335
- }
336
- case 'custom': case undefined: paintCustom(ctx, o, W, H, t, lightsOut); break;
337
- default: throw new Error(`unknown object kind ${o.kind}`);
338
- }
339
- }
340
-
341
- // Elements the world's agent paints itself. `o.code` is the body of a function (g, e):
342
- // g is a 2D canvas context in metres — origin at the element's anchor (where it stands),
343
- // x to the right, y DOWN (a 6 m house spans y −6…0). e.t = seconds by the real clock (e.phase =
344
- // 0…1 of the loop for animated elements), e.id = the element's id, e.rnd() = seeded random, e.px = one screen pixel in metres,
345
- // e.light(x, y, radius, color, strength) = a glowing light the engine animates. What scenes
346
- // share (kit.js): e.anim — the animator's toolkit; e.kit — the world's modules; e.lib — the scene's
347
- // lib. e.was(key, ago) — what a value of the state was `ago` seconds ago (what lags behind reads
348
- // it); e.speed(key) — how fast it is changing now (per second).
349
- // Errors are collected (window.__mooodErrors) so the agent reviewing its work sees them.
350
- // The scene's state (scenestate.js): values the scene's scripts, agents and widgets set, which
351
- // the painting code reads as e.state; ver counts changes, so the renderer knows to repaint.
352
- const STATE = { values: {}, ver: 0, was: key => STATE.values[key], sample: null };
353
- // what the shown scene's code shares (kit.js): the world's modules it lists and the scene's lib
354
- let SHARED = { kit: {}, lib: {} };
355
- // Where an element is now, as its painting code says (e.at): fractions of the stage as seen
356
- // from the start — speech bubbles and widgets follow it (stage.screenOf).
357
- const FOCUS = new Map();
358
- // A platformer's hero as it moves now (camera.mode platform): its painting code reads e.hero —
359
- // { vx, vy (m/s), ground } — to move its legs; the engine moves and turns the painting itself.
360
- const HERO = { id: null, vx: 0, vy: 0, ground: true };
361
- // What an element's code read as it was last painted (element → {time, state}): one that reads
362
- // the time is repainted every frame, one that reads only the state when the state changes, one
363
- // that reads neither stays as painted (gl.js) — whether it reads them itself or through the kit
364
- const READS = new WeakMap();
365
- const compiled = new Map();
366
- function reportError(o, err) {
367
- const msg = `${o.name || o.kind}: ${err && err.message || err}`;
368
- (window.__mooodErrors = window.__mooodErrors || []).push(msg);
369
- console.warn('moood custom element:', msg);
370
- }
371
- function paintCustom(ctx, o, W, H, t, lightsOut) {
372
- const code = String(o.code || '');
373
- let fn = compiled.get(code);
374
- if (fn === undefined) {
375
- try { fn = new Function('g', 'e', code); } catch (err) { fn = null; reportError(o, err); }
376
- compiled.set(code, fn);
377
- }
378
- if (!fn) return;
379
- // sky and ground elements are painted in stage heights from the stage's top-left corner, as
380
- // seen from the start (x 0…W/H, y 0…1, with margins); others in metres from their anchor
381
- const flat = o.sky || o.ground;
382
- const ppm = flat ? H : o.size * H / (o.height || 2), x = flat ? 0 : o.x * W, y = flat ? 0 : o.y * H;
383
- ctx.save();
384
- ctx.translate(x, y);
385
- ctx.scale(ppm, ppm);
386
- ctx.fillStyle = ctx.strokeStyle = o.color || '#000000';
387
- ctx.lineCap = ctx.lineJoin = 'round';
388
- const period = o.period || 2;
389
- // a point the code names, as painted — through whatever it did to the canvas since (translate,
390
- // scale, rotate): [x, y, how much it is scaled]
391
- const base = ctx.getTransform(), unbase = base.inverse();
392
- const painted = (lx, ly) => {
393
- const now = ctx.getTransform(), q = unbase.transformPoint(now.transformPoint({ x: lx, y: ly }));
394
- return [x + q.x * ppm, y + q.y * ppm, Math.hypot(now.a, now.b) / Math.hypot(base.a, base.b)];
395
- };
396
- const read = { time: false, state: false };
397
- READS.set(o, read);
398
- const e = {
399
- get t() { read.time = true; return t; },
400
- get phase() { read.time = true; return ((t / period) % 1 + 1) % 1; },
401
- get state() { read.state = true; return STATE.values; },
402
- id: o.id ?? null, rnd: seeded(hash(code) ^ hash(o.x + ':' + o.y)),
403
- px: 1 / Math.max(1e-6, base.a), W, H, aspect: W / H,
404
- light(lx, ly, r, color = '#ffd08a', strength = 0.6) {
405
- try {
406
- const [px, py, k] = painted(lx, ly);
407
- lightsOut.push({ x: px, y: py, r: r * k * ppm, col: rgb(color), a: strength, flicker: o.flicker ?? 0.3 });
408
- } catch (err) { reportError(o, err); }
409
- },
410
- // where this element is now (its head, say): what speaks or hovers over it follows this point
411
- at(lx, ly) { if (o.id) { const [px, py] = painted(lx, ly); FOCUS.set(o.id, { x: px / W, y: py / H }); } },
412
- anim: MooodKit.anim, kit: SHARED.kit, lib: SHARED.lib,
413
- was: (key, ago = 0) => { read.state = true; return STATE.was(key, ago); },
414
- speed: key => { read.state = true; const a = STATE.was(key, 0), b = STATE.was(key, 0.05); return typeof a === 'number' && typeof b === 'number' ? (a - b) / 0.05 : 0; },
415
- };
416
- if (o.id && o.id === HERO.id) { read.time = true; e.hero = { vx: HERO.vx, vy: HERO.vy, ground: HERO.ground }; }
417
- try { fn(ctx, e); } catch (err) { reportError(o, err); }
418
- ctx.restore();
419
- }
420
- // The box an object's painting fits in (pixels of the W × H stage): built-in kinds a fixed frame
421
- // around the base, custom ones their `box` in metres (default: a little wider than tall).
422
- function objectBox(o, W, H) {
423
- // the sky: as wide as the widest frame
424
- if (o.sky) return { bx: (W - WIDEST * H) / 2, by: -0.35 * H, bw: WIDEST * H, bh: 1.6 * H };
425
- // ground: from the horizon down, wider than the screen (for stepping aside and looking down)
426
- if (o.ground) return { bx: -EXT * W, by: o._yh * H, bw: (1 + 2 * EXT) * W, bh: (1.7 - o._yh) * H };
427
- const s = Math.max(2, o.size * H), x = o.x * W, y = o.y * H;
428
- if (o.kind === 'custom' || !o.kind) {
429
- const h = o.height || 2, ppm = s / h, b = Array.isArray(o.box) && o.box.length === 4 ? o.box : [-0.8 * h, -1.1 * h, 0.8 * h, 0.15 * h];
430
- return { bx: x + b[0] * ppm, by: y + b[1] * ppm, bw: (b[2] - b[0]) * ppm, bh: (b[3] - b[1]) * ppm };
431
- }
432
- return { bx: x - 1.7 * s, by: y - 1.3 * s, bw: 3.4 * s, bh: 1.6 * s };
433
- }
434
-
435
- function drawLights(ctx, lights) {
436
- for (const l of lights) {
437
- if (l.rect) { ctx.fillStyle = `rgba(${l.col},${Math.min(1, l.a * 2.2)})`; ctx.fillRect(...l.rect); }
438
- }
439
- ctx.save();
440
- ctx.globalCompositeOperation = 'lighter';
441
- for (const l of lights) {
442
- glow(ctx, l.x, l.y, l.r, l.col, l.a * 0.6);
443
- if (l.dot) glow(ctx, l.x, l.y, l.dot * 3, '255,245,220', 0.9);
444
- if (l.cone) {
445
- const g = ctx.createLinearGradient(0, l.y, 0, l.cone.y);
446
- g.addColorStop(0, `rgba(${l.col},${l.a * 0.35})`);
447
- g.addColorStop(1, `rgba(${l.col},0)`);
448
- ctx.fillStyle = g;
449
- ctx.beginPath(); ctx.moveTo(l.x - l.dot, l.y); ctx.lineTo(l.x + l.dot, l.y); ctx.lineTo(l.x + l.cone.w, l.cone.y); ctx.lineTo(l.x - l.cone.w, l.cone.y); ctx.fill();
450
- }
451
- }
452
- ctx.restore();
453
- }
454
-
455
- // ── particles ──────────────────────────────────────────────────────────
456
- const PARTICLES = { snow: 380, rain: 520, fireflies: 45, dust: 140, petals: 90, embers: 90, leaves: 60, bubbles: 60 };
457
- function makeParticles(p, W, H, rnd) {
458
- const n = Math.round((PARTICLES[p.kind] ?? (() => { throw new Error(`unknown particle ${p.kind}`); })()) * clamp(p.density ?? 0.5, 0, 1) * (W / H) / (16 / 9)); // same count at any resolution
459
- return Array.from({ length: n }, () => ({ x: rnd() * W, y: rnd() * H, z: rnd(), ph: rnd() * 7, life: rnd() }));
460
- }
461
- // Particle motion (shared by the 2D and WebGL renderers). Positions are screen pixels.
462
- function moveParticle(kind, q, W, H, t, dt, u, wind) {
463
- switch (kind) {
464
- case 'rain':
465
- q.y += (700 + q.z * 500) * u * dt; q.x += wind * 3 * dt;
466
- if (q.y > H) { q.y = -20 * u; q.x = Math.random() * W; }
467
- return;
468
- case 'snow': q.y += (25 + q.z * 60) * u * dt; q.x += (wind + Math.sin(t * 0.8 + q.ph) * 12 * u) * dt; break;
469
- case 'dust': q.y += Math.sin(t * 0.3 + q.ph) * 6 * u * dt; q.x += (wind * 0.3 + 5 * u) * dt; break;
470
- case 'petals': case 'leaves': q.y += (30 + q.z * 40) * u * dt; q.x += (wind + Math.sin(t + q.ph) * 30 * u) * dt; break;
471
- case 'fireflies': q.x += Math.sin(t * 0.4 + q.ph) * 14 * u * dt; q.y += Math.cos(t * 0.33 + q.ph * 2) * 10 * u * dt; break;
472
- case 'embers': q.y -= (40 + q.z * 60) * u * dt; q.x += (wind * 0.5 + Math.sin(t * 2 + q.ph) * 15 * u) * dt; break;
473
- case 'bubbles': q.y -= (20 + q.z * 40) * u * dt; q.x += Math.sin(t * 1.5 + q.ph) * 10 * u * dt; break;
474
- default: throw new Error(`unknown particle ${kind}`);
475
- }
476
- if (q.y > H + 20 * u) { q.y = -20 * u; q.x = Math.random() * W; }
477
- if (q.y < -20 * u) { q.y = H + 20 * u; q.x = Math.random() * W; }
478
- if (q.x > W + 20 * u) q.x -= W + 40 * u; else if (q.x < -20 * u) q.x += W + 40 * u;
479
- }
480
-
481
- function stepParticles(ctx, p, list, W, H, t, dt, u) {
482
- const col = rgb(p.color || '#ffffff'), wind = (p.wind ?? 0) * 60 * u;
483
- ctx.save();
484
- if (p.kind === 'rain') {
485
- ctx.strokeStyle = `rgba(${col},0.28)`; ctx.lineWidth = Math.max(1, u);
486
- ctx.beginPath();
487
- for (const q of list) {
488
- moveParticle('rain', q, W, H, t, dt, u, wind);
489
- const len = (10 + q.z * 16) * u;
490
- ctx.moveTo(q.x, q.y); ctx.lineTo(q.x + wind * 0.02, q.y + len);
491
- }
492
- ctx.stroke();
493
- ctx.restore();
494
- return;
495
- }
496
- if (p.kind === 'fireflies' || p.kind === 'embers') ctx.globalCompositeOperation = 'lighter';
497
- for (const q of list) {
498
- moveParticle(p.kind, q, W, H, t, dt, u, wind);
499
- const r = (1 + q.z * 3) * u;
500
- if (p.kind === 'fireflies') {
501
- const a = 0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * 1.6 + q.ph * 3), 3);
502
- glow(ctx, q.x, q.y, 18 * u, col, a * 0.6);
503
- } else if (p.kind === 'embers') {
504
- glow(ctx, q.x, q.y, 8 * u, col, 0.6 * (0.5 + 0.5 * Math.sin(t * 5 + q.ph)));
505
- } else if (p.kind === 'petals' || p.kind === 'leaves') {
506
- ctx.fillStyle = `rgba(${col},0.85)`;
507
- ctx.save(); ctx.translate(q.x, q.y); ctx.rotate(t * (0.5 + q.z) + q.ph); ctx.scale(1, Math.abs(Math.sin(t * 2 + q.ph)) * 0.8 + 0.2);
508
- ctx.beginPath(); ctx.ellipse(0, 0, r * (p.kind === 'leaves' ? 2.4 : 1.6), r, 0, 0, 7); ctx.fill(); ctx.restore();
509
- } else if (p.kind === 'bubbles') {
510
- ctx.strokeStyle = `rgba(${col},0.5)`; ctx.lineWidth = Math.max(1, u * 0.8);
511
- ctx.beginPath(); ctx.arc(q.x, q.y, r * 1.5, 0, 7); ctx.stroke();
512
- } else {
513
- ctx.fillStyle = `rgba(${col},${p.kind === 'dust' ? 0.35 : 0.3 + q.z * 0.55})`;
514
- ctx.beginPath(); ctx.arc(q.x, q.y, p.kind === 'dust' ? r * 0.5 : r, 0, 7); ctx.fill();
515
- }
516
- }
517
- ctx.restore();
518
- }
519
-
520
- // ── camera ─────────────────────────────────────────────────────────────
521
- // A multiplane camera, the way 2.5D games do it: every layer and object is a flat
522
- // cut-out standing at a real distance Z, seen through a perspective camera. Distances
523
- // come from the picture itself: the eye is 1 unit above flat ground and the horizon is
524
- // at screen height yh, so ground painted h (screen heights) below the horizon is 1/h
525
- // away. Sky, sun, moon and stars are at infinity: walking never moves them.
526
- // cam = { x: steps sideways, z: steps forward (world units, eye heights), y: gaze — down (+) / up (−) }.
527
- const Z_NEAR = 0.6, Z_FAR = 150; // in eye heights (1.7 m)
528
- const LAND = new Set(['mountains', 'hills', 'dunes', 'forest', 'city', 'water', 'ground']);
529
- const depthAt = (y, yh) => (y > yh + 0.0125 ? clamp(1 / (y - yh), Z_NEAR, Z_FAR) : Z_FAR);
530
- // The horizon is the base of the farthest landscape layer. Clouds and fog stand just
531
- // behind the next nearer landscape layer (or in front of the last one).
532
- // Typical heights (m) of what the scene may hold, and the viewer's eye height.
533
- const EYE = 1.7;
534
- const HEIGHT = { pine: 15, tree: 8, house: 6, tower: 18, lamp: 4, lantern: 2.2, campfire: 0.6, boat: 4, rock: 1, bench: 0.9, figure: 1.75, cat: 0.3, flower: 0.35, bridge: 3 };
535
- // The horizon is at eye level, so it crosses everything standing on the ground at 1.7 m:
536
- // a person at the eyes, a house low on its wall, a bench not at all. Each object painted
537
- // `size` tall at `y` suggests a horizon at y − size·EYE/height; the median of those is
538
- // the eye level of the scene (without objects: the base of the farthest landscape layer).
539
- function horizonOf(spec, host) {
540
- // (lanterns often hang in the air: they don't vote)
541
- const guesses = (spec.objects || []).filter(o => HEIGHT[o.kind] && o.kind !== 'lantern' && !host(o) && o.size > 0)
542
- .map(o => o.y - o.size * EYE / (o.kind === 'figure' && o.pose === 'sit' ? 1.2 : HEIGHT[o.kind]))
543
- .sort((a, b) => a - b);
544
- if (guesses.length) return clamp(guesses[guesses.length >> 1], 0.2, 0.95);
545
- const land = (spec.layers || []).filter(L => LAND.has(L.kind));
546
- return land.length ? Math.min(...land.map(L => L.y)) : 0.6;
547
- }
548
- function depths(spec) {
549
- const layers = spec.layers || [];
550
- // An object stands where its base is — unless it is attached to a bigger one (a window on
551
- // a wall, a lantern on a porch): its base inside the other's outline. Then it shares that depth.
552
- const objs = spec.objects || [];
553
- const host = o => objs.find(h => h !== o && h.kind !== 'bird' && h.size > o.size &&
554
- Math.abs(o.x - h.x) * 16 / 9 < h.size * 0.6 && o.y > h.y - h.size && o.y < h.y + 0.01);
555
- // the director may set the eye level explicitly (spec.horizon); else it is estimated
556
- const yh = typeof spec.horizon === 'number' ? clamp(spec.horizon, 0.05, 0.95) : horizonOf(spec, host);
557
- const Z = layers.map(L => (LAND.has(L.kind) ? depthAt(L.y, yh) : null));
558
- for (let i = 0; i < Z.length; i++) {
559
- if (Z[i] !== null) continue;
560
- const next = Z.slice(i + 1).find(z => z !== null), prev = Z.slice(0, i).reverse().find(z => z !== null);
561
- Z[i] = next !== undefined ? next * 1.05 : prev !== undefined ? prev * 0.95 : Z_FAR;
562
- }
563
- // How far an object is: from how big it looks for what it is (a house painted 16% of
564
- // the screen tall is ~22 eye heights away) — robust, unlike the height of its base above
565
- // the horizon, which blows up for things painted near the horizon line.
566
- // And so everything agrees on the eye level, each ground-standing object's base is nudged
567
- // (usually 1–3% of the screen) to where, at that distance, it meets the ground:
568
- // yh + size·EYE/height. Attached things move with their host; hanging lanterns stay.
569
- const realH = o => (o.kind === 'figure' && o.pose === 'sit' ? 1.2 : HEIGHT[o.kind]);
570
- const own = o => (realH(o) && o.size > 0 ? clamp(realH(o) / EYE / o.size, Z_NEAR, Z_FAR) : depthAt(o.y, yh));
571
- const shift = o => {
572
- const h = host(o);
573
- if (h) return shift(h);
574
- return realH(o) && o.size > 0 && o.kind !== 'lantern' ? yh + o.size * EYE / realH(o) - o.y : 0;
575
- };
576
- // Custom elements say where they are: `distance` (m) or `y` (where they touch the ground),
577
- // plus `elevation` (m above the ground) and `height` (m); size on screen follows. Beyond Z_FAR
578
- // an element is laid at Z_FAR (it barely moves as you walk anyway) but sized and placed on
579
- // screen for its real distance — a range 8 km away looks 8 km away, not 30 times bigger.
580
- const customZ = o => (o.distance > 0 ? clamp(o.distance / EYE, Z_NEAR, Z_FAR) : depthAt(o.y, yh));
581
- const sightZ = o => (o.distance > 0 ? Math.max(Z_NEAR, o.distance / EYE) : depthAt(o.y, yh));
582
- const custom = o => o.kind === 'custom' || !o.kind;
583
- // across the view: `x` — where it is seen from the start, a fraction of the stage's width; or
584
- // `across` — metres to the right of the start's line of sight (left: negative); or, looking
585
- // around (around, roam), `bearing` — degrees from straight ahead (right: positive), with its
586
- // `distance`: such an element is painted as if straight ahead, and stood where it is
587
- const planeZ = o => (o.distance > 0 ? o.distance : 10) / EYE;
588
- const bearing = o => typeof o.bearing === 'number' && o.distance > 0 && !o.sky;
589
- const paintZ = o => (o.plane ? planeZ(o) : custom(o) ? sightZ(o) : own(host(o) || o));
590
- const objectX = o => (bearing(o) ? 0.5 : typeof o.across === 'number' && !o.sky ? 0.5 + o.across / EYE / STAGE / paintZ(o) : o.x ?? 0.5);
591
- const objectY = o => (o.sky || o.ground ? 0 : custom(o) ? yh + (1 - (o.elevation || 0) / EYE) / sightZ(o) : o.kind === 'bird' ? o.y : o.y + shift(o));
592
- return {
593
- yh, Z, objectX,
594
- // where it stands in the world (eye heights: X to the right, Z ahead, Y its anchor's
595
- // height) and the distance Zp it is painted for
596
- objectAt: o => {
597
- const Zp = paintZ(o), Y = 1 - (objectY(o) - yh) * Zp;
598
- if (!bearing(o)) return { X: (objectX(o) - 0.5) * STAGE * Zp, Y, Z: Zp, Zp };
599
- const b = o.bearing * Math.PI / 180, d = o.distance / EYE;
600
- return { X: d * Math.sin(b), Y, Z: d * Math.cos(b), Zp };
601
- },
602
- // ground planes are drawn right after the sky, under everything standing on them
603
- // a horizontal plane (gl.js) is ordered by its far edge, before what stands on it
604
- objectZ: o => (o.sky ? Infinity : o.ground ? Z_FAR * 4 : o.plane ? clamp(planeZ(o) + (o.depth || 4) / 2 / EYE, Z_NEAR, Z_FAR) : custom(o) ? customZ(o) : o.kind === 'bird' ? 30 : own(host(o) || o)),
605
- objectY,
606
- objectSize: o => (o.sky || o.ground ? 1 : custom(o) ? (o.height || 2) / EYE / sightZ(o) : o.size),
607
- };
608
- }
609
- // Screen transform [a b c d e f] for a plane at distance Z (Infinity = the sky): maps the
610
- // scene as painted from the starting spot (on the stage) to the frame (W × H) from where the
611
- // camera is now.
612
- // Walking forward grows a plane by Z/(Z−z) around the vanishing point on the horizon.
613
- // A camera raised to cam.h eye heights (1 = standing) sees a plane (h − 1)/D lower: things
614
- // stay upright, the ground opens up below — a raised camera with a shifted, not tilted, lens.
615
- function view(cam, Z, W, H, yh) {
616
- if (!isFinite(Z)) return [1, 0, 0, 1, (W - STAGE * H) / 2, -cam.y * H];
617
- const D = Math.max(1e-3, Z - cam.z), s = Z / D;
618
- return [s, 0, 0, s, (W - s * STAGE * H) / 2 - H * cam.x / D, yh * H * (1 - s) - cam.y * H + H * ((cam.h ?? 1) - 1) / D];
619
- }
620
- // Looking around (around, roam): the camera also turns, cam.yaw radians (clockwise, 0 = the
621
- // start's straight ahead). A point of the world (X, Y, Z eye heights) is seen at
622
- // [x, y, its depth along the gaze], or null behind the eye. With yaw 0 it is what view() does.
623
- function project(cam, X, Y, Z, W, H, yh) {
624
- const rx = X - cam.x, rz = Z - cam.z, c = Math.cos(cam.yaw || 0), s = Math.sin(cam.yaw || 0);
625
- const x = rx * c - rz * s, z = rx * s + rz * c;
626
- if (z < 0.05) return null;
627
- return [W / 2 + H * x / z, yh * H + H * ((cam.h ?? 1) - Y) / z - cam.y * H, z];
628
- }
629
- // An element as a card facing the eye: p = { X, Y, Z, Zp } and its anchor as painted (ax, ay):
630
- // { m: screen transform of its painting, D: its depth }, or null when behind the eye
631
- function billboard(cam, p, ax, ay, W, H, yh) {
632
- const q = project(cam, p.X, p.Y, p.Z, W, H, yh);
633
- if (!q) return null;
634
- const s = p.Zp / q[2];
635
- return { m: [s, 0, 0, s, q[0] - s * ax, q[1] - s * ay], D: q[2] };
636
- }
637
- // Where an element's painting (as depths() places it on the SW-wide stage) goes on the frame from
638
- // camera cam: { m: the transform, D: its distance along the gaze }, or null behind the eye
639
- function placeOf(o, cam, W, H, dz, round) {
640
- if (!round) { const Z = dz.objectZ(o); return { m: view(cam, Z, W, H, dz.yh), D: Z - cam.z }; }
641
- return billboard(cam, dz.objectAt(o), dz.objectX(o) * STAGE * H, dz.objectY(o) * H, W, H, dz.yh);
642
- }
643
- // The hero of a platformer h = { dx, dy (metres from where it is painted), flip } as a transform
644
- // of its painting: its anchor painted at x = ax, `ppm` painted px per metre
645
- const heroMatrix = (h, ax, ppm) => [h.flip ? -1 : 1, 0, 0, 1, (h.flip ? 2 * ax : 0) + h.dx * ppm, -h.dy * ppm];
646
- // The full circle of the sky looking around: painted 2π stage heights wide, straight ahead in
647
- // its middle; seen through the frame as a cylinder.
648
- const TAU = 2 * Math.PI;
649
- // Where you may walk and look (limitCam). Walking forward: as far as the things standing in the
650
- // scene (not the far ones laid at Z_FAR — painted mountains, flat up close).
651
- // An object painted at stage x, Z away, stands at world x = (x − ½)·STAGE·Z (+ its box centre).
652
- const LOOK_UP = -0.3, LOOK_DOWN = 0.2, WALK_BACK = -0.6, TRACK = 40 / EYE, ROOM = 30 / EYE;
653
- // The scene's camera (spec.camera) — how the viewer moves through it, one of:
654
- // picture — a still picture: the camera stands, breathes a little and leans toward the
655
- // pointer (or the phone's tilt), so the near things shift against the far ones;
656
- // walk — stand at eye level and walk: forward and back, sideways, look up and down (default);
657
- // panorama — a long place passed along sideways (a park alley, a street, a shore): along it
658
- // as far as `span` [left, right] metres from the start, else as far as the things within
659
- // TRACK of the viewer reach; the wheel comes closer as when walking, and closer you still
660
- // pass along at that depth;
661
- // overhead — raised `height` metres and looking down (`look`, screen heights): a map, a yard,
662
- // a board; moving over it and closer;
663
- // around — stand at one spot and turn your head all around (or within `turn` degrees either
664
- // way) and up and down: things stand all around you (`bearing`);
665
- // roam — walk about a place and look anywhere, as in a room: within `area` [left, right,
666
- // back, front] metres of the start, else among the things standing around;
667
- // platform — a platformer seen from the side: you run and jump as the `hero` element over
668
- // the `solid` ones, the camera riding along (the mount's platform section).
669
- // Looking around, elements are cards standing up in the world, facing the eye — or, with
670
- // `facing` (degrees: where its face looks), turned their own way, like a wall or a cupboard.
671
- // `follow`: elements the camera keeps in view — it slides sideways when one is leaving the
672
- // visible part of the screen (not while the viewer moves it themselves).
673
- const MODES = ['picture', 'walk', 'panorama', 'overhead', 'around', 'roam', 'platform'];
674
- const SWAY = { picture: 0.033 };
675
- function camSetup(spec) {
676
- const c = spec?.camera || {}, mode = MODES.includes(c.mode) ? c.mode : 'walk';
677
- const h = mode === 'overhead' ? clamp((c.height ?? 8) / EYE, 1, 60) : 1;
678
- const look = clamp(c.look ?? (mode === 'overhead' ? 0.25 : 0), -0.3, 0.45);
679
- const follow = Array.isArray(c.follow) ? c.follow.filter(id => typeof id === 'string') : [];
680
- const span = mode === 'panorama' && Array.isArray(c.span) && c.span.length === 2 ? [-Math.abs(c.span[0]) / EYE, Math.abs(c.span[1]) / EYE] : null;
681
- const round = mode === 'around' || mode === 'roam';
682
- const turn = mode === 'around' && typeof c.turn === 'number' && c.turn > 0 && c.turn < 180 ? c.turn * Math.PI / 180 : null;
683
- const area = ['roam', 'walk', 'overhead'].includes(mode) && Array.isArray(c.area) && c.area.length === 4 ? c.area.map(v => Math.abs(v) / EYE) : null;
684
- const hero = mode === 'platform' && typeof c.hero === 'string' ? c.hero : null;
685
- return { mode, h, look, follow: round || hero ? [] : follow, sway: SWAY[mode] || 0, span, round, turn, area, hero, jump: c.jump };
686
- }
687
- function limits(spec, W, H) {
688
- const { Z, objectZ, objectX } = depths(spec), aspect = W / H;
689
- const land = (spec.layers || []).map((L, i) => (LAND.has(L.kind) ? Z[i] : null)).filter(z => z !== null);
690
- const things = (spec.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane).map(o => {
691
- const z = objectZ(o), b = Array.isArray(o.box) && o.box.length === 4 ? (o.box[0] + o.box[2]) / 2 / EYE : 0;
692
- return { x: (objectX(o) - 0.5) * STAGE * z + b, z };
693
- });
694
- // what stands in the scene shapes where you may go — not what is laid at Z_FAR
695
- const real = things.filter(t => t.z < Z_FAR * 0.99), around = real.length ? real : things;
696
- const far = around.length ? Math.max(...around.map(t => t.z)) : Math.max(1, ...land.filter(z => z < Z_FAR * 0.9));
697
- const setup = camSetup(spec);
698
- // roaming: within the area, else among the things standing within ROOM of the start, a
699
- // little inside them
700
- let room = null;
701
- if (setup.mode === 'roam') {
702
- if (setup.area) { const [l, r, b, f] = setup.area; room = [-l, r, -b, f]; }
703
- else {
704
- const dz = depths(spec), at = (spec.objects || []).filter(o => !o.sky && !o.ground && o.kind !== 'bird').map(o => dz.objectAt(o)).filter(p => Math.hypot(p.X, p.Z) <= ROOM);
705
- const xs = [0, ...at.map(p => p.X)], zs = [0, ...at.map(p => p.Z)], k = 1 / EYE;
706
- room = [Math.min(0, Math.min(...xs) + k), Math.max(0, Math.max(...xs) - k), Math.min(0, Math.min(...zs) + k), Math.max(0, Math.max(...zs) - k)];
707
- }
708
- }
709
- return { aspect, things: around, zMax: clamp(far - 1.1, 1, 100), setup, room };
710
- }
711
- function limitCam(cam, lim) {
712
- const s = lim.setup;
713
- if (s?.round) {
714
- cam.y = clamp(cam.y, s.look - 0.6, s.look + 0.6);
715
- if (s.turn) cam.yaw = clamp(cam.yaw, -s.turn, s.turn);
716
- if (lim.room) { cam.x = clamp(cam.x, lim.room[0], lim.room[1]); cam.z = clamp(cam.z, lim.room[2], lim.room[3]); }
717
- else { cam.x = 0; cam.z = 0; }
718
- return;
719
- }
720
- if (s?.sway) { // stays put: only the small sway
721
- cam.x = clamp(cam.x, -s.sway, s.sway); cam.z = 0; cam.y = clamp(cam.y, s.look - s.sway / 3, s.look + s.sway / 3);
722
- return;
723
- }
724
- if (s?.mode === 'platform') { // the camera rides along with the hero (the mount), over the level
725
- cam.z = 0;
726
- cam.y = clamp(cam.y, s.look - 0.8, s.look + 0.15);
727
- if (lim.level) cam.x = clamp(cam.x, lim.level[0], lim.level[1]);
728
- return;
729
- }
730
- if (s?.mode === 'panorama') {
731
- cam.z = clamp(cam.z, 0, Math.max(lim.zMax, lim.reach ?? -Infinity));
732
- cam.y = clamp(cam.y, s.look + LOOK_UP / 2, s.look + LOOK_DOWN / 2);
733
- let lo = 0, hi = 0;
734
- if (s.span) [lo, hi] = s.span;
735
- else {
736
- // as far as the nearest things reach: the outermost one may come to the frame's middle
737
- const near = lim.things.filter(t => t.z <= TRACK);
738
- if (near.length) { lo = Math.min(0, ...near.map(t => t.x)); hi = Math.max(0, ...near.map(t => t.x)); }
739
- }
740
- cam.x = clamp(cam.x, lo, hi);
741
- return;
742
- }
743
- cam.y = clamp(cam.y, (s?.look ?? 0) + LOOK_UP, (s?.look ?? 0) + LOOK_DOWN);
744
- // Where you may stand: what is seen from the back of the bounds — a triangle as wide as the
745
- // frame (a camera inside it sees only what lies inside it too, so walking toward anything you
746
- // see never leaves it), within the scene's area when it gives one; forward as far as the
747
- // things in the scene, or as far as the wheel took you toward one. Walking, the start is the
748
- // back: the farthest you can draw back, and no stepping aside there — only coming closer.
749
- let zb = s?.mode === 'walk' ? 0 : WALK_BACK, zf = Math.max(lim.zMax, lim.reach ?? -Infinity), lo = -Infinity, hi = Infinity;
750
- if (s?.area) { const [l, r, b, f] = s.area; zb = -b; zf = f; lo = -l; hi = r; }
751
- cam.z = clamp(cam.z, zb, zf);
752
- const hw = Math.max(0, cam.z - zb) * lim.aspect / 2;
753
- cam.x = clamp(cam.x, Math.max(lo, -hw), Math.min(hi, hw));
754
- }
755
- // near things fade out as you walk up to and past them
756
- const fadeNear = (Z, cam) => clamp((Z - cam.z - 0.3) / 0.6, 0, 1);
757
-
758
- function place(ctx, S, cam, Z) { ctx.transform(...view(cam, Z, S.W, S.H, S.yh)); }
759
-
760
- // ── scene build + frame ────────────────────────────────────────────────
761
- // W × H: the frame; everything is painted on the stage, SW × H
762
- function build(spec, W, H) {
763
- const u = H / 900, seed = hash(JSON.stringify(spec.layers || []) + (spec.title || '')), rnd = seeded(seed);
764
- const SW = STAGE * H, x0 = -EXT * SW, span = (1 + 2 * EXT) * SW;
765
- const dz = depths(spec);
766
- const starCount = Math.round((spec.stars?.density ?? 0) * span * H / 2500 / (u * u));
767
- const stars = Array.from({ length: starCount }, () => ({ x: x0 + rnd() * span, y: -0.45 * H + Math.pow(rnd(), 1.4) * H * 1.15, r: (0.4 + Math.pow(rnd(), 4) * 1.6) * u, p: rnd() * 7, f: 0.4 + rnd() * 2 }));
768
- const layers = (spec.layers || []).map((L, i) => {
769
- const Z = dz.Z[i];
770
- if (L.kind === 'clouds') {
771
- const clouds = Array.from({ length: Math.round((3 + (L.density ?? 0.5) * 9) * (1 + 2 * EXT)) }, () => {
772
- const cw = SW * (0.12 + rnd() * 0.2), ch = cw * 0.35, c = layer(cw, ch), cg = c.getContext('2d');
773
- for (let k = 0; k < 7; k++) glow(cg, cw * (0.2 + rnd() * 0.6), ch * (0.45 + rnd() * 0.25), ch * (0.35 + rnd() * 0.3), rgb(L.color), 0.55);
774
- return { c, x: rnd() * span, y: L.y * H + (rnd() - 0.5) * (L.height ?? 0.1) * H, sp: (4 + rnd() * 8) * u };
775
- });
776
- return { L, Z, clouds };
777
- }
778
- if (L.kind === 'fog') return { L, Z, blobs: Array.from({ length: 10 }, () => ({ x: rnd() * span, dy: (rnd() - 0.5) * (L.height ?? 0.08) * H, rx: SW * (0.2 + rnd() * 0.25), ry: H * (0.03 + rnd() * 0.04), sp: (5 + rnd() * 10) * u, a: 0.06 + rnd() * 0.06 })) };
779
- if (L.kind === 'water') return { L, Z };
780
- return { L, Z, ...staticLayer(L, SW, H, seed + i * 101) };
781
- }).sort((a, b) => b.Z - a.Z);
782
- // looking around, each also stands somewhere in the world (p, depths().objectAt)
783
- const round = camSetup(spec).round;
784
- const objects = (spec.objects || []).map(o => ({ o: { ...o, x: dz.objectX(o), y: dz.objectY(o), size: dz.objectSize(o), _yh: dz.yh }, Z: dz.objectZ(o), p: round && !o.sky && !o.ground ? dz.objectAt(o) : null })).sort((a, b) => (b.Z - a.Z) || 0);
785
- const sky = !spec.sky ? '#000000' : (() => {
786
- const c = document.createElement('canvas').getContext('2d');
787
- const g = c.createLinearGradient(0, 0, 0, H);
788
- g.addColorStop(0, spec.sky.top); g.addColorStop(0.55, spec.sky.middle); g.addColorStop(1, spec.sky.bottom);
789
- return g;
790
- })();
791
- const particles = (spec.particles || []).map(p => ({ p, list: makeParticles(p, W, H, rnd) }));
792
- // vignette depends only on the scene, not the frame: paint it once
793
- const vignette = layer(W, H), vg = vignette.getContext('2d');
794
- const v = vg.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.hypot(W, H) * 0.6);
795
- v.addColorStop(0, 'rgba(0,0,0,0)');
796
- v.addColorStop(1, `rgba(0,0,0,${0.75 * clamp(spec.light?.vignette ?? 0.5, 0, 1)})`);
797
- vg.fillStyle = v; vg.fillRect(0, 0, W, H);
798
- return { W, H, SW, u, x0, span, yh: dz.yh, round, lim: limits(spec, W, H), sky, stars, layers, particles, objects, vignette, scratch: layer(W, H) };
799
- }
800
-
801
- // Looking around, with Canvas 2D (the fallback): the sky turned as a flat band (not bent into a
802
- // cylinder), then the cards facing the eye, far to near. Cards turned their own way (`facing`),
803
- // like planes, are drawn by the WebGL renderer only.
804
- function aroundFrame(ctx, S, cam, t) {
805
- const { W, H, SW } = S, full = TAU * H, off = W / 2 - full / 2 - cam.yaw * H;
806
- for (const { o } of S.objects) {
807
- if (!o.sky) continue;
808
- for (let x0 = ((off % full) + full) % full - full; x0 < W; x0 += full) {
809
- const lights = [];
810
- ctx.save(); ctx.translate(x0, -cam.y * H);
811
- drawObject(ctx, o, full, H, t, lights); drawLights(ctx, lights);
812
- ctx.restore();
813
- }
814
- }
815
- const seen = [];
816
- for (const { o, p } of S.objects) {
817
- if (o.sky || o.ground || o.plane || typeof o.facing === 'number') continue;
818
- const b = billboard(cam, p, o.x * SW, o.y * H, W, H, S.yh);
819
- if (b) seen.push({ o, b });
820
- }
821
- seen.sort((a, b) => b.b.D - a.b.D);
822
- for (const { o, b } of seen) {
823
- const fade = clamp((b.D - 0.3) / 0.6, 0, 1), lights = [];
824
- if (fade <= 0) continue;
825
- ctx.save(); ctx.transform(...b.m); ctx.globalAlpha = fade;
826
- drawObject(ctx, o, SW, H, t, lights); drawLights(ctx, lights);
827
- ctx.restore();
828
- }
829
- }
830
-
831
- function drawWater(ctx, S, L, wy, t) {
832
- const { W, H, u } = S, depth = H - wy, step = Math.max(3, Math.round(u * 3));
833
- if (depth <= 0) return;
834
- const k = Math.min(1.2, wy / depth), speed = 1 + Math.abs(L.drift ?? 0) * 2;
835
- // copy only the band above the horizon that the reflection actually shows
836
- const band = Math.max(0, Math.floor(wy - depth * k - step)), sc = S.scratch.getContext('2d');
837
- sc.clearRect(0, band, W, wy - band);
838
- sc.drawImage(ctx.canvas, S.out.x, S.out.y + band, W, wy - band, 0, band, W, wy - band);
839
- for (let y = wy; y < H; y += step) {
840
- const d = (y - wy) / depth;
841
- const dx = (Math.sin(30 * Math.log(0.03 + d) - t * 1.4 * speed) * 0.6 + Math.sin(d * 53 + t * 0.8) * 0.4) * (1 + d * 16) * u;
842
- ctx.drawImage(S.scratch, 0, Math.max(0, wy - 1 - (y - wy) * k), W, step, dx - 20 * u, y, W + 40 * u, step);
843
- }
844
- const col = rgb(L.color), gr = ctx.createLinearGradient(0, wy, 0, H);
845
- gr.addColorStop(0, `rgba(${col},0.35)`); gr.addColorStop(1, `rgba(${col},0.8)`);
846
- ctx.fillStyle = gr; ctx.fillRect(0, wy, W, depth);
847
- ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.fillRect(0, wy - u, W, 2 * u);
848
- }
849
-
850
- const wrapX = (x, S, w) => ((x % (S.span + w)) + S.span + w) % (S.span + w) + S.x0 - w;
851
-
852
- // S.out: where the frame lies in the canvas (pixels); the scene goes on around it as painted
853
- function frame(ctx, S, spec, cam, t, dt) {
854
- const { W, H, SW, u } = S, out = S.out;
855
- ctx.setTransform(1, 0, 0, 1, 0, 0);
856
- ctx.fillStyle = '#000000';
857
- ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
858
- ctx.translate(out.x, out.y);
859
- ctx.save(); place(ctx, S, cam, Infinity);
860
- ctx.fillStyle = S.sky;
861
- ctx.fillRect(S.x0, -H, S.span, 3 * H);
862
- if (S.stars.length) {
863
- ctx.fillStyle = spec.stars?.color || '#ffffff';
864
- for (const s of S.stars) {
865
- ctx.globalAlpha = 0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * s.f + s.p), 2);
866
- ctx.fillRect(s.x - s.r, s.y - s.r, s.r * 2, s.r * 2);
867
- }
868
- ctx.globalAlpha = 1;
869
- }
870
- for (const c of spec.celestials || []) {
871
- const cx = c.x * SW, cy = c.y * H, r = c.size * H / 2, col = rgb(c.color);
872
- glow(ctx, cx, cy, r * 9, col, 0.35 * (c.glow ?? 0.5));
873
- ctx.fillStyle = c.color; ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.fill();
874
- if (c.kind === 'moon') { ctx.fillStyle = 'rgba(0,0,0,0.07)'; for (const [dx, dy, rr] of [[-0.3, -0.2, 0.25], [0.28, 0.15, 0.17], [-0.05, 0.4, 0.13]]) { ctx.beginPath(); ctx.arc(cx + dx * r, cy + dy * r, rr * r, 0, 7); ctx.fill(); } }
875
- if (c.kind === 'planet') { ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = r * 0.08; ctx.beginPath(); ctx.ellipse(cx, cy, r * 1.8, r * 0.4, -0.3, 0, 7); ctx.stroke(); }
876
- }
877
- ctx.restore();
878
-
879
- for (const l of S.round ? [] : S.layers) {
880
- const L = l.L;
881
- if (l.Z - cam.z < 0.05) continue;
882
- if (L.kind === 'water') { const m = view(cam, l.Z, W, H, S.yh); drawWater(ctx, S, L, Math.round(m[5] + m[3] * L.y * H), t); continue; }
883
- ctx.save(); place(ctx, S, cam, l.Z);
884
- if (l.canvas) {
885
- ctx.drawImage(l.canvas, l.x0, 0);
886
- ctx.fillStyle = L.color; ctx.fillRect(l.x0, l.canvas.height - 1, l.canvas.width, 50 * H); // the body goes on below
887
- if (l.windows?.length) {
888
- const lc = rgb('#ffd08a');
889
- for (const w of l.windows) {
890
- if (Math.sin(t * 0.07 + w.p) > -0.85) { ctx.fillStyle = `rgba(${lc},${0.55 + 0.25 * Math.sin(w.p)})`; ctx.fillRect(w.x, w.y, w.w, w.h); }
891
- }
892
- }
893
- } else if (L.kind === 'clouds') {
894
- for (const c of l.clouds) ctx.drawImage(c.c, wrapX(c.x + t * c.sp * (L.drift ?? 0.3) * 3, S, c.c.width), c.y - c.c.height / 2);
895
- } else if (L.kind === 'fog') {
896
- const col = rgb(L.color);
897
- for (const b of l.blobs) {
898
- ctx.save(); ctx.translate(wrapX(b.x + t * b.sp * (L.drift ?? 0.3) * 2, S, 2 * b.rx) + b.rx, L.y * H + b.dy);
899
- ctx.scale(1, b.ry / b.rx); glow(ctx, 0, 0, b.rx, col, b.a * 2.2); ctx.restore();
900
- }
901
- }
902
- ctx.restore();
903
- }
904
-
905
- if (S.round) aroundFrame(ctx, S, cam, t);
906
- else for (const { o, Z } of S.objects) {
907
- if (o.plane) continue; // horizontal planes are drawn by the WebGL renderer only
908
- const lights = [], fade = fadeNear(Z, cam);
909
- if (fade <= 0) continue;
910
- ctx.save(); place(ctx, S, cam, Z); ctx.globalAlpha = fade;
911
- if (cam.hero && o.id === cam.hero.id) ctx.transform(...heroMatrix(cam.hero, o.x * SW, o.size * H / (o.height || 2)));
912
- drawObject(ctx, o, SW, H, t, lights);
913
- drawLights(ctx, lights);
914
- ctx.restore();
915
- }
916
-
917
- for (const { p, list } of S.particles) stepParticles(ctx, p, list, W, H, t, dt, u);
918
- const lt = spec.light || {};
919
- if (lt.tint && lt.tintStrength) {
920
- ctx.save(); ctx.globalAlpha = clamp(lt.tintStrength, 0, 0.5) * 0.45;
921
- ctx.fillStyle = lt.tint; ctx.fillRect(0, 0, W, H); ctx.restore();
922
- }
923
- ctx.drawImage(S.vignette, 0, 0);
924
- if ((lt.grain ?? 0.5) > 0) {
925
- ctx.save(); ctx.globalAlpha = clamp(lt.grain ?? 0.5, 0, 1);
926
- ctx.translate(Math.random() * 160, Math.random() * 160); ctx.fillStyle = S.grain; ctx.fillRect(-160, -160, W + 320, H + 320); ctx.restore();
927
- }
928
- // out of the frame the scene dims toward the canvas's edges (with Canvas 2D, what nothing
929
- // paints stays black, and water, particles and grain keep to the frame)
930
- for (const [x0, y0, x1, y1, rx, ry, rw, rh] of [[0, 0, -H / 2, 0, -out.x, -out.y, out.x, out.ch], [W, 0, W + H / 2, 0, W, -out.y, out.cw - out.x - W, out.ch],
931
- [0, 0, 0, -H / 2, -out.x, -out.y, out.cw, out.y], [0, H, 0, H * 1.5, -out.x, H, out.cw, out.ch - out.y - H]]) {
932
- if (rw <= 0 || rh <= 0) continue;
933
- const g = ctx.createLinearGradient(x0, y0, x1, y1);
934
- g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.45)');
935
- ctx.fillStyle = g; ctx.fillRect(rx, ry, rw, rh);
936
- }
937
- }
938
-
939
- // Mounts the engine on a canvas: returns { show(spec), resetCamera() }.
940
- // Camera: drag = step sideways / turn the head up-down, wheel / pinch = walk toward the cursor,
941
- // keys: A D ← → = sideways, W S ↑ ↓ = forward-back, 0 = back to the start.
942
- // The 2D renderer: everything re-drawn with Canvas 2D every frame. Fallback for
943
- // browsers without WebGL2; the WebGL renderer (gl.js) is much lighter on the GPU.
944
- function canvas2DRenderer(canvas) {
945
- // opaque canvas: the scene paints every pixel, so the browser needn't blend it with the page
946
- const ctx = canvas.getContext('2d', { alpha: false });
947
- const grain = ctx.createPattern(grainTile, 'repeat');
948
- return {
949
- name: '2d', fpsIdle: 20, pixelBudget: 1.3e6, resMax: 1.5,
950
- build(spec, W, H, prev, out) { const S = build(spec, W, H); S.grain = grain; S.out = out; return S; },
951
- frame(S, spec, cam, t, dt) { frame(ctx, S, spec, cam, t, dt); },
952
- };
953
- }
954
-
955
- function mount(canvas) {
956
- // a canvas gets one context type for life: WebGL2 when available, else 2D
957
- // (?2d in the URL forces the 2D renderer, for comparison)
958
- const R = (typeof MooodGL !== 'undefined' && !/[?&]2d\b/.test(location.search) && MooodGL.create(canvas)) || canvas2DRenderer(canvas);
959
- // t: seconds since the stage was made, by the real clock — what the painting code sees (e.t),
960
- // in step with the state's moves; dt: the step of the camera's motion (capped, so a long frame
961
- // doesn't throw it)
962
- const born = performance.now();
963
- let spec = null, S = null, t = 0, last = born, pending;
964
- const cam = { x: 0, y: 0, z: 0, h: 1, yaw: 0 }, target = { x: 0, y: 0, z: 0, h: 1, yaw: 0 };
965
- let seen = [0, 1], lim = { aspect: STAGE, zMax: 1, things: [], view: [0, 1] };
966
- // the frame within the canvas (CSS px): landscape, centred, bars around it
967
- let box = { x: 0, y: 0, w: 1, h: 1 };
968
- const frameBox = () => {
969
- const cw = Math.max(1, canvas.clientWidth), ch = Math.max(1, canvas.clientHeight), a = clamp(cw / ch, NARROWEST, WIDEST);
970
- const w = Math.min(cw, ch * a), h = w / a;
971
- return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
972
- };
973
- // a point of the page (clientX, clientY) in the frame's CSS px — the canvas may be scaled
974
- const local = (cx, cy) => {
975
- const r = canvas.getBoundingClientRect(), k = r.width / Math.max(1, canvas.clientWidth) || 1;
976
- return [(cx - r.left) / k - box.x, (cy - r.top) / k - box.y];
977
- };
978
- // the part of the frame the page's panels leave open, as fractions of its width
979
- const openView = () => [0, 1].map(i => clamp((seen[i] * canvas.clientWidth - box.x) / box.w, 0, 1));
980
- const keys = new Set(), pointers = new Map();
981
- let pinch = null;
982
-
983
- // GPU budget. A calm scene doesn't need the display's refresh rate (120–165 Hz on
984
- // many laptops): render fpsIdle while idle, 60 while the camera moves. The canvas is
985
- // capped at the renderer's pixel budget whatever the monitor, and stretched by the
986
- // browser — invisible in this soft, hazy style.
987
- const FPS_ACTIVE = 60, PIXEL_BUDGET = R.pixelBudget, RES_MIN = 0.6;
988
- let fpsIdle = R.fpsIdle;
989
- const resCap = () => Math.min(window.devicePixelRatio || 1, R.resMax, Math.sqrt(PIXEL_BUDGET / Math.max(1, canvas.clientWidth * canvas.clientHeight)));
990
- let res = resCap(), frameEma = 1 / fpsIdle, checkAt = performance.now() + 3000, lastDowngrade = 0, activeUntil = 0;
991
- let waiting = null; // a spec to show once its modules are here
992
- const rebuild = () => {
993
- box = frameBox();
994
- res = Math.min(res, resCap());
995
- canvas.width = Math.round(canvas.clientWidth * res);
996
- canvas.height = Math.round(canvas.clientHeight * res);
997
- if (spec) {
998
- S = R.build(spec, Math.round(box.w * res), Math.round(box.h * res), S, { x: Math.round(box.x * res), y: Math.round(box.y * res), cw: canvas.width, ch: canvas.height });
999
- const was = lim.setup;
1000
- lim = { ...S.lim, view: openView(), level: plat?.level };
1001
- // a new camera setup: go to its starting spot at once
1002
- if (!was || was.mode !== lim.setup.mode || was.h !== lim.setup.h || was.look !== lim.setup.look) { reset(); Object.assign(cam, target); }
1003
- limitCam(target, lim);
1004
- }
1005
- };
1006
- // If the machine can't hold the frame rate: first a calmer idle rate, then fewer pixels.
1007
- const adapt = (now, target) => {
1008
- if (now < checkAt || !S) return;
1009
- checkAt = now + 2000;
1010
- if (frameEma > 1.4 / target && target === fpsIdle && fpsIdle > 30) {
1011
- fpsIdle = 30;
1012
- lastDowngrade = now;
1013
- } else if (frameEma > 1.4 / target && res > RES_MIN) {
1014
- res = Math.max(RES_MIN, res * 0.8);
1015
- lastDowngrade = now;
1016
- rebuild();
1017
- } else if (frameEma < 1.05 / target && res < resCap() && now - lastDowngrade > 15000) {
1018
- res = Math.min(resCap(), res * 1.15);
1019
- rebuild();
1020
- }
1021
- };
1022
- const perf = document.createElement('div');
1023
- perf.style.cssText = 'position:fixed;left:8px;top:8px;z-index:9;font:12px/1.4 ui-monospace,Consolas,monospace;color:#cfe;background:rgba(0,0,0,.6);padding:4px 8px;border-radius:6px;pointer-events:none;display:none;white-space:pre';
1024
- document.body.append(perf);
1025
- let frames = 0, perfAt = performance.now(), drawMs = 0;
1026
- addEventListener('resize', () => { clearTimeout(pending); pending = setTimeout(rebuild, 150); });
1027
-
1028
- // Wheel / pinch: walk along the ray through the cursor toward what is seen under it — the
1029
- // first thing truly painted at that point (not a veil of leaves, not a transparent edge), else
1030
- // the ground there, else (the sky) a few steps ahead. It is chosen when the gesture starts or
1031
- // the pointer moves, and kept till the gesture ends; it stays under the cursor all the way —
1032
- // the gaze lifting or lowering with it — and the walk stops where it about fills the frame
1033
- // (something high up: where it still shows with the gaze lifted as far as it goes).
1034
- let aim = null, aimAt = 0;
1035
- // is the element truly painted at the cursor: nearly opaque paint on a tenth of the 9×9
1036
- // pixels around it (the thin spokes of a wheel count; a veil, a glow, empty air don't)?
1037
- const probe = layer(9, 9), pg = probe.getContext('2d', { willReadFrequently: true });
1038
- const seenAt = (o, m, cx, cy, Hc) => {
1039
- pg.setTransform(1, 0, 0, 1, 0, 0);
1040
- pg.clearRect(0, 0, 9, 9);
1041
- pg.setTransform(m[0], 0, 0, m[3], m[4] - cx + 4.5, m[5] - cy + 4.5);
1042
- try { drawObject(pg, o, STAGE * Hc, Hc, t, []); } catch { return false; }
1043
- const d = pg.getImageData(0, 0, 9, 9).data;
1044
- let n = 0;
1045
- for (let i = 3; i < d.length; i += 4) if (d[i] > 160) n++;
1046
- return n >= 8;
1047
- };
1048
- const pick = (clientX, clientY) => {
1049
- const [cx, cy] = local(clientX, clientY), Hc = box.h, Wc = box.w, yh = S?.yh ?? 0.6, dz = spec ? depths(spec) : null;
1050
- const at = D => ({ Z: target.z + D, x: target.x + (cx - Wc / 2) / Hc * D });
1051
- const near = (spec?.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane).map(o => ({ o: { ...o, x: dz.objectX(o), y: dz.objectY(o), size: dz.objectSize(o), _yh: yh }, Z: dz.objectZ(o) }))
1052
- .filter(({ Z }) => fadeNear(Z, target) > 0).sort((a, b) => a.Z - b.Z);
1053
- for (const { o, Z } of near) {
1054
- const m = view(target, Z, Wc, Hc, yh), b = objectBox(o, STAGE * Hc, Hc);
1055
- const x0 = m[0] * b.bx + m[4], y0 = m[3] * b.by + m[5];
1056
- if (cx < x0 || cx > x0 + m[0] * b.bw || cy < y0 || cy > y0 + m[3] * b.bh) continue;
1057
- if (!seenAt(o, m, cx, cy, Hc)) continue;
1058
- // the point under the cursor, Y eye heights up; stop where the thing about fills the
1059
- // frame, or — high up — where the point still shows with the gaze lifted as far as it goes
1060
- const D = Z - target.z, Y = target.h - (cy / Hc - yh + target.y) * D, up = (lim.setup?.look ?? 0) + LOOK_UP;
1061
- const sight = Y > target.h ? (Y - target.h) / Math.max(0.05, yh - cy / Hc - up) : 0;
1062
- return { ...at(D), Y, cy: cy / Hc, stop: Math.max(1.1, 1.15 * o.size * Z, sight) };
1063
- }
1064
- const below = cy - (yh - target.y) * Hc;
1065
- return { ...at(below > 0.02 * Hc ? clamp(Hc * target.h / below, 0.8, 100) : 6), stop: 1.2 };
1066
- };
1067
- const walkToward = (factor, clientX, clientY) => {
1068
- const now = performance.now();
1069
- if (!aim || now - aimAt > 400 || Math.hypot(clientX - aim.px, clientY - aim.py) > 6) aim = { ...pick(clientX, clientY), px: clientX, py: clientY };
1070
- aimAt = now;
1071
- const d0 = aim.Z - target.z;
1072
- if (d0 <= 0.05) return;
1073
- // each notch covers a share of the way left (the approach slows by itself), up to the stop
1074
- const d = factor > 1 ? Math.max(Math.min(d0, aim.stop), d0 / factor) : d0 / factor;
1075
- target.z = aim.Z - d;
1076
- target.x = aim.x - (aim.x - target.x) * d / d0;
1077
- if (aim.Y !== undefined) target.y = (S?.yh ?? 0.6) - aim.cy + (target.h - aim.Y) / Math.max(0.05, d);
1078
- // what you walked up to may lie past where walking goes: you may stand there
1079
- lim.reach = target.z > lim.zMax ? Math.max(lim.reach ?? 0, target.z) : undefined;
1080
- limitCam(target, lim);
1081
- };
1082
-
1083
- // Drag: sideways = step, keeping what you took hold of (an object, else that spot of
1084
- // ground) under the finger — a step of dx·D/H for its distance D, so near grass moves you
1085
- // a little and a far house carries you along. Up / down = turn your head: the gaze tilts
1086
- // and the grabbed point follows the finger. Walking is the wheel, pinch or keys.
1087
- // Let go while moving and the camera glides on a little.
1088
- let grab = null, glide = null, lastMove = 0;
1089
- const grabAt = (clientX, clientY) => {
1090
- // looking around, a drag turns the head: what you took hold of follows the finger
1091
- if (round()) { const [cx, cy] = local(clientX, clientY); grab = { round: true, cx, cy, yaw: target.yaw, look: target.y }; return; }
1092
- const [cx, cy] = local(clientX, clientY), D = clamp(pick(clientX, clientY).Z - target.z, 0.4, 60);
1093
- grab = { x: target.x + (cx - box.w / 2) / box.h * D, D, cy, look: target.y };
1094
- };
1095
- const dragTo = (clientX, clientY) => {
1096
- const [cx, cy] = local(clientX, clientY), x0 = target.x, y0 = target.y, now = performance.now();
1097
- if (grab.round) {
1098
- const yaw0 = target.yaw;
1099
- target.yaw = grab.yaw - (Math.atan((cx - box.w / 2) / box.h) - Math.atan((grab.cx - box.w / 2) / box.h));
1100
- target.y = grab.look - (cy - grab.cy) / box.h;
1101
- limitCam(target, lim);
1102
- const dt = Math.max(8, now - lastMove) / 1000, k = Math.min(1, dt * 20);
1103
- glide = glide || { vx: 0, vy: 0, vyaw: 0 };
1104
- glide.vyaw += ((target.yaw - yaw0) / dt - glide.vyaw) * k;
1105
- lastMove = now;
1106
- return;
1107
- }
1108
- target.x = grab.x - (cx - box.w / 2) / box.h * grab.D;
1109
- target.y = grab.look - (cy - grab.cy) / box.h;
1110
- limitCam(target, lim);
1111
- const dt = Math.max(8, now - lastMove) / 1000, k = Math.min(1, dt * 20);
1112
- glide = glide || { vx: 0, vy: 0, vyaw: 0 };
1113
- glide.vx += ((target.x - x0) / dt - glide.vx) * k;
1114
- glide.vy += ((target.y - y0) / dt - glide.vy) * k;
1115
- lastMove = now;
1116
- };
1117
- // where in the canvas (CSS px) an element is from camera c — the point its code marks (e.at),
1118
- // else its anchor (its top, with over) — lifted by `lift` metres: [x, y, its distance]; null
1119
- // when out of sight
1120
- function seenFrom(c, id, lift = 0, over = false) {
1121
- const o = (spec?.objects || []).find(o => o.id === id);
1122
- if (!o || o.sky) return null;
1123
- const W = box.w, H = box.h, dz = depths(spec), yh = dz.yh;
1124
- if (round()) {
1125
- // looking around: its anchor (or the point its code marks) seen from where you stand
1126
- const p = dz.objectAt(o);
1127
- if (o.plane || !FOCUS.has(id)) {
1128
- const q = project(c, p.X, p.Y + (lift + (over && !o.plane ? o.height ?? EYE : 0)) / EYE, p.Z, W, H, yh);
1129
- return q && q[2] > 0.3 ? [box.x + q[0], box.y + q[1], q[2]] : null;
1130
- }
1131
- const b = billboard(c, p, dz.objectX(o) * STAGE * H, dz.objectY(o) * H, W, H, yh), f = FOCUS.get(id);
1132
- if (!b || b.D < 0.3) return null;
1133
- return [box.x + b.m[0] * f.x * STAGE * H + b.m[4], box.y + b.m[3] * (f.y * H - (lift / EYE) * H / p.Zp) + b.m[5], b.D];
1134
- }
1135
- const Z = o.plane ? (o.distance > 0 ? o.distance : 10) / EYE : dz.objectZ(o);
1136
- if (Z - c.z < 0.3 || fadeNear(Z, c) < 0.3) return null;
1137
- const f = FOCUS.get(id) || { x: dz.objectX(o), y: o.plane ? yh + (1 - (o.elevation || 0) / EYE) / Z : dz.objectY(o) };
1138
- if (over && !FOCUS.has(id)) lift += o.height ?? EYE;
1139
- const m = view(c, Z, W, H, yh);
1140
- let px = f.x * STAGE * H, py = f.y * H - (lift / EYE) * H / Z;
1141
- if (c.hero && id === c.hero.id) { const hm = heroMatrix(c.hero, dz.objectX(o) * STAGE * H, dz.objectSize(o) * H / (o.height || 2)); px = hm[0] * px + hm[4]; py += hm[5]; }
1142
- return [box.x + m[0] * px + m[4], box.y + m[3] * py + m[5], Z - c.z];
1143
- }
1144
- // the camera keeps the elements it follows in the visible part of the screen: together when
1145
- // they fit, else the first of them
1146
- function keepInView() {
1147
- const ids = lim.setup?.follow;
1148
- if (!ids?.length || pointers.size || keys.size || glide) return;
1149
- const [vl, vr] = lim.view, L = box.x + (vl + 0.1) * box.w, R = box.x + (vr - 0.1) * box.w;
1150
- const pts = ids.map(id => seenFrom(target, id)).filter(Boolean);
1151
- if (!pts.length) return;
1152
- const lo = Math.min(...pts.map(p => p[0])), hi = Math.max(...pts.map(p => p[0]));
1153
- if (lo >= L && hi <= R) return;
1154
- const aim = hi - lo <= R - L ? (lo + hi) / 2 : pts[0][0];
1155
- target.x += (aim - (L + R) / 2) * pts[0][2] / box.h;
1156
- }
1157
- const reset = () => { Object.assign(target, { x: 0, y: lim.setup?.look ?? 0, z: 0, h: lim.setup?.h ?? 1, yaw: 0 }); };
1158
- const round = () => !!lim.setup?.round, roam = () => lim.setup?.mode === 'roam';
1159
- const platform = () => lim.setup?.mode === 'platform';
1160
- // ── a platformer ─────────────────────────────────────────────────────
1161
- // The hero (camera.hero) runs and jumps in its plane, seen from the side: x metres across, y
1162
- // metres up. Solid elements (solid: true) are boxes it stands on and bumps into — across their
1163
- // box, from their elevation up their height. Gravity G, running RUN, a jump camera.jump metres
1164
- // high (default 2.2). Fallen FALL metres below the lowest place to stand, it is back at the start.
1165
- // The camera rides along.
1166
- // Keys: ← → A D run, space W ↑ jump; touch: hold the left or right third to run, the middle to jump.
1167
- const G = 22, RUN = 5.5, FALL = 5;
1168
- let plat = null;
1169
- function platformOf(spec) {
1170
- const s = camSetup(spec), o = s.hero && (spec.objects || []).find(o => o.id === s.hero);
1171
- if (!o) return null;
1172
- const dz = depths(spec), p = dz.objectAt(o);
1173
- const solids = (spec.objects || []).filter(e => e.solid && e !== o).map(e => {
1174
- const q = dz.objectAt(e), h = e.height || 2, b = Array.isArray(e.box) && e.box.length === 4 ? e.box : [-0.8 * h, 0, 0.8 * h, 0];
1175
- const x = q.X * EYE, y = e.elevation || 0;
1176
- return { x0: x + b[0], x1: x + b[2], y0: y, y1: y + h };
1177
- });
1178
- const x = p.X * EYE, y = o.elevation || 0;
1179
- return {
1180
- id: o.id, Zp: p.Zp, x0: x, y0: y, x, y, floor: y, vx: 0, vy: 0, ground: false, air: 0, jumped: false, dir: 1,
1181
- w: o.width || 0.6, h: o.height || 1.7, solids, jumpV: Math.sqrt(2 * G * clamp(s.jump ?? 2.2, 0.5, 8)),
1182
- level: [Math.min(x, ...solids.map(r => r.x0)) / EYE, Math.max(x, ...solids.map(r => r.x1)) / EYE],
1183
- bottom: Math.min(y, ...solids.map(r => r.y1)), // the lowest place to stand
1184
- };
1185
- }
1186
- // what the viewer asks for now: { left, right, jump }
1187
- function controls() {
1188
- const c = { left: keys.has('ArrowLeft') || keys.has('a'), right: keys.has('ArrowRight') || keys.has('d'), jump: keys.has(' ') || keys.has('w') || keys.has('ArrowUp') };
1189
- for (const p of pointers.values()) {
1190
- const f = local(p.x, p.y)[0] / box.w;
1191
- if (f < 1 / 3) c.left = true; else if (f > 2 / 3) c.right = true; else c.jump = true;
1192
- }
1193
- return c;
1194
- }
1195
- function stepHero(P, dt) {
1196
- const c = controls(), want = (c.right ? 1 : 0) - (c.left ? 1 : 0), hw = P.w / 2;
1197
- P.vx += (want * RUN - P.vx) * Math.min(1, dt * (P.ground ? 14 : 5));
1198
- if (want) P.dir = want;
1199
- P.air = P.ground ? 0 : P.air + dt;
1200
- // one jump a press; a moment after running off an edge still counts
1201
- if (c.jump && !P.jumped && P.air < 0.1) { P.vy = P.jumpV; P.jumped = true; P.air = 1; }
1202
- if (!c.jump) P.jumped = false;
1203
- P.vy = Math.max(P.vy - G * dt, -30);
1204
- // across, then up: out of whatever solid it moved into
1205
- P.x += P.vx * dt;
1206
- for (const r of P.solids) {
1207
- if (P.y >= r.y1 - 0.01 || P.y + P.h <= r.y0 + 0.01 || P.x + hw <= r.x0 || P.x - hw >= r.x1) continue;
1208
- if (P.vx > 0) P.x = r.x0 - hw; else if (P.vx < 0) P.x = r.x1 + hw;
1209
- P.vx = 0;
1210
- }
1211
- const y0 = P.y;
1212
- P.y += P.vy * dt; P.ground = false;
1213
- for (const r of P.solids) {
1214
- if (P.x + hw <= r.x0 || P.x - hw >= r.x1) continue;
1215
- if (P.vy <= 0 && y0 >= r.y1 - 0.01 && P.y < r.y1) { P.y = r.y1; P.vy = 0; P.ground = true; }
1216
- else if (P.vy > 0 && y0 + P.h <= r.y0 + 0.01 && P.y + P.h > r.y0) { P.y = r.y0 - P.h; P.vy = 0; }
1217
- }
1218
- if (P.y < P.bottom - FALL) Object.assign(P, { x: P.x0, y: P.y0, floor: P.y0, vx: 0, vy: 0 });
1219
- if (P.ground) P.floor = P.y;
1220
- Object.assign(HERO, { id: P.id, vx: P.vx, vy: P.vy, ground: P.ground });
1221
- // the camera: the hero a little behind the middle, the way it runs; up and down with what it
1222
- // stands on, not with every jump
1223
- target.x = (P.x + P.dir * 1.2) / EYE;
1224
- target.y = lim.setup.look - 0.8 * (P.floor - P.y0) / EYE / P.Zp;
1225
- limitCam(target, lim);
1226
- }
1227
- // roaming: a step of `d` eye heights toward `ang` (radians, as yaw)
1228
- const stepTo = (ang, d) => { target.x += Math.sin(ang) * d; target.z += Math.cos(ang) * d; limitCam(target, lim); };
1229
- // a picture: the camera stands and only leans (lean: −1…1 each way, toward the
1230
- // pointer or with the phone's tilt) and breathes
1231
- const fixed = () => lim.setup?.sway > 0, panorama = () => lim.setup?.mode === 'panorama';
1232
- const lean = { x: 0, y: 0 };
1233
- // Moving through the scene, the view still leans a little toward the pointer (or with the
1234
- // phone's tilt): near things shift against far ones, the depth is felt standing still too.
1235
- // `shown` is the camera as drawn: where you are, plus the lean (eased, LEAN eye heights).
1236
- const LEAN = 0.047, leaned = { x: 0, y: 0 }, shown = { ...cam };
1237
- // A film moves the camera from its script, through the state: camera_x (metres to the right),
1238
- // camera_h (metres up), camera_z (metres ahead — a dolly in, with all the depth it has),
1239
- // camera_look (screen heights the gaze is lowered); added to wherever the viewer has it.
1240
- const num = k => (typeof STATE.values[k] === 'number' ? STATE.values[k] : 0);
1241
- function directed(c) {
1242
- c.x += num('camera_x') / EYE; c.h = (c.h ?? 1) + num('camera_h') / EYE; c.z += num('camera_z') / EYE; c.y += num('camera_look');
1243
- }
1244
- function leanShown(dt) {
1245
- const k = 1 - Math.exp(-dt * 3), s = lim.setup;
1246
- leaned.x += ((s?.sway ? 0 : lean.x) - leaned.x) * k; leaned.y += ((s?.sway ? 0 : lean.y) - leaned.y) * k;
1247
- Object.assign(shown, cam);
1248
- const dx = leaned.x * LEAN, dy = leaned.y * LEAN / 3;
1249
- if (s?.round) { shown.x += dx * Math.cos(cam.yaw); shown.z -= dx * Math.sin(cam.yaw); }
1250
- else shown.x += dx;
1251
- shown.y += dy;
1252
- }
1253
- function leanTo(t) {
1254
- const s = lim.setup, b = 0.25;
1255
- target.x = (lean.x + b * Math.sin(t * 0.31)) * s.sway / (1 + b);
1256
- target.y = s.look + (lean.y + b * Math.sin(t * 0.19 + 1)) * s.sway / 3 / (1 + b);
1257
- }
1258
-
1259
- canvas.style.touchAction = 'none';
1260
- canvas.addEventListener('pointerdown', e => {
1261
- canvas.setPointerCapture(e.pointerId);
1262
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
1263
- glide = null; lastMove = performance.now();
1264
- if (pointers.size === 1 && !fixed() && !platform()) grabAt(e.clientX, e.clientY);
1265
- askTilt();
1266
- if (pointers.size === 2) {
1267
- const [a, b] = [...pointers.values()];
1268
- pinch = { dist: Math.hypot(a.x - b.x, a.y - b.y) };
1269
- }
1270
- });
1271
- canvas.addEventListener('pointermove', e => {
1272
- const prev = pointers.get(e.pointerId);
1273
- if (!prev) return;
1274
- if (fixed()) return;
1275
- const now = { x: e.clientX, y: e.clientY };
1276
- pointers.set(e.pointerId, now);
1277
- if (platform()) return; // fingers held are the controls (controls())
1278
- if (pointers.size === 1) {
1279
- if (grab) dragTo(now.x, now.y);
1280
- } else if (pointers.size === 2 && pinch) {
1281
- const [a, b] = [...pointers.values()];
1282
- const dist = Math.hypot(a.x - b.x, a.y - b.y);
1283
- if (roam()) stepTo(target.yaw, Math.log(dist / pinch.dist) * 3);
1284
- else if (!round()) walkToward(dist / pinch.dist, (a.x + b.x) / 2, (a.y + b.y) / 2);
1285
- pinch.dist = dist;
1286
- }
1287
- });
1288
- const up = e => {
1289
- pointers.delete(e.pointerId);
1290
- if (pointers.size < 2) pinch = null;
1291
- grab = null; // a finger lifted from a pinch doesn't turn the other into a grab
1292
- // glide only if the finger was still moving when it let go
1293
- if (glide && (pointers.size > 0 || performance.now() - lastMove > 80)) glide = null;
1294
- };
1295
- canvas.addEventListener('pointerup', up);
1296
- canvas.addEventListener('pointercancel', up);
1297
- canvas.addEventListener('wheel', e => {
1298
- e.preventDefault(); glide = null;
1299
- if (fixed() || platform()) return;
1300
- // roaming: the wheel walks toward where the pointer is; standing and looking around, nowhere
1301
- if (round()) {
1302
- if (roam()) { const [cx] = local(e.clientX, e.clientY); stepTo(target.yaw + Math.atan((cx - box.w / 2) / box.h), -e.deltaY * 0.004); }
1303
- return;
1304
- }
1305
- // a panorama: a sideways swipe (a touchpad's) carries you along
1306
- if (panorama() && Math.abs(e.deltaX) > Math.abs(e.deltaY)) { target.x += e.deltaX * 0.006; limitCam(target, lim); return; }
1307
- walkToward(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY);
1308
- }, { passive: false });
1309
- // the view leans toward wherever the pointer is
1310
- addEventListener('pointermove', e => {
1311
- if (e.pointerType !== 'mouse') return;
1312
- lean.x = clamp(2 * e.clientX / Math.max(1, innerWidth) - 1, -1, 1);
1313
- lean.y = clamp(2 * e.clientY / Math.max(1, innerHeight) - 1, -1, 1);
1314
- });
1315
- // ... or with the phone's tilt, from how it is held (the rest position follows it slowly)
1316
- let rest = null;
1317
- addEventListener('deviceorientation', e => {
1318
- if (e.beta == null || e.gamma == null) return;
1319
- const a = screen.orientation?.angle ?? 0;
1320
- // across and down the screen as held: in landscape the phone's axes are turned
1321
- const [h, v] = a === 90 ? [e.beta, -e.gamma] : a === 270 ? [-e.beta, e.gamma] : [e.gamma, e.beta];
1322
- rest = rest ? { h: rest.h + (h - rest.h) * 0.01, v: rest.v + (v - rest.v) * 0.01 } : { h, v };
1323
- lean.x = clamp((h - rest.h) / 15, -1, 1);
1324
- lean.y = clamp((v - rest.v) / 15, -1, 1);
1325
- });
1326
- // iOS lets a page read the tilt only once asked, on a touch
1327
- let asked = false;
1328
- function askTilt() {
1329
- if (asked || typeof window.DeviceOrientationEvent?.requestPermission !== 'function') return;
1330
- asked = true;
1331
- DeviceOrientationEvent.requestPermission().catch(() => {});
1332
- }
1333
-
1334
- const typing = () => ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
1335
- const KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'a', 'd', 'w', 's', ' '];
1336
- addEventListener('keydown', e => {
1337
- if (typing() || e.ctrlKey || e.metaKey || e.altKey) return;
1338
- const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
1339
- if (k === '0') { reset(); return; }
1340
- if (k === 'p') { perf.style.display = perf.style.display === 'none' ? 'block' : 'none'; return; }
1341
- if (KEYS.includes(k) && !fixed()) { keys.add(k); e.preventDefault(); }
1342
- });
1343
- addEventListener('keyup', e => keys.delete(e.key.length === 1 ? e.key.toLowerCase() : e.key));
1344
- addEventListener('blur', () => keys.clear());
1345
-
1346
- const loop = now => {
1347
- requestAnimationFrame(loop);
1348
- const moving = keys.size > 0 || pointers.size > 0 || !!glide || (plat && (Math.abs(plat.vx) > 0.01 || !plat.ground)) || Math.abs(target.x - cam.x) + Math.abs(target.y - cam.y) + Math.abs(target.z - cam.z) + Math.abs(target.h - cam.h) + Math.abs(target.yaw - cam.yaw) > 0.001;
1349
- if (moving) activeUntil = now + 500;
1350
- const fps = now < activeUntil ? FPS_ACTIVE : fpsIdle;
1351
- const real = (now - last) / 1000;
1352
- // skip this display refresh; the tolerance is under half a refresh so vsync jitter
1353
- // never drops a frame we meant to draw (that made 30 fps stutter at ~27)
1354
- if (real < 1 / fps - 0.006) return;
1355
- const dt = Math.min(0.05, real);
1356
- last = now; t = Math.max(t, (performance.now() - born) / 1000);
1357
- STATE.sample?.(); // the state's moves where they are this very moment
1358
- if (real < 0.5) frameEma += (real - frameEma) * 0.1; // ignore tab-switch gaps
1359
- adapt(now, fps);
1360
- // a brisk walk: ~6 m/s forward, ~3.5 m/s sideways (world units are eye heights)
1361
- const step = dt * 2;
1362
- if (plat) stepHero(plat, dt);
1363
- else if (round()) {
1364
- // looking around: arrows left and right turn; roaming, W S walk where you look and A D
1365
- // step aside; standing, W S and the arrows up and down look up and down, A D turn too
1366
- const turn = dt * 1.4;
1367
- if (keys.has('ArrowLeft')) target.yaw -= turn;
1368
- if (keys.has('ArrowRight')) target.yaw += turn;
1369
- if (roam()) {
1370
- if (keys.has('w') || keys.has('ArrowUp')) stepTo(target.yaw, step * 1.5);
1371
- if (keys.has('s') || keys.has('ArrowDown')) stepTo(target.yaw, -step * 1.5);
1372
- if (keys.has('a')) stepTo(target.yaw - Math.PI / 2, step);
1373
- if (keys.has('d')) stepTo(target.yaw + Math.PI / 2, step);
1374
- } else {
1375
- if (keys.has('a')) target.yaw -= turn;
1376
- if (keys.has('d')) target.yaw += turn;
1377
- if (keys.has('w') || keys.has('ArrowUp')) target.y -= dt * 0.6;
1378
- if (keys.has('s') || keys.has('ArrowDown')) target.y += dt * 0.6;
1379
- }
1380
- } else {
1381
- if (keys.has('ArrowLeft') || keys.has('a')) target.x -= step;
1382
- if (keys.has('ArrowRight') || keys.has('d')) target.x += step;
1383
- if (keys.has('ArrowUp') || keys.has('w')) target.z += step * 1.8;
1384
- if (keys.has('ArrowDown') || keys.has('s')) target.z -= step * 1.8;
1385
- }
1386
- if (fixed()) leanTo(t);
1387
- keepInView();
1388
- limitCam(target, lim);
1389
- if (glide && pointers.size === 0) {
1390
- target.x += glide.vx * dt; target.y += glide.vy * dt; target.yaw += glide.vyaw * dt;
1391
- limitCam(target, lim);
1392
- const f = Math.exp(-dt * 4);
1393
- glide.vx *= f; glide.vy *= f; glide.vyaw *= f;
1394
- if (Math.abs(glide.vx) + Math.abs(glide.vy) + Math.abs(glide.vyaw) < 0.005) glide = null;
1395
- }
1396
- // the camera eases toward the target: quickly under the finger (a light trail, ~55 ms),
1397
- // more softly for the wheel and keys (~170 ms); frame-rate independent
1398
- const k = 1 - Math.exp(-dt * (pointers.size === 1 || glide ? 18 : 6));
1399
- cam.x += (target.x - cam.x) * k; cam.y += (target.y - cam.y) * k; cam.z += (target.z - cam.z) * k; cam.h += (target.h - cam.h) * k; cam.yaw += (target.yaw - cam.yaw) * k;
1400
- cam.hero = plat && { id: plat.id, dx: plat.x - plat.x0, dy: plat.y - plat.y0, flip: plat.dir < 0 };
1401
- leanShown(dt);
1402
- directed(shown);
1403
- const t0 = performance.now();
1404
- if (S) R.frame(S, spec, shown, t, dt, now >= activeUntil, target);
1405
- drawMs += performance.now() - t0;
1406
- frames++;
1407
- if (now - perfAt > 1000 && perf.style.display !== 'none') {
1408
- perf.textContent = `${R.name} · ${Math.round(frames * 1000 / (now - perfAt))} fps (cap ${fps})\n${canvas.width}×${canvas.height} · res ${res.toFixed(2)}\njs ${(drawMs / frames).toFixed(1)} ms/frame`;
1409
- frames = 0; drawMs = 0; perfAt = now;
1410
- } else if (now - perfAt > 1000) { frames = 0; drawMs = 0; perfAt = now; }
1411
- };
1412
- requestAnimationFrame(loop);
1413
- return {
1414
- // modules: where the spec's modules come from (kit.js: {world, refs} or {texts})
1415
- show(next, modules) {
1416
- // what the scene's code shares (its modules may still be on their way): shown once they are here
1417
- const shared = MooodKit.shared(next, modules);
1418
- if (shared instanceof Promise) { waiting = next; shared.then(() => { if (waiting === next) { waiting = null; this.show(next, modules); } }); return; }
1419
- waiting = null; SHARED = shared;
1420
- spec = next; FOCUS.clear();
1421
- // values not set yet (the state may start after the scene is shown) are where the scene starts them
1422
- if (spec.state && typeof spec.state === 'object') STATE.values = { ...spec.state, ...STATE.values };
1423
- // a new level, or the same one edited: the hero stays where it got to
1424
- const was = plat; plat = platformOf(spec); HERO.id = plat?.id ?? null;
1425
- if (plat && was && was.id === plat.id) Object.assign(plat, { x: was.x, y: was.y, floor: was.floor, dir: was.dir });
1426
- rebuild();
1427
- if (plat) { Object.assign(target, { x: plat.x / EYE }); Object.assign(cam, target); }
1428
- },
1429
- // a platformer's hero now: { across, elevation (metres), ground } — or null
1430
- hero: () => (plat ? { across: plat.x, elevation: plat.y, ground: plat.ground } : null),
1431
- resetCamera() { reset(); Object.assign(cam, target); },
1432
- // the part of the canvas not covered by the page's panels, as fractions of its width
1433
- setView(left, right) { seen = [left, right]; lim = { ...lim, view: openView() }; },
1434
- // where the frame lies in the canvas (CSS px)
1435
- frame: () => ({ ...box }),
1436
- // where the camera is now (for picking things on screen, select.js)
1437
- camera: () => ({ x: shown.x, y: shown.y, z: shown.z, h: shown.h, yaw: shown.yaw, hero: shown.hero }),
1438
- // the scene's state changed (scenestate.js): the painting code sees it as e.state
1439
- // was(key, ago): what a value was `ago` seconds ago (e.was); sample(): brings the values that
1440
- // are moving to where they are now — called as each frame starts, so it paints them on time
1441
- setState(values, was, sample) {
1442
- STATE.values = values || {}; if (was) STATE.was = was; if (sample) STATE.sample = sample;
1443
- STATE.ver++; activeUntil = performance.now() + 300;
1444
- },
1445
- // where in the canvas (CSS px) an element is now — the point its code marks (e.at), else its
1446
- // anchor (its top, with over) — lifted by `lift` metres; null when out of sight
1447
- screenOf: (id, lift = 0, over = false) => seenFrom(shown, id, lift, over),
1448
- };
1449
- }
1450
-
1451
- return {
1452
- mount,
1453
- // for the WebGL renderer (gl.js): the one-time painting code and camera maths
1454
- internals: { STAGE, WIDEST, TAU, project, billboard, placeOf, camSetup, heroMatrix, STATE, READS, EXT, DEEP, Z_FAR, EYE, depths, view, limits, fadeNear, objectBox, seeded, hash, clamp, layer, rgb, glowSprite, grainTile, staticLayer, drawObject, PARTICLES, makeParticles, moveParticle },
1455
- };
1456
- })();
1
+ // Living-scene engine: paints a scene spec (see ../scene-prompt.mjs) on a canvas,
2
+ // animated. Everything is in device pixels.
3
+ // The frame is always landscape: 4:3 to 21:9, centred in the canvas; it sets the composition, and
4
+ // the canvas around it (a window narrower or wider) shows the scene going on, dimmed, the colour at
5
+ // the frame's edge carrying on where nothing is painted. Its height is the measure: the scene is painted on a 16:9 stage
6
+ // as tall as the frame (spec x is a fraction of the stage's width, y and sizes fractions of its
7
+ // height), the stage centred in the frame — a wider frame sees more on both sides, a narrower
8
+ // one less; nothing on it moves or stretches. What matters belongs in the stage's middle 4:3.
9
+ const Moood = (() => {
10
+ const STAGE = 16 / 9, NARROWEST = 4 / 3, WIDEST = 21 / 9;
11
+ // ── utilities ──────────────────────────────────────────────────────────
12
+ function seeded(seed) {
13
+ return () => {
14
+ seed = (seed + 0x6d2b79f5) | 0;
15
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
16
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
17
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
18
+ };
19
+ }
20
+ function noise1D(seed) {
21
+ const v = Array.from({ length: 512 }, seeded(seed));
22
+ return x => {
23
+ const i = Math.floor(x), f = x - i, k = f * f * (3 - 2 * f);
24
+ return v[i & 511] + (v[(i + 1) & 511] - v[i & 511]) * k;
25
+ };
26
+ }
27
+ function layer(w, h) {
28
+ const c = document.createElement('canvas');
29
+ c.width = Math.max(1, Math.round(w));
30
+ c.height = Math.max(1, Math.round(h));
31
+ return c;
32
+ }
33
+ function rgb(hex) {
34
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex || '');
35
+ if (!m) throw new Error(`bad colour ${hex}`);
36
+ const n = parseInt(m[1], 16);
37
+ return `${n >> 16},${(n >> 8) & 255},${n & 255}`;
38
+ }
39
+ // Glows are the most frequent thing drawn (fireflies, lamps, fog, fire): one
40
+ // pre-rendered radial sprite per colour, stretched and faded with globalAlpha —
41
+ // no gradient objects created per frame.
42
+ const glowSprites = new Map();
43
+ function glowSprite(col) {
44
+ let s = glowSprites.get(col);
45
+ if (!s) {
46
+ s = layer(256, 256);
47
+ const g = s.getContext('2d'), gr = g.createRadialGradient(128, 128, 0, 128, 128, 128);
48
+ gr.addColorStop(0, `rgba(${col},1)`);
49
+ gr.addColorStop(0.25, `rgba(${col},0.4)`);
50
+ gr.addColorStop(1, `rgba(${col},0)`);
51
+ g.fillStyle = gr;
52
+ g.fillRect(0, 0, 256, 256);
53
+ glowSprites.set(col, s);
54
+ }
55
+ return s;
56
+ }
57
+ function glow(ctx, x, y, r, col, a) {
58
+ if (r <= 0 || a <= 0) return;
59
+ const prev = ctx.globalAlpha;
60
+ ctx.globalAlpha = prev * Math.min(1, a);
61
+ ctx.drawImage(glowSprite(col), x - r, y - r, 2 * r, 2 * r);
62
+ ctx.globalAlpha = prev;
63
+ }
64
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
65
+ const hash = s => { let h = 2166136261; for (const ch of String(s)) h = Math.imul(h ^ ch.charCodeAt(0), 16777619); return h >>> 0; };
66
+
67
+ // Film grain as sparse light/dark specks with their own alpha, so it composites with
68
+ // plain source-over (a full-screen 'overlay' blend every frame is expensive).
69
+ const grainTile = (() => {
70
+ const c = layer(160, 160), g = c.getContext('2d'), img = g.createImageData(160, 160);
71
+ for (let i = 0; i < img.data.length; i += 4) {
72
+ img.data[i] = img.data[i + 1] = img.data[i + 2] = Math.random() < 0.5 ? 0 : 255;
73
+ img.data[i + 3] = Math.random() * 38;
74
+ }
75
+ g.putImageData(img, 0, 0);
76
+ return c;
77
+ })();
78
+
79
+ // ── static layers ──────────────────────────────────────────────────────
80
+ function ridge(L, W, H, seed) {
81
+ const n1 = noise1D(seed), n2 = noise1D(seed + 1), n3 = noise1D(seed + 2);
82
+ const base = L.y * H, h = (L.height ?? 0.2) * H, r = L.roughness ?? 0.5;
83
+ return x => {
84
+ const u = x / W;
85
+ if (L.kind === 'mountains') {
86
+ const sharp = 1 - Math.abs(2 * n1(u * 3 + 0.3) - 1);
87
+ return base - h * (0.35 + 0.45 * sharp + r * 0.25 * n2(u * 12) + r * 0.08 * n3(u * 60));
88
+ }
89
+ if (L.kind === 'dunes') return base - h * (0.5 + 0.45 * Math.sin(u * 7 + n1(u * 2) * 4) * (0.6 + 0.4 * n2(u * 3)));
90
+ if (L.kind === 'ground') return base - h * 0.15 * n1(u * 4);
91
+ return base - h * (0.35 + 0.55 * n1(u * 2.5) + r * 0.15 * n2(u * 10)); // hills + base of forest/city
92
+ };
93
+ }
94
+
95
+ function drawPine(g, x, base, h, color, rnd) {
96
+ const w = h * (0.22 + rnd() * 0.08);
97
+ g.fillStyle = color;
98
+ g.fillRect(x - h * 0.012, base - h * 0.15, h * 0.024, h * 0.16);
99
+ const tiers = 6 + Math.floor(rnd() * 3);
100
+ for (let i = 0; i < tiers; i++) {
101
+ const k = i / tiers, yb = base - h * (0.1 + k * 0.78), yt = yb - h * (0.17 + 0.05 * (1 - k));
102
+ const wi = w * (1 - k) * (0.85 + rnd() * 0.3) + h * 0.02;
103
+ g.beginPath();
104
+ g.moveTo(x, yt);
105
+ g.quadraticCurveTo(x - wi * 0.3, yb - (yb - yt) * 0.35, x - wi, yb + h * 0.01);
106
+ g.quadraticCurveTo(x, yb - h * 0.025, x + wi, yb + h * 0.01);
107
+ g.quadraticCurveTo(x + wi * 0.3, yb - (yb - yt) * 0.35, x, yt);
108
+ g.fill();
109
+ }
110
+ }
111
+
112
+ // Layers are painted wider and taller than the screen so the camera can pan and
113
+ // zoom out: world x spans [-EXT·W, (1+EXT)·W], y spans [0, DEEP·H].
114
+ const EXT = 0.4, DEEP = 1.3;
115
+
116
+ // Paints a landscape layer. By default the whole layer at scale 1; `view` = { x, y, w, h, k }
117
+ // paints only that region (layer coordinates) at scale k — for sharp close-ups.
118
+ // Also returns, for each of TOP_STRIPS vertical strips, `tops`: the highest painted y, and
119
+ // `solids`: the y below which the strip is solid colour. A renderer can skip the empty sky
120
+ // above a layer and fill its body flat; only the band between needs a texture.
121
+ // `view.canvas`, if given, is reused (cleared) instead of allocating a new canvas.
122
+ const TOP_STRIPS = 64;
123
+ function staticLayer(L, W, H, seed, view) {
124
+ const x0 = -EXT * W, x1 = (1 + EXT) * W, bottom = DEEP * H;
125
+ const v = view || { x: x0, y: 0, w: x1 - x0, h: bottom, k: 1 };
126
+ const c = v.canvas || layer(v.w * v.k, v.h * v.k), g = c.getContext('2d'), rnd = seeded(seed), f = ridge(L, W, H, seed);
127
+ if (v.canvas) { g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, c.width, c.height); }
128
+ g.setTransform(v.k, 0, 0, v.k, -v.x * v.k, -v.y * v.k);
129
+ const tops = new Array(TOP_STRIPS).fill(bottom), solids = new Array(TOP_STRIPS).fill(0), sw = (x1 - x0) / TOP_STRIPS;
130
+ const mark = (xa, xb, y) => {
131
+ for (let i = Math.max(0, Math.floor((xa - x0) / sw)); i <= Math.min(TOP_STRIPS - 1, Math.floor((xb - x0) / sw)); i++) tops[i] = Math.min(tops[i], y);
132
+ };
133
+ // finer outline steps when painting a close-up, but only across the painted region
134
+ const step = Math.max(2, W / 400) / v.k;
135
+ g.fillStyle = L.color;
136
+ const top = (L.kind === 'forest' || L.kind === 'city')
137
+ ? x => L.y * H - (L.y * H - f(x)) * 0.25 // a low base under the trees / buildings
138
+ : f;
139
+ g.beginPath();
140
+ g.moveTo(x0, bottom);
141
+ const coarse = Math.max(2, W / 400);
142
+ for (let x = x0; x <= x1 + coarse; x += coarse) {
143
+ const y = top(x);
144
+ mark(x - coarse, x + coarse, y);
145
+ const i = Math.min(TOP_STRIPS - 1, Math.max(0, Math.floor((x - x0) / sw)));
146
+ solids[i] = Math.max(solids[i], y);
147
+ }
148
+ const pa = Math.max(x0, v.x - 2 * step), pb = Math.min(x1, v.x + v.w + 2 * step);
149
+ g.lineTo(pa, bottom);
150
+ for (let x = pa; x <= pb + step; x += step) g.lineTo(x, top(x));
151
+ g.lineTo(pb + step, bottom);
152
+ g.fill();
153
+ const density = L.density ?? 0.5, h = (L.height ?? 0.2) * H;
154
+ if (L.kind === 'forest') {
155
+ const spacing = W * (0.035 - 0.028 * density);
156
+ for (let x = x0 - spacing; x < x1 + spacing; x += spacing * (0.5 + rnd())) {
157
+ const th = h * (0.55 + rnd() * 0.5), base = top(x) + h * 0.03;
158
+ mark(x - th * 0.35, x + th * 0.35, base - th * 1.12);
159
+ drawPine(g, x, base, th, L.color, rnd);
160
+ }
161
+ }
162
+ const windows = [];
163
+ if (L.kind === 'city') {
164
+ let x = x0 - rnd() * W * 0.03;
165
+ while (x < x1) {
166
+ const bw = W * (0.03 + rnd() * 0.05) * (1.3 - density * 0.6), bh = h * (0.3 + rnd() * 0.7);
167
+ const btop = L.y * H - bh;
168
+ g.fillStyle = L.color;
169
+ g.fillRect(x, btop, bw - W * 0.002, bh + bottom);
170
+ mark(x, x + bw, btop - bh * 0.12);
171
+ if (rnd() < 0.25) g.fillRect(x + bw * 0.4, btop - bh * 0.12, bw * 0.08, bh * 0.12);
172
+ const cw = Math.max(2, W * 0.004), ch = Math.max(3, H * 0.009);
173
+ for (let wy = btop + ch * 1.5; wy < L.y * H - ch; wy += ch * 2.2) {
174
+ for (let wx = x + cw; wx < x + bw - cw * 2; wx += cw * 2.2) {
175
+ if (rnd() < (L.lights ?? 0.2)) windows.push({ x: wx, y: wy, w: cw, h: ch, p: rnd() * 100 });
176
+ }
177
+ }
178
+ x += bw;
179
+ }
180
+ }
181
+ // margins: the outline is sampled, its true peaks and dips lie a little beyond the samples
182
+ return { canvas: c, x0, windows, tops: tops.map(y => Math.max(0, y - 4 - H * 0.01)), solids: solids.map((y, i) => Math.min(bottom, Math.max(y, solids[i - 1] ?? y, solids[i + 1] ?? y) + 4 + H * 0.01)), stripW: sw };
183
+ }
184
+
185
+ // ── objects ────────────────────────────────────────────────────────────
186
+ function figure(ctx, x, y, s, t, color, pose, flip) {
187
+ ctx.save();
188
+ ctx.translate(x, y);
189
+ ctx.scale(flip ? -s : s, s);
190
+ ctx.fillStyle = ctx.strokeStyle = color;
191
+ ctx.lineCap = ctx.lineJoin = 'round';
192
+ const L = (w, ...p) => { ctx.lineWidth = w; ctx.beginPath(); ctx.moveTo(p[0], p[1]); for (let i = 2; i < p.length; i += 2) ctx.lineTo(p[i], p[i + 1]); ctx.stroke(); };
193
+ const C = (cx, cy, r) => { ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.fill(); };
194
+ const b = Math.sin(t * 1.3) * 0.008;
195
+ if (pose === 'sit') {
196
+ L(0.16, 0, -0.08, -0.28, -0.4); L(0.11, -0.28, -0.4, -0.33, -0.05);
197
+ ctx.beginPath(); ctx.moveTo(0.13, -0.02); ctx.bezierCurveTo(0.22, -0.22, 0.18, -0.46, 0.05, -0.6 - b);
198
+ ctx.lineTo(-0.08, -0.57 - b); ctx.bezierCurveTo(-0.1, -0.4, -0.06, -0.2, -0.04, -0.06); ctx.fill();
199
+ L(0.075, 0.02, -0.52, -0.16, -0.46, -0.31, -0.36); C(-0.13, -0.69 - b, 0.11);
200
+ } else {
201
+ const walk = pose === 'walk' ? Math.sin(t * 3) * 0.08 : 0;
202
+ L(0.1, -0.05, -0.4, -0.06 + walk, -0.02); L(0.1, 0.05, -0.4, 0.06 - walk, -0.02);
203
+ ctx.beginPath(); ctx.moveTo(-0.11, -0.76 - b); ctx.quadraticCurveTo(-0.17, -0.74, -0.17, -0.64);
204
+ ctx.lineTo(-0.19, -0.34); ctx.lineTo(0.19, -0.34); ctx.lineTo(0.17, -0.64);
205
+ ctx.quadraticCurveTo(0.17, -0.74, 0.11, -0.76 - b); ctx.fill();
206
+ L(0.08, -0.16, -0.68, -0.2 - walk * 0.5, -0.42); L(0.08, 0.16, -0.68, 0.2 + walk * 0.5, -0.42);
207
+ C(pose === 'back' ? 0 : 0.02, -0.87 - b, 0.11);
208
+ }
209
+ ctx.restore();
210
+ }
211
+
212
+ // W × H: the stage, in pixels
213
+ function drawObject(ctx, o, W, H, t, lightsOut) {
214
+ const x = o.x * W, y = o.y * H, s = o.size * H, col = o.color;
215
+ const light = o.light ? rgb(o.light) : null, fl = 0.9 + 0.1 * Math.sin(t * 7 + x) * Math.sin(t * 3.1 + y);
216
+ ctx.fillStyle = ctx.strokeStyle = col;
217
+ ctx.lineCap = ctx.lineJoin = 'round';
218
+ switch (o.kind) {
219
+ case 'pine': drawPine(ctx, x, y, s, col, seeded(hash(o.x + ':' + o.y))); break;
220
+ case 'tree': {
221
+ const sw = Math.sin(t * 0.8 + x) * s * 0.01;
222
+ ctx.fillRect(x - s * 0.03, y - s * 0.5, s * 0.06, s * 0.5);
223
+ for (const [dx, dy, r] of [[0, -0.72, 0.28], [-0.18, -0.58, 0.2], [0.18, -0.6, 0.21], [0.05, -0.9, 0.18]]) {
224
+ ctx.beginPath(); ctx.arc(x + dx * s + sw, y + dy * s, r * s, 0, 7); ctx.fill();
225
+ }
226
+ break;
227
+ }
228
+ case 'house': {
229
+ const w = s * 0.9, h = s * 0.55;
230
+ ctx.fillRect(x - w / 2, y - h, w, h);
231
+ ctx.beginPath(); ctx.moveTo(x - w * 0.6, y - h); ctx.lineTo(x, y - s); ctx.lineTo(x + w * 0.6, y - h); ctx.fill();
232
+ ctx.fillRect(x + w * 0.2, y - s * 0.92, w * 0.1, s * 0.2);
233
+ if (light) lightsOut.push({ x: x - w * 0.15, y: y - h * 0.55, r: s * 0.9, col: light, a: 0.4 * fl, rect: [x - w * 0.27, y - h * 0.72, w * 0.22, h * 0.3] });
234
+ break;
235
+ }
236
+ case 'tower': {
237
+ const w = s * 0.22;
238
+ ctx.fillRect(x - w / 2, y - s * 0.85, w, s * 0.85);
239
+ ctx.beginPath(); ctx.moveTo(x - w * 0.7, y - s * 0.85); ctx.lineTo(x, y - s); ctx.lineTo(x + w * 0.7, y - s * 0.85); ctx.fill();
240
+ if (light) lightsOut.push({ x, y: y - s * 0.7, r: s * 0.5, col: light, a: 0.4 * fl, rect: [x - w * 0.18, y - s * 0.75, w * 0.36, s * 0.08] });
241
+ break;
242
+ }
243
+ case 'lamp': {
244
+ ctx.lineWidth = s * 0.03;
245
+ ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x, y - s); ctx.quadraticCurveTo(x, y - s * 1.05, x + s * 0.12, y - s * 1.02); ctx.stroke();
246
+ ctx.fillRect(x + s * 0.06, y - s * 1.02, s * 0.12, s * 0.05);
247
+ if (light) lightsOut.push({ x: x + s * 0.12, y: y - s * 0.95, r: s * 1.4, col: light, a: 0.45 * fl, dot: s * 0.04, cone: { y, w: s * 0.6 } });
248
+ break;
249
+ }
250
+ case 'lantern': {
251
+ const sway = Math.sin(t * 1.2 + x) * s * 0.05;
252
+ ctx.lineWidth = Math.max(1, s * 0.02);
253
+ ctx.beginPath(); ctx.moveTo(x, y - s * 0.6); ctx.lineTo(x + sway, y - s * 0.15); ctx.stroke();
254
+ ctx.fillRect(x + sway - s * 0.08, y - s * 0.15, s * 0.16, s * 0.2);
255
+ if (light) lightsOut.push({ x: x + sway, y: y - s * 0.05, r: s * 1.2, col: light, a: 0.5 * fl, dot: s * 0.07 });
256
+ break;
257
+ }
258
+ case 'campfire': {
259
+ ctx.lineWidth = s * 0.08;
260
+ ctx.beginPath(); ctx.moveTo(x - s * 0.3, y); ctx.lineTo(x + s * 0.25, y - s * 0.08); ctx.moveTo(x + s * 0.3, y); ctx.lineTo(x - s * 0.25, y - s * 0.08); ctx.stroke();
261
+ const fc = light || '255,150,60';
262
+ for (let i = 0; i < 3; i++) {
263
+ const fh = s * (0.5 + 0.2 * Math.sin(t * 9 + i * 2)), fw = s * (0.16 - i * 0.03);
264
+ ctx.fillStyle = `rgba(${i === 2 ? '255,230,170' : fc},${0.85 - i * 0.15})`;
265
+ ctx.beginPath(); ctx.moveTo(x - fw, y - s * 0.05); ctx.quadraticCurveTo(x - fw * 0.6, y - fh * 0.6, x + Math.sin(t * 5 + i) * s * 0.05, y - fh);
266
+ ctx.quadraticCurveTo(x + fw * 0.6, y - fh * 0.6, x + fw, y - s * 0.05); ctx.fill();
267
+ }
268
+ lightsOut.push({ x, y: y - s * 0.25, r: s * 3, col: fc, a: 0.4 * (0.8 + 0.2 * Math.sin(t * 11)) });
269
+ break;
270
+ }
271
+ case 'window': {
272
+ const w = s * 0.6;
273
+ if (light) lightsOut.push({ x, y: y - s / 2, r: s * 1.3, col: light, a: 0.35 * fl, rect: [x - w / 2, y - s, w, s] });
274
+ ctx.lineWidth = Math.max(2, s * 0.05);
275
+ ctx.strokeRect(x - w / 2, y - s, w, s);
276
+ ctx.beginPath(); ctx.moveTo(x, y - s); ctx.lineTo(x, y); ctx.moveTo(x - w / 2, y - s * 0.55); ctx.lineTo(x + w / 2, y - s * 0.55); ctx.stroke();
277
+ break;
278
+ }
279
+ case 'boat': {
280
+ const bob = Math.sin(t * 1.1 + x) * s * 0.03;
281
+ ctx.beginPath(); ctx.moveTo(x - s * 0.6, y - s * 0.12 + bob); ctx.quadraticCurveTo(x, y + s * 0.08 + bob, x + s * 0.6, y - s * 0.12 + bob); ctx.lineTo(x - s * 0.6, y - s * 0.12 + bob); ctx.fill();
282
+ ctx.lineWidth = s * 0.025;
283
+ ctx.beginPath(); ctx.moveTo(x, y - s * 0.1 + bob); ctx.lineTo(x, y - s + bob); ctx.stroke();
284
+ ctx.beginPath(); ctx.moveTo(x + s * 0.03, y - s * 0.95 + bob); ctx.lineTo(x + s * 0.4, y - s * 0.2 + bob); ctx.lineTo(x + s * 0.03, y - s * 0.2 + bob); ctx.fill();
285
+ break;
286
+ }
287
+ case 'rock': {
288
+ const r = seeded(hash(o.x + 'r' + o.y));
289
+ ctx.beginPath(); ctx.moveTo(x - s * 0.7, y);
290
+ for (let i = 0; i <= 6; i++) { const a = Math.PI * (1 - i / 6); ctx.lineTo(x + Math.cos(a) * s * (0.6 + r() * 0.2), y - Math.sin(a) * s * (0.7 + r() * 0.3)); }
291
+ ctx.closePath(); ctx.fill();
292
+ break;
293
+ }
294
+ case 'bench': {
295
+ ctx.fillRect(x - s * 0.8, y - s * 0.45, s * 1.6, s * 0.08);
296
+ ctx.fillRect(x - s * 0.8, y - s * 0.85, s * 1.6, s * 0.07);
297
+ ctx.fillRect(x - s * 0.7, y - s * 0.45, s * 0.06, s * 0.45); ctx.fillRect(x + s * 0.64, y - s * 0.45, s * 0.06, s * 0.45);
298
+ ctx.fillRect(x - s * 0.7, y - s * 0.85, s * 0.05, s * 0.4); ctx.fillRect(x + s * 0.65, y - s * 0.85, s * 0.05, s * 0.4);
299
+ break;
300
+ }
301
+ case 'figure': figure(ctx, x, y, s, t, col, o.pose || 'stand', o.flip); break;
302
+ case 'cat': {
303
+ ctx.beginPath(); ctx.ellipse(x, y - s * 0.3, s * 0.22, s * 0.3, 0, 0, 7); ctx.fill();
304
+ ctx.beginPath(); ctx.arc(x, y - s * 0.72, s * 0.15, 0, 7); ctx.fill();
305
+ ctx.beginPath(); ctx.moveTo(x - s * 0.14, y - s * 0.78); ctx.lineTo(x - s * 0.1, y - s * 0.98); ctx.lineTo(x - s * 0.02, y - s * 0.84);
306
+ ctx.moveTo(x + s * 0.14, y - s * 0.78); ctx.lineTo(x + s * 0.1, y - s * 0.98); ctx.lineTo(x + s * 0.02, y - s * 0.84); ctx.fill();
307
+ ctx.lineWidth = s * 0.06;
308
+ ctx.beginPath(); ctx.moveTo(x + s * 0.18, y - s * 0.05);
309
+ ctx.quadraticCurveTo(x + s * 0.5, y - s * 0.05, x + s * (0.45 + Math.sin(t * 1.5) * 0.08), y - s * 0.4); ctx.stroke();
310
+ break;
311
+ }
312
+ case 'bird': {
313
+ const span = WIDEST * H + 4 * s, bx = ((x + t * s * 1.5 * (o.flip ? -1 : 1)) % span + span) % span + (W - WIDEST * H) / 2 - 2 * s;
314
+ const by = y + Math.sin(t * 0.7 + x) * s * 0.4, fl2 = Math.sin(t * 8 + x) * s * 0.25;
315
+ ctx.lineWidth = Math.max(1.5, s * 0.08);
316
+ ctx.beginPath(); ctx.moveTo(bx - s * 0.5, by - fl2); ctx.quadraticCurveTo(bx - s * 0.2, by - s * 0.15, bx, by);
317
+ ctx.quadraticCurveTo(bx + s * 0.2, by - s * 0.15, bx + s * 0.5, by - fl2); ctx.stroke();
318
+ break;
319
+ }
320
+ case 'flower': {
321
+ const sw = Math.sin(t * 1.4 + x) * s * 0.06;
322
+ ctx.lineWidth = Math.max(1, s * 0.04);
323
+ ctx.beginPath(); ctx.moveTo(x, y); ctx.quadraticCurveTo(x, y - s * 0.5, x + sw, y - s * 0.9); ctx.stroke();
324
+ ctx.fillStyle = light ? `rgb(${light})` : col;
325
+ for (let i = 0; i < 5; i++) { const a = i / 5 * Math.PI * 2 + t * 0.2; ctx.beginPath(); ctx.ellipse(x + sw + Math.cos(a) * s * 0.1, y - s * 0.9 + Math.sin(a) * s * 0.1, s * 0.09, s * 0.05, a, 0, 7); ctx.fill(); }
326
+ break;
327
+ }
328
+ case 'bridge': {
329
+ ctx.lineWidth = s * 0.08;
330
+ ctx.beginPath(); ctx.moveTo(x - s * 1.5, y); ctx.quadraticCurveTo(x, y - s * 0.9, x + s * 1.5, y); ctx.stroke();
331
+ ctx.lineWidth = s * 0.03;
332
+ ctx.beginPath(); ctx.moveTo(x - s * 1.5, y - s * 0.2); ctx.quadraticCurveTo(x, y - s * 1.15, x + s * 1.5, y - s * 0.2); ctx.stroke();
333
+ for (let i = -4; i <= 4; i++) { const u = i / 4.5, yy = y - s * 0.45 * (1 - u * u) * 1.0; ctx.beginPath(); ctx.moveTo(x + u * s * 1.4, yy); ctx.lineTo(x + u * s * 1.4, yy - s * 0.22); ctx.stroke(); }
334
+ break;
335
+ }
336
+ case 'custom': case undefined: paintCustom(ctx, o, W, H, t, lightsOut); break;
337
+ default: throw new Error(`unknown object kind ${o.kind}`);
338
+ }
339
+ }
340
+
341
+ // Elements the world's agent paints itself. `o.code` is the body of a function (g, e):
342
+ // g is a 2D canvas context in metres — origin at the element's anchor (where it stands),
343
+ // x to the right, y DOWN (a 6 m house spans y −6…0). e.t = seconds by the real clock (e.phase =
344
+ // 0…1 of the loop for animated elements), e.id = the element's id, e.rnd() = seeded random, e.px = one screen pixel in metres,
345
+ // e.light(x, y, radius, color, strength) = a glowing light the engine animates. What scenes
346
+ // share (kit.js): e.anim — the animator's toolkit; e.kit — the world's modules; e.lib — the scene's
347
+ // lib. e.was(key, ago) — what a value of the state was `ago` seconds ago (what lags behind reads
348
+ // it); e.speed(key) — how fast it is changing now (per second).
349
+ // Errors are collected (window.__mooodErrors) so the agent reviewing its work sees them.
350
+ // The scene's state (scenestate.js): values the scene's scripts, agents and widgets set, which
351
+ // the painting code reads as e.state; ver counts changes, so the renderer knows to repaint.
352
+ const STATE = { values: {}, ver: 0, was: key => STATE.values[key], sample: null };
353
+ // what the shown scene's code shares (kit.js): the world's modules it lists and the scene's lib
354
+ let SHARED = { kit: {}, lib: {} };
355
+ // Where an element is now, as its painting code says (e.at): fractions of the stage as seen
356
+ // from the start — speech bubbles and widgets follow it (stage.screenOf).
357
+ const FOCUS = new Map();
358
+ // A platformer's hero as it moves now (camera.mode platform): its painting code reads e.hero —
359
+ // { vx, vy (m/s), ground } — to move its legs; the engine moves and turns the painting itself.
360
+ const HERO = { id: null, vx: 0, vy: 0, ground: true };
361
+ // What an element's code read as it was last painted (element → {time, state}): one that reads
362
+ // the time is repainted every frame, one that reads only the state when the state changes, one
363
+ // that reads neither stays as painted (gl.js) — whether it reads them itself or through the kit
364
+ const READS = new WeakMap();
365
+ const compiled = new Map();
366
+ function reportError(o, err) {
367
+ const msg = `${o.name || o.kind}: ${err && err.message || err}`;
368
+ (window.__mooodErrors = window.__mooodErrors || []).push(msg);
369
+ console.warn('moood custom element:', msg);
370
+ }
371
+ function paintCustom(ctx, o, W, H, t, lightsOut) {
372
+ const code = String(o.code || '');
373
+ let fn = compiled.get(code);
374
+ if (fn === undefined) {
375
+ try { fn = new Function('g', 'e', code); } catch (err) { fn = null; reportError(o, err); }
376
+ compiled.set(code, fn);
377
+ }
378
+ if (!fn) return;
379
+ // sky and ground elements are painted in stage heights from the stage's top-left corner, as
380
+ // seen from the start (x 0…W/H, y 0…1, with margins); others in metres from their anchor
381
+ const flat = o.sky || o.ground;
382
+ const ppm = flat ? H : o.size * H / (o.height || 2), x = flat ? 0 : o.x * W, y = flat ? 0 : o.y * H;
383
+ ctx.save();
384
+ ctx.translate(x, y);
385
+ ctx.scale(ppm, ppm);
386
+ ctx.fillStyle = ctx.strokeStyle = o.color || '#000000';
387
+ ctx.lineCap = ctx.lineJoin = 'round';
388
+ const period = o.period || 2;
389
+ // a point the code names, as painted — through whatever it did to the canvas since (translate,
390
+ // scale, rotate): [x, y, how much it is scaled]
391
+ const base = ctx.getTransform(), unbase = base.inverse();
392
+ const painted = (lx, ly) => {
393
+ const now = ctx.getTransform(), q = unbase.transformPoint(now.transformPoint({ x: lx, y: ly }));
394
+ return [x + q.x * ppm, y + q.y * ppm, Math.hypot(now.a, now.b) / Math.hypot(base.a, base.b)];
395
+ };
396
+ const read = { time: false, state: false };
397
+ READS.set(o, read);
398
+ const e = {
399
+ get t() { read.time = true; return t; },
400
+ get phase() { read.time = true; return ((t / period) % 1 + 1) % 1; },
401
+ get state() { read.state = true; return STATE.values; },
402
+ id: o.id ?? null, rnd: seeded(hash(code) ^ hash(o.x + ':' + o.y)),
403
+ px: 1 / Math.max(1e-6, base.a), W, H, aspect: W / H,
404
+ light(lx, ly, r, color = '#ffd08a', strength = 0.6) {
405
+ try {
406
+ const [px, py, k] = painted(lx, ly);
407
+ lightsOut.push({ x: px, y: py, r: r * k * ppm, col: rgb(color), a: strength, flicker: o.flicker ?? 0.3 });
408
+ } catch (err) { reportError(o, err); }
409
+ },
410
+ // where this element is now (its head, say): what speaks or hovers over it follows this point
411
+ at(lx, ly) { if (o.id) { const [px, py] = painted(lx, ly); FOCUS.set(o.id, { x: px / W, y: py / H }); } },
412
+ anim: MooodKit.anim, kit: SHARED.kit, lib: SHARED.lib,
413
+ was: (key, ago = 0) => { read.state = true; return STATE.was(key, ago); },
414
+ speed: key => { read.state = true; const a = STATE.was(key, 0), b = STATE.was(key, 0.05); return typeof a === 'number' && typeof b === 'number' ? (a - b) / 0.05 : 0; },
415
+ };
416
+ if (o.id && o.id === HERO.id) { read.time = true; e.hero = { vx: HERO.vx, vy: HERO.vy, ground: HERO.ground }; }
417
+ try { fn(ctx, e); } catch (err) { reportError(o, err); }
418
+ ctx.restore();
419
+ }
420
+ // The box an object's painting fits in (pixels of the W × H stage): built-in kinds a fixed frame
421
+ // around the base, custom ones their `box` in metres (default: a little wider than tall).
422
+ function objectBox(o, W, H) {
423
+ // the sky: as wide as the widest frame
424
+ if (o.sky) return { bx: (W - WIDEST * H) / 2, by: -0.35 * H, bw: WIDEST * H, bh: 1.6 * H };
425
+ // ground: from the horizon down, wider than the screen (for stepping aside and looking down)
426
+ if (o.ground) return { bx: -EXT * W, by: o._yh * H, bw: (1 + 2 * EXT) * W, bh: (1.7 - o._yh) * H };
427
+ const s = Math.max(2, o.size * H), x = o.x * W, y = o.y * H;
428
+ if (o.kind === 'custom' || !o.kind) {
429
+ const h = o.height || 2, ppm = s / h, b = Array.isArray(o.box) && o.box.length === 4 ? o.box : [-0.8 * h, -1.1 * h, 0.8 * h, 0.15 * h];
430
+ return { bx: x + b[0] * ppm, by: y + b[1] * ppm, bw: (b[2] - b[0]) * ppm, bh: (b[3] - b[1]) * ppm };
431
+ }
432
+ return { bx: x - 1.7 * s, by: y - 1.3 * s, bw: 3.4 * s, bh: 1.6 * s };
433
+ }
434
+
435
+ function drawLights(ctx, lights) {
436
+ for (const l of lights) {
437
+ if (l.rect) { ctx.fillStyle = `rgba(${l.col},${Math.min(1, l.a * 2.2)})`; ctx.fillRect(...l.rect); }
438
+ }
439
+ ctx.save();
440
+ ctx.globalCompositeOperation = 'lighter';
441
+ for (const l of lights) {
442
+ glow(ctx, l.x, l.y, l.r, l.col, l.a * 0.6);
443
+ if (l.dot) glow(ctx, l.x, l.y, l.dot * 3, '255,245,220', 0.9);
444
+ if (l.cone) {
445
+ const g = ctx.createLinearGradient(0, l.y, 0, l.cone.y);
446
+ g.addColorStop(0, `rgba(${l.col},${l.a * 0.35})`);
447
+ g.addColorStop(1, `rgba(${l.col},0)`);
448
+ ctx.fillStyle = g;
449
+ ctx.beginPath(); ctx.moveTo(l.x - l.dot, l.y); ctx.lineTo(l.x + l.dot, l.y); ctx.lineTo(l.x + l.cone.w, l.cone.y); ctx.lineTo(l.x - l.cone.w, l.cone.y); ctx.fill();
450
+ }
451
+ }
452
+ ctx.restore();
453
+ }
454
+
455
+ // ── particles ──────────────────────────────────────────────────────────
456
+ const PARTICLES = { snow: 380, rain: 520, fireflies: 45, dust: 140, petals: 90, embers: 90, leaves: 60, bubbles: 60 };
457
+ function makeParticles(p, W, H, rnd) {
458
+ const n = Math.round((PARTICLES[p.kind] ?? (() => { throw new Error(`unknown particle ${p.kind}`); })()) * clamp(p.density ?? 0.5, 0, 1) * (W / H) / (16 / 9)); // same count at any resolution
459
+ return Array.from({ length: n }, () => ({ x: rnd() * W, y: rnd() * H, z: rnd(), ph: rnd() * 7, life: rnd() }));
460
+ }
461
+ // Particle motion (shared by the 2D and WebGL renderers). Positions are screen pixels.
462
+ function moveParticle(kind, q, W, H, t, dt, u, wind) {
463
+ switch (kind) {
464
+ case 'rain':
465
+ q.y += (700 + q.z * 500) * u * dt; q.x += wind * 3 * dt;
466
+ if (q.y > H) { q.y = -20 * u; q.x = Math.random() * W; }
467
+ return;
468
+ case 'snow': q.y += (25 + q.z * 60) * u * dt; q.x += (wind + Math.sin(t * 0.8 + q.ph) * 12 * u) * dt; break;
469
+ case 'dust': q.y += Math.sin(t * 0.3 + q.ph) * 6 * u * dt; q.x += (wind * 0.3 + 5 * u) * dt; break;
470
+ case 'petals': case 'leaves': q.y += (30 + q.z * 40) * u * dt; q.x += (wind + Math.sin(t + q.ph) * 30 * u) * dt; break;
471
+ case 'fireflies': q.x += Math.sin(t * 0.4 + q.ph) * 14 * u * dt; q.y += Math.cos(t * 0.33 + q.ph * 2) * 10 * u * dt; break;
472
+ case 'embers': q.y -= (40 + q.z * 60) * u * dt; q.x += (wind * 0.5 + Math.sin(t * 2 + q.ph) * 15 * u) * dt; break;
473
+ case 'bubbles': q.y -= (20 + q.z * 40) * u * dt; q.x += Math.sin(t * 1.5 + q.ph) * 10 * u * dt; break;
474
+ default: throw new Error(`unknown particle ${kind}`);
475
+ }
476
+ if (q.y > H + 20 * u) { q.y = -20 * u; q.x = Math.random() * W; }
477
+ if (q.y < -20 * u) { q.y = H + 20 * u; q.x = Math.random() * W; }
478
+ if (q.x > W + 20 * u) q.x -= W + 40 * u; else if (q.x < -20 * u) q.x += W + 40 * u;
479
+ }
480
+
481
+ function stepParticles(ctx, p, list, W, H, t, dt, u) {
482
+ const col = rgb(p.color || '#ffffff'), wind = (p.wind ?? 0) * 60 * u;
483
+ ctx.save();
484
+ if (p.kind === 'rain') {
485
+ ctx.strokeStyle = `rgba(${col},0.28)`; ctx.lineWidth = Math.max(1, u);
486
+ ctx.beginPath();
487
+ for (const q of list) {
488
+ moveParticle('rain', q, W, H, t, dt, u, wind);
489
+ const len = (10 + q.z * 16) * u;
490
+ ctx.moveTo(q.x, q.y); ctx.lineTo(q.x + wind * 0.02, q.y + len);
491
+ }
492
+ ctx.stroke();
493
+ ctx.restore();
494
+ return;
495
+ }
496
+ if (p.kind === 'fireflies' || p.kind === 'embers') ctx.globalCompositeOperation = 'lighter';
497
+ for (const q of list) {
498
+ moveParticle(p.kind, q, W, H, t, dt, u, wind);
499
+ const r = (1 + q.z * 3) * u;
500
+ if (p.kind === 'fireflies') {
501
+ const a = 0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * 1.6 + q.ph * 3), 3);
502
+ glow(ctx, q.x, q.y, 18 * u, col, a * 0.6);
503
+ } else if (p.kind === 'embers') {
504
+ glow(ctx, q.x, q.y, 8 * u, col, 0.6 * (0.5 + 0.5 * Math.sin(t * 5 + q.ph)));
505
+ } else if (p.kind === 'petals' || p.kind === 'leaves') {
506
+ ctx.fillStyle = `rgba(${col},0.85)`;
507
+ ctx.save(); ctx.translate(q.x, q.y); ctx.rotate(t * (0.5 + q.z) + q.ph); ctx.scale(1, Math.abs(Math.sin(t * 2 + q.ph)) * 0.8 + 0.2);
508
+ ctx.beginPath(); ctx.ellipse(0, 0, r * (p.kind === 'leaves' ? 2.4 : 1.6), r, 0, 0, 7); ctx.fill(); ctx.restore();
509
+ } else if (p.kind === 'bubbles') {
510
+ ctx.strokeStyle = `rgba(${col},0.5)`; ctx.lineWidth = Math.max(1, u * 0.8);
511
+ ctx.beginPath(); ctx.arc(q.x, q.y, r * 1.5, 0, 7); ctx.stroke();
512
+ } else {
513
+ ctx.fillStyle = `rgba(${col},${p.kind === 'dust' ? 0.35 : 0.3 + q.z * 0.55})`;
514
+ ctx.beginPath(); ctx.arc(q.x, q.y, p.kind === 'dust' ? r * 0.5 : r, 0, 7); ctx.fill();
515
+ }
516
+ }
517
+ ctx.restore();
518
+ }
519
+
520
+ // ── camera ─────────────────────────────────────────────────────────────
521
+ // A multiplane camera, the way 2.5D games do it: every layer and object is a flat
522
+ // cut-out standing at a real distance Z, seen through a perspective camera. Distances
523
+ // come from the picture itself: the eye is 1 unit above flat ground and the horizon is
524
+ // at screen height yh, so ground painted h (screen heights) below the horizon is 1/h
525
+ // away. Sky, sun, moon and stars are at infinity: walking never moves them.
526
+ // cam = { x: steps sideways, z: steps forward (world units, eye heights), y: gaze — down (+) / up (−) }.
527
+ const Z_NEAR = 0.6, Z_FAR = 150; // in eye heights (1.7 m)
528
+ const LAND = new Set(['mountains', 'hills', 'dunes', 'forest', 'city', 'water', 'ground']);
529
+ const depthAt = (y, yh) => (y > yh + 0.0125 ? clamp(1 / (y - yh), Z_NEAR, Z_FAR) : Z_FAR);
530
+ // The horizon is the base of the farthest landscape layer. Clouds and fog stand just
531
+ // behind the next nearer landscape layer (or in front of the last one).
532
+ // Typical heights (m) of what the scene may hold, and the viewer's eye height.
533
+ const EYE = 1.7;
534
+ const HEIGHT = { pine: 15, tree: 8, house: 6, tower: 18, lamp: 4, lantern: 2.2, campfire: 0.6, boat: 4, rock: 1, bench: 0.9, figure: 1.75, cat: 0.3, flower: 0.35, bridge: 3 };
535
+ // The horizon is at eye level, so it crosses everything standing on the ground at 1.7 m:
536
+ // a person at the eyes, a house low on its wall, a bench not at all. Each object painted
537
+ // `size` tall at `y` suggests a horizon at y − size·EYE/height; the median of those is
538
+ // the eye level of the scene (without objects: the base of the farthest landscape layer).
539
+ function horizonOf(spec, host) {
540
+ // (lanterns often hang in the air: they don't vote)
541
+ const guesses = (spec.objects || []).filter(o => HEIGHT[o.kind] && o.kind !== 'lantern' && !host(o) && o.size > 0)
542
+ .map(o => o.y - o.size * EYE / (o.kind === 'figure' && o.pose === 'sit' ? 1.2 : HEIGHT[o.kind]))
543
+ .sort((a, b) => a - b);
544
+ if (guesses.length) return clamp(guesses[guesses.length >> 1], 0.2, 0.95);
545
+ const land = (spec.layers || []).filter(L => LAND.has(L.kind));
546
+ return land.length ? Math.min(...land.map(L => L.y)) : 0.6;
547
+ }
548
+ function depths(spec) {
549
+ const layers = spec.layers || [];
550
+ // An object stands where its base is — unless it is attached to a bigger one (a window on
551
+ // a wall, a lantern on a porch): its base inside the other's outline. Then it shares that depth.
552
+ const objs = spec.objects || [];
553
+ const host = o => objs.find(h => h !== o && h.kind !== 'bird' && h.size > o.size &&
554
+ Math.abs(o.x - h.x) * 16 / 9 < h.size * 0.6 && o.y > h.y - h.size && o.y < h.y + 0.01);
555
+ // the director may set the eye level explicitly (spec.horizon); else it is estimated
556
+ const yh = typeof spec.horizon === 'number' ? clamp(spec.horizon, 0.05, 0.95) : horizonOf(spec, host);
557
+ const Z = layers.map(L => (LAND.has(L.kind) ? depthAt(L.y, yh) : null));
558
+ for (let i = 0; i < Z.length; i++) {
559
+ if (Z[i] !== null) continue;
560
+ const next = Z.slice(i + 1).find(z => z !== null), prev = Z.slice(0, i).reverse().find(z => z !== null);
561
+ Z[i] = next !== undefined ? next * 1.05 : prev !== undefined ? prev * 0.95 : Z_FAR;
562
+ }
563
+ // How far an object is: from how big it looks for what it is (a house painted 16% of
564
+ // the screen tall is ~22 eye heights away) — robust, unlike the height of its base above
565
+ // the horizon, which blows up for things painted near the horizon line.
566
+ // And so everything agrees on the eye level, each ground-standing object's base is nudged
567
+ // (usually 1–3% of the screen) to where, at that distance, it meets the ground:
568
+ // yh + size·EYE/height. Attached things move with their host; hanging lanterns stay.
569
+ const realH = o => (o.kind === 'figure' && o.pose === 'sit' ? 1.2 : HEIGHT[o.kind]);
570
+ const own = o => (realH(o) && o.size > 0 ? clamp(realH(o) / EYE / o.size, Z_NEAR, Z_FAR) : depthAt(o.y, yh));
571
+ const shift = o => {
572
+ const h = host(o);
573
+ if (h) return shift(h);
574
+ return realH(o) && o.size > 0 && o.kind !== 'lantern' ? yh + o.size * EYE / realH(o) - o.y : 0;
575
+ };
576
+ // Custom elements say where they are: `distance` (m) or `y` (where they touch the ground),
577
+ // plus `elevation` (m above the ground) and `height` (m); size on screen follows. Beyond Z_FAR
578
+ // an element is laid at Z_FAR (it barely moves as you walk anyway) but sized and placed on
579
+ // screen for its real distance — a range 8 km away looks 8 km away, not 30 times bigger.
580
+ const customZ = o => (o.distance > 0 ? clamp(o.distance / EYE, Z_NEAR, Z_FAR) : depthAt(o.y, yh));
581
+ const sightZ = o => (o.distance > 0 ? Math.max(Z_NEAR, o.distance / EYE) : depthAt(o.y, yh));
582
+ const custom = o => o.kind === 'custom' || !o.kind;
583
+ // across the view: `x` — where it is seen from the start, a fraction of the stage's width; or
584
+ // `across` — metres to the right of the start's line of sight (left: negative); or, looking
585
+ // around (around, roam), `bearing` — degrees from straight ahead (right: positive), with its
586
+ // `distance`: such an element is painted as if straight ahead, and stood where it is
587
+ const planeZ = o => (o.distance > 0 ? o.distance : 10) / EYE;
588
+ const bearing = o => typeof o.bearing === 'number' && o.distance > 0 && !o.sky;
589
+ const paintZ = o => (o.plane ? planeZ(o) : custom(o) ? sightZ(o) : own(host(o) || o));
590
+ const objectX = o => (bearing(o) ? 0.5 : typeof o.across === 'number' && !o.sky ? 0.5 + o.across / EYE / STAGE / paintZ(o) : o.x ?? 0.5);
591
+ const objectY = o => (o.sky || o.ground ? 0 : custom(o) ? yh + (1 - (o.elevation || 0) / EYE) / sightZ(o) : o.kind === 'bird' ? o.y : o.y + shift(o));
592
+ return {
593
+ yh, Z, objectX,
594
+ // where it stands in the world (eye heights: X to the right, Z ahead, Y its anchor's
595
+ // height) and the distance Zp it is painted for
596
+ objectAt: o => {
597
+ const Zp = paintZ(o), Y = 1 - (objectY(o) - yh) * Zp;
598
+ if (!bearing(o)) return { X: (objectX(o) - 0.5) * STAGE * Zp, Y, Z: Zp, Zp };
599
+ const b = o.bearing * Math.PI / 180, d = o.distance / EYE;
600
+ return { X: d * Math.sin(b), Y, Z: d * Math.cos(b), Zp };
601
+ },
602
+ // ground planes are drawn right after the sky, under everything standing on them
603
+ // a horizontal plane (gl.js) is ordered by its far edge, before what stands on it
604
+ objectZ: o => (o.sky ? Infinity : o.ground ? Z_FAR * 4 : o.plane ? clamp(planeZ(o) + (o.depth || 4) / 2 / EYE, Z_NEAR, Z_FAR) : custom(o) ? customZ(o) : o.kind === 'bird' ? 30 : own(host(o) || o)),
605
+ objectY,
606
+ objectSize: o => (o.sky || o.ground ? 1 : custom(o) ? (o.height || 2) / EYE / sightZ(o) : o.size),
607
+ };
608
+ }
609
+ // Screen transform [a b c d e f] for a plane at distance Z (Infinity = the sky): maps the
610
+ // scene as painted from the starting spot (on the stage) to the frame (W × H) from where the
611
+ // camera is now.
612
+ // Walking forward grows a plane by Z/(Z−z) around the vanishing point on the horizon.
613
+ // A camera raised to cam.h eye heights (1 = standing) sees a plane (h − 1)/D lower: things
614
+ // stay upright, the ground opens up below — a raised camera with a shifted, not tilted, lens.
615
+ function view(cam, Z, W, H, yh) {
616
+ if (!isFinite(Z)) return [1, 0, 0, 1, (W - STAGE * H) / 2, -cam.y * H];
617
+ const D = Math.max(1e-3, Z - cam.z), s = Z / D;
618
+ return [s, 0, 0, s, (W - s * STAGE * H) / 2 - H * cam.x / D, yh * H * (1 - s) - cam.y * H + H * ((cam.h ?? 1) - 1) / D];
619
+ }
620
+ // Looking around (around, roam): the camera also turns, cam.yaw radians (clockwise, 0 = the
621
+ // start's straight ahead). A point of the world (X, Y, Z eye heights) is seen at
622
+ // [x, y, its depth along the gaze], or null behind the eye. With yaw 0 it is what view() does.
623
+ function project(cam, X, Y, Z, W, H, yh) {
624
+ const rx = X - cam.x, rz = Z - cam.z, c = Math.cos(cam.yaw || 0), s = Math.sin(cam.yaw || 0);
625
+ const x = rx * c - rz * s, z = rx * s + rz * c;
626
+ if (z < 0.05) return null;
627
+ return [W / 2 + H * x / z, yh * H + H * ((cam.h ?? 1) - Y) / z - cam.y * H, z];
628
+ }
629
+ // An element as a card facing the eye: p = { X, Y, Z, Zp } and its anchor as painted (ax, ay):
630
+ // { m: screen transform of its painting, D: its depth }, or null when behind the eye
631
+ function billboard(cam, p, ax, ay, W, H, yh) {
632
+ const q = project(cam, p.X, p.Y, p.Z, W, H, yh);
633
+ if (!q) return null;
634
+ const s = p.Zp / q[2];
635
+ return { m: [s, 0, 0, s, q[0] - s * ax, q[1] - s * ay], D: q[2] };
636
+ }
637
+ // Where an element's painting (as depths() places it on the SW-wide stage) goes on the frame from
638
+ // camera cam: { m: the transform, D: its distance along the gaze }, or null behind the eye
639
+ function placeOf(o, cam, W, H, dz, round) {
640
+ if (!round) { const Z = dz.objectZ(o); return { m: view(cam, Z, W, H, dz.yh), D: Z - cam.z }; }
641
+ return billboard(cam, dz.objectAt(o), dz.objectX(o) * STAGE * H, dz.objectY(o) * H, W, H, dz.yh);
642
+ }
643
+ // The hero of a platformer h = { dx, dy (metres from where it is painted), flip } as a transform
644
+ // of its painting: its anchor painted at x = ax, `ppm` painted px per metre
645
+ const heroMatrix = (h, ax, ppm) => [h.flip ? -1 : 1, 0, 0, 1, (h.flip ? 2 * ax : 0) + h.dx * ppm, -h.dy * ppm];
646
+ // The full circle of the sky looking around: painted 2π stage heights wide, straight ahead in
647
+ // its middle; seen through the frame as a cylinder.
648
+ const TAU = 2 * Math.PI;
649
+ // Where you may walk and look (limitCam). Walking forward: as far as the things standing in the
650
+ // scene (not the far ones laid at Z_FAR — painted mountains, flat up close).
651
+ // An object painted at stage x, Z away, stands at world x = (x − ½)·STAGE·Z (+ its box centre).
652
+ const LOOK_UP = -0.3, LOOK_DOWN = 0.2, WALK_BACK = -0.6, TRACK = 40 / EYE, ROOM = 30 / EYE;
653
+ // The scene's camera (spec.camera) — how the viewer moves through it, one of:
654
+ // picture — a still picture: the camera stands, breathes a little and leans toward the
655
+ // pointer (or the phone's tilt), so the near things shift against the far ones;
656
+ // walk — stand at eye level and walk: forward and back, sideways, look up and down (default);
657
+ // panorama — a long place passed along sideways (a park alley, a street, a shore): along it
658
+ // as far as `span` [left, right] metres from the start, else as far as the things within
659
+ // TRACK of the viewer reach; the wheel comes closer as when walking, and closer you still
660
+ // pass along at that depth;
661
+ // overhead — raised `height` metres and looking down (`look`, screen heights): a map, a yard,
662
+ // a board; moving over it and closer;
663
+ // around — stand at one spot and turn your head all around (or within `turn` degrees either
664
+ // way) and up and down: things stand all around you (`bearing`);
665
+ // roam — walk about a place and look anywhere, as in a room: within `area` [left, right,
666
+ // back, front] metres of the start, else among the things standing around;
667
+ // platform — a platformer seen from the side: you run and jump as the `hero` element over
668
+ // the `solid` ones, the camera riding along (the mount's platform section).
669
+ // Looking around, elements are cards standing up in the world, facing the eye — or, with
670
+ // `facing` (degrees: where its face looks), turned their own way, like a wall or a cupboard.
671
+ // `follow`: elements the camera keeps in view — it slides sideways when one is leaving the
672
+ // visible part of the screen (not while the viewer moves it themselves).
673
+ const MODES = ['picture', 'walk', 'panorama', 'overhead', 'around', 'roam', 'platform'];
674
+ const SWAY = { picture: 0.033 };
675
+ function camSetup(spec) {
676
+ const c = spec?.camera || {}, mode = MODES.includes(c.mode) ? c.mode : 'walk';
677
+ const h = mode === 'overhead' ? clamp((c.height ?? 8) / EYE, 1, 60) : 1;
678
+ const look = clamp(c.look ?? (mode === 'overhead' ? 0.25 : 0), -0.3, 0.45);
679
+ const follow = Array.isArray(c.follow) ? c.follow.filter(id => typeof id === 'string') : [];
680
+ const span = mode === 'panorama' && Array.isArray(c.span) && c.span.length === 2 ? [-Math.abs(c.span[0]) / EYE, Math.abs(c.span[1]) / EYE] : null;
681
+ const round = mode === 'around' || mode === 'roam';
682
+ const turn = mode === 'around' && typeof c.turn === 'number' && c.turn > 0 && c.turn < 180 ? c.turn * Math.PI / 180 : null;
683
+ const area = ['roam', 'walk', 'overhead'].includes(mode) && Array.isArray(c.area) && c.area.length === 4 ? c.area.map(v => Math.abs(v) / EYE) : null;
684
+ const hero = mode === 'platform' && typeof c.hero === 'string' ? c.hero : null;
685
+ return { mode, h, look, follow: round || hero ? [] : follow, sway: SWAY[mode] || 0, span, round, turn, area, hero, jump: c.jump };
686
+ }
687
+ function limits(spec, W, H) {
688
+ const { Z, objectZ, objectX } = depths(spec), aspect = W / H;
689
+ const land = (spec.layers || []).map((L, i) => (LAND.has(L.kind) ? Z[i] : null)).filter(z => z !== null);
690
+ const things = (spec.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane).map(o => {
691
+ const z = objectZ(o), b = Array.isArray(o.box) && o.box.length === 4 ? (o.box[0] + o.box[2]) / 2 / EYE : 0;
692
+ return { x: (objectX(o) - 0.5) * STAGE * z + b, z };
693
+ });
694
+ // what stands in the scene shapes where you may go — not what is laid at Z_FAR
695
+ const real = things.filter(t => t.z < Z_FAR * 0.99), around = real.length ? real : things;
696
+ const far = around.length ? Math.max(...around.map(t => t.z)) : Math.max(1, ...land.filter(z => z < Z_FAR * 0.9));
697
+ const setup = camSetup(spec);
698
+ // roaming: within the area, else among the things standing within ROOM of the start, a
699
+ // little inside them
700
+ let room = null;
701
+ if (setup.mode === 'roam') {
702
+ if (setup.area) { const [l, r, b, f] = setup.area; room = [-l, r, -b, f]; }
703
+ else {
704
+ const dz = depths(spec), at = (spec.objects || []).filter(o => !o.sky && !o.ground && o.kind !== 'bird').map(o => dz.objectAt(o)).filter(p => Math.hypot(p.X, p.Z) <= ROOM);
705
+ const xs = [0, ...at.map(p => p.X)], zs = [0, ...at.map(p => p.Z)], k = 1 / EYE;
706
+ room = [Math.min(0, Math.min(...xs) + k), Math.max(0, Math.max(...xs) - k), Math.min(0, Math.min(...zs) + k), Math.max(0, Math.max(...zs) - k)];
707
+ }
708
+ }
709
+ return { aspect, things: around, zMax: clamp(far - 1.1, 1, 100), setup, room };
710
+ }
711
+ function limitCam(cam, lim) {
712
+ const s = lim.setup;
713
+ if (s?.round) {
714
+ cam.y = clamp(cam.y, s.look - 0.6, s.look + 0.6);
715
+ if (s.turn) cam.yaw = clamp(cam.yaw, -s.turn, s.turn);
716
+ if (lim.room) { cam.x = clamp(cam.x, lim.room[0], lim.room[1]); cam.z = clamp(cam.z, lim.room[2], lim.room[3]); }
717
+ else { cam.x = 0; cam.z = 0; }
718
+ return;
719
+ }
720
+ if (s?.sway) { // stays put: only the small sway
721
+ cam.x = clamp(cam.x, -s.sway, s.sway); cam.z = 0; cam.y = clamp(cam.y, s.look - s.sway / 3, s.look + s.sway / 3);
722
+ return;
723
+ }
724
+ if (s?.mode === 'platform') { // the camera rides along with the hero (the mount), over the level
725
+ cam.z = 0;
726
+ cam.y = clamp(cam.y, s.look - 0.8, s.look + 0.15);
727
+ if (lim.level) cam.x = clamp(cam.x, lim.level[0], lim.level[1]);
728
+ return;
729
+ }
730
+ if (s?.mode === 'panorama') {
731
+ cam.z = clamp(cam.z, 0, Math.max(lim.zMax, lim.reach ?? -Infinity));
732
+ cam.y = clamp(cam.y, s.look + LOOK_UP / 2, s.look + LOOK_DOWN / 2);
733
+ let lo = 0, hi = 0;
734
+ if (s.span) [lo, hi] = s.span;
735
+ else {
736
+ // as far as the nearest things reach: the outermost one may come to the frame's middle
737
+ const near = lim.things.filter(t => t.z <= TRACK);
738
+ if (near.length) { lo = Math.min(0, ...near.map(t => t.x)); hi = Math.max(0, ...near.map(t => t.x)); }
739
+ }
740
+ cam.x = clamp(cam.x, lo, hi);
741
+ return;
742
+ }
743
+ cam.y = clamp(cam.y, (s?.look ?? 0) + LOOK_UP, (s?.look ?? 0) + LOOK_DOWN);
744
+ // Where you may stand: what is seen from the back of the bounds — a triangle as wide as the
745
+ // frame (a camera inside it sees only what lies inside it too, so walking toward anything you
746
+ // see never leaves it), within the scene's area when it gives one; forward as far as the
747
+ // things in the scene, or as far as the wheel took you toward one. Walking, the start is the
748
+ // back: the farthest you can draw back, and no stepping aside there — only coming closer.
749
+ let zb = s?.mode === 'walk' ? 0 : WALK_BACK, zf = Math.max(lim.zMax, lim.reach ?? -Infinity), lo = -Infinity, hi = Infinity;
750
+ if (s?.area) { const [l, r, b, f] = s.area; zb = -b; zf = f; lo = -l; hi = r; }
751
+ cam.z = clamp(cam.z, zb, zf);
752
+ const hw = Math.max(0, cam.z - zb) * lim.aspect / 2;
753
+ cam.x = clamp(cam.x, Math.max(lo, -hw), Math.min(hi, hw));
754
+ }
755
+ // near things fade out as you walk up to and past them
756
+ const fadeNear = (Z, cam) => clamp((Z - cam.z - 0.3) / 0.6, 0, 1);
757
+
758
+ function place(ctx, S, cam, Z) { ctx.transform(...view(cam, Z, S.W, S.H, S.yh)); }
759
+
760
+ // ── scene build + frame ────────────────────────────────────────────────
761
+ // W × H: the frame; everything is painted on the stage, SW × H
762
+ function build(spec, W, H) {
763
+ const u = H / 900, seed = hash(JSON.stringify(spec.layers || []) + (spec.title || '')), rnd = seeded(seed);
764
+ const SW = STAGE * H, x0 = -EXT * SW, span = (1 + 2 * EXT) * SW;
765
+ const dz = depths(spec);
766
+ const starCount = Math.round((spec.stars?.density ?? 0) * span * H / 2500 / (u * u));
767
+ const stars = Array.from({ length: starCount }, () => ({ x: x0 + rnd() * span, y: -0.45 * H + Math.pow(rnd(), 1.4) * H * 1.15, r: (0.4 + Math.pow(rnd(), 4) * 1.6) * u, p: rnd() * 7, f: 0.4 + rnd() * 2 }));
768
+ const layers = (spec.layers || []).map((L, i) => {
769
+ const Z = dz.Z[i];
770
+ if (L.kind === 'clouds') {
771
+ const clouds = Array.from({ length: Math.round((3 + (L.density ?? 0.5) * 9) * (1 + 2 * EXT)) }, () => {
772
+ const cw = SW * (0.12 + rnd() * 0.2), ch = cw * 0.35, c = layer(cw, ch), cg = c.getContext('2d');
773
+ for (let k = 0; k < 7; k++) glow(cg, cw * (0.2 + rnd() * 0.6), ch * (0.45 + rnd() * 0.25), ch * (0.35 + rnd() * 0.3), rgb(L.color), 0.55);
774
+ return { c, x: rnd() * span, y: L.y * H + (rnd() - 0.5) * (L.height ?? 0.1) * H, sp: (4 + rnd() * 8) * u };
775
+ });
776
+ return { L, Z, clouds };
777
+ }
778
+ if (L.kind === 'fog') return { L, Z, blobs: Array.from({ length: 10 }, () => ({ x: rnd() * span, dy: (rnd() - 0.5) * (L.height ?? 0.08) * H, rx: SW * (0.2 + rnd() * 0.25), ry: H * (0.03 + rnd() * 0.04), sp: (5 + rnd() * 10) * u, a: 0.06 + rnd() * 0.06 })) };
779
+ if (L.kind === 'water') return { L, Z };
780
+ return { L, Z, ...staticLayer(L, SW, H, seed + i * 101) };
781
+ }).sort((a, b) => b.Z - a.Z);
782
+ // looking around, each also stands somewhere in the world (p, depths().objectAt)
783
+ const round = camSetup(spec).round;
784
+ const objects = (spec.objects || []).map(o => ({ o: { ...o, x: dz.objectX(o), y: dz.objectY(o), size: dz.objectSize(o), _yh: dz.yh }, Z: dz.objectZ(o), p: round && !o.sky && !o.ground ? dz.objectAt(o) : null })).sort((a, b) => (b.Z - a.Z) || 0);
785
+ const sky = !spec.sky ? '#000000' : (() => {
786
+ const c = document.createElement('canvas').getContext('2d');
787
+ const g = c.createLinearGradient(0, 0, 0, H);
788
+ g.addColorStop(0, spec.sky.top); g.addColorStop(0.55, spec.sky.middle); g.addColorStop(1, spec.sky.bottom);
789
+ return g;
790
+ })();
791
+ const particles = (spec.particles || []).map(p => ({ p, list: makeParticles(p, W, H, rnd) }));
792
+ // vignette depends only on the scene, not the frame: paint it once
793
+ const vignette = layer(W, H), vg = vignette.getContext('2d');
794
+ const v = vg.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.hypot(W, H) * 0.6);
795
+ v.addColorStop(0, 'rgba(0,0,0,0)');
796
+ v.addColorStop(1, `rgba(0,0,0,${0.75 * clamp(spec.light?.vignette ?? 0.5, 0, 1)})`);
797
+ vg.fillStyle = v; vg.fillRect(0, 0, W, H);
798
+ return { W, H, SW, u, x0, span, yh: dz.yh, round, lim: limits(spec, W, H), sky, stars, layers, particles, objects, vignette, scratch: layer(W, H) };
799
+ }
800
+
801
+ // Looking around, with Canvas 2D (the fallback): the sky turned as a flat band (not bent into a
802
+ // cylinder), then the cards facing the eye, far to near. Cards turned their own way (`facing`),
803
+ // like planes, are drawn by the WebGL renderer only.
804
+ function aroundFrame(ctx, S, cam, t) {
805
+ const { W, H, SW } = S, full = TAU * H, off = W / 2 - full / 2 - cam.yaw * H;
806
+ for (const { o } of S.objects) {
807
+ if (!o.sky) continue;
808
+ for (let x0 = ((off % full) + full) % full - full; x0 < W; x0 += full) {
809
+ const lights = [];
810
+ ctx.save(); ctx.translate(x0, -cam.y * H);
811
+ drawObject(ctx, o, full, H, t, lights); drawLights(ctx, lights);
812
+ ctx.restore();
813
+ }
814
+ }
815
+ const seen = [];
816
+ for (const { o, p } of S.objects) {
817
+ if (o.sky || o.ground || o.plane || typeof o.facing === 'number') continue;
818
+ const b = billboard(cam, p, o.x * SW, o.y * H, W, H, S.yh);
819
+ if (b) seen.push({ o, b });
820
+ }
821
+ seen.sort((a, b) => b.b.D - a.b.D);
822
+ for (const { o, b } of seen) {
823
+ const fade = clamp((b.D - 0.3) / 0.6, 0, 1), lights = [];
824
+ if (fade <= 0) continue;
825
+ ctx.save(); ctx.transform(...b.m); ctx.globalAlpha = fade;
826
+ drawObject(ctx, o, SW, H, t, lights); drawLights(ctx, lights);
827
+ ctx.restore();
828
+ }
829
+ }
830
+
831
+ function drawWater(ctx, S, L, wy, t) {
832
+ const { W, H, u } = S, depth = H - wy, step = Math.max(3, Math.round(u * 3));
833
+ if (depth <= 0) return;
834
+ const k = Math.min(1.2, wy / depth), speed = 1 + Math.abs(L.drift ?? 0) * 2;
835
+ // copy only the band above the horizon that the reflection actually shows
836
+ const band = Math.max(0, Math.floor(wy - depth * k - step)), sc = S.scratch.getContext('2d');
837
+ sc.clearRect(0, band, W, wy - band);
838
+ sc.drawImage(ctx.canvas, S.out.x, S.out.y + band, W, wy - band, 0, band, W, wy - band);
839
+ for (let y = wy; y < H; y += step) {
840
+ const d = (y - wy) / depth;
841
+ const dx = (Math.sin(30 * Math.log(0.03 + d) - t * 1.4 * speed) * 0.6 + Math.sin(d * 53 + t * 0.8) * 0.4) * (1 + d * 16) * u;
842
+ ctx.drawImage(S.scratch, 0, Math.max(0, wy - 1 - (y - wy) * k), W, step, dx - 20 * u, y, W + 40 * u, step);
843
+ }
844
+ const col = rgb(L.color), gr = ctx.createLinearGradient(0, wy, 0, H);
845
+ gr.addColorStop(0, `rgba(${col},0.35)`); gr.addColorStop(1, `rgba(${col},0.8)`);
846
+ ctx.fillStyle = gr; ctx.fillRect(0, wy, W, depth);
847
+ ctx.fillStyle = 'rgba(255,255,255,0.06)'; ctx.fillRect(0, wy - u, W, 2 * u);
848
+ }
849
+
850
+ const wrapX = (x, S, w) => ((x % (S.span + w)) + S.span + w) % (S.span + w) + S.x0 - w;
851
+
852
+ // S.out: where the frame lies in the canvas (pixels); the scene goes on around it as painted
853
+ function frame(ctx, S, spec, cam, t, dt) {
854
+ const { W, H, SW, u } = S, out = S.out;
855
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
856
+ ctx.fillStyle = '#000000';
857
+ ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
858
+ ctx.translate(out.x, out.y);
859
+ ctx.save(); place(ctx, S, cam, Infinity);
860
+ ctx.fillStyle = S.sky;
861
+ ctx.fillRect(S.x0, -H, S.span, 3 * H);
862
+ if (S.stars.length) {
863
+ ctx.fillStyle = spec.stars?.color || '#ffffff';
864
+ for (const s of S.stars) {
865
+ ctx.globalAlpha = 0.25 + 0.75 * Math.pow(0.5 + 0.5 * Math.sin(t * s.f + s.p), 2);
866
+ ctx.fillRect(s.x - s.r, s.y - s.r, s.r * 2, s.r * 2);
867
+ }
868
+ ctx.globalAlpha = 1;
869
+ }
870
+ for (const c of spec.celestials || []) {
871
+ const cx = c.x * SW, cy = c.y * H, r = c.size * H / 2, col = rgb(c.color);
872
+ glow(ctx, cx, cy, r * 9, col, 0.35 * (c.glow ?? 0.5));
873
+ ctx.fillStyle = c.color; ctx.beginPath(); ctx.arc(cx, cy, r, 0, 7); ctx.fill();
874
+ if (c.kind === 'moon') { ctx.fillStyle = 'rgba(0,0,0,0.07)'; for (const [dx, dy, rr] of [[-0.3, -0.2, 0.25], [0.28, 0.15, 0.17], [-0.05, 0.4, 0.13]]) { ctx.beginPath(); ctx.arc(cx + dx * r, cy + dy * r, rr * r, 0, 7); ctx.fill(); } }
875
+ if (c.kind === 'planet') { ctx.strokeStyle = `rgba(${col},0.6)`; ctx.lineWidth = r * 0.08; ctx.beginPath(); ctx.ellipse(cx, cy, r * 1.8, r * 0.4, -0.3, 0, 7); ctx.stroke(); }
876
+ }
877
+ ctx.restore();
878
+
879
+ for (const l of S.round ? [] : S.layers) {
880
+ const L = l.L;
881
+ if (l.Z - cam.z < 0.05) continue;
882
+ if (L.kind === 'water') { const m = view(cam, l.Z, W, H, S.yh); drawWater(ctx, S, L, Math.round(m[5] + m[3] * L.y * H), t); continue; }
883
+ ctx.save(); place(ctx, S, cam, l.Z);
884
+ if (l.canvas) {
885
+ ctx.drawImage(l.canvas, l.x0, 0);
886
+ ctx.fillStyle = L.color; ctx.fillRect(l.x0, l.canvas.height - 1, l.canvas.width, 50 * H); // the body goes on below
887
+ if (l.windows?.length) {
888
+ const lc = rgb('#ffd08a');
889
+ for (const w of l.windows) {
890
+ if (Math.sin(t * 0.07 + w.p) > -0.85) { ctx.fillStyle = `rgba(${lc},${0.55 + 0.25 * Math.sin(w.p)})`; ctx.fillRect(w.x, w.y, w.w, w.h); }
891
+ }
892
+ }
893
+ } else if (L.kind === 'clouds') {
894
+ for (const c of l.clouds) ctx.drawImage(c.c, wrapX(c.x + t * c.sp * (L.drift ?? 0.3) * 3, S, c.c.width), c.y - c.c.height / 2);
895
+ } else if (L.kind === 'fog') {
896
+ const col = rgb(L.color);
897
+ for (const b of l.blobs) {
898
+ ctx.save(); ctx.translate(wrapX(b.x + t * b.sp * (L.drift ?? 0.3) * 2, S, 2 * b.rx) + b.rx, L.y * H + b.dy);
899
+ ctx.scale(1, b.ry / b.rx); glow(ctx, 0, 0, b.rx, col, b.a * 2.2); ctx.restore();
900
+ }
901
+ }
902
+ ctx.restore();
903
+ }
904
+
905
+ if (S.round) aroundFrame(ctx, S, cam, t);
906
+ else for (const { o, Z } of S.objects) {
907
+ if (o.plane) continue; // horizontal planes are drawn by the WebGL renderer only
908
+ const lights = [], fade = fadeNear(Z, cam);
909
+ if (fade <= 0) continue;
910
+ ctx.save(); place(ctx, S, cam, Z); ctx.globalAlpha = fade;
911
+ if (cam.hero && o.id === cam.hero.id) ctx.transform(...heroMatrix(cam.hero, o.x * SW, o.size * H / (o.height || 2)));
912
+ drawObject(ctx, o, SW, H, t, lights);
913
+ drawLights(ctx, lights);
914
+ ctx.restore();
915
+ }
916
+
917
+ for (const { p, list } of S.particles) stepParticles(ctx, p, list, W, H, t, dt, u);
918
+ const lt = spec.light || {};
919
+ if (lt.tint && lt.tintStrength) {
920
+ ctx.save(); ctx.globalAlpha = clamp(lt.tintStrength, 0, 0.5) * 0.45;
921
+ ctx.fillStyle = lt.tint; ctx.fillRect(0, 0, W, H); ctx.restore();
922
+ }
923
+ ctx.drawImage(S.vignette, 0, 0);
924
+ if ((lt.grain ?? 0.5) > 0) {
925
+ ctx.save(); ctx.globalAlpha = clamp(lt.grain ?? 0.5, 0, 1);
926
+ ctx.translate(Math.random() * 160, Math.random() * 160); ctx.fillStyle = S.grain; ctx.fillRect(-160, -160, W + 320, H + 320); ctx.restore();
927
+ }
928
+ // out of the frame the scene dims toward the canvas's edges (with Canvas 2D, what nothing
929
+ // paints stays black, and water, particles and grain keep to the frame)
930
+ for (const [x0, y0, x1, y1, rx, ry, rw, rh] of [[0, 0, -H / 2, 0, -out.x, -out.y, out.x, out.ch], [W, 0, W + H / 2, 0, W, -out.y, out.cw - out.x - W, out.ch],
931
+ [0, 0, 0, -H / 2, -out.x, -out.y, out.cw, out.y], [0, H, 0, H * 1.5, -out.x, H, out.cw, out.ch - out.y - H]]) {
932
+ if (rw <= 0 || rh <= 0) continue;
933
+ const g = ctx.createLinearGradient(x0, y0, x1, y1);
934
+ g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(0,0,0,0.45)');
935
+ ctx.fillStyle = g; ctx.fillRect(rx, ry, rw, rh);
936
+ }
937
+ }
938
+
939
+ // Mounts the engine on a canvas: returns { show(spec), resetCamera() }.
940
+ // Camera: drag = step sideways / turn the head up-down, wheel / pinch = walk toward the cursor,
941
+ // keys: A D ← → = sideways, W S ↑ ↓ = forward-back, 0 = back to the start.
942
+ // The 2D renderer: everything re-drawn with Canvas 2D every frame. Fallback for
943
+ // browsers without WebGL2; the WebGL renderer (gl.js) is much lighter on the GPU.
944
+ function canvas2DRenderer(canvas) {
945
+ // opaque canvas: the scene paints every pixel, so the browser needn't blend it with the page
946
+ const ctx = canvas.getContext('2d', { alpha: false });
947
+ const grain = ctx.createPattern(grainTile, 'repeat');
948
+ return {
949
+ name: '2d', fpsIdle: 20, pixelBudget: 1.3e6, resMax: 1.5,
950
+ build(spec, W, H, prev, out) { const S = build(spec, W, H); S.grain = grain; S.out = out; return S; },
951
+ frame(S, spec, cam, t, dt) { frame(ctx, S, spec, cam, t, dt); },
952
+ };
953
+ }
954
+
955
+ function mount(canvas) {
956
+ // a canvas gets one context type for life: WebGL2 when available, else 2D
957
+ // (?2d in the URL forces the 2D renderer, for comparison)
958
+ const R = (typeof MooodGL !== 'undefined' && !/[?&]2d\b/.test(location.search) && MooodGL.create(canvas)) || canvas2DRenderer(canvas);
959
+ // t: seconds since the stage was made, by the real clock — what the painting code sees (e.t),
960
+ // in step with the state's moves; dt: the step of the camera's motion (capped, so a long frame
961
+ // doesn't throw it)
962
+ const born = performance.now();
963
+ let spec = null, S = null, t = 0, last = born, pending;
964
+ const cam = { x: 0, y: 0, z: 0, h: 1, yaw: 0 }, target = { x: 0, y: 0, z: 0, h: 1, yaw: 0 };
965
+ let seen = [0, 1], lim = { aspect: STAGE, zMax: 1, things: [], view: [0, 1] };
966
+ // the frame within the canvas (CSS px): landscape, centred, bars around it
967
+ let box = { x: 0, y: 0, w: 1, h: 1 };
968
+ const frameBox = () => {
969
+ const cw = Math.max(1, canvas.clientWidth), ch = Math.max(1, canvas.clientHeight), a = clamp(cw / ch, NARROWEST, WIDEST);
970
+ const w = Math.min(cw, ch * a), h = w / a;
971
+ return { x: (cw - w) / 2, y: (ch - h) / 2, w, h };
972
+ };
973
+ // a point of the page (clientX, clientY) in the frame's CSS px — the canvas may be scaled
974
+ const local = (cx, cy) => {
975
+ const r = canvas.getBoundingClientRect(), k = r.width / Math.max(1, canvas.clientWidth) || 1;
976
+ return [(cx - r.left) / k - box.x, (cy - r.top) / k - box.y];
977
+ };
978
+ // the part of the frame the page's panels leave open, as fractions of its width
979
+ const openView = () => [0, 1].map(i => clamp((seen[i] * canvas.clientWidth - box.x) / box.w, 0, 1));
980
+ const keys = new Set(), pointers = new Map();
981
+ let pinch = null;
982
+
983
+ // GPU budget. A calm scene doesn't need the display's refresh rate (120–165 Hz on
984
+ // many laptops): render fpsIdle while idle, 60 while the camera moves. The canvas is
985
+ // capped at the renderer's pixel budget whatever the monitor, and stretched by the
986
+ // browser — invisible in this soft, hazy style.
987
+ const FPS_ACTIVE = 60, PIXEL_BUDGET = R.pixelBudget, RES_MIN = 0.6;
988
+ let fpsIdle = R.fpsIdle;
989
+ const resCap = () => Math.min(window.devicePixelRatio || 1, R.resMax, Math.sqrt(PIXEL_BUDGET / Math.max(1, canvas.clientWidth * canvas.clientHeight)));
990
+ let res = resCap(), frameEma = 1 / fpsIdle, checkAt = performance.now() + 3000, lastDowngrade = 0, activeUntil = 0;
991
+ let waiting = null; // a spec to show once its modules are here
992
+ const rebuild = () => {
993
+ box = frameBox();
994
+ res = Math.min(res, resCap());
995
+ canvas.width = Math.round(canvas.clientWidth * res);
996
+ canvas.height = Math.round(canvas.clientHeight * res);
997
+ if (spec) {
998
+ S = R.build(spec, Math.round(box.w * res), Math.round(box.h * res), S, { x: Math.round(box.x * res), y: Math.round(box.y * res), cw: canvas.width, ch: canvas.height });
999
+ const was = lim.setup;
1000
+ lim = { ...S.lim, view: openView(), level: plat?.level };
1001
+ // a new camera setup: go to its starting spot at once
1002
+ if (!was || was.mode !== lim.setup.mode || was.h !== lim.setup.h || was.look !== lim.setup.look) { reset(); Object.assign(cam, target); }
1003
+ limitCam(target, lim);
1004
+ }
1005
+ };
1006
+ // If the machine can't hold the frame rate: first a calmer idle rate, then fewer pixels.
1007
+ const adapt = (now, target) => {
1008
+ if (now < checkAt || !S) return;
1009
+ checkAt = now + 2000;
1010
+ if (frameEma > 1.4 / target && target === fpsIdle && fpsIdle > 30) {
1011
+ fpsIdle = 30;
1012
+ lastDowngrade = now;
1013
+ } else if (frameEma > 1.4 / target && res > RES_MIN) {
1014
+ res = Math.max(RES_MIN, res * 0.8);
1015
+ lastDowngrade = now;
1016
+ rebuild();
1017
+ } else if (frameEma < 1.05 / target && res < resCap() && now - lastDowngrade > 15000) {
1018
+ res = Math.min(resCap(), res * 1.15);
1019
+ rebuild();
1020
+ }
1021
+ };
1022
+ const perf = document.createElement('div');
1023
+ perf.style.cssText = 'position:fixed;left:8px;top:8px;z-index:9;font:12px/1.4 ui-monospace,Consolas,monospace;color:#cfe;background:rgba(0,0,0,.6);padding:4px 8px;border-radius:6px;pointer-events:none;display:none;white-space:pre';
1024
+ document.body.append(perf);
1025
+ let frames = 0, perfAt = performance.now(), drawMs = 0;
1026
+ addEventListener('resize', () => { clearTimeout(pending); pending = setTimeout(rebuild, 150); });
1027
+
1028
+ // Wheel / pinch: walk along the ray through the cursor toward what is seen under it — the
1029
+ // first thing truly painted at that point (not a veil of leaves, not a transparent edge), else
1030
+ // the ground there, else (the sky) a few steps ahead. It is chosen when the gesture starts or
1031
+ // the pointer moves, and kept till the gesture ends; it stays under the cursor all the way —
1032
+ // the gaze lifting or lowering with it — and the walk stops where it about fills the frame
1033
+ // (something high up: where it still shows with the gaze lifted as far as it goes).
1034
+ let aim = null, aimAt = 0;
1035
+ // is the element truly painted at the cursor: nearly opaque paint on a tenth of the 9×9
1036
+ // pixels around it (the thin spokes of a wheel count; a veil, a glow, empty air don't)?
1037
+ const probe = layer(9, 9), pg = probe.getContext('2d', { willReadFrequently: true });
1038
+ const seenAt = (o, m, cx, cy, Hc) => {
1039
+ pg.setTransform(1, 0, 0, 1, 0, 0);
1040
+ pg.clearRect(0, 0, 9, 9);
1041
+ pg.setTransform(m[0], 0, 0, m[3], m[4] - cx + 4.5, m[5] - cy + 4.5);
1042
+ try { drawObject(pg, o, STAGE * Hc, Hc, t, []); } catch { return false; }
1043
+ const d = pg.getImageData(0, 0, 9, 9).data;
1044
+ let n = 0;
1045
+ for (let i = 3; i < d.length; i += 4) if (d[i] > 160) n++;
1046
+ return n >= 8;
1047
+ };
1048
+ const pick = (clientX, clientY) => {
1049
+ const [cx, cy] = local(clientX, clientY), Hc = box.h, Wc = box.w, yh = S?.yh ?? 0.6, dz = spec ? depths(spec) : null;
1050
+ const at = D => ({ Z: target.z + D, x: target.x + (cx - Wc / 2) / Hc * D });
1051
+ const near = (spec?.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane).map(o => ({ o: { ...o, x: dz.objectX(o), y: dz.objectY(o), size: dz.objectSize(o), _yh: yh }, Z: dz.objectZ(o) }))
1052
+ .filter(({ Z }) => fadeNear(Z, target) > 0).sort((a, b) => a.Z - b.Z);
1053
+ for (const { o, Z } of near) {
1054
+ const m = view(target, Z, Wc, Hc, yh), b = objectBox(o, STAGE * Hc, Hc);
1055
+ const x0 = m[0] * b.bx + m[4], y0 = m[3] * b.by + m[5];
1056
+ if (cx < x0 || cx > x0 + m[0] * b.bw || cy < y0 || cy > y0 + m[3] * b.bh) continue;
1057
+ if (!seenAt(o, m, cx, cy, Hc)) continue;
1058
+ // the point under the cursor, Y eye heights up; stop where the thing about fills the
1059
+ // frame, or — high up — where the point still shows with the gaze lifted as far as it goes
1060
+ const D = Z - target.z, Y = target.h - (cy / Hc - yh + target.y) * D, up = (lim.setup?.look ?? 0) + LOOK_UP;
1061
+ const sight = Y > target.h ? (Y - target.h) / Math.max(0.05, yh - cy / Hc - up) : 0;
1062
+ return { ...at(D), Y, cy: cy / Hc, stop: Math.max(1.1, 1.15 * o.size * Z, sight) };
1063
+ }
1064
+ const below = cy - (yh - target.y) * Hc;
1065
+ return { ...at(below > 0.02 * Hc ? clamp(Hc * target.h / below, 0.8, 100) : 6), stop: 1.2 };
1066
+ };
1067
+ const walkToward = (factor, clientX, clientY) => {
1068
+ const now = performance.now();
1069
+ if (!aim || now - aimAt > 400 || Math.hypot(clientX - aim.px, clientY - aim.py) > 6) aim = { ...pick(clientX, clientY), px: clientX, py: clientY };
1070
+ aimAt = now;
1071
+ const d0 = aim.Z - target.z;
1072
+ if (d0 <= 0.05) return;
1073
+ // each notch covers a share of the way left (the approach slows by itself), up to the stop
1074
+ const d = factor > 1 ? Math.max(Math.min(d0, aim.stop), d0 / factor) : d0 / factor;
1075
+ target.z = aim.Z - d;
1076
+ target.x = aim.x - (aim.x - target.x) * d / d0;
1077
+ if (aim.Y !== undefined) target.y = (S?.yh ?? 0.6) - aim.cy + (target.h - aim.Y) / Math.max(0.05, d);
1078
+ // what you walked up to may lie past where walking goes: you may stand there
1079
+ lim.reach = target.z > lim.zMax ? Math.max(lim.reach ?? 0, target.z) : undefined;
1080
+ limitCam(target, lim);
1081
+ };
1082
+
1083
+ // Drag: sideways = step, keeping what you took hold of (an object, else that spot of
1084
+ // ground) under the finger — a step of dx·D/H for its distance D, so near grass moves you
1085
+ // a little and a far house carries you along. Up / down = turn your head: the gaze tilts
1086
+ // and the grabbed point follows the finger. Walking is the wheel, pinch or keys.
1087
+ // Let go while moving and the camera glides on a little.
1088
+ let grab = null, glide = null, lastMove = 0;
1089
+ const grabAt = (clientX, clientY) => {
1090
+ // looking around, a drag turns the head: what you took hold of follows the finger
1091
+ if (round()) { const [cx, cy] = local(clientX, clientY); grab = { round: true, cx, cy, yaw: target.yaw, look: target.y }; return; }
1092
+ const [cx, cy] = local(clientX, clientY), D = clamp(pick(clientX, clientY).Z - target.z, 0.4, 60);
1093
+ grab = { x: target.x + (cx - box.w / 2) / box.h * D, D, cy, look: target.y };
1094
+ };
1095
+ const dragTo = (clientX, clientY) => {
1096
+ const [cx, cy] = local(clientX, clientY), x0 = target.x, y0 = target.y, now = performance.now();
1097
+ if (grab.round) {
1098
+ const yaw0 = target.yaw;
1099
+ target.yaw = grab.yaw - (Math.atan((cx - box.w / 2) / box.h) - Math.atan((grab.cx - box.w / 2) / box.h));
1100
+ target.y = grab.look - (cy - grab.cy) / box.h;
1101
+ limitCam(target, lim);
1102
+ const dt = Math.max(8, now - lastMove) / 1000, k = Math.min(1, dt * 20);
1103
+ glide = glide || { vx: 0, vy: 0, vyaw: 0 };
1104
+ glide.vyaw += ((target.yaw - yaw0) / dt - glide.vyaw) * k;
1105
+ lastMove = now;
1106
+ return;
1107
+ }
1108
+ target.x = grab.x - (cx - box.w / 2) / box.h * grab.D;
1109
+ target.y = grab.look - (cy - grab.cy) / box.h;
1110
+ limitCam(target, lim);
1111
+ const dt = Math.max(8, now - lastMove) / 1000, k = Math.min(1, dt * 20);
1112
+ glide = glide || { vx: 0, vy: 0, vyaw: 0 };
1113
+ glide.vx += ((target.x - x0) / dt - glide.vx) * k;
1114
+ glide.vy += ((target.y - y0) / dt - glide.vy) * k;
1115
+ lastMove = now;
1116
+ };
1117
+ // where in the canvas (CSS px) an element is from camera c — the point its code marks (e.at),
1118
+ // else its anchor (its top, with over) — lifted by `lift` metres: [x, y, its distance]; null
1119
+ // when out of sight
1120
+ function seenFrom(c, id, lift = 0, over = false) {
1121
+ const o = (spec?.objects || []).find(o => o.id === id);
1122
+ if (!o || o.sky) return null;
1123
+ const W = box.w, H = box.h, dz = depths(spec), yh = dz.yh;
1124
+ if (round()) {
1125
+ // looking around: its anchor (or the point its code marks) seen from where you stand
1126
+ const p = dz.objectAt(o);
1127
+ if (o.plane || !FOCUS.has(id)) {
1128
+ const q = project(c, p.X, p.Y + (lift + (over && !o.plane ? o.height ?? EYE : 0)) / EYE, p.Z, W, H, yh);
1129
+ return q && q[2] > 0.3 ? [box.x + q[0], box.y + q[1], q[2]] : null;
1130
+ }
1131
+ const b = billboard(c, p, dz.objectX(o) * STAGE * H, dz.objectY(o) * H, W, H, yh), f = FOCUS.get(id);
1132
+ if (!b || b.D < 0.3) return null;
1133
+ return [box.x + b.m[0] * f.x * STAGE * H + b.m[4], box.y + b.m[3] * (f.y * H - (lift / EYE) * H / p.Zp) + b.m[5], b.D];
1134
+ }
1135
+ const Z = o.plane ? (o.distance > 0 ? o.distance : 10) / EYE : dz.objectZ(o);
1136
+ if (Z - c.z < 0.3 || fadeNear(Z, c) < 0.3) return null;
1137
+ const f = FOCUS.get(id) || { x: dz.objectX(o), y: o.plane ? yh + (1 - (o.elevation || 0) / EYE) / Z : dz.objectY(o) };
1138
+ if (over && !FOCUS.has(id)) lift += o.height ?? EYE;
1139
+ const m = view(c, Z, W, H, yh);
1140
+ let px = f.x * STAGE * H, py = f.y * H - (lift / EYE) * H / Z;
1141
+ if (c.hero && id === c.hero.id) { const hm = heroMatrix(c.hero, dz.objectX(o) * STAGE * H, dz.objectSize(o) * H / (o.height || 2)); px = hm[0] * px + hm[4]; py += hm[5]; }
1142
+ return [box.x + m[0] * px + m[4], box.y + m[3] * py + m[5], Z - c.z];
1143
+ }
1144
+ // the camera keeps the elements it follows in the visible part of the screen: together when
1145
+ // they fit, else the first of them
1146
+ function keepInView() {
1147
+ const ids = lim.setup?.follow;
1148
+ if (!ids?.length || pointers.size || keys.size || glide) return;
1149
+ const [vl, vr] = lim.view, L = box.x + (vl + 0.1) * box.w, R = box.x + (vr - 0.1) * box.w;
1150
+ const pts = ids.map(id => seenFrom(target, id)).filter(Boolean);
1151
+ if (!pts.length) return;
1152
+ const lo = Math.min(...pts.map(p => p[0])), hi = Math.max(...pts.map(p => p[0]));
1153
+ if (lo >= L && hi <= R) return;
1154
+ const aim = hi - lo <= R - L ? (lo + hi) / 2 : pts[0][0];
1155
+ target.x += (aim - (L + R) / 2) * pts[0][2] / box.h;
1156
+ }
1157
+ const reset = () => { Object.assign(target, { x: 0, y: lim.setup?.look ?? 0, z: 0, h: lim.setup?.h ?? 1, yaw: 0 }); };
1158
+ const round = () => !!lim.setup?.round, roam = () => lim.setup?.mode === 'roam';
1159
+ const platform = () => lim.setup?.mode === 'platform';
1160
+ // ── a platformer ─────────────────────────────────────────────────────
1161
+ // The hero (camera.hero) runs and jumps in its plane, seen from the side: x metres across, y
1162
+ // metres up. Solid elements (solid: true) are boxes it stands on and bumps into — across their
1163
+ // box, from their elevation up their height. Gravity G, running RUN, a jump camera.jump metres
1164
+ // high (default 2.2). Fallen FALL metres below the lowest place to stand, it is back at the start.
1165
+ // The camera rides along.
1166
+ // Keys: ← → A D run, space W ↑ jump; touch: hold the left or right third to run, the middle to jump.
1167
+ const G = 22, RUN = 5.5, FALL = 5;
1168
+ let plat = null;
1169
+ function platformOf(spec) {
1170
+ const s = camSetup(spec), o = s.hero && (spec.objects || []).find(o => o.id === s.hero);
1171
+ if (!o) return null;
1172
+ const dz = depths(spec), p = dz.objectAt(o);
1173
+ const solids = (spec.objects || []).filter(e => e.solid && e !== o).map(e => {
1174
+ const q = dz.objectAt(e), h = e.height || 2, b = Array.isArray(e.box) && e.box.length === 4 ? e.box : [-0.8 * h, 0, 0.8 * h, 0];
1175
+ const x = q.X * EYE, y = e.elevation || 0;
1176
+ return { x0: x + b[0], x1: x + b[2], y0: y, y1: y + h };
1177
+ });
1178
+ const x = p.X * EYE, y = o.elevation || 0;
1179
+ return {
1180
+ id: o.id, Zp: p.Zp, x0: x, y0: y, x, y, floor: y, vx: 0, vy: 0, ground: false, air: 0, jumped: false, dir: 1,
1181
+ w: o.width || 0.6, h: o.height || 1.7, solids, jumpV: Math.sqrt(2 * G * clamp(s.jump ?? 2.2, 0.5, 8)),
1182
+ level: [Math.min(x, ...solids.map(r => r.x0)) / EYE, Math.max(x, ...solids.map(r => r.x1)) / EYE],
1183
+ bottom: Math.min(y, ...solids.map(r => r.y1)), // the lowest place to stand
1184
+ };
1185
+ }
1186
+ // what the viewer asks for now: { left, right, jump }
1187
+ function controls() {
1188
+ const c = { left: keys.has('ArrowLeft') || keys.has('a'), right: keys.has('ArrowRight') || keys.has('d'), jump: keys.has(' ') || keys.has('w') || keys.has('ArrowUp') };
1189
+ for (const p of pointers.values()) {
1190
+ const f = local(p.x, p.y)[0] / box.w;
1191
+ if (f < 1 / 3) c.left = true; else if (f > 2 / 3) c.right = true; else c.jump = true;
1192
+ }
1193
+ return c;
1194
+ }
1195
+ function stepHero(P, dt) {
1196
+ const c = controls(), want = (c.right ? 1 : 0) - (c.left ? 1 : 0), hw = P.w / 2;
1197
+ P.vx += (want * RUN - P.vx) * Math.min(1, dt * (P.ground ? 14 : 5));
1198
+ if (want) P.dir = want;
1199
+ P.air = P.ground ? 0 : P.air + dt;
1200
+ // one jump a press; a moment after running off an edge still counts
1201
+ if (c.jump && !P.jumped && P.air < 0.1) { P.vy = P.jumpV; P.jumped = true; P.air = 1; }
1202
+ if (!c.jump) P.jumped = false;
1203
+ P.vy = Math.max(P.vy - G * dt, -30);
1204
+ // across, then up: out of whatever solid it moved into
1205
+ P.x += P.vx * dt;
1206
+ for (const r of P.solids) {
1207
+ if (P.y >= r.y1 - 0.01 || P.y + P.h <= r.y0 + 0.01 || P.x + hw <= r.x0 || P.x - hw >= r.x1) continue;
1208
+ if (P.vx > 0) P.x = r.x0 - hw; else if (P.vx < 0) P.x = r.x1 + hw;
1209
+ P.vx = 0;
1210
+ }
1211
+ const y0 = P.y;
1212
+ P.y += P.vy * dt; P.ground = false;
1213
+ for (const r of P.solids) {
1214
+ if (P.x + hw <= r.x0 || P.x - hw >= r.x1) continue;
1215
+ if (P.vy <= 0 && y0 >= r.y1 - 0.01 && P.y < r.y1) { P.y = r.y1; P.vy = 0; P.ground = true; }
1216
+ else if (P.vy > 0 && y0 + P.h <= r.y0 + 0.01 && P.y + P.h > r.y0) { P.y = r.y0 - P.h; P.vy = 0; }
1217
+ }
1218
+ if (P.y < P.bottom - FALL) Object.assign(P, { x: P.x0, y: P.y0, floor: P.y0, vx: 0, vy: 0 });
1219
+ if (P.ground) P.floor = P.y;
1220
+ Object.assign(HERO, { id: P.id, vx: P.vx, vy: P.vy, ground: P.ground });
1221
+ // the camera: the hero a little behind the middle, the way it runs; up and down with what it
1222
+ // stands on, not with every jump
1223
+ target.x = (P.x + P.dir * 1.2) / EYE;
1224
+ target.y = lim.setup.look - 0.8 * (P.floor - P.y0) / EYE / P.Zp;
1225
+ limitCam(target, lim);
1226
+ }
1227
+ // roaming: a step of `d` eye heights toward `ang` (radians, as yaw)
1228
+ const stepTo = (ang, d) => { target.x += Math.sin(ang) * d; target.z += Math.cos(ang) * d; limitCam(target, lim); };
1229
+ // a picture: the camera stands and only leans (lean: −1…1 each way, toward the
1230
+ // pointer or with the phone's tilt) and breathes
1231
+ const fixed = () => lim.setup?.sway > 0, panorama = () => lim.setup?.mode === 'panorama';
1232
+ const lean = { x: 0, y: 0 };
1233
+ // Moving through the scene, the view still leans a little toward the pointer (or with the
1234
+ // phone's tilt): near things shift against far ones, the depth is felt standing still too.
1235
+ // `shown` is the camera as drawn: where you are, plus the lean (eased, LEAN eye heights).
1236
+ const LEAN = 0.047, leaned = { x: 0, y: 0 }, shown = { ...cam };
1237
+ // A film moves the camera from its script, through the state: camera_x (metres to the right),
1238
+ // camera_h (metres up), camera_z (metres ahead — a dolly in, with all the depth it has),
1239
+ // camera_look (screen heights the gaze is lowered); added to wherever the viewer has it.
1240
+ const num = k => (typeof STATE.values[k] === 'number' ? STATE.values[k] : 0);
1241
+ function directed(c) {
1242
+ c.x += num('camera_x') / EYE; c.h = (c.h ?? 1) + num('camera_h') / EYE; c.z += num('camera_z') / EYE; c.y += num('camera_look');
1243
+ }
1244
+ function leanShown(dt) {
1245
+ const k = 1 - Math.exp(-dt * 3), s = lim.setup;
1246
+ leaned.x += ((s?.sway ? 0 : lean.x) - leaned.x) * k; leaned.y += ((s?.sway ? 0 : lean.y) - leaned.y) * k;
1247
+ Object.assign(shown, cam);
1248
+ const dx = leaned.x * LEAN, dy = leaned.y * LEAN / 3;
1249
+ if (s?.round) { shown.x += dx * Math.cos(cam.yaw); shown.z -= dx * Math.sin(cam.yaw); }
1250
+ else shown.x += dx;
1251
+ shown.y += dy;
1252
+ }
1253
+ function leanTo(t) {
1254
+ const s = lim.setup, b = 0.25;
1255
+ target.x = (lean.x + b * Math.sin(t * 0.31)) * s.sway / (1 + b);
1256
+ target.y = s.look + (lean.y + b * Math.sin(t * 0.19 + 1)) * s.sway / 3 / (1 + b);
1257
+ }
1258
+
1259
+ canvas.style.touchAction = 'none';
1260
+ canvas.addEventListener('pointerdown', e => {
1261
+ canvas.setPointerCapture(e.pointerId);
1262
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
1263
+ glide = null; lastMove = performance.now();
1264
+ if (pointers.size === 1 && !fixed() && !platform()) grabAt(e.clientX, e.clientY);
1265
+ askTilt();
1266
+ if (pointers.size === 2) {
1267
+ const [a, b] = [...pointers.values()];
1268
+ pinch = { dist: Math.hypot(a.x - b.x, a.y - b.y) };
1269
+ }
1270
+ });
1271
+ canvas.addEventListener('pointermove', e => {
1272
+ const prev = pointers.get(e.pointerId);
1273
+ if (!prev) return;
1274
+ if (fixed()) return;
1275
+ const now = { x: e.clientX, y: e.clientY };
1276
+ pointers.set(e.pointerId, now);
1277
+ if (platform()) return; // fingers held are the controls (controls())
1278
+ if (pointers.size === 1) {
1279
+ if (grab) dragTo(now.x, now.y);
1280
+ } else if (pointers.size === 2 && pinch) {
1281
+ const [a, b] = [...pointers.values()];
1282
+ const dist = Math.hypot(a.x - b.x, a.y - b.y);
1283
+ if (roam()) stepTo(target.yaw, Math.log(dist / pinch.dist) * 3);
1284
+ else if (!round()) walkToward(dist / pinch.dist, (a.x + b.x) / 2, (a.y + b.y) / 2);
1285
+ pinch.dist = dist;
1286
+ }
1287
+ });
1288
+ const up = e => {
1289
+ pointers.delete(e.pointerId);
1290
+ if (pointers.size < 2) pinch = null;
1291
+ grab = null; // a finger lifted from a pinch doesn't turn the other into a grab
1292
+ // glide only if the finger was still moving when it let go
1293
+ if (glide && (pointers.size > 0 || performance.now() - lastMove > 80)) glide = null;
1294
+ };
1295
+ canvas.addEventListener('pointerup', up);
1296
+ canvas.addEventListener('pointercancel', up);
1297
+ canvas.addEventListener('wheel', e => {
1298
+ e.preventDefault(); glide = null;
1299
+ if (fixed() || platform()) return;
1300
+ // roaming: the wheel walks toward where the pointer is; standing and looking around, nowhere
1301
+ if (round()) {
1302
+ if (roam()) { const [cx] = local(e.clientX, e.clientY); stepTo(target.yaw + Math.atan((cx - box.w / 2) / box.h), -e.deltaY * 0.004); }
1303
+ return;
1304
+ }
1305
+ // a panorama: a sideways swipe (a touchpad's) carries you along
1306
+ if (panorama() && Math.abs(e.deltaX) > Math.abs(e.deltaY)) { target.x += e.deltaX * 0.006; limitCam(target, lim); return; }
1307
+ walkToward(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY);
1308
+ }, { passive: false });
1309
+ // the view leans toward wherever the pointer is
1310
+ addEventListener('pointermove', e => {
1311
+ if (e.pointerType !== 'mouse') return;
1312
+ lean.x = clamp(2 * e.clientX / Math.max(1, innerWidth) - 1, -1, 1);
1313
+ lean.y = clamp(2 * e.clientY / Math.max(1, innerHeight) - 1, -1, 1);
1314
+ });
1315
+ // ... or with the phone's tilt, from how it is held (the rest position follows it slowly)
1316
+ let rest = null;
1317
+ addEventListener('deviceorientation', e => {
1318
+ if (e.beta == null || e.gamma == null) return;
1319
+ const a = screen.orientation?.angle ?? 0;
1320
+ // across and down the screen as held: in landscape the phone's axes are turned
1321
+ const [h, v] = a === 90 ? [e.beta, -e.gamma] : a === 270 ? [-e.beta, e.gamma] : [e.gamma, e.beta];
1322
+ rest = rest ? { h: rest.h + (h - rest.h) * 0.01, v: rest.v + (v - rest.v) * 0.01 } : { h, v };
1323
+ lean.x = clamp((h - rest.h) / 15, -1, 1);
1324
+ lean.y = clamp((v - rest.v) / 15, -1, 1);
1325
+ });
1326
+ // iOS lets a page read the tilt only once asked, on a touch
1327
+ let asked = false;
1328
+ function askTilt() {
1329
+ if (asked || typeof window.DeviceOrientationEvent?.requestPermission !== 'function') return;
1330
+ asked = true;
1331
+ DeviceOrientationEvent.requestPermission().catch(() => {});
1332
+ }
1333
+
1334
+ const typing = () => ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName);
1335
+ const KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'a', 'd', 'w', 's', ' '];
1336
+ addEventListener('keydown', e => {
1337
+ if (typing() || e.ctrlKey || e.metaKey || e.altKey) return;
1338
+ const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
1339
+ if (k === '0') { reset(); return; }
1340
+ if (k === 'p') { perf.style.display = perf.style.display === 'none' ? 'block' : 'none'; return; }
1341
+ if (KEYS.includes(k) && !fixed()) { keys.add(k); e.preventDefault(); }
1342
+ });
1343
+ addEventListener('keyup', e => keys.delete(e.key.length === 1 ? e.key.toLowerCase() : e.key));
1344
+ addEventListener('blur', () => keys.clear());
1345
+
1346
+ const loop = now => {
1347
+ requestAnimationFrame(loop);
1348
+ const moving = keys.size > 0 || pointers.size > 0 || !!glide || (plat && (Math.abs(plat.vx) > 0.01 || !plat.ground)) || Math.abs(target.x - cam.x) + Math.abs(target.y - cam.y) + Math.abs(target.z - cam.z) + Math.abs(target.h - cam.h) + Math.abs(target.yaw - cam.yaw) > 0.001;
1349
+ if (moving) activeUntil = now + 500;
1350
+ const fps = now < activeUntil ? FPS_ACTIVE : fpsIdle;
1351
+ const real = (now - last) / 1000;
1352
+ // skip this display refresh; the tolerance is under half a refresh so vsync jitter
1353
+ // never drops a frame we meant to draw (that made 30 fps stutter at ~27)
1354
+ if (real < 1 / fps - 0.006) return;
1355
+ const dt = Math.min(0.05, real);
1356
+ last = now; t = Math.max(t, (performance.now() - born) / 1000);
1357
+ STATE.sample?.(); // the state's moves where they are this very moment
1358
+ if (real < 0.5) frameEma += (real - frameEma) * 0.1; // ignore tab-switch gaps
1359
+ adapt(now, fps);
1360
+ // a brisk walk: ~6 m/s forward, ~3.5 m/s sideways (world units are eye heights)
1361
+ const step = dt * 2;
1362
+ if (plat) stepHero(plat, dt);
1363
+ else if (round()) {
1364
+ // looking around: arrows left and right turn; roaming, W S walk where you look and A D
1365
+ // step aside; standing, W S and the arrows up and down look up and down, A D turn too
1366
+ const turn = dt * 1.4;
1367
+ if (keys.has('ArrowLeft')) target.yaw -= turn;
1368
+ if (keys.has('ArrowRight')) target.yaw += turn;
1369
+ if (roam()) {
1370
+ if (keys.has('w') || keys.has('ArrowUp')) stepTo(target.yaw, step * 1.5);
1371
+ if (keys.has('s') || keys.has('ArrowDown')) stepTo(target.yaw, -step * 1.5);
1372
+ if (keys.has('a')) stepTo(target.yaw - Math.PI / 2, step);
1373
+ if (keys.has('d')) stepTo(target.yaw + Math.PI / 2, step);
1374
+ } else {
1375
+ if (keys.has('a')) target.yaw -= turn;
1376
+ if (keys.has('d')) target.yaw += turn;
1377
+ if (keys.has('w') || keys.has('ArrowUp')) target.y -= dt * 0.6;
1378
+ if (keys.has('s') || keys.has('ArrowDown')) target.y += dt * 0.6;
1379
+ }
1380
+ } else {
1381
+ if (keys.has('ArrowLeft') || keys.has('a')) target.x -= step;
1382
+ if (keys.has('ArrowRight') || keys.has('d')) target.x += step;
1383
+ if (keys.has('ArrowUp') || keys.has('w')) target.z += step * 1.8;
1384
+ if (keys.has('ArrowDown') || keys.has('s')) target.z -= step * 1.8;
1385
+ }
1386
+ if (fixed()) leanTo(t);
1387
+ keepInView();
1388
+ limitCam(target, lim);
1389
+ if (glide && pointers.size === 0) {
1390
+ target.x += glide.vx * dt; target.y += glide.vy * dt; target.yaw += glide.vyaw * dt;
1391
+ limitCam(target, lim);
1392
+ const f = Math.exp(-dt * 4);
1393
+ glide.vx *= f; glide.vy *= f; glide.vyaw *= f;
1394
+ if (Math.abs(glide.vx) + Math.abs(glide.vy) + Math.abs(glide.vyaw) < 0.005) glide = null;
1395
+ }
1396
+ // the camera eases toward the target: quickly under the finger (a light trail, ~55 ms),
1397
+ // more softly for the wheel and keys (~170 ms); frame-rate independent
1398
+ const k = 1 - Math.exp(-dt * (pointers.size === 1 || glide ? 18 : 6));
1399
+ cam.x += (target.x - cam.x) * k; cam.y += (target.y - cam.y) * k; cam.z += (target.z - cam.z) * k; cam.h += (target.h - cam.h) * k; cam.yaw += (target.yaw - cam.yaw) * k;
1400
+ cam.hero = plat && { id: plat.id, dx: plat.x - plat.x0, dy: plat.y - plat.y0, flip: plat.dir < 0 };
1401
+ leanShown(dt);
1402
+ directed(shown);
1403
+ const t0 = performance.now();
1404
+ if (S) R.frame(S, spec, shown, t, dt, now >= activeUntil, target);
1405
+ drawMs += performance.now() - t0;
1406
+ frames++;
1407
+ if (now - perfAt > 1000 && perf.style.display !== 'none') {
1408
+ perf.textContent = `${R.name} · ${Math.round(frames * 1000 / (now - perfAt))} fps (cap ${fps})\n${canvas.width}×${canvas.height} · res ${res.toFixed(2)}\njs ${(drawMs / frames).toFixed(1)} ms/frame`;
1409
+ frames = 0; drawMs = 0; perfAt = now;
1410
+ } else if (now - perfAt > 1000) { frames = 0; drawMs = 0; perfAt = now; }
1411
+ };
1412
+ requestAnimationFrame(loop);
1413
+ return {
1414
+ // modules: where the spec's modules come from (kit.js: {world, refs} or {texts})
1415
+ show(next, modules) {
1416
+ // what the scene's code shares (its modules may still be on their way): shown once they are here
1417
+ const shared = MooodKit.shared(next, modules);
1418
+ if (shared instanceof Promise) { waiting = next; shared.then(() => { if (waiting === next) { waiting = null; this.show(next, modules); } }); return; }
1419
+ waiting = null; SHARED = shared;
1420
+ spec = next; FOCUS.clear();
1421
+ // values not set yet (the state may start after the scene is shown) are where the scene starts them
1422
+ if (spec.state && typeof spec.state === 'object') STATE.values = { ...spec.state, ...STATE.values };
1423
+ // a new level, or the same one edited: the hero stays where it got to
1424
+ const was = plat; plat = platformOf(spec); HERO.id = plat?.id ?? null;
1425
+ if (plat && was && was.id === plat.id) Object.assign(plat, { x: was.x, y: was.y, floor: was.floor, dir: was.dir });
1426
+ rebuild();
1427
+ if (plat) { Object.assign(target, { x: plat.x / EYE }); Object.assign(cam, target); }
1428
+ },
1429
+ // a platformer's hero now: { across, elevation (metres), ground } — or null
1430
+ hero: () => (plat ? { across: plat.x, elevation: plat.y, ground: plat.ground } : null),
1431
+ resetCamera() { reset(); Object.assign(cam, target); },
1432
+ // the part of the canvas not covered by the page's panels, as fractions of its width
1433
+ setView(left, right) { seen = [left, right]; lim = { ...lim, view: openView() }; },
1434
+ // where the frame lies in the canvas (CSS px)
1435
+ frame: () => ({ ...box }),
1436
+ // where the camera is now (for picking things on screen, select.js)
1437
+ camera: () => ({ x: shown.x, y: shown.y, z: shown.z, h: shown.h, yaw: shown.yaw, hero: shown.hero }),
1438
+ // the scene's state changed (scenestate.js): the painting code sees it as e.state
1439
+ // was(key, ago): what a value was `ago` seconds ago (e.was); sample(): brings the values that
1440
+ // are moving to where they are now — called as each frame starts, so it paints them on time
1441
+ setState(values, was, sample) {
1442
+ STATE.values = values || {}; if (was) STATE.was = was; if (sample) STATE.sample = sample;
1443
+ STATE.ver++; activeUntil = performance.now() + 300;
1444
+ },
1445
+ // where in the canvas (CSS px) an element is now — the point its code marks (e.at), else its
1446
+ // anchor (its top, with over) — lifted by `lift` metres; null when out of sight
1447
+ screenOf: (id, lift = 0, over = false) => seenFrom(shown, id, lift, over),
1448
+ };
1449
+ }
1450
+
1451
+ return {
1452
+ mount,
1453
+ // for the WebGL renderer (gl.js): the one-time painting code and camera maths
1454
+ internals: { STAGE, WIDEST, TAU, project, billboard, placeOf, camSetup, heroMatrix, STATE, READS, EXT, DEEP, Z_FAR, EYE, depths, view, limits, fadeNear, objectBox, seeded, hash, clamp, layer, rgb, glowSprite, grainTile, staticLayer, drawObject, PARTICLES, makeParticles, moveParticle },
1455
+ };
1456
+ })();