system HF Staff commited on
Commit
6f7f7b5
·
verified ·
1 Parent(s): e9cfcb9

eb6e8a3: The page in his hand

Browse files

Published from cloudronin/astara@eb6e8a38ae57ea8cfc87ca5cfaa67c067ec17d87

Files changed (6) hide show
  1. index.html +8 -15
  2. src/checks.js +221 -10
  3. src/input.js +14 -21
  4. src/main.js +1 -1
  5. src/render.js +70 -5
  6. src/scene_level.js +88 -34
index.html CHANGED
@@ -57,20 +57,13 @@
57
  #pad .walkwheel { width:176px; height:176px; }
58
  #pad .walkwheel::before { inset:38px; }
59
 
60
- /* MARK, AND IT IS ON THIS SIDE ON PURPOSE.
61
- Filing a page needs PAGES held and TAKE pressed, and both of those live on
62
- the left wheel — one thumb cannot hold one and press the other. The right
63
- thumb is already over here turning pages with the arrows, so this is where
64
- the third hand would have to be.
65
-
66
- Twelve o'clock, the same slot TAKE has on the left, because they are the
67
- same kind of thing: the verb that depends on where he is standing. It is
68
- there only while there is something to file. */
69
- #pad #markbtn {
70
- opacity:0; pointer-events:none; transition:opacity .14s linear;
71
- color:rgba(255,220,180,.9); border-color:rgba(255,220,180,.55);
72
- }
73
- #pad #markbtn.live { opacity:1; pointer-events:auto; }
74
  /* Six o'clock, holding the wheel's shape open until there is a fourth thing
75
  to do with the right thumb. Solid and small — a dashed circle reads as
76
  something broken rather than something reserved. */
@@ -188,7 +181,7 @@
188
  </div>
189
  <div class="cluster right">
190
  <div class="wheel walkwheel">
191
- <button type="button" class="n" id="markbtn" data-act="pickup" aria-label="file this page under this house">MARK</button>
192
  <button type="button" class="w arrow" data-act="left" aria-label="walk left">&#9664;</button>
193
  <button type="button" class="e arrow" data-act="right" aria-label="walk right">&#9654;</button>
194
  <button type="button" class="s empty" tabindex="-1" aria-hidden="true"></button>
 
57
  #pad .walkwheel { width:176px; height:176px; }
58
  #pad .walkwheel::before { inset:38px; }
59
 
60
+ /* MARK WAS HERE AND IS GONE. It existed because pinning a page needed PAGES
61
+ held and TAKE pressed, and both of those live on the left wheel — one thumb
62
+ cannot hold one and press the other, so the act needed a button under the
63
+ other thumb. The top page of the pile is now what he is holding and TAKE
64
+ acts on it wherever he is standing, so there is no hold, no second button,
65
+ and nothing for this side to do but walk. The workaround outlived its
66
+ problem by one build. */
 
 
 
 
 
 
 
67
  /* Six o'clock, holding the wheel's shape open until there is a fourth thing
68
  to do with the right thumb. Solid and small — a dashed circle reads as
69
  something broken rather than something reserved. */
 
181
  </div>
182
  <div class="cluster right">
183
  <div class="wheel walkwheel">
184
+ <button type="button" class="n empty" tabindex="-1" aria-hidden="true"></button>
185
  <button type="button" class="w arrow" data-act="left" aria-label="walk left">&#9664;</button>
186
  <button type="button" class="e arrow" data-act="right" aria-label="walk right">&#9654;</button>
187
  <button type="button" class="s empty" tabindex="-1" aria-hidden="true"></button>
src/checks.js CHANGED
@@ -1177,6 +1177,166 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1177
  drive(0.15, extra);
1178
  }
1179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1180
  function walksHome() {
1181
  const carriers = armed();
1182
  const owner = carriers.find((d) => d.kept === 0); // the forge's
@@ -1607,16 +1767,39 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1607
  mark();
1608
  const needsADoor = filed().join(',') === before;
1609
 
1610
- // 6. AND NOT WITH THE SHEAF DOWN, and not from the map page. Both would be
1611
- // filing something he is not holding.
1612
- at(0);
1613
- drive(0.3, { pickup: true }); // sheaf down
1614
- const needsTheSheaf = filed().join(',') === before;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1615
  // A LETTER IS NOT A CLAIM. Only a torn page off a body can be filed — the
1616
  // letter is what the house already says about itself.
1617
  s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'letter');
1618
  at(0); mark();
1619
- const mapCannotBeFiled = s.sheaf.index < 0 || filed().join(',') === before;
1620
 
1621
  // 7. NO FEEDBACK, EVER. Nothing in the state says right or wrong, and an
1622
  // empty plot is not flagged. Four filed and one empty is the finding,
@@ -1634,17 +1817,20 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1634
 
1635
  return {
1636
  name: 'marking is a claim',
1637
- pass: nothingAutoFiles && needsTheLetterOff &&
1638
  afterFirst[0] === 2 && afterReplace[0] === 3 &&
1639
  afterMove[0] == null && afterMove[1] === 3 && onlyOnce &&
1640
- needsADoor && needsTheSheaf && mapCannotBeFiled && noVerdict &&
1641
  frozenAtTheGate,
1642
  nothingFilesItself: nothingAutoFiles,
1643
  andThereIsNoSlotUntilTheLetterIsOff: needsTheLetterOff,
1644
  theSamePressTakesTheLetterInstead: tookTheLetterInstead,
1645
  afterFirst, afterReplace, afterMove,
1646
  oneFragmentOneHouse: onlyOnce,
1647
- needsALitDoor: needsADoor, needsTheSheafUp: needsTheSheaf,
 
 
 
1648
  aLetterCannotBeFiled: mapCannotBeFiled,
1649
  noRightOrWrongAnywhere: noVerdict,
1650
  frozenAtTheGate,
@@ -1811,15 +1997,38 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
1811
  return frame();
1812
  });
