cloudronin commited on
Commit
44bae7c
·
verified ·
1 Parent(s): 2428d78

Health and level restart: six hits, no bar, death takes the map with it

Browse files
assets/prince/hurt/lit_00.svg ADDED
assets/prince/hurt/lit_01.svg ADDED
assets/prince/hurt/lit_02.svg ADDED
assets/prince/hurt/lit_03.svg ADDED
manifest.json CHANGED
@@ -72,6 +72,14 @@
72
  "handPivot": [166, 58],
73
  "silhouette": "auto"
74
  },
 
 
 
 
 
 
 
 
75
  "prince_map": {
76
  "_note": "The torch pose with the other hand out in front. Holding the map raises the torch — one button, one cost. The SHEET is a separate clip anchored at mapPivot, the same way the flame is anchored at handPivot, because that is what lets the sheet be lit while he stays a silhouette inside his own torchlight.",
77
  "path": "assets/prince/map/", "file": "lit_{nn}.svg",
 
72
  "handPivot": [166, 58],
73
  "silhouette": "auto"
74
  },
75
+ "prince_hurt": {
76
+ "_note": "THERE IS NO HEALTH BAR. He holds his side, leans away from it and his head comes down, and that is how much is left. The only new drawing health needed — the limp is the run frames on an uneven cadence, and the slowed swing is a number.",
77
+ "path": "assets/prince/hurt/", "file": "lit_{nn}.svg",
78
+ "frames": 4, "fps": 2, "variants": true,
79
+ "_frames_note": "Two stages of two. Frames 0-1 are hurt, 2-3 are spent, and the scene picks the pair off how much is left — two frames alone read as 'he is hurt' and said nothing about how close he was.",
80
+ "native": [220, 340], "pivot": [110, 340],
81
+ "silhouette": "auto"
82
+ },
83
  "prince_map": {
84
  "_note": "The torch pose with the other hand out in front. Holding the map raises the torch — one button, one cost. The SHEET is a separate clip anchored at mapPivot, the same way the flame is anchored at handPivot, because that is what lets the sheet be lit while he stays a silhouette inside his own torchlight.",
85
  "path": "assets/prince/map/", "file": "lit_{nn}.svg",
src/checks.js CHANGED
@@ -821,15 +821,106 @@ export function createChecks({ renderer, scene, canvas }) {
821
  };
822
  }
823
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
824
  function all() {
825
  const out = [figureContrast(), watcherContrast(), brightMass(), skyWeave(),
826
  bowlLayout(), bodiesPersist(), wholeLevelWalkable(), stillUntilLit(),
827
- doorsRead(), mapRecords(), populationReads(), nothingOutOfReach(), theGate()];
 
828
  return { pass: out.every((r) => r.pass), results: out };
829
  }
830
 
831
  return { figureContrast, watcherContrast, brightMass, skyWeave,
832
  bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead,
833
- mapRecords, populationReads, nothingOutOfReach, theGate,
834
  all, frameAt, frameAtX };
835
  }
 
821
  };
822
  }
823
 
