system HF Staff commited on
Commit
f38d705
Β·
verified Β·
1 Parent(s): db17af1

dd4a694: The key was invisible, and it is called an inventory

Browse files

Published from cloudronin/astara@dd4a69487fdcd5dc93c68f73714aa19dc98d90a5

Files changed (4) hide show
  1. index.html +1 -1
  2. src/checks.js +44 -2
  3. src/render.js +105 -14
  4. src/scene_level.js +22 -1
index.html CHANGED
@@ -176,7 +176,7 @@
176
  <button type="button" class="n" data-act="pickup" aria-label="take the object">TAKE</button>
177
  <button type="button" class="e" data-act="attack" aria-label="swing the sword">SWING</button>
178
  <button type="button" class="s" data-act="torch" aria-label="hold to raise the torch">TORCH</button>
179
- <button type="button" class="w" data-act="sheaf" aria-label="hold to look through your pages">PAGES</button>
180
  </div>
181
  </div>
182
  <div class="cluster right">
 
176
  <button type="button" class="n" data-act="pickup" aria-label="take the object">TAKE</button>
177
  <button type="button" class="e" data-act="attack" aria-label="swing the sword">SWING</button>
178
  <button type="button" class="s" data-act="torch" aria-label="hold to raise the torch">TORCH</button>
179
+ <button type="button" class="w" data-act="sheaf" aria-label="hold to look through everything you are carrying">ITEMS</button>
180
  </div>
181
  </div>
182
  <div class="cluster right">
src/checks.js CHANGED
@@ -1382,9 +1382,14 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1382
  // fall, so the first version of this measured a wall nothing was drawn
1383
  // on: every comparison came back 0 except the one that accidentally
1384
  // caught the lightning.
1385
- const TORCH = { ...NONE, torch: true };
 
 
 
 
 
1386
  const shot = (h) => {
1387
- for (let i = 0; i < 3; i++) scene.update(1 / 60, TORCH);
1388
  s.clock = 3.0;
1389
  renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 });
1390
  return ctx.getImageData(...patch(h)).data;
@@ -1433,6 +1438,34 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1433
  const bare = brightness(shot(forge));
1434
  const noise = Math.abs(brightness(shot(forge)) - bare);
1435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1436
  // 2. A SCRAP ON TOP AND THE WALL SHOWS IT.
1437
  put(0);
1438
  const ghosted = brightness(shot(forge));
@@ -1449,6 +1482,12 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1449
  const pinned = brightness(shot(forge));
1450
  const ghostIsNotThePin = ghosted - pinned; // solid, so darker again
1451
 
 
 
 
 
 
 
1452
  // 4. AND IT IS OVER. Lightning, not a filter.
1453
  drive(1.2, { torch: true });
1454
  const gone = !(s.flash > 0) && (s.flashMs ?? -1) < 0;
@@ -1471,8 +1510,11 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1471
  pass: ghostShows > Math.max(8, noise * 3) &&
1472
  ghostIsNotThePin > Math.max(8, noise * 3) &&
1473
  wrongGhost > Math.max(8, noise * 3) &&
 
1474
  struck && quiet && gone,
1475
  flickerMovesTheMeanBy: noise,
 
 
1476
  theWallPreviewsTheScrap: ghostShows,
1477
  andItPreviewsAWrongOneToo: wrongGhost,
1478
  theGhostIsNotThePin: ghostIsNotThePin,
 
1382
  // fall, so the first version of this measured a wall nothing was drawn
1383
  // on: every comparison came back 0 except the one that accidentally
1384
  // caught the lightning.
1385
+ // NOT CALLED `TORCH`. It was, and it shadowed the TORCH this file
1386
+ // imports from tuning.js β€” so `TORCH.KEPT_PULSE_HZ` a few lines down was
1387
+ // undefined, the clock went NaN, and the renderer died trying to build a
1388
+ // gradient stop out of it. The same shadowing that once made the whole
1389
+ // module refuse to load, in a new place.
1390
+ const HOLDING = { ...NONE, torch: true };
1391
  const shot = (h) => {
1392
+ for (let i = 0; i < 3; i++) scene.update(1 / 60, HOLDING);
1393
  s.clock = 3.0;
1394
  renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 });