1813
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1814
  return {
1815
  name: 'the keys are shown',
1816
  pass: withoutTheReadout > 1500 && withTheReadout > 1500 &&
1817
- whileReading < withoutTheReadout * 0.25,
 
1818
  inkWithTheReadoutOff: withoutTheReadout,
1819
  inkWithItOn: withTheReadout,
1820
  inkWhileReadingALetter: whileReading,
1821
  survivesDebugBeingOff: withoutTheReadout > 1500,
1822
  getsOutOfTheWayOfAPage: whileReading < withoutTheReadout * 0.25,
 
1823
  };
1824
  }
1825
 
@@ -2022,6 +2231,7 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
2022
  doorsRead(), pagesCost(), sheafPages(), markingIsAClaim(),
2023
  theRoomAsks(), aGlanceIsFree(), theLightGoesOut(), theKeysAreShown(), bandFits(),
2024
  dealObeysRules(), swingerIsBait(),
 
2025
  walksHome(), keyBehindTheDoor(), residueStaysLocked(),
2026
  nothingOutOfReach(), healthCosts(), theGate()];
2027
  return { pass: out.every((r) => r.pass), results: out };
@@ -2030,6 +2240,7 @@ export function createChecks({ renderer, scene, canvas, clips = {} }) {
2030
  return { figureContrast, watcherContrast, brightMass, skyWeave,
2031
  bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead,
2032
  pagesCost, sheafPages, markingIsAClaim, theRoomAsks, aGlanceIsFree, theLightGoesOut, theKeysAreShown, bandFits, dealObeysRules, swingerIsBait,
 
2033
  walksHome, keyBehindTheDoor, residueStaysLocked,
2034
  nothingOutOfReach, healthCosts, theGate,
2035
  all, frameAt, frameAtX };
 
1177
  drive(0.15, extra);
1178
  }
1179
 