824
+ /**
825
+ * A FIGHT NOW COSTS SOMETHING.
826
+ *
827
+ * Village one played like a beat 'em up because beating up every Drained cost
828
+ * nothing: three hits, no health, no death. The optimal play was to hit
829
+ * everything and the game agreed. At zero risk there is no search at all,
830
+ * just a sweep — and a sweep cannot be tedious, because it is not looking for
831
+ * anything.
832
+ *
833
+ * What this asserts is the shape of the cost, not that it is fun:
834
+ *
835
+ * - A man standing in the dark doing nothing is still harmless. The level's
836
+ * whole argument is that nothing is hostile until it is looked at, and
837
+ * health must not quietly take that away.
838
+ * - Six catches, not five and not seven, and the sixth restarts the LEVEL.
839
+ * - The restart takes the findings with it. A checkpoint that keeps the map
840
+ * is too forgiving to change how anybody plays.
841
+ * - Grace: one contact costs one hit, not one per frame of touching.
842
+ * - It shows on his body — the swing slows and the stance changes — and
843
+ * nowhere else. There is no bar.
844
+ */
845
+ function healthCosts() {
846
+ scene.reset();
847
+ const v = s.drained[0];
848
+
849
+ // 1. unlit, and standing right on top of it
850
+ s.prince.x = s.prince.prevX = v.x - 40;
851
+ s.camera.reset(s.prince.x);
852
+ drive(2);
853
+ const harmless = s.prince.hits === 0 && v.state === 'WANDER';
854
+
855
+ // 2. GRACE: one contact is one hit, not one per frame of touching.
856
+ //
857
+ // `v.takeHit` hurts the DRAINED — it is what wakes this one up. What hurts
858
+ // the prince is being in contact with it once it is rushing.
859
+ v.takeHit(s.prince.x);
860
+ let n = 0;
861
+ while (s.prince.hits === 0 && n < 60 * 8) { scene.update(1 / 60, { ...NONE }); n++; }
862
+ const first = s.prince.hits;
863
+ // Then pinned against it, well inside the grace window.
864
+ const held = Math.round((TUNING.HURT_GRACE_MS / 1000) * 60 * 0.6);
865
+ for (let i = 0; i < held; i++) {
866
+ s.prince.x = s.prince.prevX = v.x - 30;
867
+ scene.update(1 / 60, { ...NONE });
868
+ }
869
+ const oneContact = first === 1 && s.prince.hits === 1;
870
+
871
+ // 3. it shows on him
872
+ const fresh = TUNING.SWING_SLOW[0];
873
+ const worn = TUNING.SWING_SLOW[TUNING.SWING_SLOW.length - 1];
874
+ s.prince.hits = TUNING.LIMP_FROM;
875
+ s.prince.x = s.prince.prevX = BOWL.X0 + 200;
876
+ drive(0.5);
877
+ const stance = s.prince.clip?.name === 'prince_hurt';
878
+
879
+ // 4. six, and the level starts again with the findings gone
880
+ scene.reset();
881
+ s.houses[0].seen = true;
882
+ s.prince.inventory.push({ kept: 0, identity: 'a blacksmith', seen: true });
883
+ s.prince.x = s.prince.prevX = v.x - 30;
884
+ s.camera.reset(s.prince.x);
885
+ let caught = 0;
886
+ for (let k = 0; k < 14 && s.run.deaths === 0; k++) {
887
+ s.prince.x = s.prince.prevX = v.x - 30;
888
+ s.camera.reset(s.prince.x);
889
+ drive(1.5, { torch: true });
890
+ drive(2.2);
891
+ caught = Math.max(caught, s.prince.hits);
892
+ }
893
+ const died = {
894
+ tookSix: caught === TUNING.PRINCE_HITS,
895
+ deaths: s.run.deaths,
896
+ backToTheStart: s.prince.x < 1000,
897
+ lostTheMap: !s.houses[0].seen && s.prince.inventory.length === 0,
898
+ hitsReset: s.prince.hits === 0,
899
+ };
900
+
901
+ return {
902
+ name: 'health costs',
903
+ pass: harmless && oneContact && stance && worn > fresh * 1.5 &&
904
+ died.tookSix && died.deaths === 1 && died.backToTheStart &&
905
+ died.lostTheMap && died.hitsReset,
906
+ unlitIsStillHarmless: harmless,
907
+ graceHoldsForOneHit: oneContact,
908
+ holdsHisSide: stance,
909
+ swingSlowsBy: +(worn / fresh).toFixed(2),
910
+ died,
911
+ };
912
+ }
913
+
914
  function all() {
915
  const out = [figureContrast(), watcherContrast(), brightMass(), skyWeave(),
916
  bowlLayout(), bodiesPersist(), wholeLevelWalkable(), stillUntilLit(),
917
+ doorsRead(), mapRecords(), populationReads(), nothingOutOfReach(),
918
+ healthCosts(), theGate()];
919
  return { pass: out.every((r) => r.pass), results: out };
920
  }
921
 
922
  return { figureContrast, watcherContrast, brightMass, skyWeave,
923
  bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead,
924
+ mapRecords, populationReads, nothingOutOfReach, healthCosts, theGate,
925
  all, frameAt, frameAtX };
926
  }
src/drained.js CHANGED
@@ -257,8 +257,10 @@ export function createDrained(clips, spawnX, dir, keptIndex = 0, opts = {}) {
257
  };
258
 
259
  /**
260
- * Shoved off as he gets back up. Staggers it without counting a hit, so it
261
- * cannot simply walk back into him during the moment he needs to stand.
 
 
262
  */
263
  d.shoveOff = (fromX, push) => {
264
  if (!post) d.x += Math.sign(d.x - fromX || 1) * push;
 
257
  };
258
 
259
  /**
260
+ * Shoved off without counting a hit. Written for the old get-up-where-you-
261
+ * fell recovery, which death replaced with a level restart. Kept because it
262
+ * is the only way to move one of these without hurting it, and the ending
263
+ * will want that.
264
  */
