File size: 32,072 Bytes
71eae78
 
 
2428d78
71eae78
 
 
 
2428d78
 
 
d16591c
 
 
 
71eae78
 
 
9986f05
 
835b445
 
 
 
d16591c
 
 
 
 
 
71eae78
 
 
 
 
d16591c
71eae78
 
 
 
835b445
 
 
 
 
71eae78
d16591c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
2428d78
7d93bf0
 
 
2428d78
 
 
7d93bf0
 
 
 
 
 
 
 
 
2428d78
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
7d93bf0
 
 
2428d78
7d93bf0
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d93bf0
 
 
 
 
 
 
 
 
 
 
71eae78
 
d16591c
 
 
 
 
71eae78
 
 
 
835b445
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d16591c
 
 
 
 
 
71eae78
 
 
 
2428d78
 
7d93bf0
2428d78
 
 
 
 
7d93bf0
 
835b445
 
 
 
 
9986f05
 
 
 
 
71eae78
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
7d93bf0
 
 
2428d78
 
835b445
 
 
d16591c
 
 
 
2428d78
 
 
 
 
 
 
 
71eae78
 
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
2428d78
 
 
 
 
 
 
71eae78
 
 
 
d16591c
 
 
 
 
9986f05
 
71eae78
2428d78
71eae78
 
7d93bf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
835b445
 
9986f05
d16591c
 
835b445
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
835b445
 
 
 
 
 
 
 
 
 
 
 
 
 
9986f05
835b445
 
 
9986f05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
835b445
 
 
d16591c
 
 
 
835b445
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
 
 
44bae7c
 
 
 
71eae78
 
2428d78
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d16591c
 
 
 
71eae78
 
 
 
d16591c
 
 
 
71eae78
 
 
 
 
d16591c
71eae78
 
2428d78
 
 
 
 
71eae78
 
 
 
 
d16591c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d93bf0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
d16591c
2428d78
d16591c
 
 
 
 
7d93bf0
2428d78
71eae78
d16591c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
9986f05
 
 
 
2428d78
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
2428d78
 
 
 
 
 
71eae78
 
 
 
 
 
 
 
 
 
 
 
 
 
2428d78
 
 
 
 
 
71eae78
 
835b445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
 
 
 
835b445
 
71eae78
 
 
 
 
 
2428d78
71eae78
 
2428d78
 
 
 
 
 
 
 
71eae78
2428d78
 
 
 
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
// One Drained, and the five states it can be in.
//
// THE ARGUMENT THE WHOLE LEVEL MAKES LIVES IN THIS FILE. A Drained that has
// never been lit never attacks β€” it stands, and the prince can walk straight
// past it. The only thing that ever makes one hostile is the player choosing to
// see it. So one hundred percent of the combat in this level is caused by
// looking.
//
//   WANDER   stands exactly where it stopped. Harmless, and it does not move
//            at all β€” not a step, not a sway. Being looked at is the only
//            thing that has ever made one of them do anything.
//   STEP_DOWN  revealed, and stepping out of the back lane onto his road.
//            Cannot be hit and cannot hurt: it is the telegraph. The only
//            moment one of them ever changes lane on its own.
//   RUSH     comes for him. Reached by stepping down, and never left.
//   STAGGER  knocked back, cannot act.
//   DOWNED   lies where it fell. Cannot be revealed. Cannot rush.
//   RISING   getting up. Can be knocked back down, for ever.
//            A body woken by a WRONG pin rises into RUSH too, and comes for him
//            from wherever it fell β€” off screen included. See wake().
//   HOMING   walking back to its own door, because somebody gave it its name.
//            Harmless, never hurries, and the only thing in this game that
//            goes somewhere on purpose.
//   HOME     standing in that doorway, facing out. Never moves again.
//
// THE LANE IS THE STATE, SEEN FROM OUTSIDE. Everything that has never been lit
// stands in the back lane, beside the road; everything else is on the road, on
// his line. `lane` is 1 in the back and 0 on the road, and it is only ever in
// between during STEP_DOWN. It moves nothing in the world β€” x is x in both β€”
// and laneLook() turns it into what the renderer and the torch need.

import { TUNING } from './tuning.js';
import { createPlayer } from './anim.js';