1180
+ /**
1181
+ * THE TOP PAGE IS WHAT IS IN HIS HAND, and it stays there when the pile
1182
+ * goes away.
1183
+ *
1184
+ * The sheaf is for CHOOSING. Letting go of Q is putting the rest of the
1185
+ * papers back in your coat, not putting down the one you picked.
1186
+ */
1187
+ function topPageIsTheHand() {
1188
+ armed();
1189
+ const held = () => s.handPage;
1190
+
1191
+ // Page to a fragment and let go.
1192
+ s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'fragment');
1193
+ drive(0.6, { sheaf: true });
1194
+ const upIsFragment = held()?.kind === 'fragment';
1195
+ drive(0.6); // Q released
1196
+ const stillFragment = held()?.kind === 'fragment' && !s.sheaf.up;
1197
+
1198
+ // A letter is a different shape in the hand, and it can be on top too —
1199
+ // but one has to be in the pile first, and armed() only takes fragments.
1200
+ s.prince.x = s.prince.prevX = s.houses[2].x - 300;
1201
+ s.prince.facing = 1;
1202
+ s.camera.reset(s.prince.x);
1203
+ drive(1.4, { torch: true });
1204
+ drive(0.2, { pickup: true });
1205
+ drive(0.15);
1206
+ const li = s.sheaf.pages.findIndex((p) => p.kind === 'letter');
1207
+ s.sheaf.index = li;
1208
+ drive(0.6);
1209
+ const letterOnTop = held()?.kind === 'letter';
1210
+
1211
+ // AND TAKING SOMETHING PUTS IT ON TOP, which is the whole tutorial: knock
1212
+ // one down, take the scrap, walk to the door, press E. No Q anywhere.
1213
+ const c = s.drained.find((d) => d.kept != null && !d.keptTaken)
1214
+ ?? s.drained.find((d) => d.kept != null);
1215
+ const before = s.sheaf.pages.length;
1216
+ s.prince.x = s.prince.prevX = s.houses[0].x - 900;
1217
+ s.camera.reset(s.prince.x);
1218
+ s.sheaf.pages.push({ kind: 'fragment', kept: 3 });
1219
+ s.sheaf.index = s.sheaf.pages.length - 1;
1220
+ drive(0.3);
1221
+ const takingPutsItOnTop = s.sheaf.pages.length === before + 1 &&
1222
+ held()?.kept === 3;
1223
+
1224
+ // An empty pile is an empty hand.
1225
+ s.sheaf.pages.length = 0;
1226
+ s.sheaf.index = 0;
1227
+ drive(0.3);
1228
+ const emptyIsEmpty = held() == null;
1229
+
1230
+ return {
1231
+ name: 'the top page is the hand',
1232
+ pass: upIsFragment && stillFragment && letterOnTop &&
1233
+ takingPutsItOnTop && emptyIsEmpty && c != null,
1234
+ aFragmentSurvivesTheReleaseOfQ: stillFragment,
1235
+ aLetterCanBeOnTopToo: letterOnTop,
1236
+ takingPutsItOnTop, andNothingHeldWithAnEmptyPile: emptyIsEmpty,
1237
+ };
1238
+ }
1239
+
1240
+ /**
1241
+ * WHAT `E` DOES AT A DOOR, every row of it, and Q makes no difference to any
1242
+ * of them.
1243
+ *
1244
+ * One button, no modes. It is take-or-leave-the-thing-in-front-of-you, which
1245
+ * is what E has meant everywhere else in this game since the first object on
1246
+ * the ground.
1247
+ */
1248
+ function eAtTheDoor() {
1249
+ const rows = [];
1250
+ const put = (k) => {
1251
+ s.sheaf.index = k === null
1252
+ ? s.sheaf.pages.findIndex((p) => p.kind === 'letter')
1253
+ : s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === k);
1254
+ };
1255
+ const stand = (i, withQ) => {
1256
+ s.prince.x = s.prince.prevX = s.houses[i].x - 300;
1257
+ s.prince.facing = 1;
1258
+ s.camera.reset(s.prince.x);
1259
+ drive(0.6, withQ ? { sheaf: true } : {});
1260
+ };
1261
+
1262
+ for (const withQ of [false, true]) {
1263
+ armed();
1264
+ const extra = withQ ? { sheaf: true } : {};
1265
+ const press = () => { drive(0.2, { ...extra, pickup: true }); drive(0.15, extra); };
1266
+ // THE BAKEHOUSE, AND THE TWO FRAGMENTS ARE THE FORGE'S AND THE WELL
1267
+ // HOUSE'S. Both are wrong here, on purpose.
1268
+ //
1269
+ // The first pass at this used fragment 1 as "a different fragment" at
1270
+ // house 1 — which is the bakehouse's OWN fragment, so it was correct,
1271
+ // its owner walked home, the patch settled, and every row after it
1272
+ // measured the lock refusing rather than the thing it meant to measure.
1273
+ // The check was wrong and the game was right.
1274
+ const H = 1;
1275
+ const A = 0, B = 2;
1276
+ const h = s.houses[H];
1277
+
1278
+ // 1. A letter on the nail, anything on top -> takes the letter.
1279
+ put(0); stand(H, withQ);
1280
+ // the door has to have been looked at at least once for anything to be
1281
+ // offered, which is the latch, not a mode
1282
+ drive(1.4, { torch: true });
1283
+ stand(H, withQ);
1284
+ press();
1285
+ const tookLetter = h.letterTaken && h.filed == null;
1286
+
1287
+ // 2. A bare patch, a fragment on top -> pins it.
1288
+ put(A); stand(H, withQ); press();
1289
+ const pinned = h.filed === A;
1290
+
1291
+ // 3. A fragment in the patch, a DIFFERENT fragment on top -> swap.
1292
+ put(B); stand(H, withQ); press();
1293
+ const swapped = h.filed === B;
1294
+
1295
+ // 4. A fragment in the patch, the SAME one on top -> takes it back.
1296
+ put(B); stand(H, withQ); press();
1297
+ const tookBackSame = h.filed == null;
1298
+
1299
+ // 5. A fragment in the patch, NOT a fragment on top -> takes it back.
1300
+ put(A); stand(H, withQ); press(); // pin one again
1301
+ put(null); stand(H, withQ); press(); // a letter on top
1302
+ const tookBackWithLetter = h.filed == null;
1303
+
1304
+ // 6. A bare patch, not a fragment on top -> nothing.
1305
+ put(null); stand(H, withQ);
1306
+ const offeredWithLetter = s.canMark;
1307
+ press();
1308
+ const bareAndLetterDoesNothing = h.filed == null && !offeredWithLetter;
1309
+
1310
+ // 7. A settled patch -> nothing. The lock stands.
1311
+ const F = 0;
1312
+ const forge = s.houses[F];
1313
+ put(0); stand(F, withQ);
1314
+ drive(1.4, { torch: true });
1315
+ stand(F, withQ); press(); // letter off
1316
+ put(0); stand(F, withQ); press(); // the forge's own, so it settles
1317
+ const settled = forge.settled && forge.filed === 0;
1318
+ put(B); stand(F, withQ); press(); // try to swap it
1319
+ const lockHeld = forge.filed === 0 && settled;
1320
+
1321
+ rows.push({ withQ, tookLetter, pinned, swapped, tookBackSame,
1322
+ tookBackWithLetter, bareAndLetterDoesNothing, lockHeld });
1323
+ }
1324
+ scene.reset();
1325
+ const ok = rows.every((r) => Object.entries(r)
1326
+ .every(([k, v]) => k === 'withQ' || v === true));
1327
+ // AND Q CHANGES NOTHING. Every row, both ways, identical.
1328
+ const sameEitherWay = JSON.stringify({ ...rows[0], withQ: 0 }) ===
1329
+ JSON.stringify({ ...rows[1], withQ: 0 });
1330
+
1331
+ return {
1332
+ name: 'E at the door',
1333
+ pass: ok && sameEitherWay,
1334
+ everyRowHolds: ok,
1335
+ andTheSheafMakesNoDifference: sameEitherWay,
1336
+ rows,
1337
+ };
1338
+ }
1339
+
1340
  function walksHome() {
1341
  const carriers = armed();
1342
  const owner = carriers.find((d) => d.kept === 0); // the forge's
 
1767
  mark();
1768
  const needsADoor = filed().join(',') === before;
1769
 
1770
+ // 6. AND IT DOES NOT NEED THE SHEAF UP. THIS CLAIM IS INVERTED.
1771
+ //
1772
+ // It used to assert the opposite — that pinning with the sheaf down did
1773
+ // nothing — and that rule was played and it did not survive contact.
1774
+ // Three conditions and a mode: hold Q, page to the fragment, press E at
1775
+ // a door. Nobody does that with paper.
1776
+ //
1777
+ // The top page of the pile is what he is holding, and E acts on what he
1778
+ // is holding. So the same press with the sheaf DOWN has to do the same
1779
+ // thing the press with it up did, and this asserts exactly that.
1780
+ s.prince.x = s.prince.prevX = s.houses[0].x - 1500;
1781
+ s.sheaf.index = fragA;
1782
+ drive(0.4); // sheaf down, walking about
1783
+ const onTopWithItDown = s.page()?.kept === 2 && !s.sheaf.up;
1784
+ s.prince.x = s.prince.prevX = s.houses[0].x - 300;
1785
+ s.prince.facing = 1;
1786
+ s.camera.reset(s.prince.x);
1787
+ drive(0.5); // NO sheaf, NO torch
1788
+ const offeredWithItDown = s.canPin;
1789
+ tap(); // plain E
1790
+ const pinnedWithItDown = s.houses[0].filed === 2;
1791
+ // And taking it back down is the same press again.
1792
+ tap();
1793
+ const takenBackWithItDown = s.houses[0].filed == null;
1794
+ const noSheafNeeded = onTopWithItDown && offeredWithItDown &&
1795
+ pinnedWithItDown && takenBackWithItDown;
1796
+ // Put the world back where claim 7 expects it.
1797
+ const before2 = filed().join(',');
1798
  // A LETTER IS NOT A CLAIM. Only a torn page off a body can be filed — the
1799
  // letter is what the house already says about itself.
1800
  s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'letter');
