Spaces:
Running
Running
dd4a694: The key was invisible, and it is called an inventory
Browse filesPublished from cloudronin/astara@dd4a69487fdcd5dc93c68f73714aa19dc98d90a5
- index.html +1 -1
- src/checks.js +44 -2
- src/render.js +105 -14
- 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1386 |
const shot = (h) => {
|
| 1387 |
-
for (let i = 0; i < 3; i++) scene.update(1 / 60,
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
if (!art) return;
|
| 646 |
-
const i =
|
| 647 |
-
:
|
|
|
|
| 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(
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 ? `
|
| 1340 |
-
: ` [Q]
|
| 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
|
|
|
|
|
|
|
| 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;
|