@moood/render 0.1.3 → 0.1.5

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
@@ -210,7 +210,8 @@ const Moood = (() => {
210
210
  }
211
211
 
212
212
  // W × H: the stage, in pixels
213
- function drawObject(ctx, o, W, H, t, lightsOut) {
213
+ function drawObject(ctx, o, W, H, t, lightsOut, partsOut) {
214
+ const t0 = clock();
214
215
  const x = o.x * W, y = o.y * H, s = o.size * H, col = o.color;
215
216
  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
217
  ctx.fillStyle = ctx.strokeStyle = col;
@@ -333,43 +334,101 @@ const Moood = (() => {
333
334
  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
335
  break;
335
336
  }
336
- case 'custom': case undefined: paintCustom(ctx, o, W, H, t, lightsOut); break;
337
+ case 'custom': case undefined: paintCustom(ctx, o, W, H, t, lightsOut, partsOut); break;
337
338
  default: throw new Error(`unknown object kind ${o.kind}`);
338
339
  }
340
+ PERF.add(o, clock() - t0);
339
341
  }
340
342
 
341
343
  // Elements the world's agent paints itself. `o.code` is the body of a function (g, e):
342
344
  // g is a 2D canvas context in metres — origin at the element's anchor (where it stands),
343
345
  // x to the right, y DOWN (a 6 m house spans y −6…0). e.t = seconds by the real clock (e.phase =
344
346
  // 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
+ // e.light(x, y, radius, color, strength, name) = a glowing light the engine animates — and a source
348
+ // of light the scene knows (below: lights), by the element's id, or 'id#name'; e.lights(x, y) = the
349
+ // light falling on a point of the painting, from where and how strong (lightsAt); e.ambient = the
350
+ // scene's ambient light now. What scenes share (kit.js): e.anim — the animator's toolkit; e.kit — the world's modules; e.lib — the scene's
347
351
  // 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).
352
+ // it); e.speed(key) — how fast it is changing now (per second); e.cut — when the last cut was
353
+ // (scenestate.js: scene.cut, or the scene's start), by e.t's clock: what keeps its own inertia (a
354
+ // module's springs, cloth, hair) starts afresh after it. e.debug(value, x?, y?) — a note shown
355
+ // only to whoever looks closely (?debug, a render's --debug and --json); e.warn(text) — something
356
+ // wrong but not an error (a pose, a name it does not know), said once in the scene's errors.
349
357
  // Errors are collected (window.__mooodErrors) so the agent reviewing its work sees them.
350
358
  // The scene's state (scenestate.js): values the scene's scripts, agents and widgets set, which
351
359
  // 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: {} };
360
+ const STATE = { values: {}, ver: 0, was: key => STATE.values[key], sample: null, cut: 0, shots: 0, read: new Set() };
361
+ // the state as the painting code reads it (e.state; a module's beforeFrame too): each key it reads is
362
+ // kept (STATE.read, since the scene was shown) — a key set from outside that nothing in the scene
363
+ // reads is said (scenestate.js) — and, by whoever reads it (`keys`: an element's painting, or
364
+ // BEFORE: the modules' beforeFrame), with its value then: what it is repainted for (moved). Listing
365
+ // the keys (Object.keys, for…in) reads them all.
366
+ // (a key kept once per painting: a read after the first costs a lookup)
367
+ const keep = (keys, v, k) => { if (typeof k === 'string' && !keys.has(k)) { keys.set(k, v[k]); STATE.read.add(k); } };
368
+ const stateRead = keys => new Proxy(STATE.values, {
369
+ get: (v, k) => { keep(keys, v, k); return v[k]; },
370
+ has: (v, k) => { keep(keys, v, k); return k in v; },
371
+ ownKeys: v => { keys.set(ALL, STATE.ver); return Reflect.ownKeys(v); },
372
+ });
373
+ const ALL = Symbol('all');
374
+ // the keys the modules' beforeFrame read (since the scene was shown): what an element painting
375
+ // through them (e.kit, e.lib) depends on as well
376
+ const BEFORE = new Map();
377
+ // whether the state an element's painting read (READS: read.keys — through the modules too: read.kit,
378
+ // how many keys their beforeFrame had read then) has changed since it was painted
379
+ function moved(read) {
380
+ if (!read || read.kit < BEFORE.size) return true;
381
+ for (const [k, was] of read.keys) if (k === ALL ? was !== STATE.ver : !Object.is(STATE.values[k], was)) return true;
382
+ return false;
383
+ }
384
+ // what the shown scene's code shares (kit.js): the world's modules it lists, the scene's lib and
385
+ // its land (ground: (across, distance) → metres, or null — flat)
386
+ let SHARED = { kit: {}, lib: {}, ground: null };
387
+ const groundAt = (across, distance) => (SHARED.ground ? SHARED.ground(across, distance) : 0);
355
388
  // Where an element is now, as its painting code says (e.at): the point it marks, in metres from
356
389
  // its anchor as painted — speech bubbles and widgets follow it (stage.screenOf) wherever the
357
- // element is put (its `place`)
358
- const FOCUS = new Map();
390
+ // element is put (its `place`). FOCUS: its own point (the head, say) — it stays till marked
391
+ // again; NAMED: the points it names (e.at(x, y, 'window')) — as its last painting marked them, so
392
+ // one it no longer marks is gone. A point in one of its parts (e.part) carries the part's offset.
393
+ const FOCUS = new Map(), NAMED = new Map();
359
394
  // A platformer's hero as it moves now (camera.mode platform): its painting code reads e.hero —
360
395
  // { vx, vy (m/s), ground } — to move its legs; the engine moves and turns the painting itself.
361
396
  const HERO = { id: null, vx: 0, vy: 0, ground: true };
362
- // What an element's code read as it was last painted (element → {time, state}): one that reads
363
- // the time is repainted every frame, one that reads only the state when the state changes, one
364
- // that reads neither stays as painted (gl.js) — whether it reads them itself or through the kit
397
+ // What an element's code read as it was last painted (element → {time, state, keys, kit, lights},
398
+ // and how many parts it painted): one that reads the time is repainted every frame, one that reads
399
+ // only the state (or the light falling on it) when a key it read changes (moved), one that reads
400
+ // neither stays as painted (gl.js) — whether it reads them itself or through the kit
365
401
  const READS = new WeakMap();
402
+ // What looking closely at a scene shows (?debug, a render's --debug and --json), by element (its
403
+ // id, else its name): NOTES — what its code wrote as it was last painted (e.debug: a value, and the
404
+ // point it is about), PARTS — the parts it painted (e.part: dx, dz, box); what its painting cost
405
+ // and what it read are PERF's (below).
406
+ const NOTES = new Map(), PARTS = new Map(), keyOf = o => o.id ?? o.name;
407
+ // the real clock, for what costs are timed by: a render stops the page's own (window.__mooodClock:
408
+ // the clock before it did)
409
+ const clock = window.__mooodClock || (() => performance.now());
366
410
  const compiled = new Map();
411
+ // what the scene does wrong without failing (a name it does not know, a value it can't take — e.warn's
412
+ // too), said once (window.__mooodWarnings); what its code throws is an error (window.__mooodErrors)
413
+ const warned = new Set();
414
+ // the scene checked as it is drawn — in a render (mount's check) and with ?debug: what costs a
415
+ // little each frame to find (gl.js: a painting cut off by its box) is looked for only then
416
+ const CHECK = { on: false };
417
+ function warn(msg) {
418
+ if (warned.has(msg)) return;
419
+ warned.add(msg);
420
+ (window.__mooodWarnings = window.__mooodWarnings || []).push(msg);
421
+ console.warn('moood:', msg);
422
+ }
367
423
  function reportError(o, err) {
368
- const msg = `${o.name || o.kind}: ${err && err.message || err}`;
424
+ const msg = `${o.name || o.id || o.kind}: ${err && err.message || err}`;
369
425
  (window.__mooodErrors = window.__mooodErrors || []).push(msg);
370
426
  console.warn('moood custom element:', msg);
371
427
  }
372
- function paintCustom(ctx, o, W, H, t, lightsOut) {
428
+ // partsOut: where the parts it paints (e.part) go, each to be painted in a canvas of its own at its
429
+ // own depth — { dx, dz, box, paint(g, ax, ay, ppm, lights) }; without it (a probe, the picking)
430
+ // they are painted in place, as seen from the start
431
+ function paintCustom(ctx, o, W, H, t, lightsOut, partsOut) {
373
432
  const code = String(o.code || '');
374
433
  let fn = compiled.get(code);
375
434
  if (fn === undefined) {
@@ -384,48 +443,128 @@ const Moood = (() => {
384
443
  ctx.fillStyle = ctx.strokeStyle = o.color || '#000000';
385
444
  ctx.lineCap = ctx.lineJoin = 'round';
386
445
  const period = o.period || 2;
446
+ // what is painting now: the element itself, or one of its parts — its canvas, its anchor (x, y,
447
+ // ppm on the stage) and the transform there, where its lights go, the part's offset
448
+ const own = (g, ax, ay, k, lights, part) => { const base = g.getTransform(); return { g, x: ax, y: ay, ppm: k, base, unbase: base.inverse(), lights, part }; };
449
+ let cur = own(ctx, x, y, ppm, lightsOut, null);
387
450
  // a point the code names, in its units from the anchor as painted — through whatever it did to
388
451
  // the canvas since (translate, scale, rotate): [x, y, how much it is scaled]
389
- const base = ctx.getTransform(), unbase = base.inverse();
390
452
  const painted = (lx, ly) => {
391
- const now = ctx.getTransform(), q = unbase.transformPoint(now.transformPoint({ x: lx, y: ly }));
392
- return [q.x, q.y, Math.hypot(now.a, now.b) / Math.hypot(base.a, base.b)];
453
+ const now = cur.g.getTransform(), q = cur.unbase.transformPoint(now.transformPoint({ x: lx, y: ly }));
454
+ return [q.x, q.y, Math.hypot(now.a, now.b) / Math.hypot(cur.base.a, cur.base.b)];
393
455
  };
394
- const read = { time: false, state: false };
456
+ const read = { time: false, state: false, keys: new Map(), kit: Infinity, lights: false }, key = keyOf(o);
457
+ let state = null;
458
+ // a key read through e.was, e.speed, e.spot (its place), e.ambient (the light's looks)
459
+ const reads = (...ks) => { read.state = true; for (const k of ks) { STATE.read.add(k); if (!read.keys.has(k)) read.keys.set(k, STATE.values[k]); } };
460
+ // (an insert is painted through the iris its key opens: gl.js)
461
+ if (typeof o.insert === 'string') reads(o.insert);
462
+ // what the modules' beforeFrame read, through the kit or the lib
463
+ const throughKit = () => { if (read.kit !== Infinity || !BEFORE.size) return; read.state = true; read.kit = BEFORE.size; for (const k of BEFORE.keys()) if (!read.keys.has(k)) read.keys.set(k, k === ALL ? STATE.ver : STATE.values[k]); };
395
464
  READS.set(o, read);
465
+ NOTES.delete(key);
466
+ const parts = [], named = new Map();
467
+ if (o.id) NAMED.set(o.id, named);
396
468
  const e = {
397
469
  get t() { read.time = true; return t; },
398
470
  get phase() { read.time = true; return ((t / period) % 1 + 1) % 1; },
399
- get state() { read.state = true; return STATE.values; },
471
+ get state() { read.state = true; return state ||= stateRead(read.keys); },
400
472
  id: o.id ?? null, rnd: seeded(hash(code) ^ hash(o.x + ':' + o.y)),
401
- px: 1 / Math.max(1e-6, base.a), W, H, aspect: W / H,
402
- light(lx, ly, r, color = '#ffd08a', strength = 0.6) {
473
+ get px() { return 1 / Math.max(1e-6, cur.base.a); }, W, H, aspect: W / H,
474
+ light(lx, ly, r, color = '#ffd08a', strength = 0.6, name) {
403
475
  try {
404
476
  const [px, py, k] = painted(lx, ly);
405
- lightsOut.push({ x: x + px * ppm, y: y + py * ppm, r: r * k * ppm, col: rgb(color), a: strength, flicker: o.flicker ?? 0.3 });
477
+ cur.lights.push({ x: cur.x + px * cur.ppm, y: cur.y + py * cur.ppm, r: r * k * cur.ppm, col: rgb(color), a: strength, flicker: o.flicker ?? 0.3, id: o.id == null ? null : name == null ? String(o.id) : `${o.id}#${name}` });
406
478
  } catch (err) { reportError(o, err); }
407
479
  },
408
- // where this element is now (its head, say): what speaks or hovers over it follows this point
409
- at(lx, ly) { if (o.id) { const [px, py] = painted(lx, ly); FOCUS.set(o.id, { x: px, y: py }); } },
410
- anim: MooodKit.anim, kit: SHARED.kit, lib: SHARED.lib,
411
- was: (key, ago = 0) => { read.state = true; return STATE.was(key, ago); },
412
- 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; },
480
+ // the light falling on a point of the painting: [{ id, color, strength, x, y, z, dir, distance }]
481
+ lights(lx, ly) {
482
+ read.lights = true;
483
+ const [px, py] = painted(lx, ly);
484
+ return lightsAt(worldOf(o, px, py, cur.part));
485
+ },
486
+ get ambient() { reads(...Object.keys(LOOK.spec?.light?.looks || {})); return LOOK.now?.ambient ?? 1; },
487
+ // where this element is now (its head, say): what speaks or hovers over it follows this point;
488
+ // with a name, one of its points the game and the camera may aim at ('window', 'head', 'horn')
489
+ at(lx, ly, name) {
490
+ if (!o.id) return;
491
+ const [px, py] = painted(lx, ly), p = { x: px, y: py, part: cur.part };
492
+ if (name == null) FOCUS.set(o.id, p); else named.set(String(name), p);
493
+ },
494
+ // a part of it standing dx metres to the right and dz farther than its anchor (on the ground
495
+ // there): painted by paint(g, e) in metres from its own foot, at its own depth — what stands
496
+ // between is drawn between (a fence along a road, a crowd, figures one behind another)
497
+ part(dx, dz, paint, box) {
498
+ if (!Number.isFinite(dx) || !Number.isFinite(dz) || typeof paint !== 'function') { reportError(o, new Error('e.part(dx, dz, paint, box?): metres to the right, metres farther, a function painting it')); return; }
499
+ if (box !== undefined && !(Array.isArray(box) && box.length === 4 && box.every(Number.isFinite))) { reportError(o, new Error('e.part: box is [left, top, right, bottom], metres around its foot')); box = undefined; }
500
+ parts.push({ dx, dz, box, paint });
501
+ },
502
+ // the ground's height (metres) at (across, distance) — spec.relief, 0 without one
503
+ ground: groundAt,
504
+ // where it stands now (metres): across, distance, its elevation and the ground's under it
505
+ get spot() { if (o.place) reads(...Object.values(o.place).filter(k => typeof k === 'string')); return o._at ? { ...o._at } : null; },
506
+ // a note for whoever looks closely (?debug): text or a value (a module's poses and their
507
+ // weights), about the point (lx, ly) if given — shown by the element; nothing otherwise
508
+ debug(value, lx, ly) {
509
+ if (key == null) return;
510
+ let at = null;
511
+ try { if (typeof lx === 'number' && typeof ly === 'number') { const [px, py] = painted(lx, ly); at = { x: px, y: py }; } } catch (err) { reportError(o, err); }
512
+ (NOTES.get(key) || NOTES.set(key, []).get(key)).push({ value, at });
513
+ },
514
+ // something wrong but not an error — a name it does not know (a pose, a figure) — said once
515
+ warn(text) { warn(`${o.name || o.id || o.kind}: ${text}`); },
516
+ anim: MooodKit.anim,
517
+ get kit() { throughKit(); return SHARED.kit; },
518
+ get lib() { throughKit(); return SHARED.lib; },
519
+ was: (key, ago = 0) => { reads(String(key)); return STATE.was(key, ago); },
520
+ speed: key => { reads(String(key)); const a = STATE.was(key, 0), b = STATE.was(key, 0.05); return typeof a === 'number' && typeof b === 'number' ? (a - b) / 0.05 : 0; },
521
+ cut: STATE.cut,
413
522
  };
414
523
  if (o.id && o.id === HERO.id) { read.time = true; e.hero = { vx: HERO.vx, vy: HERO.vy, ground: HERO.ground }; }
415
524
  try { fn(ctx, e); } catch (err) { reportError(o, err); }
416
525
  ctx.restore();
526
+ // its parts: each painted where the renderer puts it — or here, in place as seen from the start
527
+ // (u, v: metres in its own plane, from its anchor)
528
+ const run = part => (g, ax, ay, k, lights) => {
529
+ g.save(); g.translate(ax, ay); g.scale(k, k);
530
+ g.fillStyle = g.strokeStyle = o.color || '#000000';
531
+ g.lineCap = g.lineJoin = 'round';
532
+ const was = cur;
533
+ cur = own(g, ax, ay, k, lights, { dx: part.dx, dz: part.dz });
534
+ try { part.paint(g, e); } catch (err) { reportError(o, err); }
535
+ cur = was;
536
+ g.restore();
537
+ };
538
+ read.parts = parts.length;
539
+ if (key != null) { if (parts.length) PARTS.set(key, parts.map(q => ({ dx: q.dx, dz: q.dz, box: q.box }))); else PARTS.delete(key); }
540
+ for (const part of parts) {
541
+ if (partsOut) { partsOut.push({ dx: part.dx, dz: part.dz, box: part.box, paint: run(part) }); continue; }
542
+ const q = inPlace(o, part.dx, part.dz);
543
+ if (q) run(part)(ctx, x + q.u * ppm, y + q.v * ppm, q.s * ppm, lightsOut);
544
+ }
545
+ }
546
+ // A part (dx, dz) of element o painted in o's own plane as seen from the start — its foot u, v
547
+ // metres from o's anchor, its scale s — or null (o is not where it could have parts)
548
+ function inPlace(o, dx, dz) {
549
+ const at = o._at;
550
+ if (!at || !(at.distance > 0) || !(at.distance + dz > 0.05)) return null;
551
+ const s = at.distance / (at.distance + dz), feet = at.elevation - at.ground + groundAt(at.across + dx, at.distance + dz);
552
+ return { u: (at.across + dx) * s - at.across, v: (EYE - feet) * s - (EYE - at.elevation), s };
417
553
  }
418
554
  // Where a custom element's painting has its origin on the W × H stage, and its scale: [x, y, px a
419
- // unit]. Sky and ground elements are painted in stage heights from the stage's top-left corner, as
420
- // seen from the start (x 0…W/H, y 0…1, with margins); others in metres from their anchor.
555
+ // unit]. Sky, ground and insert elements are painted in stage heights from the stage's top-left
556
+ // corner (x 0…W/H, y 0…1, with margins; the sky and the ground as seen from the start); others in
557
+ // metres from their anchor.
421
558
  function anchorOf(o, W, H) {
422
- return o.sky || o.ground ? [0, 0, H] : [o.x * W, o.y * H, o.size * H / (o.height || 2)];
559
+ return o.sky || o.ground || o.insert ? [0, 0, H] : [o.x * W, o.y * H, o.size * H / (o.height || 2)];
423
560
  }
424
561
  // The box an object's painting fits in (pixels of the W × H stage): built-in kinds a fixed frame
425
562
  // around the base, custom ones their `box` in metres (default: a little wider than tall).
426
563
  function objectBox(o, W, H) {
427
564
  // the sky: as wide as the widest frame
428
565
  if (o.sky) return { bx: (W - WIDEST * H) / 2, by: -0.35 * H, bw: WIDEST * H, bh: 1.6 * H };
566
+ // an insert: the frame itself, as wide as the widest
567
+ if (o.insert) return { bx: (W - WIDEST * H) / 2, by: 0, bw: WIDEST * H, bh: H };
429
568
  // ground: from the horizon down, wider than the screen (for stepping aside and looking down)
430
569
  if (o.ground) return { bx: -EXT * W, by: o._yh * H, bw: (1 + 2 * EXT) * W, bh: (1.7 - o._yh) * H };
431
570
  const s = Math.max(2, o.size * H), x = o.x * W, y = o.y * H;
@@ -456,6 +595,329 @@ const Moood = (() => {
456
595
  ctx.restore();
457
596
  }
458
597
 
598
+ // ── the scene's light ──────────────────────────────────────────────────
599
+ // spec.light as the state has it now: the grade of the whole picture — tint, tintStrength,
600
+ // vignette, grain, haze, hazeColor, brightness (×), saturation (×) — and, for a lit scene, ambient
601
+ // (0…1: how much of its painted colour a thing keeps where no light falls on it) and ambientColor.
602
+ // `looks`: { key: fields } — a state value `key` (0…1) mixes the light toward that look, in the
603
+ // order they are listed (night over the day, dawn over the night): a script moves the key with
604
+ // `over`, the light changes softly. A scene is lit — its light lies on what is painted, by how far
605
+ // each thing is from each source (gl.js) — as much as it or a look of it gives `ambient` now: `lit`
606
+ // 1 when the light itself has it, else the weight of the most of the looks that do (0: not lit —
607
+ // its sources come in as their look does, from nothing, so it never jumps). hazeGlow (0 unset): the
608
+ // haze lit by the point sources (airOf).
609
+ // haze 0…1 → how far (eye heights) the air hides a thing to 1/e; unset: clear air, 220
610
+ const hazeReach = h => 600 * Math.pow(25 / 600, clamp(h, 0, 1));
611
+ const LOOK_NUM = { tintStrength: 0, vignette: 0.5, grain: 0.5, haze: Math.log(220 / 600) / Math.log(25 / 600), brightness: 1, saturation: 1, ambient: 1, hazeGlow: 0 };
612
+ const LOOK_COL = ['tint', 'hazeColor', 'ambientColor'];
613
+ const LOOK = { spec: null, ver: -1, now: null };
614
+ // "#rrggbb" → [r, g, b] 0…255, or null (said)
615
+ function colourOf(hex, where) {
616
+ const m = /^#?([0-9a-f]{6})$/i.exec(typeof hex === 'string' ? hex : '');
617
+ if (!m) { warn(`${where}: ${JSON.stringify(hex)} is not a colour ("#rrggbb")`); return null; }
618
+ const n = parseInt(m[1], 16);
619
+ return [n >> 16, (n >> 8) & 255, n & 255];
620
+ }
621
+ function lookOf(spec) {
622
+ if (LOOK.spec === spec && LOOK.ver === STATE.ver) return LOOK.now;
623
+ const L = spec?.light && typeof spec.light === 'object' ? spec.light : {}, v = {};
624
+ for (const [k, d] of Object.entries(LOOK_NUM)) v[k] = typeof L[k] === 'number' ? L[k] : d;
625
+ v.tint = L.tint ? colourOf(L.tint, 'light.tint') : null;
626
+ v.hazeColor = colourOf(L.hazeColor || spec?.sky?.bottom || '#808080', 'light.hazeColor') || [128, 128, 128];
627
+ v.ambientColor = (L.ambientColor && colourOf(L.ambientColor, 'light.ambientColor')) || [255, 255, 255];
628
+ let lit = typeof L.ambient === 'number' ? 1 : 0;
629
+ const looks = L.looks && typeof L.looks === 'object' ? L.looks : {};
630
+ for (const [key, look] of Object.entries(looks)) {
631
+ if (!look || typeof look !== 'object') { warn(`light.looks.${key}: an object of light fields`); continue; }
632
+ for (const k of Object.keys(look)) if (!(k in LOOK_NUM) && !LOOK_COL.includes(k)) warn(`light.looks.${key}: "${k}" is not a field of the light (${[...Object.keys(LOOK_NUM), ...LOOK_COL].join(', ')})`);
633
+ const w = clamp(Number(STATE.values[key]) || 0, 0, 1);
634
+ if (w <= 0) continue;
635
+ if (typeof look.ambient === 'number') lit = Math.max(lit, w);
636
+ for (const k of Object.keys(LOOK_NUM)) if (typeof look[k] === 'number') v[k] += (look[k] - v[k]) * w;
637
+ for (const k of LOOK_COL) {
638
+ const c = look[k] && colourOf(look[k], `light.looks.${key}.${k}`);
639
+ if (c) v[k] = v[k] ? v[k].map((a, i) => a + (c[i] - a) * w) : c;
640
+ }
641
+ }
642
+ v.lit = lit;
643
+ v.hazeReach = hazeReach(v.haze);
644
+ Object.assign(LOOK, { spec, ver: STATE.ver, now: v });
645
+ return v;
646
+ }
647
+
648
+ // ── where light comes from ─────────────────────────────────────────────
649
+ // The scene's sources of light, as data its painting reads (e.lights) and the renderer lays on
650
+ // what is painted (a lit scene) and casts shadows from (`shadow`):
651
+ // spec.lights: { id, across | x, distance, elevation (metres), place (as an element's: a lamp
652
+ // carried), color, strength (1), reach (metres it lights, 8), on (a state key 0…1: it shines
653
+ // that much), flicker (0…1), glow (metres: its halo, which the renderer draws) } — a point: a
654
+ // lamp, a forge, a fire; { id, of: an element's light (its id, or 'id#name' — below), color,
655
+ // strength, reach, on, flicker } — the light an element paints, where it paints it, shining as
656
+ // this says (a lantern in a hand: its glow small, its reach long); or { id, from: [bearing,
657
+ // height] (degrees: where it shines from — 0 the far side, 90 the right, 180 behind the viewer;
658
+ // how high above the horizon), color, strength, on } — the moon, the sun, the dawn: far off, the
659
+ // same everywhere;
660
+ // every e.light() an element paints (its id the element's, or 'id#name' for one it names),
661
+ // reaching twice its radius — as it was last painted.
662
+ // World units, eye heights: X to the right, Y up, Z ahead. `ver` changes when any of them does:
663
+ // what reads them is repainted then.
664
+ const SOURCES = { list: [], ver: 0 };
665
+ const hexOf = c => '#' + c.map(v => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')).join('');
666
+ // the spec's sources now (dz: depths()); from: the elements' lights — as [{ id, at, reach, rgb,
667
+ // strength }] — they too become the sources (but those the spec takes over: `of`). A source that
668
+ // flickers has `flicker` (0…1) and `seed`; one with a halo `glow` (eye heights)
669
+ function sourcesNow(spec, dz, from) {
670
+ const list = [], taken = new Set();
671
+ (Array.isArray(spec.lights) ? spec.lights : []).forEach((l, i) => {
672
+ if (!l || typeof l !== 'object') { warn(`lights[${i}]: an object`); return; }
673
+ const id = l.id != null ? String(l.id) : `lights[${i}]`;
674
+ const on = typeof l.on === 'string' ? clamp(Number(STATE.values[l.on]) || 0, 0, 1) : 1;
675
+ const flicker = clamp(Number(l.flicker) || 0, 0, 1), seed = hash(id) % 1000;
676
+ if (l.of != null) {
677
+ const el = from.find(s => s.id === String(l.of));
678
+ if (!el) {
679
+ // (an element painting no such light now: it is out)
680
+ const owner = String(l.of).split('#')[0];
681
+ if (!(spec.objects || []).some(o => o.id != null && String(o.id) === owner)) warn(`lights ${id}: "of" names an element's light ("lamp", "lamp#wick") — there is no element ${JSON.stringify(owner)}`);
682
+ return;
683
+ }
684
+ taken.add(el);
685
+ const rgb = l.color ? colourOf(l.color, `lights ${id}: color`) || el.rgb : el.rgb;
686
+ list.push({ ...el, id, rgb, strength: (typeof l.strength === 'number' ? l.strength : el.strength) * on, reach: l.reach > 0 ? l.reach / EYE : el.reach, ...(flicker ? { flicker, seed, sprite: undefined } : {}) });
687
+ return;
688
+ }
689
+ const rgb = colourOf(l.color || '#ffffff', `lights ${id}: color`) || [255, 255, 255];
690
+ const strength = (typeof l.strength === 'number' ? l.strength : 1) * on;
691
+ if (Array.isArray(l.from)) {
692
+ const [b, h] = l.from.map(v => (Number(v) || 0) * Math.PI / 180);
693
+ list.push({ id, dir: [Math.sin(b) * Math.cos(h), Math.sin(h), Math.cos(b) * Math.cos(h)], rgb, strength });
694
+ return;
695
+ }
696
+ if (!(l.distance > 0) && typeof l.place?.distance !== 'string') { warn(`lights ${id}: a point light has a "distance" (metres); one far off has "from": [bearing, height]`); return; }
697
+ const at = dz.now({ ...l, kind: undefined, sky: false, plane: false, ground: false, height: 1 }).at;
698
+ list.push({ id, at: [at.X, at.Y, at.Z], reach: (l.reach > 0 ? l.reach : 8) / EYE, rgb, strength, flicker, seed, halo: l.glow > 0 ? l.glow / EYE : 0 });
699
+ });
700
+ list.push(...from.filter(s => !taken.has(s)));
701
+ // (the same as a frame ago, to a thousandth, compared as numbers: nothing made each frame)
702
+ const near = (a, b) => Math.abs(a - b) < 5e-4, was = SOURCES.list;
703
+ const same = list.length === was.length && list.every((s, i) => {
704
+ const w = was[i], p = s.at || s.dir, q = w.at || w.dir;
705
+ return s.id === w.id && near(s.strength, w.strength) && s.rgb.every((v, j) => v === w.rgb[j]) && p.length === q.length && p.every((v, j) => near(v, q[j]));
706
+ });
707
+ if (!same) SOURCES.ver++;
708
+ SOURCES.list = list;
709
+ return list;
710
+ }
711
+ // how strong a source that flickers (sourcesNow: flicker, seed) shines at t, 0…1
712
+ const flickerNow = (s, t) => (s.flicker ? 1 - s.flicker * 0.5 * (0.5 + 0.5 * Math.sin(t * 9 + s.seed) * Math.sin(t * 5.3 + s.seed * 0.7)) : 1);
713
+ // The light in the air, drawn among what stands, each at its depth (far: a source → its depth, as
714
+ // the renderer orders what it draws): the halos of the scene's own lights (`glow`), and — the
715
+ // light's hazeGlow — the haze round every point source (a lamp, a fire, a window's e.light) glowing
716
+ // its colour as far as it reaches, a little nearer than the source (over what it lights). Farthest
717
+ // first: [{ h: the source, fog, Z }]; with no point sources, or no hazeGlow, only the halos.
718
+ function airOf(sources, look, far) {
719
+ const out = [];
720
+ for (const h of sources) {
721
+ if (!h.at || h.strength <= 0.002) continue;
722
+ if (h.halo) out.push({ h, fog: false, Z: far(h) });
723
+ if (look.hazeGlow > 0) out.push({ h, fog: true, Z: far(h) - h.reach / 2 });
724
+ }
725
+ return out.sort((a, b) => b.Z - a.Z);
726
+ }
727
+ // how strongly the haze round point source s glows seen D eye heights away (0…1, 0: not at all): as
728
+ // much as there is air within its reach (its haze — thick fog glows more), scattered toward the eye
729
+ // (×3), through the air between, as the light shines now — × hazeGlow
730
+ const airGlow = (look, s, D, t) => Math.min(1, look.hazeGlow * 3 * s.reach / look.hazeReach * Math.exp(-Math.max(0, D) / look.hazeReach) * Math.min(1, s.strength) * flickerNow(s, t));
731
+ // how much of a point light reaches x of its reach away
732
+ const falloff = x => (x >= 1 ? 0 : (1 - x * x) * (1 - x * x));
733
+ // A point of an element's painting (metres, from its anchor as painted — or from the foot of its
734
+ // part { dx, dz }, e.part) in the world (eye heights), or null for what has no place in it (the sky,
735
+ // the ground's painting, an insert)
736
+ function worldOf(o, px, py, part) {
737
+ if (o._plane) { const P = o._plane, u = px / EYE, v = py / EYE; return [P.Xc + u * P.cos + v * P.sin, P.Y, P.Zc - v * P.cos + u * P.sin]; }
738
+ const at = o._at;
739
+ if (!at || o.sky || o.ground || o.insert) return null;
740
+ if (!part) return [(at.across + px) / EYE, (at.elevation - py) / EYE, at.distance / EYE];
741
+ const across = at.across + part.dx, distance = at.distance + part.dz;
742
+ return [(across + px) / EYE, (at.elevation - at.ground + groundAt(across, distance) - py) / EYE, distance / EYE];
743
+ }
744
+ // The light falling on world point P (null: only what comes from far off), strongest first:
745
+ // { id, color, strength (as much as reaches it), x, y (toward the light on the painting: x to the
746
+ // right, y down — a unit vector with z), z (toward the viewer: + it lights the side we see, − it
747
+ // is behind: an edge of light), dir (degrees on the screen toward the light: 0 right, 90 down,
748
+ // −90 up, 180 left), distance (metres; null from far off) }
749
+ function lightsAt(P) {
750
+ const out = [];
751
+ for (const s of SOURCES.list) {
752
+ let v = s.dir, k = 1, d = null;
753
+ if (!v) {
754
+ if (!P) continue;
755
+ v = [s.at[0] - P[0], s.at[1] - P[1], s.at[2] - P[2]];
756
+ d = Math.hypot(v[0], v[1], v[2]);
757
+ k = falloff(d / s.reach);
758
+ v = d > 1e-6 ? v.map(c => c / d) : [0, 1, 0];
759
+ }
760
+ const strength = s.strength * k;
761
+ if (strength < 0.002) continue;
762
+ out.push({ id: s.id, color: hexOf(s.rgb), strength, x: v[0], y: -v[1], z: -v[2], dir: Math.atan2(-v[1], v[0]) * 180 / Math.PI, distance: d === null ? null : d * EYE });
763
+ }
764
+ return out.sort((a, b) => b.strength - a.strength);
765
+ }
766
+ // `shadow` on an element standing in the scene: it casts one from a light of the scene onto what
767
+ // `onto` names — 'ground' (the ground under everything, height 0), a plane (a floor, a table top) or
768
+ // an upright element (a wall) — each point of it onto the first of them its ray meets: its own
769
+ // painting, black, softened by the size of the light (`soft`, metres), as dark as `strength` where
770
+ // the light is full, fading as the light does. true = { light: the strongest at it, onto:
771
+ // ['ground'], strength: 0.5, soft: 0.15 }. The scene's shadows: [{ id, onto: ['ground' | id],
772
+ // light: id | null, strength, soft (eye heights) }] (what is wrong with one, said)
773
+ function shadowsOf(spec) {
774
+ const objs = spec.objects || [], byId = new Map(objs.filter(o => o.id != null).map(o => [String(o.id), o])), out = [];
775
+ for (const o of objs) {
776
+ const sh = o.shadow;
777
+ if (!sh) continue;
778
+ const who = o.name || o.id, c = sh === true ? {} : typeof sh === 'object' && !Array.isArray(sh) ? sh : null;
779
+ if (!c || o.id == null || o.sky || o.ground || o.plane || o.insert || o.kind === 'bird') { warn(`${who}: "shadow" is true or { light, onto, strength, soft } — on something standing in the scene, with an id`); continue; }
780
+ if (o.unseen) { warn(`${who}: it is unseen — what is not seen casts no shadow`); continue; }
781
+ const onto = (c.onto == null ? ['ground'] : Array.isArray(c.onto) ? c.onto : [c.onto]).map(id => {
782
+ const r = byId.get(String(id));
783
+ if (id === 'ground' || (r && r !== o && !r.sky && !r.ground && !r.insert)) return String(id);
784
+ warn(`${who}: its shadow falls on ${JSON.stringify(id)} — 'ground', a plane or an upright element of the scene`);
785
+ return null;
786
+ }).filter(Boolean);
787
+ const light = c.light != null ? String(c.light) : null, owner = light && light.split('#')[0];
788
+ if (light && !(spec.lights || []).some(l => l && String(l.id) === light) && !byId.has(owner)) warn(`${who}: its shadow's light "${light}" is not a light of the scene (a light's id, or an element's — "lamp", "lamp#wick")`);
789
+ out.push({ id: String(o.id), onto, light, strength: clamp(Number(c.strength ?? 0.5) || 0, 0, 1), soft: Math.max(0, Number(c.soft ?? 0.15) || 0) / EYE });
790
+ }
791
+ return out;
792
+ }
793
+ // the light a shadow is cast from: the one it names (null while it does not shine: an element not
794
+ // painting it now), else the strongest at A (where the element stands, eye heights) — a source
795
+ // (sourcesNow), or null
796
+ function shadowLight(sources, sh, A) {
797
+ if (sh.light) return sources.find(s => s.id === sh.light) || null;
798
+ let best = null, most = 0.01;
799
+ for (const s of sources) {
800
+ const k = s.dir ? s.strength : s.strength * falloff(Math.hypot(s.at[0] - A.X, s.at[1] - A.Y, s.at[2] - A.Z) / s.reach);
801
+ if (k > most) { most = k; best = s; }
802
+ }
803
+ return best;
804
+ }
805
+ // A lit scene's light where no source reaches: the ambient, and what comes from far off (as much as
806
+ // the scene is lit: look.lit) — from the viewer's side it lights what we see, from beyond only a
807
+ // little: [r, g, b], 0…1 a channel
808
+ function ambientOf(look, sources) {
809
+ const amb = look.ambientColor.map(v => v / 255 * look.ambient);
810
+ for (const s of sources) if (s.dir) { const k = (1 - 0.7 * clamp(s.dir[2] / 0.3, 0, 1)) * look.lit; for (let i = 0; i < 3; i++) amb[i] += s.rgb[i] / 255 * s.strength * k; }
811
+ return amb;
812
+ }
813
+ // how dark a shadow of `src` is at world point P: in a lit scene the share of the light there that
814
+ // the source gives (amb: the ambient's level), else as much of full light as reaches — mixed by how
815
+ // lit the scene is (lit 0…1, lookOf) — times strength
816
+ function shadowAt(sh, src, P, amb, lit) {
817
+ const I = src.strength * (src.at ? falloff(Math.hypot(P[0] - src.at[0], P[1] - src.at[1], P[2] - src.at[2]) / src.reach) : 1);
818
+ return sh.strength * ((1 - lit) * Math.min(1, I) + lit * I / (amb + I || 1));
819
+ }
820
+ // where a ray of light meets a plane { q: a point of it, n: its normal }: from a point L through C
821
+ // (t ≥ 1: at C or beyond it), or from far off along d (toward the light) back through C (t > 0) —
822
+ // [point, t], or null
823
+ const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
824
+ function rayHit(pl, C, L, d) {
825
+ if (d) {
826
+ const nd = dot3(pl.n, d);
827
+ if (Math.abs(nd) < 1e-6) return null;
828
+ const t = (dot3(pl.n, C) - dot3(pl.n, pl.q)) / nd;
829
+ return t > 1e-4 ? [[C[0] - d[0] * t, C[1] - d[1] * t, C[2] - d[2] * t], t] : null;
830
+ }
831
+ const v = [C[0] - L[0], C[1] - L[1], C[2] - L[2]], nv = dot3(pl.n, v);
832
+ if (Math.abs(nv) < 1e-6) return null;
833
+ const t = (dot3(pl.n, pl.q) - dot3(pl.n, L)) / nv;
834
+ return t > 0.9999 ? [[L[0] + v[0] * t, L[1] + v[1] * t, L[2] + v[2] * t], t] : null;
835
+ }
836
+ // `redraw`: the state keys a painting depends on — [keys], or { key: step } (repainted once the
837
+ // value has moved a step) — repainted when they change, whatever else it reads. What they are now,
838
+ // as a string to compare; null without it
839
+ function redrawOf(o) {
840
+ const r = o.redraw;
841
+ if (r == null) return null;
842
+ const steps = Array.isArray(r) ? Object.fromEntries(r.map(k => [k, 0])) : r && typeof r === 'object' ? r : null;
843
+ if (!steps || Object.entries(steps).some(([k, v]) => typeof v !== 'number' || v < 0)) { warn(`${o.name || o.id}: "redraw" is [state keys] or { key: step }`); return null; }
844
+ return JSON.stringify(Object.entries(steps).map(([k, step]) => {
845
+ const v = STATE.values[k];
846
+ return typeof v === 'number' && step > 0 ? Math.round(v / step) : v ?? null;
847
+ }));
848
+ }
849
+
850
+ // What the scene's code shares (a world's module, the scene's lib) may give beforeFrame({ t, dt,
851
+ // state, cut }): called once a frame before anything is painted, whether its figures are painted or
852
+ // not — to keep its own time (the springs and steps of a figure out of sight go on), and to work
853
+ // out this frame's points of its figures before any element reads them.
854
+ // (those that have one, found once for what is shared: a scene with none does nothing a frame)
855
+ let befores = null, beforesOf = null, beforeState = null, beforeValues = null;
856
+ function beforeFrame(t, dt) {
857
+ if (beforesOf !== SHARED) {
858
+ beforesOf = SHARED;
859
+ befores = [...Object.entries(SHARED.kit), ['lib', SHARED.lib]].filter(([, m]) => typeof m?.beforeFrame === 'function');
860
+ }
861
+ if (!befores.length) return;
862
+ // (the state's values are a new object when they change: read through one made for them)
863
+ if (beforeValues !== STATE.values) { beforeValues = STATE.values; beforeState = stateRead(BEFORE); }
864
+ for (const [name, m] of befores) {
865
+ try { m.beforeFrame({ t, dt, state: beforeState, cut: STATE.cut }); } catch (err) { warn(`${name === 'lib' ? 'lib' : `module ${name}`}: beforeFrame: ${err?.message || err}`); }
866
+ }
867
+ }
868
+
869
+ // ── what each element costs ────────────────────────────────────────────
870
+ // While the performance panel is open (key P): the time each element's painting takes a frame (ms,
871
+ // summed by its name; in WebGL its upload too). And always its last painting's, with what it read
872
+ // (`last`, by its id, else its name — one with neither isn't told apart): what ?debug and a
873
+ // render's --json show.
874
+ const PERF = {
875
+ on: false, ms: new Map(), last: new Map(),
876
+ // how many times each live element (one painted again as what it reads moves) was repainted since
877
+ // the scene was shown, by its id (else its name)
878
+ repaints: new Map(),
879
+ repainted(o) { const k = keyOf(o); if (k != null) PERF.repaints.set(k, (PERF.repaints.get(k) || 0) + 1); },
880
+ add(o, ms, upload = false) {
881
+ if (!upload && keyOf(o) != null) PERF.last.set(keyOf(o), { ms, read: READS.get(o) });
882
+ if (PERF.on) { const k = o.name || o.id || o.kind || '?'; PERF.ms.set(k, (PERF.ms.get(k) || 0) + ms); }
883
+ },
884
+ };
885
+
886
+ // ── inserts ────────────────────────────────────────────────────────────
887
+ // An insert (`insert`: the state key that opens it) is a shot of its own over the scene — the six
888
+ // fingers seen close, a letter, a face: painted in the frame's own coordinates (stage heights, as
889
+ // the sky is), fixed to the frame whatever the camera does, and seen through an iris that its key
890
+ // opens, 0 (closed: the scene) … 1 (open). `iris`: [x, y, rx, ry] — the window fully open (stage
891
+ // heights: its centre and radii, an oval); without it the iris opens from the middle of the frame
892
+ // to all of it. The scene goes on under it; closing it is the way back.
893
+ const IRIS_ALL = Math.hypot(WIDEST / 2, 0.5) + 0.05;
894
+ function opening(o) {
895
+ if (typeof o.insert === 'string') return clamp(Number(STATE.values[o.insert]) || 0, 0, 1);
896
+ warn(`${o.name || o.id}: "insert" is the state key that opens it`);
897
+ return 0;
898
+ }
899
+ // the insert's glows painted in (they don't shine past the iris), then all cut to the iris opened u:
900
+ // on g, a canvas holding the insert alone, transformed to the W × H stage
901
+ function iris(g, o, W, H, u, lights) {
902
+ g.save();
903
+ g.globalCompositeOperation = 'lighter';
904
+ for (const l of lights) { glow(g, l.x, l.y, l.r, l.col, l.a * 0.6); if (l.dot) glow(g, l.x, l.y, l.dot * 3, '255,245,220', 0.9); }
905
+ lights.length = 0;
906
+ let win = o.iris;
907
+ if (win !== undefined && !(Array.isArray(win) && win.length === 4 && win.every(v => typeof v === 'number') && win[2] > 0 && win[3] > 0)) {
908
+ warn(`${o.name || o.id}: "iris" is [x, y, rx, ry] (stage heights, radii above 0)`);
909
+ win = undefined;
910
+ }
911
+ const [cx, cy, rx, ry] = win || [W / H / 2, 0.5, IRIS_ALL, IRIS_ALL], r = Math.min(rx, ry) * u;
912
+ // a soft edge, about 2% of the frame's height
913
+ const soft = clamp(0.02 / r, 0.002, 0.5), reach = 2 * WIDEST / r + 2, gr = g.createRadialGradient(0, 0, 0, 0, 0, 1);
914
+ gr.addColorStop(1 - soft, '#000'); gr.addColorStop(1, 'rgba(0,0,0,0)');
915
+ g.globalCompositeOperation = 'destination-in';
916
+ g.translate(cx * H, cy * H); g.scale(rx * u * H, ry * u * H);
917
+ g.fillStyle = gr; g.fillRect(-reach, -reach, 2 * reach, 2 * reach);
918
+ g.restore();
919
+ }
920
+
459
921
  // ── particles ──────────────────────────────────────────────────────────
460
922
  const PARTICLES = { snow: 380, rain: 520, fireflies: 45, dust: 140, petals: 90, embers: 90, leaves: 60, bubbles: 60 };
461
923
  function makeParticles(p, W, H, rnd) {
@@ -601,7 +1063,16 @@ const Moood = (() => {
601
1063
  const bearing = o => typeof o.bearing === 'number' && o.distance > 0 && !o.sky;
602
1064
  const paintZ = o => (o.plane ? planeZ(o) : custom(o) ? sightZ(o) : own(host(o) || o));
603
1065
  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);
604
- 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));
1066
+ // where it is over the land (metres: across, ahead) — for an element placed by its distance
1067
+ const spotOf = o => {
1068
+ if (bearing(o)) { const b = o.bearing * Math.PI / 180; return [o.distance * Math.sin(b), o.distance * Math.cos(b)]; }
1069
+ return [(objectX(o) - 0.5) * STAGE * sightZ(o) * EYE, o.distance];
1070
+ };
1071
+ // with a relief (spec.relief), what stands where its distance says stands on the ground there:
1072
+ // its elevation is above it
1073
+ const onLand = o => !!SHARED.ground && custom(o) && o.distance > 0 && !o.sky && !o.ground && !o.insert && !o.plane;
1074
+ const landUnder = o => (onLand(o) ? groundAt(...spotOf(o)) : 0);
1075
+ const objectY = o => (o.sky || o.ground ? 0 : custom(o) ? yh + (1 - ((o.elevation || 0) + landUnder(o)) / EYE) / sightZ(o) : o.kind === 'bird' ? o.y : o.y + shift(o));
605
1076
  const objectSize = o => (o.sky || o.ground ? 1 : custom(o) ? (o.height || 2) / EYE / sightZ(o) : o.size);
606
1077
  // where it stands in the world (eye heights: X to the right, Z ahead, Y its anchor's height) and
607
1078
  // the distance Zp it is painted for
@@ -614,17 +1085,219 @@ const Moood = (() => {
614
1085
  // ground planes are drawn right after the sky, under everything standing on them
615
1086
  // a horizontal plane (gl.js) is ordered by its far edge, before what stands on it
616
1087
  const 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));
1088
+ // an element where it is now (its `place`), the one truth the renderers, the picking and
1089
+ // whatever follows it go by: { o: as painted on the stage (x, y, size: fractions of it; _at:
1090
+ // where it is in the world, metres — across, distance, its anchor's height and the ground's
1091
+ // under it), Z: the depth it is drawn at, at: where it stands in the world }
1092
+ const now = o => {
1093
+ const n = placed(o), at = objectAt(n), ground = landUnder(n);
1094
+ const _at = o.sky || o.ground || o.insert ? null : { across: at.X * EYE, distance: at.Z * EYE, elevation: at.Y * EYE, ground };
1095
+ return { o: { ...o, x: objectX(n), y: objectY(n), size: objectSize(n), _yh: yh, _at }, Z: objectZ(n), at };
1096
+ };
1097
+ return {
1098
+ yh, Z, objectX, objectAt, objectZ, now,
1099
+ // part (dx, dz) of element o (e.part) as an element of its own: standing dx metres to the
1100
+ // right and dz farther than o's anchor, at o's elevation over the ground there, its box `box`
1101
+ // (o's without one) — where it is now, like now()
1102
+ part: (o, dx, dz, box) => {
1103
+ const n = placed(o), at = objectAt(n), elevation = at.Y * EYE - landUnder(n);
1104
+ const { x, y, bearing, place, ...rest } = o;
1105
+ return now({ ...rest, across: at.X * EYE + dx, distance: Math.max(0.05, at.Z * EYE + dz), elevation, box: box || o.box });
1106
+ },
1107
+ };
1108
+ }
1109
+ // ── in front, behind, cut by another ───────────────────────────────────
1110
+ // What is drawn in front of what is their depth — at equal depth the later in the spec's objects —
1111
+ // unless an element says: `behind: [ids]` — drawn behind those, `inFront: [ids]` — in front of
1112
+ // them, whatever the depths. The rules of a spec: [the one behind, the one in front] by id.
1113
+ // v: an id or a list of them, given as o's `key` — those naming other elements of the scene (ids:
1114
+ // what stands in it — an insert is over all of it, not among them)
1115
+ const idList = (v, o, key, ids) => {
1116
+ if (v === undefined) return [];
1117
+ const list = typeof v === 'string' ? [v] : Array.isArray(v) ? v : null;
1118
+ if (!list) { warn(`${o.name || o.id}: "${key}" is a list of element ids`); return []; }
1119
+ return list.filter(id => {
1120
+ if (ids.has(id) && id !== o.id) return true;
1121
+ warn(`${o.name || o.id}: "${key}" names "${id}" — not another element standing in the scene (an insert is over all of it)`);
1122
+ return false;
1123
+ });
1124
+ };
1125
+ function orderRules(spec) {
1126
+ const objs = spec.objects || [], ids = new Set(objs.filter(o => !o.insert).map(o => o.id).filter(id => id != null)), rules = [];
1127
+ for (const o of objs) {
1128
+ if (o.insert && (o.behind || o.inFront || o.clip)) warn(`${o.name || o.id}: an insert is over the whole scene — "behind", "inFront" and "clip" are for what stands in it`);
1129
+ if (o.id == null || o.insert) continue;
1130
+ for (const id of idList(o.behind, o, 'behind', ids)) rules.push([o.id, id]);
1131
+ for (const id of idList(o.inFront, o, 'inFront', ids)) rules.push([id, o.id]);
1132
+ }
1133
+ return rules;
1134
+ }
1135
+ // list (far to near) with the rules kept: what is to be behind moved to just before the first
1136
+ // drawing of what it is behind (an element drawn in parts: all of them). idOf(item) → its element's id
1137
+ function arrange(list, rules, idOf) {
1138
+ for (let pass = 0; pass <= rules.length; pass++) {
1139
+ let moved = false;
1140
+ for (const [far, near] of rules) {
1141
+ const first = list.findIndex(it => idOf(it) === near), late = [];
1142
+ if (first < 0) continue;
1143
+ for (let i = list.length - 1; i > first; i--) if (idOf(list[i]) === far) late.unshift(...list.splice(i, 1));
1144
+ if (late.length) { list.splice(first, 0, ...late); moved = true; }
1145
+ }
1146
+ if (!moved) return list;
1147
+ }
1148
+ warn('behind / inFront: elements put each in front of the other, round in a circle');
1149
+ return list;
1150
+ }
1151
+ // `clip`: an element seen only within what other elements paint — {inside: ids} (a figure in a
1152
+ // doorway, a face in a window) — or only out of it — {outside: ids} (under a table top, below
1153
+ // water) — as they are in the frame now. An element with `unseen: true` is not shown itself: a
1154
+ // shape others are cut by. → { inside: true | false, ids } or null
1155
+ function clipOf(o, ids) {
1156
+ const c = o.clip;
1157
+ if (c == null) return null;
1158
+ const side = c && typeof c === 'object' && !Array.isArray(c) ? ['inside', 'outside'].find(k => k in c) : null;
1159
+ if (!side) { warn(`${o.name || o.id}: "clip" is {inside: ids} or {outside: ids}`); return null; }
1160
+ const list = idList(c[side], o, `clip.${side}`, ids);
1161
+ return list.length ? { inside: side === 'inside', ids: list } : null;
1162
+ }
1163
+ // ── flat pieces of the world ───────────────────────────────────────────
1164
+ // A plane (`plane`: a floor, a deck, a table top; the land, draped over the relief) where it lies:
1165
+ // { Xc, Y, Zc: its centre (eye heights), w, d: across and away, cos, sin: its turn, drape, mirror,
1166
+ // pool } — mirror: what it reflects (mirrorOf), pool: how much the scene's lights lie on it unlit (lit)
1167
+ function planeOf(o, dz) {
1168
+ const at = dz.objectAt(o), rot = (o.rotation || 0) * Math.PI / 180;
1169
+ return { Xc: at.X, Y: (o.elevation || 0) / EYE, Zc: at.Z, w: Math.max(0.05, o.width || 4) / EYE, d: Math.max(0.05, o.depth || 4) / EYE, cos: Math.cos(rot), sin: Math.sin(rot), drape: !!o.drape, mirror: mirrorOf(o), pool: poolOf(o) };
1170
+ }
1171
+ // A plane that reflects (`reflect`: a polished floor, still water): all that is above it is seen in
1172
+ // it as in a mirror — { strength: how much (0.35), fade: metres above the plane over which what it
1173
+ // mirrors fades out (4; 0: never), blur: how much the mirrored spreads per metre above it (0.05) };
1174
+ // true, or a number: the strength. → { strength, fade (eye heights), blur } or null (what is wrong, said)
1175
+ function mirrorOf(o) {
1176
+ const r = o.reflect;
1177
+ if (!r) return null;
1178
+ const c = r === true ? {} : typeof r === 'number' ? { strength: r } : typeof r === 'object' && !Array.isArray(r) ? r : null;
1179
+ if (!c || [c.strength, c.fade, c.blur].some(v => v != null && !(typeof v === 'number' && v >= 0))) { warn(`${o.name || o.id}: "reflect" on a plane is true, a strength 0…1 or { "strength", "fade" (metres), "blur" }`); return null; }
1180
+ return { strength: clamp(c.strength ?? 0.35, 0, 1), fade: (c.fade ?? 4) / EYE, blur: c.blur ?? 0.05 };
1181
+ }
1182
+ // `lit` on a plane: the scene's point lights lie on it where the scene is not lit (lookOf) — a pool
1183
+ // under a lamp, a fire's glow on the floor; true, or how strongly (1). → 0…
1184
+ function poolOf(o) {
1185
+ if (o.lit == null || o.lit === false) return 0;
1186
+ if (o.lit === true) return 1;
1187
+ if (typeof o.lit === 'number' && o.lit >= 0) return o.lit;
1188
+ warn(`${o.name || o.id}: "lit" on a plane is true or a strength (1)`);
1189
+ return 0;
1190
+ }
1191
+ // is world point A ({ X, Y, Z }, eye heights) above a reflecting plane of `planes` (planeOf's)? — what
1192
+ // stands on it is mirrored in it (its own `reflect` copy is not drawn)
1193
+ const overMirror = (planes, A) => !!A && planes.some(P => {
1194
+ if (!P.mirror || A.Y < P.Y - 0.01) return false;
1195
+ const dx = A.X - P.Xc, dz = A.Z - P.Zc;
1196
+ return Math.abs(dx * P.cos + dz * P.sin) <= P.w / 2 && Math.abs(dx * P.sin - dz * P.cos) <= P.d / 2;
1197
+ });
1198
+ // the point (f, g) of a plane's painting (both 0…1: across, and from its far edge toward the viewer)
1199
+ // in the world, eye heights — a draped one over the land there
1200
+ const onPlane = P => (f, g) => {
1201
+ const u = -P.w / 2 + P.w * f, v = -P.d / 2 + P.d * g, X = P.Xc + u * P.cos + v * P.sin, Z = P.Zc - v * P.cos + u * P.sin;
1202
+ return [X, P.Y + (P.drape ? groundAt(X * EYE, Z * EYE) / EYE : 0), Z];
1203
+ };
1204
+ // how finely each renderer lays a plane (cells across × away); the land hides what is behind it by
1205
+ // the same cells as it is drawn (landLine)
1206
+ const PLANE_CELLS = { webgl: [24, 48], '2d': [12, 24] };
1207
+ // A flat piece of the world seen truly in perspective from cam: world(f, g) → [X, Y, Z] (eye heights)
1208
+ // for the point (f, g) of its painting, both 0…1, cut into C × R cells; a cell close to the eye
1209
+ // (nearer than it is big) is split finer, and one partly behind the eye is cut at the near plane,
1210
+ // not dropped — a floor reaches right to your feet. each(pts) for each cell within view [x0, y0,
1211
+ // x1, y1] (the frame's px): its corners [x, y, f, g, depth along the gaze] — 3 to 5, convex.
1212
+ const NEAR = 0.06;
1213
+ function cellsOf(cam, world, C, R, W, H, yh, view, each) {
1214
+ const c = Math.cos(cam.yaw || 0), s = Math.sin(cam.yaw || 0), h = cam.h ?? 1;
1215
+ // a point of it seen from the eye: e — [right, down, along the gaze] (eye heights), f, g
1216
+ const eye = (f, g) => { const P = world(f, g), rx = P[0] - cam.x, rz = P[2] - cam.z; return { e: [rx * c - rz * s, h - P[1], rx * s + rz * c], f, g }; };
1217
+ // the grid's corners, each seen once
1218
+ const grid = [];
1219
+ for (let i = 0; i <= R; i++) for (let j = 0; j <= C; j++) grid.push(eye(j / C, i / R));
1220
+ const cell = (a, b, d, q, depth) => { // its corners: far left, far right, near right, near left
1221
+ const zs = [a.e[2], b.e[2], d.e[2], q.e[2]];
1222
+ let zmin = zs[0], zmax = zs[0];
1223
+ for (const z of zs) { if (z < zmin) zmin = z; if (z > zmax) zmax = z; }
1224
+ if (zmax < NEAR) return;
1225
+ if (depth < 4 && zmin < Math.hypot(a.e[0] - d.e[0], a.e[1] - d.e[1], a.e[2] - d.e[2])) {
1226
+ const fm = (a.f + b.f) / 2, gm = (a.g + q.g) / 2, top = eye(fm, a.g), right = eye(b.f, gm), bottom = eye(fm, q.g), left = eye(a.f, gm), mid = eye(fm, gm);
1227
+ cell(a, top, mid, left, depth + 1); cell(top, b, right, mid, depth + 1); cell(left, mid, bottom, q, depth + 1); cell(mid, right, d, bottom, depth + 1);
1228
+ return;
1229
+ }
1230
+ let poly = [a, b, d, q];
1231
+ if (zmin < NEAR) {
1232
+ const cut = [];
1233
+ for (let i = 0; i < 4; i++) {
1234
+ const u = poly[i], v = poly[(i + 1) % 4], uin = u.e[2] >= NEAR, vin = v.e[2] >= NEAR;
1235
+ if (uin) cut.push(u);
1236
+ if (uin !== vin) {
1237
+ const k = (NEAR - u.e[2]) / (v.e[2] - u.e[2]);
1238
+ cut.push({ e: u.e.map((x, j) => x + (v.e[j] - x) * k), f: u.f + (v.f - u.f) * k, g: u.g + (v.g - u.g) * k });
1239
+ }
1240
+ }
1241
+ poly = cut;
1242
+ if (poly.length < 3) return;
1243
+ }
1244
+ const pt = [];
1245
+ let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity;
1246
+ for (const k of poly) {
1247
+ const x = W / 2 + H * k.e[0] / k.e[2], y = yh * H + H * k.e[1] / k.e[2] - cam.y * H;
1248
+ pt.push([x, y, k.f, k.g, k.e[2]]);
1249
+ if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y;
1250
+ }
1251
+ if (x1 < view[0] || x0 > view[2] || y1 < view[1] || y0 > view[3]) return;
1252
+ each(pt);
1253
+ };
1254
+ for (let i = 0; i < R; i++) for (let j = 0; j < C; j++) { const n = i * (C + 1) + j; cell(grid[n], grid[n + 1], grid[n + C + 2], grid[n + C + 1], 0); }
1255
+ }
1256
+ // The land (spec.relief, drawn as the planes draped over it) hides what stands behind its rises.
1257
+ // Over the columns of view [x0, y0, x1, y1] (the frame's px: what is seen; one every LAND_PX), for
1258
+ // LAND_STEPS depths: how high on the frame the land nearer than each reaches — the land as it is
1259
+ // drawn this frame: `cells`, those of its draped planes (cellsOf; what lies below the view hides
1260
+ // nothing seen, so they may leave it out) — so an element D away (along the gaze) is seen only
1261
+ // above the line at the depth just in front of it. Null without them. (The line half a pixel low:
1262
+ // what is cut and the land's edge meet with no pixel of sky between.)
1263
+ const LAND_PX = 4, LAND_STEPS = 80, LAND_NEAR = 0.1, LAND_GAP = 0.1, LAND_NONE = 1e5;
1264
+ function landLine([x0, , x1], cells) {
1265
+ if (!cells.length) return null;
1266
+ const k = Math.pow(Z_FAR / LAND_NEAR, 1 / (LAND_STEPS - 1)), zs = Array.from({ length: LAND_STEPS }, (_, i) => LAND_NEAR * Math.pow(k, i));
1267
+ const cols = Math.max(2, Math.ceil((x1 - x0) / LAND_PX)), dx = (x1 - x0) / cols, top = new Float32Array((cols + 1) * LAND_STEPS).fill(LAND_NONE);
1268
+ // each cell as drawn: where its edges cross each column, at the depth there — a point of the land
1269
+ // there is in front of every depth from the first at or past it (its step: the log of its depth,
1270
+ // eased along the edge)
1271
+ const step = z => Math.log(Math.max(z, LAND_NEAR) / LAND_NEAR) / Math.log(k);
1272
+ for (const pts of cells) {
1273
+ for (let i = 0; i < pts.length; i++) {
1274
+ let l = pts[i], r = pts[(i + 1) % pts.length];
1275
+ if (l[0] === r[0]) continue;
1276
+ if (l[0] > r[0]) [l, r] = [r, l];
1277
+ const sl = step(l[4]), sr = step(r[4]), w = r[0] - l[0];
1278
+ for (let j = Math.max(0, Math.ceil((l[0] - x0) / dx)), last = Math.min(cols, Math.floor((r[0] - x0) / dx)); j <= last; j++) {
1279
+ const u = (x0 + j * dx - l[0]) / w, y = l[1] + (r[1] - l[1]) * u, n = j * LAND_STEPS + Math.min(LAND_STEPS - 1, Math.max(0, Math.ceil(sl + (sr - sl) * u - 1e-9)));
1280
+ if (y < top[n]) top[n] = y;
1281
+ }
1282
+ }
1283
+ }
1284
+ for (let j = 0; j <= cols; j++) for (let i = 1, n = j * LAND_STEPS + 1; i < LAND_STEPS; i++, n++) if (top[n - 1] < top[n]) top[n] = top[n - 1];
617
1285
  return {
618
- yh, Z, objectX, objectAt, objectZ,
619
- // an element where it is now (its `place`), the one truth the renderers, the picking and
620
- // whatever follows it go by: { o: as painted on the stage (x, y, size: fractions of it),
621
- // Z: the depth it is drawn at, at: where it stands in the world }
622
- now: o => {
623
- const n = placed(o);
624
- return { o: { ...o, x: objectX(n), y: objectY(n), size: objectSize(n), _yh: yh }, Z: objectZ(n), at: objectAt(n) };
1286
+ // the line in front of depth D over the columns from xa to xb: [[x, y], …], or null if nowhere
1287
+ // there it comes above `bottom` (y: the lowest the element reaches on the frame)
1288
+ at(D, xa, xb, bottom) {
1289
+ let n = zs.findIndex(z => z > D - LAND_GAP);
1290
+ n = n < 0 ? LAND_STEPS - 1 : n - 1;
1291
+ if (n < 0 || xb <= x0 || xa >= x1) return null;
1292
+ const j0 = Math.max(0, Math.floor((xa - x0) / dx)), j1 = Math.min(cols, Math.ceil((xb - x0) / dx));
1293
+ const pts = [];
1294
+ let hides = false;
1295
+ for (let j = j0; j <= j1; j++) { const y = top[j * LAND_STEPS + n] + 0.5; pts.push([x0 + j * dx, y]); if (y < bottom) hides = true; }
1296
+ return hides ? pts : null;
625
1297
  },
626
1298
  };
627
1299
  }
1300
+
628
1301
  // Screen transform [a b c d e f] for a plane at distance Z (Infinity = the sky): maps the
629
1302
  // scene as painted from the starting spot (on the stage) to the frame (W × H) from where the
630
1303
  // camera is now.
@@ -655,14 +1328,17 @@ const Moood = (() => {
655
1328
  }
656
1329
  // Where an element's painting (as depths() places it on the SW-wide stage) goes on the frame from
657
1330
  // camera cam, where it is now: { m: the transform, D: its distance along the gaze }, or null behind the eye
658
- function placeOf(o, cam, W, H, dz, round) {
659
- const n = dz.now(o);
660
- if (!round) return { m: view(cam, n.Z, W, H, dz.yh), D: n.Z - cam.z };
661
- return billboard(cam, n.at, n.o.x * STAGE * H, n.o.y * H, W, H, dz.yh);
1331
+ function placeOf(o, cam, W, H, dz, round) { return seenOf(cam, dz.now(o), W, H, dz.yh, round); }
1332
+ // where something standing — n: { o, at, Z }, a now() of it (depths: the element, or a part of it) —
1333
+ // is seen from cam: { m, D }, or null (behind the eye, looking around)
1334
+ function seenOf(cam, n, W, H, yh, round) {
1335
+ return round ? billboard(cam, n.at, n.o.x * STAGE * H, n.o.y * H, W, H, yh) : { m: view(cam, n.Z, W, H, yh), D: n.Z - cam.z };
662
1336
  }
663
1337
  // The hero of a platformer h = { dx, dy (metres from where it is painted), flip } as a transform
664
1338
  // of its painting: its anchor painted at x = ax, `ppm` painted px per metre
665
1339
  const heroMatrix = (h, ax, ppm) => [h.flip ? -1 : 1, 0, 0, 1, (h.flip ? 2 * ax : 0) + h.dx * ppm, -h.dy * ppm];
1340
+ // (2D) affine m after n: [a b c d e f] each
1341
+ const mul = (m, n) => [m[0] * n[0] + m[2] * n[1], m[1] * n[0] + m[3] * n[1], m[0] * n[2] + m[2] * n[3], m[1] * n[2] + m[3] * n[3], m[0] * n[4] + m[2] * n[5] + m[4], m[1] * n[4] + m[3] * n[5] + m[5]];
666
1342
  // The full circle of the sky looking around: painted 2π stage heights wide, straight ahead in
667
1343
  // its middle; seen through the frame as a cylinder.
668
1344
  const TAU = 2 * Math.PI;
@@ -707,7 +1383,7 @@ const Moood = (() => {
707
1383
  function limits(spec, W, H) {
708
1384
  const { Z, objectZ, objectX } = depths(spec), aspect = W / H;
709
1385
  const land = (spec.layers || []).map((L, i) => (LAND.has(L.kind) ? Z[i] : null)).filter(z => z !== null);
710
- const things = (spec.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane).map(o => {
1386
+ const things = (spec.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane && !o.insert).map(o => {
711
1387
  const z = objectZ(o), b = Array.isArray(o.box) && o.box.length === 4 ? (o.box[0] + o.box[2]) / 2 / EYE : 0;
712
1388
  return { x: (objectX(o) - 0.5) * STAGE * z + b, z };
713
1389
  });
@@ -721,7 +1397,7 @@ const Moood = (() => {
721
1397
  if (setup.mode === 'roam') {
722
1398
  if (setup.area) { const [l, r, b, f] = setup.area; room = [-l, r, -b, f]; }
723
1399
  else {
724
- 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);
1400
+ const dz = depths(spec), at = (spec.objects || []).filter(o => !o.sky && !o.ground && !o.insert && o.kind !== 'bird').map(o => dz.objectAt(o)).filter(p => Math.hypot(p.X, p.Z) <= ROOM);
725
1401
  const xs = [0, ...at.map(p => p.X)], zs = [0, ...at.map(p => p.Z)], k = 1 / EYE;
726
1402
  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)];
727
1403
  }
@@ -772,8 +1448,13 @@ const Moood = (() => {
772
1448
  const hw = Math.max(0, cam.z - zb) * lim.aspect / 2;
773
1449
  cam.x = clamp(cam.x, Math.max(lo, -hw), Math.min(hi, hw));
774
1450
  }
775
- // near things fade out as you walk up to and past them
776
- const fadeNear = (Z, cam) => clamp((Z - cam.z - 0.3) / 0.6, 0, 1);
1451
+ // Near things: an element D away along the gaze (eye heights) fades over the last steps before
1452
+ // the eye — from PASS_TO (60 cm) to PASS_FROM (20 cm), as you walk through a curtain of leaves: a
1453
+ // painted card is not to pass the eye magnified many times over. Nearer than that, and behind the
1454
+ // eye, it is not drawn. One beside the way (a post, a figure a metre aside) slides out of the frame
1455
+ // whole before it comes so near.
1456
+ const PASS_FROM = 0.12, PASS_TO = 0.35;
1457
+ const nearFade = D => clamp((D - PASS_FROM) / (PASS_TO - PASS_FROM), 0, 1);
777
1458
 
778
1459
  function place(ctx, S, cam, Z) { ctx.transform(...view(cam, Z, S.W, S.H, S.yh)); }
779
1460
 
@@ -803,7 +1484,8 @@ const Moood = (() => {
803
1484
  // through the world (`place`) is put where the state has it as each frame starts (move)
804
1485
  const round = camSetup(spec).round, farFirst = (a, b) => (b.Z - a.Z) || 0;
805
1486
  const at = o => { const n = dz.now(o); return { o: n.o, Z: n.Z, p: round && !o.sky && !o.ground ? n.at : null }; };
806
- const objects = (spec.objects || []).map(o => ({ from: o, ...at(o) })).sort(farFirst), moving = objects.filter(ob => ob.from.place);
1487
+ const objects = (spec.objects || []).filter(o => !o.insert).map(o => ({ from: o, ...at(o) })).sort(farFirst), moving = objects.filter(ob => ob.from.place);
1488
+ const inserts = (spec.objects || []).filter(o => o.insert).map(o => ({ ...o, _yh: dz.yh }));
807
1489
  const move = () => { if (!moving.length) return; for (const ob of moving) Object.assign(ob, at(ob.from)); objects.sort(farFirst); };
808
1490
  const sky = !spec.sky ? '#000000' : (() => {
809
1491
  const c = document.createElement('canvas').getContext('2d');
@@ -812,20 +1494,151 @@ const Moood = (() => {
812
1494
  return g;
813
1495
  })();
814
1496
  const particles = (spec.particles || []).map(p => ({ p, list: makeParticles(p, W, H, rnd) }));
815
- // vignette depends only on the scene, not the frame: paint it once
1497
+ // vignette depends only on the frame: painted once, full, laid as strong as the light says
816
1498
  const vignette = layer(W, H), vg = vignette.getContext('2d');
817
1499
  const v = vg.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.hypot(W, H) * 0.6);
818
1500
  v.addColorStop(0, 'rgba(0,0,0,0)');
819
- v.addColorStop(1, `rgba(0,0,0,${0.75 * clamp(spec.light?.vignette ?? 0.5, 0, 1)})`);
1501
+ v.addColorStop(1, 'rgba(0,0,0,0.75)');
820
1502
  vg.fillStyle = v; vg.fillRect(0, 0, W, H);
821
- return { W, H, SW, u, x0, span, yh: dz.yh, round, lim: limits(spec, W, H), sky, stars, layers, particles, objects, move, vignette, scratch: layer(W, H) };
1503
+ const ids = new Set(objects.map(ob => ob.from.id).filter(id => id != null)), clippers = new Set();
1504
+ for (const ob of objects) { ob.clip = clipOf(ob.from, ids); for (const id of ob.clip?.ids || []) clippers.add(id); }
1505
+ // planes where they lie (planeOf); the land: those draped over the relief
1506
+ for (const ob of objects) if (ob.o.plane) ob.plane = planeOf(ob.from, dz);
1507
+ // the shadows each element casts (by its id)
1508
+ const casts = new Map();
1509
+ for (const sh of shadowsOf(spec)) casts.set(sh.id, [...(casts.get(sh.id) || []), sh]);
1510
+ return { W, H, SW, u, x0, span, yh: dz.yh, dz, round, lim: limits(spec, W, H), sky, stars, layers, particles, objects, inserts, move, vignette, scratch: layer(W, H), rules: orderRules(spec), casts, clippers, lands: objects.filter(ob => ob.plane?.drape), mirrors: objects.filter(ob => ob.plane?.mirror && !ob.from.unseen).map(ob => ob.plane), painted: [] };
1511
+ }
1512
+
1513
+ // ── light and shadows with Canvas 2D ───────────────────────────────────
1514
+ // (the fallback: lit and shadowed looking ahead only; the light laid by the screen, not by depth)
1515
+ // canvas-sized layers of a 2D stage, made once: [canvas, its context]
1516
+ function layer2d(S, name) {
1517
+ const o = S.out;
1518
+ if (!S[name]) S[name] = layer(o.cw, o.ch);
1519
+ return [S[name], S[name].getContext('2d')];
1520
+ }
1521
+ // the elements' lights as sources (sourcesNow), where they were last painted (S.painted: { o, lights }
1522
+ // — each element, and each part of one, as standing() drew it)
1523
+ function sources2d(S, spec) {
1524
+ const from = [];
1525
+ for (const { o, lights } of S.painted) {
1526
+ const [ax, ay, k] = anchorOf(o, S.SW, S.H);
1527
+ for (const l of lights) {
1528
+ const at = worldOf(o, (l.x - ax) / k, (l.y - ay) / k);
1529
+ if (at) from.push({ id: l.id ?? String(o.id ?? o.kind), at, reach: 2 * l.r / k / EYE, rgb: l.col.split(',').map(Number), strength: l.a });
1530
+ }
1531
+ }
1532
+ return sourcesNow(spec, S.dz, from);
1533
+ }
1534
+ // (2D) A lit scene's light: what is lit (the land, what stands, particles) is drawn on a layer of its
1535
+ // own (L.g), and the light laid on it — each time something unlit comes (L.flush: a halo, what
1536
+ // glows; and at the end), so that what is unlit is over what is behind it and under what is in
1537
+ // front of it, as in WebGL. L.dirty: drawn on since. Not lit: L.g null, ctx drawn on.
1538
+ function lit2d(ctx, S, look, cam) {
1539
+ const L = { g: null, dirty: false, look, flush() {} };
1540
+ if (!S.lit) return L;
1541
+ const { out } = S, [lc, g] = layer2d(S, 'litLayer');
1542
+ g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, out.cw, out.ch); g.translate(out.x, out.y);
1543
+ let map = null;
1544
+ L.g = g;
1545
+ L.flush = () => {
1546
+ if (!L.dirty) return;
1547
+ L.dirty = false;
1548
+ map ||= lightMap(S, look, cam);
1549
+ // the light only where something is painted, multiplied in; then put down, the layer cleared
1550
+ const [laid, lg] = layer2d(S, 'lightLaid');
1551
+ lg.globalCompositeOperation = 'copy'; lg.drawImage(map, 0, 0);
1552
+ lg.globalCompositeOperation = 'destination-in'; lg.drawImage(lc, 0, 0);
1553
+ g.save(); g.setTransform(1, 0, 0, 1, 0, 0);
1554
+ g.globalCompositeOperation = 'multiply'; g.drawImage(laid, 0, 0);
1555
+ ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(lc, 0, 0); ctx.restore();
1556
+ g.globalCompositeOperation = 'source-over'; g.clearRect(0, 0, out.cw, out.ch); g.restore();
1557
+ };
1558
+ return L;
1559
+ }
1560
+ // the light as it falls this frame: the ambient, the point sources as soft discs where they are seen
1561
+ function lightMap(S, look, cam) {
1562
+ const { W, H, out } = S, [map, mg] = layer2d(S, 'lightMap'), amb = ambientOf(look, S.sources).map(v => v * 255);
1563
+ // (its sources as much as the scene is lit)
1564
+ mg.setTransform(1, 0, 0, 1, 0, 0); mg.globalCompositeOperation = 'source-over';
1565
+ mg.fillStyle = `rgb(${amb.map(v => Math.round(clamp(v, 0, 255))).join(',')})`; mg.fillRect(0, 0, map.width, map.height);
1566
+ mg.globalCompositeOperation = 'lighter';
1567
+ for (const s of S.sources) {
1568
+ const p = !s.dir && project(cam, ...s.at, W, H, S.yh);
1569
+ if (!p || s.strength < 0.002) continue;
1570
+ const r = s.reach * H / p[2], x = p[0] + out.x, y = p[1] + out.y, g = mg.createRadialGradient(x, y, 0, x, y, r);
1571
+ // (1 − x²)²: as the light falls off (in its own colour: past full it can't brighten here)
1572
+ for (const u of [0, 0.25, 0.5, 0.75, 1]) g.addColorStop(u, `rgba(${s.rgb.join(',')},${Math.min(1, s.strength * look.lit * falloff(u))})`);
1573
+ mg.fillStyle = g; mg.fillRect(x - r, y - r, 2 * r, 2 * r);
1574
+ }
1575
+ mg.globalCompositeOperation = 'source-over';
1576
+ return map;
1577
+ }
1578
+ // the affine map taking three points a to three points b ([x, y] each): [a b c d e f]
1579
+ function affine3(a, b) {
1580
+ const [[x0, y0], [x1, y1], [x2, y2]] = a, det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
1581
+ if (Math.abs(det) < 1e-9) return null;
1582
+ const solve = (v0, v1, v2) => { const p = ((v1 - v0) * (y2 - y0) - (v2 - v0) * (y1 - y0)) / det, q = ((x1 - x0) * (v2 - v0) - (x2 - x0) * (v1 - v0)) / det; return [p, q, v0 - p * x0 - q * y0]; };
1583
+ const [A, C, E] = solve(b[0][0], b[1][0], b[2][0]), [B, D, F] = solve(b[0][1], b[1][1], b[2][1]);
1584
+ return [A, B, C, D, E, F];
1585
+ }
1586
+ // An element's shadows (shadowsOf), with Canvas 2D — or a part's, its element's: its painting (the
1587
+ // item's own canvas, standing()) laid where the light throws three points of its box — an affine
1588
+ // likeness of the true shadow — black and blurred, onto g before it (on an upright element: within
1589
+ // its box; on a plane: within it as it lies). g draws in the canvas's pixels; items are in the frame's
1590
+ function shadows2d(g, S, it, list, cam) {
1591
+ const { W, H, SW, out } = S, o = it.o, m = it.m, b = objectBox(o, SW, H), [ax, ay, k] = anchorOf(o, SW, H);
1592
+ const world = (px, py) => worldOf(o, (px - ax) / k, (py - ay) / k), A0 = world(ax, ay), A = { X: A0[0], Y: A0[1], Z: A0[2] };
1593
+ // (its foot at the anchor: what stands on the ground touches its shadow there)
1594
+ const pts = [[b.bx, ay], [b.bx + b.bw, ay], [b.bx + b.bw / 2, b.by]];
1595
+ for (const sh of S.casts.get(String(it.id)) || []) {
1596
+ const src = shadowLight(S.sources, sh, A);
1597
+ if (!src) continue;
1598
+ const on = sh.onto.filter(id => id !== 'ground').map(id => list.find(x => !x.part && String(x.id) === id)).filter(Boolean), walls = on.filter(w => !w.plane);
1599
+ for (const id of sh.onto) {
1600
+ const R = id === 'ground' ? null : on.find(w => String(w.id) === id);
1601
+ if (id !== 'ground' && !R) continue;
1602
+ const pl = R?.plane ? { q: [R.plane.Xc, R.plane.Y, R.plane.Zc], n: [0, 1, 0] } : R ? { q: worldOf(R.o, 0, 0), n: [0, 0, 1] } : { q: [0, groundAt(o._at.across, o._at.distance) / EYE, 0], n: [0, 1, 0] };
1603
+ const hits = pts.map(([px, py]) => rayHit(pl, world(px, py), src.at, src.dir));
1604
+ const seen = hits.map(h => h && project(cam, ...h[0], W, H, S.yh));
1605
+ if (seen.some(q => !q)) continue;
1606
+ const T = affine3(pts.map(([px, py]) => [m[0] * px + m[4] - it.sx, m[3] * py + m[5] - it.sy]), seen.map(q => [q[0] + out.x, q[1] + out.y]));
1607
+ if (!T) continue;
1608
+ // as dark as at its foot
1609
+ const [sc, sg] = layer2d(S, 'shadowLayer'), top = hits[2], a = shadowAt(sh, src, hits[0][0], S.ambient, S.lit);
1610
+ if (a < 0.005) continue;
1611
+ sg.setTransform(1, 0, 0, 1, 0, 0); sg.globalCompositeOperation = 'source-over'; sg.clearRect(0, 0, sc.width, sc.height);
1612
+ sg.setTransform(...T); sg.drawImage(it.canvas, 0, 0);
1613
+ sg.setTransform(1, 0, 0, 1, 0, 0); sg.globalCompositeOperation = 'source-in'; sg.fillStyle = '#000'; sg.fillRect(0, 0, sc.width, sc.height);
1614
+ g.save(); g.setTransform(1, 0, 0, 1, 0, 0);
1615
+ // on a plane: within it; on a wall: within its box; on the ground: below the walls' feet it
1616
+ // also falls on
1617
+ if (R?.plane) {
1618
+ g.beginPath();
1619
+ cellsOf(cam, onPlane(R.plane), 1, 1, W, H, S.yh, [-out.x, -out.y, out.cw - out.x, out.ch - out.y], pts => { g.moveTo(pts[0][0] + out.x, pts[0][1] + out.y); for (const q of pts) g.lineTo(q[0] + out.x, q[1] + out.y); g.closePath(); });
1620
+ g.clip();
1621
+ } else if (R) {
1622
+ const rm = R.m, rb = R.box;
1623
+ g.beginPath(); g.rect(rm[0] * rb.bx + rm[4] + out.x, rm[3] * rb.by + rm[5] + out.y, rm[0] * rb.bw, rm[3] * rb.bh); g.clip();
1624
+ } else if (walls.length) {
1625
+ const feet = Math.max(...walls.map(w => w.m[3] * w.o.y * H + w.m[5]));
1626
+ g.beginPath(); g.rect(0, feet + out.y, out.cw, out.ch); g.clip();
1627
+ }
1628
+ // a penumbra as wide as the light seen from the caster's top, over the way to the shadow
1629
+ const blur = sh.soft * (src.dir ? top[1] : top[1] - 1) * H / seen[2][2] / 2;
1630
+ g.globalAlpha = a; if (blur > 0.5) g.filter = `blur(${blur.toFixed(1)}px)`;
1631
+ g.drawImage(sc, 0, 0);
1632
+ g.restore();
1633
+ }
1634
+ }
822
1635
  }
823
1636
 
824
1637
  // Looking around, with Canvas 2D (the fallback): the sky turned as a flat band (not bent into a
825
- // cylinder), then the cards facing the eye, far to near. Cards turned their own way (`facing`),
826
- // like planes, are drawn by the WebGL renderer only.
827
- function aroundFrame(ctx, S, cam, t) {
828
- const { W, H, SW } = S, full = TAU * H, off = W / 2 - full / 2 - cam.yaw * H;
1638
+ // cylinder); then what stands about (standing()). Cards turned their own way (`facing`) are drawn
1639
+ // by the WebGL renderer only.
1640
+ function skyAround(ctx, S, cam, t) {
1641
+ const { W, H } = S, full = TAU * H, off = W / 2 - full / 2 - cam.yaw * H;
829
1642
  for (const { o } of S.objects) {
830
1643
  if (!o.sky) continue;
831
1644
  for (let x0 = ((off % full) + full) % full - full; x0 < W; x0 += full) {
@@ -835,30 +1648,279 @@ const Moood = (() => {
835
1648
  ctx.restore();
836
1649
  }
837
1650
  }
838
- const seen = [];
839
- for (const { o, p } of S.objects) {
840
- if (o.sky || o.ground || o.plane || typeof o.facing === 'number') continue;
841
- const b = billboard(cam, p, o.x * SW, o.y * H, W, H, S.yh);
842
- if (b) seen.push({ o, b });
843
- }
844
- seen.sort((a, b) => b.b.D - a.b.D);
845
- for (const { o, b } of seen) {
846
- const fade = clamp((b.D - 0.3) / 0.6, 0, 1), lights = [];
847
- if (fade <= 0) continue;
848
- ctx.save(); ctx.transform(...b.m); ctx.globalAlpha = fade;
849
- drawObject(ctx, o, SW, H, t, lights); drawLights(ctx, lights);
1651
+ }
1652
+
1653
+ // What stands in the scene, with Canvas 2D: far to near (as the elements say: behind, inFront),
1654
+ // each cut by what it is to be seen inside or out of (clip), and by the land in front of it. An
1655
+ // element painting parts (e.part) is painted first, it and each part in a canvas of its own, and
1656
+ // those drawn among the rest at their own depths (as is one that casts a shadow or cuts another:
1657
+ // its canvas is what is cast, and the mask). Planes lie among them, each at its farthest. Everything
1658
+ // here is in the frame's pixels. A lit scene: what is lit is drawn on L's layer (lit2d), what glows
1659
+ // and the halos straight on ctx, between. One that casts a shadow (and each of its parts) has it
1660
+ // drawn just before it; the halos of the scene's own lights at their depth among the rest.
1661
+ function standing(ctx, S, cam, t, L) {
1662
+ const { W, H, SW, dz, out } = S, items = [], painted = S.painted = [], lit = L.g;
1663
+ // o as seen from cam: an item to draw ({ id, o, m, D, Z: what orders it, box, fade, clip, unseen }), or null
1664
+ const itemOf = (ob, o, p, Z) => {
1665
+ const at = seenOf(cam, { o, at: p, Z }, W, H, S.yh, S.round);
1666
+ if (!at) return null;
1667
+ const box = objectBox(o, SW, H), fade = nearFade(at.D);
1668
+ // (a platformer's hero: moved and turned from where it is painted)
1669
+ const m = cam.hero && o.id === cam.hero.id ? mul(at.m, heroMatrix(cam.hero, o.x * SW, o.size * H / (o.height || 2))) : at.m;
1670
+ return fade > 0 ? { id: ob.from.id, ob, o, m, D: at.D, Z: S.round ? at.D : Z, box, fade, clip: ob.clip, unseen: !!ob.from.unseen } : null;
1671
+ };
1672
+ for (const ob of S.objects) {
1673
+ const o = ob.o;
1674
+ if (o.plane) {
1675
+ const P = ob.plane;
1676
+ if (!ob.from.unseen) items.push({ id: ob.from.id, ob, o, plane: P, Z: S.round ? Math.hypot(P.Xc - cam.x, P.Zc - cam.z) + Math.max(P.w, P.d) / 2 : ob.Z });
1677
+ continue;
1678
+ }
1679
+ if (S.round && (o.sky || o.ground || typeof o.facing === 'number')) continue;
1680
+ const it = itemOf(ob, o, ob.p, ob.Z), id = String(ob.from.id);
1681
+ // (out of sight — behind the eye, passed — it goes on all the same, as a frame unseen paints it)
1682
+ if (!S.casts.has(id) && !S.clippers.has(id) && (ob.parted === false || (o.kind && o.kind !== 'custom'))) { if (it) items.push(it); else unseen2d(S, o, t); continue; }
1683
+ // painted now, it and its parts, each where it is seen (painted all the same when it is out of
1684
+ // sight: its parts may not be)
1685
+ const recs = [], whole = prepaint(S, ob, 0, it || { o, m: [1, 0, 0, 1, 0, 0] }, t, painted, recs);
1686
+ ob.parted = recs.length > 0;
1687
+ // (its canvases kept only while it needs them: one painting no parts, casting no shadow, cutting
1688
+ // nothing is drawn straight from the next frame on — this frame's item holds its canvas till drawn)
1689
+ if (!ob.parted && !S.casts.has(id) && !S.clippers.has(id)) ob.canvases = null;
1690
+ else ob.canvases.length = recs.length + 1;
1691
+ if (it && whole) items.push(whole);
1692
+ recs.forEach((r, i) => {
1693
+ const n = dz.part(ob.from, r.dx, r.dz, r.box), pit = itemOf(ob, n.o, n.at, n.Z), done = pit && prepaint(S, ob, i + 1, pit, t, painted, null, r);
1694
+ if (done) items.push(done);
1695
+ });
1696
+ }
1697
+ items.sort((a, b) => (b.Z - a.Z) || 0);
1698
+ arrange(items, S.rules, it => it.id);
1699
+ // the land hiding what stands behind its rises: for the camera as it is (kept while it stands)
1700
+ const key = SHARED.ground && `${cam.x}|${cam.y}|${cam.z}|${cam.h}|${cam.yaw}`;
1701
+ // (the land's cells as they are drawn this frame: plane2d)
1702
+ const seen = [-out.x, -out.y, out.cw - out.x, out.ch - out.y];
1703
+ for (const ob of S.lands) { ob.cells = []; cellsOf(cam, onPlane(ob.plane), ...PLANE_CELLS['2d'], W, H, S.yh, [seen[0], -Infinity, seen[2], seen[3]], pts => ob.cells.push(pts)); }
1704
+ if (key !== S.landKey) { S.landKey = key; S.land = key ? landLine(seen, S.lands.flatMap(ob => ob.cells)) : null; }
1705
+ const land = S.land;
1706
+ const far = h => (S.round ? Math.hypot(h.at[0] - cam.x, h.at[2] - cam.z) : h.at[2]);
1707
+ const look = L.look, halos = airOf(S.sources, look, far);
1708
+ const halo = ({ h, fog }) => {
1709
+ const p = project(cam, ...h.at, W, H, S.yh);
1710
+ if (!p) return;
1711
+ ctx.save(); ctx.globalCompositeOperation = 'lighter';
1712
+ if (fog) glow(ctx, p[0], p[1], h.reach * H / p[2], h.rgb.join(','), airGlow(look, h, p[2], t));
1713
+ else {
1714
+ const r = h.halo * H / p[2], a = Math.min(1, h.strength) * flickerNow(h, t);
1715
+ glow(ctx, p[0], p[1], r, h.rgb.join(','), 0.6 * a); glow(ctx, p[0], p[1], r / 4, '255,245,220', 0.8 * a);
1716
+ }
850
1717
  ctx.restore();
1718
+ };
1719
+ const masks = new Map();
1720
+ // (in a lit scene what is unlit — a halo, what glows — is painted over what the light has been
1721
+ // laid on so far, under what is in front of it: L.flush)
1722
+ // the reflecting planes' mirrors, made before any is drawn: what stands nearer is in them too
1723
+ const mirrors = new Map(items.filter(it => it.plane?.mirror).map(it => [it, mirror2d(S, it, items, cam, t)]));
1724
+ for (const it of items) {
1725
+ if (halos.length && halos[0].Z >= it.Z) { L.flush(); while (halos.length && halos[0].Z >= it.Z) halo(halos.shift()); }
1726
+ if (it.unseen) continue;
1727
+ if (lit && it.o.glows) L.flush();
1728
+ const g = lit && !it.o.sky && !it.o.glows ? lit : ctx;
1729
+ if (g === lit) L.dirty = true;
1730
+ if (it.plane) {
1731
+ plane2d(g, it, S, cam, t, painted);
1732
+ if (mirrors.has(it)) onPlane2d(g, S, it.plane, cam, () => { g.globalAlpha = it.plane.mirror.strength; g.filter = `blur(${(it.plane.mirror.blur * 0.1 * H).toFixed(1)}px)`; g.drawImage(mirrors.get(it), 0, 0); });
1733
+ if (it.plane.pool && S.lit < 1) onPlane2d(g, S, it.plane, cam, () => pool2d(g, S, it.plane, cam, t));
1734
+ continue;
1735
+ }
1736
+ const cut = land && !it.o.sky && !it.o.ground ? landCut(land, it) : null;
1737
+ if (cut === false) continue;
1738
+ if (it.canvas && S.casts.has(String(it.id))) shadows2d(g, S, it, items, cam);
1739
+ // on water: a mirrored, fainter copy about the water surface under it, fading away from it (as
1740
+ // gl.js draws it) — not over a reflecting plane: that mirrors it
1741
+ const copy = !S.round && !it.part && (it.o.reflect ?? (it.o.kind === 'boat' || it.o.kind === 'bridge')), A = copy && worldOf(it.o, 0, 0);
1742
+ if (copy && !(A && overMirror(S.mirrors, { X: A[0], Y: A[1], Z: A[2] }))) {
1743
+ const m = it.m, gy = m[3] * (isFinite(it.Z) ? Math.max(it.o.y * H, (S.yh + 1 / it.Z) * H) : S.yh * H) + m[5];
1744
+ g.save(); g.setTransform(1, 0, 0, 1, 0, 0); g.globalAlpha = 0.3 * it.fade;
1745
+ g.drawImage(flipped(S, gy, gy - (m[3] * it.box.by + m[5]), 0.27, gc => drawItem(gc, it, S, cam, t)), 0, 0);
1746
+ g.restore();
1747
+ }
1748
+ g.save();
1749
+ if (cut) {
1750
+ g.beginPath(); g.moveTo(cut[0][0], -10 * H);
1751
+ for (const [x, y] of cut) g.lineTo(x, y);
1752
+ g.lineTo(cut.at(-1)[0], -10 * H); g.clip();
1753
+ }
1754
+ if (it.clip) {
1755
+ // in a layer of its own, cut by what the others named paint now (their mask made once a
1756
+ // frame for all it cuts — a plane among them laid as it lies)
1757
+ const ids = it.clip.ids.join('\n');
1758
+ let mask = masks.get(ids);
1759
+ if (!mask) {
1760
+ mask = (S.masks ||= [])[masks.size] ||= layer(W, H);
1761
+ const mg = mask.getContext('2d');
1762
+ mg.clearRect(0, 0, W, H);
1763
+ for (const m of items) if (it.clip.ids.includes(m.id)) { if (m.plane) plane2d(mg, m, S, cam, t, []); else drawItem(mg, m, S, cam, t); }
1764
+ masks.set(ids, mask);
1765
+ }
1766
+ const lay = S.lay ||= layer(W, H), lg = lay.getContext('2d');
1767
+ lg.clearRect(0, 0, W, H);
1768
+ drawItem(lg, it, S, cam, t);
1769
+ lg.globalCompositeOperation = it.clip.inside ? 'destination-in' : 'destination-out';
1770
+ lg.drawImage(mask, 0, 0);
1771
+ lg.globalCompositeOperation = 'source-over';
1772
+ g.drawImage(lay, 0, 0);
1773
+ } else drawItem(g, it, S, cam, t, painted);
1774
+ g.restore();
1775
+ // its glows (e.light, a house's windows) over it, unlit
1776
+ if (lit && it.lights?.length) { L.flush(); ctx.save(); ctx.transform(...it.m); ctx.globalAlpha = it.fade; drawLights(ctx, it.lights); ctx.restore(); }
1777
+ // (one that began painting parts: from the next frame on, they stand at their own depths)
1778
+ if (!it.canvas && READS.get(it.o)?.parts) it.ob.parted = true;
1779
+ }
1780
+ if (halos.length) { L.flush(); halos.forEach(halo); }
1781
+ }
1782
+ // (2D) drawn by draw(g) — in the frame's pixels — mirrored about the line y = line, only below it,
1783
+ // fading over `fade` px below it to `to` (0 by default; fade 0: none): a canvas the size of the canvas
1784
+ function flipped(S, line, fade, to, draw) {
1785
+ const out = S.out, [c, g] = layer2d(S, 'flipped'), y = out.y + line;
1786
+ g.setTransform(1, 0, 0, 1, 0, 0); g.globalCompositeOperation = 'source-over'; g.clearRect(0, 0, c.width, c.height);
1787
+ g.setTransform(1, 0, 0, -1, out.x, out.y + 2 * line);
1788
+ draw(g);
1789
+ g.setTransform(1, 0, 0, 1, 0, 0); g.globalCompositeOperation = 'destination-in';
1790
+ if (fade > 0) { const gr = g.createLinearGradient(0, y, 0, y + fade); gr.addColorStop(0, '#000'); gr.addColorStop(1, `rgba(0,0,0,${to})`); g.fillStyle = gr; } else g.fillStyle = '#000';
1791
+ g.fillRect(0, y, c.width, Math.max(0, c.height - y));
1792
+ g.globalCompositeOperation = 'source-over';
1793
+ return c;
1794
+ }
1795
+ // (2D) a reflecting plane's mirror (planeOf: mirror): what stands above it — each item, and each
1796
+ // halo of the scene's lights — flipped about where the plane's level is seen at its depth, fading out
1797
+ // over the mirror's `fade` above it; a canvas the size of the canvas. (Planes, and what is cut by
1798
+ // others, are not mirrored; its blur is one for all of it.)
1799
+ function mirror2d(S, pl, items, cam, t) {
1800
+ const { H, out } = S, P = pl.plane, M = P.mirror, [c, g] = layer2d(S, `mirror:${pl.id}`);
1801
+ g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, c.width, c.height);
1802
+ const A = (S.yh - cam.y) * H, B = H * ((cam.h ?? 1) - P.Y);
1803
+ const add = (D, draw) => {
1804
+ if (!isFinite(D) && M.fade > 0) return;
1805
+ g.drawImage(flipped(S, isFinite(D) ? A + B / D : A, M.fade * H / D, 0, draw), 0, 0);
1806
+ };
1807
+ for (const it of items) if (!it.plane && !it.unseen && !it.clip && !it.o.ground) add(it.D, gc => drawItem(gc, it, S, cam, t));
1808
+ for (const h of S.sources) {
1809
+ const p = h.halo && h.strength > 0.002 && project(cam, ...h.at, S.W, H, S.yh);
1810
+ if (p) add(p[2], gc => { gc.globalCompositeOperation = 'lighter'; glow(gc, p[0], p[1], h.halo * H / p[2], h.rgb.join(','), 0.6 * Math.min(1, h.strength) * flickerNow(h, t)); });
1811
+ }
1812
+ return c;
1813
+ }
1814
+ // (2D) draw(g) over a plane P only where it lies — g in the canvas's pixels
1815
+ function onPlane2d(g, S, P, cam, draw) {
1816
+ const { W, H, out } = S;
1817
+ g.save(); g.setTransform(1, 0, 0, 1, 0, 0); g.beginPath();
1818
+ cellsOf(cam, onPlane(P), 1, 1, W, H, S.yh, [-out.x, -out.y, out.cw - out.x, out.ch - out.y], pts => { g.moveTo(pts[0][0] + out.x, pts[0][1] + out.y); for (const q of pts) g.lineTo(q[0] + out.x, q[1] + out.y); g.closePath(); });
1819
+ g.clip();
1820
+ draw();
1821
+ g.restore();
1822
+ }
1823
+ // (2D) a `lit` plane P: the scene's point lights lie on it as soft pools, as much as the scene is not
1824
+ // lit — each a disc of the plane under the light as wide as its reach there, laid by three of its
1825
+ // points (an affine likeness)
1826
+ function pool2d(g, S, P, cam, t) {
1827
+ const { W, H, out } = S, k = P.pool * (1 - S.lit);
1828
+ g.globalCompositeOperation = 'lighter';
1829
+ for (const s of S.sources) {
1830
+ const h = s.dir ? -1 : s.at[1] - P.Y;
1831
+ if (h < -0.03 || h >= s.reach || s.strength < 0.002) continue;
1832
+ const r = Math.sqrt(s.reach * s.reach - h * h), C = [s.at[0], P.Y, s.at[2]];
1833
+ const q = [C, [C[0] + r, C[1], C[2]], [C[0], C[1], C[2] + r]].map(p => project(cam, ...p, W, H, S.yh));
1834
+ const T = q.every(Boolean) && affine3([[0, 0], [1, 0], [0, 1]], q.map(p => [p[0] + out.x, p[1] + out.y]));
1835
+ if (!T) continue;
1836
+ const a = k * s.strength * flickerNow(s, t) * falloff(Math.max(0, h) / s.reach), gr = g.createRadialGradient(0, 0, 0, 0, 0, 1);
1837
+ for (const u of [0, 0.25, 0.5, 0.75, 1]) gr.addColorStop(u, `rgba(${s.rgb.join(',')},${Math.min(1, 0.35 * a * falloff(u))})`);
1838
+ g.setTransform(...T); g.fillStyle = gr; g.fillRect(-1, -1, 2, 2);
1839
+ }
1840
+ }
1841
+ // (2D) a plane lying in the scene (it.plane: planeOf): its painting (from above, in metres — the
1842
+ // sky's units, as gl.js paints it) kept in a canvas of its own, repainted as gl.js repaints a live
1843
+ // element (what it read moves; `redraw`), laid in perspective through the renderer's cells
1844
+ // (cellsOf), each an affine triangle or two of it. painted: where its lights go (sources2d)
1845
+ const PLANE_2D = 1024;
1846
+ function plane2d(g, it, S, cam, t, painted) {
1847
+ const { W, H, SW, out } = S, ob = it.ob, P = it.plane, w = P.w * EYE * H, d = P.d * EYE * H;
1848
+ const o = ob.flat ||= { ...ob.from, sky: true, plane: true, _plane: P }, snap = redrawOf(o);
1849
+ const read = ob.texRead || {}, due = !ob.tex || (o.redraw != null ? ob.texSnap !== snap : read.time || (read.state && ob.texVer !== STATE.ver && moved(read)) || (read.lights && ob.texSrc !== SOURCES.ver));
1850
+ if (due) {
1851
+ const k = PLANE_2D / Math.max(w, d), c = ob.tex ||= layer(w * k, d * k), pg = c.getContext('2d'), lights = [];
1852
+ pg.setTransform(1, 0, 0, 1, 0, 0); pg.clearRect(0, 0, c.width, c.height);
1853
+ pg.setTransform(k, 0, 0, k, w / 2 * k, d / 2 * k);
1854
+ drawObject(pg, o, SW, H, t, lights); drawLights(pg, lights);
1855
+ Object.assign(ob, { texLights: lights, texVer: STATE.ver, texSrc: SOURCES.ver, texSnap: snap, texRead: READS.get(o) });
1856
+ PERF.repainted(o);
851
1857
  }
1858
+ painted.push({ o, lights: ob.texLights });
1859
+ const c = ob.tex, tris = pts => {
1860
+ for (let i = 1; i + 1 < pts.length; i++) {
1861
+ const tri = [pts[0], pts[i], pts[i + 1]], T = affine3(tri.map(q => [q[2] * c.width, q[3] * c.height]), tri.map(q => [q[0], q[1]]));
1862
+ if (!T) continue;
1863
+ // (a little wider than the triangle: neighbours meet without a seam)
1864
+ const cx = (tri[0][0] + tri[1][0] + tri[2][0]) / 3, cy = (tri[0][1] + tri[1][1] + tri[2][1]) / 3;
1865
+ g.save(); g.beginPath();
1866
+ for (const [x, y] of tri) { const l = Math.hypot(x - cx, y - cy) || 1; g.lineTo(x + (x - cx) / l * 0.7, y + (y - cy) / l * 0.7); }
1867
+ g.clip(); g.transform(...T); g.drawImage(c, 0, 0); g.restore();
1868
+ }
1869
+ };
1870
+ // (the land's cells made already this frame: standing)
1871
+ if (ob.cells) ob.cells.forEach(tris); else cellsOf(cam, onPlane(P), ...PLANE_CELLS['2d'], W, H, S.yh, [-out.x, -out.y, out.cw - out.x, out.ch - out.y], tris);
1872
+ }
1873
+ // (2D) an element's painting — or, with rec, one of its parts' — made in a canvas of its own as it
1874
+ // is seen (it.m), over the part of the canvas it covers: the item, now drawn as that canvas, or
1875
+ // null when it covers none of it. painted: where its lights go (sources2d); recs: where its parts
1876
+ // go (e.part)
1877
+ function prepaint(S, ob, i, it, t, painted, recs, rec) {
1878
+ const { SW, H, out } = S, box = objectBox(it.o, SW, H), m = it.m;
1879
+ // (m mirrors a platformer's hero turned left)
1880
+ const xs = [m[0] * box.bx + m[4], m[0] * (box.bx + box.bw) + m[4]], ys = [m[3] * box.by + m[5], m[3] * (box.by + box.bh) + m[5]];
1881
+ const xa = Math.max(-out.x, Math.min(...xs)), xb = Math.min(out.cw - out.x, Math.max(...xs));
1882
+ const ya = Math.max(-out.y, Math.min(...ys)), yb = Math.min(out.ch - out.y, Math.max(...ys));
1883
+ const seen = xb - xa >= 1 && yb - ya >= 1, w = seen ? Math.ceil(xb - xa) : 1, h = seen ? Math.ceil(yb - ya) : 1;
1884
+ const c = (ob.canvases ||= [])[i] ||= layer(1, 1), g = c.getContext('2d'), lights = [];
1885
+ if (c.width !== w || c.height !== h) { c.width = w; c.height = h; } else { g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, w, h); }
1886
+ g.setTransform(1, 0, 0, 1, -xa, -ya); g.transform(...m);
1887
+ if (rec) { const [ax, ay, k] = anchorOf(it.o, SW, H); rec.paint(g, ax, ay, k, lights); } else drawObject(g, it.o, SW, H, t, lights, recs);
1888
+ // (in a lit scene its glows are unlit: drawn apart, over it — standing)
1889
+ if (!(S.lit > 0)) drawLights(g, lights);
1890
+ painted.push({ o: it.o, lights });
1891
+ return seen ? { ...it, canvas: c, sx: xa, sy: ya, part: !!rec, lights: S.lit > 0 ? lights : null } : null;
1892
+ }
1893
+ // (2D) one item drawn on g (the frame's pixels); painted: where its lights go
1894
+ function drawItem(g, it, S, cam, t, painted) {
1895
+ g.save();
1896
+ g.globalAlpha = it.fade;
1897
+ if (it.canvas) { g.drawImage(it.canvas, it.sx, it.sy); g.restore(); return; }
1898
+ const lights = [];
1899
+ g.transform(...it.m);
1900
+ drawObject(g, it.o, S.SW, S.H, t, lights);
1901
+ // (in a lit scene its glows are unlit: drawn apart, over it — standing)
1902
+ if (S.lit > 0) it.lights = lights; else drawLights(g, lights);
1903
+ painted?.push({ o: it.o, lights });
1904
+ g.restore();
1905
+ }
1906
+ // what of an item the land in front of it leaves seen: the line it is seen above ([[x, y], …]),
1907
+ // null — all of it, false — none of it
1908
+ function landCut(land, it) {
1909
+ const { m, box: b } = it, xa = m[0] * b.bx + m[4], xb = xa + m[0] * b.bw, x0 = Math.min(xa, xb), x1 = Math.max(xa, xb), y0 = m[3] * b.by + m[5], y1 = y0 + m[3] * b.bh;
1910
+ const line = land.at(it.D, x0, x1, y1);
1911
+ if (!line) return null;
1912
+ return line.every(([, y]) => y <= y0) ? false : line;
852
1913
  }
853
1914
 
854
- function drawWater(ctx, S, L, wy, t) {
1915
+ // (src: the canvas the water mirrors — what is put down so far)
1916
+ function drawWater(ctx, S, L, wy, t, src) {
855
1917
  const { W, H, u } = S, depth = H - wy, step = Math.max(3, Math.round(u * 3));
856
1918
  if (depth <= 0) return;
857
1919
  const k = Math.min(1.2, wy / depth), speed = 1 + Math.abs(L.drift ?? 0) * 2;
858
1920
  // copy only the band above the horizon that the reflection actually shows
859
1921
  const band = Math.max(0, Math.floor(wy - depth * k - step)), sc = S.scratch.getContext('2d');
860
1922
  sc.clearRect(0, band, W, wy - band);
861
- sc.drawImage(ctx.canvas, S.out.x, S.out.y + band, W, wy - band, 0, band, W, wy - band);
1923
+ sc.drawImage(src, S.out.x, S.out.y + band, W, wy - band, 0, band, W, wy - band);
862
1924
  for (let y = wy; y < H; y += step) {
863
1925
  const d = (y - wy) / depth;
864
1926
  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;
@@ -900,53 +1962,79 @@ const Moood = (() => {
900
1962
  }
901
1963
  ctx.restore();
902
1964
 
1965
+ const look = lookOf(spec);
1966
+ S.sources = sources2d(S, spec);
1967
+ // lit as much as the light is now (lookOf); its ambient level: how much a shadow takes away is the
1968
+ // source's share of the light
1969
+ S.lit = look.lit;
1970
+ S.ambient = ambientOf(look, S.sources).reduce((a, b) => a + b) / 3;
1971
+ const lit = lit2d(ctx, S, look, cam);
903
1972
  for (const l of S.round ? [] : S.layers) {
904
- const L = l.L;
1973
+ const L = l.L, g = lit.g || ctx;
905
1974
  if (l.Z - cam.z < 0.05) continue;
906
- 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; }
907
- ctx.save(); place(ctx, S, cam, l.Z);
1975
+ lit.dirty = !!lit.g;
1976
+ // (the water mirrors what is put down already: in a lit scene, lit)
1977
+ if (L.kind === 'water') { lit.flush(); lit.dirty = !!lit.g; const m = view(cam, l.Z, W, H, S.yh); drawWater(g, S, L, Math.round(m[5] + m[3] * L.y * H), t, ctx.canvas); continue; }
1978
+ g.save(); place(g, S, cam, l.Z);
908
1979
  if (l.canvas) {
909
- ctx.drawImage(l.canvas, l.x0, 0);
910
- ctx.fillStyle = L.color; ctx.fillRect(l.x0, l.canvas.height - 1, l.canvas.width, 50 * H); // the body goes on below
1980
+ g.drawImage(l.canvas, l.x0, 0);
1981
+ g.fillStyle = L.color; g.fillRect(l.x0, l.canvas.height - 1, l.canvas.width, 50 * H); // the body goes on below
911
1982
  if (l.windows?.length) {
912
1983
  const lc = rgb('#ffd08a');
913
1984
  for (const w of l.windows) {
914
- 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); }
1985
+ if (Math.sin(t * 0.07 + w.p) > -0.85) { g.fillStyle = `rgba(${lc},${0.55 + 0.25 * Math.sin(w.p)})`; g.fillRect(w.x, w.y, w.w, w.h); }
915
1986
  }
916
1987
  }
917
1988
  } else if (L.kind === 'clouds') {
918
- 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);
1989
+ for (const c of l.clouds) g.drawImage(c.c, wrapX(c.x + t * c.sp * (L.drift ?? 0.3) * 3, S, c.c.width), c.y - c.c.height / 2);
919
1990
  } else if (L.kind === 'fog') {
920
1991
  const col = rgb(L.color);
921
1992
  for (const b of l.blobs) {
922
- 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);
923
- ctx.scale(1, b.ry / b.rx); glow(ctx, 0, 0, b.rx, col, b.a * 2.2); ctx.restore();
1993
+ g.save(); g.translate(wrapX(b.x + t * b.sp * (L.drift ?? 0.3) * 2, S, 2 * b.rx) + b.rx, L.y * H + b.dy);
1994
+ g.scale(1, b.ry / b.rx); glow(g, 0, 0, b.rx, col, b.a * 2.2); g.restore();
924
1995
  }
925
1996
  }
926
- ctx.restore();
1997
+ g.restore();
927
1998
  }
928
1999
 
929
- if (S.round) aroundFrame(ctx, S, cam, t);
930
- else for (const { o, Z } of S.objects) {
931
- if (o.plane) continue; // horizontal planes are drawn by the WebGL renderer only
932
- const lights = [], fade = fadeNear(Z, cam);
933
- if (fade <= 0) continue;
934
- ctx.save(); place(ctx, S, cam, Z); ctx.globalAlpha = fade;
935
- if (cam.hero && o.id === cam.hero.id) ctx.transform(...heroMatrix(cam.hero, o.x * SW, o.size * H / (o.height || 2)));
936
- drawObject(ctx, o, SW, H, t, lights);
937
- drawLights(ctx, lights);
2000
+ if (S.round) skyAround(ctx, S, cam, t);
2001
+ standing(ctx, S, cam, t, lit);
2002
+
2003
+ for (const { p, list } of S.particles) { stepParticles(lit.g || ctx, p, list, W, H, t, dt, u); lit.dirty = !!lit.g; }
2004
+ lit.flush();
2005
+ // inserts over it all, each in a layer of its own (the iris cuts it alone)
2006
+ for (const o of S.inserts) {
2007
+ const open = opening(o);
2008
+ if (open < 0.002) continue;
2009
+ const g = S.scratch.getContext('2d'), lights = [];
2010
+ g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, W, H);
2011
+ g.setTransform(1, 0, 0, 1, (W - SW) / 2, 0);
2012
+ drawObject(g, o, SW, H, t, lights);
2013
+ iris(g, o, SW, H, open, lights);
2014
+ ctx.drawImage(S.scratch, 0, 0);
2015
+ }
2016
+ // the grade: tint, saturation, brightness, vignette, grain (the light now: lookOf)
2017
+ if (look.tint && look.tintStrength) {
2018
+ ctx.save(); ctx.globalAlpha = clamp(look.tintStrength, 0, 0.5) * 0.45;
2019
+ ctx.fillStyle = `rgb(${look.tint.map(Math.round).join(',')})`; ctx.fillRect(0, 0, W, H); ctx.restore();
2020
+ }
2021
+ if (look.saturation < 1) {
2022
+ ctx.save(); ctx.globalCompositeOperation = 'saturation'; ctx.globalAlpha = clamp(1 - look.saturation, 0, 1);
2023
+ ctx.fillStyle = '#808080'; ctx.fillRect(0, 0, W, H); ctx.restore();
2024
+ }
2025
+ // (the colour times b, as WebGL: darker multiplied, brighter added to itself)
2026
+ if (look.brightness < 1) {
2027
+ ctx.save(); ctx.globalCompositeOperation = 'multiply';
2028
+ ctx.fillStyle = `rgb(${Array(3).fill(Math.round(Math.max(0, look.brightness) * 255)).join(',')})`;
2029
+ ctx.fillRect(0, 0, W, H); ctx.restore();
2030
+ } else if (look.brightness > 1) {
2031
+ ctx.save(); ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.globalCompositeOperation = 'lighter';
2032
+ ctx.globalAlpha = Math.min(1, look.brightness - 1); ctx.drawImage(ctx.canvas, 0, 0); // (up to twice as bright)
938
2033
  ctx.restore();
939
2034
  }
940
-
941
- for (const { p, list } of S.particles) stepParticles(ctx, p, list, W, H, t, dt, u);
942
- const lt = spec.light || {};
943
- if (lt.tint && lt.tintStrength) {
944
- ctx.save(); ctx.globalAlpha = clamp(lt.tintStrength, 0, 0.5) * 0.45;
945
- ctx.fillStyle = lt.tint; ctx.fillRect(0, 0, W, H); ctx.restore();
946
- }
947
- ctx.drawImage(S.vignette, 0, 0);
948
- if ((lt.grain ?? 0.5) > 0) {
949
- ctx.save(); ctx.globalAlpha = clamp(lt.grain ?? 0.5, 0, 1);
2035
+ ctx.save(); ctx.globalAlpha = clamp(look.vignette, 0, 1); ctx.drawImage(S.vignette, 0, 0); ctx.restore();
2036
+ if (look.grain > 0) {
2037
+ ctx.save(); ctx.globalAlpha = clamp(look.grain, 0, 1);
950
2038
  ctx.translate(Math.random() * 160, Math.random() * 160); ctx.fillStyle = S.grain; ctx.fillRect(-160, -160, W + 320, H + 320); ctx.restore();
951
2039
  }
952
2040
  // out of the frame the scene dims toward the canvas's edges (with Canvas 2D, what nothing
@@ -960,6 +2048,28 @@ const Moood = (() => {
960
2048
  }
961
2049
  }
962
2050
 
2051
+ // A frame unseen (stage.draw(false)): the scene goes on as if it were drawn — what moves through
2052
+ // the world is put where it is now, the painting code of what has a memory of its own (a module's
2053
+ // springs and steps, the point it marks) runs as for a frame, particles move — and nothing is
2054
+ // drawn. Custom elements are painted at a fraction of their size, to a canvas nobody sees.
2055
+ // (A drawn frame paints so what is out of sight: unseen2d.)
2056
+ const UNSEEN = 0.05;
2057
+ function unseen2d(S, o, t) {
2058
+ if (o.kind && o.kind !== 'custom') return;
2059
+ const g = S.unseen.getContext('2d');
2060
+ g.setTransform(UNSEEN, 0, 0, UNSEEN, 0, 0);
2061
+ paintCustom(g, o, S.SW, S.H, t, []);
2062
+ }
2063
+ function tick(S, t, dt) {
2064
+ const { W, H, u } = S;
2065
+ S.move();
2066
+ for (const o of [...S.objects.map(ob => ob.o), ...S.inserts.filter(o => opening(o) >= 0.002)]) unseen2d(S, o, t);
2067
+ for (const { p, list } of S.particles) {
2068
+ const wind = (p.wind ?? 0) * 60 * u;
2069
+ for (const q of list) moveParticle(p.kind, q, W, H, t, dt, u, wind);
2070
+ }
2071
+ }
2072
+
963
2073
  // Mounts the engine on a canvas: returns { show(spec), resetCamera() }.
964
2074
  // Camera: drag = step sideways / turn the head up-down, wheel / pinch = walk toward the cursor,
965
2075
  // keys: A D ← → = sideways, W S ↑ ↓ = forward-back, 0 = back to the start.
@@ -971,12 +2081,16 @@ const Moood = (() => {
971
2081
  const grain = ctx.createPattern(grainTile, 'repeat');
972
2082
  return {
973
2083
  name: '2d', fpsIdle: 20, pixelBudget: 1.3e6, resMax: 1.5,
974
- build(spec, W, H, prev, out) { const S = build(spec, W, H); S.grain = grain; S.out = out; return S; },
2084
+ build(spec, W, H, prev, out) { const S = build(spec, W, H); S.grain = grain; S.out = out; S.unseen = layer(8, 8); return S; },
975
2085
  frame(S, spec, cam, t, dt) { frame(ctx, S, spec, cam, t, dt); },
2086
+ // painted as when drawn: Canvas 2D paints every element each frame — that is drawing it
2087
+ tick(S, spec, cam, t, dt, goal, painted) { if (painted) frame(ctx, S, spec, cam, t, dt); else tick(S, t, dt); },
976
2088
  };
977
2089
  }
978
2090
 
979
- function mount(canvas) {
2091
+ // opts.check: the scene checked as it is drawn (CHECK — ?debug does it too)
2092
+ function mount(canvas, opts = {}) {
2093
+ CHECK.on = !!opts.check || /[?&]debug\b/.test(location.search);
980
2094
  // a canvas gets one context type for life: WebGL2 when available, else 2D
981
2095
  // (?2d in the URL forces the 2D renderer, for comparison)
982
2096
  const R = (typeof MooodGL !== 'undefined' && !/[?&]2d\b/.test(location.search) && MooodGL.create(canvas)) || canvas2DRenderer(canvas);
@@ -1055,6 +2169,9 @@ const Moood = (() => {
1055
2169
  // the pointer moves, and kept till the gesture ends; it stays under the cursor all the way —
1056
2170
  // the gaze lifting or lowering with it — and the walk stops where it about fills the frame
1057
2171
  // (something high up: where it still shows with the gaze lifted as far as it goes).
2172
+ // What the viewer points at is seen from the camera as shown — the film's camera on top of the
2173
+ // viewer's own (off: how far the film moves it); the viewer's moves change their own.
2174
+ const off = { x: 0, y: 0, z: 0, h: 0, yaw: 0 }, seenCam = () => ({ x: target.x + off.x, y: target.y + off.y, z: target.z + off.z, h: target.h + off.h, yaw: target.yaw + off.yaw });
1058
2175
  let aim = null, aimAt = 0;
1059
2176
  // is the element truly painted at the cursor: nearly opaque paint on a tenth of the 9×9
1060
2177
  // pixels around it (the thin spokes of a wheel count; a veil, a glow, empty air don't)?
@@ -1070,35 +2187,35 @@ const Moood = (() => {
1070
2187
  return n >= 8;
1071
2188
  };
1072
2189
  const pick = (clientX, clientY) => {
1073
- const [cx, cy] = local(clientX, clientY), Hc = box.h, Wc = box.w, yh = S?.yh ?? 0.6, dz = spec ? depths(spec) : null;
1074
- const at = D => ({ Z: target.z + D, x: target.x + (cx - Wc / 2) / Hc * D });
1075
- const near = (spec?.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane).map(o => dz.now(o))
1076
- .filter(({ Z }) => fadeNear(Z, target) > 0).sort((a, b) => a.Z - b.Z);
2190
+ const [cx, cy] = local(clientX, clientY), Hc = box.h, Wc = box.w, yh = S?.yh ?? 0.6, dz = spec ? depths(spec) : null, v = seenCam();
2191
+ const at = D => ({ Z: v.z + D, x: v.x + (cx - Wc / 2) / Hc * D });
2192
+ const near = (spec?.objects || []).filter(o => o.kind !== 'bird' && !o.sky && !o.ground && !o.plane && !o.insert && !o.unseen).map(o => dz.now(o))
2193
+ .filter(({ Z }) => nearFade(Z - v.z) > 0).sort((a, b) => a.Z - b.Z);
1077
2194
  for (const { o, Z } of near) {
1078
- const m = view(target, Z, Wc, Hc, yh), b = objectBox(o, STAGE * Hc, Hc);
2195
+ const m = view(v, Z, Wc, Hc, yh), b = objectBox(o, STAGE * Hc, Hc);
1079
2196
  const x0 = m[0] * b.bx + m[4], y0 = m[3] * b.by + m[5];
1080
2197
  if (cx < x0 || cx > x0 + m[0] * b.bw || cy < y0 || cy > y0 + m[3] * b.bh) continue;
1081
2198
  if (!seenAt(o, m, cx, cy, Hc)) continue;
1082
2199
  // the point under the cursor, Y eye heights up; stop where the thing about fills the
1083
2200
  // frame, or — high up — where the point still shows with the gaze lifted as far as it goes
1084
- const D = Z - target.z, Y = target.h - (cy / Hc - yh + target.y) * D, up = (lim.setup?.look ?? 0) + LOOK_UP;
1085
- const sight = Y > target.h ? (Y - target.h) / Math.max(0.05, yh - cy / Hc - up) : 0;
2201
+ const D = Z - v.z, Y = v.h - (cy / Hc - yh + v.y) * D, up = (lim.setup?.look ?? 0) + LOOK_UP;
2202
+ const sight = Y > v.h ? (Y - v.h) / Math.max(0.05, yh - cy / Hc - up) : 0;
1086
2203
  return { ...at(D), Y, cy: cy / Hc, stop: Math.max(1.1, 1.15 * o.size * Z, sight) };
1087
2204
  }
1088
- const below = cy - (yh - target.y) * Hc;
1089
- return { ...at(below > 0.02 * Hc ? clamp(Hc * target.h / below, 0.8, 100) : 6), stop: 1.2 };
2205
+ const below = cy - (yh - v.y) * Hc;
2206
+ return { ...at(below > 0.02 * Hc ? clamp(Hc * v.h / below, 0.8, 100) : 6), stop: 1.2 };
1090
2207
  };
1091
2208
  const walkToward = (factor, clientX, clientY) => {
1092
2209
  const now = performance.now();
1093
2210
  if (!aim || now - aimAt > 400 || Math.hypot(clientX - aim.px, clientY - aim.py) > 6) aim = { ...pick(clientX, clientY), px: clientX, py: clientY };
1094
2211
  aimAt = now;
1095
- const d0 = aim.Z - target.z;
2212
+ const v = seenCam(), d0 = aim.Z - v.z;
1096
2213
  if (d0 <= 0.05) return;
1097
2214
  // each notch covers a share of the way left (the approach slows by itself), up to the stop
1098
2215
  const d = factor > 1 ? Math.max(Math.min(d0, aim.stop), d0 / factor) : d0 / factor;
1099
- target.z = aim.Z - d;
1100
- target.x = aim.x - (aim.x - target.x) * d / d0;
1101
- if (aim.Y !== undefined) target.y = (S?.yh ?? 0.6) - aim.cy + (target.h - aim.Y) / Math.max(0.05, d);
2216
+ target.z = aim.Z - d - off.z;
2217
+ target.x = aim.x - (aim.x - v.x) * d / d0 - off.x;
2218
+ if (aim.Y !== undefined) target.y = (S?.yh ?? 0.6) - aim.cy + (v.h - aim.Y) / Math.max(0.05, d) - off.y;
1102
2219
  // what you walked up to may lie past where walking goes: you may stand there
1103
2220
  lim.reach = target.z > lim.zMax ? Math.max(lim.reach ?? 0, target.z) : undefined;
1104
2221
  limitCam(target, lim);
@@ -1113,8 +2230,8 @@ const Moood = (() => {
1113
2230
  const grabAt = (clientX, clientY) => {
1114
2231
  // looking around, a drag turns the head: what you took hold of follows the finger
1115
2232
  if (round()) { const [cx, cy] = local(clientX, clientY); grab = { round: true, cx, cy, yaw: target.yaw, look: target.y }; return; }
1116
- const [cx, cy] = local(clientX, clientY), D = clamp(pick(clientX, clientY).Z - target.z, 0.4, 60);
1117
- grab = { x: target.x + (cx - box.w / 2) / box.h * D, D, cy, look: target.y };
2233
+ const [cx, cy] = local(clientX, clientY), v = seenCam(), D = clamp(pick(clientX, clientY).Z - v.z, 0.4, 60);
2234
+ grab = { x: v.x + (cx - box.w / 2) / box.h * D, D, cy, look: target.y };
1118
2235
  };
1119
2236
  const dragTo = (clientX, clientY) => {
1120
2237
  const [cx, cy] = local(clientX, clientY), x0 = target.x, y0 = target.y, now = performance.now();
@@ -1129,7 +2246,7 @@ const Moood = (() => {
1129
2246
  lastMove = now;
1130
2247
  return;
1131
2248
  }
1132
- target.x = grab.x - (cx - box.w / 2) / box.h * grab.D;
2249
+ target.x = grab.x - (cx - box.w / 2) / box.h * grab.D - off.x;
1133
2250
  target.y = grab.look - (cy - grab.cy) / box.h;
1134
2251
  limitCam(target, lim);
1135
2252
  const dt = Math.max(8, now - lastMove) / 1000, k = Math.min(1, dt * 20);
@@ -1138,37 +2255,51 @@ const Moood = (() => {
1138
2255
  glide.vy += ((target.y - y0) / dt - glide.vy) * k;
1139
2256
  lastMove = now;
1140
2257
  };
1141
- // where in the canvas (CSS px) an element is from camera c — where its `place` has it now: the
1142
- // point its code marks (e.at), else its anchor (its top, with over) — lifted by `lift` metres:
2258
+ // A point of an element, by `ref`: its id — the point its code marks (e.at), else its anchor — or
2259
+ // 'id#name', a point its painting names (e.at(x, y, name)). → { o: the element, n: it where it is
2260
+ // now (depths().now — or the part the point is in, depths().part), f: the point in metres from
2261
+ // n's anchor as painted, or null (its anchor), named }, or null: no such element, or its painting
2262
+ // marks no such point now
2263
+ function pointOf(ref, dz) {
2264
+ const [id, name] = String(ref).split('#'), o = byId.get(id);
2265
+ if (!o) return null;
2266
+ const f = name !== undefined ? NAMED.get(id)?.get(name) : FOCUS.get(id);
2267
+ if (name !== undefined && !f) return null;
2268
+ const n = f?.part ? dz.part(o, f.part.dx, f.part.dz) : dz.now(o);
2269
+ return { o, n, f: f || null, named: name !== undefined };
2270
+ }
2271
+ // where in the canvas (CSS px) an element's point (pointOf) is from camera c — where its `place`
2272
+ // has it now — its anchor's top (with over) when its code marks none — lifted by `lift` metres:
1143
2273
  // [x, y, its distance]; null when out of sight
1144
- function seenFrom(c, id, lift = 0, over = false) {
1145
- const o = (spec?.objects || []).find(o => o.id === id);
1146
- if (!o || o.sky) return null;
1147
- const W = box.w, H = box.h, dz = depths(spec), yh = dz.yh;
2274
+ function seenFrom(c, ref, lift = 0, over = false) {
2275
+ if (!spec) return null;
2276
+ const dz = depths(spec), P = pointOf(ref, dz);
2277
+ if (!P || P.o.sky || P.o.insert) return null;
2278
+ const { o, n, f } = P, W = box.w, H = box.h, yh = dz.yh;
1148
2279
  if (o.plane) {
1149
- const p = dz.objectAt(o), Z = p.Zp, up = lift / EYE;
2280
+ const p = dz.objectAt(o), Z = p.Zp, up = lift / EYE + (o.drape ? groundAt(p.X * EYE, p.Z * EYE) / EYE : 0);
1150
2281
  if (round()) {
1151
2282
  const q = project(c, p.X, p.Y + up, p.Z, W, H, yh);
1152
2283
  return q && q[2] > 0.3 ? [box.x + q[0], box.y + q[1], q[2]] : null;
1153
2284
  }
1154
- if (fadeNear(Z, c) < 0.3) return null;
2285
+ if (Z - c.z <= 0.05) return null; // (behind the eye)
1155
2286
  const m = view(c, Z, W, H, yh), px = dz.objectX(o) * STAGE * H, py = (yh + (1 - (o.elevation || 0) / EYE) / Z - up / Z) * H;
1156
2287
  return [box.x + m[0] * px + m[4], box.y + m[3] * py + m[5], Z - c.z];
1157
2288
  }
1158
- const at = placeOf(o, c, W, H, dz, round());
1159
- // behind the eye, or faded out as it is passed (fadeNear)
1160
- if (!at || at.D < 0.48) return null;
1161
- const n = dz.now(o), [ax, ay, ppm] = anchorOf(n.o, STAGE * H, H), f = FOCUS.get(id) || { x: 0, y: 0 };
1162
- if (over && !FOCUS.has(id)) lift += o.height ?? EYE;
1163
- let px = ax + f.x * ppm, py = ay + f.y * ppm - lift / EYE * H / n.at.Zp;
1164
- if (c.hero && id === c.hero.id) { const hm = heroMatrix(c.hero, ax, ppm); px = hm[0] * px + hm[4]; py += hm[5]; }
2289
+ const at = seenOf(c, n, W, H, yh, round());
2290
+ // behind the eye, or faded out as it is passed (nearFade)
2291
+ if (!at || nearFade(at.D) < 0.3) return null;
2292
+ const [ax, ay, ppm] = anchorOf(n.o, STAGE * H, H);
2293
+ if (over && !f) lift += o.height ?? EYE;
2294
+ let px = ax + (f ? f.x : 0) * ppm, py = ay + (f ? f.y : 0) * ppm - lift / EYE * H / n.at.Zp;
2295
+ if (c.hero && o.id === c.hero.id && !f?.part) { const hm = heroMatrix(c.hero, ax, ppm); px = hm[0] * px + hm[4]; py += hm[5]; }
1165
2296
  return [box.x + at.m[0] * px + at.m[4], box.y + at.m[3] * py + at.m[5], at.D];
1166
2297
  }
1167
2298
  // the camera keeps the elements it follows in the visible part of the screen: together when
1168
2299
  // they fit, else the first of them
1169
2300
  function keepInView() {
1170
2301
  const ids = lim.setup?.follow;
1171
- if (!ids?.length || pointers.size || keys.size || glide) return;
2302
+ if (!ids?.length || pointers.size || keys.size || glide || op.pose) return;
1172
2303
  const [vl, vr] = lim.view, L = box.x + (vl + 0.1) * box.w, R = box.x + (vr - 0.1) * box.w;
1173
2304
  const pts = ids.map(id => seenFrom(target, id)).filter(Boolean);
1174
2305
  if (!pts.length) return;
@@ -1177,6 +2308,136 @@ const Moood = (() => {
1177
2308
  const aim = hi - lo <= R - L ? (lo + hi) / 2 : pts[0][0];
1178
2309
  target.x += (aim - (L + R) / 2) * pts[0][2] / box.h;
1179
2310
  }
2311
+ // What is where in the frame now, seen from camera c — for whoever looks closely (?debug, a
2312
+ // render's --json). By element (its id, else its name, else its kind and place in the spec):
2313
+ // { kind, seen, box: [x, y, w, h] — where its painting may reach, in the canvas's CSS px; at — the
2314
+ // point its code marks (e.at), anchor — where it stands, [x, y]; distance (m); fade — 1, less in
2315
+ // the last steps before the eye (nearFade); points — those its code names (e.at(x, y, name)): {name: [x, y]};
2316
+ // parts — those it paints (e.part): [{dx, dz, box, seen, distance}]; notes — what its code wrote
2317
+ // (e.debug): {value, at?}; paint — its last painting: {ms, repaint: 'frame' (it reads the time),
2318
+ // 'state' (the state or the light on it) or 'once'} }
2319
+ function layout(c) {
2320
+ const out = {};
2321
+ if (!spec || !dzOf) return out;
2322
+ const W = box.w, H = box.h, SW = STAGE * H, yh = dzOf.yh;
2323
+ const r1 = v => Math.round(v * 10) / 10, pt = (x, y) => [r1(box.x + x), r1(box.y + y)];
2324
+ const rect = (x0, y0, x1, y1) => [r1(box.x + Math.min(x0, x1)), r1(box.y + Math.min(y0, y1)), r1(Math.abs(x1 - x0)), r1(Math.abs(y1 - y0))];
2325
+ const inFrame = ([x, y, w, h]) => x < box.x + W && x + w > box.x && y < box.y + H && y + h > box.y;
2326
+ (spec.objects || []).forEach((o, i) => {
2327
+ const key = keyOf(o), el = { kind: o.sky ? 'sky' : o.ground ? 'ground' : o.plane ? 'plane' : o.insert ? 'insert' : o.kind || 'custom' };
2328
+ let place = null; // a point its code names (its units, from its anchor) → [x, y] on the canvas
2329
+ if (o.sky || o.insert) { el.box = rect(0, 0, W, H); el.seen = !!o.sky || opening(o) >= 0.002; }
2330
+ else if (o.ground) {
2331
+ const m = view(c, Z_FAR, W, H, yh), y = m[5] + m[3] * yh * H;
2332
+ el.box = rect(0, y, W, H); el.seen = y < H;
2333
+ } else if (o.plane) {
2334
+ const p = dzOf.objectAt(o), w = (o.width || 4) / EYE / 2, d = (o.depth || 4) / EYE / 2, a = (o.rotation || 0) * Math.PI / 180;
2335
+ const q = [[-w, -d], [w, -d], [w, d], [-w, d]].map(([u, v]) => project(c, p.X + u * Math.cos(a) + v * Math.sin(a), (o.elevation || 0) / EYE, p.Z - v * Math.cos(a) + u * Math.sin(a), W, H, yh)).filter(Boolean);
2336
+ if (q.length) { const xs = q.map(v => v[0]), ys = q.map(v => v[1]); el.box = rect(Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)); }
2337
+ el.seen = !!el.box && inFrame(el.box);
2338
+ } else {
2339
+ // where something standing (the element, or one of its parts: a now() of it) is in the frame:
2340
+ // its box, anchor, distance, fade, and onFrame — its painting's point → the canvas
2341
+ const stand = (n, hero) => {
2342
+ const at = seenOf(c, n, W, H, yh, round());
2343
+ if (!at) return null;
2344
+ const [ax, ay, ppm] = anchorOf(n.o, SW, H), b = objectBox(n.o, SW, H), m = at.m;
2345
+ const hm = hero ? heroMatrix(c.hero, ax, ppm) : null;
2346
+ const onFrame = (x, y) => { if (hm) { x = hm[0] * x + hm[4]; y += hm[5]; } return [m[0] * x + m[4], m[3] * y + m[5]]; };
2347
+ const p0 = onFrame(b.bx, b.by), p1 = onFrame(b.bx + b.bw, b.by + b.bh), box_ = rect(p0[0], p0[1], p1[0], p1[1]);
2348
+ const fade = Math.round(nearFade(at.D) * 100) / 100;
2349
+ return { box: box_, fade, seen: fade > 0 && inFrame(box_), anchor: pt(...onFrame(ax, ay)), distance: r1(at.D * EYE), on: (x, y) => pt(...onFrame(ax + x * ppm, ay + y * ppm)) };
2350
+ };
2351
+ const own = stand(dzOf.now(o), c.hero && o.id === c.hero.id);
2352
+ if (!own) el.seen = false;
2353
+ else {
2354
+ const { on, ...rest } = own;
2355
+ Object.assign(el, rest);
2356
+ place = on;
2357
+ // its points: the one its code marks (e.at), and those it names (e.at(x, y, name)) — each
2358
+ // where it was painted, in the element or in one of its parts
2359
+ const where = f => (f.part ? stand(dzOf.part(o, f.part.dx, f.part.dz), false)?.on(f.x, f.y) ?? null : on(f.x, f.y));
2360
+ const f = FOCUS.get(o.id), named = NAMED.get(o.id);
2361
+ if (f) el.at = where(f);
2362
+ if (named?.size) el.points = Object.fromEntries([...named].map(([nm, p]) => [nm, where(p)]));
2363
+ // its parts (e.part), each standing at its own depth
2364
+ const parts = PARTS.get(key);
2365
+ if (parts?.length) el.parts = parts.map(q => { const s_ = stand(dzOf.part(o, q.dx, q.dz, q.box), false); return s_ ? { dx: q.dx, dz: q.dz, box: s_.box, seen: s_.seen, distance: s_.distance } : { dx: q.dx, dz: q.dz, seen: false }; });
2366
+ }
2367
+ }
2368
+ const notes = NOTES.get(key), last = PERF.last.get(key), ms = last?.ms, read = last?.read;
2369
+ if (notes) el.notes = notes.map(nt => (nt.at && place ? { value: nt.value, at: place(nt.at.x, nt.at.y) } : { value: nt.value }));
2370
+ if (ms != null) el.paint = { ms: Math.round(ms * 100) / 100, repaint: read?.time || o.frames > 1 ? 'frame' : read?.state || read?.lights ? 'state' : 'once', ...(read?.keys.size ? { keys: [...read.keys.keys()].map(k => (k === ALL ? '*' : k)) } : {}), ...(PERF.repaints.has(key) ? { repaints: PERF.repaints.get(key) } : {}) };
2371
+ out[key ?? `${el.kind}#${i}`] = el;
2372
+ });
2373
+ return out;
2374
+ }
2375
+ // ?debug: over the frame, what is where — each element seen: its box and id, its anchor (a ring),
2376
+ // the point its code marks (a cross), what its code wrote (e.debug); the scene's second, and
2377
+ // what it does wrong (window.__mooodErrors, then __mooodWarnings: e.warn's too) (?debug=keeper,boy: only those)
2378
+ const looking = /[?&]debug\b/.test(location.search) ? overlay(new URLSearchParams(location.search).get('debug')) : null;
2379
+ function overlay(only) {
2380
+ const ids = only ? new Set(only.split(',')) : null;
2381
+ const c = document.createElement('canvas'), g = c.getContext('2d');
2382
+ c.style.cssText = 'position:fixed;pointer-events:none;z-index:8';
2383
+ document.body.append(c);
2384
+ const say = v => (typeof v === 'string' ? v : JSON.stringify(v, (k, x) => (typeof x === 'number' ? Math.round(x * 100) / 100 : x)) ?? String(v)).replace(/"/g, '');
2385
+ const hue = k => hash(k) % 360;
2386
+ return {
2387
+ draw(cam, t) {
2388
+ const r = canvas.getBoundingClientRect(), dpr = window.devicePixelRatio || 1, cw = Math.max(1, canvas.clientWidth), ch = Math.max(1, canvas.clientHeight);
2389
+ Object.assign(c.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
2390
+ if (c.width !== Math.round(cw * dpr) || c.height !== Math.round(ch * dpr)) { c.width = Math.round(cw * dpr); c.height = Math.round(ch * dpr); }
2391
+ g.setTransform(dpr, 0, 0, dpr, 0, 0);
2392
+ g.clearRect(0, 0, cw, ch);
2393
+ g.font = '12px ui-monospace, Consolas, monospace'; g.textBaseline = 'top'; g.lineWidth = 1;
2394
+ const label = (text, x, y, col) => {
2395
+ const w = g.measureText(text).width + 6;
2396
+ g.fillStyle = 'rgba(0,0,0,0.6)'; g.fillRect(x, y, w, 15);
2397
+ g.fillStyle = col; g.fillText(text, x + 3, y + 1.5);
2398
+ };
2399
+ for (const [id, el] of Object.entries(layout(cam))) {
2400
+ if (!el.seen || el.kind === 'sky' || el.kind === 'ground' || !el.box || (ids && !ids.has(id))) continue;
2401
+ const col = `hsl(${hue(id)},85%,70%)`, [x, y, w, h] = el.box;
2402
+ // what spans most of the frame (a wall, a floor) is drawn faintly, so the rest reads
2403
+ g.globalAlpha = w * h > 0.5 * box.w * box.h ? 0.4 : 1;
2404
+ g.strokeStyle = col; g.setLineDash(el.kind === 'insert' || el.kind === 'plane' ? [4, 4] : []);
2405
+ g.strokeRect(x + 0.5, y + 0.5, w, h); g.setLineDash([]);
2406
+ if (el.anchor) { g.beginPath(); g.arc(...el.anchor, 4, 0, 7); g.stroke(); }
2407
+ if (el.at) { const [ax, ay] = el.at; g.beginPath(); g.moveTo(ax - 7, ay); g.lineTo(ax + 7, ay); g.moveTo(ax, ay - 7); g.lineTo(ax, ay + 7); g.stroke(); }
2408
+ // its named points (a diamond and the name), its parts (dashed, each where it stands)
2409
+ for (const [nm, q] of Object.entries(el.points || {})) {
2410
+ if (!q) continue;
2411
+ const [qx, qy] = q; g.beginPath(); g.moveTo(qx, qy - 5); g.lineTo(qx + 5, qy); g.lineTo(qx, qy + 5); g.lineTo(qx - 5, qy); g.closePath(); g.stroke();
2412
+ label(`#${nm}`, qx + 7, qy - 7, col);
2413
+ }
2414
+ g.setLineDash([2, 3]);
2415
+ for (const q of el.parts || []) if (q.seen) g.strokeRect(q.box[0] + 0.5, q.box[1] + 0.5, q.box[2], q.box[3]);
2416
+ g.setLineDash([]);
2417
+ const lx = Math.max(box.x, Math.min(x, box.x + box.w - 160)), ly = Math.max(box.y, y);
2418
+ label(`${id}${el.paint?.ms >= 0.1 ? ` · ${el.paint.ms.toFixed(1)} ms` : ''}`, lx, ly, col);
2419
+ (el.notes || []).forEach((nt, i) => {
2420
+ const text = say(nt.value).slice(0, 90);
2421
+ if (nt.at) { const [px, py] = nt.at; g.fillStyle = col; g.fillRect(px - 2, py - 2, 4, 4); label(text, px + 5, py - 7, col); }
2422
+ else label(text, lx, ly + 16 * (i + 1), col);
2423
+ });
2424
+ g.globalAlpha = 1;
2425
+ }
2426
+ // what the scene does wrong (its errors, what its modules said they do not know), the latest
2427
+ const wrong = [...(window.__mooodErrors || []), ...(window.__mooodWarnings || [])];
2428
+ if (wrong.length > 6) label(`… ${wrong.length - 6} more (window.__mooodErrors, __mooodWarnings)`, box.x + 8, box.y + 8, '#ffb4a0');
2429
+ wrong.slice(-6).forEach((m, i) => label(m.slice(0, 200), box.x + 8, box.y + 8 + 16 * (i + (wrong.length > 6)), '#ffb4a0'));
2430
+ const target = STATE.values.camera_target;
2431
+ label(`${t.toFixed(2)} s${target != null ? ` · camera_target ${target}` : ''}`, box.x + 8, box.y + box.h - 23, '#fff');
2432
+ },
2433
+ };
2434
+ }
2435
+ // false: the scene goes on unseen — each frame steps it (R.tick), nothing is drawn, live elements
2436
+ // painted coarsely (a render seeking a late moment draws only the frames it takes); 'painted' —
2437
+ // unseen, each painted as a drawn frame paints it (the last seconds before a frame taken: what
2438
+ // keeps a memory goes on exactly as when drawn); drawn again, the next frame is drawn whatever
2439
+ // the frame rate (owed)
2440
+ let drawn = true, owed = false;
1180
2441
  const reset = () => { Object.assign(target, { x: 0, y: lim.setup?.look ?? 0, z: 0, h: lim.setup?.h ?? 1, yaw: 0 }); };
1181
2442
  const round = () => !!lim.setup?.round, roam = () => lim.setup?.mode === 'roam';
1182
2443
  const platform = () => lim.setup?.mode === 'platform';
@@ -1257,13 +2518,156 @@ const Moood = (() => {
1257
2518
  // phone's tilt): near things shift against far ones, the depth is felt standing still too.
1258
2519
  // `shown` is the camera as drawn: where you are, plus the lean (eased, LEAN eye heights).
1259
2520
  const LEAN = 0.047, leaned = { x: 0, y: 0 }, shown = { ...cam };
1260
- // A film moves the camera from its script, through the state: camera_x (metres to the right),
1261
- // camera_h (metres up), camera_z (metres ahead — a dolly in, with all the depth it has),
1262
- // camera_look (screen heights the gaze is lowered); added to wherever the viewer has it.
2521
+ // ── the film's camera ────────────────────────────────────────────────
2522
+ // A film moves the camera through the state. camera_target — an element to look at: an operator
2523
+ // frames it (camera_shot: how big — wide, full, medium, close, or how many times the frame's
2524
+ // height it is tall; camera_frame: where in the frame its point goes, [x, y] fractions of the
2525
+ // frame, or x alone — its point is the one its code marks, e.at, else its box's middle) and keeps
2526
+ // it there as it moves, leaving it room ahead the way it goes — with a cameraman's weight: setting
2527
+ // off and settling gently, over camera_glide seconds (from one target to the next too, and back to
2528
+ // the scene's own camera when the target is gone; 0 — there at once). Standing ahead the operator
2529
+ // dollies and slides; looking around (around, roam) it turns toward the target and comes as near
2530
+ // as the shot needs.
2531
+ // On top of that: camera_x (metres to the right), camera_z (metres ahead — a dolly in, with all
2532
+ // the depth the scene has), camera_look (screen heights the gaze is lowered), camera_h (metres up
2533
+ // — the operator still keeps the target framed); and on top of all, wherever the viewer moved from
2534
+ // the start. A cut (scene.cut) puts the operator where it is meant to be at once.
1263
2535
  const num = k => (typeof STATE.values[k] === 'number' ? STATE.values[k] : 0);
1264
- function directed(c) {
2536
+ const SHOTS = { wide: 0.3, full: 0.8, medium: 1.6, close: 3.5 }, GLIDE = 1.5, AXES = ['x', 'y', 'z', 'h', 'yaw'];
2537
+ // where a head goes down the frame for a shot: the whole figure in the middle while it fits, then
2538
+ // a head with room above it, the eyes near the upper third close up
2539
+ const HEAD = [[0, 0.5], [0.8, 0.18], [1.6, 0.3], [3.5, 0.38]];
2540
+ const headAt = shot => { const i = HEAD.findIndex(([k]) => k > shot); if (i < 0) return HEAD.at(-1)[1]; const [a, u] = HEAD[i - 1], [b, v] = HEAD[i]; return u + (v - u) * (shot - a) / (b - a); };
2541
+ // the operator: its pose (as the scene's start pose is — the viewer's moves go on top), how fast
2542
+ // it moves, the target's point a frame ago and how fast that moves; null pose: the scene's own camera
2543
+ let op = { pose: null, vel: null, cut: null, fresh: false, id: null, at: null, v: { X: 0, Y: 0, Z: 0 } };
2544
+ let byId = new Map(), dzOf = null;
2545
+ const glideOf = () => { const g = STATE.values.camera_glide; return typeof g === 'number' && g >= 0 ? g : GLIDE; };
2546
+ const startPose = () => ({ x: 0, y: lim.setup?.look ?? 0, z: 0, h: lim.setup?.h ?? 1, yaw: 0 });
2547
+ // the target's point in the world (eye heights) and how tall it is (metres), or null
2548
+ // (id, or 'id#name' — a point its painting names: aimed at, the shot measured by the element)
2549
+ function targetOf(ref) {
2550
+ const o = byId.get(ref.split('#')[0]);
2551
+ if (!o) { warn(`camera_target: there is no element "${ref}"`); return null; }
2552
+ if (o.sky || o.ground || o.plane || o.insert) { warn(`camera_target: "${ref}" is not something standing in the scene`); return null; }
2553
+ const custom = o.kind === 'custom' || !o.kind, tall = custom ? o.height || 2 : HEIGHT[o.kind] || 2, P = custom ? pointOf(ref, dzOf) : null;
2554
+ if (ref.includes('#') && !P) return null; // a point it doesn't mark now: the camera stays as it is
2555
+ let f = P?.f;
2556
+ const at = P ? P.n.at : dzOf.now(o).at;
2557
+ if (!f) {
2558
+ const b = custom && Array.isArray(o.box) && o.box.length === 4 ? o.box : [0, -tall, 0, 0];
2559
+ f = { x: (b[0] + b[2]) / 2, y: (b[1] + b[3]) / 2 };
2560
+ }
2561
+ return { X: at.X + f.x / EYE, Y: at.Y - f.y / EYE, Z: at.Z, tall, marked: !!P?.f, known: !custom || READS.has(o) };
2562
+ }
2563
+ const shotOf = v => {
2564
+ if (v == null) return SHOTS.full;
2565
+ if (typeof v === 'number' && v > 0) return v;
2566
+ if (SHOTS[v]) return SHOTS[v];
2567
+ warn(`camera_shot: ${JSON.stringify(v)} — one of ${Object.keys(SHOTS).join(', ')}, or how many times the frame's height the target is tall`);
2568
+ return SHOTS.full;
2569
+ };
2570
+ const frameOf = v => {
2571
+ if (v == null) return [null, null];
2572
+ if (typeof v === 'number') return [v, null];
2573
+ if (Array.isArray(v) && v.length === 2 && v.every(k => typeof k === 'number')) return v;
2574
+ warn(`camera_frame: ${JSON.stringify(v)} — [x, y] (fractions of the frame) or x`);
2575
+ return [null, null];
2576
+ };
2577
+ // where the operator would stand for target `id` now: { pose — like the start pose, lead — how far
2578
+ // ahead it aims and how fast the target goes }; c: the camera as the viewer has it (its height and
2579
+ // where it stands count)
2580
+ function framing(id, c, dt) {
2581
+ const P = targetOf(id);
2582
+ if (!P) return null;
2583
+ // how fast its point moves (smoothed): the operator aims where it is going, not where it was
2584
+ if (op.at && dt > 0) {
2585
+ const k = 1 - Math.exp(-dt / 0.3);
2586
+ for (const a of ['X', 'Y', 'Z']) op.v[a] += ((P[a] - op.at[a]) / dt - op.v[a]) * k;
2587
+ }
2588
+ op.at = P;
2589
+ const w = 5 / Math.max(0.1, glideOf()), ahead = 2 / w, s = lim.setup;
2590
+ const X = P.X + op.v.X * ahead, Z = P.Z + op.v.Z * ahead, shot = shotOf(STATE.values.camera_shot);
2591
+ // (never so near that the target itself fades as a passed thing does: nearFade)
2592
+ const D = Math.max(PASS_TO, P.tall / EYE / shot), A = box.w / box.h, yh = S?.yh ?? 0.6;
2593
+ op.D = D;
2594
+ const b = startPose(), yaw = s?.round ? Math.atan2(X - c.x, Z - c.z) : 0;
2595
+ // room ahead: a tenth of the frame at a walk across it
2596
+ const across = op.v.X * Math.cos(yaw) - op.v.Z * Math.sin(yaw);
2597
+ let [fx, fy] = frameOf(STATE.values.camera_frame);
2598
+ fx ??= 0.5 - 0.1 * clamp(across * EYE / 1.4, -1, 1);
2599
+ fy ??= P.marked ? headAt(shot) : 0.5;
2600
+ // closer than full height the camera comes down (or up) to the point's own height, as an
2601
+ // operator does: a close-up is level with the face, not looked at from far below with a shifted lens
2602
+ const ph = b.h + (P.Y - b.h) * clamp((shot - SHOTS.full) / (SHOTS.medium - SHOTS.full), 0, 1);
2603
+ const h = c.h + ph - b.h + num('camera_h') / EYE, off = Math.atan((fx - 0.5) * A);
2604
+ // (aimed ahead by `lead`: where an operator following it all along would be is that much behind)
2605
+ const lead = { x: op.v.X * ahead, z: op.v.Z * ahead, vx: op.v.X, vz: op.v.Z };
2606
+ if (s?.round) {
2607
+ const d = Math.hypot(X - c.x, Z - c.z) || 1;
2608
+ return { pose: { x: X - D * (X - c.x) / d, z: Z - D * (Z - c.z) / d, y: yh + (h - P.Y) / (D * Math.cos(off)) - fy, h: ph, yaw: yaw - off }, lead, known: P.known };
2609
+ }
2610
+ return { pose: { x: X - (fx - 0.5) * A * D, z: Z - D, y: yh + (h - P.Y) / D - fy, h: ph, yaw: 0 }, lead, known: P.known };
2611
+ }
2612
+ // the operator's step: c — the camera as the viewer has it — becomes the film's camera
2613
+ function directed(c, dt) {
2614
+ const id = STATE.values.camera_target, s = lim.setup, b = startPose();
2615
+ op.D = null; // (framing: the shot's distance, while a target is framed)
2616
+ if (op.cut !== STATE.cut) { op.cut = STATE.cut; op.fresh = true; op.at = null; op.v = { X: 0, Y: 0, Z: 0 }; }
2617
+ if (id !== op.id) { op.id = id; op.at = null; op.v = { X: 0, Y: 0, Z: 0 }; }
2618
+ // (not while a scene is on its way: the state may be the next one's already)
2619
+ const aim = id != null && S && !waiting && s?.mode !== 'platform' ? framing(String(id), c, dt) : null;
2620
+ // without a target: the scene's own camera (looking around, where the viewer stands)
2621
+ const own = { ...b, ...(s?.round ? { x: c.x, z: c.z } : {}) }, goal = aim ? aim.pose : own;
2622
+ if (aim || op.pose) {
2623
+ // just after a cut, there at once (until the target has been painted: its painting marks its
2624
+ // point as it is painted); else from where the camera is, along a critically damped spring (exact for
2625
+ // any step): it sets off and settles without overshoot
2626
+ const snap = op.fresh || glideOf() < 0.01;
2627
+ if (snap || !op.pose) {
2628
+ op.pose = { ...(snap ? goal : own) }; op.vel = { x: 0, y: 0, z: 0, h: 0, yaw: 0 };
2629
+ // at a moving target: as if following it all along — on it, moving with it
2630
+ if (snap && aim) { op.pose.x -= aim.lead.x; op.pose.z -= aim.lead.z; op.vel.x = aim.lead.vx; op.vel.z = aim.lead.vz; }
2631
+ }
2632
+ if (!snap) {
2633
+ const w = 5 / glideOf(), e = Math.exp(-w * dt);
2634
+ if (s?.round) goal.yaw = op.pose.yaw + ((((goal.yaw - op.pose.yaw) % TAU) + 3 * Math.PI) % TAU - Math.PI);
2635
+ for (const a of AXES) {
2636
+ const d = op.pose[a] - goal[a], k = (op.vel[a] + w * d) * dt;
2637
+ op.vel[a] = (op.vel[a] - w * k) * e;
2638
+ op.pose[a] = goal[a] + (d + k) * e;
2639
+ }
2640
+ }
2641
+ const p = op.pose;
2642
+ // settled back on the scene's own camera: the operator steps away
2643
+ if (!aim && AXES.every(a => Math.abs(p[a] - goal[a]) < 1e-4 && Math.abs(op.vel[a]) < 1e-4)) op.pose = null;
2644
+ else {
2645
+ if (s?.round) { c.x = p.x; c.z = p.z; } else { c.x += p.x - b.x; c.z += p.z - b.z; }
2646
+ c.y += p.y - b.y; c.h += p.h - b.h; c.yaw += p.yaw - b.yaw;
2647
+ }
2648
+ }
2649
+ if (op.fresh) op.fresh = id != null && !aim?.known;
1265
2650
  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');
1266
2651
  }
2652
+ const operating = () => !!op.pose && AXES.some(a => Math.abs(op.vel[a]) > 1e-4);
2653
+ // The viewer's own moves over the film's camera (target: where they moved from the start): while
2654
+ // the operator frames a target they are a look aside — they ease back to nothing once the viewer
2655
+ // lets go (VIEWER_BACK s), they come at most VIEWER_NEAR of the way to the target (never through
2656
+ // it, nor so near that it fades: nearFade). A cut — a new shot (scene.cut, not the scene's start: shown again, a draft, the viewer
2657
+ // stays where they were) — takes them back to the start at once.
2658
+ const VIEWER_BACK = 0.8, VIEWER_NEAR = 0.5;
2659
+ let viewerShot = 0;
2660
+ function overTarget(dt) {
2661
+ // (a platformer's camera rides with its hero: no moves of the viewer's own)
2662
+ if (viewerShot !== STATE.shots && !platform()) { viewerShot = STATE.shots; Object.assign(target, startPose()); Object.assign(cam, target); }
2663
+ if (!op.pose || !(op.D > 0)) return;
2664
+ // (looking around, the operator stands where the shot needs: the viewer only turns their head)
2665
+ const b = startPose(), axes = round() ? ['y', 'yaw'] : AXES;
2666
+ if (!round()) target.z = Math.min(target.z, b.z + Math.min(VIEWER_NEAR * op.D, op.D - PASS_TO));
2667
+ if (fixed() || pointers.size || keys.size || glide || performance.now() - aimAt < 600) return;
2668
+ const k = 1 - Math.exp(-dt / VIEWER_BACK);
2669
+ for (const a of axes) target[a] += (b[a] - target[a]) * k;
2670
+ }
1267
2671
  function leanShown(dt) {
1268
2672
  const k = 1 - Math.exp(-dt * 3), s = lim.setup;
1269
2673
  leaned.x += ((s?.sway ? 0 : lean.x) - leaned.x) * k; leaned.y += ((s?.sway ? 0 : lean.y) - leaned.y) * k;
@@ -1360,7 +2764,7 @@ const Moood = (() => {
1360
2764
  if (typing() || e.ctrlKey || e.metaKey || e.altKey) return;
1361
2765
  const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
1362
2766
  if (k === '0') { reset(); return; }
1363
- if (k === 'p') { perf.style.display = perf.style.display === 'none' ? 'block' : 'none'; return; }
2767
+ if (k === 'p') { PERF.on = perf.style.display === 'none'; perf.style.display = PERF.on ? 'block' : 'none'; PERF.ms.clear(); return; }
1364
2768
  if (KEYS.includes(k) && !fixed()) { keys.add(k); e.preventDefault(); }
1365
2769
  });
1366
2770
  addEventListener('keyup', e => keys.delete(e.key.length === 1 ? e.key.toLowerCase() : e.key));
@@ -1368,16 +2772,18 @@ const Moood = (() => {
1368
2772
 
1369
2773
  const loop = now => {
1370
2774
  requestAnimationFrame(loop);
1371
- 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;
2775
+ const moving = keys.size > 0 || pointers.size > 0 || !!glide || operating() || (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;
1372
2776
  if (moving) activeUntil = now + 500;
1373
2777
  const fps = now < activeUntil ? FPS_ACTIVE : fpsIdle;
1374
2778
  const real = (now - last) / 1000;
1375
2779
  // skip this display refresh; the tolerance is under half a refresh so vsync jitter
1376
2780
  // never drops a frame we meant to draw (that made 30 fps stutter at ~27)
1377
- if (real < 1 / fps - 0.006) return;
1378
- const dt = Math.min(0.05, real);
2781
+ if (real < 1 / fps - 0.006 && !owed) return;
2782
+ owed = false;
2783
+ const dt = Math.min(0.05, real), was = t;
1379
2784
  last = now; t = Math.max(t, (performance.now() - born) / 1000);
1380
2785
  STATE.sample?.(); // the state's moves where they are this very moment
2786
+ if (spec && !waiting) beforeFrame(t, t - was);
1381
2787
  if (real < 0.5) frameEma += (real - frameEma) * 0.1; // ignore tab-switch gaps
1382
2788
  adapt(now, fps);
1383
2789
  // a brisk walk: ~6 m/s forward, ~3.5 m/s sideways (world units are eye heights)
@@ -1409,6 +2815,7 @@ const Moood = (() => {
1409
2815
  if (fixed()) leanTo(t);
1410
2816
  keepInView();
1411
2817
  limitCam(target, lim);
2818
+ overTarget(dt);
1412
2819
  if (glide && pointers.size === 0) {
1413
2820
  target.x += glide.vx * dt; target.y += glide.vy * dt; target.yaw += glide.vyaw * dt;
1414
2821
  limitCam(target, lim);
@@ -1422,14 +2829,25 @@ const Moood = (() => {
1422
2829
  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;
1423
2830
  cam.hero = plat && { id: plat.id, dx: plat.x - plat.x0, dy: plat.y - plat.y0, flip: plat.dir < 0 };
1424
2831
  leanShown(dt);
1425
- directed(shown);
2832
+ const own = { ...shown };
2833
+ // on a relief (spec.relief) the viewer's eyes go over the land as they walk: 1.7 m over the
2834
+ // ground where they stand, as they were at the start
2835
+ if (SHARED.ground && !plat) shown.h += (groundAt(shown.x * EYE, shown.z * EYE) - groundAt(0, 0)) / EYE;
2836
+ directed(shown, dt);
2837
+ for (const a of AXES) off[a] = (shown[a] ?? 0) - (own[a] ?? 0);
1426
2838
  const t0 = performance.now();
1427
- if (S) R.frame(S, spec, shown, t, dt, now >= activeUntil, target);
2839
+ if (S && drawn === true) {
2840
+ lookOf(spec);
2841
+ R.frame(S, spec, shown, t, dt, now >= activeUntil, op.pose ? shown : target);
2842
+ if (looking) looking.draw(shown, t);
2843
+ } else if (S) { lookOf(spec); R.tick(S, spec, shown, t, dt, op.pose ? shown : target, drawn === 'painted'); }
1428
2844
  drawMs += performance.now() - t0;
1429
2845
  frames++;
1430
- if (now - perfAt > 1000 && perf.style.display !== 'none') {
1431
- 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`;
1432
- frames = 0; drawMs = 0; perfAt = now;
2846
+ if (now - perfAt > 1000 && PERF.on) {
2847
+ // and the elements that cost the most: their painting's ms a frame
2848
+ const costly = [...PERF.ms].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([k, ms]) => `\n ${(ms / frames).toFixed(2).padStart(6)} ms ${k.slice(0, 28)}`).join('');
2849
+ 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${costly}`;
2850
+ frames = 0; drawMs = 0; perfAt = now; PERF.ms.clear();
1433
2851
  } else if (now - perfAt > 1000) { frames = 0; drawMs = 0; perfAt = now; }
1434
2852
  };
1435
2853
  requestAnimationFrame(loop);
@@ -1440,7 +2858,8 @@ const Moood = (() => {
1440
2858
  const shared = MooodKit.shared(next, modules);
1441
2859
  if (shared instanceof Promise) { waiting = next; shared.then(() => { if (waiting === next) { waiting = null; this.show(next, modules); } }); return; }
1442
2860
  waiting = null; SHARED = shared;
1443
- spec = next; FOCUS.clear();
2861
+ spec = next; FOCUS.clear(); NAMED.clear(); NOTES.clear(); PARTS.clear(); PERF.last.clear(); warned.clear(); STATE.read.clear(); BEFORE.clear(); PERF.repaints.clear();
2862
+ dzOf = depths(spec); byId = new Map((spec.objects || []).filter(o => o.id != null).map(o => [String(o.id), o]));
1444
2863
  // values not set yet (the state may start after the scene is shown) are where the scene starts them
1445
2864
  if (spec.state && typeof spec.state === 'object') STATE.values = { ...spec.state, ...STATE.values };
1446
2865
  // a new level, or the same one edited: the hero stays where it got to
@@ -1461,19 +2880,50 @@ const Moood = (() => {
1461
2880
  // the scene's state changed (scenestate.js): the painting code sees it as e.state
1462
2881
  // was(key, ago): what a value was `ago` seconds ago (e.was); sample(): brings the values that
1463
2882
  // are moving to where they are now — called as each frame starts, so it paints them on time
1464
- setState(values, was, sample) {
2883
+ // cutAt: when the last cut was (the scene's start or scene.cut); shots: how many scene.cuts
2884
+ setState(values, was, sample, cutAt, shots) {
1465
2885
  STATE.values = values || {}; if (was) STATE.was = was; if (sample) STATE.sample = sample;
2886
+ if (cutAt != null) STATE.cut = (cutAt - born) / 1000;
2887
+ if (shots != null) STATE.shots = shots;
1466
2888
  STATE.ver++; activeUntil = performance.now() + 300;
1467
2889
  },
1468
2890
  // where in the canvas (CSS px) an element is now — the point its code marks (e.at), else its
1469
- // anchor (its top, with over) — lifted by `lift` metres; null when out of sight
1470
- screenOf: (id, lift = 0, over = false) => seenFrom(shown, id, lift, over),
2891
+ // anchor (its top, with over); 'id#name' — a point its painting names — lifted by `lift`
2892
+ // metres: [x, y, its distance]; null when out of sight (behind the eye, passed, a point its
2893
+ // painting doesn't mark now)
2894
+ screenOf: (ref, lift = 0, over = false) => seenFrom(shown, ref, lift, over),
2895
+ // the ground's height (metres) at (across, distance): spec.relief, 0 without one
2896
+ ground: groundAt,
2897
+ // the state keys the shown scene's painting has read so far — its elements' code, its modules
2898
+ read: () => STATE.read,
2899
+ // the moment drawn as another camera sees it (a render's --views): `values` — the camera's keys
2900
+ // (camera_target, camera_shot, camera_x…) over the film's, the operator there at once; the film
2901
+ // itself — its state, its clock, its operator — goes on untouched (the next frame is its own)
2902
+ still(values) {
2903
+ if (!S || !spec || drawn !== true) return;
2904
+ const film = STATE.values, was = structuredClone(op), c = Object.fromEntries(AXES.map(a => [a, shown[a] - off[a]]));
2905
+ c.hero = shown.hero;
2906
+ try {
2907
+ STATE.values = { ...film, ...values, camera_glide: 0 };
2908
+ op.fresh = true;
2909
+ directed(c, 0);
2910
+ lookOf(spec);
2911
+ R.frame(S, spec, c, t, 0, false, c);
2912
+ if (looking) looking.draw(c, t);
2913
+ } finally { STATE.values = film; op = was; }
2914
+ },
2915
+ // true: drawn; false: the scene goes on unseen; 'painted': unseen, painted as when drawn (above)
2916
+ draw(how = true) { owed = how === true && drawn !== true; drawn = how === true || how === 'painted' ? how : false; },
2917
+ // what is where now, for whoever looks closely: { t (s), camera, frame (CSS px), elements (layout) }
2918
+ inspect: () => ({ t: Math.round(t * 1000) / 1000, camera: Object.fromEntries(AXES.map(a => [a, Math.round(shown[a] * 1e4) / 1e4])), frame: { ...box }, elements: layout(shown) }),
1471
2919
  };
1472
2920
  }
1473
2921
 
1474
2922
  return {
1475
2923
  mount,
1476
2924
  // for the WebGL renderer (gl.js): the one-time painting code and camera maths
1477
- 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 },
2925
+ internals: { clock, STAGE, WIDEST, TAU, project, billboard, placeOf, camSetup, heroMatrix, STATE, READS, EXT, DEEP, Z_FAR, EYE, depths, view, limits, nearFade, objectBox, anchorOf, iris, opening, warn, seeded, hash, clamp, layer, rgb, glowSprite, grainTile, staticLayer, drawObject, PARTICLES, makeParticles, moveParticle,
2926
+ orderRules, arrange, clipOf, landLine, groundAt, land: () => !!SHARED.ground, CHECK, planeOf, overMirror, onPlane, cellsOf, PLANE_CELLS,
2927
+ lookOf, hazeReach, airOf, airGlow, sourcesNow, SOURCES, falloff, flickerNow, redrawOf, moved, PERF, shadowsOf, shadowLight, rayHit, ambientOf, shadowAt },
1478
2928
  };
1479
2929
  })();