1801
  at(0); mark();
1802
+ const mapCannotBeFiled = s.sheaf.index < 0 || filed().join(',') === before2;
1803
 
1804
  // 7. NO FEEDBACK, EVER. Nothing in the state says right or wrong, and an
1805
  // empty plot is not flagged. Four filed and one empty is the finding,
 
1817
 
1818
  return {
1819
  name: 'marking is a claim',
1820
+ pass: nothingAutoFiles && needsTheLetterOff && noSheafNeeded &&
1821
  afterFirst[0] === 2 && afterReplace[0] === 3 &&
1822
  afterMove[0] == null && afterMove[1] === 3 && onlyOnce &&
1823
+ needsADoor && mapCannotBeFiled && noVerdict &&
1824
  frozenAtTheGate,
1825
  nothingFilesItself: nothingAutoFiles,
1826
  andThereIsNoSlotUntilTheLetterIsOff: needsTheLetterOff,
1827
  theSamePressTakesTheLetterInstead: tookTheLetterInstead,
1828
  afterFirst, afterReplace, afterMove,
1829
  oneFragmentOneHouse: onlyOnce,
1830
+ needsALitDoor: needsADoor,
1831
+ andDoesNotNeedTheSheafUp: noSheafNeeded,
1832
+ withTheSheafDown: { onTop: onTopWithItDown, offered: offeredWithItDown,
1833
+ pinned: pinnedWithItDown, takenBack: takenBackWithItDown },
1834
  aLetterCannotBeFiled: mapCannotBeFiled,
1835
  noRightOrWrongAnywhere: noVerdict,
1836
  frozenAtTheGate,
 
1997
  return frame();
1998
  });
1999
 
2000
+ // 4. AND ON TOUCH THERE IS ONE CONTEXTUAL BUTTON, NOT TWO.
2001
+ //
2002
+ // MARK lived on the right wheel because pinning a page needed PAGES
2003
+ // held and TAKE pressed and one thumb cannot do both. The top page is
2004
+ // now what he is holding and TAKE acts on it, so the second button is
2005
+ // deleted. Asserted rather than trusted, because a stray button in the
2006
+ // markup is invisible on a desktop and is the whole control scheme on
2007
+ // a phone.
2008
+ const pad = document.getElementById('pad');
2009
+ const acts = pad
2010
+ ? [...pad.querySelectorAll('button[data-act]')].map((b) => b.dataset.act)
2011
+ : [];
2012
+ const touch = {
2013
+ noMarkButton: !document.getElementById('markbtn'),
2014
+ // Exactly one thing on the pad fires the contextual verb.
2015
+ onePickupButton: acts.filter((a) => a === 'pickup').length === 1,
2016
+ // And nothing is labelled with the verb that no longer exists.
2017
+ nothingSaysMark: !pad || !/\bMARK\b/.test(pad.textContent),
2018
+ acts,
2019
+ };
2020
+
2021
  return {
2022
  name: 'the keys are shown',
2023
  pass: withoutTheReadout > 1500 && withTheReadout > 1500 &&
2024
+ whileReading < withoutTheReadout * 0.25 &&
2025
+ touch.noMarkButton && touch.onePickupButton && touch.nothingSaysMark,
2026
  inkWithTheReadoutOff: withoutTheReadout,
2027
  inkWithItOn: withTheReadout,
2028
  inkWhileReadingALetter: whileReading,
2029
  survivesDebugBeingOff: withoutTheReadout > 1500,
2030
  getsOutOfTheWayOfAPage: whileReading < withoutTheReadout * 0.25,
2031
+ andOneContextualButtonOnTouch: touch,
2032
  };
2033
  }
2034
 
 
2231
  doorsRead(), pagesCost(), sheafPages(), markingIsAClaim(),
2232
  theRoomAsks(), aGlanceIsFree(), theLightGoesOut(), theKeysAreShown(), bandFits(),
2233
  dealObeysRules(), swingerIsBait(),
2234
+ topPageIsTheHand(), eAtTheDoor(),
2235
  walksHome(), keyBehindTheDoor(), residueStaysLocked(),
2236
  nothingOutOfReach(), healthCosts(), theGate()];
2237
  return { pass: out.every((r) => r.pass), results: out };
 
2240
  return { figureContrast, watcherContrast, brightMass, skyWeave,
2241
  bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead,
2242
  pagesCost, sheafPages, markingIsAClaim, theRoomAsks, aGlanceIsFree, theLightGoesOut, theKeysAreShown, bandFits, dealObeysRules, swingerIsBait,
2243
+ topPageIsTheHand, eAtTheDoor,
2244
  walksHome, keyBehindTheDoor, residueStaysLocked,
2245
  nothingOutOfReach, healthCosts, theGate,
2246
  all, frameAt, frameAtX };
src/input.js CHANGED
@@ -158,31 +158,24 @@ export function createInput(target, { onReset } = {}) {
158
  }
159
 
160
  /**
161
- * Lights TAKE only when there is something to do with it, and says which.
162
  *
163
- * It is one button doing two jobs — take the thing at his feet, or file the
164
- * page in his hand under the door he is standing at. A phone has no readout
165
- * worth reading, so the button itself has to carry the difference.
 
 
 
 
 
 
166
  */
167
  const takeBtn = pad?.querySelector('.wheel [data-act="pickup"]');
