File size: 37,199 Bytes
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d16591c
71eae78
 
 
6742ef0
 
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6742ef0
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6742ef0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d16591c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
 
d16591c
 
 
71eae78
 
 
6742ef0
71eae78
 
d16591c
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d16591c
 
 
 
 
71eae78
d16591c
 
 
71eae78
 
 
6742ef0
71eae78
 
 
 
 
 
 
 
 
6742ef0
71eae78
 
 
d16591c
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
6742ef0
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d16591c
71eae78
 
 
d16591c
71eae78
d16591c
 
 
71eae78
d16591c
 
 
 
71eae78
 
 
 
d16591c
 
71eae78
 
d16591c
71eae78
 
 
6742ef0
 
 
 
71eae78
 
 
 
 
 
 
 
 
6742ef0
 
71eae78
 
 
 
 
 
6742ef0
 
 
 
71eae78
 
6742ef0
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d16591c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
d16591c
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
// The forest, in layers.
//
// THE TWO-SIDED VALUE RULE, measured off Blade Warrior. See PALETTE in
// tuning.js for the numbers.
//
//   1. Nothing lives between luminance 30 and 90. A layer is either pure black
//      or it is well clear of black. The middle is banned.
//   2. Something substantial has to sit above 160, or a silhouette has nothing
//      to read against.
//
// The build this replaces broke both: 30.4% of the frame in the forbidden zone,
// and not one pixel above 160. Three near-identical dark greys under a flat
// dead sky. That was the mud.
//
// So there are three values and black: a sky range, one mid band for the hills,
// and everything from the watchers inward pure black. What separates a watcher
// from a trunk is SHAPE, not tone β€” in Blade Warrior the foreground reeds are
// the same black as the hero and are never once confused with him, because thin
// spikes and solid human mass are different grammars.
//
// WHAT GETS LIT. The torch's mask is a flat screen-space circle with no depth
// information, so "that layer is far away" does not protect it. Each layer says
// which art it hands to pass 2; the ones that must not be lit hand over their
// dark art, the same trick the prince uses to stay a silhouette inside his own
// torchlight.

import { TUNING, BOWL, PALETTE, SKY, depthAt, DEPTH, TONE, TERRACE, atLuminance, luminance } from './tuning.js';

const SKY_TILE_W = 2048;

/** Blend two [r,g,b] palette entries. */
const mix3 = (a, b, t) => [
  a[0] + (b[0] - a[0]) * t,
  a[1] + (b[1] - a[1]) * t,
  a[2] + (b[2] - a[2]) * t,
];