1395
  return ctx.getImageData(...patch(h)).data;
 
1438
  const bare = brightness(shot(forge));
1439
  const noise = Math.abs(brightness(shot(forge)) - bare);
1440
 
1441
+ // 1b. AND AN EMPTY PATCH BREATHES.
1442
+ //
1443
+ // The ghost below only helps somebody already standing here with the
1444
+ // right thing in hand. Nothing walked them over: they lit a door, saw
1445
+ // a slightly cleaner rectangle, and had no reason to think it was
1446
+ // anything β€” which is what actually happened to the first person who
1447
+ // played it. The breath is the same one a dropped scrap has and a key
1448
+ // has, at the same numbers, and it means what it has always meant.
1449
+ //
1450
+ // Measured over one full cycle of KEPT_PULSE_HZ, on a box wider than
1451
+ // the patch so the halo around it is counted too.
1452
+ const wide = (h) => {
1453
+ const [nx, ny, nw, nh] = clip.nailAt[h.variant];
1454
+ return [Math.round(h.x - s.camera.x - clip.pivotX + nx) - 26,
1455
+ Math.round(GROUND_Y - clip.pivotY + ny) - 26, nw + 52, nh + 52];
1456
+ };
1457
+ const breath = (h) => {
1458
+ const seen = [];
1459
+ for (let i = 0; i < 12; i++) {
1460
+ s.clock = 3.0 + (i / 12) / TORCH.KEPT_PULSE_HZ;
1461
+ renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 });
1462
+ seen.push(brightness(ctx.getImageData(...wide(h)).data));
1463
+ }
1464
+ s.clock = 3.0;
1465
+ return Math.round(Math.max(...seen) - Math.min(...seen));
1466
+ };
1467
+ const emptyBreathes = breath(forge);
1468
+
1469
  // 2. A SCRAP ON TOP AND THE WALL SHOWS IT.
1470
  put(0);
1471
  const ghosted = brightness(shot(forge));
 
1482
  const pinned = brightness(shot(forge));
1483
  const ghostIsNotThePin = ghosted - pinned; // solid, so darker again
1484
 
1485
+ // AND IT STOPS. An empty place asks its own question; a full one has
1486
+ // nothing left to ask. Same reason the gate's sockets never needed
1487
+ // explaining β€” and why the loom house goes on asking all level, with
1488
+ // nobody left to answer it.
1489
+ const filledIsStill = breath(forge);
1490
+
1491
  // 4. AND IT IS OVER. Lightning, not a filter.
1492
  drive(1.2, { torch: true });
1493
  const gone = !(s.flash > 0) && (s.flashMs ?? -1) < 0;
 
1510
  pass: ghostShows > Math.max(8, noise * 3) &&
1511
  ghostIsNotThePin > Math.max(8, noise * 3) &&
1512
  wrongGhost > Math.max(8, noise * 3) &&
1513
+ emptyBreathes > 8 && filledIsStill <= 2 &&
1514
  struck && quiet && gone,
1515
  flickerMovesTheMeanBy: noise,
1516
+ anEmptyPatchBreathesBy: emptyBreathes,
1517
+ andAFilledOneIsStill: filledIsStill,
1518
  theWallPreviewsTheScrap: ghostShows,
1519
  andItPreviewsAWrongOneToo: wrongGhost,
1520
  theGhostIsNotThePin: ghostIsNotThePin,
src/render.js CHANGED
@@ -280,7 +280,7 @@ export function createRenderer(ctx) {
280
  // only shows where the light actually falls.
281
  if (mode === 'lit') {
282
  c.drawImage(clip.frames[h.variant].lit, x, y);
283
- drawNail(c, s, clip, h, x, y);
284
  drawRoomKey(c, s, clip, h, x, y, s.clock ?? 0);
285
  } else c.drawImage(s.houseArt[h.variant], x, y);
286
  }