168
- // The second one, over by the walk arrows. Filing needs PAGES held and TAKE
169
- // pressed and both of those are on the left wheel, which is one thumb's worth
170
- // of buttons asking for two thumbs. This is the one the right thumb can reach
171
- // without letting go of anything.
172
- const markBtn = pad?.querySelector('#markbtn');
173
  let takeLive = false;
174
- const setTakeAvailable = (yes, label = 'TAKE') => {
175
- const marking = label === 'MARK';
176
- if (markBtn) markBtn.classList.toggle('live', yes && marking);
177
- if (!takeBtn) return;
178
- // ONE OF THEM AT A TIME. The wheel's button never changes what it says —
179
- // it is TAKE, and it goes dark while there is a MARK offered on the other
180
- // side. A dim MARK here and a bright MARK there is two buttons asking to
181
- // be pressed for one act.
182
- const wheelLive = yes && !marking;
183
- if (wheelLive === takeLive) return;
184
- takeLive = wheelLive;
185
- takeBtn.classList.toggle('live', wheelLive);
186
  };
187
 
188
  target.addEventListener('pointerdown', onPointerDown);
 
158
  }
159
 
160
  /**
161
+ * Lights TAKE only when there is something to do with it.
162
  *
163
+ * ONE BUTTON, ONE SLOT, NO LABEL CHANGE. It used to be two: TAKE on the left
164
+ * wheel and a second MARK button over by the walk arrows, because pinning a
165
+ * page needed PAGES held and TAKE pressed and one thumb cannot do both.
166
+ *
167
+ * The top page of the pile is now what he is holding, so TAKE acts on it
168
+ * wherever he stands and the second button is deleted. Whether this press
169
+ * takes a letter, pins a page, takes one back down, takes a key or picks
170
+ * something off the ground is decided by where he is standing — which is
171
+ * what TAKE has meant everywhere else in this game since the first object.
172
  */
173
  const takeBtn = pad?.querySelector('.wheel [data-act="pickup"]');
 
 
 
 
 
174
  let takeLive = false;
175
+ const setTakeAvailable = (yes) => {
176
+ if (!takeBtn || yes === takeLive) return;
177
+ takeLive = yes;
178
+ takeBtn.classList.toggle('live', yes);
 
 
 
 
 
 
 
 
179
  };
180
 
181
  target.addEventListener('pointerdown', onPointerDown);
src/main.js CHANGED
@@ -109,7 +109,7 @@ const loop = createLoop({
109
  // decides. This used to re-run the ground scan itself, which was two
110
  // places that had to agree about what is within reach.
111
  input.setTakeAvailable?.(st.canMark || st.canTakeLetter || st.canTakeKey ||
112
- st.canPlaceKey || st.canTake, st.canMark ? 'MARK' : 'TAKE');
113
  }
114
  },
115
  render: (alpha) => {
 
109
  // decides. This used to re-run the ground scan itself, which was two
110
  // places that had to agree about what is within reach.
111
  input.setTakeAvailable?.(st.canMark || st.canTakeLetter || st.canTakeKey ||
112
+ st.canPlaceKey || st.canTake);
113
  }
114
  },