const rgb = (hex) => {
  const n = parseInt(hex.slice(1), 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
};

/** Deterministic 0..1 from an integer. No state, no list, no allocation. */
function hash01(i, salt) {
  let h = Math.imul((i | 0) ^ salt, 2654435761) >>> 0;
  h ^= h >>> 15;
  h = Math.imul(h, 2246822507) >>> 0;
  h ^= h >>> 13;
  return (h >>> 0) / 4294967296;
}

/**
 * The sky, built once into a tiling texture.
 *
 * A dithered field quantised to three palette entries rather than a smooth
 * gradient, plus a moon and two cloud masses. Cloud masses are placed with
 * wrap-around so the tile is seamless, because at 0.06x parallax the seam would
 * otherwise cross the screen once during a full level.
 */
function buildSky(h) {
  const c = document.createElement('canvas');
  c.width = SKY_TILE_W;
  c.height = h;
  const x = c.getContext('2d');
  const img = x.createImageData(SKY_TILE_W, h);
  const d = img.data;

  const low = rgb(PALETTE.SKY_LOW);
  const high = rgb(PALETTE.SKY_HIGH);
  const cloud = rgb(PALETTE.CLOUD);

  // NO CLOUD BLOBS. Soft radial gradients in a sky read as glowing orbs, not as
  // cloud β€” they look like light sources sitting in mid-air.
  //
  // The brightness is a horizontal BAND instead, brightest just above the
  // horizon and falling away both up and down. That is where it is wanted
  // anyway: the figures stand against the horizon, so the light belongs behind
  // them rather than at the top of the frame. It also leaves the zenith darker,
  // which is where the canopy arrives from.
  const peakY = SKY.CENTRE * h;
  const spread = SKY.SPREAD * h;

  // The moon is measured in PIXELS, not in tile fractions. In fractions it
  // comes out an ellipse, because the tile is not square.
  const moonX = 0.17 * SKY_TILE_W;
  const moonY = 0.15 * h;
  const moonR = 52;

  for (let py = 0; py < h; py++) {
    // One band of brightness sitting just above the horizon.
    const t0 = (py - peakY) / spread;
    const v0 = SKY.BASE + SKY.PEAK * Math.exp(-t0 * t0);
    for (let px = 0; px < SKY_TILE_W; px++) {
      const v = v0;
      let mdx = px - moonX;
      if (mdx > SKY_TILE_W / 2) mdx -= SKY_TILE_W;
      if (mdx < -SKY_TILE_W / 2) mdx += SKY_TILE_W;
      const md = Math.hypot(mdx, py - moonY);
      // a crescent: the disc, minus a disc offset up and to the right
      const shadow = Math.hypot(mdx - moonR * 0.46, py - moonY + moonR * 0.34);
      let vv = v;
      if (md < moonR && shadow > moonR * 0.86) vv = 1.4;
      else if (md < moonR) vv = Math.max(v, 0.62);

      // CONTINUOUS TONE. This used to quantise to three palette entries through
      // an ordered 4x4 Bayer matrix at 3px cells, to imitate a 16-colour Amiga
      // game. It produced a visible quilt, and the maths says why: where the
      // gradient sits halfway between two entries a Bayer threshold is a
      // perfect checkerboard, so the sky carried a 6px weave with the matrix
      // repeating every 12px. Measured at 0.765 autocorrelation at lag 6.
      //
      // Limbo is the opposite aesthetic and that is the direction now. The
      // gradient is written straight, interpolating the same three palette
      // entries instead of choosing between them β€” the colours and the band
      // shape are unchanged, only the steps are gone.
      const col = vv < 0.5
        ? mix3(low, high, vv / 0.5)
        : mix3(high, cloud, Math.min(1, (vv - 0.5) / 0.5));
      const i = (py * SKY_TILE_W + px) * 4;
      d[i] = col[0]; d[i + 1] = col[1]; d[i + 2] = col[2]; d[i + 3] = 255;
    }
  }
  x.putImageData(img, 0, 0);
  return c;
}

/**
 * Recolour a black silhouette once, at load, into a cached bitmap.
 *
 * The old build alpha-blended black onto the background to land on a target
 * grey. That works for greys and not for the blue, because the hills are not a
 * darker version of the sky β€” they are a different mix. Tinting once costs
 * nothing per frame.
 */
export async function tintFrames(clip, hex, which = 'dark') {
  // An undefined colour would leave fillStyle untouched and silently paint the
  // previous one, which is how this quietly did nothing the first time.
  if (typeof hex !== 'string') throw new Error(`tintFrames: no colour for "${clip.name}"`);
  const out = [];
  for (const f of clip.frames) {
    const c = document.createElement('canvas');
    c.width = clip.width;
    c.height = clip.height;
    const x = c.getContext('2d');
    x.drawImage(f[which], 0, 0, clip.width, clip.height);
    x.globalCompositeOperation = 'source-in';
    x.fillStyle = hex;
    x.fillRect(0, 0, c.width, c.height);
    out.push(await createImageBitmap(c));
  }
  return out;
}

export async function createBackground(clips, groundY, viewH) {
  const sky = buildSky(viewH);

  /**
   * Blur, and the constraint IS the finding.
   *
   * Hills (97) against sky (163) is a gradient from 97 to 163 β€” entirely above
   * 90, so it costs nothing. Canopy (0) against sky runs straight through the
   * forbidden zone, and so does any black trunk, actor or ground.
   *
   * So: the hills and the sky, and nothing else, ever.
   *
   * Baked once when the strip is made. The strip is already blitted once per
   * layer per frame, so at runtime this is free.
   */
  const blurred = (src, px) => {
    if (!px) return src;
    const pad = Math.ceil(px * 3);
    const cv = document.createElement('canvas');
    cv.width = src.width + pad * 2;
    cv.height = src.height + pad * 2;
    const x = cv.getContext('2d');
    x.filter = `blur(${px}px)`;
    // Drawn three times across the seam so a tiling strip still tiles: a blur
    // at the edge would otherwise pull in transparent pixels and leave a seam.
    x.drawImage(src, pad - src.width, pad);
    x.drawImage(src, pad, pad);
    x.drawImage(src, pad + src.width, pad);
    x.filter = 'none';
    const out = document.createElement('canvas');
    out.width = src.width; out.height = src.height;
    out.getContext('2d').drawImage(cv, -pad, -pad);
    return out;
  };

  /**
   * Haze: fade a layer toward the sky it sits against. The further away, the
   * more of the air is between you and it.
   *
   * Toward SKY_HIGH rather than toward the sky's exact local value, because the
   * sky is a gradient and a layer is one bitmap β€” SKY_HIGH is what sits behind
   * the horizon band where the hills actually are.
   */
  const haze = (hex, amount) => {
    if (!amount) return hex;
    const a = rgb(hex), b = rgb(PALETTE.SKY_HIGH);
    const m = mix3(a, b, Math.min(1, amount)).map((v) => Math.round(v));
    return `#${m.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
  };

  /**
   * Soften and re-hue a whole clip's frames, once, at load.
   *
   * The canopy and the near trunks are PURE BLACK, which is exactly why neither
   * could be blurred before: a soft edge from 0 to a 163 sky runs straight
   * through the old forbidden zone. In Limbo the extreme foreground is out of
   * focus and only the boy is sharp, so this is the cue that was most obviously
   * missing and most firmly banned.
   *
   * Warmth is a hue change at the SAME darkness, never a lift. A lighter trunk
   * in front of a black boy tints instead of blocks, which reads as a rendering
   * fault, and pure black against a lit background is what says "close".
   */
  const soften = (clip, blurPx, warm) => {
    if (!clip || (!blurPx && !warm)) return null;
    // BOTH passes. The near trunks hand the lit pass their own art, so softening
    // only the dark one left the torch revealing a sharp trunk inside a blurred
    // one β€” the defocus would switch off wherever the light landed.
    const one = (f, which) => {
      const cv = document.createElement('canvas');
      cv.width = clip.width; cv.height = clip.height;
      const g = cv.getContext('2d');
      g.drawImage(f[which], 0, 0);
      if (warm) {
        g.globalCompositeOperation = 'source-in';
        // Luminance about 12 β€” still black at a glance, warm not neutral.
        g.fillStyle = `rgb(${Math.round(24 * warm)},${Math.round(14 * warm)},${Math.round(7 * warm)})`;
        g.fillRect(0, 0, cv.width, cv.height);
        g.globalCompositeOperation = 'source-over';
      }
      return blurPx ? blurred(cv, blurPx) : cv;
    };
    return clip.frames.map((f) => ({ dark: one(f, 'dark'), lit: one(f, 'lit') }));
  };

  const hillsTint = await tintFrames(clips.bg_treeline, haze(PALETTE.HILLS, TONE.HILLS_HAZE), 'dark');
  const watcherTint = await tintFrames(clips.drained_watcher, haze(PALETTE.WATCHERS, TONE.WATCHER_HAZE), 'dark');

  let canopySoft = soften(clips.bg_canopy, Math.round(TONE.CANOPY_BLUR), 0);
  let trunkSoft = soften(clips.bg_trunks_near, Math.round(TONE.TRUNK_BLUR), TONE.TRUNK_WARM);

  /**
   * Rebuild the softened strips. Blurring is a load-time cost, so this is not
   * something to call per frame β€” but it has to exist, because judging a soft
   * foreground means A/B-ing it, and reloading the page between every pair is
   * how a sweep stops happening.
   */
  function setSoften(canopyBlur, trunkBlur, trunkWarm) {
    canopySoft = soften(clips.bg_canopy, Math.round(canopyBlur), 0);
    trunkSoft = soften(clips.bg_trunks_near, Math.round(trunkBlur), trunkWarm);
    layers.find((l) => l.key === 'canopy').soft = canopySoft;
    layers.find((l) => l.key === 'trunks_near').soft = trunkSoft;
  }

  // Sharp originals kept, so the toggle is reversible without a reload.
  const sharpSky = sky;
  const sharpHills = hillsTint[0];
  let skyStrip = sharpSky;
  let hillsStrip = sharpHills;
  let blurPx = -1, blurSkyPx = -1;
  // Separately, because they turn out to cost completely different things: the
  // hills are an edge against the sky and blurring them is nearly free, while
  // the sky is a vertical gradient and blurring it smears the bright band down
  // into the dark end and throws bright mass away.
  function setBlur(px, skyPx = px) {
    if (px === blurPx && skyPx === blurSkyPx) return;
    blurPx = px; blurSkyPx = skyPx;
    skyStrip = blurred(sharpSky, skyPx);
    hillsStrip = blurred(sharpHills, px);
    layers.find((l) => l.key === 'hills').tinted = [hillsStrip];
  }

  // How much sky is open above a given spot on the walk.
  //
  // The sky itself has no horizontal variation any more β€” it is a band, and
  // blobs in it read as orbs hanging in mid-air. So the only thing that makes
  // one stretch brighter than another is the CANOPY: its bottom edge is
  // ragged, and where a tooth hangs low there is less sky behind a figure.
  //
  // Measured once, off the canopy bitmap, into a per-column profile of where
  // its ink ends. Encounter placement reads this so fights happen under the
  // open patches, which is the one thing Blade Warrior does that costs nothing.
  /** Topmost ink row per column of a clip's dark art. */
  const topProfile = (cl) => {
    if (!cl) return null;
    const cv = document.createElement('canvas');
    cv.width = cl.width; cv.height = cl.height;
    const g = cv.getContext('2d', { willReadFrequently: true });
    g.drawImage(cl.frames[0].dark, 0, 0);
    const px = g.getImageData(0, 0, cl.width, cl.height).data;
    const prof = new Float32Array(cl.width);
    for (let x = 0; x < cl.width; x++) {
      let top = cl.height;
      for (let y = 0; y < cl.height; y++) {
        if (px[(y * cl.width + x) * 4 + 3] > 16) { top = y; break; }
      }
      prof[x] = top;
    }
    return prof;
  };
  const hillProfile = topProfile(clips.bg_treeline);

  // Which rows of the sky are actually BRIGHT. Open sky below the band is still
  // sky, but a silhouette does not read against it, so placement must not count
  // it. Measured off the built texture rather than re-deriving the gradient,
  // so it cannot drift from what is on screen.
  const brightBand = (() => {
    const g = document.createElement('canvas');
    g.width = 1; g.height = sky.height;
    const gx = g.getContext('2d', { willReadFrequently: true });
    gx.drawImage(sky, 0, 0);
    const px = gx.getImageData(0, 0, 1, sky.height).data;
    let top = sky.height, bot = 0;
    for (let y = 0; y < sky.height; y++) {
      const i = y * 4;
      if (0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2] > 160) {
        if (y < top) top = y;
        bot = y;
      }
    }
    return top <= bot ? { top, bot } : { top: 0, bot: sky.height };
  })();

  const canopyProfile = (() => {
    const cl = clips.bg_canopy;
    if (!cl) return null;
    const cv = document.createElement('canvas');
    cv.width = cl.width; cv.height = cl.height;
    const cx2 = cv.getContext('2d', { willReadFrequently: true });
    cx2.drawImage(cl.frames[0].dark, 0, 0);
    const px = cx2.getImageData(0, 0, cl.width, cl.height).data;
    const prof = new Float32Array(cl.width);
    for (let x = 0; x < cl.width; x++) {
      let low = 0;
      for (let y = cl.height - 1; y >= 0; y--) {
        if (px[(y * cl.width + x) * 4 + 3] > 16) { low = y; break; }
      }
      prof[x] = low;
    }
    return prof;
  })();


  // THE BACK LANE KEEPS ITS OWN SPACE.
  //
  // A Drained standing beside the road and a watcher standing in the trees are
  // the same drawing at two sizes, and where one stood in front of the other
  // the two outlines ran together into a single shape with too many arms. No
  // placement can prevent it: the watchers scroll at 0.65 and the lane at 1.0,
  // so every watcher slides behind every Drained somewhere along the walk.
  //
  // So the renderer hands over, each frame, the screen span each back-lane
  // figure covers, and a watcher that comes within CLEAR_MARGIN of one fades
  // out before the two can touch, and back in once it is clear.
  //
  // FADED OVER TIME, NOT OVER DISTANCE. The first version faded by how close
  // it was, which is right while he walks and wrong the moment he stops: a
  // watcher parked inside the fade band stayed half there, a ghost, for as
  // long as he stood still. Now each one is either wanted or not, and walks
  // its alpha there over CLEAR_FADE_S.
  const CLEAR_MARGIN = 40;
  const CLEAR_FADE_S = 0.25;
  const CLEAR_MAX = 32;
  const clear = { camX: 0, clock: -1, n: 0, spans: new Float32Array(CLEAR_MAX * 3) };
  const watcherFade = new Map();          // watcher slot -> alpha
  const watcherInk = (() => {
    const cl = clips.drained_watcher;
    const ink = cl && cl.ink;
    return ink ? { l: ink.x - cl.pivotX, r: ink.x + ink.w - cl.pivotX } : { l: -40, r: 40 };
  })();

  /** Whether a watcher at screen x should be showing, given the back lane. */
  function watcherWanted(sx, sc) {
    const wl = sx + watcherInk.l * sc;
    const wr = sx + watcherInk.r * sc;
    for (let k = 0; k < clear.n; k++) {
      if (!(clear.spans[k * 3 + 2] > 0)) continue;
      const gap = Math.max(clear.spans[k * 3] - wr, wl - clear.spans[k * 3 + 1]);
      if (gap < CLEAR_MARGIN) return 0;
    }
    return 1;
  }

  /** How visible watcher slot i is right now. 0..1. */
  function watcherAlpha(i, sx, sc) {
    const a = watcherFade.get(i);
    return a === undefined ? watcherWanted(sx, sc) : a;
  }

  /**
   * Whether watcher slot i has anyone in it, and if so where and how big.
   * ONE ROLL, used by the draw and by sample(). The two drifting apart would
   * make the watcher check measure a layer that is not on screen.
   */
  function watcherRoll(l, i) {
    // Density at this watcher's own world position, not the prince's.
    //
    // And thinner again inside the village, on top of what the clearing
    // already takes off. Six of them on screen plus the shapes standing
    // about read as a crowd, and this place has been empty sixty years β€”
    // the crowd belongs to the trees, not to the street.
    const wx0 = (i * l.cell) / l.rate;
    const density = depthAt(wx0) *
      (wx0 >= BOWL.X0 && wx0 <= BOWL.X1 ? TUNING.BOWL_WATCHERS : 1);
    if (hash01(i, 0x5eed) >= density) return null;
    return {
      wx: i * l.cell + hash01(i, 0x77f1) * l.jitter,
      variant: Math.floor(hash01(i, 0x1b9d) * l.clip.count) % l.clip.count,
      sc: l.scale * (0.86 + hash01(i, 0x3a5f) * 0.28),
    };
  }

  // --- the terraces -----------------------------------------------------------
  // What they are and why they look like this is in TERRACE, in tuning.js.
  const HILLS_DRAWN = haze(PALETTE.HILLS, TONE.HILLS_HAZE);
  const hexMix = (a, b, t) => {
    const m = mix3(rgb(a), rgb(b), t).map((v) => Math.round(Math.max(0, Math.min(255, v))));
    return `#${m.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
  };
  const lighter = (hex, d) => atLuminance(hex, luminance(hex) + d);
  const laneTop = groundY - TUNING.BACK_LANE_RISE;
  /** The edge of step k, near to far. Step 0 is the back lane itself. */
  const stepTop = (k) => laneTop - TERRACE.STEPS[k].above;
  const STEP_COL = TERRACE.STEPS.map((S) => ({
    wall: hexMix(atLuminance(TERRACE.WALL, TERRACE.WALL_LUM + S.wallUp), HILLS_DRAWN, S.blend),
    lip: hexMix(atLuminance(TERRACE.FIELD, TERRACE.WALL_LUM + S.wallUp + S.lip), lighter(HILLS_DRAWN, 3), S.blend),
    shadow: hexMix(atLuminance(TERRACE.WALL, TERRACE.SHADOW_LUM), HILLS_DRAWN, S.blend * 0.5),
  }));
  // Under the torch the first step is a retaining wall of laid stone, the way
  // the road under it is earth with stones in it.
  const LIT_WALL = '#a39684';
  const LIT_LIP = '#c9b89c';

  /**
   * One flat step, drawn fresh: a handful of fields per screen, each a few px
   * up or down from the next on the steps that wobble. The field edge first,
   * then the wall under it, lipH lower.
   */
  function drawStep(c, k, camX, pass, viewW, viewH2) {
    const S = TERRACE.STEPS[k];
    const rate = S.rate ?? layers.find((l) => l.key === 'watchers').rate;
    const shift = camX * rate;
    const top = stepTop(k);
    const lit = pass === 'lit' && k === 0;
    const SEG = S.seg ?? 520;
    const j0 = Math.floor((shift - 60) / SEG);
    const j1 = Math.ceil((shift + viewW + 60) / SEG);
    // Three fills of the same edge, each a little lower: the field top, a
    // two-pixel shadow where the wall starts, then the wall.
    for (let layer = 0; layer < 3; layer++) {
      const dy = layer === 0 ? 0 : layer === 1 ? S.lipH : S.lipH + 2;
      c.beginPath();
      c.moveTo(j0 * SEG - shift, viewH2);
      for (let j = j0; j <= j1; j++) {
        const off = S.wobble ? Math.round((hash01(j, 0x51 + k * 97) - 0.5) * 2 * S.wobble) : 0;
        const xa = j * SEG - shift;
        const y = top + off + dy;
        c.lineTo(xa, y);
        // Grass on the field edge, on the back lane's own step only.
        if (S.tufts && layer === 0) {
          const n = 1 + Math.floor(hash01(j, 0x77) * 3);
          for (let t = 0; t < n; t++) {
            const tx = xa + SEG * (t + 0.3 + 0.4 * hash01(j * 13 + t, 0x99)) / n;
            const h = 5 + 7 * hash01(j * 17 + t, 0x3c);
            c.lineTo(tx - 3, y); c.lineTo(tx - 1, y - h); c.lineTo(tx + 1, y - h * 0.6); c.lineTo(tx + 3, y);
          }
        }
        c.lineTo(xa + SEG, y);
      }
      c.lineTo((j1 + 1) * SEG - shift, viewH2);
      c.closePath();
      c.fillStyle = layer === 0 ? (lit ? LIT_LIP : STEP_COL[k].lip)
        : layer === 1 ? (lit ? '#000' : STEP_COL[k].shadow)
        : (lit ? LIT_WALL : STEP_COL[k].wall);
      c.fill();
    }
    if (lit) {
      c.strokeStyle = '#000';
      c.lineWidth = 2;
      c.beginPath();
      for (let j = Math.floor((shift - 50) / 46); j * 46 - shift < viewW + 20; j++) {
        const x = j * 46 - shift + hash01(j, 0xab) * 14;
        const row = hash01(j, 0xcd) < 0.5 ? 0 : 1;
        c.moveTo(x, top + S.lipH + 2 + row * 22);
        c.lineTo(x, top + 28 + row * 22);
      }
      c.moveTo(0, top + 30);
      c.lineTo(viewW, top + 30);
      c.stroke();
    }
    return 1;
  }

  /**
   * The rows of fields up the hills' lower slopes, baked once into a strip the
   * width of one hills tile and drawn with the hills, at their rate β€” they are
   * ON the hills, so they move with them. Each row is the ridge again, scaled
   * down toward the third step: flatter and browner the lower it is, closer to
   * the hills' own colour the higher. The top of every hill is left alone.
   */
  let rowsTile = null;
  function buildRowsTile(hl) {
    const sc = hl.scale ?? 1;
    const cl = hl.clip;
    const tileW = Math.round(cl.width * sc);
    const hillsTop = hl.baseY - cl.pivotY * sc;
    const bottom = stepTop(2);
    const ridgeAt = (x) => hillsTop + hillProfile[Math.min(cl.width - 1, Math.floor(x / sc))] * sc;
    let y0 = bottom;
    for (let x = 0; x < tileW; x += 2) y0 = Math.min(y0, bottom - (bottom - ridgeAt(x)) * TERRACE.ROWS[0] - 4);
    y0 = Math.floor(y0) - 2;
    const cv = document.createElement('canvas');
    cv.width = tileW;
    cv.height = Math.max(1, viewH - y0);
    const g = cv.getContext('2d');
    TERRACE.ROWS.forEach((h, k) => {
      const t = 1 - h / TERRACE.ROWS[0];          // 0 on the top row, near 1 on the bottom
      const face = hexMix(HILLS_DRAWN, STEP_COL[2].wall, 0.25 + 0.7 * t);
      const lip = lighter(face, 3 + 4 * t);
      const lipH = 3 + 3 * t;
      const fieldW = 150 + 90 * t;                // lower down is nearer, so wider
      const yAt = (x) => {
        const off = Math.round((hash01(Math.floor(x / fieldW), 0x61 + k * 7) - 0.5) * 6);
        return Math.min(bottom, bottom - (bottom - ridgeAt(x)) * h + off) - y0;
      };
      for (const [col, dy] of [[lip, 0], [face, lipH]]) {
        g.beginPath();
        g.moveTo(0, cv.height);
        for (let x = 0; x <= tileW; x += 3) g.lineTo(x, yAt(Math.min(x, tileW - 1)) + dy);
        g.lineTo(tileW, cv.height);
        g.closePath();
        g.fillStyle = col;
        g.fill();
      }
    });
    return { img: cv, y: y0, w: tileW };
  }
  function drawRows(c, camX, viewW) {
    const hl = layers.find((l) => l.key === 'hills');
    if (!hl || !hillProfile) return 0;
    rowsTile ??= buildRowsTile(hl);
    const shift = Math.round(camX * hl.rate);
    const start = Math.floor(shift / rowsTile.w) * rowsTile.w - shift;
    let n = 0;
    for (let x = start; x < viewW; x += rowsTile.w) { c.drawImage(rowsTile.img, x, rowsTile.y); n++; }
    return n;
  }

  const layers = [
    {
      key: 'hills', clip: clips.bg_treeline, rate: 0.15,
      kind: 'tile', scale: 1.25, baseY: groundY + 24,
      tinted: hillsTint, litMode: 'dark', order: 0,
    },
    // The fields on the hills' slopes. They are the hills, so they move with
    // them; see drawRows.
    { key: 'terrace_rows', kind: 'rows', rate: 0.15, order: 0.5 },
    {
      // Overhead foliage is NEAR, so it scrolls fast even though it is a
      // background element. Slow would read as a distant ceiling.
      key: 'canopy', clip: clips.bg_canopy, rate: 0.55, soft: canopySoft,
      kind: 'canopy', scale: 1, litMode: 'dark', order: 1,
    },
    { key: 'terrace_3', kind: 'terrace', step: 2, order: 1.9 },
    {
      // Background people. They never move, they never react, and because this
      // layer hands the lit pass its DARK art the torch can never reach one β€”
      // a watcher stays a black shape for ever and the player will not learn
      // who a single one of them was.
      //
      // Density rides the SAME depth curve as the canopy. One curve, two
      // systems: the forest fills up at exactly the rate the sky runs out.
      // Existence is rolled per cell against the density at that cell's OWN
      // world position, so a watcher that is there is always there β€” walking
      // back and forth cannot make one appear or vanish.
      key: 'watchers', clip: clips.drained_watcher, rate: 0.65,
      kind: 'watchers', cell: 200, jitter: 185, scale: 0.44,
      // Their feet sit ABOVE the ground line, not on it. Shrinking them alone
      // reads as small people standing next to him; standing them further up
      // the frame is what reads as distance.
      //
      // AND ON THE THIRD TERRACE, which scrolls at their own rate. They stood
      // on nothing at 26px up; with the back lane on the first step at 56 they
      // were higher up the screen than the drained in front of them.
      tinted: watcherTint, baseY: stepTop(2), litMode: 'dark', order: 2,
    },
    // The two nearer steps, in front of the crowd. The first IS the back lane.
    { key: 'terrace_2', kind: 'terrace', step: 1, order: 2.5 },
    { key: 'terrace_1', kind: 'terrace', step: 0, order: 2.9 },
    {
      // The near layer is what makes it read as a forest rather than a
      // backdrop. One trunk every few seconds is enough.
      key: 'trunks_near', clip: clips.bg_trunks_near, rate: 1.80, soft: trunkSoft,
      kind: 'scatter', cell: 2400, jitter: 1100, scale: 1,
      baseY: groundY + 210, litMode: 'lit', near: true, order: 9,
    },
  ];

  /** The sky itself, which is the background fill rather than a layer. */
  function drawSky(c, camX, viewW) {
    const shift = Math.round(camX * 0.06) % SKY_TILE_W;
    const start = -((shift % SKY_TILE_W) + SKY_TILE_W) % SKY_TILE_W;
    for (let x = start; x < viewW; x += SKY_TILE_W) c.drawImage(skyStrip, x, 0);
  }

  function drawLayer(c, layer, camX, viewW, viewH2, pass, worldX) {
    if (layer.kind === 'rows') return drawRows(c, camX, viewW);
    if (layer.kind === 'terrace') return drawStep(c, layer.step, camX, pass, viewW, viewH2);
    const clip = layer.clip;
    if (!clip) return 0;
    const mode = pass === 'lit' ? layer.litMode : 'dark';
    const shift = Math.round(camX * layer.rate);
    let drawn = 0;

    if (layer.kind === 'canopy') {
      // Slides down as he goes deeper. At depth 0 it is entirely off the top of
      // the frame; at depth 1 it has eaten nearly all the sky.
      const depth = depthAt(worldX);
      if (depth <= 0.001) return 0;
      const top = -clip.height + depth * TUNING.CANOPY_MAX_DROP;
      const img = layer.soft ? layer.soft[0][mode] : clip.frames[0][mode];
      const tileW = clip.width;
      const start = Math.floor(shift / tileW) * tileW - shift;
      for (let x = start; x < viewW; x += tileW) {
        c.drawImage(img, x, top);
        drawn++;
      }
      return drawn;
    }

    if (layer.kind === 'tile') {
      const sc = layer.scale ?? 1;
      // The hills use the tinted bitmap; there is no lit variant of a mid band.
      const img = layer.tinted ? layer.tinted[0] : clip.frames[0][mode];
      const tileW = Math.round(clip.width * sc);
      const start = Math.floor(shift / tileW) * tileW - shift;
      for (let x = start; x < viewW; x += tileW) {
        c.drawImage(img, x, layer.baseY - clip.pivotY * sc, tileW, clip.height * sc);
        drawn++;
      }
      return drawn;
    }

    if (layer.kind === 'watchers') {
      const { cell, scale } = layer;
      const margin = clip.width * scale;
      const first = Math.floor((shift - margin) / cell);
      const last = Math.ceil((shift + viewW + margin) / cell);
      const alpha0 = c.globalAlpha;
      for (let i = first; i <= last; i++) {
        const w = watcherRoll(layer, i);
        if (!w) continue;
        const sx = Math.round(w.wx - shift);
        if (sx < -margin || sx > viewW + margin) continue;
        // Out of the way of anyone standing in the back lane. See CLEAR_MARGIN.
        const a = watcherAlpha(i, sx, w.sc);
        if (a <= 0.01) continue;
        c.globalAlpha = alpha0 * a;
        // Never mirrored. Every one of them is already facing the way he is
        // coming from before it enters the frame. Nothing turns to look.
        // Always the tinted bitmap, in both passes. The torch cannot change
        // one of these and it cannot brighten one.
        c.drawImage(layer.tinted[w.variant], sx - clip.pivotX * w.sc,
          layer.baseY - clip.pivotY * w.sc, clip.width * w.sc, clip.height * w.sc);
        drawn++;
      }
      c.globalAlpha = alpha0;
      return drawn;
    }

    // Occlusion: more near-layer objects, same value, same rate. The cheapest
    // depth cue there is and it spends nothing β€” no new values, no soft edges.
    const { jitter, scale } = layer;
    const cell = layer.near ? layer.cell / Math.max(1, DEPTH.OCC) : layer.cell;
    const margin = clip.width * scale;
    const first = Math.floor((shift - margin) / cell);
    const last = Math.ceil((shift + viewW + margin) / cell);
    for (let i = first; i <= last; i++) {
      const wx = i * cell + hash01(i, 0x9e37) * jitter;
      const sx = Math.round(wx - shift);
      if (sx < -margin || sx > viewW + margin) continue;
      const variant = Math.floor(hash01(i, 0x85eb) * clip.count) % clip.count;
      const sc = scale * (0.86 + hash01(i, 0xc2b2) * 0.3);
      c.drawImage((layer.soft ? layer.soft[variant][mode] : clip.frames[variant][mode]),
        sx - clip.pivotX * sc,
        layer.baseY - clip.pivotY * sc, clip.width * sc, clip.height * sc);
      drawn++;
    }
    return drawn;
  }

  // Hills blur is tone now, not an experiment toggle. DEPTH.BLUR still adds on
  // top so the depth A/B keeps working.
  setBlur(Math.round(TONE.HILLS_BLUR + DEPTH.BLUR), Math.round(DEPTH.BLUR_SKY ?? 0));

  return {
    layers,
    setBlur,
    setSoften,
    get blurPx() { return blurPx; },
    drawSky,

    /** Everything behind the actors. */
    drawFar(c, camX, viewW, viewH2, pass = 'dark', worldX = 0) {
      let n = 0;
      for (const l of layers) if (!l.near) n += drawLayer(c, l, camX, viewW, viewH2, pass, worldX);
      return n;
    },

    /** Everything in front of the actors. */
    drawNear(c, camX, viewW, viewH2, pass = 'dark', worldX = 0) {
      let n = 0;
      for (const l of layers) if (l.near) n += drawLayer(c, l, camX, viewW, viewH2, pass, worldX);
      return n;
    },

    /**
     * How many screen pixels of bright SKY a fight at worldX would have behind
     * it. This is the placement score, and it has to account for all three
     * things that eat the bright band, because scoring on the canopy alone put
     * three encounters out of five BELOW the average of the spots they were
     * picked over:
     *
     *   the canopy, closing down from the top
     *   the hills, whose ridge line rises into it from below
     *   a near trunk, pure black and full height, stood across the action
     *
     * Columns are sampled across the width the figures occupy, not one point.
     */
    skyOpenAt(worldX, viewW = 1920) {
      const canopy = layers.find((l) => l.key === 'canopy');
      const hills = layers.find((l) => l.key === 'hills');
      const trunks = layers.find((l) => l.key === 'trunks_near');
      const cc = canopy && canopy.clip;
      const hc = hills && hills.clip;
      const depth = depthAt(worldX);
      const camX = worldX - viewW / 2;

      const canopyTop = cc ? -cc.height + depth * TUNING.CANOPY_MAX_DROP : -1e6;
      const hSc = hills ? (hills.scale ?? 1) : 1;
      const hTileW = hc ? Math.round(hc.width * hSc) : 1;
      const hTop = hc ? hills.baseY - hc.pivotY * hSc : 1e6;

      // trunk columns to strike out entirely
      const blocked = [];
      if (trunks) {
        const shift = camX * trunks.rate;
        for (const wx of this.sample('trunks_near', shift - 600, shift + viewW + 600)) {
          blocked.push([wx - shift - trunks.clip.width / 2, wx - shift + trunks.clip.width / 2]);
        }
      }

      let open = 0;
      for (let sx = viewW / 2 - 300; sx <= viewW / 2 + 300; sx += 6) {
        if (blocked.some(([a, b]) => sx >= a && sx <= b)) continue;
        let top = 0;
        if (cc && depth > 0.001) {
          const col = Math.floor(((sx + camX * canopy.rate) % cc.width + cc.width) % cc.width);
          top = canopyTop + canopyProfile[col];
        }
        let bottom = 1e6;
        if (hc && hillProfile) {
          const tx = ((sx + camX * hills.rate) % hTileW + hTileW) % hTileW;
          const col = Math.min(hc.width - 1, Math.floor(tx / hSc));
          bottom = hTop + hillProfile[col] * hSc;
        }
        // Clip to the rows that are actually above luminance 160.
        const a = Math.max(top, brightBand.top);
        const b = Math.min(bottom, brightBand.bot);
        open += Math.max(0, b - a);
      }
      return open;
    },

    /**
     * Screen y of the canopy's lowest edge over the spot a fight at worldX
     * would happen, averaged across the width of the action so one stray tooth
     * does not decide it. Smaller means the canopy is higher and there is more
     * bright sky behind the figures. Null before the canopy has closed at all.
     */
    canopyBottomAt(worldX, viewW = 1920) {
      const cl = clips.bg_canopy;
      if (!canopyProfile || !cl) return null;
      const depth = depthAt(worldX);
      if (depth <= 0.001) return null;
      const top = -cl.height + depth * TUNING.CANOPY_MAX_DROP;
      const layerShift = (worldX - viewW / 2) * 0.55;
      let sum = 0, n = 0;
      for (let sx = viewW / 2 - 320; sx <= viewW / 2 + 320; sx += 16) {
        const col = Math.floor(((sx + layerShift) % cl.width + cl.width) % cl.width);
        sum += top + canopyProfile[col];
        n++;
      }
      return sum / n;
    },

    /**
     * The screen spans the back lane is standing in, for this frame. Flat
     * triples β€” left, right, weight β€” so handing them over allocates nothing.
     */
    keepClear(camX, spans, n, clock = 0) {
      // A cut rather than a frame: a restart, a check staging a scene, a
      // reset clock. Everything goes straight to where it should be.
      const dt = clock - clear.clock;
      const cut = !(dt >= 0 && dt < 0.25) || Math.abs(camX - clear.camX) > 300;
      clear.camX = camX;
      clear.clock = clock;
      clear.n = Math.min(n, CLEAR_MAX);
      for (let k = 0; k < clear.n * 3; k++) clear.spans[k] = spans[k];
      // Walk every watcher on screen toward wanted or not. Once per frame,
      // here, and not in the draw β€” the draw runs twice a frame, once for
      // each pass, and a check can call it again.
      const wl = layers.find((l) => l.key === 'watchers');
      if (!wl) return;
      const shift = Math.round(camX * wl.rate);
      const margin = wl.clip.width * wl.scale;
      const step = dt > 0 ? dt / CLEAR_FADE_S : 0;
      for (let i = Math.floor((shift - margin) / wl.cell); i <= Math.ceil((shift + 1920 + margin) / wl.cell); i++) {
        const w = watcherRoll(wl, i);
        if (!w) continue;
        const want = watcherWanted(Math.round(w.wx - shift), w.sc);
        const was = watcherFade.get(i);
        if (cut || was === undefined) { watcherFade.set(i, want); continue; }
        watcherFade.set(i, want > was ? Math.min(want, was + step) : Math.max(want, was - step));
      }
    },

    /**
     * For the determinism check: trunk positions in a world range.
     *
     * For watchers, `shownOnly` leaves out any the back lane has faded, even
     * partly. A faded one is not a watcher on screen, and measuring its torso
     * measures whatever is behind it.
     */
    sample(key, fromX, toX, shownOnly = false) {
      const l = layers.find((x) => x.key === key);
      if (!l) return [];
      const out = [];
      if (l.kind === 'watchers') {
        for (let i = Math.floor(fromX / l.cell); i <= Math.ceil(toX / l.cell); i++) {
          const w = watcherRoll(l, i);
          if (!w) continue;
          if (shownOnly && watcherAlpha(i, Math.round(w.wx - clear.camX * l.rate), w.sc) < 0.999) continue;
          out.push(Math.round(w.wx));
        }
        return out;
      }
      if (l.kind !== 'scatter') return [];
      for (let i = Math.floor(fromX / l.cell); i <= Math.ceil(toX / l.cell); i++) {
        out.push(Math.round(i * l.cell + hash01(i, 0x9e37) * l.jitter));
      }
      return out;
    },
  };
}