export const WANDER = 'WANDER';
export const STEP_DOWN = 'STEP_DOWN';
export const RUSH = 'RUSH';
export const STAGGER = 'STAGGER';
export const DOWNED = 'DOWNED';
export const RISING = 'RISING';
// The two the walk-home addendum adds. Everything before this point is a thing
// that happens TO one of them; these two are the only states it enters because
// the player gave something back.
export const HOMING = 'HOMING';
export const HOME = 'HOME';

/**
 * What a lane position looks like: how big, how far up the frame, how light.
 *
 * ONE PLACE, because three things have to agree about it β€” the renderer draws
 * the figure with it, the torch decides whether the figure is lit with it, and
 * the checks measure it. A figure the torch thinks is somewhere it is not drawn
 * is a reveal nobody can predict.
 */
export function laneLook(lane) {
  const l = Math.max(0, Math.min(1, lane || 0));
  return {
    lane: l,
    scale: 1 - (1 - TUNING.BACK_LANE_SCALE) * l,
    rise: TUNING.BACK_LANE_RISE * l,
    value: TUNING.BACK_LANE_VALUE * l,
  };
}

/**
 * The height the torch has to reach to have seen this figure: the middle of
 * the body, not of the canvas. The loops sit on a taller canvas to give the
 * swing room, so half the canvas is above the head. 175 is half of a standing
 * figure, and in the back lane the whole figure is smaller and higher.
 */
export function figureMidY(groundY, lane) {
  const L = laneLook(lane);
  return groundY - L.rise - 175 * L.scale;
}

// Who each of them was. The object is the only thing that says it, and it only
// says it inside the torch.
// INDEX ORDER IS HOUSE ORDER: forge, bakehouse, well house, home, gatehouse.
// One fragment per house. scene_level.js builds the houses straight off this
// list, so a fragment's house and a plot on the map are the same number and
// cannot drift apart.
//
// The weaver is in here and is never carried by anything. The loom in Home is
// the only place a weaver appears at all, and that is the finding.
// INDEX ORDER IS HOUSE ORDER: forge, bakehouse, well house, home, gatehouse.
// Which house a fragment answers. INTERNAL ONLY β€” nothing a player sees ever
// says it, because the whole point of a fragment is that the words decide.
// It survives as a key for the band text and for the debug readout.
//
// Home has a fragment written for it and nothing in the forest carries it.
// That is the residue.
export const HOUSE_OF = [
  'the forge', 'the bakehouse', 'the well house', 'home', 'the gatehouse',
];

/**
 * Where the kept object lands when this one goes down, as an offset from the
 * body's pivot. The downed clip declares a point past each end of the pose; we
 * take the one on the side he walks off towards, so it is in his path and never
 * behind him. Which end of the body that is β€” the head or the boots β€” depends
 * on which way this one happened to fall.
 */
export function keptDropOffset(clip, facing, side) {
  const face = facing === (clip.authoredFacing ?? 1) ? 1 : -1;
  const drops = clip.keptDrop;
  let p = drops[0];
  for (let i = 0; i < drops.length; i++) {
    if (Math.sign((drops[i][0] - clip.pivotX) * face) === side) { p = drops[i]; break; }
  }
  return [(p[0] - clip.pivotX) * face, p[1] - clip.pivotY];
}

/**
 * @param opts.standing  it does not walk. Village shapes stand at their work
 *                       and loop, and the loop is the thing you read them by.
 * @param opts.kept      which object it carries, or null for nobody's. Eight of
 *                       the village's twelve carry nothing, and a fight that
 *                       drops nothing is the negative result β€” it needs no
 *                       marking because the absence IS the feedback.
 * @param opts.stayDown  once down, down for good. A body is the only record of
 *                       which shapes you already checked, and twelve near
 *                       identical outlines with no memory of what you tried is
 *                       precisely the tedium the village exists to measure.
 */