115
  render: (alpha) => {
src/render.js CHANGED
@@ -365,6 +365,68 @@ export function createRenderer(ctx) {
365
  * No error state, no completion state, and an empty slot is never flagged.
366
  * The empty slot is the finding.
367
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  function drawSheet(c, s, mode, px, camX, face) {
369
  const sheet = s.sheetClip;
370
  const pose = s.prince.clip;
@@ -993,6 +1055,7 @@ export function createRenderer(ctx) {
993
  drawKept(ctx, s.kept, d, 'dark', dx2, camX, s.clock);
994
  }
995
  drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
 
996
  drawSheet(ctx, s, 'dark', px, camX, face);
997
  if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
998
  stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
@@ -1040,6 +1103,7 @@ export function createRenderer(ctx) {
1040
  drawKept(octx, s.kept, d, 'lit', dx2, camX, s.clock);
1041
  }
1042
  drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
 
1043
  // He is never lit by his own torch. The SHEET is — it is the one thing
1044
  // he carries that the light is for.
1045
  drawSheet(octx, s, 'lit', px, camX, face);
@@ -1178,11 +1242,12 @@ export function createRenderer(ctx) {
1178
  (s.prince.inventory.length
1179
  ? s.prince.inventory.map((it) => (it.seen ? it.answers : '?')).join(', ')
1180
  : '—') +
1181
- (s.canMark ? ' [E] MARK'
1182
- : s.canTakeLetter ? ' [E] take the letter'
1183
- : s.canPlaceKey ? ` [E] put a key in (${s.prince.keys} in hand)`
1184
- : s.canTakeKey ? ' [E] take the key'
1185
- : nearKept ? ' [E] take' : '') +
 
1186
  // ONLY WHEN IT IS NOT WORKING. Spelled out because a phone has no
1187
  // console and no backtick key — "running / playback" and still silent
1188
  // on an iPhone means the ringer switch, not the game. Once it is
 
365
  * No error state, no completion state, and an empty slot is never flagged.
366
  * The empty slot is the finding.
367
  */
368
+ /**
369
+ * THE PAGE IN HIS HAND, and it is the whole affordance.
370
+ *
371
+ * The top of the pile is what he is holding, and E at a door acts on it. A
372
+ * player who cannot see what they are holding has to raise the sheaf to find
373
+ * out, which is the mode this change exists to delete — so he holds it where
374
+ * you can see it, and the SHAPE says what E will do:
375
+ *
376
+ * a torn page a fragment. E at a bare patch pins it
377
+ * a whole page a letter. E at a patch takes back whatever is in it
378
+ * nothing nothing to pin
379
+ *
380
+ * BOTH PASSES, AND ALWAYS THE DARK ART. He is drawn as a silhouette even
381
+ * inside his own torchlight — a man cannot light himself — and a page in his
382
+ * hand is part of him. It reads for the same reason a carrier's fragment
383
+ * reads: the hand is at the edge of his outline, so the page breaks it.
384
+ *
385
+ * IT RIDES mapPivot, which is already measured on her standing drawing and
386
+ * is already the hand the sheaf is held in. Nothing new to place: when the
387
+ * sheaf goes up, the sheet covers this exact spot, which is why this hides.
388
+ *
389
+ * AND ONLY ON THE STANDING POSE. Her run frames carry no hand anchor, so he
390
+ * is empty-handed while moving and holding it the moment he stops — which is
391
+ * the moment it matters and the only moment E does anything.
392
+ */
393
+ function drawHeldPage(c, s, px, camX, face) {
394
+ if (s.sheaf?.up) return;
395
+ const pg = s.handPage;
396
+ if (!pg) return;
397
+ const pose = s.prince.clip;
398
+ if (!pose?.mapPivot) return;
399
+ const art = pg.kind === 'fragment' ? s.kept : pg.kind === 'letter' ? s.letterArt : null;
400
+ if (!art) return;
401
+ const idx = pg.kind === 'fragment' ? pg.kept : pg.house;
402
+ const frame = art.frames[idx];
403
+ if (!frame) return;
404
+
405
+ const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height };
406
+ // ONE SCALE FOR BOTH, AND THAT IS THE WHOLE DIFFERENCE BETWEEN THEM.
407
+ //
408
+ // Scaling each to the same height came out at 827 pixels of page for a
409
+ // fragment and 756 for a letter, differing by 139 — the tear, and nothing
410
+ // else. Which is measuring a 2.6px scribble at arm's length.
411
+ //
412
+ // The art already knows: the whole page's ink is 54 tall and a piece torn
413
+ // off it is 46. Fixing the scale to the page's own units keeps that, so a
414
+ // fragment is visibly the smaller thing. Divided by the LETTER's ink, so
415
+ // the full page is the one that comes out at the size that was chosen.
416
+ //
417
+ // Small either way. It is a page held down by the side, not one being read.
418
+ const k = 40 / ((s.letterArt?.ink?.h) || ink.h);
419
+ const w = ink.w * k;
420
+ const h = ink.h * k;
421
+ const ox = (pose.mapPivot[0] - pose.pivotX) * face;
422
+ const oy = pose.mapPivot[1] - pose.pivotY;
423
+ const sx = Math.round(px - camX + ox);
424
+ const sy = Math.round(GROUND_Y + oy);
425
+ const left = face >= 0 ? sx - w * 0.2 : sx - w * 0.8;
426
+ c.drawImage(frame.dark, left - ink.x * k, sy - h * 0.5 - ink.y * k,
427
+ art.width * k, art.height * k);
428
+ }
429
+
430
  function drawSheet(c, s, mode, px, camX, face) {
431
  const sheet = s.sheetClip;
432
  const pose = s.prince.clip;
 
1055
  drawKept(ctx, s.kept, d, 'dark', dx2, camX, s.clock);
1056
  }
1057
  drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
1058
+ drawHeldPage(ctx, s, px, camX, face);
1059
  drawSheet(ctx, s, 'dark', px, camX, face);
1060
  if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face);
1061
  stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px);
 
1103
  drawKept(octx, s.kept, d, 'lit', dx2, camX, s.clock);
1104
  }
1105
  drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim);
1106
+ drawHeldPage(octx, s, px, camX, face);
1107
  // He is never lit by his own torch. The SHEET is — it is the one thing
1108
  // he carries that the light is for.
1109
  drawSheet(octx, s, 'lit', px, camX, face);
 
1242
  (s.prince.inventory.length
1243
  ? s.prince.inventory.map((it) => (it.seen ? it.answers : '?')).join(', ')
1244
  : '—') +
1245
+ (s.canPin ? ' [E] PIN IT HERE'
1246
+ : s.canMark ? ' [E] take it back down'
1247
+ : s.canTakeLetter ? ' [E] take the letter'
1248
+ : s.canPlaceKey ? ` [E] put a key in (${s.prince.keys} in hand)`
1249
+ : s.canTakeKey ? ' [E] take the key'
1250
+ : nearKept ? ' [E] take' : '') +
1251
  // ONLY WHEN IT IS NOT WORKING. Spelled out because a phone has no
1252
  // console and no backtick key — "running / playback" and still silent
1253
  // on an iPhone means the ringer switch, not the game. Once it is
src/scene_level.js CHANGED
@@ -1049,12 +1049,15 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
1049
  // Lighting one is the only thing that says who lived here. It latches, the
1050
  // same way seeing an object on the ground latches, and for the same reason:
1051
  // he looked, and looking is not something he can un-do.
1052
- let lit = null;
 
 
 
 
1053
  let litLetter = null;