265
  d.shoveOff = (fromX, push) => {
266
  if (!post) d.x += Math.sign(d.x - fromX || 1) * push;
src/render.js CHANGED
@@ -115,7 +115,7 @@ export function createRenderer(ctx) {
115
  // drew the Drained backwards every time it moved. The manifest says which way
116
  // each clip was authored.
117
  //
118
- // ART_HANDOFF.md used to promise nothing was mirrored in code. That stopped
119
  // being true the moment the prince got a facing and could be attacked from
120
  // either side. The pivot stays on the anchor through the flip.
121
  function drawClip(c, clip, index, mode, worldX, worldY, camX = 0, facing = 1) {
@@ -535,6 +535,13 @@ export function createRenderer(ctx) {
535
  const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
536
  const face = s.prince.facing;
537
  const torchUp = !s.prince.hasSword;
 
 
 
 
 
 
 
538
 
539
  ctx.setTransform(1, 0, 0, 1, 0, 0);
540
 
@@ -561,7 +568,7 @@ export function createRenderer(ctx) {
561
  drawClip(ctx, d.clip, d.frame, 'dark', dx2, GROUND_Y, camX, d.facing);
562
  drawKept(ctx, s.kept, d, 'dark', dx2, camX, s.clock);
563
  }
564
- drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, camX, face);
565
  drawMap(ctx, s, 'dark', px, camX, face);
566
  if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
567
  stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
@@ -608,7 +615,7 @@ export function createRenderer(ctx) {
608
  drawClip(octx, d.clip, d.frame, 'lit', dx2, GROUND_Y, camX, d.facing);
609
  drawKept(octx, s.kept, d, 'lit', dx2, camX, s.clock);
610
  }
611
- drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, camX, face);
612
  // He is never lit by his own torch. The SHEET is — it is the one thing
613
  // he carries that the light is for.
614
  drawMap(octx, s, 'lit', px, camX, face);
@@ -695,6 +702,14 @@ export function createRenderer(ctx) {
695
  ` village ${TUNING.BOWL_W} (${(TUNING.BOWL_W / W).toFixed(1)} screens, ` +
696
  `${(TUNING.BOWL_W / TUNING.WALK_SPEED).toFixed(0)}s across)` +
697
  ` shapes ${standing}/${s.village ? s.village.length : 0} standing, ${s.villageGap ?? 0}px apart`,
 
 
 
 
 
 
 
 
698
  `gate ${s.gate ? `${s.gate.filled}/${TUNING.GATE_WANTS}` : '—'}` +
699
  (s.gate?.open >= 1 ? ' OPEN' : ' shut') +
700
  (s.gate?.through ? ' — through' : '') +
 
115
  // drew the Drained backwards every time it moved. The manifest says which way
116
  // each clip was authored.
117
  //
118
+ // docs/ART_HANDOFF.md used to promise nothing was mirrored in code. That stopped
119
  // being true the moment the prince got a facing and could be attacked from
120
  // either side. The pivot stays on the anchor through the flip.
121
  function drawClip(c, clip, index, mode, worldX, worldY, camX = 0, facing = 1) {
 
535
  const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha);
536
  const face = s.prince.facing;
537
  const torchUp = !s.prince.hasSword;
538
+ // The weight going out of the bad leg. Half a limp is the rhythm, which the
539
+ // scene does by hurrying one half of the cycle; the other half is this —
540
+ // he drops a few px while that foot is down.
541
+ const hurt = Math.max(0, s.prince.hits - TUNING.LIMP_FROM);
542
+ const limpDip = (hurt > 0 && s.prince.moving &&
543
+ s.prince.frame >= s.prince.clip.count / 2)
544
+ ? Math.min(TUNING.LIMP_DIP_PX, TUNING.LIMP_DIP_PX * (hurt / 3)) : 0;
545
 
546
  ctx.setTransform(1, 0, 0, 1, 0, 0);
547
 
 
568
  drawClip(ctx, d.clip, d.frame, 'dark', dx2, GROUND_Y, camX, d.facing);
569
  drawKept(ctx, s.kept, d, 'dark', dx2, camX, s.clock);
570
  }
571
+ drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face);
572
  drawMap(ctx, s, 'dark', px, camX, face);
573
  if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
574
  stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
 
615
  drawClip(octx, d.clip, d.frame, 'lit', dx2, GROUND_Y, camX, d.facing);
616
  drawKept(octx, s.kept, d, 'lit', dx2, camX, s.clock);
617
  }
618
+ drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face);
619
  // He is never lit by his own torch. The SHEET is — it is the one thing
620
  // he carries that the light is for.
621
  drawMap(octx, s, 'lit', px, camX, face);
 
702
  ` village ${TUNING.BOWL_W} (${(TUNING.BOWL_W / W).toFixed(1)} screens, ` +
703
  `${(TUNING.BOWL_W / TUNING.WALK_SPEED).toFixed(0)}s across)` +
704
  ` shapes ${standing}/${s.village ? s.village.length : 0} standing, ${s.villageGap ?? 0}px apart`,