@@ -342,8 +342,28 @@ export function createRenderer(ctx) {
342
  const n = TUNING.GATE_WANTS;
343
  const row = gx - ((mw * n + mgap * (n - 1)) / 2);
344
  const my = leafTop + 70 - lift;
 
 
 
 
 
 
 
345
  for (let i = 0; i < TUNING.GATE_WANTS; i++) {
346
  const mx = row + i * (mw + mgap);
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  c.fillStyle = '#000';
348
  c.fillRect(mx, my, mw, mh);
349
  if (i >= filled) {
@@ -410,9 +430,11 @@ export function createRenderer(ctx) {
410
  if (!pg) return;
411
  const pose = s.prince.clip;
412
  if (!pose?.mapPivot) return;
413
- const art = pg.kind === 'fragment' ? s.kept : pg.kind === 'letter' ? s.letterArt : null;
 
 
414
  if (!art) return;
415
- const idx = pg.kind === 'fragment' ? pg.kept : pg.house;
416
  const frame = art.frames[idx];
417
  if (!frame) return;
418
 
@@ -635,16 +657,22 @@ export function createRenderer(ctx) {
635
  * every other act of looking in this game charges.
636
  */
637
  function drawSheetPage(c, s, sheet, x0, y0, pg) {
638
- const whole = pg.kind === 'letter';
639
  const known = s.pageSeen ? s.pageSeen(pg) : true;
640
  // A page he has not turned to is the SHAPE OF THE THING WITHOUT ITS
641
  // CONTENTS β€” the same contour with nothing written inside it. Drawn from
642
  // its own art rather than from the silhouette pass, which would put a solid
643
  // black page on the paper and read as a hole in the sheet.
644
- const art = known ? (whole ? s.letterArt : s.kept) : s.pageBlank;
 
 
 
 
 
645
  if (!art) return;
646
- const i = known ? (pg.house ?? pg.kept ?? 0)
647
- : (art.pageFace ? (whole ? art.pageFace.whole : art.pageFace.torn) : 0);
 
648
  const frame = art.frames[i];
649
  if (!frame) return;
650
  // FITTED BY ITS INK, NOT BY ITS CANVAS. The page art uses about half of its
@@ -787,7 +815,7 @@ export function createRenderer(ctx) {
787
  * LIT PASS ONLY, so all three live and die with the torch like everything
788
  * else in a doorway.
789
  */
790
- function drawNail(c, s, clip, house, x, y) {
791
  if (!clip.nailAt) return;
792
 
793
  // 1. THE LETTER, while it is still hanging there.
@@ -811,13 +839,51 @@ export function createRenderer(ctx) {
811
  // made it, because paper curls and the weather got in at the edges.
812
  const rect = clip.nailAt[house.variant];
813
  const [nx, ny, nw, nh] = rect;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
814
  c.fillStyle = PATCH;
815
- c.fillRect(Math.round(x + nx), Math.round(y + ny), nw, nh);
816
  // The nail itself, bare now. Four pixels of iron, and the only reason it is
817
  // drawn at all is that a patch with nothing at the top of it reads as a
818
  // stain rather than as a place something hung.
819
  c.fillStyle = '#000';
820
- c.fillRect(Math.round(x + nx + nw / 2) - 2, Math.round(y + ny) + 3, 4, 5);
821
 
822
  // 2b. AND IF HE IS STANDING HERE HOLDING A PAGE, THE WALL SHOWS WHERE IT
823
  // WOULD GO. A ghost of the exact page in his hand, faint, in the patch.
@@ -898,7 +964,32 @@ export function createRenderer(ctx) {
898
  const w = ink.w * k;
899
  const h = ink.h * k;
900
  const px = x + kx + (kw - w) / 2;
901
- const py = y + ky + (kh - h) / 2;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
902
 
903
  if (house.open) {
904
  // The same breath the dropped pages take, off the same tuning values, so
@@ -929,7 +1020,7 @@ export function createRenderer(ctx) {
929
  // standing, which is the whole point. A line naming a mode that no longer
930
  // exists is worse than no line.
931
  const HELP = 'A/D or arrows walk β€” hold SPACE torch β€” F swing β€” E take'
932
- + ' β€” hold Q pages, arrows scroll β€” M mute β€” R reset β€” ` readout';
933
  // Keyboards only. On a touch screen every one of these is a labelled button
934
  // already, and a line about SPACE and backtick is noise on a device with
935
  // neither.
@@ -1336,8 +1427,8 @@ export function createRenderer(ctx) {
1336
  (s.gate?.open >= 1 ? ' OPEN' : ' shut') +
1337
  (s.gate?.through ? ' β€” through' : '') +
1338
  ` doors lit: ${doors || 'β€”'}` +
1339
- (s.sheaf?.up ? ` PAGES UP ${s.sheaf.index + 1}/${s.sheaf.pages.length}`
1340
- : ` [Q] pages (${s.sheaf?.pages.length ?? 0})`),
1341
  `${face > 0 ? 'β†’' : '←'} ${s.prince.moving ? 'walking' : 'still'} sword ${s.prince.hasSword ? 'out' : 'away'}` +
1342
  ` torch ${(s.torch.raise * 100).toFixed(0)}% r ${radius.toFixed(1)}` +
1343
  ` ${s.prince.clip.name} f${s.prince.frame}` +
 
280
  // only shows where the light actually falls.
281
  if (mode === 'lit') {
282
  c.drawImage(clip.frames[h.variant].lit, x, y);
283
+ drawNail(c, s, clip, h, x, y, s.clock ?? 0);
284
  drawRoomKey(c, s, clip, h, x, y, s.clock ?? 0);
285
  } else c.drawImage(s.houseArt[h.variant], x, y);
286
  }
 
342
  const n = TUNING.GATE_WANTS;
343
  const row = gx - ((mw * n + mgap * (n - 1)) / 2);
344
  const my = leafTop + 70 - lift;
345
+ // AN EMPTY SOCKET BREATHES, the same as an empty patch on a door and for
346
+ // the same reason. Four holes in a leaf have always asked their own
347
+ // question β€” but only of somebody who already knows the answer is a key.
348
+ // The first person to reach this gate carrying four of them put one in by
349
+ // accident, pressing the button to see what it would do.
350
+ const sock = 1 - TORCH.KEPT_PULSE_DEPTH *
351
+ (0.5 - 0.5 * Math.cos((s.clock ?? 0) * Math.PI * 2 * TORCH.KEPT_PULSE_HZ));
352
  for (let i = 0; i < TUNING.GATE_WANTS; i++) {
353
  const mx = row + i * (mw + mgap);
354
+ if (i >= filled) {
355
+ const scx = mx + mw / 2;
356
+ const scy = my + mh / 2;
357
+ const sr = Math.max(mw, mh) * 0.95;
358
+ const sg = c.createRadialGradient(scx, scy, 0, scx, scy, sr);
359
+ sg.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * sock * 0.8).toFixed(3)})`);
360
+ sg.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`);
361
+ const wasOp = c.globalCompositeOperation;
362
+ c.globalCompositeOperation = 'lighter';
363
+ c.fillStyle = sg;
364
+ c.fillRect(scx - sr, scy - sr, sr * 2, sr * 2);
365
+ c.globalCompositeOperation = wasOp;
366
+ }
367
  c.fillStyle = '#000';
368
  c.fillRect(mx, my, mw, mh);
369
  if (i >= filled) {
 
430
  if (!pg) return;
431
  const pose = s.prince.clip;
432
  if (!pose?.mapPivot) return;
433
+ const art = pg.kind === 'fragment' ? s.kept
434
+ : pg.kind === 'letter' ? s.letterArt
435
+ : pg.kind === 'key' ? s.keyArt : null;
436
  if (!art) return;
437
+ const idx = pg.kind === 'fragment' ? pg.kept : pg.kind === 'key' ? 0 : pg.house;
438
  const frame = art.frames[idx];
439
  if (!frame) return;
440
 
 
657
  * every other act of looking in this game charges.
658
  */
659
  function drawSheetPage(c, s, sheet, x0, y0, pg) {
660
+ const whole = pg.kind === 'letter' || pg.kind === 'key';
661
  const known = s.pageSeen ? s.pageSeen(pg) : true;
662
  // A page he has not turned to is the SHAPE OF THE THING WITHOUT ITS
663
  // CONTENTS β€” the same contour with nothing written inside it. Drawn from
664
  // its own art rather than from the silhouette pass, which would put a solid
665
  // black page on the paper and read as a hole in the sheet.
666
+ // A KEY IS IN HERE TOO. It carries nothing to read and never will, but a
667
+ // thing you are carrying that you cannot look at is a thing you do not
668
+ // know you have β€” which is exactly what happened: somebody collected four
669
+ // and only found out at the gate, by pressing the button to see.
670
+ const art = pg.kind === 'key' ? s.keyArt
671
+ : known ? (whole ? s.letterArt : s.kept) : s.pageBlank;
672
  if (!art) return;
673
+ const i = pg.kind === 'key' ? 0
674
+ : known ? (pg.house ?? pg.kept ?? 0)
675
+ : (art.pageFace ? (whole ? art.pageFace.whole : art.pageFace.torn) : 0);
676
  const frame = art.frames[i];
677
  if (!frame) return;
678
  // FITTED BY ITS INK, NOT BY ITS CANVAS. The page art uses about half of its
 
815
  * LIT PASS ONLY, so all three live and die with the torch like everything
816
  * else in a doorway.
817
  */
818
+ function drawNail(c, s, clip, house, x, y, clock) {
819
  if (!clip.nailAt) return;
820
 
821
  // 1. THE LETTER, while it is still hanging there.
 
839
  // made it, because paper curls and the weather got in at the edges.
840
  const rect = clip.nailAt[house.variant];
841
  const [nx, ny, nw, nh] = rect;
842
+ const px0 = Math.round(x + nx);
843
+ const py0 = Math.round(y + ny);
844
+
845
+ // 2a. AND WHILE IT IS EMPTY IT BREATHES.
846
+ //
847
+ // The ghost below only helps a player who is ALREADY standing here with
848
+ // the right thing in his hand. Nothing walked him over in the first place:
849
+ // he lit a door, saw a slightly cleaner rectangle of wall, and had no
850
+ // reason to think it was anything. That is what actually happened to the
851
+ // first person who played it.
852
+ //
853
+ // THE GAME ALREADY HAS A WORD FOR THIS and it is not a new one. A scrap
854
+ // lying in the dark breathes; so does a key once its door is open. Same
855
+ // numbers, same rhythm, same meaning β€” something here is yours to do
856
+ // something with. A new kind of highlight would have been UI; this is the
857
+ // sentence the level has been speaking since the first object on the
858
+ // ground.
859
+ //
860
+ // IT STOPS THE MOMENT SOMETHING IS IN IT. An empty place asks its own
861
+ // question, which is the same reason four empty sockets on the gate have
862
+ // never needed explaining β€” and it is why the loom house goes on asking
863
+ // for the whole level, with nobody left to answer it.
864
+ if (house.filed == null) {
865
+ const pulse = 1 - TORCH.KEPT_PULSE_DEPTH *
866
+ (0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ));
867
+ const cx = px0 + nw / 2;
868
+ const cy = py0 + nh / 2;
869
+ const r = Math.max(nw, nh) * TORCH.KEPT_GLOW_R;
870
+ const g = c.createRadialGradient(cx, cy, 0, cx, cy, r);
871
+ g.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * pulse).toFixed(3)})`);
872
+ g.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`);
873
+ const was = c.globalCompositeOperation;
874
+ c.globalCompositeOperation = 'lighter';
875
+ c.fillStyle = g;
876
+ c.fillRect(cx - r, cy - r, r * 2, r * 2);
877
+ c.globalCompositeOperation = was;
878
+ }
879
+
880
  c.fillStyle = PATCH;
881
+ c.fillRect(px0, py0, nw, nh);
882
  // The nail itself, bare now. Four pixels of iron, and the only reason it is
883
  // drawn at all is that a patch with nothing at the top of it reads as a
884
  // stain rather than as a place something hung.
885
  c.fillStyle = '#000';
886
+ c.fillRect(Math.round(px0 + nw / 2) - 2, py0 + 3, 4, 5);
887
 
888
  // 2b. AND IF HE IS STANDING HERE HOLDING A PAGE, THE WALL SHOWS WHERE IT
889
  // WOULD GO. A ghost of the exact page in his hand, faint, in the patch.
 
964
  const w = ink.w * k;
965
  const h = ink.h * k;
966
  const px = x + kx + (kw - w) / 2;
967
+
968
+ // IT HANGS ON ITS OWN PLATE OF LIGHT, and that is not decoration.
969
+ //
970
+ // The key is black line work hanging inside the doorway β€” where the anvil
971
+ // and the oven and the buckets and the loom also are, and those are black
972
+ // too. Measured: 48% of the key is drawn over the anvil, 87% over a bucket,
973
+ // 96% over the oven dome and over the loom. A black key on a black oven is
974
+ // not a dim key, it is no key, and the first people to play this never saw
975
+ // one at all.
976
+ //
977
+ // THE LETTER SOLVED THIS ALREADY: it paints its own patch of doorway light
978
+ // before drawing the page, "because on a dark wall it came out
979
+ // dark-on-dark". Same fix, same value. The key is now always a black shape
980
+ // on light, whatever it is hanging in front of.
981
+ //
982
+ // AND IT SWINGS WHEN IT IS YOURS. A warm halo is this game's word for "you
983
+ // can pick this up", but a warm halo on an already-bright doorway barely
984
+ // moves the picture β€” the same mistake as a white bolt on a white sky. What
985
+ // reads against any background is motion.
986
+ const swing = house.open
987
+ ? Math.sin(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ) * 4
988
+ : 0;
989
+ const py = y + ky + (kh - h) / 2 + swing;
990
+ c.fillStyle = DOORLIGHT;
991
+ c.fillRect(Math.round(px - 7), Math.round(py - 7),
992
+ Math.round(w + 14), Math.round(h + 14));
993
 
994
  if (house.open) {
995
  // The same breath the dropped pages take, off the same tuning values, so
 
1020
  // standing, which is the whole point. A line naming a mode that no longer
1021
  // exists is worse than no line.
1022
  const HELP = 'A/D or arrows walk β€” hold SPACE torch β€” F swing β€” E take'
1023
+ + ' β€” hold Q inventory, arrows scroll β€” M mute β€” R reset β€” ` readout';
1024
  // Keyboards only. On a touch screen every one of these is a labelled button
1025
  // already, and a line about SPACE and backtick is noise on a device with
1026
  // neither.
 
1427
  (s.gate?.open >= 1 ? ' OPEN' : ' shut') +
1428
  (s.gate?.through ? ' β€” through' : '') +
1429
  ` doors lit: ${doors || 'β€”'}` +
1430
+ (s.sheaf?.up ? ` INVENTORY ${s.sheaf.index + 1}/${s.sheaf.pages.length}`
1431
+ : ` [Q] inventory (${s.sheaf?.pages.length ?? 0})`),
1432
  `${face > 0 ? 'β†’' : '←'} ${s.prince.moving ? 'walking' : 'still'} sword ${s.prince.hasSword ? 'out' : 'away'}` +
1433
  ` torch ${(s.torch.raise * 100).toFixed(0)}% r ${radius.toFixed(1)}` +
1434
  ` ${s.prince.clip.name} f${s.prince.frame}` +
src/scene_level.js CHANGED
@@ -666,6 +666,8 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
666
  * the whole gate at the end of the level turns on that difference.
667
  */
668
  const pageSeen = (pg) => {
 
 
669
  if (!pg || pg.kind !== 'fragment') return true;
670
  return !!prince.inventory.find((it) => it.kept === pg.kept)?.seen;
671
  };
@@ -680,7 +682,9 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
680
  if (!letters || !pg) return null;
681
  const h = letters.byHouse?.[pg.house ?? pg.kept];
682
  if (!h) return null;
683
- // A MAP HAS NOTHING TO READ ON IT.
 
 
684
  return pg.kind === 'letter' ? h.letter : pg.kind === 'fragment' ? h.fragment : null;
685
  };
686
 
@@ -1337,12 +1341,29 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
1337
  // fifth house whose key is still hanging behind a door that never opened.
1338
  prince.keys--;
1339
  gate.placed++;
 
 
 
 
 
 
1340
  audio?.mark?.();
1341
  } else if (wantsPickup && state.canTakeKey && !prince.attacking) {
1342
  // Off the hook and into his pocket. No page, no band, no entry in the
1343
  // sheaf: there is nothing to read on a key and nothing to work out.
1344
  litKey.keyTaken = true;
1345
  prince.keys++;
 
 
 
 
 
 
 
 
 
 
 
1346
  audio?.mark?.();
1347
  } else if (wantsPickup && !state.canMark && nearest && !prince.attacking) {
1348
  nearest.keptTaken = true;
 
666
  * the whole gate at the end of the level turns on that difference.
667
  */
668
  const pageSeen = (pg) => {
669
+ // A letter was read off a lit door and a key has nothing written on it, so
670
+ // only a torn page can be a thing he owns without having looked at it.
671
  if (!pg || pg.kind !== 'fragment') return true;
672
  return !!prince.inventory.find((it) => it.kept === pg.kept)?.seen;
673
  };
 
682
  if (!letters || !pg) return null;
683
  const h = letters.byHouse?.[pg.house ?? pg.kept];
684
  if (!h) return null;
685
+ // A KEY HAS NOTHING WRITTEN ON IT, so the band stays empty on one β€” which
686
+ // is the difference between a key and a letter, shown rather than said.
687
+ // (This used to say "a map", back when page zero was one.)
688
  return pg.kind === 'letter' ? h.letter : pg.kind === 'fragment' ? h.fragment : null;
689
  };
690
 
 
1341
  // fifth house whose key is still hanging behind a door that never opened.
1342
  prince.keys--;
1343
  gate.placed++;
1344
+ // Out of the inventory too, or he is holding a key that is in the gate.
1345
+ // The last one found, so the pile behaves like a pile.
1346
+ for (let i = sheaf.pages.length - 1; i >= 0; i--) {
1347
+ if (sheaf.pages[i].kind === 'key') { sheaf.pages.splice(i, 1); break; }
1348
+ }
1349
+ sheaf.index = Math.max(0, Math.min(sheaf.index, sheaf.pages.length - 1));
1350
  audio?.mark?.();
1351
  } else if (wantsPickup && state.canTakeKey && !prince.attacking) {
1352
  // Off the hook and into his pocket. No page, no band, no entry in the
1353
  // sheaf: there is nothing to read on a key and nothing to work out.
1354
  litKey.keyTaken = true;
1355
  prince.keys++;
1356
+ // AND IT GOES IN WITH EVERYTHING ELSE HE IS CARRYING.
1357
+ //
1358
+ // Keys were a number and nothing else β€” deliberately, because "a key is
1359
+ // not evidence, it does not go in the sheaf, there is nothing to work out
1360
+ // about one". All true, and it made them invisible: somebody collected
1361
+ // four and did not know until they put one in the gate by accident. A
1362
+ // thing you carry that you cannot look at is a thing you do not have.
1363
+ //
1364
+ // Nothing to read on it, so the band stays empty on a key page. That is
1365
+ // the difference between a key and a letter, said by showing you both.
1366
+ filePage({ kind: 'key' });
1367
  audio?.mark?.();
1368
  } else if (wantsPickup && !state.canMark && nearest && !prince.attacking) {
1369
  nearest.keptTaken = true;