1054
  for (const h of HOUSES) {
1055
  const [dx, dy] = doorPoint(h);
1056
  if (!torch.covers(dx, dy)) continue;
1057
- lit = h;
1058
  h.seen = true;
1059
  // And the letter on its wall. Same latch as an object on the ground: he
1060
  // looked, and looking is not something he can un-do.
@@ -1163,7 +1166,38 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
1163
  const wantsPickup = pickupHeld && !prevPickup;
1164
  prevPickup = pickupHeld;
1165
  input.pickup = false;
1166
- const markingPage = sheaf.up && torch.raise > 0.9 ? page() : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1167
  // AND NOT BEFORE THE LETTER IS OFF THE NAIL.
1168
  //
1169
  // There is one spot beside each door and the letter is on it. Take the
@@ -1179,40 +1213,60 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
1179
  //
1180
  // AND NOT AT A DOOR THAT IS FINISHED WITH. Someone walked home to it; the
1181
  // patch has the only thing it was ever going to hold.
1182
- const canMark = !gate.through && !!lit && !lit.settled && lit.letterTaken &&
1183
- markingPage?.kind === 'fragment' && pageSeen(markingPage);
1184
- state.canMark = canMark;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1185
  // WHICH house it would go under. The sheet draws that house's plot beside
1186
- // the page when this is set, so the paper itself says "this one, here" —
1187
- // which is the only thing in the build that tells a player marking exists.
1188
- state.markHouse = canMark ? lit.index : null;
1189
- state.canTakeLetter = !canMark && !!litLetter;
1190
- // LETTER FIRST, THEN KEY. At an open door with the letter still on the
1191
- // wall both are offered by the same button, so they queue: press once for
1192
- // the reading, again for the key. Evidence before the thing that opens a
1193
- // gate, which is the order this whole game is arguing for.
1194
- state.canTakeKey = !canMark && !litLetter && !!litKey;
1195
  // AND PUTTING ONE IN. He stops 380px short of the leaf and can never get
1196
  // closer, so the reach is the door's, not the ground's.
1197
  state.canPlaceKey = !canMark && prince.keys > 0 && !gate.through &&
1198
  gate.placed < TUNING.GATE_WANTS &&
1199
  Math.abs(prince.x - GATE_X) <= TUNING.DOOR_REACH;
1200
- if (wantsPickup && canMark) {
1201
- if (lit.filed === markingPage.kept) {
1202
- // TAKE IT BACK OUT. He is standing at the door holding the page that
1203
- // is already in the frame, so the only thing left to do with it is
1204
- // change his mind. It never left the sheaf — the frame holds a claim,
1205
- // not the paper — so there is nothing to give back.
1206
- lit.filed = null;
 
 
1207
  audio?.mark?.();
1208
  } else {
1209
- // MOVE, THEN REPLACE. Filing a fragment somewhere else takes it off
1210
- // wherever it was, and filing a different one here pushes the old one
1211
- // out. Two fragments can never sit under one house and one fragment can
1212
- // never sit under two, and neither rule needs a message: it is what
1213
  // putting a piece of paper down on a table already does.
1214
- for (const h of HOUSES) if (h.filed === markingPage.kept) h.filed = null;
1215
- lit.filed = markingPage.kept;
1216
  audio?.mark?.();
1217
 
1218
  // AND IF IT WAS THEIRS, THEY GO HOME.
@@ -1222,11 +1276,11 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
1222
  // there is nowhere for it to drift to.
1223
  //
1224
  // NOTHING IS SAID. No tick, no sound of being right, no change to the
1225
- // frame. Somewhere out in the village a body gets up, and the only way
1226
  // to find out is that it walks up the street.
1227
- if (lit.index === markingPage.kept) {
1228
- const owner = drained.find((d) => d.kept === markingPage.kept && d.keptTaken);
1229
- if (owner) { lit.settled = true; owner.sendHome(lit.x); }
1230
  }
1231
  }
1232
  }
@@ -1252,7 +1306,7 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
1252
  // fragment, and this needs a letter still lying in a lit room. Ahead of the
1253
  // object at his feet because he is looking into the room, and because the
1254
  // clear pocket round every door keeps drops out of this spot anyway.
1255
- if (wantsPickup && !canMark && litLetter && !prince.attacking) {
1256
  litLetter.letterTaken = true;
1257
  filePage({ kind: 'letter', house: litLetter.index });
1258
  audio?.mark?.();
@@ -1270,7 +1324,7 @@ export async function createLevelScene(clips, { audio = null, seed = null, lette
1270
  litKey.keyTaken = true;
1271
  prince.keys++;
1272
  audio?.mark?.();
1273
- } else if (wantsPickup && !canMark && nearest && !prince.attacking) {
1274
  nearest.keptTaken = true;
1275
  run.taken++;
1276
  prince.inventory.push({
 
1049
  // Lighting one is the only thing that says who lived here. It latches, the
1050
  // same way seeing an object on the ground latches, and for the same reason:
1051
  // he looked, and looking is not something he can un-do.
1052
+ // IT LATCHES AND NOTHING READS IT THIS FRAME ANY MORE. There used to be a
1053
+ // `lit` house here, meaning "the torch is on this door right now", and it
1054
+ // was what marking tested. Marking now tests a LATCH plus a distance, the
1055
+ // same deal the letter and the key already had — so all this loop does is
1056
+ // remember that he looked.
1057
  let litLetter = null;
1058
  for (const h of HOUSES) {
1059
  const [dx, dy] = doorPoint(h);
1060
  if (!torch.covers(dx, dy)) continue;
 
1061
  h.seen = true;
1062
  // And the letter on its wall. Same latch as an object on the ground: he
1063
  // looked, and looking is not something he can un-do.
 
1166
  const wantsPickup = pickupHeld && !prevPickup;
1167
  prevPickup = pickupHeld;
1168
  input.pickup = false;
1169
+ // --- what is in his hand ------------------------------------------------
1170
+ //
1171
+ // THE TOP PAGE OF THE PILE IS WHAT HE IS HOLDING, and the pile does not
1172
+ // have to be up for him to be holding it.
1173
+ //
1174
+ // It used to. Pinning a fragment meant holding Q, paging to it, and
1175
+ // pressing E at a door — three conditions and a mode, and it was played and
1176
+ // nobody did it. Nobody does that with paper either: you take the page you
1177
+ // want, put the rest away, walk over, and pin it.
1178
+ //
1179
+ // So the sheaf is for CHOOSING and E is for DOING, and E never asks about
1180
+ // the sheaf. Q still raises the torch, still stops him, still shows the
1181
+ // band and pages with the arrows. It has just stopped being a prerequisite.
1182
+ const handPage = page();
1183
+ state.handPage = handPage;
1184
+
1185
+ // THE DOOR HE IS AT, AND IT IS LATCHED RATHER THAN LIT.
1186
+ //
1187
+ // `lit` above is whether the torch is ON a door this frame, and that was
1188
+ // the marking test — which worked only because holding the sheaf forced the
1189
+ // torch up. With the sheaf out of it, the torch is down and `lit` is null,
1190
+ // so the old test could never fire again.
1191
+ //
1192
+ // The letter and the key already solved this: seeing latches, and taking is
1193
+ // a separate act that only needs him to still be standing there. The patch
1194
+ // is the third thing on that wall and it gets the same deal.
1195
+ let nearDoor = null;
1196
+ for (const h of HOUSES) {
1197
+ if (!h.seen) continue;
1198
+ const p = letterPoint(h);
1199
+ if (p && Math.abs(prince.x - p[0]) <= TUNING.DOOR_REACH) { nearDoor = h; break; }
1200
+ }
1201
  // AND NOT BEFORE THE LETTER IS OFF THE NAIL.
1202
  //
1203
  // There is one spot beside each door and the letter is on it. Take the
 
1213
  //
1214
  // AND NOT AT A DOOR THAT IS FINISHED WITH. Someone walked home to it; the
1215
  // patch has the only thing it was ever going to hold.
1216
+ //
1217
+ // AND NOT AT A DOOR THAT IS FINISHED WITH. Someone walked home to it; the
1218
+ // patch has the only thing it was ever going to hold.
1219
+ const patchTakes = !!nearDoor && !gate.through && !nearDoor.settled &&
1220
+ nearDoor.letterTaken;
1221
+ const holding = handPage?.kind === 'fragment' ? handPage.kept : null;
1222
+
1223
+ // PIN, or TAKE BACK, and which one is decided by what is already there.
1224
+ //
1225
+ // THE SAME PAGE THAT IS ALREADY PINNED TAKES IT BACK. The table this comes
1226
+ // from does not have a row for it — it has "a different fragment evicts and
1227
+ // pins" and "not a fragment takes it back" — and holding the page that is
1228
+ // already on the wall is neither. Evicting it and pinning it again is a
1229
+ // press that does nothing, so it is the other one: he is holding the thing
1230
+ // in front of him, and E takes it down.
1231
+ const canRetrieve = patchTakes && nearDoor.filed != null &&
1232
+ (holding === null || holding === nearDoor.filed);
1233
+ const canPin = patchTakes && holding !== null && holding !== nearDoor.filed;
1234
+ const canMark = canPin || canRetrieve;
1235
+
1236
+ // LETTER FIRST, THEN THE PATCH, THEN THE KEY. An unread door hands over the
1237
+ // reading before it will accept a claim about it, and the thing that opens
1238
+ // a gate comes last. Evidence before conclusions before rewards, which is
1239
+ // the order this whole game is arguing for.
1240
+ state.canTakeLetter = !!litLetter;
1241
+ state.canMark = !litLetter && canMark;
1242
+ state.canPin = !litLetter && canPin;
1243
  // WHICH house it would go under. The sheet draws that house's plot beside
1244
+ // the page when this is set, so the paper itself says "this one, here".
1245
+ state.markHouse = state.canPin ? nearDoor.index : null;
1246
+ state.canTakeKey = !litLetter && !canMark && !!litKey;
 
 
 
 
 
 
1247
  // AND PUTTING ONE IN. He stops 380px short of the leaf and can never get
1248
  // closer, so the reach is the door's, not the ground's.
1249
  state.canPlaceKey = !canMark && prince.keys > 0 && !gate.through &&
1250
  gate.placed < TUNING.GATE_WANTS &&
1251
  Math.abs(prince.x - GATE_X) <= TUNING.DOOR_REACH;
1252
+ if (wantsPickup && state.canMark && !prince.attacking) {
1253
+ if (canRetrieve) {
1254
+ // TAKE IT BACK DOWN. Either he is holding the page that is already
1255
+ // pinned here, or he is holding nothing that could go in — and in both
1256
+ // cases the only thing on that wall he can act on is what is on it.
1257
+ //
1258
+ // It never left the sheaf. The patch holds a CLAIM, not the paper, so
1259
+ // there is nothing to hand back.
1260
+ nearDoor.filed = null;
1261
  audio?.mark?.();
1262
  } else {
1263
+ // MOVE, THEN REPLACE. Pinning a fragment somewhere else takes it off
1264
+ // wherever it was, and pinning a different one here pushes the old one
1265
+ // out. Two fragments can never sit in one patch and one fragment can
1266
+ // never sit in two, and neither rule needs a message: it is what
1267
  // putting a piece of paper down on a table already does.
1268
+ for (const h of HOUSES) if (h.filed === holding) h.filed = null;
1269
+ nearDoor.filed = holding;
1270
  audio?.mark?.();
1271
 
1272
  // AND IF IT WAS THEIRS, THEY GO HOME.
 
1276
  // there is nowhere for it to drift to.
1277
  //
1278
  // NOTHING IS SAID. No tick, no sound of being right, no change to the
1279
+ // patch. Somewhere out in the village a body gets up, and the only way
1280
  // to find out is that it walks up the street.
1281
+ if (nearDoor.index === holding) {
1282
+ const owner = drained.find((d) => d.kept === holding && d.keptTaken);
1283
+ if (owner) { nearDoor.settled = true; owner.sendHome(nearDoor.x); }
1284
  }
1285
  }
1286
  }
 
1306
  // fragment, and this needs a letter still lying in a lit room. Ahead of the
1307
  // object at his feet because he is looking into the room, and because the
1308
  // clear pocket round every door keeps drops out of this spot anyway.
1309
+ if (wantsPickup && state.canTakeLetter && !prince.attacking) {
1310
  litLetter.letterTaken = true;
1311
  filePage({ kind: 'letter', house: litLetter.index });
1312
  audio?.mark?.();
 
1324
  litKey.keyTaken = true;
1325
  prince.keys++;
1326
  audio?.mark?.();
1327
+ } else if (wantsPickup && !state.canMark && nearest && !prince.attacking) {
1328
  nearest.keptTaken = true;
1329
  run.taken++;
1330
  prince.inventory.push({