705
+ // NO HEALTH BAR ON SCREEN. This line is for judging the build, not for
706
+ // playing it — the player is meant to read it off how he is standing.
707
+ `hits ${s.prince.hits}/${TUNING.PRINCE_HITS}` +
708
+ (s.prince.hits >= TUNING.LIMP_FROM ? ' limping' : '') +
709
+ ` swing x${TUNING.SWING_SLOW[Math.min(s.prince.hits, TUNING.SWING_SLOW.length - 1)].toFixed(2)}` +
710
+ (s.run ? ` run ${s.run.seconds.toFixed(0)}s fights ${s.run.fights}` +
711
+ ` took ${s.run.taken} hit ${s.run.hitsTaken} deaths ${s.run.deaths}` +
712
+ ` WATCHING ${s.run.watching.toFixed(1)}s` : ''),
713
  `gate ${s.gate ? `${s.gate.filled}/${TUNING.GATE_WANTS}` : '—'}` +
714
  (s.gate?.open >= 1 ? ' OPEN' : ' shut') +
715
  (s.gate?.through ? ' — through' : '') +
src/scene_level.js CHANGED
@@ -26,6 +26,7 @@ export async function createLevelScene(clips, { audio = null } = {}) {
26
  const torchClip = clips.prince_torch;
27
  const guardClip = clips.prince_guard;
28
  const mapClip = clips.prince_map;
 
29
  const sheetClip = clips.map_sheet;
30
 
31
  // The hand the torch is held in, published by the manifest in sprite
@@ -66,6 +67,9 @@ export async function createLevelScene(clips, { audio = null } = {}) {
66
  torchPlayer: createPlayer(torchClip),
67
  guard: createPlayer(guardClip),
68
  mapPlayer: mapClip ? createPlayer(mapClip) : null,
 
 
 
69
  attacking: false,
70
  attackTime: 0,
71
  attackConnected: false,
@@ -385,9 +389,40 @@ export async function createLevelScene(clips, { audio = null } = {}) {
385
 
386
  // The loss beat. No text, no shake, no sting — the light going out is the
387
  // whole thing.
 
 
388
  const loss = { active: false, timer: 0, torchWasUp: false, x: 0, y: 0, vx: 0, r: 0, by: null };
389
  let grace = 0;
390
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
391
  // THE MAP. Not a screen — a sheet he holds up in his own torchlight. It
392
  // records and it never reasons: it knows what he has looked at and nothing
393
  // else, and the player never does a moment's bookkeeping.
@@ -398,6 +433,20 @@ export async function createLevelScene(clips, { audio = null } = {}) {
398
  // He has to look. That is the level.
399
  const gate = { filled: 0, open: 0, through: false };
400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  const map = {
402
  up: false,
403
  // How long the sheet has been held at full raise. Reading it is looking, so
@@ -412,7 +461,7 @@ export async function createLevelScene(clips, { audio = null } = {}) {
412
  audio,
413
  ground: clips.ground,
414
  kept: clips.kept,
415
- map, sheetClip, mapClip,
416
  houses: HOUSES, houseTint: PALETTE.WATCHERS,
417
  houseClip, houseArt, doorPoint,
418
  village, gateX: GATE_X, gateStopX: GATE_STOP_X,
@@ -428,6 +477,7 @@ export async function createLevelScene(clips, { audio = null } = {}) {
428
  prince.facing = 1;
429
  prince.hasSword = true;
430
  prince.moving = false;
 
431
  prince.run.reset();
432
  prince.idle.reset();
433
  torch.reset();
@@ -447,6 +497,9 @@ export async function createLevelScene(clips, { audio = null } = {}) {
447
  gate.open = 0;
448
  gate.through = false;
449
  state.fade = 0;
 
 
 
450
  lastRunFrame = -1;
451
  prince.inventory.length = 0;
452
  drained.forEach((d) => d.reset());
@@ -478,19 +531,13 @@ export async function createLevelScene(clips, { audio = null } = {}) {
478
  torch.cy = loss.y;
479
  torch.radius = loss.r;
480
  }
481
- if (loss.timer >= TUNING.LOSS_HOLD_MS + 900) {
482
- // He gets up where he fell. The level is not restarted — the world
483
- // remembers, which means the thing that got him is still right there.
484
- // So it is pushed clear and he has a moment before contact counts
485
- // again, otherwise it kills him on the frame he stands up.
486
- loss.active = false;
487
- loss.timer = 0;
488
- prince.down.reset();
489
- grace = TUNING.RECOVER_GRACE_MS;
490
- if (loss.by) loss.by.shoveOff(prince.x, TUNING.RECOVER_PUSH_PX);
491
- torch.reset();
492
- prince.attacking = false;
493
- }
494
  return;
495
  }
496
 
@@ -537,7 +584,10 @@ export async function createLevelScene(clips, { audio = null } = {}) {
537
  }
538
  }
539
  if (prince.attacking) {
540
- prince.attackTime += dt;
 
 
 
541
  const ms = prince.attackTime * 1000;
542
  const active = ms >= TUNING.ATTACK_WINDUP_MS &&
543
  ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS;
@@ -573,25 +623,16 @@ export async function createLevelScene(clips, { audio = null } = {}) {
573
  // Contact with a WANDER one is not a loss. He walks into someone standing
574
  // in the dark and nothing happens at all, which is worse.
575
  if (kind === 'shove' && grace <= 0) {
576
- // Knocked back out of his reach. He loses ground, not the run — and a
577
- // moment's grace, or the same thrust shoves him again next frame.
578
- prince.x = d.x + Math.sign(prince.x - d.x || -1) * (TUNING.POST_REACH + TUNING.POST_SHOVE_PX);
579
- prince.prevX = prince.x;
580
- grace = TUNING.RECOVER_GRACE_MS * 0.5;
581
- prince.attacking = false;
582
- audio?.bodyFall();
583
  }
584
  if (kind === 'fatal' && !loss.active) {
585
- loss.active = true;
586
- loss.timer = 0;
587
  loss.by = d;
588
- audio?.bodyFall();
589
- prince.down.reset();
590
- loss.torchWasUp = torch.raise > 0.01;
591
- loss.x = torch.handX;
592
- loss.y = torch.handY;
593
- loss.vx = prince.facing * 120;
594
- loss.r = torch.radius;
595
  }
596
  }
597
 
@@ -643,11 +684,21 @@ export async function createLevelScene(clips, { audio = null } = {}) {
643
  }
644
  if (wantsPickup && nearest && !prince.attacking) {
645
  nearest.keptTaken = true;
 
646
  prince.inventory.push({
647
  kept: nearest.kept, identity: nearest.identity, seen: nearest.keptSeen,
648
  });
649
  }
650
 
 
 
 
 
 
 
 
 
 
651
  // --- the gate -----------------------------------------------------------
652
  // It counts what he is CARRYING and has looked at, and it counts live: put
653
  // one down and a mark goes out again. The map keeps score and the score is
@@ -680,7 +731,21 @@ export async function createLevelScene(clips, { audio = null } = {}) {
680
  state.flame.frame = state.flame.player.index;
681
  } else if (prince.moving) {
682
  const rate = runClip.groundSpeed ? TUNING.WALK_SPEED / runClip.groundSpeed : 1;
683
- prince.run.advance(dt * rate);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  prince.clip = runClip;
685
  prince.frame = prince.run.index;
686
  // A footstep when the boot ARRIVES, not on a timer. runClip.footFrames is
@@ -689,6 +754,17 @@ export async function createLevelScene(clips, { audio = null } = {}) {
689
  if (runClip.footFrames?.includes(prince.frame)) audio?.footstep();
690
  lastRunFrame = prince.frame;
691
  }
 
 
 
 
 
 
 
 
 
 
 
692
  } else if (drained.some((d) => d.revealed && d.state !== DOWNED && Math.abs(d.x - prince.x) < W)) {
693
  // Once one has been seen and is coming, he stands ready.
694
  prince.guard.advance(dt);
 
26
  const torchClip = clips.prince_torch;
27
  const guardClip = clips.prince_guard;
28
  const mapClip = clips.prince_map;
29
+ const hurtClip = clips.prince_hurt;
30
  const sheetClip = clips.map_sheet;
31
 
32
  // The hand the torch is held in, published by the manifest in sprite
 
67
  torchPlayer: createPlayer(torchClip),
68
  guard: createPlayer(guardClip),
69
  mapPlayer: mapClip ? createPlayer(mapClip) : null,
70
+ hurtPlayer: hurtClip ? createPlayer(hurtClip) : null,
71
+ // Hits TAKEN, not left. There is no bar; it is read off his body.
72
+ hits: 0,
73
  attacking: false,
74
  attackTime: 0,
75
  attackConnected: false,
 
389
 
390
  // The loss beat. No text, no shake, no sting — the light going out is the
391
  // whole thing.
392
+ // The DEATH beat now, not the loss beat. Being caught costs a hit; the sixth
393
+ // one is this, and it restarts the level.
394
  const loss = { active: false, timer: 0, torchWasUp: false, x: 0, y: 0, vx: 0, r: 0, by: null };
395
  let grace = 0;
396
 
397
+ /**
398
+ * Caught. Knocked back, a moment to get clear, and one less.
399
+ *
400
+ * WANDER contact is still harmless — the whole level argues that nothing is
401
+ * hostile until it is looked at, and a man standing in the dark doing nothing
402
+ * has to stay a man standing in the dark doing nothing.
403
+ */
404
+ function takeHit(fromX, knock) {
405
+ if (grace > 0 || loss.active) return;
406
+ prince.hits++;
407
+ run.hitsTaken++;
408
+ prince.x += (Math.sign(prince.x - fromX) || -1) * knock;
409
+ prince.prevX = prince.x;
410
+ prince.attacking = false;
411
+ grace = TUNING.HURT_GRACE_MS;
412
+ audio?.hit();
413
+ if (prince.hits < TUNING.PRINCE_HITS) return;
414
+ // The sixth. The light goes out and the level starts again.
415
+ loss.active = true;
416
+ loss.timer = 0;
417
+ prince.down.reset();
418
+ loss.torchWasUp = torch.raise > 0.01;
419
+ loss.x = torch.handX;
420
+ loss.y = torch.handY;
421
+ loss.vx = prince.facing * 120;
422
+ loss.r = torch.radius;
423
+ audio?.bodyFall();
424
+ }
425
+
426
  // THE MAP. Not a screen — a sheet he holds up in his own torchlight. It
427
  // records and it never reasons: it knows what he has looked at and nothing
428
  // else, and the player never does a moment's bookkeeping.
 
433
  // He has to look. That is the level.
434
  const gate = { filled: 0, open: 0, through: false };
435
 
436
+ /**
437
+ * WHAT THIS BUILD IS FOR, counted.
438
+ *
439
+ * The question health is being added to answer is one thing: does he start
440
+ * standing still and looking at something before walking up to it? So the
441
+ * run keeps score of the only number that answers it — seconds spent with
442
+ * the torch up, standing still, with nothing hostile near enough to reach
443
+ * him. Watching from safety.
444
+ *
445
+ * Everything else here is context for that one figure.
446
+ */
447
+ const run = { seconds: 0, fights: 0, hitsTaken: 0, deaths: 0, watching: 0, taken: 0 };
448
+ const WATCH_CLEAR_PX = 600;
449
+
450
  const map = {
451
  up: false,
452
  // How long the sheet has been held at full raise. Reading it is looking, so
 
461
  audio,
462
  ground: clips.ground,
463
  kept: clips.kept,
464
+ map, sheetClip, mapClip, hurtClip, run,
465
  houses: HOUSES, houseTint: PALETTE.WATCHERS,
466
  houseClip, houseArt, doorPoint,
467
  village, gateX: GATE_X, gateStopX: GATE_STOP_X,
 
477
  prince.facing = 1;
478
  prince.hasSword = true;
479
  prince.moving = false;
480
+ prince.hits = 0;
481
  prince.run.reset();
482
  prince.idle.reset();
483
  torch.reset();
 
497
  gate.open = 0;
498
  gate.through = false;
499
  state.fade = 0;
500
+ // Deaths survive a restart. Everything else about the run does not — that
501
+ // is what "the findings are at stake" means.
502
+ run.seconds = 0; run.fights = 0; run.hitsTaken = 0; run.watching = 0; run.taken = 0;
503
  lastRunFrame = -1;
504
  prince.inventory.length = 0;
505
  drained.forEach((d) => d.reset());
 
531
  torch.cy = loss.y;
532
  torch.radius = loss.r;
533
  }
534
+ // THE LEVEL RESTARTS. He does not get up where he fell any more.
535
+ //
536
+ // The spec's checkpoint at the mouth keeps the map, and a checkpoint that
537
+ // keeps the findings is too forgiving to change how anybody plays. The
538
+ // findings have to be at stake or the fights are not a cost.
539
+ state.fade = Math.min(1, (loss.timer - TUNING.LOSS_HOLD_MS) / TUNING.DEATH_FADE_MS);
540
+ if (loss.timer >= TUNING.LOSS_HOLD_MS + TUNING.DEATH_FADE_MS) { run.deaths++; reset(); }
 
 
 
 
 
 
541
  return;
542
  }
543
 
 
584
  }
585
  }
586
  if (prince.attacking) {
587
+ // THE SWING SLOWS. A cost he feels in the one thing he was using to make
588
+ // the cost not matter.
589
+ const slow = TUNING.SWING_SLOW[Math.min(prince.hits, TUNING.SWING_SLOW.length - 1)];
590
+ prince.attackTime += dt / slow;
591
  const ms = prince.attackTime * 1000;
592
  const active = ms >= TUNING.ATTACK_WINDUP_MS &&
593
  ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS;
 
623
  // Contact with a WANDER one is not a loss. He walks into someone standing
624
  // in the dark and nothing happens at all, which is worse.
625
  if (kind === 'shove' && grace <= 0) {
626
+ // He loses ground AND one of his six. He was the only thing in the
627
+ // village that could not hurt you, which made the hard fight free.
628
+ loss.by = d;
629
+ const back = d.x + Math.sign(prince.x - d.x || -1) * (TUNING.POST_REACH + TUNING.POST_SHOVE_PX);
630
+ takeHit(d.x, 0);
631
+ if (!loss.active) { prince.x = back; prince.prevX = back; }
 
632
  }
633
  if (kind === 'fatal' && !loss.active) {
 
 
634
  loss.by = d;
635
+ takeHit(d.x, TUNING.HURT_KNOCK_PX);
 
 
 
 
 
 
636
  }
637
  }
638
 
 
684
  }
685
  if (wantsPickup && nearest && !prince.attacking) {
686
  nearest.keptTaken = true;
687
+ run.taken++;
688
  prince.inventory.push({
689
  kept: nearest.kept, identity: nearest.identity, seen: nearest.keptSeen,
690
  });
691
  }
692
 
693
+ // --- the score ----------------------------------------------------------
694
+ run.seconds += dt;
695
+ run.fights = drained.filter((d) => d.revealed).length;
696
+ if (torch.raise > 0.9 && !prince.moving) {
697
+ const near = drained.some((d) => d.state !== DOWNED &&
698
+ Math.abs(d.x - prince.x) < WATCH_CLEAR_PX);
699
+ if (!near) run.watching += dt;
700
+ }
701
+
702
  // --- the gate -----------------------------------------------------------
703
  // It counts what he is CARRYING and has looked at, and it counts live: put
704
  // one down and a mark goes out again. The map keeps score and the score is
 
731
  state.flame.frame = state.flame.player.index;
732
  } else if (prince.moving) {
733
  const rate = runClip.groundSpeed ? TUNING.WALK_SPEED / runClip.groundSpeed : 1;
734
+ // THE LIMP, AND IT IS HER OWN RUN FRAMES.
735
+ //
736
+ // A limp is an asymmetric rhythm, not a different drawing: the good foot
737
+ // carries longer and the bad one is hurried off. So the cycle is slowed
738
+ // through its second half and rushed through the first, by an amount that
739
+ // grows with the hits he has taken. Nothing was redrawn for it, and it
740
+ // reads because it is what a limp actually is.
741
+ const hurt = Math.max(0, prince.hits - TUNING.LIMP_FROM);
742
+ if (hurt > 0) {
743
+ const half = prince.run.index >= runClip.count / 2;
744
+ const hitch = 1 - Math.min(0.8, TUNING.LIMP_HITCH * (hurt / 3)) * (half ? 1 : -0.5);
745
+ prince.run.advance(dt * rate / hitch);
746
+ } else {
747
+ prince.run.advance(dt * rate);
748
+ }
749
  prince.clip = runClip;
750
  prince.frame = prince.run.index;
751
  // A footstep when the boot ARRIVES, not on a timer. runClip.footFrames is
 
754
  if (runClip.footFrames?.includes(prince.frame)) audio?.footstep();
755
  lastRunFrame = prince.frame;
756
  }
757
+ } else if (prince.hits >= TUNING.LIMP_FROM && hurtClip) {
758
+ // Standing still with something left in him, he holds his side. This is
759
+ // the whole health readout: no bar, no number, just how he is standing.
760
+ //
761
+ // Two stages. Which pair of frames he is in is how close he is — the
762
+ // first is hurt, the second is spent, and the switch is at the halfway
763
+ // point of what he can take.
764
+ prince.hurtPlayer.advance(dt);
765
+ const spent = prince.hits >= Math.ceil(TUNING.PRINCE_HITS / 2) + 1;
766
+ prince.clip = hurtClip;
767
+ prince.frame = (spent ? 2 : 0) + (prince.hurtPlayer.index % 2);
768
  } else if (drained.some((d) => d.revealed && d.state !== DOWNED && Math.abs(d.x - prince.x) < W)) {
769
  // Once one has been seen and is coming, he stands ready.
770
  prince.guard.advance(dt);
src/silhouette.js CHANGED
@@ -19,7 +19,7 @@
19
  // sealing: 2.43x, and it reads correctly — body solid, fingers separate, boots
20
  // intact.
21
  //
22
- // See FINDINGS.md for the full write-up.
23
 
24
  const INF = 1 << 28;
25
 
 
19
  // sealing: 2.43x, and it reads correctly — body solid, fingers separate, boots
20
  // intact.
21
  //
22
+ // See docs/FINDINGS.md for the full write-up.
23
 
24
  const INF = 1 << 28;
25
 
src/tuning.js CHANGED
@@ -257,17 +257,49 @@ export const TUNING = {
257
  ATTACK_ACTIVE_MS: 80,
258
  ATTACK_RECOVERY_MS: 220,
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  LOSS_HOLD_MS: 800,
 
 
 
 
261
 
262
- // Getting up after a loss. He comes back where he fell and the world
263
- // remembers — bodies still down, revealed ones still revealed. The thing that
264
- // got him is still standing there, so he needs a moment or it simply kills
265
- // him again on the frame he stands up.
266
- RECOVER_GRACE_MS: 1200,
267
- // 190 left a 98px gap against a 110px contact reach, so it grabbed him again
268
- // the instant the grace ran out. It also kept walking in during the grace,
269
- // which is why it is staggered as well as pushed.
270
- RECOVER_PUSH_PX: 420,
271
 
272
  // How close he has to be to take the object off the ground. The drop sits
273
  // 225px out from the body, and he stops about 170px short of it when he
@@ -408,7 +440,7 @@ export const TONE = {
408
 
409
  // --- the depth experiment ---------------------------------------------------
410
  // ALL OFF BY DEFAULT. Nothing here is a feature yet; see the spec and the table
411
- // in FINDINGS.md. Toggled from the address bar, and X cycles them one at a time
412
  // because judging these by eye needs fast A/B and typing five URLs is not fast.
413
  export const DEPTH = {
414
  OCC: 1, // near-layer density multiplier. 1 leaves it alone
 
257
  ATTACK_ACTIVE_MS: 80,
258
  ATTACK_RECOVERY_MS: 220,
259
 
260
+ // --- HEALTH, and the reason it is here ------------------------------------
261
+ //
262
+ // Village one played like a beat 'em up, and it played that way because
263
+ // beating up every Drained cost nothing: three hits, no health, no death. So
264
+ // the optimal play was to hit everything, and the game agreed.
265
+ //
266
+ // The spec's reasoning for leaving health out was wrong, and its author said
267
+ // so: the question was whether the SEARCH is tedious, and tedium measures
268
+ // fine at zero risk — but at zero risk there is no search at all, just a
269
+ // sweep. A sweep cannot be tedious because it is not looking for anything.
270
+ //
271
+ // SIX, not three. Three plus a ten-screen restart means one mistake costs
272
+ // fifteen minutes and people reload the page instead of playing on. Six makes
273
+ // a death a run of bad decisions rather than one bad moment.
274
+ //
275
+ // It does not come back. Six is six for the whole run, and a restart is the
276
+ // only thing that resets it.
277
+ PRINCE_HITS: 6,
278
+ HURT_GRACE_MS: 900, // caught once, then a moment to get clear
279
+ HURT_KNOCK_PX: 260,
280
+
281
+ // NO HEALTH BAR. It is read off his body, the same rule as everything else:
282
+ // he limps, he holds his side, and the swing slows.
283
+ //
284
+ // The limp is HER run frames on an uneven cadence with a dip on the bad side,
285
+ // not a new drawing. A limp is an asymmetric rhythm, so this is the truer
286
+ // version as well as the cheaper one.
287
+ LIMP_FROM: 2, // hits taken before the walk goes uneven
288
+ LIMP_HITCH: 0.42, // how much slower the bad foot's half of the cycle is
289
+ LIMP_DIP_PX: 7,
290
+ // The swing slows as he goes, by hits taken. A cost he can feel in the one
291
+ // thing he was using to make the cost not matter.
292
+ SWING_SLOW: [1, 1, 1.1, 1.24, 1.42, 1.66],
293
+
294
  LOSS_HOLD_MS: 800,
295
+ // Death restarts the LEVEL. The spec's checkpoint at the mouth keeps the map,
296
+ // and a checkpoint that keeps the findings is too forgiving to change how
297
+ // anybody plays. The findings have to be at stake.
298
+ DEATH_FADE_MS: 1600,
299
 
300
+ // RECOVER_GRACE_MS and RECOVER_PUSH_PX lived here and are gone. They were for
301
+ // getting up where he fell, which is not what happens any more: being caught
302
+ // costs one of six, and the sixth restarts the level. See PRINCE_HITS.
 
 
 
 
 
 
303
 
304
  // How close he has to be to take the object off the ground. The drop sits
305
  // 225px out from the body, and he stops about 170px short of it when he
 
440
 
441
  // --- the depth experiment ---------------------------------------------------
442
  // ALL OFF BY DEFAULT. Nothing here is a feature yet; see the spec and the table
443
+ // in docs/FINDINGS.md. Toggled from the address bar, and X cycles them one at a time
444
  // because judging these by eye needs fast A/B and typing five URLs is not fast.
445
  export const DEPTH = {
446
  OCC: 1, // near-layer density multiplier. 1 leaves it alone