export function createDrained(clips, spawnX0, dir, keptIndex = 0, opts = {}) {
  let spawnX = spawnX0;
  const { standing = true, stayDown = false, loop = null, post = false,
    keptMaxX = Infinity } = opts;
  let roam = opts.roam ?? 0;

  // THE STILL POSE, and it is the watcher art at full size in pure black.
  //
  // Nothing wanders any more. A Drained that has not been lit does not walk the
  // road, does not mark time on the spot and does not sway: it stands exactly
  // where it stopped, and the only thing that moves it is being looked at. That
  // is what the level has always argued and it was being undercut by the
  // animation β€” a walk cycle played at a quarter speed reads as a man walking
  // on the spot, which looks like a bug rather than like a person who stopped.
  //
  // The art for it already existed. The background crowd is these same four
  // poses at 0.44 and tinted; up close, at full size and pure black, they are
  // what a person standing still looks like.
  //
  // V4 REPLACES THIS WITH TRADE LOOPS, and that is not a contradiction. A
  // blacksmith miming a hammer swing is a loop that READS β€” it is the first
  // skill the search teaches. A walk cycle at quarter speed reads as nothing.
  const stillClip = clips.drained_watcher;

  // THE TRADE LOOP, and it is the first skill the village teaches. Something
  //
  // A DECOY DOES THE SAME LOOP AS ITS CARRIER. One carrier to two decoys, so
  // watching loops alone never finishes the job β€” if only carriers worked, the
  // search would resolve on sight and there would be no search.
  // THE GATEKEEPER. Everything about hits, stagger, going down and getting up
  // is the shared machinery β€” consistency beats making him special. What he has
  // instead of a rush is a thrust, and what he has instead of a kill is a shove.
  const postClip = post ? clips.drained_thrust ?? null : null;

  // THE LOOP, and it is the first skill the village teaches. Something is
  // swinging. It narrows twelve shapes to three without saying a word, and
  // reading which of the three has a page-shaped break in its outline picks
  // the one.
  //
  // IT IS A MOTION AND IT IS NOT A TRADE. Nobody out there is a blacksmith.
  // Once the fragment stopped being a tool, the loop stopped being able to name
  // a house β€” and which house a fragment answers is decided by reading it.
  const loopClip = loop ? clips[`loop_${loop}`] ?? null : null;
  const loopPlayer = loopClip ? createPlayer(loopClip) : null;
  const kept = opts.kept === undefined ? keptIndex % HOUSE_OF.length : opts.kept;
  const players = {
    [WANDER]: createPlayer(clips.drained_walk),
    // THE STEP DOWN IS THE WALK, PLAYED FOR ONE STRIDE. The spec's first try,
    // before anyone draws it: if it reads as sliding it needs three or four
    // frames of its own, and a `drained_step` clip in the manifest is used
    // with no code change.
    [STEP_DOWN]: createPlayer(clips.drained_step ?? clips.drained_walk),
    [RUSH]: createPlayer(clips.drained_rush),
    [STAGGER]: createPlayer(clips.drained_stagger),
    [DOWNED]: createPlayer(clips.drained_downed),
    [RISING]: createPlayer(clips.drained_rising),
    // It walks home on the same cycle the two wanderers pace on. There is no
    // separate going-home drawing and there should not be: a walk is a walk,
    // and what makes this one different is where it ends.
    [HOMING]: createPlayer(clips.drained_walk),
    [HOME]: createPlayer(clips.drained_walk),
  };

  const d = {
    x: spawnX, prevX: spawnX,
    dir,                        // fixed at spawn, often toward the wall
    facing: dir,
    // Until he hits it: it walks at him, so he arrives from the other side.
    keptSide: -dir,
    keptDropped: false,    // once true it never goes back on the body
    keptSeen: false,       // was the object ever actually inside the torch?
    keptTaken: false,      // he picked it up; it is out of the world now
    keptX: 0, keptY: 0,
    state: WANDER,
    timer: 0,
    hits: 0,
    dwell: 0,                   // how long it has been covered by the light
    revealed: false,            // latches, and never unlatches
    // 1 in the back lane, 0 on the road. See laneLook.
    lane: post ? 0 : 1,
    // Turned to face him. Hysteresis between WATCH_NEAR and WATCH_FAR.
    watching: false,
    // How far along a stare is, 0..1. Zero when the light is off it.
    stir: 0,
    knockVel: 0,
    clip: clips.drained_walk,
    frame: 0,
    spawnX,
    standing,
    stayDown,
    loop,
    post,
    // 0 ready, 1 windup, 2 thrust, 3 recovery β€” and the timer that drives them.
    phase: 0,
    phaseMs: 0,
    kept,
    // Which house this answers. The player never sees it.
    answers: kept === null ? null : HOUSE_OF[kept],
    // WALKING HOME. `homeX` is the door it is walking to, in world space.
    // `homing` is what makes RISING come up into a walk rather than into a
    // rush, and it is the one flag in here that the player's own act sets.
    homing: false,
    homeX: null,
    // WOKEN BY A WRONG PIN. Someone put its last words on a stranger's house,
    // and it is coming for him β€” from wherever it fell, so this is also what
    // keeps it out of the distance cull and moving while he cannot see it.
    // Cleared the moment it goes down again or is sent home.
    woken: false,
  };

  /**
   * Which drawing this one is showing. Split out because a standing Drained in
   * WANDER is not playing a clip at all β€” it is holding one pose β€” and three
   * places needed to agree about that.
   */
  function applyPose() {
    // At his post, the pose is the attack phase. Not a clip played back at a
    // frame rate β€” the phases have different lengths and the timer owns them.
    if (postClip && (d.state === WANDER || d.state === RUSH)) {
      d.clip = postClip;
      d.frame = d.phase;
      return;
    }
    if (d.state === WANDER && loopClip) {
      d.clip = loopClip;
      d.frame = loopPlayer.index;
      return;
    }
    // HOME IS THE STILL POSE, the same one it held in the street before any of
    // this started. It is standing in a doorway now instead of nowhere, and
    // that is the entire difference β€” nothing in this game gets better.
    // AND A WANDERER THAT HAS STOPPED TO WATCH HIM. It does not walk while it
    // turns to follow him, or it moonwalks; it stands, which is the pose a
    // person who stopped already has.
    if ((d.state === HOME || (d.state === WANDER && (d.standing || d.watching || d.dwell > 0))) && stillClip) {
      d.clip = stillClip;
      d.frame = keptIndex % stillClip.count;
      return;
    }
    d.clip = players[d.state].clip;
    d.frame = players[d.state].index;
  }

  function enter(next) {
    // Going down is when the object leaves it. Frozen here, in world space, so
    // it stays on the ground where it fell however far the body is shoved
    // afterwards β€” and so it is still lying there when this one gets up, which
    // is the whole point of it having fallen out.
    if (next === DOWNED && !d.keptDropped && d.kept !== null) {
      // WHICH SIDE IT FALLS ON. Normally the side the prince walks off towards,
      // so the object is in his path and never behind him.
      //
      // The gatekeeper is the one case where that is exactly wrong. He does not
      // get walked past β€” there is a shut gate behind him, and the prince stops
      // 150px short of him and can never get closer. His spear landed 375px
      // beyond the stop line against a 120px pickup reach, so the run could be
      // finished with three objects and no way to reach the fourth.
      //
      // At a post it falls on the side the prince is STANDING on, which is
      // what his facing already points at.
      const side = post ? (Math.sign(d.facing) || -1) : (d.keptSide || 1);
      const off = keptDropOffset(clips.drained_downed, d.facing, side);
      d.keptDropped = true;
      // AND IT NEVER LANDS WHERE HE CANNOT WALK. Belt and braces on top of the
      // side choice: whatever the pose and the facing conspire to do, an object
      // outside the walkable range is a run that cannot be finished, and that
      // is the worst bug this level can have. Clamped HERE rather than by the
      // caller watching for the transition, because a post goes straight to
      // DOWNED inside takeHit and the caller never sees a transition at all.
      d.keptX = Math.max(60, Math.min(keptMaxX, d.x + off[0]));
      d.keptY = off[1];
    }
    d.state = next;
    d.timer = 0;
    // Back lane until it has been seen, road for everything after. STEP_DOWN
    // starts in the back and walks the number down itself; nothing else ever
    // moves it, so a state that is not these two is simply on the road.
    d.lane = (next === WANDER || next === STEP_DOWN) && !post ? 1 : 0;
    if (next !== WANDER) d.watching = false;
    // Only rising, rushing and reeling are part of coming for him.
    if (next !== RISING && next !== RUSH && next !== STAGGER) d.woken = false;
    players[next].reset();
    applyPose();
  }

  /**
   * Re-dealt. The BODY is built once with its loop fixed β€” a loop is a clip and
   * a player, and swapping those per run would be rebuilding the level. What a
   * run seed moves is which slot this body stands in and what it is carrying.
   */
  d.placeAt = (x, roamHalfWidth = roam) => {
    // BOTH, AND THE ROAM WITH THEM. The closure drives d.reset() and the pacing
    // bounds; the property is what the checks read. Updating only the closure
    // left every check measuring drift against the slot this body was
    // CONSTRUCTED in rather than the one it was dealt into.
    //
    // And how far it may pace is a property of the SLOT, not of the body β€” a
    // slot near a door has less room. Carrying the old allowance into a new
    // slot walked a wanderer into a doorway, which is the one thing the whole
    // placement was built to prevent.
    spawnX = x;
    d.spawnX = x;
    d.x = d.prevX = x;
    roam = roamHalfWidth;
  };
  d.setKept = (k) => {
    d.kept = k;
    d.answers = k === null ? null : HOUSE_OF[k];
  };

  d.reset = () => {
    d.x = d.prevX = spawnX;
    d.dir = d.facing = dir;
    d.keptSide = -dir;
    d.keptDropped = false;
    d.keptSeen = false;
    d.keptTaken = false;
    d.hits = 0;
    d.dwell = 0;
    d.revealed = false;
    d.knockVel = 0;
    d.homing = false;
    d.homeX = null;
    d.woken = false;
    d.watching = false;
    d.stir = 0;
    // THE GATEKEEPER'S CYCLE, and it was surviving a reset.
    //
    // His four phases are a timer, not a clip, so they were never touched here
    // β€” which meant a restarted level dropped the prince in front of a man who
    // might be a tenth of a second from a thrust or most of a second from one.
    // The same three-hit fight measured 1.3s and shoved nobody, or 1.9s and
    // shoved twice, entirely on what the previous run happened to leave behind.
    //
    // Found because it made a standing check flicker, which is the only reason
    // anyone would ever have found it: to a player it is just a gatekeeper who
    // is sometimes harder.
    d.phase = 0;
    d.phaseMs = 0;
    enter(WANDER);
  };

  /**
   * Somebody put this one's own words back on its own wall, so it goes home.
   *
   * FROM ANY STATE, and that is deliberate rather than tidy. Today a taken
   * fragment implies a downed body, because `stayDown` is true in the bowl β€”
   * but that is an assumption living in a tuning value, and one change to
   * DOWN_MS would leave a body flagged and standing in the street for ever
   * with nothing to say why. So DOWNED gets up first and everything else
   * simply turns and walks.
   */
  d.sendHome = (x) => {
    if (d.state === HOMING || d.state === HOME) return;
    d.homeX = x;
    d.homing = true;
    d.woken = false;
    enter(d.state === DOWNED ? RISING : HOMING);
  };

  /**
   * Somebody put this one's words on the WRONG wall, so it gets up and comes
   * for him. The same rise as going home; RISING already forks on `homing`,
   * so this is that flag held false and the rise started.
   *
   * A NORMAL REVEALED DRAINED from here: RUSH, three hits to put down. It is
   * already on the road where it fell, so there is no step down. `revealed`
   * is set because the gatekeeper β€” and anything downed without a look β€” was
   * never revealed, and the guard pose and the fight count read that flag.
   *
   * Returns whether it actually woke. One already up is left as it is: it is
   * coming anyway. One going home, or home, was filed right and is finished.
   */
  d.wake = () => {
    if (d.state === HOMING || d.state === HOME) return false;
    d.homing = false;
    d.homeX = null;
    d.revealed = true;
    if (d.state === DOWNED) {
      enter(RISING);
      d.woken = true;
      return true;
    }
    if (d.state === RISING) { d.woken = true; return true; }
    return false;
  };

  /** A connected sword hit. Returns true if it did anything at all. */
  d.takeHit = (fromX) => {
    // Hitting a DOWNED one does nothing. No knockback, no reaction, no counter.
    // The player presses it three or four times and stops, which teaches "he
    // cannot kill it" in two seconds with nothing on screen.
    // And one that is on its way home, or already in its door. It is not a
    // fight any more; the same two seconds of pressing that teach "he cannot
    // kill it" over a body teach it again here, with nothing on screen.
    //
    // And one stepping down. The step IS the warning; a hit that landed in it
    // would turn the telegraph into a free first strike.
    if (d.state === DOWNED || d.state === STAGGER || d.state === STEP_DOWN ||
        d.state === HOMING || d.state === HOME) return false;

    // Which way he will walk off from here. He is swinging at it, so he is
    // facing it, so he carries on past it this way. Frozen on the last hit that
    // lands and read only once it is down, which is what keeps the dropped
    // object from jumping across the body every time he turns around.
    d.keptSide = Math.sign(d.x - fromX) || 1;

    if (d.state === RISING) {
      // Straight back down, timer reset. Allowed to happen for ever.
      enter(DOWNED);
      return true;
    }

    // HE DOES NOT FLINCH, and that is what makes him a fight.
    //
    // STAGGER_MS is 500 and his windup is 500, so a hit every half second held
    // him in stagger for ever: you could stand at the wall, mash, and put him
    // down in four swings without him once getting his spear out. Braced
    // against a gate he has held for sixty years, he takes the hit and carries
    // on, so mashing inside his reach gets you shoved back out of it.
    if (post) {
      d.hits++;
      if (d.hits >= TUNING.HITS_TO_DOWN) enter(DOWNED);
      else if (d.state === WANDER) enter(RUSH);
      return true;
    }

    d.hits++;
    d.knockVel = Math.sign(d.x - fromX) || 1;
    enter(STAGGER);
    return true;
  };

  /**
   * Shoved off without counting a hit. Written for the old get-up-where-you-
   * fell recovery, which death replaced with a level restart. Kept because it
   * is the only way to move one of these without hurting it, and the ending
   * will want that.
   */
  d.shoveOff = (fromX, push) => {
    if (!post) d.x += Math.sign(d.x - fromX || 1) * push;
    d.prevX = d.x;
    if (d.state === RUSH || d.state === WANDER) {
      d.knockVel = Math.sign(d.x - fromX) || 1;
      enter(STAGGER);
    }
  };

  /**
   * @param ctx { princeX, torch, groundY, inFrame }
   */
  d.update = (dt, ctx) => {
    d.prevX = d.x;
    const T = TUNING;

    // --- being seen ------------------------------------------------------
    // Only WANDER and RISING can be revealed. A DOWNED one cannot, which is
    // what makes it the only safe thing in the level to look at, and the torch
    // over it must never punish the player for staying.
    if (!d.revealed && (d.state === WANDER || d.state === RISING)) {
      // Off the FIGURE, not the canvas, and off the figure WHERE IT IS DRAWN.
      // In the back lane that is smaller and higher, which puts it nearer the
      // flame; see figureMidY.
      const midY = figureMidY(ctx.groundY, d.lane);
      if (ctx.torch.covers(d.x, midY)) {
        d.dwell += dt * 1000;
        if (d.dwell >= T.REVEAL_MS) {
          d.revealed = true;
          // The gatekeeper is already on the road. He is the one Drained in
          // the level that is actually in the way, and his windup is his
          // telegraph.
          if (d.state === WANDER) enter(post ? RUSH : STEP_DOWN);
        }
      } else {
        d.dwell = 0;
      }
    }
    d.stir = d.revealed ? 0 : Math.min(1, d.dwell / T.REVEAL_MS);

    // --- knockback, which runs in any state -------------------------------
    // Except at a post. He is braced against a gate he has been holding for
    // sixty years, and being knocked off it cost two things at once: the fight
    // drifted away from the gate, and the first hit pushed him out of sword
    // range so the second never landed.
    if (d.knockVel !== 0 && !post) {
      const push = T.KNOCKBACK_PX / (T.STAGGER_MS / 1000);
      d.x += d.knockVel * push * dt;
    }

    switch (d.state) {
      case WANDER: {
        // IT WATCHES HIM PASS. This used to be "facing was fixed at spawn and
        // nothing turns to look", the contract the background watchers keep β€”
        // and walking through one produced no reaction at all, so a player who
        // tried it got no evidence it was safe.
        //
        // Now: inside WATCH_NEAR the whole body turns to face him β€” a head
        // turn is invisible in silhouette β€” and follows him as he goes by.
        // Past WATCH_FAR it goes back to its loop. In between it holds
        // whatever it was doing, so standing on the boundary cannot flicker.
        //
        // Not the gatekeeper. He is on the road, facing the only way anyone
        // ever comes at him.
        if (!post) {
          const gap = ctx.princeX - d.x;
          if (Math.abs(gap) <= T.WATCH_NEAR) d.watching = true;
          else if (Math.abs(gap) >= T.WATCH_FAR) d.watching = false;
          // A small dead band over his own x, so a prince standing on top of
          // it does not spin it on a rounding error.
          if (d.watching && Math.abs(gap) > 8) d.facing = Math.sign(gap);
        } else {
          d.watching = false;
        }
        if (!d.watching) d.facing = d.dir;
        // LIT, IT STOPS AND TURNS TO THE LIGHT. Whatever it was doing, it is not
        // doing it now: the loop holds its frame and a wanderer stands still.
        // The torch reaches a little past WATCH_NEAR, so this turns it even
        // where the watch has not.
        const lit = d.dwell > 0;
        if (lit && !post) d.facing = Math.sign(ctx.princeX - d.x) || d.facing;
        if (!d.standing && !d.watching && !lit) {
          // A WANDERER IS BOUNDED. Two shapes doing nothing anybody wrote down
          // are the negative control for the loop skill β€” without something it
          // can correctly rule OUT, every shape is worth a fight and watching
          // is decoration. They have to move, because standing still reads as
          // the gatekeeper or as a watcher.
          //
          // But they may not drift. Let one walk into a door's clear pocket and
          // lighting that door wakes it, which is the one guarantee the whole
          // placement was built to buy.
          if (ctx.inFrame) {
            d.x += d.dir * T.DRAINED_SPEED * dt;
            if (roam) {
              if (d.x <= spawnX - roam) { d.x = spawnX - roam; d.dir = 1; }
              else if (d.x >= spawnX + roam) { d.x = spawnX + roam; d.dir = -1; }
            }
          }
          players[WANDER].advance(dt * (T.DRAINED_SPEED / (d.clip.groundSpeed || T.DRAINED_SPEED)));
        } else if (loopPlayer && ctx.inFrame && !lit) {
          // Only while he can see it. A loop nobody is watching is a timer.
          //
          // AND IT KEEPS GOING WHILE IT WATCHES HIM. The loop is the first
          // skill the village teaches, and it has to be readable from where
          // the torch is: stopping it inside WATCH_NEAR would stop it at the
          // exact distance a player stands to look.
          loopPlayer.advance(dt);
        }
        break;
      }

      case STEP_DOWN: {
        // OFF THE BACK LANE AND ONTO HIS. Scale, height and value all come
        // from `lane`, eased, and x does not move: it steps toward the camera,
        // not along the road. It faces him the whole way, because the moment
        // it was lit it stopped doing anything else.
        d.timer += dt * 1000;
        const t = Math.min(1, d.timer / Math.max(1, T.STEP_MS));
        d.lane = 1 - t * t * (3 - 2 * t);
        d.facing = Math.sign(ctx.princeX - d.x) || d.facing;
        // One stride over the step, whatever the clip's own rate.
        const stride = (players[STEP_DOWN].clip.count / 2) / players[STEP_DOWN].clip.fps;
        players[STEP_DOWN].advance(dt * stride / Math.max(0.001, T.STEP_MS / 1000));
        if (t >= 1) enter(RUSH);
        break;
      }

      case RUSH: {
        const toward = Math.sign(ctx.princeX - d.x) || -1;
        d.facing = toward;
        // Off screen it waits β€” unless a wrong pin woke it, in which case it is
        // coming for him and does not need to see him to do it. The gatekeeper
        // waits either way: he is angry at the gate, not chasing anyone.
        if (!ctx.inFrame && (!d.woken || postClip)) break;
        if (postClip) {
          // HE DOES NOT CLOSE THE DISTANCE. Sixty years after anyone relieved
          // him, he is still keeping people away from a gate β€” so he stands
          // where he stands and the fight is about his timing, not his feet.
          d.phaseMs += dt * 1000;
          const len = [T.POST_READY_MS, T.POST_WINDUP_MS, T.POST_THRUST_MS, T.POST_RECOVER_MS][d.phase];
          if (d.phase === 0 && Math.abs(ctx.princeX - d.x) > T.POST_REACH + 160) {
            d.phaseMs = 0;                  // out of range: he simply waits
          } else if (d.phaseMs >= len) {
            d.phaseMs = 0;
            d.phase = (d.phase + 1) % 4;
          }
          break;
        }
        // Everything else comes for him, and only while they are in the same
        // frame. Off screen it stops and waits β€” it stays hostile, because
        // revealed is permanent, but a thing that chases you across half a
        // level you cannot see is an off-screen timer, not a presence.
        d.x += toward * T.RUSH_SPEED * dt;
        players[RUSH].advance(dt * (T.RUSH_SPEED / (d.clip.groundSpeed || T.RUSH_SPEED)));
        break;
      }

      case STAGGER:
        d.timer += dt * 1000;
        players[STAGGER].advance(dt);
        if (d.timer >= T.STAGGER_MS) {
          d.knockVel = 0;
          enter(d.hits >= T.HITS_TO_DOWN ? DOWNED : RUSH);
        }
        break;

      case DOWNED:
        d.timer += dt * 1000;
        // In the village it never gets up, and nothing cleans it up either.
        // Besides being the player's record of what they already checked, it is
        // the best image this level produces for free: brute-force the bowl and
        // you walk out through twelve people you knocked over to find four
        // objects. On the road DOWN_MS still applies, unchanged.
        if (!d.stayDown && d.timer >= T.DOWN_MS) enter(RISING);
        break;

      case HOMING: {
        // IT WALKS OFF SCREEN AND KEEPS WALKING. Everything else in this file
        // stops when it cannot see him, because a thing that chases you across
        // half a level you cannot see is a timer rather than a presence. This
        // one is going somewhere that is not him, so `inFrame` is none of its
        // business β€” gate it and it would freeze at the edge of the view and
        // never arrive.
        const toward = Math.sign(d.homeX - d.x);
        if (toward === 0 || Math.abs(d.homeX - d.x) <= T.HOME_SPEED * dt) {
          d.x = d.homeX;
          // Turning in the doorway to face the street it walked up. The last
          // thing it does is look back the way it came.
          d.facing = -(Math.sign(d.homeX - d.prevX) || d.facing);
          // The journey is over, so the scene may stop paying for it. `homing`
          // is what kept this one out of the distance cull; HOME is a state
          // that does nothing and can be culled like any other.
          d.homing = false;
          enter(HOME);
          break;
        }
        d.facing = toward;
        d.x += toward * T.HOME_SPEED * dt;
        players[HOMING].advance(dt * (T.HOME_SPEED / (d.clip.groundSpeed || T.HOME_SPEED)));
        break;
      }

      case HOME:
        // Nothing. Not a timer, not a sway, not a look. That is the point of
        // the loop stopping.
        break;

      case RISING:
        d.timer += dt * 1000;
        players[RISING].advance(dt);
        if (d.timer >= T.RISE_MS) {
          d.hits = 0;          // it got up. The count starts again.
          // The one fork in this whole file that the player wrote.
          enter(d.homing ? HOMING : RUSH);
        }
        break;
      default:
    }

    if (d.state !== STAGGER) d.knockVel = 0;
    applyPose();
  };

  /**
   * Contact with the prince. Only a RUSH contact ends the run β€” and the
   * gatekeeper's never does.
   *
   * He is dangerous only during the 150ms his spear is out, at spear range
   * rather than body range, and what he does is SHOVE. You lose ground, not the
   * run. A gatekeeper is keeping people away from a gate, not killing them.
   */
  d.contactKind = (princeX, reach) => {
    if (post) {
      if (d.state !== RUSH || d.phase !== 2) return null;
      return Math.abs(d.x - princeX) <= TUNING.POST_REACH ? 'shove' : null;
    }
    if (Math.abs(d.x - princeX) > reach) return null;
    return d.state === RUSH ? 'fatal' : 'harmless';
  };

  enter(WANDER);
  return d;
}