Spaces:
Running
Running
Download src/checks.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 188 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/checks.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/checks.js
-
curl -L -o checks.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/checks.js
188 kB
| // The standing tests, as code rather than as scripts pasted into a console. | |
| // | |
| // They were ad hoc for six gates and that is how the worst measurement bug in | |
| // this project survived: the debug readout — antialiased black text on a bright | |
| // sky — was being counted as scene. It sits in the forbidden zone by | |
| // construction, it covers enough of the frame to swamp what it measures, and it | |
| // gets LONGER when a cue is switched on because it prints that cue's name. So | |
| // every cue was charged for its own label, and a baseline of 0.78% was really | |
| // 0.199%. Every one of these turns the overlay off and puts it back. | |
| // | |
| // WHY THE OLD GLOBAL TEST IS GONE. It histogrammed the whole frame and asserted | |
| // under 2% between luminance 30 and 90. That was always a PROXY for the real | |
| // question — does a black figure have something clearly lighter behind it? With | |
| // continuous tone allowed in the background the proxy stops working, so the two | |
| // tests below ask the real question directly, in the two places it matters. | |
| import { TUNING, BOWL, TORCH, REVEAL_T } from './tuning.js'; | |
| import { HOUSE_OF, laneLook, figureMidY } from './drained.js'; | |
| import { BAND, linesNeeded, bandBox } from './band.js'; | |
| import { loudness } from './loudness.js'; | |
| import { GROUND_Y, W, H } from './scene.js'; | |
| /** The band a standing figure occupies. */ | |
| const FIGURE_BAND = 380; | |
| /** | |
| * Horizontally, only where a figure can actually BE. | |
| * | |
| * The camera has a dead zone across the middle third, so the prince is never | |
| * outside it — see camera.js. Measuring the full 1920 was harmless while the | |
| * background was evenly lit, and became wrong the moment the vignette went in: | |
| * it charged the frame for darkening corners the player's character can never | |
| * stand in. Taken from the camera's own constant so the two cannot drift. | |
| */ | |
| const deadZone = () => { | |
| const half = (W * TUNING.CAMERA_DEAD_ZONE) / 2; | |
| return { x0: Math.round(W / 2 - half), w: Math.round(half * 2) }; | |
| }; | |
| /** Below this is the black mass — actors, trunks, ground — not background. */ | |
| const BLACK = 12; | |
| // Floors measured off the build that passed L2R through L6, not picked. | |
| // Figure band p10 reads 97 all along the walk; watcher-to-hills is 71 levels. | |
| const FLOOR_FIGURE = 90; | |
| const FLOOR_WATCHER_GAP = 55; | |
| const FLOOR_BRIGHT_START = 25; | |
| /** | |
| * Where the frame gets sampled, named rather than numbered. | |
| * | |
| * The last three are inside the village, which is past the end of the road — | |
| * and they are the point of this table existing. depthAt returns 1 out there, | |
| * so without the clearing the bowl is a fully closed canopy and a figure in it | |
| * has nothing behind it to be a silhouette against. The whole matching skill | |
| * the village is built to test dies quietly if these three fail. | |
| */ | |
| const ROAD_AND_BOWL = [ | |
| { at: 'road 5%', x: TUNING.LEVEL_LENGTH_PX * 0.05 }, | |
| { at: 'road 20%', x: TUNING.LEVEL_LENGTH_PX * 0.20 }, | |
| { at: 'road 35%', x: TUNING.LEVEL_LENGTH_PX * 0.35 }, | |
| { at: 'road 62%', x: TUNING.LEVEL_LENGTH_PX * 0.62 }, | |
| { at: 'road 90%', x: TUNING.LEVEL_LENGTH_PX * 0.90 }, | |
| { at: 'bowl mouth', x: BOWL.X0 + TUNING.BOWL_W * 0.10 }, | |
| { at: 'bowl mid', x: BOWL.X0 + TUNING.BOWL_W * 0.50 }, | |
| { at: 'bowl gate', x: BOWL.X0 + TUNING.BOWL_W * 0.88 }, | |
| ]; | |
| export function createChecks({ renderer, scene, canvas, clips = {}, audio = null }) { | |
| const s = scene.state; | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const none = { torch: false, attack: false, pickup: false, sheaf: false, left: false, right: false }; | |
| /** Put the world at one spot, settled and deterministic, and draw it once. */ | |
| function frameAt(frac, torch = false) { | |
| return frameAtX(TUNING.LEVEL_LENGTH_PX * frac, torch); | |
| } | |
| /** | |
| * The same, by world x rather than by fraction of the road. | |
| * | |
| * The village lives PAST the end of the road, so every fraction inside it is | |
| * greater than one and reads as nonsense in a results table. Sampling it is | |
| * not optional: depthAt saturates at 1 out there and the clearing is the only | |
| * reason a silhouette has anything behind it. | |
| */ | |
| function frameAtX(worldX, torch = false) { | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = worldX; | |
| // reset() ALREADY subtracts half the viewport — see camera.js. Passing | |
| // `prince.x - W/2` parks him on the dead-zone edge at 1280 instead of the | |
| // centre at 960, and camera.follow then tugs him back just enough that the | |
| // framing looks plausible. Every staged frame this session was off-centre | |
| // by 320px because of it. | |
| s.camera.reset(s.prince.x); | |
| for (let i = 0; i < (torch ? 90 : 10); i++) scene.update(1 / 60, { ...none, torch }); | |
| s.clock = 3.0; // fixed, so the dust and the pulse cannot drift | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| } | |
| function withOverlayOff(fn) { | |
| const was = renderer.debugShown; | |
| renderer.setDebug(false); | |
| try { return fn(); } finally { renderer.setDebug(was); } | |
| } | |
| const lum = (d, i) => 0.299 * d[i] + 0.587 * d[i + 1] + 0.114 * d[i + 2]; | |
| /** | |
| * TEST A — can the prince be seen? | |
| * | |
| * Of the pixels in the band a figure occupies, ignore the black mass and ask | |
| * how light the rest is. The 10th percentile, not the mean: the question is | |
| * whether the DIMMEST part of the backdrop still separates from a black | |
| * silhouette, and a mean is carried by the bright sky above. | |
| * | |
| * Measured over the walkable level only. The ending is a wall of people on | |
| * purpose, and a figure among them is not meant to read against a backdrop. | |
| */ | |
| function figureContrast(points = ROAD_AND_BOWL) { | |
| return withOverlayOff(() => { | |
| const rows = points.map((pt) => { | |
| frameAtX(pt.x); | |
| // Measure the BACKDROP, not the finished frame. | |
| // | |
| // A near trunk crossing the middle third is in FRONT of the prince, and | |
| // a blurred one spreads soft dark over him. Measuring the composited | |
| // frame counted that foreground fog as "what is behind him" and reported | |
| // a contrast failure at exactly the spots where the occlusion cue — the | |
| // one that passed its gate — was working. Same mistake the watcher test | |
| // made by sampling sideways into the next watcher. | |
| // | |
| // So: sky, the far layers, and the vignette. Nothing in front. | |
| const camX = s.camera.x; | |
| ctx.clearRect(0, 0, W, H); | |
| s.background.drawSky(ctx, camX, W); | |
| s.background.drawFar(ctx, camX, W, H, 'dark', s.prince.x); | |
| renderer.drawVignette(ctx); | |
| const z = deadZone(); | |
| const d = ctx.getImageData(z.x0, GROUND_Y - FIGURE_BAND, z.w, FIGURE_BAND).data; | |
| const vals = []; | |
| for (let i = 0; i < d.length; i += 4) { | |
| const v = lum(d, i); | |
| if (v >= BLACK) vals.push(v); | |
| } | |
| vals.sort((a, b) => a - b); | |
| return { | |
| at: pt.at, | |
| p10: Math.round(vals[Math.floor(vals.length * 0.1)]), | |
| median: Math.round(vals[Math.floor(vals.length * 0.5)]), | |
| }; | |
| }); | |
| const worst = Math.min(...rows.map((r) => r.p10)); | |
| return { name: 'figure contrast', pass: worst >= FLOOR_FIGURE, worst, floor: FLOOR_FIGURE, rows }; | |
| }); | |
| } | |
| /** | |
| * TEST B — can a WATCHER be seen? | |
| * | |
| * Test A protects the boy and abandons the 159 figures standing in the trees. | |
| * They sit at luminance 26 precisely because two human silhouettes at the same | |
| * value merge into one mass, and making them legible at all cost an entire | |
| * investigation — the 58px arm gap, measured on the silhouette after 42px | |
| * produced a gap of zero. | |
| * | |
| * Haze them toward the sky and haze the hills too and that separation closes | |
| * quietly. So: sample each watcher's torso and the background just beside it | |
| * at the same height, and assert the difference survives. | |
| */ | |
| function watcherContrast(points = ROAD_AND_BOWL.slice(3)) { | |
| return withOverlayOff(() => { | |
| const layer = s.background.layers.find((l) => l.key === 'watchers'); | |
| const rows = []; | |
| /** | |
| * Screen columns a village building covers. | |
| * | |
| * A HOUSE AND A WATCHER ARE THE SAME VALUE, deliberately — both are built | |
| * or standing things set back off the road, and both sit at luminance 26 | |
| * so the pure-black prince reads against them. Which means this test's | |
| * scan-up cannot tell them apart: it walks while the value stays within | |
| * 8, so it climbs straight through a wall, and a probe that lands on a | |
| * house reports a watcher at 26 against a background of 26. | |
| * | |
| * That is not a contrast failure. It is occlusion, exactly like a watcher | |
| * standing behind a near trunk, and it is excluded for the same reason — | |
| * except that trunks are caught by their blackness and a house cannot be, | |
| * so its footprint has to be handed over explicitly. | |
| */ | |
| const houseCols = () => { | |
| const out = []; | |
| for (const h of (s.houses ?? [])) { | |
| const sx = h.x - s.camera.x; | |
| out.push([sx - h.w / 2 - 20, sx + h.w / 2 + 20]); // +20 for the gable | |
| } | |
| if (s.gateX != null) { | |
| const gx = s.gateX - s.camera.x; | |
| out.push([gx - 540, gx + 540]); // leaf, posts, lintel | |
| } | |
| return out; | |
| }; | |
| for (const pt of points) { | |
| frameAtX(pt.x); | |
| const blocked = houseCols(); | |
| const d = ctx.getImageData(0, 0, W, H).data; | |
| const at = (px, py) => lum(d, (py * W + px) * 4); | |
| const shift = s.camera.x * layer.rate; | |
| // Only the ones actually drawn. A watcher faded out of the back lane's | |
| // way is not on screen, and probing its torso reads whoever is in | |
| // front of it. | |
| const spots = s.background.sample('watchers', shift - 100, shift + W + 100, true) | |
| .map((wx) => Math.round(wx - shift)) | |
| .filter((px) => px > 120 && px < W - 120) | |
| .filter((px) => !blocked.some(([a, b]) => px >= a && px <= b)); | |
| for (const px of spots) { | |
| // Torso height for a watcher standing at baseY. | |
| const py = layer.baseY - 90; | |
| const fig = at(px, py); | |
| // The background it is seen against, found by walking UP the same | |
| // column until the value changes. Sampling sideways looked obvious | |
| // and was wrong: at full density they stand about 100px apart, so a | |
| // probe beside a watcher lands on the NEXT watcher and every gap | |
| // comes back 0. Straight up clears the head into whatever is behind. | |
| let ty = py; | |
| while (ty > 40 && Math.abs(at(px, ty) - fig) <= 8) ty--; | |
| // The BEST contrast available above the head, not the first pixel | |
| // that differs. The first differing pixel is the figure's own | |
| // antialiased edge — it reads 37 where the hills are 97 — and a | |
| // single sample also lands inside a canopy tooth whenever one happens | |
| // to hang directly overhead. Neither is a contrast failure. | |
| let bg = 0; | |
| for (let k = 4; k < 84 && ty - k > 0; k++) bg = Math.max(bg, at(px, ty - k)); | |
| rows.push({ | |
| at: pt.at, x: px, watcher: Math.round(fig), | |
| behind: Math.round(bg), gap: Math.round(bg - fig), headTop: ty, | |
| }); | |
| } | |
| } | |
| // Two kinds of sample are not contrast failures and must not count: | |
| // a watcher standing in FRONT of a near trunk has black behind it, and a | |
| // watcher standing BEHIND one is not visible at all so the probe reads the | |
| // trunk. Both are occlusion, which is the near layer doing its job. | |
| const real = rows.filter((r) => r.behind >= BLACK && r.watcher >= BLACK); | |
| const worst = real.length ? Math.min(...real.map((r) => r.gap)) : 0; | |
| return { | |
| name: 'watcher contrast', | |
| pass: real.length > 0 && worst >= FLOOR_WATCHER_GAP, | |
| worst, floor: FLOOR_WATCHER_GAP, sampled: rows.length, againstBackground: real.length, rows, | |
| }; | |
| }); | |
| } | |
| /** | |
| * The other half of the two-sided rule: something substantial has to live at | |
| * the top, and it has to run out as the canopy closes. That curve IS the | |
| * difficulty curve — if brightness does not fall, the level has no shape. | |
| */ | |
| /** | |
| * ROAD ONLY, on purpose. The curve this asserts IS the difficulty curve — the | |
| * light runs out as he goes deeper — and the village deliberately breaks it | |
| * by being a clearing. Sampling the bowl here would fail a check for doing | |
| * the thing it was designed to do. The bowl is covered by figure contrast, | |
| * which asks the question that actually matters out there. | |
| */ | |
| function brightMass(points = [0.05, 0.35, 0.62, 0.9, 0.98]) { | |
| return withOverlayOff(() => { | |
| const rows = points.map((f) => { | |
| frameAt(f); | |
| // Same reason as the figure band: the vignette darkens the edges on | |
| // purpose, so counting them answers a question nobody asked. | |
| const z = deadZone(); | |
| const d = ctx.getImageData(z.x0, 0, z.w, H).data; | |
| let b = 0, n = 0; | |
| for (let i = 0; i < d.length; i += 4) { n++; if (lum(d, i) > 160) b++; } | |
| return { at: `${Math.round(f * 100)}%`, bright: +(100 * b / n).toFixed(1) }; | |
| }); | |
| // A TREND, not a step-by-step guarantee. | |
| // | |
| // Strict monotonicity worked while this measured the whole frame, because | |
| // averaging 1920 pixels wide smoothed everything out. Narrowed to the | |
| // camera's dead zone it bounces with whatever happens to be in the middle | |
| // third — a trunk, a hill peak — and a real curve gets failed by local | |
| // noise. What the check is actually for is "the light runs out as he goes | |
| // deeper", which is a trend, so compare the ends. | |
| const head = (rows[0].bright + rows[1].bright) / 2; | |
| const tail = (rows[rows.length - 1].bright + rows[rows.length - 2].bright) / 2; | |
| const falls = tail < head - 4; | |
| return { | |
| name: 'bright mass', | |
| pass: rows[0].bright >= FLOOR_BRIGHT_START && falls, | |
| atStart: rows[0].bright, floor: FLOOR_BRIGHT_START, | |
| fallsOverTheWalk: falls, startAvg: +head.toFixed(1), endAvg: +tail.toFixed(1), rows, | |
| }; | |
| }); | |
| } | |
| /** | |
| * THE QUILT. The sky was quantised to three colours through an ordered 4x4 | |
| * Bayer matrix at 3px cells, which is a checkerboard with a 6px period and a | |
| * matrix that repeats every 12px. Looking at it is not proof: it is invisible | |
| * at some brightnesses and obvious at others, so the test is periodicity. | |
| * | |
| * Autocorrelation of a mean-removed row. A woven pattern spikes at its period. | |
| */ | |
| function skyWeave(rowY = 300) { | |
| return withOverlayOff(() => { | |
| frameAt(0.35); | |
| const d = ctx.getImageData(0, rowY, W, 40).data; | |
| const corr = {}; | |
| for (let lag = 2; lag <= 26; lag++) { | |
| let sum = 0, n = 0; | |
| for (let y = 0; y < 40; y++) { | |
| const row = []; | |
| for (let px = 0; px < W; px++) row.push(lum(d, ((y * W) + px) * 4)); | |
| const mean = row.reduce((p, q) => p + q, 0) / row.length; | |
| let num = 0, den = 0; | |
| for (let px = 0; px + lag < row.length; px++) { | |
| num += (row[px] - mean) * (row[px + lag] - mean); | |
| den += (row[px] - mean) ** 2; | |
| } | |
| if (den > 0) { sum += num / den; n++; } | |
| } | |
| corr[lag] = sum / Math.max(1, n); | |
| } | |
| // PROMINENCE, not the raw correlation — and getting this wrong the first | |
| // time nearly produced a confidently wrong finding. | |
| // | |
| // A smooth gradient correlates with itself strongly at every small lag and | |
| // decays monotonically: 0.92, 0.89, 0.83, 0.76. The raw value at lag 6 is | |
| // therefore HIGHER on a clean sky than on a woven one, and a threshold on | |
| // it reports the fix as a regression. | |
| // | |
| // A weave is a local BUMP: the dithered sky read 0.71 at lag 4, 0.77 at | |
| // lag 6, 0.61 at lag 8 — up, then down. So the test asks whether lag 6 or | |
| // 12 stands above its own neighbours. | |
| const prominence = (lag) => +(corr[lag] - (corr[lag - 2] + corr[lag + 2]) / 2).toFixed(3); | |
| const p6 = prominence(6); | |
| const p12 = prominence(12); | |
| const worst = Math.max(p6, p12); | |
| return { | |
| name: 'sky weave', | |
| pass: worst < 0.03, | |
| worstAt6or12: worst, floor: 0.03, | |
| prominenceAt6: p6, prominenceAt12: p12, | |
| peaks: Object.entries(corr).map(([lag, c]) => ({ lag: +lag, corr: +c.toFixed(3) })), | |
| }; | |
| }); | |
| } | |
| /** No input, in the shape scene.update expects. */ | |
| const NONE = { torch: false, attack: false, pickup: false, sheaf: false, left: false, right: false }; | |
| const drive = (secs, input = {}) => { | |
| const n = Math.round(secs * 60); | |
| for (let i = 0; i < n; i++) scene.update(1 / 60, { ...NONE, ...input }); | |
| }; | |
| /** | |
| * THE SAME VILLAGE EVERY TIME. | |
| * | |
| * Everything out there is placed by hashing a world position, so nothing is | |
| * random — but a salt collision would quietly put two shapes in one spot, or | |
| * a house on top of a shape, and looking at it would not catch that. So: | |
| * fingerprint the layout and compare it against what the build shipped with. | |
| */ | |
| /** | |
| * CAN HE LOOK AT A DOOR WITHOUT STARTING A FIGHT? | |
| * | |
| * The check this project most needed and did not have. Holding the torch | |
| * freezes him and takes his sword away, and it reveals out to RADIUS plus | |
| * LIGHT_AHEAD. So a Drained standing inside that distance of a door means | |
| * lighting the door wakes it while he cannot move or swing, with nowhere to | |
| * back off to. The first village had one 59px from the forge, and all five | |
| * doors were unlookable — found by playing it, which is the expensive way. | |
| * | |
| * MEASURED OFF THE LIT TORCH, not computed from RADIUS + LIGHT_AHEAD. | |
| * | |
| * That sum was right while the pool hung off a fixed offset from him. The | |
| * light comes out of the flame now, and the flame is 317px up in the air, so | |
| * the pool's footprint on the ground is nothing like its radius: the same | |
| * 340px circle that reached 461 ahead of him reaches 328. A derived constant | |
| * that derives the wrong thing is worse than a written-down one, because it | |
| * looks like it is keeping up. | |
| * | |
| * So: raise the torch and ask it. Scan forward for the furthest point it can | |
| * still reveal a standing figure. Measured once and kept, because it cannot | |
| * change inside a run. | |
| * | |
| * AT THE BACK LANE'S HEIGHT. Every shape a door could wake is standing | |
| * beside the road until it is lit, smaller and 56px up, and that puts its | |
| * middle nearer the flame: it is lit from 339px where one on the road is lit | |
| * from 328. Measuring at the road's height understated it by 11px. | |
| */ | |
| let doorClear = null; | |
| function revealReach() { | |
| if (doorClear !== null) return doorClear; | |
| const keepX = s.prince.x; | |
| const keepFace = s.prince.facing; | |
| scene.reset(); | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| for (let i = 0; i < 180; i++) scene.update(1 / 60, { ...none, torch: true }); | |
| let far = 0; | |
| for (let dx = 0; dx <= 1200; dx++) { | |
| if (s.torch.covers(s.prince.x + dx, figureMidY(GROUND_Y, 1))) far = dx; | |
| } | |
| s.prince.x = s.prince.prevX = keepX; | |
| s.prince.facing = keepFace; | |
| doorClear = far; | |
| return far; | |
| } | |
| function bowlLayout() { | |
| // SAME SEED, SAME VILLAGE. The fingerprint used to assert one fixed | |
| // arrangement; with a run seed it asserts that a seed reproduces its own. | |
| // The spacing floor and the door clearances below are seed-independent — | |
| // the placement maths never sees the run seed, which is the whole reason | |
| // shuffling roles costs none of V1's guarantees. | |
| scene.reset(20250920); | |
| // EVERY Drained, road ones included. There is no one-way mouth any more, so | |
| // the road is part of the same walkable space and the same rule applies to | |
| // it — two encounters landed 667px apart the moment this stopped being two | |
| // separate places. | |
| const shapes = s.drained.map((d) => d.spawnX); | |
| const houses = s.houses.map((h) => `${h.kind}@${h.x}`); | |
| const parts = [...shapes, ...houses, `gate@${s.gateX}`, `stop@${s.gateStopX}`]; | |
| // A plain string hash. It only has to change when the layout changes. | |
| let h = 2166136261 >>> 0; | |
| for (const ch of parts.join('|')) { | |
| h ^= ch.charCodeAt(0); | |
| h = Math.imul(h, 16777619) >>> 0; | |
| } | |
| // Nothing may land closer than a body's width, or two shapes read as one. | |
| const sorted = [...shapes].sort((a, b) => a - b); | |
| let tightest = Infinity; | |
| for (let i = 1; i < sorted.length; i++) tightest = Math.min(tightest, sorted[i] - sorted[i - 1]); | |
| // Two shapes closer than the torch is WIDE means one raise wakes both. | |
| const TORCH_WIDE = TORCH.RADIUS * 2; | |
| // The gatekeeper is exempt. He stands at the gatehouse door on purpose — | |
| // being blocked by him and swinging is the tutorial, and he is the only | |
| // fight in the village the player is meant to walk into rather than choose. | |
| const inTheVillage = s.drained.filter((d) => d !== s.village[s.village.length - 1]) | |
| .map((d) => d.spawnX); | |
| const doors = s.houses.map((hs) => ({ | |
| house: hs.kind, | |
| nearest: Math.round(Math.min(...inTheVillage.map((x) => Math.abs(x - hs.x)))), | |
| })); | |
| const worstDoor = Math.min(...doors.map((dd) => dd.nearest)); | |
| return { | |
| name: 'bowl layout', | |
| pass: tightest >= TORCH_WIDE && worstDoor >= revealReach(), | |
| fingerprint: h.toString(16), atSeed: 20250920, | |
| shapes: shapes.length, inTheVillage: s.village.length, | |
| tightestGap: tightest, shapeFloor: TORCH_WIDE, | |
| closestToADoor: worstDoor, doorFloor: revealReach(), doors, | |
| spacing: Math.round((Math.max(...shapes) - Math.min(...shapes)) / (shapes.length - 1)), | |
| houses, | |
| }; | |
| } | |
| /** | |
| * BODIES STAY DOWN, AND NOTHING CLEANS THEM UP. | |
| * | |
| * On the road a downed Drained gets up after DOWN_MS. In the village it never | |
| * does, for a reason worth protecting: a body is the only record the player | |
| * has of which shapes they already checked, and a dozen near-identical | |
| * outlines with no memory of what you tried is the exact tedium this level | |
| * exists to measure. | |
| * | |
| * It is also the best image the level produces for free — brute-force the | |
| * village and you walk out through everyone you knocked over. A draw cull or | |
| * a despawn added a year from now would delete that silently, so it is a test | |
| * rather than a comment. | |
| */ | |
| function bodiesPersist() { | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = BOWL.X0; | |
| for (const d of s.village) { d.hits = 99; d.takeHit(d.x - 1); } | |
| // scene_level only updates what is within W * 1.4 of him, so he has to walk | |
| // the bowl for every one of them to get out of STAGGER and go down. Doing | |
| // this on the spot downed the three nearest and silently passed the rest. | |
| // | |
| // Derived from the width, not written down. A hard-coded 26 seconds was | |
| // right for a 9600 village and silently downed only ten of twelve once it | |
| // grew — a test that quietly stops covering what it claims to. | |
| const crossing = TUNING.BOWL_W / TUNING.WALK_SPEED; | |
| drive(crossing + 6, { right: true }); | |
| const downed = s.village.filter((d) => d.state === 'DOWNED').length; | |
| // Long enough that DOWN_MS would have stood every one of them back up. | |
| const waited = Math.max((TUNING.DOWN_MS / 1000) + 5, crossing * 2); | |
| drive(waited / 2, { left: true }); | |
| drive(waited / 2, { right: true }); | |
| const still = s.village.filter((d) => d.state === 'DOWNED').length; | |
| return { | |
| name: 'bodies persist', | |
| pass: downed === s.village.length && still === downed, | |
| downed, stillDown: still, of: s.village.length, waitedSeconds: Math.round(waited), | |
| }; | |
| } | |
| /** | |
| * NOTHING IS EVER PUT OUT OF REACH. | |
| * | |
| * The village used to have a one-way mouth: cross it and the camera stopped | |
| * following and you could not walk back out. The spec called that "the whole | |
| * transition", and it was written when the road was twelve screens of empty | |
| * approach nobody would want to re-walk. | |
| * | |
| * The road is four screens now and has Drained on it carrying what they kept. | |
| * Locking the threshold deleted those encounters the moment he stepped past a | |
| * house — found by playing it. So the whole level is walkable in both | |
| * directions, and this says so in a way a future "focus the level" change | |
| * cannot quietly undo. | |
| */ | |
| function wholeLevelWalkable() { | |
| scene.reset(); | |
| drive((s.gateStopX / TUNING.WALK_SPEED) + 8, { right: true }); | |
| const atGate = Math.round(s.prince.x) === s.gateStopX; | |
| drive((s.gateStopX / TUNING.WALK_SPEED) + 8, { left: true }); | |
| const back = Math.round(s.prince.x); | |
| // Every road encounter has to be reachable again from where he ends up. | |
| const road = s.drained.filter((d) => !s.village.includes(d)); | |
| const reachable = road.every((d) => d.x >= 0 && d.x <= s.gateStopX); | |
| return { | |
| name: 'whole level walkable', | |
| pass: atGate && back === 0 && reachable && road.length > 0, | |
| reachedGate: atGate, walkedBackTo: back, | |
| roadEncounters: road.length, allReachable: reachable, | |
| }; | |
| } | |
| /** | |
| * NOTHING THAT IS STANDING MOVES UNTIL IT IS LOOKED AT. | |
| * | |
| * Narrowed from "nothing moves", and the narrowing is a design decision | |
| * rather than a concession. Two shapes in the village wander on purpose: they | |
| * are the negative control for the loop skill, and their motion has to be | |
| * legibly not a trade — standing still would read as the gatekeeper or as a | |
| * watcher. | |
| * | |
| * What survives, and what matters, is that **a wanderer cannot pace into the | |
| * light of a door.** Lighting a door would then wake it, and the one thing | |
| * the whole placement was built to buy is gone. | |
| * | |
| * Measured against the torch's own measured reach, and not against | |
| * BOWL_CLEAR. 800 is the placement budget a slot is given; 500 is the rule, | |
| * derived from RADIUS plus LIGHT_AHEAD. A wanderer sitting at exactly 800 | |
| * from a door is legal and 300px outside the light. | |
| * | |
| * Asserted four ways, because the version before this passed the first two — | |
| * it held its position perfectly while playing a walk cycle at quarter speed, | |
| * which reads as a man walking on the spot. | |
| */ | |
| function stillUntilLit() { | |
| const reach = revealReach(); | |
| scene.reset(); | |
| const target = s.drained[0]; | |
| const other = s.drained[1]; | |
| // Far enough that the torch cannot reach either of them. | |
| s.prince.x = s.prince.prevX = target.x - reach - 400; | |
| const pose = { clip: target.clip.name, frame: target.frame }; | |
| drive(20, { torch: true }); | |
| const standing = s.drained.filter((dd) => dd.standing); | |
| const drift = Math.max(...standing.map((dd) => Math.abs(dd.x - dd.spawnX))); | |
| const posed = target.clip.name === pose.clip && target.frame === pose.frame; | |
| // AND THE WANDERERS STAYED ON THEIR OWN GROUND — which means walking past | |
| // them, because a Drained is only updated within W * 1.4 of him. Asserting | |
| // this from across the village measured nothing at all and passed. | |
| const roamers = s.drained.filter((dd) => !dd.standing); | |
| let strayed = 0; | |
| let penned = roamers.length > 0; | |
| s.prince.x = s.prince.prevX = BOWL.X0; | |
| for (let pass = 0; pass < 2; pass++) { | |
| const n = Math.round(((TUNING.BOWL_W / TUNING.WALK_SPEED) + 4) * 60); | |
| for (let i = 0; i < n; i++) { | |
| scene.update(1 / 60, { ...NONE, [pass === 0 ? 'right' : 'left']: true }); | |
| for (const dd of roamers) { | |
| strayed = Math.max(strayed, Math.abs(dd.x - dd.spawnX)); | |
| if (s.houses.some((h) => Math.abs(dd.x - h.x) <= reach)) penned = false; | |
| } | |
| } | |
| } | |
| // Now stand inside the light's reach and it has to wake and come. INSIDE | |
| // IT, measured -- this was a written-down 380, which was comfortably within | |
| // the 461 the torch used to reach and 52px outside the 328 it reaches now. | |
| // The check failed and the game was fine. | |
| s.prince.x = s.prince.prevX = target.x - Math.round(reach * 0.85); | |
| s.prince.facing = 1; | |
| const from = target.x; | |
| let ticks = 0; | |
| while (!target.revealed && ticks < 60 * 8) { scene.update(1 / 60, { ...NONE, torch: true }); ticks++; } | |
| drive(3); | |
| return { | |
| name: 'still until lit', | |
| pass: drift === 0 && posed && target.revealed && target.x < from && penned, | |
| driftWhileUnlit: drift, | |
| wanderersStayedOutOfEveryDoorsLight: penned, doorLightReaches: reach, | |
| furthestAWandererGot: Math.round(strayed), | |
| wanderers: roamers.length, | |
| heldItsPose: posed, | |
| wokeAfterSeconds: +(ticks / 60).toFixed(2), | |
| cameCloserBy: Math.round(from - target.x), | |
| }; | |
| } | |
| /** | |
| * CAN HE READ EVERY DOOR, AND DOES IT COST HIM A FIGHT? | |
| * | |
| * The V2 gate in code. Three things at once, because they are one question: | |
| * | |
| * 1. Standing where a player would stand, the torch reaches the door and it | |
| * latches. A door that cannot be lit says nothing, and the door is the | |
| * only thing that says who lived here. | |
| * 2. Nothing wakes up while he does it. This is what V1's clear pockets | |
| * bought, and it is the thing that will quietly rot first — move a house | |
| * or add a shape and it goes without a sound. | |
| * 3. The palette rule survives the one bright thing in the level. The wall | |
| * has to stay under 30 and the lit doorway over 160, with nothing living | |
| * in between, or the forbidden zone has been reopened by a doorway. | |
| */ | |
| function doorsRead() { | |
| const rows = withOverlayOff(() => scene.state.houses.map((h) => { | |
| scene.reset(); | |
| // Where a player stands to look at a door: beside it, facing in. | |
| s.prince.x = s.prince.prevX = h.x - 300; | |
| s.prince.facing = 1; | |
| drive(2, { torch: true }); | |
| s.camera.reset(s.prince.x); | |
| s.clock = 3.0; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| const [dx, dy] = s.doorPoint(h); | |
| const px = Math.round(dx - s.camera.x); | |
| const py = Math.round(dy); | |
| const d = ctx.getImageData(px - 200, py - 240, 400, 300).data; | |
| let wall = 255, door = 0; | |
| for (let i = 0; i < d.length; i += 4) { | |
| const v = lum(d, i); | |
| // The wall is the darkest thing that is not the black trade object; | |
| // the doorway is the brightest thing in the same patch. | |
| if (v > BLACK && v < wall) wall = v; | |
| if (v > door) door = v; | |
| } | |
| return { | |
| house: h.kind, lit: h.seen, | |
| wall: Math.round(wall), doorway: Math.round(door), | |
| woke: s.drained.filter((dd) => dd.revealed).length, | |
| }; | |
| })); | |
| const allLit = rows.every((r) => r.lit); | |
| const noneWoke = rows.every((r) => r.woke === 0); | |
| const palette = rows.every((r) => r.wall < 30 && r.doorway > 160); | |
| return { | |
| name: 'doors read', | |
| pass: allLit && noneWoke && palette, | |
| everyDoorLatched: allLit, nothingWoke: noneWoke, paletteHolds: palette, | |
| rows, | |
| }; | |
| } | |
| /** | |
| * THE MAP RECORDS, AND READING IT IS LOOKING. | |
| * | |
| * The V3 gate, and the half that matters is the softlock. The spec sends a | |
| * player who took four objects blind back "to light four things on the | |
| * ground" — except he took them, so there is nothing on the ground and the | |
| * gate could never open. Reading the map is already standing in his own | |
| * torchlight, so a `?` resolves into what it is. | |
| * | |
| * Also asserted: it costs what looking costs. Raising the map raises the | |
| * torch, so he cannot move and cannot swing while he does it. | |
| */ | |
| function pagesCost() { | |
| scene.reset(); | |
| // Well clear of a door, so nothing files itself by accident. | |
| s.prince.x = s.prince.prevX = s.houses[1].x - 1500; | |
| for (let k = 0; k < 4; k++) { | |
| s.prince.inventory.push({ kept: k, answers: null, seen: false }); | |
| } | |
| const blind = s.prince.inventory.filter((it) => !it.seen).length; | |
| // Try to walk and swing with it up; neither may do anything. | |
| const from = s.prince.x; | |
| drive(1, { sheaf: true, right: true, attack: true }); | |
| const frozen = Math.abs(s.prince.x - from) < 0.001 && !s.prince.attacking; | |
| // THE POSE THE SCENE READS IN, whichever drawing that is. It was her | |
| // standing drawing, and since she drew the torch held up it is that: | |
| // holding the pages holds the torch up. What this ever tested is that he | |
| // is STOPPED and holding something, not which drawing it is. | |
| const posed = !!s.prince.clip && s.prince.clip === s.mapClip; | |
| // And it must NOT resolve anything with the sheaf down. What resolves a | |
| // blind take moved onto the page at L3 — `sheafPages` owns that claim now, | |
| // because it is a claim about paging. What is left here is the COST of | |
| // holding the thing up, which has not changed since it was only a map. | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = s.houses[1].x - 1500; | |
| s.prince.inventory.push({ kept: 0, answers: null, seen: false }); | |
| drive(4); | |
| const stillBlind = !s.prince.inventory[0].seen; | |
| return { | |
| name: 'the pages cost what looking costs', | |
| pass: blind === 4 && frozen && posed && stillBlind, | |
| tookBlind: blind, | |
| cannotMoveOrSwing: frozen, usesTheSheetPose: posed, | |
| resolvesNothingWithItDown: stillBlind, | |
| }; | |
| } | |
| /** | |
| * THE SHEAF. A pile of loose paper, and every claim here is about paper. | |
| * | |
| * His orders are page one. Everything after them is in the order he picked | |
| * it up — no grouping, no sorting, no index and no tabs, because all of those | |
| * are the game telling him what he has, and what he has is the thing he is | |
| * meant to work out. | |
| * | |
| * AND NOTHING IN IT CAME OFF A WALL. Letters stay on their doors and the | |
| * note stays on its tree: he does not pocket a stranger's mail. So the paper | |
| * in it is his orders and the fragments he took off bodies, and in village | |
| * one that is five pages at the most. Keys go in too, and come out again at | |
| * the gate; they are carried, and they are not paper. | |
| */ | |
| function sheafPages() { | |
| scene.reset(); | |
| const sheaf = s.sheaf; | |
| const isPaper = (p) => p.kind === 'letter' || p.kind === 'fragment'; | |
| // 1. IT STARTS WITH HIS ORDERS AND NOTHING ELSE. | |
| // | |
| // This asserted that it started EMPTY, above a comment explaining that | |
| // at the start he is carrying nothing. That was true of a pile of what | |
| // he had FOUND. The order is the one page he did not find — it is what | |
| // he was handed at the palace and what sent him down this road — and it | |
| // is page one from before the first frame and permanently. | |
| const order = sheaf.pages[0]; | |
| const startsWithTheOrder = sheaf.pages.length === 1 && !!order && | |
| order.kind === 'letter' && !s.houses[order.house] && | |
| s.textFor(order) === s.letters.byHouse[order.house].letter; | |
| // 2. LETTERS STAY WHERE THEY WERE LEFT. Light every door and the tree, | |
| // stand at each and press E: the band reads it, and the pile does not | |
| // grow by a single page. With his orders on top, E at a bare slot does | |
| // nothing at all. | |
| const stops = s.houses.map((h) => ({ x: h.x, text: s.letters.byHouse[h.index].letter })); | |
| if (s.tree) stops.push({ x: s.tree.x, text: s.letters.byHouse[s.tree.index].letter }); | |
| let readEveryOne = true; | |
| for (const st of stops) { | |
| s.prince.x = s.prince.prevX = st.x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(1.4, { torch: true }); | |
| readEveryOne = readEveryOne && s.band.text === st.text; | |
| tap(); | |
| } | |
| const nothingCameOffAWall = sheaf.pages.length === 1 && sheaf.pages[0] === order; | |
| // 3. And running off the front of a one-page pile stays on that page. | |
| drive(0.5, { sheaf: true, left: true }); | |
| const holdsAtTheFront = sheaf.index === 0 && !!s.band.text; | |
| // 4. HIS ORDERS AND FOUR FRAGMENTS, AND THAT IS ALL THE PAPER THERE IS. | |
| // armed() downs every carrier in the village and takes what each one | |
| // was carrying, in the order they stand — so this is the fullest the | |
| // pile's paper can ever be, in the order he found it. | |
| const carriers = armed(); | |
| const paper = sheaf.pages.filter(isPaper); | |
| const fullPile = paper.length === 5 && paper.length === carriers.length + 1 && | |
| paper[0].kind === 'letter' && !s.houses[paper[0].house] && | |
| paper.slice(1).every((p) => p.kind === 'fragment'); | |
| const neverAWallsLetter = sheaf.pages.every((p) => p.kind !== 'letter' || | |
| (!s.houses[p.house] && p.house !== s.tree?.index)); | |
| const foundOrder = paper.slice(1).map((p) => p.kept).join(','); | |
| const takenOrder = carriers.map((c) => c.kept).join(','); | |
| // 5. A FLIP WITH A BEAT. Holding an arrow walks the pile at reading speed, | |
| // not at frame rate. Hold right for a second from the front and count | |
| // the turns: at 60fps a free browse would be sixty. | |
| sheaf.index = 0; | |
| const before = sheaf.turns; | |
| drive(1, { sheaf: true, right: true }); | |
| const turnsPerSecond = sheaf.turns - before; | |
| // 6. IT DOES NOT WRAP. Running off the end of a pile of paper does nothing. | |
| drive(4, { sheaf: true, right: true }); | |
| const stopsAtTheEnd = sheaf.index === sheaf.pages.length - 1; | |
| drive(6, { sheaf: true, left: true }); | |
| const stopsAtTheStart = sheaf.index === 0; | |
| // 7. THE INDEX SURVIVES RELEASE. He reopens where he left off. | |
| drive(1.2, { sheaf: true, right: true }); | |
| const wasOn = sheaf.index; | |
| drive(1); // sheaf down, walking about | |
| drive(0.6, { sheaf: true }); | |
| const reopensWhereHeLeftIt = sheaf.index === wasOn && wasOn > 0; | |
| // 8. A `?` RESOLVES THE MOMENT HE PAGES TO IT, and only the one he turned | |
| // to. Two blind takes, open the second: it fills, the first stays blank. | |
| // The pages BEHIND the one in his hand are what the unknown state is for. | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = s.houses[1].x - 1500; | |
| s.prince.inventory.push({ kept: 0, answers: null, seen: false }); | |
| s.prince.inventory.push({ kept: 2, answers: null, seen: false }); | |
| sheaf.pages.push({ kind: 'fragment', kept: 0 }, { kind: 'fragment', kept: 2 }); | |
| sheaf.index = 2; // the SECOND one he took, past the order | |
| drive(0.6, { sheaf: true }); | |
| const resolvedOnPage = s.prince.inventory[1].seen && | |
| s.band.text === s.letters.byHouse[2].fragment; | |
| const resolvedTheOneHeIsOn = !s.prince.inventory[0].seen; | |
| // 9. And the one still behind him reads as blank paper — no words in the | |
| // band for a page he has not turned to. | |
| sheaf.index = 1; // the FIRST fragment, past the order | |
| s.band.text = null; | |
| const blankWhileUnknown = !s.band.text; | |
| drive(0.3, { sheaf: true }); | |
| const opensWhenHeTurnsToIt = s.prince.inventory[0].seen && | |
| s.band.text === s.letters.byHouse[0].fragment; | |
| return { | |
| name: 'the sheaf pages', | |
| pass: startsWithTheOrder && readEveryOne && nothingCameOffAWall && | |
| holdsAtTheFront && fullPile && neverAWallsLetter && | |
| foundOrder === takenOrder && | |
| turnsPerSecond >= 3 && turnsPerSecond <= 5 && | |
| stopsAtTheEnd && stopsAtTheStart && reopensWhereHeLeftIt && | |
| blankWhileUnknown && resolvedOnPage && resolvedTheOneHeIsOn && | |
| opensWhenHeTurnsToIt, | |
| startsWithTheOrder, | |
| readsEveryDoorAndTheTree: readEveryOne, | |
| andTakesNothingOffAWall: nothingCameOffAWall, | |
| holdsAtTheFrontOfThePile: holdsAtTheFront, | |
| paperAtItsFullest: paper.length, ordersAndFourFragments: fullPile, | |
| neverAHouseLetterOrTheNote: neverAWallsLetter, | |
| foundOrder, takenOrder, turnsPerSecondHeld: turnsPerSecond, | |
| stopsAtTheEnd, stopsAtTheStart, reopensWhereHeLeftIt, | |
| blankUntilHeTurnsToIt: blankWhileUnknown, resolvesOnPage: resolvedOnPage, | |
| leavesThePagesBehindHimBlank: resolvedTheOneHeIsOn, | |
| opensWhenHeTurnsToIt, | |
| }; | |
| } | |
| /** | |
| * THE DEAL OBEYS ITS RULES, FOR ANY RUN SEED. | |
| * | |
| * Replaces the old `populationReads`, which asserted "three to a motion, one | |
| * carrying" — true of the build where the loop meant nothing and false by | |
| * design now. Swinging carries zero and bearing carries two, on purpose. | |
| * | |
| * The rules are the authored part of the level; which specific body satisfies | |
| * one is not. So this asserts the rules across a hundred seeds rather than | |
| * fingerprinting one arrangement. | |
| */ | |
| function dealObeysRules(seeds = 100) { | |
| const bad = []; | |
| let sample = null; | |
| for (let n = 0; n < seeds; n++) { | |
| scene.reset(n * 7919 + 13); | |
| const v = s.village; | |
| const keeper = v[v.length - 1]; | |
| const carriers = s.drained.filter((d) => d.kept != null); | |
| const of = (k) => v.find((d) => d.kept === k); | |
| const row = { | |
| seed: n, | |
| fourCarriers: carriers.length === 4, | |
| keeperHasGatehouse: keeper.kept === HOUSE_OF.indexOf('the gatehouse'), | |
| forgeOnAHauler: of(HOUSE_OF.indexOf('the forge'))?.loop === 'haul', | |
| pairOnBearers: [HOUSE_OF.indexOf('the bakehouse'), HOUSE_OF.indexOf('the well house')] | |
| .every((k) => of(k)?.loop === 'bear'), | |
| noSwingerCarries: !v.some((d) => d.loop === 'swing' && d.kept != null), | |
| nothingForHome: !s.drained.some((d) => d.kept === HOUSE_OF.indexOf('home')), | |
| kindsUnique: new Set(carriers.map((d) => d.kept)).size === carriers.length, | |
| // No two NEIGHBOURS share a motion. Measured in the order he walks | |
| // past them, which is the only order a player experiences — the array | |
| // is in construction order and says nothing about the street. | |
| noAdjacentMotion: (() => { | |
| const line = v.filter((d) => !d.post).sort((a, b) => a.x - b.x); | |
| return !line.some((d, i) => i > 0 && d.loop && d.loop === line[i - 1].loop); | |
| })(), | |
| }; | |
| if (!sample) sample = row; | |
| if (!Object.values(row).every((x) => x === true || typeof x === 'number')) bad.push(row); | |
| } | |
| scene.reset(); | |
| return { | |
| name: 'deal obeys rules', | |
| pass: bad.length === 0, | |
| seedsTried: seeds, broke: bad.length, firstFailure: bad[0] ?? null, sample, | |
| }; | |
| } | |
| /** | |
| * THE SWINGER IS BAIT, AND IT HAS TO BE PROVABLY TRUE. | |
| * | |
| * The forge letter says "I could hear you at the anvil half the night" and | |
| * points at a motion. Walk to the swinging shape because of it, knock it | |
| * down, and nothing falls out — then read the fragment on the hauler and it | |
| * says "could not work today, my brother's fever is worse". The letter | |
| * changed the route twice. | |
| * | |
| * If one swinger in a hundred seeds drops something, the forge letter is a | |
| * lie rather than a subversion. So this walks to every one of them. | |
| */ | |
| function swingerIsBait(seeds = 25) { | |
| const rows = []; | |
| for (let n = 0; n < seeds; n++) { | |
| scene.reset(n * 104729 + 7); | |
| const swingers = s.village.filter((d) => d.loop === 'swing'); | |
| let dropped = 0; | |
| for (const d of swingers) { | |
| // Stand next to it rather than walking the whole village: a Drained is | |
| // only updated within W * 1.4 of him, and forty crossings to prove the | |
| // same thing is forty crossings. | |
| s.prince.x = s.prince.prevX = d.x - 200; | |
| s.camera.reset(s.prince.x); | |
| d.hits = 99; | |
| d.takeHit(s.prince.x); | |
| drive(1.5); | |
| if (d.keptDropped) dropped++; | |
| } | |
| rows.push({ seed: n, swingers: swingers.length, downed: swingers.filter((d) => d.state === 'DOWNED').length, dropped }); | |
| } | |
| scene.reset(); | |
| const worst = Math.max(...rows.map((r) => r.dropped)); | |
| const allDown = rows.every((r) => r.downed === r.swingers); | |
| return { | |
| name: 'swinger is bait', | |
| pass: worst === 0 && allDown && rows.every((r) => r.swingers === 3), | |
| seedsTried: seeds, mostThatEverDropped: worst, | |
| swingersPerRun: rows[0].swingers, everyOneActuallyWentDown: allDown, | |
| }; | |
| } | |
| /** | |
| * THE RUN CAN ALWAYS BE FINISHED. | |
| * | |
| * The worst bug this level can have, and it shipped: the gatekeeper's spear | |
| * fell on the side the prince "walks off towards", which at a shut gate is | |
| * BEHIND him. It landed 375px past the stop line against a 120px pickup | |
| * reach, so you could put down all twelve, collect three objects, and have | |
| * nothing left to do. | |
| * | |
| * Checking the coordinates would not have caught it — the drop was where the | |
| * code meant it to be. So this walks to every one of them and takes it, which | |
| * is the only version of the question that means anything. | |
| */ | |
| function nothingOutOfReach() { | |
| scene.reset(); | |
| const carriers = s.drained.filter((d) => d.kept != null); | |
| // DOWN THEM ONE AT A TIME, STOOD NEXT TO EACH. | |
| // | |
| // This used to hit all four and then walk right for a minute, which worked | |
| // only because a run starts on the road and walking right goes past every | |
| // one of them. At `?at=gate` he starts 1600px from the leaf, reaches it in | |
| // three seconds and stands there — and a Drained is only updated within | |
| // W * 1.4 of him, so two carriers six thousand px west stayed in STAGGER | |
| // for the whole drive, never reached DOWNED, and never dropped anything. | |
| // The check then walked to a fragment that was still on a body. | |
| // | |
| // Standing next to each one is the idiom swingerIsBait already uses, it is | |
| // start-position independent, and it is fifty seconds faster. | |
| for (const d of carriers) { | |
| s.prince.x = s.prince.prevX = d.x - 200; | |
| s.camera.reset(s.prince.x); | |
| d.hits = 99; | |
| d.takeHit(d.x - 1); | |
| drive(1.0); // STAGGER is 500ms; the drop lands on DOWNED | |
| } | |
| const rows = []; | |
| for (const d of carriers) { | |
| // Walk to it and pick it up, the way a player would. | |
| const before = s.prince.inventory.length; | |
| let ticks = 0; | |
| while (Math.abs(s.prince.x - d.keptX) > TUNING.PICKUP_REACH * 0.5 && ticks < 60 * 90) { | |
| scene.update(1 / 60, { ...NONE, [s.prince.x < d.keptX ? 'right' : 'left']: true }); | |
| ticks++; | |
| } | |
| // TAKEN BLIND, which is allowed and is what a player does half the time. | |
| // | |
| // The key half of this check needs all four FILED, and a fragment he has | |
| // not looked at cannot be filed — but the reading happens at a door, | |
| // where atDoor() pages to each one. Not here. Holding the torch over a | |
| // body in the open street is the one thing in this game that starts a | |
| // fight, and a reach check has no business starting one. | |
| scene.update(1 / 60, { ...NONE, pickup: true }); | |
| drive(0.2); | |
| rows.push({ | |
| kind: d.answers, at: Math.round(d.keptX), | |
| walkedTo: Math.round(s.prince.x), | |
| taken: s.prince.inventory.length > before, | |
| }); | |
| } | |
| const allTaken = rows.every((r) => r.taken); | |
| // AND THE FOUR KEYS, which is the half of the run that did not exist | |
| // before. A key is behind a door, so getting to one means filing that | |
| // house's fragment correctly and waiting for its owner to walk home — and | |
| // the walk is the thing most likely to strand one somewhere unreachable. | |
| for (const k of [0, 1, 2, 4]) { atDoor(k, k); tap({ sheaf: true }); } | |
| let t = 0; | |
| while (s.drained.some((d) => d.homing) && t < 60 * 250) { scene.update(1 / 60, NONE); t++; } | |
| const keyRows = []; | |
| for (const k of [0, 1, 2, 4]) { | |
| const before = s.prince.keys; | |
| const [kx] = s.keyPoint(s.houses[k]); | |
| let ticks = 0; | |
| while (Math.abs(s.prince.x - kx) > TUNING.DOOR_REACH * 0.5 && ticks < 60 * 120) { | |
| scene.update(1 / 60, { ...NONE, [s.prince.x < kx ? 'right' : 'left']: true }); | |
| ticks++; | |
| } | |
| drive(1.4, { torch: true }); | |
| // HIS ORDERS ON TOP, so the press can only be for the key. It used to go | |
| // to the letter first; letters stay on the door now, and a fragment on | |
| // top at a door that is not its own would pin there and wake somebody. | |
| s.sheaf.index = 0; | |
| tap(); | |
| keyRows.push({ house: s.houses[k].kind, at: Math.round(kx), | |
| open: s.houses[k].open, taken: s.prince.keys > before }); | |
| } | |
| const allKeys = keyRows.every((r) => r.taken); | |
| // AND EXACTLY ONE THING IT MUST FAIL TO REACH. The loom house key is | |
| // visible from the first time that door is lit and there is no way to get | |
| // it, for the whole level. Asserted, because an unreachable object is | |
| // normally the worst bug this level can have, and this one is the design. | |
| const loom = s.houses[HOUSE_OF.indexOf('home')]; | |
| s.prince.x = s.prince.prevX = loom.x - 300; | |
| s.camera.reset(s.prince.x); | |
| drive(1.6, { torch: true }); | |
| const heldBefore = s.prince.keys; | |
| s.sheaf.index = 0; // his orders on top: nothing to pin here | |
| tap(); tap(); tap(); | |
| const residue = { lit: loom.seen, open: loom.open, taken: loom.keyTaken, | |
| gotNothing: s.prince.keys === heldBefore }; | |
| return { | |
| name: 'nothing out of reach', | |
| pass: allTaken && rows.length === TUNING.GATE_WANTS && | |
| allKeys && keyRows.length === TUNING.GATE_WANTS && | |
| residue.lit && !residue.open && !residue.taken && residue.gotNothing, | |
| objects: rows.length, allReachable: allTaken, | |
| keys: keyRows.length, everyKeyReachable: allKeys, | |
| theOneThingHeCannotReach: residue, | |
| walkableTo: s.gateStopX, rows, keyRows, | |
| }; | |
| } | |
| /** | |
| * A FIGHT NOW COSTS SOMETHING. | |
| * | |
| * Village one played like a beat 'em up because beating up every Drained cost | |
| * nothing: three hits, no health, no death. The optimal play was to hit | |
| * everything and the game agreed. At zero risk there is no search at all, | |
| * just a sweep — and a sweep cannot be tedious, because it is not looking for | |
| * anything. | |
| * | |
| * What this asserts is the shape of the cost, not that it is fun: | |
| * | |
| * - A man standing in the dark doing nothing is still harmless. The level's | |
| * whole argument is that nothing is hostile until it is looked at, and | |
| * health must not quietly take that away. | |
| * - Six catches, not five and not seven, and the sixth restarts the LEVEL. | |
| * - The restart takes the findings with it. A checkpoint that keeps the map | |
| * is too forgiving to change how anybody plays. | |
| * - Grace: one contact costs one hit, not one per frame of touching. | |
| * - It shows on his body — the swing slows and the stance changes — and | |
| * nowhere else. There is no bar. | |
| */ | |
| function healthCosts() { | |
| scene.reset(); | |
| const v = s.drained[0]; | |
| // 1. unlit, and standing right on top of it | |
| s.prince.x = s.prince.prevX = v.x - 40; | |
| s.camera.reset(s.prince.x); | |
| drive(2); | |
| const harmless = s.prince.hits === 0 && v.state === 'WANDER'; | |
| // 2. GRACE: one contact is one hit, not one per frame of touching. | |
| // | |
| // `v.takeHit` hurts the DRAINED — it is what wakes this one up. What hurts | |
| // the prince is being in contact with it once it is rushing. | |
| v.takeHit(s.prince.x); | |
| let n = 0; | |
| while (s.prince.hits === 0 && n < 60 * 8) { scene.update(1 / 60, { ...NONE }); n++; } | |
| const first = s.prince.hits; | |
| // Then pinned against it, well inside the grace window. | |
| const held = Math.round((TUNING.HURT_GRACE_MS / 1000) * 60 * 0.6); | |
| for (let i = 0; i < held; i++) { | |
| s.prince.x = s.prince.prevX = v.x - 30; | |
| scene.update(1 / 60, { ...NONE }); | |
| } | |
| const oneContact = first === 1 && s.prince.hits === 1; | |
| // 3. it shows on him | |
| const fresh = TUNING.SWING_SLOW[0]; | |
| const worn = TUNING.SWING_SLOW[TUNING.SWING_SLOW.length - 1]; | |
| s.prince.hits = TUNING.LIMP_FROM; | |
| s.prince.x = s.prince.prevX = BOWL.X0 + 200; | |
| drive(0.5); | |
| // IT SHOWS ON HIM, and this used to mean a "holding his side" pose. That | |
| // pose is gone — the eye dimming carries the readout, and a placeholder | |
| // body next to her standing drawing was worse than no pose change at all. | |
| // What has to stay true is that being hurt is VISIBLE without a bar. | |
| const dimmed = s.prince.eyeDim > 0; | |
| // 4. six, and the level starts again with the findings gone | |
| scene.reset(); | |
| // RUN THE CHECK TWICE AND IT USED TO LIE. `scene.reset()` restarts the | |
| // LEVEL; `run.deaths` is a tally for the RUN and deliberately survives it, | |
| // so the second call started with deaths already at 1, the loop below | |
| // never ran, and the result reported a prince who never took a hit as a | |
| // health failure. Count the deaths this call causes, not the run's total. | |
| const deaths0 = s.run.deaths; | |
| // WHERE A RESET PUTS HIM, asked rather than assumed. This was `x < 1000`, | |
| // which is only true when the run started at the road — so the check failed | |
| // on `?at=bowl` and blamed health for it. A check that cries wolf on a debug | |
| // flag gets ignored on the day it is right. | |
| const spawnX = s.prince.x; | |
| s.houses[0].seen = true; | |
| s.prince.inventory.push({ kept: 0, answers: 'the forge', seen: true }); | |
| s.prince.x = s.prince.prevX = v.x - 30; | |
| s.camera.reset(s.prince.x); | |
| // SAMPLED AS IT HAPPENS, not once per approach. Reading `hits` after a | |
| // 2.2s drive misses the sixth entirely when the death lands inside that | |
| // drive — the restart zeroes it before anyone looks. It showed up the day | |
| // the reveal dwell changed and the timing shifted by a few hundred ms. | |
| let caught = 0; | |
| const step = (secs, input) => { | |
| const n = Math.round(secs / 0.1); | |
| for (let i = 0; i < n && s.run.deaths === deaths0; i++) { | |
| drive(0.1, input); | |
| caught = Math.max(caught, s.prince.hits); | |
| } | |
| }; | |
| for (let k = 0; k < 14 && s.run.deaths === deaths0; k++) { | |
| s.prince.x = s.prince.prevX = v.x - 30; | |
| s.camera.reset(s.prince.x); | |
| step(1.5, { torch: true }); | |
| step(2.2, {}); | |
| } | |
| const died = { | |
| tookSix: caught === TUNING.PRINCE_HITS, | |
| deaths: s.run.deaths - deaths0, | |
| backToTheStart: Math.abs(s.prince.x - spawnX) < 200, | |
| lostTheMap: !s.houses[0].seen && s.prince.inventory.length === 0, | |
| hitsReset: s.prince.hits === 0, | |
| }; | |
| return { | |
| name: 'health costs', | |
| pass: harmless && oneContact && dimmed && worn > fresh * 1.5 && | |
| died.tookSix && died.deaths === 1 && died.backToTheStart && | |
| died.lostTheMap && died.hitsReset, | |
| unlitIsStillHarmless: harmless, | |
| graceHoldsForOneHit: oneContact, | |
| theLightIsDimmerOnHim: dimmed, | |
| swingSlowsBy: +(worn / fresh).toFixed(2), | |
| died, | |
| }; | |
| } | |
| // --- walking home ------------------------------------------------------- | |
| // | |
| // Put a fragment in the right frame and the person it belonged to gets up, | |
| // walks to their own door and opens it. That is the first confirmation this | |
| // level has ever given, and it is given by the world rather than by a lock: | |
| // nothing is scored, nothing is ticked, and the wrong answer is not refused. | |
| /** Down every carrier and take its fragment, so there is something to file. */ | |
| function armed(seed = 7) { | |
| scene.reset(seed); | |
| const carriers = s.drained.filter((d) => d.kept != null); | |
| for (const c of carriers) { | |
| s.prince.x = s.prince.prevX = c.x - 200; | |
| s.camera.reset(s.prince.x); | |
| c.hits = 99; | |
| c.takeHit(c.x - 1); | |
| drive(1.0); // STAGGER is 500ms; it drops on DOWNED | |
| s.prince.x = s.prince.prevX = c.keptX; | |
| s.camera.reset(s.prince.x); | |
| drive(1.4, { torch: true }); // look at it, so the page is not blank | |
| drive(0.2, { pickup: true }); | |
| drive(0.15); | |
| } | |
| return carriers; | |
| } | |
| /** | |
| * Stand at house i with the sheaf up, on the page for fragment k. | |
| * | |
| * THE DOOR IS LIT FIRST, the one time it needs to be: the slot takes a pin | |
| * from the first light, and the letter stays where it is. | |
| */ | |
| function atDoor(i, k = null) { | |
| s.prince.x = s.prince.prevX = s.houses[i].x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| if (!s.houses[i].seen) drive(1.4, { torch: true }); | |
| if (k !== null) { | |
| s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === k); | |
| } | |
| drive(1.2, { sheaf: true }); | |
| } | |
| /** One press of take, on the edge. Holding it would toggle. */ | |
| function tap(extra = {}) { | |
| drive(0.2, { ...extra, pickup: true }); | |
| drive(0.15, extra); | |
| } | |
| /** | |
| * THE TOP PAGE IS WHAT IS IN HIS HAND, and it stays there when the pile | |
| * goes away. | |
| * | |
| * The sheaf is for CHOOSING. Letting go of Q is putting the rest of the | |
| * papers back in your coat, not putting down the one you picked. | |
| */ | |
| function topPageIsTheHand() { | |
| armed(); | |
| const held = () => s.handPage; | |
| // Page to a fragment and let go. | |
| s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'fragment'); | |
| drive(0.6, { sheaf: true }); | |
| const upIsFragment = held()?.kind === 'fragment'; | |
| drive(0.6); // Q released | |
| const stillFragment = held()?.kind === 'fragment' && !s.sheaf.up; | |
| // A letter is a different shape in the hand, and it can be on top too. | |
| // The only one he carries is his orders — the rest stay on their doors. | |
| const li = s.sheaf.pages.findIndex((p) => p.kind === 'letter'); | |
| s.sheaf.index = li; | |
| drive(0.6); | |
| const letterOnTop = held()?.kind === 'letter'; | |
| // AND TAKING SOMETHING PUTS IT ON TOP, which is the whole tutorial: knock | |
| // one down, take the scrap, walk to the door, press E. No Q anywhere. | |
| const c = s.drained.find((d) => d.kept != null && !d.keptTaken) | |
| ?? s.drained.find((d) => d.kept != null); | |
| const before = s.sheaf.pages.length; | |
| s.prince.x = s.prince.prevX = s.houses[0].x - 900; | |
| s.camera.reset(s.prince.x); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: 3 }); | |
| s.sheaf.index = s.sheaf.pages.length - 1; | |
| drive(0.3); | |
| const takingPutsItOnTop = s.sheaf.pages.length === before + 1 && | |
| held()?.kept === 3; | |
| // An empty pile is an empty hand. | |
| s.sheaf.pages.length = 0; | |
| s.sheaf.index = 0; | |
| drive(0.3); | |
| const emptyIsEmpty = held() == null; | |
| return { | |
| name: 'the top page is the hand', | |
| pass: upIsFragment && stillFragment && letterOnTop && | |
| takingPutsItOnTop && emptyIsEmpty && c != null, | |
| aFragmentSurvivesTheReleaseOfQ: stillFragment, | |
| aLetterCanBeOnTopToo: letterOnTop, | |
| takingPutsItOnTop, andNothingHeldWithAnEmptyPile: emptyIsEmpty, | |
| }; | |
| } | |
| /** | |
| * WHAT `E` DOES AT A DOOR, every row of it, and Q makes no difference to any | |
| * of them. | |
| * | |
| * One button, no modes. It is take-or-leave-the-thing-in-front-of-you, which | |
| * is what E has meant everywhere else in this game since the first object on | |
| * the ground. | |
| */ | |
| function eAtTheDoor() { | |
| const rows = []; | |
| const put = (k) => { | |
| s.sheaf.index = k === null | |
| ? s.sheaf.pages.findIndex((p) => p.kind === 'letter') | |
| : s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === k); | |
| }; | |
| const stand = (i, withQ) => { | |
| s.prince.x = s.prince.prevX = s.houses[i].x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(0.6, withQ ? { sheaf: true } : {}); | |
| }; | |
| for (const withQ of [false, true]) { | |
| armed(); | |
| const extra = withQ ? { sheaf: true } : {}; | |
| const press = () => { drive(0.2, { ...extra, pickup: true }); drive(0.15, extra); }; | |
| // THE BAKEHOUSE, AND THE TWO FRAGMENTS ARE THE FORGE'S AND THE WELL | |
| // HOUSE'S. Both are wrong here, on purpose. | |
| // | |
| // The first pass at this used fragment 1 as "a different fragment" at | |
| // house 1 — which is the bakehouse's OWN fragment, so it was correct, | |
| // its owner walked home, the patch settled, and every row after it | |
| // measured the lock refusing rather than the thing it meant to measure. | |
| // The check was wrong and the game was right. | |
| const H = 1; | |
| const A = 0, B = 2; | |
| const h = s.houses[H]; | |
| // The door has to have been looked at once for anything to be offered, | |
| // which is the latch, not a mode. THERE IS NO LETTER ROW ANY MORE: it | |
| // took the letter, and letters stay on the door. | |
| stand(H, withQ); | |
| drive(1.4, { torch: true }); | |
| // 2. A bare slot, a fragment on top -> pins it. | |
| put(A); stand(H, withQ); press(); | |
| const pinned = h.filed === A; | |
| // 3. A fragment in the patch, a DIFFERENT fragment on top -> swap. | |
| put(B); stand(H, withQ); press(); | |
| const swapped = h.filed === B; | |
| // 4. A fragment in the patch, the SAME one on top -> takes it back. | |
| put(B); stand(H, withQ); press(); | |
| const tookBackSame = h.filed == null; | |
| // 5. A fragment in the patch, NOT a fragment on top -> takes it back. | |
| put(A); stand(H, withQ); press(); // pin one again | |
| put(null); stand(H, withQ); press(); // a letter on top | |
| const tookBackWithLetter = h.filed == null; | |
| // 6. A bare patch, not a fragment on top -> nothing. | |
| put(null); stand(H, withQ); | |
| const offeredWithLetter = s.canMark; | |
| press(); | |
| const bareAndLetterDoesNothing = h.filed == null && !offeredWithLetter; | |
| // 7. A settled patch -> nothing. The lock stands. | |
| const F = 0; | |
| const forge = s.houses[F]; | |
| put(0); stand(F, withQ); | |
| drive(1.4, { torch: true }); | |
| put(0); stand(F, withQ); press(); // the forge's own, so it settles | |
| const settled = forge.settled && forge.filed === 0; | |
| put(B); stand(F, withQ); press(); // try to swap it | |
| const lockHeld = forge.filed === 0 && settled; | |
| rows.push({ withQ, pinned, swapped, tookBackSame, | |
| tookBackWithLetter, bareAndLetterDoesNothing, lockHeld }); | |
| } | |
| scene.reset(); | |
| const ok = rows.every((r) => Object.entries(r) | |
| .every(([k, v]) => k === 'withQ' || v === true)); | |
| // AND Q CHANGES NOTHING. Every row, both ways, identical. | |
| const sameEitherWay = JSON.stringify({ ...rows[0], withQ: 0 }) === | |
| JSON.stringify({ ...rows[1], withQ: 0 }); | |
| return { | |
| name: 'E at the door', | |
| pass: ok && sameEitherWay, | |
| everyRowHolds: ok, | |
| andTheSheafMakesNoDifference: sameEitherWay, | |
| rows, | |
| }; | |
| } | |
| /** | |
| * THE WALL SAYS WHERE IT GOES, AND THE SKY SAYS IT LANDED. | |
| * | |
| * A ten-year-old played the level, picked the scraps up, probably read some | |
| * of them, and never worked out that a scrap goes on a wall. She was right to | |
| * miss it. The patch was made deliberately quiet — "a value, not an outline, | |
| * it must not read as UI" — and it succeeded so completely that it asked | |
| * nothing at all. The only thing that had ever said otherwise was the debug | |
| * readout printing "[E] PIN IT HERE", and that was on the whole time anybody | |
| * tested it. | |
| * | |
| * Two things, and one without the other still fails: | |
| * | |
| * THE WALL SHOWS WHAT WOULD GO THERE. Stand at a door holding a scrap and | |
| * a ghost of that exact page appears in the patch. It is not a new idea — | |
| * the sheaf sheet has always drawn this ghost — it was just drawn on the | |
| * paper in his hand instead of on the wall he was looking at. | |
| * | |
| * THE SKY GOES OFF WHEN IT LANDS. A correct pin was silent, and its payoff | |
| * happens somewhere else up to a minute later, so the one right act in the | |
| * level looked exactly like nothing. | |
| * | |
| * NEITHER IS A VERDICT. The ghost appears for a wrong scrap just as readily, | |
| * because it previews an act rather than judging one. | |
| */ | |
| function theWallAsksAndTheSkyAnswers() { | |
| return withOverlayOff(() => { | |
| const clip = s.houseClip; | |
| const patch = (h) => { | |
| const [nx, ny, nw, nh] = clip.slotAt[h.variant]; | |
| return [Math.round(h.x - s.camera.x - clip.pivotX + nx), | |
| Math.round(GROUND_Y - clip.pivotY + ny), nw, nh]; | |
| }; | |
| // RENDER WITHOUT DRIVING. The first pass at this called a helper that | |
| // walked him to the door and held the torch for 1.4s before every single | |
| // render, so the two frames being compared were a second and a half of | |
| // world apart: the grain floor came out at 5270 against a signal of 7791 | |
| // and the check was measuring dust. Three ticks is what `canPin` needs to | |
| // notice the page changed, and nothing else moves in three ticks. | |
| // AND WITH THE TORCH HELD. Everything in a doorway — the room, the nail, | |
| // the patch, the key — is drawn in the LIT pass and the lit pass only | |
| // runs while the torch is up. Ticking three frames with no input let it | |
| // fall, so the first version of this measured a wall nothing was drawn | |
| // on: every comparison came back 0 except the one that accidentally | |
| // caught the lightning. | |
| // NOT CALLED `TORCH`. It was, and it shadowed the TORCH this file | |
| // imports from tuning.js — so `TORCH.KEPT_PULSE_HZ` a few lines down was | |
| // undefined, the clock went NaN, and the renderer died trying to build a | |
| // gradient stop out of it. The same shadowing that once made the whole | |
| // module refuse to load, in a new place. | |
| const HOLDING = { ...NONE, torch: true }; | |
| const shot = (h) => { | |
| for (let i = 0; i < 3; i++) scene.update(1 / 60, HOLDING); | |
| s.clock = 3.0; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| return ctx.getImageData(...patch(h)).data; | |
| }; | |
| // BRIGHTNESS, NOT A PIXEL DIFF. | |
| // | |
| // Counting changed subpixels is what every other pixel check here does, | |
| // and it cannot work with the torch held: the flame flickers, so two | |
| // renders of an unchanged wall differed in 12,668 of 17,024 subpixels — | |
| // a noise floor above every signal. A mean is almost blind to the | |
| // flicker and is the honest measure anyway, because what a page on a | |
| // wall does is put ink on it. | |
| const brightness = (dd) => { | |
| let sum = 0; | |
| for (let k = 0; k < dd.length; k += 4) sum += lum(dd, k); | |
| return Math.round(sum / (dd.length / 4)); | |
| }; | |
| // THE TORCH NEVER GOES DOWN IN HERE. It takes 250ms to rise, so a drive | |
| // without it drops the light and the next three ticks are measuring a | |
| // half-lit wall getting brighter — which came out as the patch gaining 89 | |
| // luminance when a page was put on it, and a "flicker floor" of 32. | |
| const stand = (h) => { | |
| s.prince.x = s.prince.prevX = h.x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(1.4, { torch: true }); | |
| drive(0.5, { torch: true }); // settled, and staying up | |
| }; | |
| const put = (k) => { | |
| s.sheaf.index = k === null | |
| ? s.sheaf.pages.findIndex((p) => p.kind === 'letter') | |
| : s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === k); | |
| }; | |
| armed(); | |
| const forge = s.houses[0]; | |
| stand(forge); | |
| // 1. HIS ORDERS ON TOP IS A BARE SLOT, and two renders of it back to back | |
| // are the grain floor everything else is measured against. | |
| put(null); | |
| const bare = brightness(shot(forge)); | |
| const noise = Math.abs(brightness(shot(forge)) - bare); | |
| // 1b. AND AN EMPTY SLOT DOES NOT GLOW. THIS CLAIM IS INVERTED. | |
| // | |
| // It used to breathe, with the same pulse a dropped scrap has: the | |
| // patch only appeared once the letter came off, and nothing walked a | |
| // player over to find it. The slot is under the letter from the first | |
| // light now, and a bare nail under a letter asks its own question. A | |
| // pulse on a wall reads as UI — and the loom house's empty slot has to | |
| // read as a letter nobody answered, all level, not as a button. | |
| // | |
| // Measured over one full cycle of KEPT_PULSE_HZ, on a box wider than | |
| // the slot so any halo around it would be counted too. | |
| const wide = (h) => { | |
| const [nx, ny, nw, nh] = clip.slotAt[h.variant]; | |
| return [Math.round(h.x - s.camera.x - clip.pivotX + nx) - 26, | |
| Math.round(GROUND_Y - clip.pivotY + ny) - 26, nw + 52, nh + 52]; | |
| }; | |
| const breath = (h) => { | |
| const seen = []; | |
| for (let i = 0; i < 12; i++) { | |
| s.clock = 3.0 + (i / 12) / TORCH.KEPT_PULSE_HZ; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| seen.push(brightness(ctx.getImageData(...wide(h)).data)); | |
| } | |
| s.clock = 3.0; | |
| return Math.round(Math.max(...seen) - Math.min(...seen)); | |
| }; | |
| const emptyIsStill = breath(forge); | |
| // 2. A SCRAP ON TOP AND THE WALL SHOWS IT. | |
| put(0); | |
| const ghosted = brightness(shot(forge)); | |
| const ghostShows = bare - ghosted; // ink, so it gets darker | |
| // 3. AND IT IS A GHOST, NOT THE THING. Pinning for real has to change the | |
| // same pixels again, or the preview is a lie. | |
| drive(0.2, { torch: true, pickup: true }); | |
| // THE SKY, READ NOW. It lasts 560ms; the first version of this read it | |
| // after a 1.4s render helper and got zero, which is a check measuring its | |
| // own staging rather than the game. | |
| const struck = s.flash > 0; | |
| drive(0.15, { torch: true }); | |
| const pinned = brightness(shot(forge)); | |
| const ghostIsNotThePin = ghosted - pinned; // solid, so darker again | |
| // AND A FULL ONE IS STILL TOO. Nothing on this wall pulses, ever. | |
| const filledIsStill = breath(forge); | |
| // 4. AND IT IS OVER. Lightning, not a filter. | |
| drive(1.2, { torch: true }); | |
| const gone = !(s.flash > 0) && (s.flashMs ?? -1) < 0; | |
| // 5. A WRONG SCRAP PREVIEWS JUST THE SAME — it is an act, not an opinion | |
| // about one — AND THE SKY STAYS SHUT. | |
| armed(); | |
| const bake = s.houses[1]; | |
| stand(bake); | |
| put(null); | |
| const bareB = brightness(shot(bake)); | |
| put(0); // the forge's, at the bakehouse | |
| const wrongGhost = bareB - brightness(shot(bake)); | |
| drive(0.2, { torch: true, pickup: true }); | |
| const quiet = !(s.flash > 0) && bake.filed === 0; | |
| return { | |
| name: 'the wall asks and the sky answers', | |
| // The flicker moves the mean by a point or two; ink moves it by tens. | |
| pass: ghostShows > Math.max(8, noise * 3) && | |
| ghostIsNotThePin > Math.max(8, noise * 3) && | |
| wrongGhost > Math.max(8, noise * 3) && | |
| emptyIsStill <= 2 && filledIsStill <= 2 && | |
| struck && quiet && gone, | |
| flickerMovesTheMeanBy: noise, | |
| anEmptySlotMovesBy: emptyIsStill, | |
| andAFilledOneBy: filledIsStill, | |
| theWallPreviewsTheScrap: ghostShows, | |
| andItPreviewsAWrongOneToo: wrongGhost, | |
| theGhostIsNotThePin: ghostIsNotThePin, | |
| theSkyGoesOffForARightOne: struck, | |
| andStaysShutForAWrongOne: quiet, | |
| andTheStrikeEnds: gone, | |
| }; | |
| }); | |
| } | |
| function walksHome() { | |
| const carriers = armed(); | |
| const owner = carriers.find((d) => d.kept === 0); // the forge's | |
| // 1. THE WRONG FRAME WAKES THEM. This was "the wrong frame does nothing", | |
| // and that is exactly what made the walk home an answer key: pin, see | |
| // who walks, try the next door, never read a word. Now a stranger's | |
| // house wakes the owner and it comes for him. The page stays on the | |
| // wall, and nothing opens or settles. | |
| atDoor(1, 0); | |
| tap({ sheaf: true }); | |
| const seen = [owner.state]; | |
| for (let i = 0; i < 60 * 3; i++) { | |
| scene.update(1 / 60, NONE); | |
| if (seen[seen.length - 1] !== owner.state) seen.push(owner.state); | |
| } | |
| const wrong = { | |
| onTheWall: s.houses[1].filed === 0, | |
| rose: seen.includes('RISING'), | |
| cameForHim: owner.state === 'RUSH' && !owner.homing, | |
| states: seen.join(' > '), | |
| nothingOpened: s.houses.every((h) => !h.open), | |
| nothingSettled: s.houses.every((h) => !h.settled), | |
| }; | |
| // Put it back down before it reaches him. What follows is about the walk | |
| // home, not a fight; wrongDoorWakes owns the fight. | |
| owner.hits = 99; | |
| owner.takeHit(owner.x - 1); | |
| drive(1.0); | |
| // 2. AND IT COMES BACK OUT. Brute force is allowed: four fragments, five | |
| // frames, and nothing stops a player working through them. | |
| atDoor(1, 0); | |
| tap({ sheaf: true }); | |
| const pulledBack = { | |
| frameEmpty: s.houses[1].filed == null, | |
| stillInTheSheaf: s.sheaf.pages.some((p) => p.kind === 'fragment' && p.kept === 0), | |
| }; | |
| // 3. THE RIGHT FRAME AND HE GETS UP. Nothing is said, here or anywhere. | |
| // From wherever it went down last — which, after step 1, is not where | |
| // it spawned. | |
| atDoor(0, 0); | |
| const from = owner.x; | |
| const pinnedAt = s.clock; | |
| tap({ sheaf: true }); | |
| const filed = { | |
| onTheWall: s.houses[0].filed === 0, | |
| settled: s.houses[0].settled, | |
| gotUp: owner.state === 'RISING' || owner.state === 'HOMING', | |
| walkingTo: owner.homeX, | |
| noVerdictField: !('correct' in s.houses[0]) && !('wrong' in s.houses[0]), | |
| }; | |
| // 4. AND THE FRAME IS FINISHED WITH. Not a cost — it stops one thing, and | |
| // that is taking a name back off a wall after its owner has set off. | |
| atDoor(0, 0); | |
| const markOffered = s.canMark; | |
| tap({ sheaf: true }); | |
| const settled = { stillFiled: s.houses[0].filed === 0, markOffered }; | |
| // 5. IT ARRIVES, AND ONLY ITS OWN DOOR OPENS. | |
| let ticks = 0; | |
| while (owner.state !== 'HOME' && ticks < 60 * 200) { scene.update(1 / 60, NONE); ticks++; } | |
| const walked = Math.abs(s.houses[0].x - from); | |
| const arrived = { | |
| home: owner.state === 'HOME', | |
| atTheDoor: Math.round(owner.x) === s.houses[0].x, | |
| doorsOpen: s.houses.filter((h) => h.open).length, | |
| // From the pin, not from here: steps 4's second and a half is part of | |
| // the walk, and counting from after it hid behind the old error in | |
| // where the walk started. | |
| seconds: +(s.clock - pinnedAt).toFixed(1), | |
| px: Math.round(walked), | |
| // Derived from HOME_SPEED rather than written down, plus the rise. | |
| expected: +(walked / TUNING.HOME_SPEED + TUNING.RISE_MS / 1000).toFixed(1), | |
| }; | |
| // 6. AND NOW IT IS FURNITURE. Hitting it does nothing, like a downed one. | |
| const where = owner.x; | |
| const hit = owner.takeHit(owner.x - 1); | |
| drive(3); | |
| const frozen = { ignoredTheSword: hit === false, didNotMove: owner.x === where, | |
| stillHome: owner.state === 'HOME' }; | |
| return { | |
| name: 'walks home', | |
| pass: wrong.onTheWall && wrong.rose && wrong.cameForHim && wrong.nothingOpened && | |
| wrong.nothingSettled && | |
| pulledBack.frameEmpty && pulledBack.stillInTheSheaf && | |
| filed.onTheWall && filed.settled && filed.gotUp && | |
| filed.walkingTo === s.houses[0].x && filed.noVerdictField && | |
| settled.stillFiled && !settled.markOffered && | |
| arrived.home && arrived.atTheDoor && arrived.doorsOpen === 1 && | |
| Math.abs(arrived.seconds - arrived.expected) < 1.5 && | |
| frozen.ignoredTheSword && frozen.didNotMove && frozen.stillHome, | |
| theWrongFrameWakesThem: wrong, | |
| andItComesBackOut: pulledBack, | |
| theRightFrameSendsThemHome: filed, | |
| andThenTheFrameIsFinishedWith: settled, | |
| arrived, frozen, | |
| }; | |
| } | |
| /** | |
| * THE KEY IS BEHIND THE DOOR, and the two door states are the whole point. | |
| * | |
| * LIT is what the torch does: you can see in, and the key is hanging there. | |
| * OPEN is what the person does: you can reach in. You can see it the whole | |
| * time and not have it, and nothing anywhere says so — the warm halo is | |
| * already this game's one word for "you can pick this up", and it simply is | |
| * not on yet. | |
| */ | |
| function keyBehindTheDoor() { | |
| return withOverlayOff(() => { | |
| const carriers = armed(); | |
| // 1. VISIBLE THE MOMENT A DOOR IS LIT, and measured rather than assumed: | |
| // the key's own rectangle has to carry ink against the lit doorway, | |
| // and it has to sit inside the light from where he stands to read it. | |
| const clip = s.houseClip; | |
| const rows = s.houses.map((h) => { | |
| s.prince.x = s.prince.prevX = h.x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(1.6, { torch: true }); | |
| s.clock = 3.0; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| const [kx, ky, kw, kh] = clip.keyAt[h.variant]; | |
| const x = Math.round(h.x - s.camera.x - clip.pivotX + kx); | |
| const y = Math.round(GROUND_Y - clip.pivotY + ky); | |
| const px = ctx.getImageData(x, y, kw, kh).data; | |
| let ink = 0, light = 0; | |
| for (let i = 0; i < px.length; i += 4) { | |
| const L = lum(px, i); | |
| if (L < 60) ink++; else if (L > 140) light++; | |
| } | |
| const p = s.keyPoint(h); | |
| return { house: h.kind, ink, light, litByTheTorch: s.torch.covers(p[0], p[1]), | |
| offered: s.canTakeKey }; | |
| }); | |
| const visible = rows.every((r) => r.ink > 120 && r.light > 200 && r.litByTheTorch); | |
| const noneOfferedWhileShut = rows.every((r) => !r.offered); | |
| // THE DOOR CARRIES FOUR THINGS NOW — letter, slot, key and room — and | |
| // whether they all fit is its own check. See theDoorCarriesFour. | |
| // 2. NOT HIS WHILE THE DOOR IS SHUT. | |
| // HIS ORDERS ON TOP. The top page after armed() is a fragment, and E at | |
| // a door that is not its own pins it there — and wakes its owner. | |
| s.prince.x = s.prince.prevX = s.houses[0].x - 300; | |
| drive(1.6, { torch: true }); | |
| s.sheaf.index = 0; | |
| tap(); tap(); | |
| const shut = { keys: s.prince.keys, taken: s.houses[0].keyTaken }; | |
| // 3. HIS ONCE SOMEBODY OPENS IT. | |
| for (const k of [0, 1, 2, 4]) { atDoor(k, k); tap({ sheaf: true }); } | |
| let ticks = 0; | |
| while (s.drained.some((d) => d.homing) && ticks < 60 * 250) { | |
| scene.update(1 / 60, NONE); ticks++; | |
| } | |
| for (const k of [0, 1, 2, 4]) { | |
| s.prince.x = s.prince.prevX = s.houses[k].x - 300; | |
| s.camera.reset(s.prince.x); | |
| drive(1.6, { torch: true }); | |
| s.sheaf.index = 0; // his orders on top: the press is for the key | |
| tap(); | |
| } | |
| const open = { keys: s.prince.keys, taken: s.houses.map((h) => h.keyTaken) }; | |
| // 4. AND THE LOOM HOUSE KEY HANGS THERE FOR EVER. She took her own when | |
| // she went to the palace and nobody is coming back to open it from | |
| // inside. It needs no code: nothing carries that fragment, so nothing | |
| // ever walks to that door. | |
| s.prince.x = s.prince.prevX = s.houses[3].x - 300; | |
| s.camera.reset(s.prince.x); | |
| drive(1.6, { torch: true }); | |
| const offeredAtTheLoom = s.canTakeKey; | |
| s.sheaf.index = 0; | |
| tap(); tap(); tap(); | |
| const residue = { open: s.houses[3].open, taken: s.houses[3].keyTaken, | |
| offered: offeredAtTheLoom, keys: s.prince.keys }; | |
| return { | |
| name: 'the key is behind the door', | |
| pass: visible && noneOfferedWhileShut && | |
| shut.keys === 0 && !shut.taken && | |
| open.keys === 4 && open.taken.filter(Boolean).length === 4 && | |
| !residue.open && !residue.taken && !residue.offered && residue.keys === 4 && | |
| carriers.length === 4, | |
| visibleInEveryLitDoorway: visible, | |
| andNotOfferedWhileShut: noneOfferedWhileShut, | |
| shut, open, | |
| theLoomHouseNeverOpens: residue, | |
| rows, | |
| }; | |
| }); | |
| } | |
| /** | |
| * THE RESIDUE STAYS LOCKED, across a hundred seeds. | |
| * | |
| * The loom house has a letter and nothing in the forest answers it. Four | |
| * doors open with people standing in them and one shut with a key hanging | |
| * inside it, which is a more legible thing than an absence — and the game | |
| * never says a word about it. | |
| */ | |
| function residueStaysLocked(seeds = 100) { | |
| const LOOM = HOUSE_OF.indexOf('home'); | |
| const bad = []; | |
| let sample = null; | |
| for (let n = 0; n < seeds; n++) { | |
| scene.reset(n * 15485863 + 11); | |
| const row = { | |
| seed: n, | |
| nobodyCarriesIt: !s.drained.some((d) => d.kept === LOOM), | |
| // So nothing can ever be sent there, whatever the player files. | |
| noBodyCanEverHomeToIt: !s.drained.some((d) => d.kept === LOOM), | |
| itsDoorIsShut: !s.houses[LOOM].open, | |
| itsKeyIsStillThere: !s.houses[LOOM].keyTaken, | |
| andTheOtherFourAreCarried: | |
| [0, 1, 2, 4].every((k) => s.drained.some((d) => d.kept === k)), | |
| }; | |
| if (!sample) sample = row; | |
| if (!Object.values(row).every((x) => x === true || typeof x === 'number')) bad.push(row); | |
| } | |
| scene.reset(); | |
| return { | |
| name: 'the residue stays locked', | |
| pass: bad.length === 0, | |
| seedsTried: seeds, broke: bad.length, firstFailure: bad[0] ?? null, sample, | |
| }; | |
| } | |
| /** | |
| * THE GATE, AND IT INVERTS FOR THE SECOND TIME. | |
| * | |
| * It once wanted nothing. Then it wanted four fragments he had LOOKED at, | |
| * and that is now deleted rather than switched off — looking at four pieces | |
| * of paper is not the same act as understanding one, which is exactly how | |
| * the level came to be beaten without a word being read. | |
| * | |
| * IT WANTS FOUR KEYS. A key comes off a hook behind a door that somebody | |
| * opened from the inside, and the only thing that makes them open it is | |
| * being given their own name back. The gate still never reads the map, still | |
| * never grades an answer, and a completely wrong sheaf still walks through. | |
| * | |
| * 1. Torch down, he reaches the gate, fights nothing, and it stays shut. | |
| * 2. Four fragments taken and read leave it shut. That is the old | |
| * condition, asserted as a NEGATIVE so it cannot quietly come back. | |
| * 3. Three keys leave it shut. Four open it. | |
| * 4. Through it ends the playthrough. | |
| */ | |
| function theGate() { | |
| scene.reset(); | |
| drive((s.gateStopX / TUNING.WALK_SPEED) + 8, { right: true }); | |
| const walked = { | |
| reached: Math.round(s.prince.x) === s.gateStopX, | |
| fights: s.drained.filter((d) => d.revealed).length, | |
| shut: s.gate.open < 1, | |
| marks: s.gate.filled, | |
| }; | |
| // THE OLD CONDITION, AND IT MUST NOT WORK. Four fragments, taken, read, | |
| // and even filed under houses — the gate does not look at any of it. | |
| for (const k of [0, 1, 2, 4]) { | |
| s.prince.inventory.push({ kept: k, answers: null, seen: true }); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: k }); | |
| s.houses[k].filed = k; | |
| } | |
| drive(2); | |
| const paperAlone = { | |
| carrying: s.prince.inventory.length, | |
| filed: s.houses.filter((h) => h.filed != null).length, | |
| marks: s.gate.filled, | |
| shut: s.gate.open < 1, | |
| }; | |
| // AND NOW KEYS. Handed over rather than earned — walking four bodies home | |
| // is walksHome()'s job, and this one is only about the leaf. | |
| s.prince.keys = 3; | |
| s.prince.x = s.prince.prevX = s.gateStopX; | |
| s.camera.reset(s.prince.x); | |
| drive(0.3); | |
| for (let i = 0; i < 3; i++) { drive(0.2, { pickup: true }); drive(0.15); } | |
| drive(2); | |
| const three = { placed: s.gate.placed, marks: s.gate.filled, shut: s.gate.open < 1 }; | |
| s.prince.keys = 1; | |
| drive(0.2, { pickup: true }); | |
| drive(0.15); | |
| drive(2 + TUNING.GATE_OPEN_MS / 1000); | |
| const four = { placed: s.gate.placed, marks: s.gate.filled, open: s.gate.open >= 1, | |
| keysLeft: s.prince.keys }; | |
| drive(6, { right: true }); | |
| const done = { through: s.gate.through, fade: s.fade > 0.9 }; | |
| // And the gatekeeper himself: braced, shoving rather than killing, and | |
| // never once off the post he has held for sixty years. | |
| // | |
| // HIS CYCLE IS RESET WITH HIM NOW. It used to survive, so this same fight | |
| // measured 1.3s and shoved nobody or 1.9s and shoved twice depending on | |
| // what the previous check left him mid-swing at. | |
| scene.reset(); | |
| const gk = s.village[s.village.length - 1]; | |
| const home = gk.x; | |
| s.prince.x = s.prince.prevX = s.gateStopX; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| let ticks = 0; | |
| let shoves = 0; | |
| let last = s.prince.x; | |
| while (gk.state !== 'DOWNED' && ticks < 60 * 45) { | |
| scene.update(1 / 60, { ...NONE, attack: true }); | |
| ticks++; | |
| drive(0.42); | |
| ticks += 25; | |
| if (s.prince.x < last - 60) shoves++; | |
| last = s.prince.x; | |
| if (s.prince.x < s.gateStopX - 10) { | |
| const w = (s.gateStopX - s.prince.x) / TUNING.WALK_SPEED + 0.1; | |
| drive(w, { right: true }); | |
| ticks += Math.round(w * 60); | |
| last = s.prince.x; | |
| } | |
| } | |
| const keeper = { | |
| down: gk.state === 'DOWNED', hits: gk.hits, | |
| movedFromHisPost: Math.round(gk.x - home), | |
| shovedRatherThanKilled: shoves > 0 && !s.loss.active, | |
| seconds: +(ticks / 60).toFixed(1), | |
| }; | |
| return { | |
| name: 'the gate', | |
| pass: walked.reached && walked.fights === 0 && walked.shut && walked.marks === 0 && | |
| paperAlone.carrying === 4 && paperAlone.filed === 4 && | |
| paperAlone.marks === 0 && paperAlone.shut && | |
| three.placed === 3 && three.marks === 3 && three.shut && | |
| four.placed === 4 && four.marks === 4 && four.open && four.keysLeft === 0 && | |
| done.through && done.fade && | |
| keeper.down && keeper.movedFromHisPost === 0 && keeper.shovedRatherThanKilled, | |
| walked, | |
| readingAloneNeverOpensIt: paperAlone, | |
| shutOnThree: three, opensOnFour: four, done, keeper, | |
| }; | |
| } | |
| /** | |
| * MARKING IS A CLAIM, NOT A RECEIPT. | |
| * | |
| * The map used to file an object under its own house the moment he looked at | |
| * it, which meant the document could never be wrong — and a document that | |
| * cannot be wrong says nothing when it is right. Everything asserted here is | |
| * about that difference: he writes it, he can change it, nothing tells him | |
| * whether he is correct, and the empty plot is never flagged. | |
| */ | |
| function markingIsAClaim() { | |
| scene.reset(); | |
| // Stand at house i's door with the sheaf up, the door lit. | |
| const at = (i) => atDoor(i); | |
| const mark = () => drive(0.3, { sheaf: true, pickup: true }); | |
| const filed = () => s.houses.map((h) => h.filed); | |
| // Light two doors so there is somewhere to file, and take two fragments. | |
| at(0); at(1); | |
| s.prince.x = s.prince.prevX = s.houses[0].x - 1500; | |
| for (const k of [2, 3]) { | |
| s.prince.inventory.push({ kept: k, answers: null, seen: true }); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: k }); | |
| } | |
| const fragA = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === 2); | |
| const fragB = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === 3); | |
| // 1. NOTHING FILES ITSELF. He is carrying two he has looked at and every | |
| // nail is still bare. | |
| const nothingAutoFiles = filed().every((f) => f == null); | |
| // 1b. AND A DOOR TAKES A PIN THE FIRST TIME IT IS LIT. THIS CLAIM IS | |
| // INVERTED. | |
| // | |
| // It used to assert that there was nowhere to file until the letter | |
| // came off the nail: one spot, the letter on it, and the patch it left | |
| // was the slot. Letters stay on the door now and the slot is under the | |
| // letter from the first light, so a door he has only just lit takes a | |
| // pin like any other. It is the MOVE in claim 2 that takes it off again. | |
| const unread = s.houses[2]; | |
| const neverLit = !unread.seen; | |
| s.prince.x = s.prince.prevX = unread.x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| s.sheaf.index = fragA; | |
| drive(1.2, { sheaf: true }); | |
| const offeredOnFirstLight = unread.seen && s.canMark && s.canPin; | |
| mark(); | |
| const slotFromTheFirstLight = neverLit && offeredOnFirstLight && unread.filed === 2; | |
| // 2. He files one at a lit door. | |
| s.sheaf.index = fragA; at(0); mark(); | |
| const afterFirst = filed(); | |
| // 3. REPLACE. A different page at the same door pushes the first out — | |
| // two fragments can never sit under one house. | |
| s.sheaf.index = fragB; at(0); mark(); | |
| const afterReplace = filed(); | |
| // 4. MOVE. The same page at another door takes it off the first — one | |
| // fragment can never sit under two houses. | |
| at(1); mark(); | |
| const afterMove = filed(); | |
| const onlyOnce = afterMove.filter((f) => f === 3).length === 1; | |
| // 5. AND IT CANNOT BE DONE FROM NOWHERE. Away from any door, marking does | |
| // nothing at all: the door is the evidence he is comparing against. | |
| s.prince.x = s.prince.prevX = s.houses[0].x - 1500; | |
| const before = filed().join(','); | |
| s.sheaf.index = fragA; | |
| mark(); | |
| const needsADoor = filed().join(',') === before; | |
| // 6. AND IT DOES NOT NEED THE SHEAF UP. THIS CLAIM IS INVERTED. | |
| // | |
| // It used to assert the opposite — that pinning with the sheaf down did | |
| // nothing — and that rule was played and it did not survive contact. | |
| // Three conditions and a mode: hold Q, page to the fragment, press E at | |
| // a door. Nobody does that with paper. | |
| // | |
| // The top page of the pile is what he is holding, and E acts on what he | |
| // is holding. So the same press with the sheaf DOWN has to do the same | |
| // thing the press with it up did, and this asserts exactly that. | |
| s.prince.x = s.prince.prevX = s.houses[0].x - 1500; | |
| s.sheaf.index = fragA; | |
| drive(0.4); // sheaf down, walking about | |
| const onTopWithItDown = s.page()?.kept === 2 && !s.sheaf.up; | |
| s.prince.x = s.prince.prevX = s.houses[0].x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(0.5); // NO sheaf, NO torch | |
| const offeredWithItDown = s.canPin; | |
| tap(); // plain E | |
| const pinnedWithItDown = s.houses[0].filed === 2; | |
| // And taking it back down is the same press again. | |
| tap(); | |
| const takenBackWithItDown = s.houses[0].filed == null; | |
| const noSheafNeeded = onTopWithItDown && offeredWithItDown && | |
| pinnedWithItDown && takenBackWithItDown; | |
| // Put the world back where claim 7 expects it. | |
| const before2 = filed().join(','); | |
| // A LETTER IS NOT A CLAIM. Only a torn page off a body can be filed — the | |
| // letter is what the house already says about itself. | |
| s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'letter'); | |
| at(0); mark(); | |
| const mapCannotBeFiled = s.sheaf.index < 0 || filed().join(',') === before2; | |
| // 7. NO VERDICT, EVER. The world answers every filing now — a person | |
| // walking home, or a person coming for you — and that is all it does. | |
| // Nothing in the state says right or wrong: no tick, no score, no | |
| // `correct` or `wrong` field anywhere. An empty plot is not flagged. | |
| // Four filed and one empty is the finding, and it has to look exactly | |
| // like four filed and one not done yet. | |
| const noVerdict = !('correct' in s.houses[0]) && !('wrong' in s.houses[0]) && | |
| s.houses.every((h) => h.filed == null || typeof h.filed === 'number'); | |
| // 8. THROUGH THE GATE, FROZEN. He walks out with a document he made and no | |
| // way to check it — and no way to tidy it either. | |
| s.sheaf.index = fragA; | |
| s.gate.through = true; | |
| const atTheGate = filed().join(','); | |
| at(0); mark(); | |
| const frozenAtTheGate = filed().join(',') === atTheGate; | |
| return { | |
| name: 'marking is a claim', | |
| pass: nothingAutoFiles && slotFromTheFirstLight && noSheafNeeded && | |
| afterFirst[0] === 2 && afterReplace[0] === 3 && | |
| afterMove[0] == null && afterMove[1] === 3 && onlyOnce && | |
| needsADoor && mapCannotBeFiled && noVerdict && | |
| frozenAtTheGate, | |
| nothingFilesItself: nothingAutoFiles, | |
| aDoorTakesAPinTheFirstTimeItIsLit: slotFromTheFirstLight, | |
| afterFirst, afterReplace, afterMove, | |
| oneFragmentOneHouse: onlyOnce, | |
| needsALitDoor: needsADoor, | |
| andDoesNotNeedTheSheafUp: noSheafNeeded, | |
| withTheSheafDown: { onTop: onTopWithItDown, offered: offeredWithItDown, | |
| pinned: pinnedWithItDown, takenBack: takenBackWithItDown }, | |
| aLetterCannotBeFiled: mapCannotBeFiled, | |
| noRightOrWrongAnywhere: noVerdict, | |
| frozenAtTheGate, | |
| }; | |
| } | |
| /** | |
| * THE LIGHT GOES OUT OF HIM, AND IT IS THE HEALTH BAR. | |
| * | |
| * There is no bar and there never was one. He limps, he holds his side, the | |
| * swing slows — and now the one thing always visible on a black silhouette | |
| * dims as he is hurt. | |
| * | |
| * Which makes three promises, and all three are easy to break by accident: | |
| * every pose must HAVE an eye, they must all be the same SIZE, and the dimmed | |
| * eye must never land in the 30-to-90 band this build refuses to put anything | |
| * in. | |
| */ | |
| function theLightGoesOut() { | |
| const princeClips = Object.keys(clips).filter((k) => k.startsWith('prince')); | |
| // 1. EVERY POSE, EVERY FRAME. A pose without one would read as a man whose | |
| // light had already gone out — and `guard` is the pose he takes the | |
| // moment something wakes, which is exactly when you look at him. | |
| // | |
| // EXCEPT THE FALL, which is where it goes out. Her drawing turns his | |
| // face away as he goes over, and the eye goes with it -- the light going | |
| // out of him is the death. So that one clip may lose it, but only once: | |
| // an eye from the first frame, and once it is gone it never comes back. | |
| const missing = []; | |
| for (const k of princeClips) { | |
| const frames = clips[k].frames; | |
| const goesOut = k === 'prince_down' && frames[0]?.eyes && | |
| frames.findIndex((f) => !f.eyes) > 0 && | |
| frames.slice(frames.findIndex((f) => !f.eyes)).every((f) => !f.eyes); | |
| if (goesOut) continue; | |
| frames.forEach((f, i) => { if (!f.eyes) missing.push(`${k}[${i}]`); }); | |
| } | |
| // 2. THE SAME SIZE IN ALL OF THEM. Her standing eye is 1.7% of the figure's | |
| // height and her running one 2.4%; the placeholder poses were drawn at | |
| // 4.4% first, so changing pose doubled it. | |
| const widths = {}; | |
| for (const k of princeClips) { | |
| const c = clips[k]; | |
| const off = new OffscreenCanvas(c.width, c.height); | |
| const octx2 = off.getContext('2d', { willReadFrequently: true }); | |
| octx2.drawImage(c.frames[0].eyes, 0, 0); | |
| const g = octx2.getImageData(0, 0, c.width, c.height).data; | |
| let lo = 1e9, hi = -1; | |
| for (let y = 0; y < c.height; y++) { | |
| for (let x = 0; x < c.width; x++) { | |
| if (g[(y * c.width + x) * 4 + 3] > 200) { if (x < lo) lo = x; if (x > hi) hi = x; } | |
| } | |
| } | |
| widths[k] = +((hi - lo + 1) / c.height * 100).toFixed(1); | |
| } | |
| const ws = Object.values(widths); | |
| const sameSize = Math.max(...ws) - Math.min(...ws) <= 1.2; | |
| // 3. IT DIMS, STEP BY STEP, AND NEVER INTO THE MUD. White over black lands | |
| // at 255 x (1 - dim), so the dimmest living state has to clear 90. | |
| scene.reset(); | |
| const steps = []; | |
| for (let h = 0; h < TUNING.PRINCE_HITS; h++) { | |
| s.prince.hits = h; | |
| drive(0.2); | |
| steps.push({ hits: h, dim: +s.prince.eyeDim.toFixed(3), | |
| lum: Math.round(255 * (1 - s.prince.eyeDim)) }); | |
| } | |
| let falls = true; | |
| for (let i = 1; i < steps.length; i++) if (steps[i].lum >= steps[i - 1].lum) falls = false; | |
| const muddy = steps.filter((r) => r.lum > 30 && r.lum < 90); | |
| scene.reset(); | |
| return { | |
| name: 'the light goes out of him', | |
| pass: missing.length === 0 && sameSize && falls && muddy.length === 0, | |
| posesWithoutAnEye: missing, | |
| eyeWidthPctPerPose: widths, sameSizeInEveryPose: sameSize, | |
| dimsOnEveryHit: falls, stepsInTheForbiddenBand: muddy, | |
| steps, | |
| }; | |
| } | |
| /** | |
| * A GLANCE IS FREE AND A STARE IS NOT. | |
| * | |
| * The whole search rests on this. The loops narrow twelve shapes to a group; | |
| * what narrows a group to a person is seeing the page a carrier is holding, | |
| * and that only shows inside the torch. So there has to be a moment where the | |
| * light is bright enough to read them and they have not come for you yet. | |
| * | |
| * THERE WAS NO SUCH MOMENT. The shortest hold that showed a shape and the | |
| * shortest hold that woke it were the same 0.25s, because `covers()` starts | |
| * the wake clock while the circle is still growing. Which made hitting all | |
| * twelve the only strategy, on a health budget that fits five fights. | |
| */ | |
| function aGlanceIsFree() { | |
| const hold = (secs) => { | |
| scene.reset(); | |
| const d = s.village.find((v) => v.loop === 'haul'); | |
| s.prince.x = s.prince.prevX = d.x - 260; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| let readable = false; | |
| const n = Math.round(secs * 60); | |
| for (let i = 0; i < n; i++) { | |
| scene.update(1 / 60, { ...NONE, torch: true }); | |
| // Bright enough that the page on a carrier is drawn in the lit pass. | |
| if (s.torch.raise > 0.9 && s.torch.covers(d.x, figureMidY(GROUND_Y, d.lane))) readable = true; | |
| } | |
| drive(2); | |
| return { secs, readable, woke: d.revealed }; | |
| }; | |
| const rows = [0.2, 0.3, 0.45, 0.6, 0.9].map(hold); | |
| const glances = rows.filter((r) => r.readable && !r.woke); | |
| const stares = rows.filter((r) => r.secs >= 0.9); | |
| return { | |
| name: 'a glance is free', | |
| pass: glances.length >= 2 && stares.every((r) => r.woke), | |
| // Holds that show you the shape and leave it asleep. | |
| readableWithoutWaking: glances.map((r) => r.secs), | |
| wakesAt: rows.find((r) => r.woke)?.secs ?? null, | |
| andAStareStillWakesThem: stares.every((r) => r.woke), | |
| rows, | |
| }; | |
| } | |
| /** | |
| * THE KEYS ARE ALWAYS THERE. | |
| * | |
| * The controls used to be the last line of the debug readout, which put the | |
| * one thing a new player needs behind the same toggle as the frame timings — | |
| * and hid it outright under `?debug=0`. Now it is its own line at the foot of | |
| * the frame, and it is only allowed to leave for one reason: a page is open | |
| * and he is reading it. | |
| */ | |
| function theKeysAreShown() { | |
| // The strip below the ground line, where the line sits and nothing else in | |
| // the world is drawn. | |
| const foot = () => { | |
| const d = ctx.getImageData(0, H - 52, Math.round(W * 0.85), 48).data; | |
| let ink = 0; | |
| for (let i = 0; i < d.length; i += 4) if (d[i] > 60 && d[i] > d[i + 2]) ink++; | |
| return ink; | |
| }; | |
| const frame = () => { | |
| s.camera.reset(s.prince.x); | |
| s.clock = 3.0; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| return foot(); | |
| }; | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = s.houses[1].x - 1400; | |
| s.prince.facing = 1; | |
| // 1. WITH THE READOUT OFF. This is the case that was broken: `?debug=0` is | |
| // the sane way to play and it took the controls with it. | |
| const withoutTheReadout = withOverlayOff(() => { drive(0.4); return frame(); }); | |
| // 2. And with it on, unchanged — it is not part of it any more. | |
| const withTheReadout = frame(); | |
| // 3. It steps aside for a page. Reading a letter is the one thing that | |
| // wants the bottom of the frame more than the keys do. | |
| const [dx] = s.doorPoint(s.houses[1]); | |
| s.prince.x = s.prince.prevX = dx - 300; | |
| const whileReading = withOverlayOff(() => { | |
| drive(1.5, { torch: true }); | |
| return frame(); | |
| }); | |
| // 4. AND ON TOUCH THERE IS ONE CONTEXTUAL BUTTON, NOT TWO. | |
| // | |
| // MARK lived on the right wheel because pinning a page needed PAGES | |
| // held and TAKE pressed and one thumb cannot do both. The top page is | |
| // now what he is holding and TAKE acts on it, so the second button is | |
| // deleted. Asserted rather than trusted, because a stray button in the | |
| // markup is invisible on a desktop and is the whole control scheme on | |
| // a phone. | |
| const pad = document.getElementById('pad'); | |
| const acts = pad | |
| ? [...pad.querySelectorAll('button[data-act]')].map((b) => b.dataset.act) | |
| : []; | |
| const touch = { | |
| noMarkButton: !document.getElementById('markbtn'), | |
| // Exactly one thing on the pad fires the contextual verb. | |
| onePickupButton: acts.filter((a) => a === 'pickup').length === 1, | |
| // And nothing is labelled with the verb that no longer exists. | |
| nothingSaysMark: !pad || !/\bMARK\b/.test(pad.textContent), | |
| acts, | |
| }; | |
| return { | |
| name: 'the keys are shown', | |
| pass: withoutTheReadout > 1500 && withTheReadout > 1500 && | |
| whileReading < withoutTheReadout * 0.25 && | |
| touch.noMarkButton && touch.onePickupButton && touch.nothingSaysMark, | |
| inkWithTheReadoutOff: withoutTheReadout, | |
| inkWithItOn: withTheReadout, | |
| inkWhileReadingALetter: whileReading, | |
| survivesDebugBeingOff: withoutTheReadout > 1500, | |
| getsOutOfTheWayOfAPage: whileReading < withoutTheReadout * 0.25, | |
| andOneContextualButtonOnTouch: touch, | |
| }; | |
| } | |
| /** | |
| * THE DOOR ASKS THE QUESTION, and it asks it with a mark rather than a box. | |
| * | |
| * Three conditions have to be true at once for a fragment to go anywhere and | |
| * nothing said so. The first fix put cues on the sheet, which have the same | |
| * flaw every sheet cue has: they only appear once he is already holding it up | |
| * at a door, which is the thing the cue was meant to teach. So the question | |
| * is asked in the world. | |
| * | |
| * IT WAS A FRAME AND IT IS NOW TWO NAILS. A bright square box bolted to every | |
| * house read as a noticeboard, and no village in any century had one. A | |
| * letter nailed up is how you reached somebody who was not home, anywhere, | |
| * ever — and under it hangs a second nail and the lighter patch of wall where | |
| * their own reply hung. That patch is the slot. The frame was drawn; the | |
| * patch is a VALUE, and it is there from the first light, beside a letter | |
| * that never comes down. | |
| * | |
| * THE GATE HAS ALWAYS WORKED THIS WAY. Four empty sockets on the leaf, and | |
| * nobody has ever needed telling what they are for. | |
| * | |
| * Measured in pixels, because it is entirely a claim about what is on screen. | |
| */ | |
| function theRoomAsks() { | |
| const onScreen = (house, rect) => { | |
| const clip = s.houseClip; | |
| const [fx, fy, fw, fh] = rect; | |
| return [Math.round(house.x - s.camera.x - clip.pivotX + fx), | |
| Math.round(GROUND_Y - clip.pivotY + fy), fw, fh]; | |
| }; | |
| const nailRect = (house) => onScreen(house, s.houseClip.slotAt[house.variant]); | |
| const letterRect = (house) => onScreen(house, s.houseClip.letterAt[house.variant]); | |
| const look = (house) => { | |
| const [dx] = s.doorPoint(house); | |
| s.prince.x = s.prince.prevX = dx - 300; | |
| s.prince.facing = 1; | |
| drive(1.4, { torch: true }); | |
| s.camera.reset(s.prince.x); | |
| s.clock = 3.0; | |
| // AT THE TORCH'S OWN SIZE, NOT WHEREVER ITS FLICKER LEFT IT. Two looks | |
| // are 1.4s apart, so they caught the pool at two different sizes, and | |
| // every subpixel the flicker moved counted as the patch changing. It | |
| // passed by 4619 to 4252 with the flame at 317px; her torch holds it at | |
| // 333, the slot sits further out in the falloff, and the flicker alone | |
| // came to more than a pinned page. The grain floor is grain again. | |
| s.torch.radius = s.torch.prevRadius = TORCH.RADIUS; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| const [x, y, w, h] = nailRect(house); | |
| return ctx.getImageData(x, y, w, h).data; | |
| }; | |
| const differs = (a, b) => { | |
| let n = 0; | |
| for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) n++; | |
| return n; | |
| }; | |
| const brightness = (d) => { | |
| let sum = 0; | |
| for (let i = 0; i < d.length; i += 4) sum += lum(d, i); | |
| return Math.round(sum / (d.length / 4)); | |
| }; | |
| return withOverlayOff(() => { | |
| scene.reset(); | |
| const forge = s.houses[0]; | |
| const home = s.houses[3]; | |
| // 1. THE FIRST LIGHT, AND BOTH ARE THERE. Nothing has been read, taken or | |
| // pinned: the letter hangs on its nail and the slot is under it. | |
| const patch = look(forge); | |
| const patchLum = brightness(patch); | |
| const noise = differs(look(forge), patch); | |
| const letterLum = (() => { | |
| const [x, y, w, h] = letterRect(forge); | |
| return brightness(ctx.getImageData(x, y, w, h).data); | |
| })(); | |
| // 2. AND THE LETTER IS PAPER, NOT PATCH. The two share a column and a | |
| // shape, so the thing that tells them apart is the value: the letter | |
| // is a lit page with writing on it, the slot is a paler mark on wood. | |
| const letterIsPaper = letterLum > patchLum + 20; | |
| // THE WALL BELOW IT, which is what the patch has to beat. Finding | |
| // somewhere that is actually wall took three goes as the spot moved: | |
| // beside it is the lit doorway, above it is roof, and left of it is | |
| // where he stands. | |
| const wall = (() => { | |
| const [x, y, w, h] = nailRect(forge); | |
| return brightness(ctx.getImageData(x, y + h + 8, w, h).data); | |
| })(); | |
| // 3. AND IT IS A MARK, NOT A SOCKET. | |
| // | |
| // MEASURED AGAINST THE DOORWAY, not against the letter. The letter is | |
| // paper on its own lit backing and is bright by construction; what "must | |
| // not read as UI" means is that the patch must not look like the socket | |
| // it replaced, and the socket was doorway-coloured. So: clear of the | |
| // wall, clear of the forbidden band, and nowhere near the lit doorway. | |
| // | |
| // The BRIGHT part of it, not the average. A sample straight across from | |
| // the nail lands half on lit doorway and half on the anvil standing in | |
| // it, which averaged to 110 and made the doorway look dimmer than the | |
| // patch. What the patch must not be mistaken for is the light, so the | |
| // light is what it is measured against. | |
| const doorway = (() => { | |
| const [x, y, w, h] = nailRect(forge); | |
| const d = ctx.getImageData(x + w + 70, y - 100, w, h).data; | |
| const all = []; | |
| for (let i = 0; i < d.length; i += 4) all.push(lum(d, i)); | |
| all.sort((a, b) => a - b); | |
| return Math.round(all[Math.floor(all.length * 0.9)]); | |
| })(); | |
| const readsAsWall = patchLum > 90 && patchLum < doorway - 50; | |
| // 4. PIN A PAGE INTO IT AND IT IS THERE. | |
| forge.filed = 2; | |
| const filledDiff = differs(look(forge), patch); | |
| // 5. AND THE HOUSE HE NEVER FILES KEEPS ASKING. Same nail, same patch, | |
| // nothing in it, and nothing anywhere says it is empty on purpose. | |
| const [hx] = s.doorPoint(home); | |
| s.prince.x = s.prince.prevX = hx - 300; | |
| drive(1.4, { torch: true }); | |
| // Its own grain floor, measured here rather than borrowed from the | |
| // forge: two looks at this door with nothing changed between them. A | |
| // floor taken under a different stretch of world is not a floor. | |
| const homeBefore = look(home); | |
| const homeNoise = differs(look(home), homeBefore); | |
| const homeSettled = look(home); | |
| s.houses[1].filed = 1; s.houses[2].filed = 0; s.houses[4].filed = 4; | |
| const homeDiff = differs(look(home), homeSettled); | |
| return { | |
| name: 'the room asks', | |
| pass: letterIsPaper && | |
| readsAsWall && patchLum > wall + 40 && | |
| filledDiff > Math.max(300, noise * 4) && | |
| homeDiff <= Math.max(homeNoise * 2, 200), | |
| letterOnTheNailLum: letterLum, | |
| patchLum, wallLum: wall, doorwayLum: doorway, | |
| grainNoiseFloor: noise, atTheFifthNail: homeNoise, | |
| letterAndSlotBothThereFromTheFirstLight: letterIsPaper && readsAsWall, | |
| andTheSlotIsAMarkNotASocket: readsAsWall, | |
| itReadsAgainstTheWall: patchLum > wall + 40, | |
| pinningPutsItOnTheWall: filledDiff > Math.max(300, noise * 4), | |
| andTheFifthNailKeepsAsking: homeDiff <= Math.max(homeNoise * 2, 200), | |
| subpixelsChangedByPinning: filledDiff, | |
| subpixelsChangedAtTheHouseHeNeverFiled: homeDiff, | |
| }; | |
| }); | |
| } | |
| /** | |
| * THE WRITING FITS, AND THIS TESTS THE WRITING RATHER THAN THE CODE. | |
| * | |
| * The band is sized so three lines fit with no scrolling. A fragment that | |
| * wraps to four is a failure of the writing, not of the layout, and the test | |
| * should say so before anybody plays it — which is the right kind of check | |
| * for a game whose evidence is text. | |
| * | |
| * Measured at the AUTHORING size. The band grows on a small display, where | |
| * more lines is the correct answer; what is being asserted here is that a | |
| * letter is short enough to be read at a glance. | |
| */ | |
| function bandFits() { | |
| const rows = []; | |
| withOverlayOff(() => { | |
| for (const h of (scene.state.letters?.byHouse ?? [])) { | |
| for (const kind of ['letter', 'fragment']) { | |
| const text = h[kind]; | |
| if (!text) continue; | |
| // TWO MEASUREMENTS, BECAUSE THE BAND IS TWO DIFFERENT SHAPES. | |
| // | |
| // At the authoring size, three lines is the writing rule and a fourth | |
| // is a writing failure. On a handset the type grows, the rule cannot | |
| // hold, and what matters instead is how far up the picture the band | |
| // reaches — so that case is judged on its top edge, not its lines. | |
| const small = bandBox(ctx, text, BAND.MIN_CLIENT_W); | |
| rows.push({ | |
| house: h.house, kind, | |
| lines: linesNeeded(ctx, text), | |
| chars: text.length, | |
| phoneLines: small.lines.length, | |
| phoneTop: Math.round(small.top), | |
| }); | |
| } | |
| } | |
| }); | |
| const worst = rows.length ? Math.max(...rows.map((r) => r.lines)) : 99; | |
| const highest = rows.length ? Math.min(...rows.map((r) => r.phoneTop)) : 0; | |
| return { | |
| name: 'band fits', | |
| // ELEVEN, EXACTLY. Nine village strings, his orders, and the note on the | |
| // tree. An exact equality rather than a floor, so adding a string and not | |
| // looking at it fails here rather than on somebody's phone. | |
| pass: rows.length === 11 && worst <= BAND.MAX_LINES && highest >= BAND.TOP_FLOOR, | |
| strings: rows.length, longest: worst, allowed: BAND.MAX_LINES, | |
| // How far up the scene the tallest band climbs on the narrowest display, | |
| // and how far up it is allowed to. Bigger number is lower on screen. | |
| highestTopOnPhone: highest, floor: BAND.TOP_FLOOR, | |
| overLength: rows.filter((r) => r.lines > BAND.MAX_LINES), | |
| tooTall: rows.filter((r) => r.phoneTop < BAND.TOP_FLOOR), | |
| rows, | |
| }; | |
| } | |
| // --- the order ---------------------------------------------------------- | |
| /** The palace letter's index in byHouse. Found, not counted to. */ | |
| const orderHouse = () => s.letters.byHouse.findIndex((h) => h.origin === 'palace'); | |
| /** | |
| * PAGE ONE, AND HE CANNOT PUT IT DOWN. | |
| * | |
| * It is the only page in the game he did not find, and the only one that | |
| * cannot leave the pile. Nothing in src/ removes a page except the key | |
| * splice, which searches for a key and stops at the first one -- so this | |
| * falls out of the rules rather than being defended, which is exactly why it | |
| * is worth asserting instead of trusting. | |
| */ | |
| function orderIsPageOne() { | |
| const oh = orderHouse(); | |
| scene.reset(); | |
| const isFirst = (why) => { | |
| const p = s.sheaf.pages[0]; | |
| return p && p.kind === 'letter' && p.house === oh ? true : why; | |
| }; | |
| const atInit = isFirst(false) === true; | |
| // It survives four fragments going on top of it. | |
| armed(); | |
| const afterTaking = isFirst(false) === true; | |
| // It survives pinning and retrieving at a slot. Not the forge's own | |
| // fragment, or it settles and the retrieve is refused. | |
| const armedPages = s.sheaf.pages.length; | |
| const h = s.houses[0]; | |
| s.sheaf.index = s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept !== h.index); | |
| s.prince.x = s.prince.prevX = h.x - 300; | |
| s.camera.reset(s.prince.x); | |
| if (!h.seen) drive(1.4, { torch: true }); | |
| drive(0.3, { pickup: true }); | |
| drive(0.3); | |
| drive(0.3, { pickup: true }); | |
| const afterPinning = isFirst(false) === true && s.sheaf.pages.length === armedPages; | |
| // And it survives a restart. | |
| scene.reset(); | |
| const afterReset = isFirst(false) === true && s.sheaf.pages.length === 1; | |
| // IT IS NOT A FRAGMENT, so it cannot be claimed. `holding` collapses every | |
| // kind but fragment to null, which is what makes E at a bare patch a no-op | |
| // with the order in hand -- but the order is a LETTER now, and letters have | |
| // always been no-ops there, so this is guarding a rule it shares. | |
| const p0 = s.sheaf.pages[0]; | |
| const neverPinned = s.houses.every((hh) => hh.filed == null) && | |
| !s.filedUnder(p0); | |
| const readsAsPalacePaper = s.textFor(p0) === s.letters.byHouse[oh].letter && | |
| !s.houses[oh]; | |
| return { | |
| name: 'the order is page one', | |
| pass: atInit && afterTaking && afterPinning && afterReset && | |
| neverPinned && readsAsPalacePaper && oh >= 0, | |
| palaceIsByHouse: oh, | |
| atInit, afterTaking, afterPinning, afterReset, | |
| neverPinned, readsAsPalacePaper, | |
| pagesAfterEverything: armedPages, | |
| }; | |
| } | |
| /** | |
| * HE IS READING IT WHEN THE GAME STARTS, and he holds it until you press | |
| * something. | |
| * | |
| * The posture the player will use for the whole game, shown before they know | |
| * there is a button for it -- and it is the one time he does not have to HOLD | |
| * the button to keep the page up. | |
| */ | |
| function openingHolds() { | |
| const oh = orderHouse(); | |
| scene.reset(); | |
| scene.openAgain(); | |
| // FRAME ONE, ALREADY UP. Not ramping: the raise is set rather than driven, | |
| // because RAISE_MS is 250 and the band needs 0.9, so coming up through the | |
| // input path would spend a quarter of a second showing an unreadable page. | |
| // | |
| // A BOUND TAKES BOTH OF THE NEXT TWO CLAUSES WITH IT. ?order_hold_ms= is | |
| // an upper limit on the wait and exists so a developer can get the opening | |
| // out of the way; at 1ms it is over before the first frame finishes, so | |
| // neither "up on frame one" nor "it waits" can be true and neither is a | |
| // fault. They are skipped and the bound is reported instead. Everything | |
| // below them still runs. | |
| const bounded = TUNING.ORDER_HOLD_MS > 0; | |
| const from = s.prince.x; | |
| drive(1 / 60); | |
| const upOnFrameOne = bounded || (s.sheaf.up && s.torch.raise > 0.9 && | |
| s.band.text === s.letters.byHouse[oh].letter); | |
| // IT WAITS. Four seconds of nothing -- comfortably past the hold the spec | |
| // originally asked for -- and it is still up and he has not moved. | |
| // | |
| drive(4); | |
| const waits = bounded || (!s.opening.done && s.sheaf.up && | |
| s.band.text === s.letters.byHouse[oh].letter && | |
| Math.abs(s.prince.x - from) < 0.001); | |
| if (bounded) { scene.reset(); scene.openAgain(); } | |
| // ONE FRAME OF EVERYTHING AT ONCE ends it, and that frame costs nothing. | |
| // | |
| // This first asserted he could not move "while the page is up with input | |
| // held", which is not a state that exists: any input ends the beat, so the | |
| // frame after the first one he is already free and of course he walks. The | |
| // property that is actually worth having is that the frame which ENDS it | |
| // is spent ending it -- he does not also step, and he does not also swing. | |
| scene.update(1 / 60, { ...NONE, right: true, attack: true, pickup: true }); | |
| const anyInputEndsIt = s.opening.done; | |
| const frozen = Math.abs(s.prince.x - from) < 0.001 && !s.prince.attacking; | |
| // The page goes down on the existing ramp and he can walk. | |
| // | |
| // THE BAND STOPS READING HIS ORDERS, which is not the same as closing. At | |
| // ?at=gate he starts 320px from the gatehouse, and reading his orders by | |
| // torchlight lights its letter too — so with the orders down and him stood | |
| // still, the band reads that door. That is the rule for a lit door, not | |
| // the order hanging on. | |
| drive(0.8); | |
| const lowered = !s.sheaf.up && s.torch.raise < 0.01 && | |
| (s.band.alpha < 0.01 || s.band.text !== s.letters.byHouse[oh].letter); | |
| drive(0.5, { right: true }); | |
| const controlsLive = s.prince.x > from + 100; | |
| // AND THE PRESS THAT SKIPPED DOES NOT ALSO SWING. The real input object is | |
| // not the one the beat ran on, so its one-shots were never cleared -- this | |
| // is the single thing here a human playtest would not reliably catch. | |
| scene.reset(); | |
| scene.openAgain(); | |
| const live = { ...NONE, attack: true }; | |
| scene.update(1 / 60, live); // ends it, and must eat the press | |
| const swungOnTheSkip = s.prince.attacking; | |
| scene.update(1 / 60, { ...NONE }); | |
| // R DOES NOT REPLAY IT. Dying and restarting must not cost a second read. | |
| scene.reset(); | |
| drive(1 / 60); | |
| const resetDoesNotReplay = s.opening.done && !s.sheaf.up; | |
| return { | |
| name: 'the opening holds', | |
| pass: upOnFrameOne && waits && frozen && anyInputEndsIt && lowered && | |
| controlsLive && !swungOnTheSkip && resetDoesNotReplay, | |
| upOnFrameOne, waitsForHim: waits, nothingMovesHim: frozen, | |
| // 0 is the shipping value: no bound, it waits for him. | |
| upperBoundMs: TUNING.ORDER_HOLD_MS, | |
| anyInputEndsIt, loweredAfter: lowered, controlsLive, | |
| theSkipDoesNotSwing: !swungOnTheSkip, | |
| resetDoesNotReplay, | |
| }; | |
| } | |
| // --- wrong doors ---------------------------------------------------------------- | |
| // | |
| // Played through, the level was finished without reading a word: pin a page, | |
| // see who walks, try the next door. The walk home was an answer key. So a | |
| // stranger's house now wakes the owner, and it comes for him — guessing is | |
| // still allowed, and it costs blood. See docs/specs/astara-wrong-door-addendum.md. | |
| /** States a body passes through over the next few seconds, in order. */ | |
| const statesOver = (d, secs) => { | |
| const seen = [d.state]; | |
| for (let i = 0; i < Math.round(secs * 60); i++) { | |
| scene.update(1 / 60, NONE); | |
| if (seen[seen.length - 1] !== d.state) seen.push(d.state); | |
| } | |
| return seen; | |
| }; | |
| /** Put a body down on the spot. The fixture armed() already uses. */ | |
| const putDown = (d) => { d.hits = 99; d.takeHit(d.x - 1); drive(1.0); }; | |
| /** | |
| * A WRONG DOOR WAKES THEM. | |
| * | |
| * Every carrier, the gatekeeper included: pinned on a door that is not | |
| * theirs, the owner rises and comes — RISING, then RUSH, never homing — and | |
| * the page stays on the wrong wall. Knocked down, it can be woken again by | |
| * the next wrong pin, as often as he makes one. Pinned right, it goes home. | |
| * | |
| * AND THE BEAT THE RULES MAKE WITHOUT ANYONE DESIGNING IT. `sendHome` from | |
| * RUSH goes straight to HOMING, so the way to stop someone coming for you is | |
| * to put their words where they belong. A woken body chasing him from beyond | |
| * its own door: he pins it right, and it walks up to its door, stops short of | |
| * him, turns its back and never touches him. Asserted, because it falls out | |
| * of two methods sitting side by side and nothing else protects it. | |
| */ | |
| function wrongDoorWakes() { | |
| const rows = []; | |
| for (const c of armed()) { | |
| const k = c.kept; | |
| // A door that is not theirs AND not finished with. A door filed right is | |
| // locked, and the rows before this one have filed theirs right. | |
| const w = s.houses.map((_, i) => (k + 1 + i) % s.houses.length) | |
| .find((i) => i !== k && !s.houses[i].settled); | |
| atDoor(w, k); | |
| tap({ sheaf: true }); | |
| const first = statesOver(c, 2.5); | |
| const woke = first.includes('RISING') && c.state === 'RUSH' && !c.homing && c.revealed; | |
| const leftUp = s.houses[w].filed === k; | |
| putDown(c); | |
| const downed = c.state === 'DOWNED'; | |
| const stillUp = s.houses[w].filed === k; | |
| atDoor(w, k); | |
| tap({ sheaf: true }); // take it back down... | |
| const retrieved = s.houses[w].filed == null; | |
| atDoor(w, k); | |
| tap({ sheaf: true }); // ...and pin it wrong again | |
| const again = statesOver(c, 2.5); | |
| const wokeAgain = again.includes('RISING') && c.state === 'RUSH' && !c.homing; | |
| putDown(c); | |
| atDoor(w, k); | |
| tap({ sheaf: true }); // off the wrong wall | |
| atDoor(k, k); | |
| tap({ sheaf: true }); // onto its own | |
| const home = c.homing && (c.state === 'RISING' || c.state === 'HOMING') && | |
| c.homeX === s.houses[k].x; | |
| rows.push({ kept: k, wrongDoor: w, woke, pageStaysUp: leftUp && stillUp, downed, | |
| retrieved, wokeAgain, goesHomeWhenRight: home, states: first.join(' > ') }); | |
| } | |
| // The beat. A carrier that fell beyond its own door, from where he pins, | |
| // and a wrong door on his side of it — so it chases him past nothing, and | |
| // its own door is between them when he puts the page up. | |
| const beat = (() => { | |
| for (const seed of [7, 11, 23, 41, 97, 131]) { | |
| for (const c of armed(seed).filter((d) => !d.post)) { | |
| const k = c.kept; | |
| const door = s.houses[k].x; | |
| if (!(c.x > door + 300)) continue; | |
| const w = s.houses.findIndex((h, i) => i !== k && h.x < door - 300); | |
| if (w < 0) continue; | |
| atDoor(w, k); | |
| tap({ sheaf: true }); // wrong: it wakes | |
| atDoor(w, k); | |
| tap({ sheaf: true }); // straight back down | |
| // He goes to its door and waits; it comes for him, still beyond it. | |
| s.prince.x = s.prince.prevX = door - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| let t = 0; | |
| while (!(c.state === 'RUSH' && c.x - s.prince.x < W / 2) && t < 60 * 60) { | |
| scene.update(1 / 60, NONE); | |
| t++; | |
| } | |
| const closing = c.state === 'RUSH' && c.x - s.prince.x < W / 2 && c.x > door; | |
| const cameAtHim = c.facing === -1; | |
| const hits0 = s.prince.hits; | |
| atDoor(k, k); | |
| tap({ sheaf: true }); // right: it stops coming | |
| const stopped = c.state === 'HOMING' && c.homing && c.homeX === door; | |
| let u = 0, hurt = false, nearest = Infinity; | |
| while (c.state !== 'HOME' && u < 60 * 60) { | |
| scene.update(1 / 60, NONE); | |
| u++; | |
| if (s.prince.hits > hits0) hurt = true; | |
| nearest = Math.min(nearest, Math.abs(c.x - s.prince.x)); | |
| } | |
| return { | |
| seed, kept: k, secondsToClose: +(t / 60).toFixed(1), closing, cameAtHim, stopped, | |
| atItsDoor: c.state === 'HOME' && Math.round(c.x) === door, | |
| turnedItsBack: c.facing === Math.sign(c.x - s.prince.x), | |
| neverTouchedHim: !hurt, closestItCame: Math.round(nearest), | |
| }; | |
| } | |
| } | |
| return null; | |
| })(); | |
| scene.reset(); | |
| return { | |
| name: 'a wrong door wakes them', | |
| pass: rows.length === 4 && | |
| rows.every((r) => r.woke && r.pageStaysUp && r.downed && r.retrieved && | |
| r.wokeAgain && r.goesHomeWhenRight) && | |
| !!beat && beat.closing && beat.cameAtHim && beat.stopped && beat.atItsDoor && | |
| beat.turnedItsBack && beat.neverTouchedHim, | |
| rows, theBeat: beat, | |
| }; | |
| } | |
| /** | |
| * THE GATEKEEPER WAKES AT HIS POST. | |
| * | |
| * He is the first page most players pin, and pinned wrong he comes for them | |
| * with the spear — the hardest fight in the level, and the first time a | |
| * player learns that where things go matters. But he does not leave the | |
| * gate: pinned from the far end of the village he wakes and waits there, | |
| * and at the gate he braces, thrusts and shoves, exactly as before. | |
| */ | |
| function gatekeeperWakesAtHisPost() { | |
| armed(); | |
| const gk = gatekeeper(); | |
| const post = gk.x; | |
| atDoor(0, gk.kept); // the forge: the far end | |
| tap({ sheaf: true }); | |
| const seen = statesOver(gk, 3); | |
| const woke = seen.includes('RISING') && gk.state === 'RUSH' && gk.revealed && !gk.homing; | |
| const stayedWhileHeWasAway = gk.x === post; | |
| s.prince.x = s.prince.prevX = s.gateStopX; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| const hits0 = s.prince.hits; | |
| const phases = new Set(); | |
| let shoved = false, moved = false; | |
| for (let i = 0; i < 60 * 4; i++) { | |
| const before = s.prince.x; | |
| scene.update(1 / 60, NONE); | |
| phases.add(gk.phase); | |
| if (s.prince.x < before - 60) shoved = true; | |
| if (gk.x !== post) moved = true; | |
| } | |
| const hitsTaken = s.prince.hits - hits0; | |
| scene.reset(); | |
| return { | |
| name: 'the gatekeeper wakes at his post', | |
| pass: woke && stayedWhileHeWasAway && !moved && phases.size >= 3 && shoved, | |
| woke, states: seen.join(' > '), stayedWhileHeWasAway, movedAtTheGate: moved, | |
| phasesSeen: [...phases].sort(), shovedHim: shoved, hitsItCostHim: hitsTaken, | |
| }; | |
| } | |
| /** | |
| * THE TREE TEACHES THE RULE, AND NOTHING ELSE. | |
| * | |
| * A note nailed to a tree on the road: they cannot speak, what they carried | |
| * is all that is left of them, take it home. It stands past the first | |
| * drained, so "they" has somebody to mean, and before the village, so it is | |
| * read before the first fight — and far enough from every drained that | |
| * lighting it wakes nobody. | |
| * | |
| * It reads exactly the way a door does. Light it and the band reads it; | |
| * after that, standing by it reads it again with the torch down; walking | |
| * past, it does not. It STAYS ON THE TREE, like every letter stays on its | |
| * door, so it is never a page. And it is not a door: holding a fragment | |
| * there, nothing is offered, and E does nothing. | |
| */ | |
| function theTreeTeaches() { | |
| scene.reset(); | |
| const t = s.tree; | |
| if (!t) return { name: 'the tree teaches', pass: false, why: 'there is no tree' }; | |
| const note = s.letters.byHouse[t.index]?.letter ?? null; | |
| const road = s.drained.filter((d) => !s.village.includes(d)); | |
| const first = Math.min(...road.map((d) => d.x)); | |
| const clearOfAll = Math.min(...road.map((d) => Math.abs(d.x - t.x))); | |
| const placed = t.x > first + TUNING.TREE_CLEAR && t.x < BOWL.X0 && | |
| clearOfAll >= TUNING.TREE_CLEAR; | |
| const settle = TUNING.READ_STILL_MS / 1000 + 0.4; | |
| // Up to it in the dark, and stood there: nothing read. The first read | |
| // needs light. | |
| s.prince.x = s.prince.prevX = t.x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(settle); | |
| const unreadInTheDark = !t.seen && s.band?.text !== note; | |
| // Lit: the band reads it, and nobody wakes. | |
| drive(1.4, { torch: true }); | |
| const reads = t.seen && s.band?.text === note; | |
| const nobodyWoke = s.drained.every((d) => !d.revealed); | |
| // Torch down and still stood by it: it reads again, for nothing. | |
| drive(settle); | |
| const readsAgain = s.torch.raise < 0.01 && s.band?.text === note; | |
| // Walking past it, it does not. Every frame of a walk from one side of | |
| // its reach to the other. | |
| let openWhileWalking = 0; | |
| let frames = 0; | |
| while (s.prince.x < t.x + TUNING.DOOR_REACH + 100 && frames < 60 * 20) { | |
| scene.update(1 / 60, { ...NONE, right: true }); | |
| if (s.band?.text) openWhileWalking++; | |
| frames++; | |
| } | |
| const closedWhileWalking = openWhileWalking === 0 && frames > 0; | |
| // It stays on the tree: E there does nothing, holding a fragment or not, | |
| // and the pile is what it was. | |
| s.prince.x = s.prince.prevX = t.x - 300; | |
| s.camera.reset(s.prince.x); | |
| s.prince.inventory.push({ kept: 0, answers: null, seen: true }); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: 0 }); | |
| s.sheaf.index = s.sheaf.pages.length - 1; | |
| const pile = s.sheaf.pages.length; | |
| drive(0.5, { torch: true }); | |
| drive(0.3); | |
| const nothingGoesBack = !s.canPin && !s.canMark; | |
| tap(); | |
| s.sheaf.index = 0; | |
| tap(); | |
| const staysOnTheTree = s.sheaf.pages.length === pile && | |
| s.sheaf.pages.every((p) => p.kind !== 'letter' || p.house !== t.index); | |
| const stillNobodyWoke = s.drained.every((d) => !d.revealed); | |
| scene.reset(); | |
| const unlitAfterARestart = !t.seen; | |
| return { | |
| name: 'the tree teaches', | |
| pass: !!note && placed && unreadInTheDark && reads && nobodyWoke && | |
| readsAgain && closedWhileWalking && nothingGoesBack && staysOnTheTree && | |
| stillNobodyWoke && unlitAfterARestart, | |
| at: t.x, pastTheFirstDrained: t.x - first, clearOfEveryDrained: clearOfAll, | |
| clearFloor: TUNING.TREE_CLEAR, beforeTheVillage: t.x < BOWL.X0, | |
| unreadInTheDark, readsUnderTheTorch: reads, nobodyWoke, | |
| readsAgainWithTheTorchDown: readsAgain, | |
| framesOpenWhileWalkingPast: openWhileWalking, walkedFrames: frames, | |
| nothingGoesBack, staysOnTheTree, stillNobodyWoke, unlitAfterARestart, | |
| }; | |
| } | |
| // --- letters stay on the door ------------------------------------------------ | |
| // | |
| // Letters stay where they were left; fragments come off bodies. He does not | |
| // pocket a stranger's mail, and the door becomes where he judges: the | |
| // question nailed in front of him, the answer in his hand. See | |
| // docs/specs/astara-letters-stay-addendum.md. | |
| /** | |
| * THE LETTERS STAY, on every door and on the tree, whatever he does. | |
| * | |
| * There is no state for it any more — nothing can take one down — so this | |
| * looks at the wall. At the start of a run, after every input there is at | |
| * every door, and at the end of a whole run with the gate open: the letter's | |
| * rect is paper. And not once in all of that does a letter from a wall turn | |
| * up in the sheaf. | |
| * | |
| * PAPER IS COUNTED, NOT AVERAGED. The share of the rect brighter than 140, | |
| * which nothing on a wall at night comes near except a lit page — and two | |
| * renders of the same frame are the grain floor it has to clear. | |
| */ | |
| function lettersStay() { | |
| return withOverlayOff(() => { | |
| const orderAt = orderHouse(); | |
| let wallLettersHeld = 0; | |
| const note = () => { | |
| wallLettersHeld += s.sheaf.pages | |
| .filter((p) => p.kind === 'letter' && p.house !== orderAt).length; | |
| }; | |
| const targets = [...s.houses, ...(s.tree ? [s.tree] : [])]; | |
| const clipOf = (t) => (t === s.tree ? s.treeClip : s.houseClip); | |
| const rectOf = (t) => clipOf(t).letterAt[t.variant]; | |
| // WHERE HE STANDS: 130px short of the letter, which is h.x - 300 at a | |
| // house — where every check and every player reads a door from — and | |
| // the same distance from the note on the tree. | |
| const standX = (t) => { | |
| const r = rectOf(t); | |
| return t.x - clipOf(t).pivotX + r[0] + r[2] / 2 - 130; | |
| }; | |
| const paper = (t) => { | |
| const clip = clipOf(t); | |
| const [rx, ry, rw, rh] = rectOf(t); | |
| const d = ctx.getImageData(Math.round(t.x - s.camera.x - clip.pivotX + rx), | |
| Math.round(GROUND_Y - clip.pivotY + ry), rw, rh).data; | |
| let n = 0; | |
| for (let i = 0; i < d.length; i += 4) if (lum(d, i) > 140) n++; | |
| return n / (d.length / 4); | |
| }; | |
| const shot = () => { | |
| s.clock = 3.0; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| }; | |
| const look = (t) => { | |
| s.prince.x = s.prince.prevX = standX(t); | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(1.4, { torch: true }); | |
| shot(); | |
| const a = paper(t); | |
| shot(); | |
| const floor = Math.abs(paper(t) - a); | |
| note(); | |
| return { at: t.kind, paper: +a.toFixed(3), floor: +floor.toFixed(3), | |
| present: a > Math.max(0.35, floor * 4) }; | |
| }; | |
| // 1. FROM THE FIRST FRAME. Every carrier down and every fragment taken, | |
| // and every letter is still where it was left. | |
| const carriers = armed(); | |
| const atStart = targets.map(look); | |
| // 2. AND NOTHING HE CAN DO AT A DOOR TAKES ONE DOWN. E with his orders | |
| // on top, Q, SPACE, F — and E with the house's own fragment, which | |
| // pins it and sends its owner home. | |
| const afterInputs = targets.map((t) => { | |
| look(t); | |
| s.sheaf.index = 0; tap(); | |
| drive(0.6, { sheaf: true }); drive(0.3); | |
| drive(0.6, { torch: true }); drive(0.3); | |
| drive(0.1, { attack: true }); drive(0.6); | |
| const own = t === s.tree ? -1 | |
| : s.sheaf.pages.findIndex((p) => p.kind === 'fragment' && p.kept === t.index); | |
| if (own >= 0) { s.sheaf.index = own; tap(); } | |
| note(); | |
| return look(t); | |
| }); | |
| // 3. A WHOLE RUN. Everybody who can go home does, four keys come off | |
| // four hooks, and the gate opens on them. | |
| let ticks = 0; | |
| while (s.drained.some((d) => d.homing) && ticks < 60 * 250) { | |
| scene.update(1 / 60, NONE); ticks++; | |
| } | |
| for (const h of s.houses) { | |
| if (!h.open) continue; | |
| s.prince.x = s.prince.prevX = h.x - 300; | |
| s.camera.reset(s.prince.x); | |
| drive(1.4, { torch: true }); | |
| s.sheaf.index = 0; | |
| tap(); | |
| } | |
| note(); | |
| s.prince.x = s.prince.prevX = s.gateStopX; | |
| s.camera.reset(s.prince.x); | |
| drive(0.3); | |
| for (let i = 0; i < TUNING.GATE_WANTS; i++) tap(); | |
| drive(2 + TUNING.GATE_OPEN_MS / 1000); | |
| const gateOpen = s.gate.open >= 1; | |
| note(); | |
| const atTheEnd = targets.map(look); | |
| const all = (rows) => rows.every((r) => r.present); | |
| return { | |
| name: 'letters stay', | |
| pass: carriers.length === TUNING.GATE_WANTS && targets.length === 6 && | |
| all(atStart) && all(afterInputs) && gateOpen && all(atTheEnd) && | |
| wallLettersHeld === 0, | |
| onEveryWallAtTheStart: all(atStart), | |
| afterEveryInputAtEveryDoor: all(afterInputs), | |
| andAtTheEndOfARunWithTheGateOpen: gateOpen && all(atTheEnd), | |
| wallLettersEverInTheSheaf: wallLettersHeld, | |
| atStart, afterInputs, atTheEnd, | |
| }; | |
| }); | |
| } | |
| /** | |
| * THE SLOT IS ALWAYS THERE, from the first light, at every door. | |
| * | |
| * It is the affordance, and it used to be earned: no slot until the letter | |
| * came off. Now a bare nail and a lighter patch sit under the letter from the | |
| * first moment the torch finds the wall — before anything is pinned, and | |
| * before anything is read. Rendered with the door's latch wiped, to prove | |
| * the wall draws it off nothing but the light. | |
| * | |
| * AND IT IS STILL. A value, not a glow: over a whole pulse cycle the patch | |
| * and the wall round it do not move. The tree has no slot at all. | |
| */ | |
| function theSlotIsAlwaysThere() { | |
| return withOverlayOff(() => { | |
| const clip = s.houseClip; | |
| const box = (h, pad = 0) => { | |
| const [nx, ny, nw, nh] = clip.slotAt[h.variant]; | |
| return [Math.round(h.x - s.camera.x - clip.pivotX + nx) - pad, | |
| Math.round(GROUND_Y - clip.pivotY + ny) - pad, nw + pad * 2, nh + pad * 2]; | |
| }; | |
| const brightness = (d) => { | |
| let sum = 0; | |
| for (let i = 0; i < d.length; i += 4) sum += lum(d, i); | |
| return Math.round(sum / (d.length / 4)); | |
| }; | |
| const render = (clock = 3.0) => { | |
| s.clock = clock; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| }; | |
| const rows = s.houses.map((h) => { | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = h.x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(1.4, { torch: true }); | |
| // FORGET HE EVER LOOKED. The render reads state only, so with the | |
| // latch wiped nothing in the scene says this door was lit or read. | |
| h.seen = false; | |
| render(); | |
| const slot = brightness(ctx.getImageData(...box(h)).data); | |
| const [bx, by, bw, bh] = box(h); | |
| const wall = brightness(ctx.getImageData(bx, by + bh + 8, bw, bh).data); | |
| render(); | |
| const floor = Math.abs(brightness(ctx.getImageData(...box(h)).data) - slot); | |
| const seenPulse = []; | |
| for (let i = 0; i < 12; i++) { | |
| render(3.0 + (i / 12) / TORCH.KEPT_PULSE_HZ); | |
| seenPulse.push(brightness(ctx.getImageData(...box(h, 26)).data)); | |
| } | |
| render(); | |
| return { house: h.kind, slot, wall, floor, | |
| movesOverAPulse: Math.max(...seenPulse) - Math.min(...seenPulse), | |
| nothingPinned: h.filed == null }; | |
| }); | |
| const everyDoor = rows.every((r) => r.nothingPinned && | |
| r.slot > r.wall + 40 && r.slot - r.wall > r.floor * 3); | |
| const still = rows.every((r) => r.movesOverAPulse <= 2); | |
| const treeHasNone = !!s.treeClip && !s.treeClip.slotAt; | |
| scene.reset(); | |
| return { | |
| name: 'the slot is always there', | |
| pass: everyDoor && still && treeHasNone, | |
| onEveryDoorFromTheFirstLight: everyDoor, | |
| andItDoesNotGlow: still, | |
| theTreeHasNone: treeHasNone, | |
| rows, | |
| }; | |
| }); | |
| } | |
| /** | |
| * THE BAND READS THE DOOR, and it is one button to compare. | |
| * | |
| * Q held the page in his hand. Wins | |
| * the torch on a letter, in reach that letter. The first read | |
| * standing still, in reach of a lit door that door's letter | |
| * otherwise closed | |
| * | |
| * THE FIRST READ NEEDS LIGHT; EVERY ONE AFTER IS FREE. On a phone TORCH and | |
| * PAGES share a thumb, so comparing a door with the page in his hand cannot | |
| * need both: hold PAGES for the paper, let go for the door. | |
| * | |
| * And the band never reads one door while E would act on another. Sampled | |
| * on every frame this check runs. | |
| */ | |
| function bandReadsTheDoor() { | |
| const wait = TUNING.READ_STILL_MS; | |
| const letterOf = (h) => s.letters.byHouse[h.index].letter; | |
| let frames = 0; | |
| let notTheDoorEActsOn = 0; | |
| const step = (input = {}) => { | |
| scene.update(1 / 60, { ...NONE, ...input }); | |
| frames++; | |
| if (s.band.text) { | |
| const h = s.houses.find((hh) => letterOf(hh) === s.band.text); | |
| if (h && h !== s.nearDoor) notTheDoorEActsOn++; | |
| } | |
| }; | |
| const run = (secs, input = {}, each = null) => { | |
| const n = Math.round(secs * 60); | |
| for (let i = 0; i < n; i++) { step(input); if (each) each(i); } | |
| }; | |
| const standAt = (x) => { | |
| s.prince.x = s.prince.prevX = x; | |
| s.prince.facing = 1; | |
| s.camera.reset(x); | |
| }; | |
| scene.reset(); | |
| const forge = s.houses[0]; | |
| const L = letterOf(forge); | |
| // 1. A DOOR HE HAS NEVER LIT: closed, however long he stands at it. | |
| standAt(forge.x - 300); | |
| run(wait / 1000 + 0.5); | |
| const neverLitIsClosed = !forge.seen && s.band.text == null; | |
| // 2. THE FIRST READ IS THE TORCH'S. Walk in, stop, raise it: it reads | |
| // before standing still could have opened it. | |
| standAt(forge.x - 400); | |
| run(0.2, { right: true }); | |
| let firstRead = -1; | |
| run(0.5, { torch: true }, (i) => { if (firstRead < 0 && s.band.text === L) firstRead = i + 1; }); | |
| const firstReadMs = Math.round(firstRead * 1000 / 60); | |
| const theTorchReadsIt = firstRead > 0 && firstReadMs < wait && forge.seen; | |
| // 3. TORCH DOWN, STILL STOOD THERE: the same letter again, for nothing. | |
| run(0.3); | |
| run(wait / 1000 + 0.2); | |
| const readsAgainInTheDark = s.torch.raise < 0.01 && s.band.text === L; | |
| // 4. COMPARING IS ONE BUTTON. A fragment in his hand; hold Q and the band | |
| // reads it, let go and it reads the door. Twice, and the torch is never | |
| // pressed. It blanks at every switch: two pieces of paper, not a label. | |
| s.prince.inventory.push({ kept: 2, answers: null, seen: true }); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: 2 }); | |
| s.sheaf.index = s.sheaf.pages.length - 1; | |
| const F = s.letters.byHouse[2].fragment; | |
| const switches = []; | |
| let last = s.band.text; | |
| const watch = () => { | |
| if (s.band.text && s.band.text !== last) { | |
| switches.push({ to: s.band.text === F ? 'page' : s.band.text === L ? 'door' : 'other', | |
| alpha: +s.band.alpha.toFixed(3) }); | |
| } | |
| if (s.band.text) last = s.band.text; | |
| }; | |
| for (let k = 0; k < 2; k++) { | |
| run(0.8, { sheaf: true }, watch); | |
| run(0.6, {}, watch); | |
| } | |
| const order = switches.map((w) => w.to).join(','); | |
| const oneButton = order === 'page,door,page,door'; | |
| const blanksEachTime = switches.length > 0 && switches.every((w) => w.alpha < 0.1); | |
| // 5. EVERY DOOR LIT, THEN THE WHOLE STREET WALKED WITH THE TORCH DOWN: | |
| // closed on every frame he is walking. | |
| for (const h of s.houses) { standAt(h.x - 300); run(1.4, { torch: true }); } | |
| const allLit = s.houses.every((h) => h.seen); | |
| standAt(s.houses[0].x - 700); | |
| const end = s.houses[s.houses.length - 1].x + 600; | |
| let walked = 0; | |
| let openWhileWalking = 0; | |
| while (s.prince.x < end && walked < 60 * 60) { | |
| step({ right: true }); | |
| walked++; | |
| if (s.band.text) openWhileWalking++; | |
| } | |
| const closedWhileWalking = allLit && walked > 60 && openWhileWalking === 0; | |
| // 6. STOPPING AT A LIT DOOR OPENS IT AFTER READ_STILL_MS, AND NOT BEFORE. | |
| standAt(forge.x - 700); | |
| // Bounded: a check must never hang the runner. If he cannot walk, the | |
| // claim below fails and says so. | |
| for (let n = 0; s.prince.x < forge.x - 300 && n < 60 * 5; n++) step({ right: true }); | |
| let opened = -1; | |
| for (let i = 0; i < 60; i++) { | |
| step(); | |
| if (opened < 0 && s.band.text === L) opened = i + 1; | |
| } | |
| const openedMs = Math.round(opened * 1000 / 60); | |
| const waitsThenOpens = opened > 0 && openedMs >= wait - 1 && openedMs <= wait + 50; | |
| // 7. A SWING IS NOT STANDING STILL. Nothing flashes up mid-fight. | |
| let swingFrames = 0; | |
| let openWhileSwinging = 0; | |
| step({ attack: true }); | |
| while (s.prince.attacking && swingFrames < 120) { | |
| swingFrames++; | |
| if (s.band.text) openWhileSwinging++; | |
| step(); | |
| } | |
| const closedWhileSwinging = swingFrames > 0 && openWhileSwinging === 0; | |
| // 8. OUT OF REACH: closed. | |
| standAt(forge.x - 900); | |
| run(wait / 1000 + 0.5); | |
| const closedOutOfReach = s.band.text == null; | |
| scene.reset(); | |
| return { | |
| name: 'the band reads the door', | |
| pass: neverLitIsClosed && theTorchReadsIt && readsAgainInTheDark && | |
| oneButton && blanksEachTime && closedWhileWalking && waitsThenOpens && | |
| closedWhileSwinging && closedOutOfReach && notTheDoorEActsOn === 0, | |
| neverLitIsClosed, | |
| theTorchReadsItFirst: theTorchReadsIt, firstReadAfterMs: firstReadMs, | |
| readsAgainWithTheTorchDown: readsAgainInTheDark, | |
| comparingIsOneButton: oneButton, switches: order, | |
| andItBlanksAtEachSwitch: blanksEachTime, | |
| closedWhileWalkingPastLitDoors: closedWhileWalking, framesWalked: walked, | |
| opensAfterStandingStillMs: openedMs, readStillMs: wait, | |
| waitsThenOpens, closedWhileSwinging, closedOutOfReach, | |
| framesReadingADoorEWouldNotActOn: notTheDoorEActsOn, framesSampled: frames, | |
| }; | |
| } | |
| /** | |
| * THE DOOR CARRIES FOUR THINGS: the letter, the slot, the key and the room. | |
| * | |
| * None may cover another — except the key, which has always hung INSIDE the | |
| * room by the jamb, because that is where a key to your own front door goes. | |
| * The room is measured off the art rather than written down: the lit | |
| * doorway's own pixels, and 16px round them for the things that poke out of | |
| * it, the forge's anvil horn furthest. | |
| * | |
| * AND BOTH PAPERS ARE LIT FROM WHERE HE STANDS, on every frame of three | |
| * seconds of flicker rather than on one: a check that passes or fails on | |
| * which frame it sampled is not a check. Within DOOR_REACH too, which is | |
| * where E and the band both measure from. | |
| */ | |
| function theDoorCarriesFour() { | |
| const clip = s.houseClip; | |
| const clear = (a, b) => | |
| a[0] + a[2] <= b[0] || b[0] + b[2] <= a[0] || a[1] + a[3] <= b[1] || b[1] + b[3] <= a[1]; | |
| const roomBox = (v) => { | |
| const off = new OffscreenCanvas(clip.width, clip.height); | |
| const o = off.getContext('2d', { willReadFrequently: true }); | |
| o.drawImage(clip.frames[v].lit, 0, 0); | |
| const d = o.getImageData(0, 0, clip.width, clip.height).data; | |
| let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity; | |
| for (let y = 0; y < clip.height; y++) { | |
| for (let x = 0; x < clip.width; x++) { | |
| const i = (y * clip.width + x) * 4; | |
| if (d[i + 3] < 250 || Math.abs(d[i] - 0xd8) > 6 || | |
| Math.abs(d[i + 1] - 0xc3) > 6 || Math.abs(d[i + 2] - 0xa0) > 6) continue; | |
| if (x < x0) x0 = x; | |
| if (x > x1) x1 = x; | |
| if (y < y0) y0 = y; | |
| if (y > y1) y1 = y; | |
| } | |
| } | |
| return [x0 - 16, y0 - 16, x1 - x0 + 33, y1 - y0 + 33]; | |
| }; | |
| const rows = s.houses.map((h) => { | |
| const L = clip.letterAt[h.variant]; | |
| const S = clip.slotAt[h.variant]; | |
| const K = clip.keyAt[h.variant]; | |
| const R = roomBox(h.variant); | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = h.x - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(0.6, { torch: true }); | |
| // The margin by which each paper is inside the reveal circle, at the | |
| // worst frame. Positive is lit. | |
| let letterMargin = Infinity; | |
| let slotMargin = Infinity; | |
| const inside = (p) => REVEAL_T * s.torch.radius - Math.hypot(p[0] - s.torch.cx, p[1] - s.torch.cy); | |
| for (let i = 0; i < 180; i++) { | |
| scene.update(1 / 60, { ...NONE, torch: true }); | |
| letterMargin = Math.min(letterMargin, inside(s.letterPoint(h))); | |
| slotMargin = Math.min(slotMargin, inside(s.slotPoint(h))); | |
| } | |
| const lp = s.letterPoint(h); | |
| const sp = s.slotPoint(h); | |
| return { | |
| house: h.kind, room: R, | |
| letterClearOfSlot: clear(L, S), letterClearOfKey: clear(L, K), | |
| slotClearOfKey: clear(S, K), letterClearOfRoom: clear(L, R), | |
| slotClearOfRoom: clear(S, R), keyInsideTheRoom: !clear(K, R), | |
| letterLitEveryFrame: letterMargin > 0, slotLitEveryFrame: slotMargin > 0, | |
| worstLetterMargin: Math.round(letterMargin), worstSlotMargin: Math.round(slotMargin), | |
| inReach: Math.abs(s.prince.x - lp[0]) <= TUNING.DOOR_REACH && | |
| Math.abs(s.prince.x - sp[0]) <= TUNING.DOOR_REACH, | |
| }; | |
| }); | |
| scene.reset(); | |
| const ok = rows.every((r) => r.letterClearOfSlot && r.letterClearOfKey && | |
| r.slotClearOfKey && r.letterClearOfRoom && r.slotClearOfRoom && r.keyInsideTheRoom && | |
| r.letterLitEveryFrame && r.slotLitEveryFrame && r.inReach); | |
| return { | |
| name: 'the door carries four', | |
| pass: ok && rows.length === 5, | |
| everyDoor: ok, | |
| rows, | |
| }; | |
| } | |
| /** | |
| * THE GATEWAY IS HIS ONCE ITS KEEPER HAS GONE HOME. | |
| * | |
| * While the gatekeeper holds his post, the shut leaf stops the prince just | |
| * inside the left post. Once the keeper has walked home, he can walk the | |
| * whole width of the leaf, under all four sockets, to just inside the right | |
| * post. Found in play: stood at the left edge, a player could not get close | |
| * enough to count what the gate wanted. The view follows him in far enough to | |
| * frame the whole gateway. | |
| * | |
| * AND THE LEAF STILL HAS TO LIFT, AND HE STILL HAS TO WALK. Standing under | |
| * it when it opens does not end the level; a step through it does. | |
| */ | |
| function theGateRoomOpens() { | |
| // The posts' outer edges, as render.js draws the gate: a 420px half-leaf | |
| // and a 100px post each side. | |
| const EDGE = 520; | |
| const toGate = (secs = 6) => { | |
| s.prince.x = s.prince.prevX = s.gateX - 900; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(secs, { right: true }); | |
| }; | |
| const keeper = gatekeeper(); | |
| // 1. Down, but at his post: the old stop. A fight does not move him. | |
| const carriers = armed(); | |
| toGate(); | |
| const heldShut = Math.round(s.prince.x) === s.gateStopX; | |
| // 2. His scrap on his own door, and he gets up and walks home. | |
| atDoor(4, 4); | |
| tap({ sheaf: true }); | |
| drive(3); | |
| const goneHome = keeper.state === 'HOMING' || keeper.state === 'HOME'; | |
| // 3. The whole gateway, and no further — with all of it in the frame. | |
| toGate(); | |
| const walkedIn = Math.round(s.prince.x) === s.gateRoomX; | |
| const cam = s.camera.x; | |
| const framed = s.gateX - EDGE - cam >= 0 && s.gateX + EDGE - cam <= W && | |
| s.prince.x - cam <= W - 200; | |
| // 4. Walking into the far post with the leaf shut ends nothing. | |
| drive(1, { right: true }); | |
| const shutEndsNothing = !s.gate.through && s.fade === 0; | |
| // 5. The leaf lifts over him while he stands under it, and he is still | |
| // there. One step takes him through. | |
| s.prince.x = s.prince.prevX = s.gateX + 100; | |
| s.prince.keys = TUNING.GATE_WANTS; | |
| s.sheaf.index = 0; | |
| for (let i = 0; i < TUNING.GATE_WANTS; i++) tap(); | |
| drive(1 + TUNING.GATE_OPEN_MS / 1000); | |
| const openOverHim = s.gate.open >= 1 && !s.gate.through; | |
| drive(0.3, { right: true }); | |
| const aStepTakesHimThrough = s.gate.through; | |
| // 6. A restart puts the keeper back on his post, and the gateway shuts. | |
| scene.reset(); | |
| toGate(); | |
| const shutAgain = Math.round(s.prince.x) === s.gateStopX; | |
| return { | |
| name: 'the gate room opens', | |
| pass: carriers.length === TUNING.GATE_WANTS && heldShut && goneHome && walkedIn && | |
| framed && shutEndsNothing && openOverHim && aStepTakesHimThrough && shutAgain, | |
| whileHeHoldsThePost: heldShut, keeperWalkedHome: goneHome, | |
| walksTheWholeGateway: walkedIn, stopsAt: Math.round(s.gateRoomX - s.gateX), | |
| theWholeGatewayIsInFrame: framed, | |
| shutEndsNothing, openOverHimAndStillThere: openOverHim, aStepTakesHimThrough, | |
| shutAgainAfterARestart: shutAgain, | |
| }; | |
| } | |
| // --- her poses --------------------------------------------------------------- | |
| /** | |
| * HER POSES FIT THE GAME. The swing, the fall and the torch held up are her | |
| * drawings now, cut by tools/import_poses.py, and each was placed against the | |
| * standing drawing on purpose. This is what has to stay true if one of them | |
| * is ever imported again without the others. | |
| * | |
| * The torch pose is her standing body with an arm up: raising the torch | |
| * must change the arm and nothing else, or he jumps every time he looks. | |
| * The arm comes up over four frames, her own arm swung, and the fire sits | |
| * on the torch in every one of them -- her flame gone from under it -- and | |
| * the light comes out of that fire. | |
| * The thrust is on screen when the hit can land, not a frame after. | |
| * The fall ends with him on the ground. | |
| */ | |
| function herPosesFit() { | |
| const idle = clips.prince_idle; | |
| const torch = clips.prince_torch; | |
| const swing = clips.prince_attack; | |
| const fall = clips.prince_down; | |
| if (!idle || !torch || !swing || !fall) { | |
| return { name: 'her poses fit', pass: false, why: 'a pose is missing from the manifest' }; | |
| } | |
| // Alpha of one frame, on a canvas the size of the clip. | |
| const alphaOf = (c, i = 0) => { | |
| const off = new OffscreenCanvas(c.width, c.height); | |
| const o = off.getContext('2d', { willReadFrequently: true }); | |
| o.drawImage(c.frames[i].lit, 0, 0); | |
| return o.getImageData(0, 0, c.width, c.height).data; | |
| }; | |
| const inkAt = (c, a, x, y) => x >= 0 && y >= 0 && x < c.width && y < c.height && | |
| a[(y * c.width + x) * 4 + 3] > 60; | |
| // 1. THE SAME BODY, IN EVERY FRAME OF THE RAISE. Below the shoulders -- | |
| // legs and coat -- each frame, placed by its own pivot, lies on the | |
| // standing drawing. | |
| const ia = alphaOf(idle); | |
| const bodies = []; | |
| for (let i = 0; i < torch.count; i++) { | |
| const ta = alphaOf(torch, i); | |
| let both = 0, either = 0; | |
| for (let dy = -Math.round(idle.pivotY * 0.55); dy <= 0; dy++) { | |
| for (let dx = -idle.pivotX; dx < idle.width - idle.pivotX; dx++) { | |
| const a = inkAt(idle, ia, idle.pivotX + dx, idle.pivotY + dy); | |
| const b = inkAt(torch, ta, torch.pivotX + dx, torch.pivotY + dy); | |
| if (a && b) both++; | |
| if (a || b) either++; | |
| } | |
| } | |
| bodies.push(either > 0 ? +(both / either).toFixed(3) : 0); | |
| } | |
| const sameBody = Math.min(...bodies); | |
| // 2. THE FIRE ON HER TORCH, IN EVERY FRAME, AS HE IS SEEN: in the black | |
| // shape drawn on screen, something under where the fire stands and | |
| // nothing of her flame above it. And it climbs, frame by frame. | |
| const hands = torch.handPivots ?? [torch.handPivot]; | |
| const darkOf = (i) => { | |
| const off = new OffscreenCanvas(torch.width, torch.height); | |
| const o = off.getContext('2d', { willReadFrequently: true }); | |
| o.drawImage(torch.frames[i].dark, 0, 0); | |
| return o.getImageData(0, 0, torch.width, torch.height).data; | |
| }; | |
| const fires = hands.map(([hx, hy], i) => { | |
| const da = darkOf(i); | |
| let under = 0, above = 0; | |
| for (let x = hx - 8; x <= hx + 8; x++) { | |
| for (let y = hy + 1; y <= hy + 10; y++) if (inkAt(torch, da, x, y)) under++; | |
| for (let y = hy - 30; y <= hy - 6; y++) if (inkAt(torch, da, x, y)) above++; | |
| } | |
| return { under, above }; | |
| }); | |
| const climbs = hands.every((h, i) => i === 0 || h[1] < hands[i - 1][1]); | |
| const fireOnTheTorch = hands.length === torch.count && fires.every((f) => f.under > 20 && f.above === 0) && | |
| climbs && !torch.torchArm; | |
| // 3. A RAISE SHOWS EVERY FRAME IN ORDER, AND LOWERING PLAYS THEM BACK -- | |
| // and on every one of those frames the fire stands where that frame's | |
| // torch is, and the light comes out of the fire. | |
| scene.reset(); | |
| const shown = [], off = []; | |
| const sample = (input) => { | |
| scene.update(1 / 60, { ...NONE, ...input }); | |
| if (s.prince.clip !== torch) return; | |
| if (shown[shown.length - 1] !== s.prince.frame) shown.push(s.prince.frame); | |
| const [hx, hy] = hands[s.prince.frame]; | |
| const ex = s.prince.x + (hx - torch.pivotX) * s.prince.facing; | |
| const ey = GROUND_Y + (hy - torch.pivotY); | |
| if (Math.abs(s.torch.handX - ex) > 0.5 || Math.abs(s.torch.handY - ey) > 0.5 || | |
| Math.abs(s.torch.cx - s.torch.handX) > 0.5 || | |
| Math.abs(s.torch.cy - (s.torch.handY - torch.fireCentre)) > 0.5) off.push(s.prince.frame); | |
| }; | |
| for (let i = 0; i < 60; i++) sample({ torch: true }); | |
| const up = shown.join(','); | |
| shown.length = 0; | |
| for (let i = 0; i < 60; i++) sample({}); | |
| const down = shown.join(','); | |
| scene.reset(); | |
| // Lowering starts FROM the last frame, which the raise ended on, so what it | |
| // shows after that is every frame below it, top down. | |
| const order = [...Array(torch.count).keys()]; | |
| const playsInOrder = up === order.join(',') && down === order.slice(0, -1).reverse().join(','); | |
| const lightOutOfTheFire = off.length === 0; | |
| // 4. THE THRUST WHEN THE HIT CAN LAND. The active window opens at the end of | |
| // the windup; the frame on screen then has to be the one she drew the | |
| // blade out straight in. | |
| const at = (ms) => Math.min(Math.floor((ms / 1000) * swing.fps), swing.count - 1); | |
| const thrustOnTheHit = at(TUNING.ATTACK_WINDUP_MS) === swing.hitFrame && | |
| at(TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS - 1) === swing.hitFrame && | |
| at(TUNING.ATTACK_WINDUP_MS - 1) < swing.hitFrame; | |
| // 5. THE FALL ENDS ON THE GROUND. Measured by where the body is -- the | |
| // mean height of its ink above the ground -- not by its top: lying on | |
| // his back she has him raise an arm, and that one hand is 46% of his | |
| // standing height on its own. | |
| const meanHeight = (c, i) => { | |
| const a = alphaOf(c, i); | |
| let sum = 0, n = 0; | |
| for (let y = 0; y < c.height; y++) { | |
| for (let x = 0; x < c.width; x++) { | |
| if (a[(y * c.width + x) * 4 + 3] > 60) { sum += c.pivotY - y; n++; } | |
| } | |
| } | |
| return n ? Math.round(sum / n) : 0; | |
| }; | |
| const standing = meanHeight(fall, 0), lying = meanHeight(fall, fall.count - 1); | |
| const endsOnTheGround = lying < standing * 0.35; | |
| return { | |
| name: 'her poses fit', | |
| pass: sameBody > 0.9 && fireOnTheTorch && playsInOrder && lightOutOfTheFire && thrustOnTheHit && endsOnTheGround, | |
| torchBodyLiesOnTheStandingBody: bodies, | |
| fireOnTheTorch, fires, fireClimbs: climbs, | |
| raiseShowsFrames: up, lowerShowsFrames: down, playsInOrder, | |
| lightOutOfTheFire, framesWithTheFireOffItsTorch: off.length, thrustOnTheHit, | |
| fallStandsAt: standing, fallLiesAt: lying, endsOnTheGround, | |
| }; | |
| } | |
| // --- the voices --------------------------------------------------------------- | |
| // | |
| // Every paper is read aloud by whoever wrote it, while the band shows it. | |
| // Sound is muted while checks run, so these watch what the scene ASKS the | |
| // audio for -- the three calls a voice goes through -- and leave the rest | |
| // of the real, silent object alone. | |
| /** | |
| * Stand in for the voices. `unlocked: false` is a browser before its first | |
| * press: every request is refused until unlock(). | |
| */ | |
| function fakeVoices({ unlocked = true } = {}) { | |
| const saved = { voice: audio.voice, stopVoice: audio.stopVoice, canSpeak: audio.canSpeak }; | |
| const log = []; | |
| let playing = null; | |
| let overlaps = 0; | |
| let open = unlocked; | |
| audio.voice = (name) => { | |
| if (!open) return false; | |
| if (playing) overlaps++; | |
| playing = name; | |
| log.push({ voice: name, at: s.clock }); | |
| return true; | |
| }; | |
| audio.stopVoice = () => { | |
| if (playing) log.push({ stop: playing, at: s.clock }); | |
| playing = null; | |
| }; | |
| audio.canSpeak = () => true; | |
| return { | |
| log, | |
| get playing() { return playing; }, | |
| get overlaps() { return overlaps; }, | |
| unlock() { open = true; }, | |
| restore() { Object.assign(audio, saved); }, | |
| }; | |
| } | |
| /** | |
| * EVERY PAPER HAS ITS VOICE: each letter and each fragment names a recording, | |
| * the file is there, and no two papers share one. And the meter that levels | |
| * them is right -- a 1 kHz tone at -20 dBFS is -23 LUFS by BS.1770's own | |
| * definition, so that is what it has to read. | |
| */ | |
| function everyPaperHasAVoice() { | |
| const found = (file) => { | |
| if (!file) return null; | |
| const x = new XMLHttpRequest(); | |
| x.open('HEAD', file, false); | |
| try { x.send(); } catch { return 0; } | |
| return x.status; | |
| }; | |
| const rows = s.letters.byHouse.map((h) => ({ | |
| paper: h.house, | |
| letter: found(h.letterVoice), fragment: h.fragment ? found(h.fragmentVoice) : 'none written', | |
| })); | |
| const papers = s.letters.byHouse.reduce((n, h) => n + (h.letter ? 1 : 0) + (h.fragment ? 1 : 0), 0); | |
| const files = s.letters.byHouse.flatMap((h) => [h.letterVoice, h.fragmentVoice]).filter(Boolean); | |
| const everyOneThere = rows.every((r) => r.letter === 200 && (r.fragment === 200 || r.fragment === 'none written')); | |
| const noneShared = new Set(files).size === files.length; | |
| const sr = 48000; | |
| const tone = new Float32Array(sr * 3); | |
| for (let i = 0; i < tone.length; i++) tone[i] = 0.1 * Math.sin(2 * Math.PI * 1000 * i / sr); | |
| const meter = loudness(tone, sr); | |
| return { | |
| name: 'every paper has a voice', | |
| pass: everyOneThere && noneShared && files.length === papers && Math.abs(meter + 23.01) < 0.1, | |
| papers, voices: files.length, everyOneThere, noneShared, | |
| meterReadsTheReferenceTone: +meter.toFixed(2), rows, | |
| }; | |
| } | |
| /** | |
| * THE VOICE READS WHAT THE BAND SHOWS, and nothing else. | |
| * | |
| * A paper is read once it has been up VOICE_DELAY_MS, from the top; a new | |
| * paper stops the old voice first, so two never speak at once; paging past | |
| * with an arrow held reads nothing; looking back reads it again; walking | |
| * away stops it; and dying stops it. | |
| */ | |
| function theVoiceReadsWhatTheBandShows() { | |
| if (!audio) return { name: 'the voice reads what the band shows', pass: false, why: 'no audio' }; | |
| const fake = fakeVoices(); | |
| const delay = TUNING.VOICE_DELAY_MS; | |
| const frameMs = 1000 / 60; | |
| const step = (input = {}) => scene.update(1 / 60, { ...NONE, ...input }); | |
| const run = (secs, input = {}) => { for (let i = 0; i < Math.round(secs * 60); i++) step(input); }; | |
| const standAt = (x) => { s.prince.x = s.prince.prevX = x; s.prince.facing = 1; s.camera.reset(x); }; | |
| const voiced = (from) => fake.log.slice(from).filter((e) => e.voice).map((e) => e.voice); | |
| try { | |
| scene.reset(); | |
| const forge = s.houses[0]; | |
| const L = s.letters.byHouse[0].letter; | |
| // 1. The torch on the forge's letter: the band opens, and VOICE_DELAY_MS | |
| // later Mira reads it. Not a frame before. | |
| standAt(forge.x - 300); | |
| let opened = -1, spoke = -1; | |
| for (let i = 0; i < 90; i++) { | |
| step({ torch: true }); | |
| if (opened < 0 && s.band.text === L) opened = i; | |
| if (spoke < 0 && fake.log.some((e) => e.voice === 'letter 0')) spoke = i; | |
| } | |
| const waitedMs = Math.round((spoke - opened) * frameMs); | |
| const readAfterTheDelay = opened >= 0 && spoke > opened && waitedMs >= delay - frameMs && waitedMs <= delay + 2 * frameMs; | |
| // 2. Torch down, stood still: the same paper, so the same reading goes on. | |
| let mark = fake.log.length; | |
| run(1.2); | |
| const notRestartedByTheLight = s.band.text === L && voiced(mark).length === 0 && fake.playing === 'letter 0'; | |
| // 3. Hold Q on the well house keeper's scrap: Mira stops and the keeper is | |
| // read. Let go: the door is read again, from the top. | |
| s.prince.inventory.push({ kept: 2, answers: null, seen: true }); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: 2 }); | |
| s.sheaf.index = s.sheaf.pages.length - 1; | |
| mark = fake.log.length; | |
| run(1.2, { sheaf: true }); | |
| run(1.2); | |
| const loop = fake.log.slice(mark).map((e) => (e.voice ? `read ${e.voice}` : `stop ${e.stop}`)).join(', '); | |
| const comparing = loop === 'stop letter 0, read fragment 2, stop fragment 2, read letter 0'; | |
| // 4. Paging with an arrow held flips past every page in silence, and | |
| // only the one he stops on is read. | |
| for (const k of [0, 1, 4]) { | |
| s.prince.inventory.push({ kept: k, answers: null, seen: true }); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: k }); | |
| } | |
| s.sheaf.index = 0; | |
| run(0.6, { sheaf: true }); | |
| mark = fake.log.length; | |
| run(1.0, { sheaf: true, right: true }); | |
| const flippedPast = voiced(mark); | |
| run(1.0, { sheaf: true }); | |
| const last = s.page(); | |
| const pagedInSilence = flippedPast.length === 0 && voiced(mark).length === 1 && | |
| voiced(mark)[0] === `fragment ${last.kept}`; | |
| run(0.6); | |
| // 5. Walking away closes the band and stops the voice. | |
| mark = fake.log.length; | |
| run(1.5, { left: true }); | |
| const walkingStops = fake.playing === null && fake.log.slice(mark).some((e) => e.stop); | |
| // 6. The note on the tree has its own voice. | |
| standAt(s.tree.x - 127); | |
| mark = fake.log.length; | |
| run(1.4, { torch: true }); | |
| const treeRead = voiced(mark).includes(`letter ${s.tree.index}`); | |
| // 7. Dying stops it. His papers up beside a drained on his last hit: the | |
| // light wakes it, it comes, and the voice ends with him. | |
| scene.reset(); | |
| const v = s.drained[0]; | |
| s.prince.inventory.push({ kept: 1, answers: null, seen: true }); | |
| s.sheaf.pages.push({ kind: 'fragment', kept: 1 }); | |
| s.sheaf.index = s.sheaf.pages.length - 1; | |
| s.prince.hits = TUNING.PRINCE_HITS - 1; | |
| standAt(v.x - 40); | |
| let readWhenHeDied = null; | |
| for (let i = 0; i < 60 * 12 && !s.loss.active; i++) { | |
| step({ sheaf: true }); | |
| if (s.loss.active) readWhenHeDied = fake.playing; | |
| } | |
| const deathStops = s.loss.active && readWhenHeDied === null && | |
| fake.log.some((e) => e.voice === 'fragment 1'); | |
| return { | |
| name: 'the voice reads what the band shows', | |
| pass: readAfterTheDelay && notRestartedByTheLight && comparing && pagedInSilence && | |
| walkingStops && treeRead && deathStops && fake.overlaps === 0, | |
| readAfterMs: waitedMs, delayMs: delay, readAfterTheDelay, notRestartedByTheLight, | |
| comparingDoorAndPaper: loop, pagedInSilence, walkingStops, treeRead, deathStops, | |
| twoAtOnce: fake.overlaps, | |
| }; | |
| } finally { | |
| fake.restore(); | |
| scene.reset(); | |
| } | |
| } | |
| /** | |
| * HIS ORDERS ARE READ TO HIM, and the first press is what lets them be. | |
| * | |
| * A browser makes no sound before a press, so that press asks for the | |
| * reading and the page stays up; the next one puts it away. A key still held | |
| * from the first does not count. If the voice never comes, a press after | |
| * VOICE_WAIT_MS ends it as it always did -- and with sound already on he | |
| * hears it at once, and with sound off nothing about the opening changed. | |
| */ | |
| function theOrdersAreRead() { | |
| if (!audio) return { name: 'the orders are read', pass: false, why: 'no audio' }; | |
| const ORDERS = `letter ${orderHouse()}`; | |
| const step = (input = {}) => scene.update(1 / 60, { ...NONE, ...input }); | |
| const run = (secs, input = {}) => { for (let i = 0; i < Math.round(secs * 60); i++) step(input); }; | |
| const out = {}; | |
| // 1-4. A browser that has heard nothing yet. | |
| let fake = fakeVoices({ unlocked: false }); | |
| try { | |
| scene.reset(); | |
| scene.openAgain(); | |
| const from = s.prince.x; | |
| run(1); | |
| out.silentBeforeAPress = fake.log.length === 0 && !s.opening.done; | |
| step({ right: true }); | |
| fake.unlock(); // the press is what lets a browser make a sound | |
| run(0.3, { right: true }); | |
| out.firstPressReads = !s.opening.done && fake.log.some((e) => e.voice === ORDERS) && | |
| Math.abs(s.prince.x - from) < 0.001; | |
| run(1.5, { right: true }); | |
| out.aHeldKeyDoesNotEndIt = !s.opening.done; | |
| run(0.3); | |
| step({ right: true }); | |
| out.theNextPressEndsIt = s.opening.done; | |
| run(0.2); | |
| out.andTheReadingStops = fake.playing === null && fake.log.some((e) => e.stop === ORDERS); | |
| } finally { fake.restore(); } | |
| // 5. A voice that never comes. | |
| fake = fakeVoices({ unlocked: false }); | |
| try { | |
| scene.reset(); | |
| scene.openAgain(); | |
| step({ right: true }); | |
| run(0.5); | |
| out.itWaitsForTheVoice = !s.opening.done; | |
| run(TUNING.VOICE_WAIT_MS / 1000); | |
| step({ right: true }); | |
| out.butNotForEver = s.opening.done; | |
| } finally { fake.restore(); } | |
| // 6. Sound already on -- he clicked the game before pressing anything. | |
| fake = fakeVoices({ unlocked: true }); | |
| try { | |
| scene.reset(); | |
| scene.openAgain(); | |
| run(TUNING.VOICE_DELAY_MS / 1000 + 0.3); | |
| out.readAtOnceWithSoundOn = fake.log.some((e) => e.voice === ORDERS) && !s.opening.done; | |
| step({ right: true }); | |
| out.thenTheFirstPressEndsIt = s.opening.done; | |
| } finally { fake.restore(); } | |
| // 7. Sound off -- the real audio, muted while the checks run. | |
| scene.reset(); | |
| scene.openAgain(); | |
| run(0.5); | |
| step({ right: true }); | |
| out.soundOffIsUnchanged = s.opening.done; | |
| scene.reset(); | |
| return { name: 'the orders are read', pass: Object.values(out).every(Boolean), ...out }; | |
| } | |
| // --- the two lanes ------------------------------------------------------------ | |
| // | |
| // Every player who saw a Drained on his road fought it: no side scroller lets | |
| // you walk past an enemy on your own line. So an unlit one stands beside the | |
| // road instead, and these are the rules that make that true rather than a | |
| // picture of it. See docs/specs/astara-two-lanes-spec.md. | |
| const gatekeeper = () => s.village[s.village.length - 1]; | |
| /** Where a figure's feet are drawn, off the one function the renderer uses. */ | |
| const feetY = (d) => GROUND_Y - laneLook(d.lane).rise; | |
| /** Draw the level once, with the clock pinned, and keep the pixels. */ | |
| const grabFrame = () => { | |
| s.clock = 3.0; | |
| renderer.drawLevel(scene, 0, { pass1Ms: 0, pass2Ms: 0, bgDrawn: 0 }); | |
| return ctx.getImageData(0, 0, W, H).data.slice(); | |
| }; | |
| /** | |
| * NOBODY BLOCKS THE ROAD. | |
| * | |
| * Before anything is lit, every Drained but the gatekeeper stands off the | |
| * road's ground line. Then the whole walk, from the first step to the shut | |
| * gate with the torch down: nothing on his path is on his plane, nothing | |
| * wakes, nothing hurts him. | |
| * | |
| * AND THE SWORD PASSES THEM. He swings at every one of them as he reaches it. | |
| * A blade that reached the back lane would start the exact fight the lane | |
| * exists to stop, and nothing on screen would say why it had. | |
| * | |
| * ONCE EACH. Swinging for as long as one was in reach rooted him there for | |
| * good — the blade passes, he is still in reach, he swings again — and the | |
| * walk never reached the gate. That was the check, not the game. | |
| */ | |
| function nobodyBlocksTheRoad() { | |
| scene.reset(); | |
| const gk = gatekeeper(); | |
| const others = s.drained.filter((d) => d !== gk); | |
| const beside = others.filter((d) => feetY(d) < GROUND_Y - 1).length; | |
| s.prince.x = s.prince.prevX = 0; | |
| s.prince.facing = 1; | |
| s.camera.reset(0); | |
| const hits0 = s.prince.hits; | |
| const reach = attackReach(); | |
| const swungAt = new Set(); | |
| let onHisLine = 0, swings = 0, ticks = 0; | |
| const limit = Math.round((s.gateStopX / TUNING.WALK_SPEED + 40) * 60); | |
| while (s.prince.x < s.gateStopX - 1 && ticks < limit) { | |
| // Swing at each one once, the first moment it is inside the blade's reach. | |
| const target = s.prince.attacking ? null : others.find((d) => { | |
| const gap = d.x - s.prince.x; | |
| return !swungAt.has(d) && gap > -40 && gap <= reach; | |
| }); | |
| if (target) { swungAt.add(target); swings++; } | |
| scene.update(1 / 60, { ...NONE, right: true, attack: !!target }); | |
| ticks++; | |
| for (const d of others) { | |
| if (!d.revealed && d.lane < 1 && Math.abs(d.x - s.prince.x) < 110) onHisLine++; | |
| } | |
| } | |
| const reached = s.prince.x >= s.gateStopX - 1; | |
| const struck = others.filter((d) => d.hits > 0).length; | |
| const woke = s.drained.filter((d) => d.revealed).length; | |
| return { | |
| name: 'nobody blocks the road', | |
| pass: beside === others.length && reached && onHisLine === 0 && | |
| swings >= others.length && struck === 0 && woke === 0 && s.prince.hits === hits0, | |
| besideTheRoad: beside, of: others.length, | |
| walkedToTheGate: reached, seconds: +(ticks / 60).toFixed(1), | |
| ticksAnyoneWasOnHisLine: onHisLine, | |
| swungAtThem: swings, struck, woke, hurt: s.prince.hits - hits0, | |
| }; | |
| } | |
| /** How far the sword reaches ahead of him, from the clip the scene swings with. */ | |
| const attackReach = () => (clips.prince_attack?.reach ?? 175); | |
| /** | |
| * THE GATEKEEPER IS ON THE ROAD, AND ONLY HIM. | |
| * | |
| * The gate is on the road, so its keeper stands on it: the one Drained that | |
| * is actually in the way, and so the tutorial for the lane rule as well as | |
| * for everything else. Every seed, because the deal moves every other body | |
| * from slot to slot and nothing about that may put one on his line. | |
| */ | |
| function gatekeeperIsOnTheRoad(seeds = 25) { | |
| const seed0 = s.seed; | |
| const bad = []; | |
| for (let n = 0; n < seeds; n++) { | |
| scene.reset(n * 104723 + 11); | |
| const gk = gatekeeper(); | |
| const onRoad = s.drained.filter((d) => feetY(d) >= GROUND_Y - 1 && laneLook(d.lane).scale > 0.999); | |
| if (onRoad.length !== 1 || onRoad[0] !== gk || !gk.post) { | |
| bad.push({ seed: n, onTheRoad: onRoad.length, gatekeeperAmongThem: onRoad.includes(gk) }); | |
| } | |
| } | |
| // Put the run's own deal back. The other seed-sweeping checks leave the | |
| // last one dealt, which is harmless only until something reads it. | |
| scene.reset(seed0); | |
| return { | |
| name: 'the gatekeeper is on the road', | |
| pass: bad.length === 0, | |
| seedsTried: seeds, broke: bad.length, firstFailure: bad[0] ?? null, | |
| }; | |
| } | |
| /** | |
| * THE STEP DOWN IS THE TELEGRAPH. | |
| * | |
| * A stare wakes one into STEP_DOWN, not straight into RUSH. For STEP_MS it | |
| * cannot be hit — refused by its own rule, and passed by the sword, which is | |
| * out and mid-swing into it — while its scale, height and value ease from | |
| * the back lane's to the road's, and it does not move along the road. Then | |
| * it rushes. The stare is 800ms and this is 400, so it is 1.2s from "I am | |
| * looking hard" to "it is on the road coming at me". | |
| */ | |
| function stepDownTelegraphs() { | |
| scene.reset(); | |
| const d = s.drained[0]; | |
| s.prince.x = s.prince.prevX = d.x - 120; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| let n = 0; | |
| while (!d.revealed && n < 60 * 4) { scene.update(1 / 60, { ...NONE, torch: true }); n++; } | |
| const entered = d.state; | |
| // The torch DROPPED rather than lowered, so the sword is out for the whole | |
| // step and the swing's live window lands inside it. | |
| s.torch.raise = s.torch.prevRaise = 0; | |
| const x0 = d.x; | |
| const looks = [laneLook(d.lane)]; | |
| let refused = 0, tries = 0, t = 0, moved = false, bladeLive = false; | |
| while (d.state === 'STEP_DOWN' && t < 120) { | |
| tries++; | |
| if (!d.takeHit(s.prince.x)) refused++; | |
| scene.update(1 / 60, { ...NONE, attack: t === 0 }); | |
| const ms = s.prince.attackTime * 1000; | |
| if (s.prince.attacking && ms >= TUNING.ATTACK_WINDUP_MS && | |
| ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS && d.state === 'STEP_DOWN') bladeLive = true; | |
| if (d.x !== x0) moved = true; | |
| looks.push(laneLook(d.lane)); | |
| t++; | |
| } | |
| const stepMs = Math.round((t / 60) * 1000); | |
| const first = looks[0], last = looks[looks.length - 1]; | |
| let monotonic = true; | |
| for (let i = 1; i < looks.length; i++) { | |
| if (looks[i].scale < looks[i - 1].scale - 1e-9 || looks[i].rise > looks[i - 1].rise + 1e-9 || | |
| looks[i].value > looks[i - 1].value + 1e-9) monotonic = false; | |
| } | |
| // Eased, not linear: less of the way covered in the first quarter than in | |
| // the second. | |
| const q = Math.max(1, Math.floor(looks.length / 4)); | |
| const eased = (looks[0].rise - looks[q].rise) < (looks[q].rise - looks[2 * q].rise); | |
| const startsBeside = Math.abs(first.scale - TUNING.BACK_LANE_SCALE) < 0.01 && | |
| Math.abs(first.rise - TUNING.BACK_LANE_RISE) < 1 && Math.abs(first.value - TUNING.BACK_LANE_VALUE) < 1; | |
| const endsOnRoad = last.scale === 1 && last.rise === 0 && last.value === 0; | |
| return { | |
| name: 'the step down telegraphs', | |
| pass: entered === 'STEP_DOWN' && d.state === 'RUSH' && | |
| Math.abs(stepMs - TUNING.STEP_MS) <= 34 && tries > 0 && refused === tries && | |
| bladeLive && d.hits === 0 && startsBeside && endsOnRoad && monotonic && eased && !moved, | |
| wokeInto: entered, thenInto: d.state, stepMs, stepFloor: TUNING.STEP_MS, | |
| hitsRefused: `${refused} of ${tries}`, swordWasLiveDuringIt: bladeLive, hitsLanded: d.hits, | |
| startsBeside, endsOnRoad, monotonic, eased, movedAlongTheRoad: moved, | |
| }; | |
| } | |
| /** | |
| * THEY WATCH YOU PASS. | |
| * | |
| * Walking through one used to produce no reaction at all, so a player who | |
| * tried it got no evidence it was safe. Inside WATCH_NEAR it turns its whole | |
| * body to face him, it follows him as he goes by, and past WATCH_FAR it goes | |
| * back to its own facing. In between it holds — asserted twice more, | |
| * standing still in the band from each side, because that is where a | |
| * facing would flicker if anything were going to. | |
| */ | |
| function watchesYouPass() { | |
| const near = TUNING.WATCH_NEAR, far = TUNING.WATCH_FAR; | |
| scene.reset(); | |
| const d = s.drained[0]; | |
| s.prince.x = s.prince.prevX = d.x - far - 300; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(0.1); | |
| const wrong = []; | |
| let flips = 0, lastFlip = -1e9, quickFlip = false, prev = d.facing, t = 0, cameNear = false; | |
| while (s.prince.x < d.x + far + 300 && t < 60 * 12) { | |
| scene.update(1 / 60, { ...NONE, right: true }); | |
| t++; | |
| if (d.facing !== prev) { | |
| flips++; | |
| if (t - lastFlip < 15) quickFlip = true; | |
| lastFlip = t; | |
| prev = d.facing; | |
| } | |
| const gap = s.prince.x - d.x; | |
| const dist = Math.abs(gap); | |
| if (dist <= near) cameNear = true; | |
| let want; | |
| if (!cameNear && dist > near) want = d.dir; // not yet: its own facing | |
| else if (dist < far && dist > 8) want = Math.sign(gap); // watching him, either side | |
| else if (dist > far) want = d.dir; // he is gone | |
| if (want !== undefined && d.facing !== want && wrong.length < 5) { | |
| wrong.push({ gap: Math.round(gap), facing: d.facing, want }); | |
| } | |
| } | |
| // The band, from outside: walked in to near + 60 and stood there. | |
| scene.reset(); | |
| s.prince.x = s.prince.prevX = d.x - near - 60; | |
| s.camera.reset(s.prince.x); | |
| drive(2); | |
| const holdsFromOutside = d.facing === d.dir && !d.watching; | |
| // And from inside: came to near - 40, then backed out to near + 60. | |
| s.prince.x = s.prince.prevX = d.x - near + 40; | |
| drive(0.3); | |
| s.prince.x = s.prince.prevX = d.x - near - 60; | |
| drive(2); | |
| const holdsFromInside = d.facing === -1 && d.watching; | |
| scene.reset(); | |
| return { | |
| name: 'they watch you pass', | |
| pass: wrong.length === 0 && flips <= 3 && !quickFlip && holdsFromOutside && holdsFromInside, | |
| turns: flips, anyTwoWithinQuarterSecond: quickFlip, | |
| wrongFacings: wrong, bandHoldsFromOutside: holdsFromOutside, bandHoldsFromInside: holdsFromInside, | |
| near, far, | |
| }; | |
| } | |
| /** | |
| * THE LANE READS: the right size, the right height, the right value, and | |
| * standing clear of both of its neighbours. | |
| * | |
| * Measured off the frame, not off the numbers. The figure is drawn where it | |
| * stands, then taken away, and the difference between the two frames is the | |
| * figure. Its fill is read off the upper half, clear of its edge and above | |
| * the mist. | |
| * | |
| * THE VALUE IS SET FROM HERE, which is what the spec asked for. The prince | |
| * measures 2 and a watcher, hazed, measures 41 — not the 26 the palette | |
| * names. The value furthest from both is their midpoint, so the rule is that | |
| * the lane sits in the middle third between them, and under the no-mud band. | |
| * 14 was the spec's starting guess and sat too close to the prince. | |
| */ | |
| function laneReads() { | |
| return withOverlayOff(() => { | |
| scene.reset(); | |
| const d = s.drained[1]; | |
| s.prince.x = s.prince.prevX = d.x - 600; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(0.1); | |
| s.camera.x = s.camera.prevX = d.x - W / 2; // it stands at dead centre | |
| const withIt = grabFrame(); | |
| const x0 = d.x, px0 = d.prevX; | |
| d.x = d.prevX = -1e6; | |
| const without = grabFrame(); | |
| d.x = x0; d.prevX = px0; | |
| d.lane = 0; | |
| const onRoad = grabFrame(); | |
| d.lane = 1; | |
| const differs = (img, i) => Math.abs(lum(img, i) - lum(without, i)) > 6; | |
| const box = (img) => { | |
| let t = H, b = -1, l = W, r = -1; | |
| for (let y = 0; y < H; y++) { | |
| for (let x = 0; x < W; x++) { | |
| if (!differs(img, (y * W + x) * 4)) continue; | |
| if (y < t) t = y; if (y > b) b = y; if (x < l) l = x; if (x > r) r = x; | |
| } | |
| } | |
| return { t, b, l, r, h: b - t + 1 }; | |
| }; | |
| const lane = box(withIt), road = box(onRoad); | |
| const fill = []; | |
| for (let y = lane.t + 6; y < lane.t + lane.h * 0.45; y++) { | |
| for (let x = lane.l; x <= lane.r; x++) { | |
| let clear = true; | |
| for (const [dx, dy] of [[-3, 0], [3, 0], [0, -3], [0, 3]]) { | |
| if (!differs(withIt, ((y + dy) * W + x + dx) * 4)) { clear = false; break; } | |
| } | |
| if (clear) fill.push(lum(withIt, (y * W + x) * 4)); | |
| } | |
| } | |
| fill.sort((a, b) => a - b); | |
| const value = fill.length ? fill[Math.floor(fill.length / 2)] : NaN; | |
| const midX = Math.round((lane.l + lane.r) / 2); | |
| let behind = 0; | |
| for (let y = lane.t - 4; y > Math.max(0, lane.t - 84); y--) behind = Math.max(behind, lum(withIt, (y * W + midX) * 4)); | |
| const patch = (x, y) => { | |
| const v = []; | |
| for (let j = -2; j <= 2; j++) for (let i = -2; i <= 2; i++) v.push(lum(withIt, ((y + j) * W + x + i) * 4)); | |
| v.sort((a, b) => a - b); | |
| return v[12]; | |
| }; | |
| const prince = patch(Math.round(s.prince.x - s.camera.x), GROUND_Y - 200); | |
| const layer = s.background.layers.find((l) => l.key === 'watchers'); | |
| const shift = s.camera.x * layer.rate; | |
| const watcherVals = s.background.sample('watchers', shift - 100, shift + W + 100, true) | |
| .map((wx) => Math.round(wx - shift)) | |
| .filter((x) => x > 150 && x < W - 150 && Math.abs(x - midX) > 250) | |
| .map((x) => patch(x, layer.baseY - 60)) | |
| .filter((v) => v > BLACK && v < 90) | |
| .sort((a, b) => a - b); | |
| const watcher = watcherVals.length ? watcherVals[Math.floor(watcherVals.length / 2)] : NaN; | |
| scene.reset(); | |
| const scale = lane.h / road.h; | |
| const rise = road.b - lane.b; | |
| const third = (watcher - prince) / 3; | |
| const distinct = value - prince >= third && watcher - value >= third; | |
| return { | |
| name: 'the lane reads', | |
| pass: Math.abs(scale - TUNING.BACK_LANE_SCALE) <= 0.02 && | |
| Math.abs(rise - TUNING.BACK_LANE_RISE) <= 2 && | |
| Math.abs(value - TUNING.BACK_LANE_VALUE) <= 3 && value < 30 && | |
| behind - value >= FLOOR_WATCHER_GAP && distinct, | |
| scale: +scale.toFixed(3), scaleSet: TUNING.BACK_LANE_SCALE, | |
| rise, riseSet: TUNING.BACK_LANE_RISE, | |
| value: Math.round(value), valueSet: TUNING.BACK_LANE_VALUE, | |
| prince: Math.round(prince), watcher: Math.round(watcher), watchersMeasured: watcherVals.length, | |
| middleThird: [Math.round(prince + third), Math.round(watcher - third)], clearOfBoth: distinct, | |
| againstTheSky: Math.round(behind - value), floor: FLOOR_WATCHER_GAP, | |
| }; | |
| }); | |
| } | |
| /** | |
| * THE GLANCE STILL WORKS AT 0.82. | |
| * | |
| * The same carrier drawn with its page and without, in the dark and under a | |
| * glance, in the back lane and — for comparison — forced to the road's size. | |
| * Every pixel that differs between the two is what the page is worth to the | |
| * eye. | |
| * | |
| * The spec feared the smaller figure would take the page with it. At 0.82 a | |
| * figure has 67% of the area, so the page alone would keep 67% of what it | |
| * showed on the road. Measured over ten deals it keeps 84 to 101%: the lane | |
| * is 56px up, the flame is high, and a page nearer the flame is better lit. | |
| * The lift pays back most of what the size costs. | |
| * | |
| * THE FLOOR IS FROM THE DARK. In the dark the page merges into the body and | |
| * a carrier cannot be told from a decoy — about 800 pixels, 0.7% of the | |
| * figure, which is the number that says unreadable. A glance has to show at | |
| * least four times that. Over those ten deals it shows 8.5 to 9.4. | |
| * | |
| * On the bowl layout's fixed deal, so the number does not wander with the | |
| * run seed; the run's own deal is put back after. | |
| */ | |
| function theGlanceStillWorks() { | |
| const seed0 = s.seed; | |
| const measure = (torchSecs, lane) => { | |
| scene.reset(20250920); | |
| const c = s.village.find((v) => v.kept === HOUSE_OF.indexOf('the forge')); | |
| s.prince.x = s.prince.prevX = c.x - 280; | |
| s.prince.facing = 1; | |
| s.camera.reset(s.prince.x); | |
| drive(0.1); | |
| drive(torchSecs, { torch: torchSecs > 0 }); | |
| const woke = c.revealed; | |
| const keepLane = c.lane; | |
| c.lane = lane; | |
| const k = c.kept; | |
| const a = grabFrame(); | |
| c.setKept(null); | |
| const b = grabFrame(); | |
| c.setKept(k); | |
| c.lane = keepLane; | |
| let n = 0; | |
| for (let i = 0; i < a.length; i += 4) if (Math.abs(lum(a, i) - lum(b, i)) > 8) n++; | |
| const sc = laneLook(lane).scale; | |
| return { pixels: n, ofFigure: +(100 * n / (380 * 430 * sc * sc)).toFixed(2), woke }; | |
| }; | |
| const rows = withOverlayOff(() => ({ | |
| laneDark: measure(0, 1), laneGlance: measure(0.45, 1), | |
| roadDark: measure(0, 0), roadGlance: measure(0.45, 0), | |
| })); | |
| scene.reset(seed0); | |
| return { | |
| name: 'the glance still works', | |
| pass: !rows.laneGlance.woke && rows.laneGlance.pixels >= 4 * rows.laneDark.pixels, | |
| glanceOverDark: +(rows.laneGlance.pixels / Math.max(1, rows.laneDark.pixels)).toFixed(1), floor: 4, | |
| keptOfTheRoadsReading: +(rows.laneGlance.pixels / Math.max(1, rows.roadGlance.pixels)).toFixed(2), | |
| ...rows, | |
| }; | |
| } | |
| function all() { | |
| const out = [figureContrast(), watcherContrast(), brightMass(), skyWeave(), | |
| bowlLayout(), bodiesPersist(), wholeLevelWalkable(), stillUntilLit(), | |
| doorsRead(), pagesCost(), sheafPages(), markingIsAClaim(), | |
| theRoomAsks(), aGlanceIsFree(), theLightGoesOut(), theKeysAreShown(), bandFits(), | |
| dealObeysRules(), swingerIsBait(), | |
| topPageIsTheHand(), eAtTheDoor(), theWallAsksAndTheSkyAnswers(), | |
| walksHome(), keyBehindTheDoor(), residueStaysLocked(), | |
| nothingOutOfReach(), healthCosts(), theGate(), | |
| orderIsPageOne(), openingHolds(), | |
| nobodyBlocksTheRoad(), gatekeeperIsOnTheRoad(), stepDownTelegraphs(), | |
| watchesYouPass(), laneReads(), theGlanceStillWorks(), | |
| wrongDoorWakes(), gatekeeperWakesAtHisPost(), theTreeTeaches(), | |
| lettersStay(), theSlotIsAlwaysThere(), bandReadsTheDoor(), theDoorCarriesFour(), | |
| theGateRoomOpens(), | |
| everyPaperHasAVoice(), theVoiceReadsWhatTheBandShows(), theOrdersAreRead(), | |
| herPosesFit()]; | |
| return { pass: out.every((r) => r.pass), results: out }; | |
| } | |
| /** | |
| * EVERY CHECK RUNS SILENT. | |
| * | |
| * The suite drives something like half an hour of game into a few hundred | |
| * milliseconds. The scene does not know that: it fires a gasp for every | |
| * Drained that wakes, a drag for every footfall, a clap for every filing, | |
| * and with no real time between them they all land at once and sum into one | |
| * blast. Running the checks on a page reload — which is most of what | |
| * developing this game consists of — meant that blast, every time. | |
| * | |
| * Nested calls are safe: `all()` mutes, the checks inside it see audio | |
| * already off, take `was` as false and leave it alone, and only the outer | |
| * call turns it back on. And it restores what it found rather than turning | |
| * sound ON, so running a check while muted does not unmute the game. | |
| */ | |
| const silent = (fn) => function (...a) { | |
| const was = audio ? audio.enabled : false; | |
| if (was) audio.setEnabled(false); | |
| try { return fn.apply(this, a); } finally { if (was) audio.setEnabled(true); } | |
| }; | |
| const api = { figureContrast, watcherContrast, brightMass, skyWeave, | |
| bowlLayout, bodiesPersist, wholeLevelWalkable, stillUntilLit, doorsRead, | |
| pagesCost, sheafPages, markingIsAClaim, theRoomAsks, aGlanceIsFree, theLightGoesOut, theKeysAreShown, bandFits, dealObeysRules, swingerIsBait, | |
| topPageIsTheHand, eAtTheDoor, theWallAsksAndTheSkyAnswers, | |
| walksHome, keyBehindTheDoor, residueStaysLocked, | |
| nothingOutOfReach, healthCosts, theGate, | |
| orderIsPageOne, openingHolds, | |
| nobodyBlocksTheRoad, gatekeeperIsOnTheRoad, stepDownTelegraphs, | |
| watchesYouPass, laneReads, theGlanceStillWorks, | |
| wrongDoorWakes, gatekeeperWakesAtHisPost, theTreeTeaches, | |
| lettersStay, theSlotIsAlwaysThere, bandReadsTheDoor, theDoorCarriesFour, | |
| theGateRoomOpens, | |
| everyPaperHasAVoice, theVoiceReadsWhatTheBandShows, theOrdersAreRead, | |
| herPosesFit, | |
| all, frameAt, frameAtX }; | |
| return Object.fromEntries(Object.entries(api).map(([k, fn]) => [k, silent(fn)])); | |
| } | |