// The level. A walkable stretch of forest with a beginning, encounters and an // end. // // THE ARGUMENT THIS LEVEL MAKES: looking is what starts every fight. A Drained // that has never been lit never attacks. All of the combat here is caused by // the player choosing to see. A player who never raises the torch walks the // whole level, learns nothing, and finishes it — and that has to stay possible. // // L1 scope: he walks, he has a facing, the camera follows, and the torch locks // him in place. Background is L2, combat is L4, watchers are L5. import { createPlayer } from './anim.js'; import { createTorch } from './torch.js'; import { createCamera } from './camera.js'; import { createBackground, tintFrames } from './background.js'; import { createDrained, HOUSE_OF, WANDER, RUSH, DOWNED, RISING, HOMING, HOME } from './drained.js'; import { TUNING, BOWL, PALETTE, TORCH, AUDIO, startX, runSeed, mulberry32, FLASH, flashAt, atLuminance } from './tuning.js'; export const W = 1920; export const H = 1080; export const GROUND_Y = 880; export async function createLevelScene(clips, { audio = null, seed = null, letters = null } = {}) { const SEED = seed ?? runSeed(); const runClip = clips.prince_run; const idleClip = clips.prince_idle; // EVERY POSE FALLS BACK TO HER STANDING DRAWING. // // The generated stand-ins are gone — raising the torch, holding the sheaf, // standing ready, holding his side. Each was a different body next to hers, // and changing pose looked like changing character, which is a worse lie than // not changing pose at all. // // AND NOW SHE HAS DRAWN THE REST: the swing, the fall and the torch held up. // Nothing he does is generated any more. // // Put any of them back in the manifest and it is used again with no code // change. That is the whole point of this file reading the manifest instead // of naming frames. const pose = (clip) => clip ?? idleClip; const torchClip = pose(clips.prince_torch); const guardClip = pose(clips.prince_guard); // HOLDING THE SHEAF HOLDS THE TORCH UP, so with no drawing of his papers in // his hand, the pose is the torch held up -- her arm -- and the sheet is drawn // at its mapPivot. Falling back to the standing drawing put the jointed // placeholder arm back on him every time he read. const mapClip = clips.prince_map ?? torchClip; const hurtClip = clips.prince_hurt ?? null; const sheetClip = clips.map_sheet; // The hand the torch is held in, published by the manifest in sprite // coordinates. Move the pose in the art and the light follows it — and with // no raised-torch drawing, that is her far hand at hip height. const hand = torchClip.handPivot ?? [torchClip.pivotX, torchClip.pivotY - 230]; const HAND_DX = hand[0] - torchClip.pivotX; const HAND_DY = hand[1] - torchClip.pivotY; // The jointed placeholder arm, if the manifest has one. Its down pose lands // on handPivot to the pixel, so with or without it the lowered hand is in // the same place and only the raise differs. // Where the fire stands in each frame of her raise, as offsets from the pose's // pivot. Null for a pose with one hand position, which uses HAND_DX/DY. const TORCH_HANDS = torchClip.handPivots ? torchClip.handPivots.map(([x, y]) => [x - torchClip.pivotX, y - torchClip.pivotY]) : null; const TORCH_ARM = torchClip.torchArm ? { ...torchClip.torchArm, shoulder: [torchClip.torchArm.shoulder[0], torchClip.torchArm.shoulder[1]] } : null; const START_X = startX(); const attackClip = pose(clips.prince_attack); const downClip = pose(clips.prince_down); const ATTACK_TOTAL = (TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS + TUNING.ATTACK_RECOVERY_MS) / 1000; const CONTACT_REACH = 110; /** Deterministic 0..1 from an integer. Same walk, same encounters. */ const hash01 = (i, salt) => { let h = Math.imul((i | 0) ^ salt, 2654435761) >>> 0; h ^= h >>> 15; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13; return (h >>> 0) / 4294967296; }; const drained = []; // Bounded at both ends from the first frame: the start of the road, and the // shut gate. See the note at GATE_CAM_X for why the right-hand bound is not // simply the gate. const camera = createCamera(W, 0, 0); // real bounds set once GATE_CAM_X exists const torch = createTorch(); const prince = { x: START_X, prevX: START_X, facing: 1, moving: false, hasSword: true, clip: idleClip, frame: 0, run: createPlayer(runClip), idle: createPlayer(idleClip), torchPlayer: createPlayer(torchClip), guard: createPlayer(guardClip), mapPlayer: mapClip ? createPlayer(mapClip) : null, hurtPlayer: hurtClip ? createPlayer(hurtClip) : null, // Hits TAKEN, not left. There is no bar; it is read off his body. hits: 0, // HOW FAR THE LIGHT HAS GONE OUT OF HIM. 0 is a full white eye, 1 is dark. // This is the health readout, and it replaces nothing because there never // was one: no bar, no number. He limps, he holds his side, the swing slows, // and now the one thing you can always see on a black silhouette dims. eyeDim: 0, attacking: false, attackTime: 0, attackConnected: false, attack: createPlayer(attackClip), down: createPlayer(downClip), // What he is carrying. Each entry knows whether he ever actually LOOKED at // it: take one in the dark and you own the thing without ever learning // whose it was, which is the level's whole argument stated as an object. inventory: [], // KEYS ARE NOT IN THE SHEAF and this is why they are a number rather than // a page. They carry no deduction, they cannot be read, and they are the // same object five times over. A pile of loose paper is what he has worked // out; this is just what is in his pocket. keys: 0, }; // Async because the sky texture and the tinted hills are built once, here, // rather than per frame. const background = await createBackground(clips, GROUND_Y, H); // THE BACK LANE'S VALUE, baked once. Everything a Drained can be showing // while it stands beside the road — the still pose, the three loops, the // walk a wanderer paces on and steps down with — and the page a carrier // holds, because the page is part of the figure's mass in the dark. // // A tint and not a translucent black. Black at 86% would be 14 against the // hills and 21 against the sky: a figure you can see the world through. // `back` sits beside `dark` and `lit` on every frame, and the renderer // blends it toward `dark` while one steps down. { const hex = atLuminance(PALETTE.WATCHERS, TUNING.BACK_LANE_VALUE); for (const name of ['drained_watcher', 'drained_walk', 'drained_step', 'loop_swing', 'loop_haul', 'loop_bear', 'kept']) { const c = clips[name]; if (!c || c.frames[0]?.back) continue; const tinted = await tintFrames(c, hex, 'dark'); c.frames.forEach((f, i) => { f.back = tinted[i]; }); } } // Three Drained along the road, deterministic, NONE of them hostile until the // player makes them hostile. A player who never raises the torch walks the // whole level, learns nothing, and finishes it. That has to remain possible — // it is the fed run in miniature. // // They stand. Nothing in this level wanders any more: an unlit Drained is a // person who stopped, and standing still is what that looks like. See the // note at stillClip in drained.js. // // Each one gets a window and stands at the spot inside it with the most open // sky behind the action. A silhouette only shows up against light, so this is // the one thing Blade Warrior does that costs nothing — the fight belongs // under the bright patch. See background.skyOpenAt for what "open" counts. // THE TREE GOES DOWN FIRST, the way the doors do, and the three road drained // go in what is left. See TREE_CLEAR. const TREE_X = Math.round(TUNING.LEVEL_LENGTH_PX * TUNING.TREE_AT); { const L = TUNING.LEVEL_LENGTH_PX; const n = TUNING.ROAD_ENCOUNTERS; for (let i = 0; i < n; i++) { // The jitter moves the WINDOW, not the answer. Applying it after the // choice simply undid the choice. const centre = (0.25 + i * (0.5 / Math.max(1, n - 1))) * L + (hash01(i, 0x51) - 0.5) * 500; const span = 0.08 * L; let bestX = Math.round(centre), best = -Infinity; for (let k = -12; k <= 12; k++) { const cand = centre + (k / 12) * span; if (cand < 900 || cand > BOWL.X0 - 900) continue; // Never within the torch's own WIDTH of one already placed. Chasing the // open sky pulled two of three encounters to 667px apart, and the torch // is 680 across — so one raise woke both, on the stretch that is // supposed to be teaching him what raising it costs. if (drained.some((o) => Math.abs(o.x - cand) < TORCH.RADIUS * 2)) continue; // And never within the torch's reach of the note on the tree. if (Math.abs(cand - TREE_X) < TUNING.TREE_CLEAR) continue; // Gentle pull to the middle of the window so ties do not drift to an end. const score = background.skyOpenAt(cand, W) - Math.abs(cand - centre) * 0.6; if (score > best) { best = score; bestX = Math.round(cand); } } // They carry NOTHING and they do no loop at all. They are from elsewhere: // travellers who stopped, not residents. One object per kind means a // second hammer out here would file against the forge and the map's only // error state is two objects landing on one house. // // So the loop skill correctly rules them out, once the player has it. drained.push(createDrained(clips, bestX, hash01(i, 0x77) < 0.5 ? -1 : 1, i, { kept: null, keptMaxX: BOWL.X0 })); } } // --- the village ------------------------------------------------------ // What used to be here: thirty-four watchers stepping onto the path until he // could not get through. A placeholder from the first day, and deleted rather // than disabled. The background watcher layer already populates the bowl, and // a second non-interactive crowd system beside it was one system too many. // // The mouth is a one-way threshold. Cross it and the camera stops following // and starts being bounded; walking back inside is free, walking back out is // not. There is no art at the mouth and no fade. The houses are the only // thing that says the mode changed. const GATE_X = BOWL.X1; const GATE_STOP_X = GATE_X - 380; // inside the gateway, clear of the post // AND THE FAR SIDE OF THE SAME GATEWAY, once the man who holds it has gone // home. 40px inside the right post, as GATE_STOP_X is inside the left, so he // never stands in front of pure black and disappears into it. const GATE_ROOM_X = GATE_X + 380; // Where the view stops, and it is not simply the gate. Bounding the camera at // GATE_X - W/2 put the wall across the middle of the frame and shoved him // into the left half to stand at it. Stopping three quarters of the way over // leaves the village behind him visible while he is working out that the gate // will not open, which is the exact moment he has to turn around. const GATE_CAM_X = GATE_X - W * 0.75; // AND A LITTLE FURTHER ONCE THE GATEWAY IS HIS. With its keeper home he can // walk to the far post, and at GATE_CAM_X that stood him 100px from the edge // of the frame with the post cut in half. This frames the whole gateway — // both posts, the leaf and all four sockets — with 100px of wall past it. // The keeper leaves while the prince is at the gatehouse, a screen away, so // the view is never at either bound when it changes and nothing jumps. const GATE_ROOM_CAM_X = GATE_X + 620 - W; // THERE IS NO ONE-WAY MOUTH, and the reason is worth keeping. // // The spec called the threshold at the village's edge "the whole transition": // cross it and the camera stops following and starts being bounded, and you // cannot walk back out. That was written when the road was twelve screens of // empty approach nobody would ever want to re-walk. // // The road is four screens now and has three Drained on it. Locking the // threshold behind him does not focus the level, it deletes three encounters // and everything they were carrying the moment he steps past a house. So the // camera is bounded at BOTH ends of the whole level from the first frame, and // the village is a place rather than a room with a door. // // What the threshold was FOR survives without it: the houses are still the // only signal the mode changed, and the shut gate is still the only way out. camera.setBounds(0, GATE_CAM_X); // --- the street -------------------------------------------------------- // Five houses and eleven shapes, laid out as a RHYTHM rather than a scatter. // // The village is divided into five blocks. Each block is a house at its head // and open ground behind it, and the shapes live in the open ground. That // guarantees the clear pocket around every door by construction, instead of // scattering shapes and hoping none of them lands on a doorstep — which is // what the first pass did, and all five doors were unlookable. // // It also gives the place a shape you can read while walking: a building, a // stretch of people, a building. A village street, not a field of posts. const CLEAR = TUNING.BOWL_CLEAR; // THE GATEHOUSE COMES OUT OF THE ROW AND STANDS BESIDE THE GATE, which is // what its own art note has said since it was drawn: "palace-built, like the // gate it stands beside". It was 2400px away, which is off the right edge of // a pinned camera — so filing the gatekeeper's fragment, the first thing // anybody files and the tutorial for the entire ending, showed nothing at all // for the better part of a minute. // // At 1100px short of the gate his walk home is 870px: he is 210px off the // right edge when the button is pressed and in frame within a second. His // house is still not the gate and he still walks away from the post, which // is the whole of what section 8 is about. It just happens where you can see // it. The facade is 960 wide, so it ends at 27380 and the gate's posts begin // at 27580; the torch on its door reaches 27100, which is 670px short of him // and comfortably outside DOOR_CLEAR, so lighting it still wakes nothing. const GATEHOUSE_X = BOWL.X1 - 1100; const SPAN4 = (GATEHOUSE_X - 1600) - (BOWL.X0 + 500); const houseX = (i) => (i === 4 ? GATEHOUSE_X : Math.round(BOWL.X0 + 500 + SPAN4 * (i + 0.5) / 4)); // Lighting a door tells you who lived there and nothing else — no shaped // gaps, no racks, no missing-tool puzzle. // // Four of the five have someone out in the forest carrying their answer. The fifth // is not anywhere, and the game never once says so. // // SIZED AGAINST A MAN, not picked. At 470 tall a house came out 1.8x a // standing figure, and a building a man nearly fills the door of reads as a // shed. These run 1.9x to 2.3x and are wider than they are tall. // // AND THEY ARE NOT PURE BLACK. Standing the prince in front of an 820px black // wall merged the two into one mass and he simply disappeared for two seconds // at a time — the same failure the watchers had, at ten times the area. So a // house takes the watcher treatment, which is already measured and already // tested: luminance 26 rather than 0. Everything ON the road stays pure black // and reads against them. Its foot still meets the ground — a building // floating above the ground line reads as a mistake, and the VALUE is what // says it is set back, not a gap under it. // // The gate is the exception and stays pure black, because he never reaches // it — he stops 380px short and the wall is the thing that stopped him. const houseClip = clips.houses; // Where his orders came from. Not one of the five, and deliberately not a // sixth variant of `houses` -- see the manifest's note on the palace clip. const palaceClip = clips.palace ?? null; // Index order is house order and the names come straight off HOUSE_OF, so a // fragment's house and a plot on the map are the same number and cannot // drift apart. const HOUSES = ['forge', 'bakehouse', 'wellhouse', 'home', 'gatehouse'].map((kind, i) => ({ kind, index: i, answers: HOUSE_OF[i], variant: i, x: houseX(i), w: houseClip ? houseClip.width : 960, // HAS THE TORCH BEEN ON ITS DOOR OR ITS LETTER? Latches, like the objects // on the ground, and for the same reason. It is the whole of what the door // remembers about him: the band reads a lit door again for free, and the // slot takes a pin. // // THE LETTER NEVER COMES DOWN, so there is no second flag for it. Letters // stay where they were left and fragments come off bodies: he does not // pocket a stranger's mail, and the door is where he judges. seen: false, // WHAT HE HAS FILED UNDER THIS HOUSE, and nothing else in the build writes // it. The `kept` index of one fragment, or null. The map used to fill this // in by itself the moment he looked at something, which made it a receipt; // a claim only means anything if it can be wrong, so he makes it. filed: null, // SOMEBODY CAME HOME TO IT. Set when a body finishes its walk at this // door, and it is the only thing in the level that is ever a verdict — // except that it is not one, because the game does not say it. A person // is standing in the doorway who was not standing there before. open: false, // The key off its hook. One per house, and four of the five can be had. keyTaken: false, // THE FRAME IS FINISHED WITH. Set the instant a correct fragment goes in, // which is the instant its owner starts walking, not when it arrives. // // It is not a cost. Brute-forcing a door is still four presses and the // addendum allows it. What it stops is one thing: taking a name back off a // wall after the person it belonged to is already on their way, which // would either have to turn them round or quietly not. settled: false, })); // The door, as a world point, from the clip's own pixels. Move it in the art // and the reveal moves with it — the same deal keptAnchor and handPivot get. // // No fallback. A missing doorAt used to quietly default to 140px above the // ground, which happened to be the exact value the manifest asks for — so the // field was not being read at all and nothing showed it. Throwing is the same // guard tintFrames got after it silently painted the previous colour. if (houseClip && !houseClip.doorAt) { throw new Error('clip "houses" has no doorAt: nothing would say where the door is'); } const doorOffset = houseClip ? [houseClip.doorAt[0] - houseClip.pivotX, houseClip.doorAt[1] - houseClip.pivotY] : [0, 0]; const doorPoint = (h) => [h.x + doorOffset[0], GROUND_Y + doorOffset[1]]; /** * The middle of the letter beside that door, as a world point. It never * comes down, so this is always where it hangs. * * Straight out of the clip's own pixels, the same deal `doorAt` gets: move it * in the art and the thing the torch has to cover moves with it. */ const letterPoint = (h) => { const r = houseClip?.letterAt?.[h.variant]; if (!r) return null; return [h.x - houseClip.pivotX + r[0] + r[2] / 2, GROUND_Y - houseClip.pivotY + r[1] + r[3] / 2]; }; /** * And the middle of the slot under it: the second nail, the lighter patch * where their own paper hung, and the page he pins back into it. Same column * as the letter, so the reach to either is the same reach. */ const slotPoint = (h) => { const r = houseClip?.slotAt?.[h.variant]; if (!r) return null; return [h.x - houseClip.pivotX + r[0] + r[2] / 2, GROUND_Y - houseClip.pivotY + r[1] + r[3] / 2]; }; /** * The middle of the key on its hook, as a world point. Same deal doorAt and * letterAt get: move it in the art and what the player has to walk to moves * with it. */ const keyPoint = (h) => { const r = houseClip?.keyAt?.[h.variant]; if (!r) return null; return [h.x - houseClip.pivotX + r[0] + r[2] / 2, GROUND_Y - houseClip.pivotY + r[1] + r[3] / 2]; }; // The dark pass wants them at the watcher value, not black — see the note // above. Tinted once here rather than per frame, like the watchers are. const houseArt = houseClip ? await tintFrames(houseClip, PALETTE.WATCHERS, 'dark') : null; // --- holes the shape of what goes in them -------------------------------- // // The patch beside a door was a rectangle and the gate's sockets were // rectangles, and a rectangle says "a place" without saying what for. Cut // them to the shape of the thing instead and they say both at once, with no // words and no prompt: a torn page fits in a torn-page-shaped mark, a key // fits in a key-shaped hole. It is the oldest trick there is and this level // had been asking its question in the vaguest possible shape. // // TINTED ONCE AT LOAD, off the real art, so the hole and the thing can never // drift apart — the same treatment the houses and the watchers get. All five // torn pages share one contour by design and the silhouette pass throws the // scribble away, so frame 0 is every fragment's outline. const PATCH_HEX = '#6f6553'; // lum 101 — the cleaner wall. See render.js const patchArt = clips.kept ? (await tintFrames(clips.kept, PATCH_HEX, 'dark'))[0] : null; const socketArt = clips.key ? (await tintFrames(clips.key, PALETTE.WATCHERS, 'dark'))[0] : null; // --- the tree on the road ------------------------------------------------ // A rebel nailed the rule to it: they cannot speak, what they carried is all // that is left of them, take it home. Never an answer — not which ones, not // which home. It reads like a house's letter in every way the code cares // about: light it and the band reads it, and after that standing by it // does. It stays on the tree, the same as every letter stays on its door, // and it is not a door — no slot, and nothing is ever pinned back to it. const treeClip = clips.road_tree ?? null; const TREE_NOTE = letters?.byHouse?.findIndex((h) => h.origin === 'tree') ?? -1; const TREE = treeClip && TREE_NOTE >= 0 ? { kind: 'tree', x: TREE_X, index: TREE_NOTE, variant: 0, seen: false } : null; /** The middle of the note on the tree, as a world point. Off the clip's pixels. */ const treePoint = () => { const r = treeClip?.letterAt?.[0]; if (!TREE || !r) return null; return [TREE.x - treeClip.pivotX + r[0] + r[2] / 2, GROUND_Y - treeClip.pivotY + r[1] + r[3] / 2]; }; // PALE IN THE DARK. A house letter has a lit doorway beside it to draw the // eye; a note on a lone tree has nothing, and would be walked past. So the // scrap shows as paper catching what light there is — enough to say // "something is pinned here", never enough to read. Above the no-mud floor. const NOTE_IN_THE_DARK = '#9a9484'; // lum 148 const treeNoteDark = TREE && clips.letter ? (await tintFrames(clips.letter, NOTE_IN_THE_DARK, 'dark'))[TREE_NOTE] ?? null : null; // --- the twelve -------------------------------------------------------- // Twelve shapes you can swing at, against the background watchers you cannot. // Two populations doing different jobs: the watchers make it feel like a // village and cost nothing, and these are what you walk to. // // Eleven stand out in the village and the twelfth is the gatekeeper, at his // post sixty years after anyone relieved him. // // V1 CARRIES NOTHING. The four carriers, the loops and the one-in-three // ratio that makes the search a search are V4; what V1 is for is the size of // the room, and objects in it would only be something to look at instead. // // THE DOORS ARE PLACED FIRST AND THE SHAPES GO IN WHAT IS LEFT. // // Holding the torch freezes him and takes his sword away, so a shape inside // the light's reach of a door means looking at that door wakes something with // nowhere to back off to. The first village scattered shapes evenly and // checked afterwards: all five doors came out unlookable, one of them with a // Drained 59px away. So the clear pockets are cut out of the street FIRST, // and the eleven are spread evenly through the ground that remains. // // Even in FREE-SPACE coordinates, not world ones. Walking a straight line // through the gaps and dropping a shape every so often is what keeps them // from bunching up in whichever gap happened to be largest — which is exactly // how the per-block version put three of them 591px apart. const free = (() => { const out = []; let at = BOWL.X0 + 400; for (const h of HOUSES) { if (h.x - CLEAR > at) out.push([at, h.x - CLEAR]); at = Math.max(at, h.x + CLEAR); } // Short of the gate: the gatekeeper's ground is his, and a shape loitering // there would be in the fight that is supposed to be his alone. if (GATE_STOP_X - CLEAR > at) out.push([at, GATE_STOP_X - CLEAR]); return out; })(); const FREE_LEN = free.reduce((n, [a, b]) => n + (b - a), 0); /** A distance along the free ground, as a world x. */ const atFree = (u) => { let left = u; for (const [a, b] of free) { if (left <= b - a) return a + left; left -= b - a; } return free[free.length - 1][1]; }; // WHO IS DOING WHAT, and it is a fixed pattern rather than a roll. // // Three motions are out here — the gatekeeper is the fourth carrier and he is // at the gate. Each motion is done by three shapes: one carrying, two not. The // last two do nothing in particular, which is what gives the loop skill // something to correctly rule OUT. That is how a skill gets taught without // being taught. // // hammer 1 carrier + 2 decoys yoke 1 carrier + 2 decoys // peel 1 carrier + 2 decoys nothing 2 decoys // // Eleven. Four fights if reading outlines has landed, twelve if it has not, // and that spread IS what V4 measures. // WHO DOES WHAT, AND THE LOOP IS A LAST MOMENT. // // The drained are still doing whatever they were doing when she took them. // That is not a life: a blacksmith swings a hammer for thirty years, and on // the morning she came he was hauling water because his brother had a fever. // The house letter says who he was, the loop says what he was doing at the // end, and the gap between the two is the deduction. // // swinging 3 shapes, and NONE of them carries. The swinger is bait, and // the forge letter names this motion on purpose // hauling 3 shapes, one carrying — the forge's own fragment // bearing 3 shapes, two carrying — the bakehouse and the well house, // who were both doing the same thing that morning // wandering 2 shapes doing nothing anybody wrote down // // THE LAST TWO ARE THE NEGATIVE CONTROL. Without something the loop skill can // correctly rule OUT, every shape is worth a fight and watching is // decoration. They wander rather than stand, because standing reads as the // gatekeeper or as a watcher — their motion has to be legibly not a trade. const ROLES = [ { loop: 'swing' }, { loop: 'swing' }, { loop: 'swing' }, { loop: 'haul' }, { loop: 'haul' }, { loop: 'haul' }, { loop: 'bear' }, { loop: 'bear' }, { loop: 'bear' }, { roams: true }, { roams: true }, ]; const BOWL_N = ROLES.length; // The furthest a dropped object may land and still be pickable. Handed to // every Drained, because the one that got this wrong was unreachable by // 375px and the run simply could not be finished. const KEPT_MAX_X = GATE_STOP_X - TUNING.PICKUP_REACH * 0.6; const village = []; const SLOTS = []; /** * How far a wanderer at x may pace without ever entering a door's pocket. * * Bounded PER DIRECTION. Taking the nearest house in any direction and * applying it both ways leaves the far side under-constrained, and measuring * across a dozen seeds it came within 2px of the clearance — passing, but on * nothing. The house on the left decides how far left it goes. */ const roamRoom = (x) => { const left = HOUSES.filter((h) => h.x < x).map((h) => x - h.x - CLEAR); const right = HOUSES.filter((h) => h.x >= x).map((h) => h.x - x - CLEAR); return Math.max(0, Math.min( (FREE_LEN / BOWL_N) * 0.4, ...(left.length ? [Math.min(...left)] : []), ...(right.length ? [Math.min(...right)] : []), )); }; { const slot = FREE_LEN / BOWL_N; for (let i = 0; i < BOWL_N; i++) { // The slots themselves are WORLD-seeded and never move: their clear // pockets round each door and their spacing floor are what V1 bought. const u = slot * (i + 0.5) + (hash01(i, 0xb0a1) - 0.5) * slot * 0.2; SLOTS.push(Math.round(atFree(u))); } ROLES.forEach((role, i) => { village.push(createDrained(clips, SLOTS[i], hash01(i, 0xb0a2) < 0.5 ? -1 : 1, i, { standing: !role.roams, stayDown: true, loop: role.loop ?? null, kept: null, keptMaxX: KEPT_MAX_X, // A wanderer stays in the open ground it was placed in. Let one drift // and it walks into a door's clear pocket, and lighting that door // wakes it — which is the one thing V1's placement bought. // // BOUNDED BY THE DOOR, NOT ONLY BY THE SLOT. Half a slot is 370px, // and a slot only has to be BOWL_CLEAR from a door to be legal — so // a wanderer in the tightest slot could pace 370 toward the door and // end up well inside the pocket. Which slot a wanderer lands in // varies per run, so this only started happening when roles began // moving between slots. roam: role.roams ? roamRoom(SLOTS[i]) : 0 })); }); // The gatekeeper. Inside the opening, against the shut leaf, and within the // prince's 175px reach of where the gate stops him. He never leaves. village.push(createDrained(clips, GATE_X - 230, -1, BOWL_N, { standing: true, stayDown: true, post: true, kept: HOUSE_OF.indexOf('the gatehouse'), keptMaxX: KEPT_MAX_X })); } // Whether his post is empty: walking home, or home. Nothing else takes him // off it — a fight leaves him on the ground where he stood. const GATEKEEPER = village[village.length - 1]; const keeperGone = () => GATEKEEPER.state === HOMING || GATEKEEPER.state === HOME; /** * THE DEAL, AND IT IS A CONSTRAINED SHUFFLE. * * The rules are the authored part of the level. Which specific body satisfies * one is not, and it is re-rolled every run so that four friends comparing * notes cannot say "the blacksmith is third from the left". * * It buys that and not a second puzzle: a player who remembers that the forge * letter subverts has spent the deduction, and this makes them fight again * rather than think again. */ function dealVillage(seed) { const rnd = mulberry32(seed >>> 0); const shuffled = (list) => list.map((v) => [v, rnd()]) .sort((a, b) => a[1] - b[1]).map((v) => v[0]); // --- which body stands in which slot --------------------------------- // Dealt so NO TWO NEIGHBOURS SHARE A MOTION. A clump of three identical // loops hands the player a spatial tell, and the point is that narrowing by // motion leaves candidates spread out with nothing to choose between them. const pool = new Map(); ROLES.forEach((r, i) => { const key = r.loop ?? '-'; if (!pool.has(key)) pool.set(key, []); pool.get(key).push(i); }); for (const [k, list] of pool) pool.set(k, shuffled(list)); const order = []; let last = null; for (let n = 0; n < BOWL_N; n++) { // Largest group first so nothing is left stranded at the end, and TIES // BROKEN AT RANDOM. Without the tie-break the greedy rule is fully // determined by group sizes, so every run laid the village out in the // same order of motions — swing, haul, bear, swing, haul, bear — and // "the fifth shape is a hauler" survived the shuffle intact. let best = null; let bestN = -1; for (const k of shuffled([...pool.keys()])) { const arr = pool.get(k); if (!arr.length || (k === last && pool.size > 1)) continue; if (arr.length > bestN) { best = k; bestN = arr.length; } } if (!best) best = [...pool.keys()].find((k) => pool.get(k).length); order.push(pool.get(best).shift()); if (!pool.get(best).length) pool.delete(best); last = best; } order.forEach((body, slotIndex) => { const x = SLOTS[slotIndex]; village[body].placeAt(x, roamRoom(x)); }); // --- who is carrying what -------------------------------------------- for (let i = 0; i < BOWL_N; i++) village[i].setKept(null); const byLoop = (l) => shuffled(ROLES.map((r, i) => (r.loop === l ? i : -1)).filter((i) => i >= 0)); // The forge's fragment goes on a HAULER, never a swinger. The whole // subversion rests on it: read "I could hear you at the anvil", walk to the // swinging shape, and nothing falls out of it. village[byLoop('haul')[0]].setKept(HOUSE_OF.indexOf('the forge')); // The bakehouse and the well house were both walking somewhere with // something that morning, so both go on BEARERS. Two of the three. const bearers = byLoop('bear'); const pair = shuffled([HOUSE_OF.indexOf('the bakehouse'), HOUSE_OF.indexOf('the well house')]); village[bearers[0]].setKept(pair[0]); village[bearers[1]].setKept(pair[1]); // Home gets nothing, anywhere. That is the residue. // The gatekeeper keeps his own and is never re-dealt. } dealVillage(SEED); // What the layout actually produced, which is what V1 is judging and is NOT // the bowl width over twelve — the clear pockets around the doors eat it. const VILLAGE_GAP = (() => { const xs = village.map((v) => v.x).sort((a, b) => a - b); let sum = 0; for (let i = 1; i < xs.length; i++) sum += xs[i] - xs[i - 1]; return Math.round(sum / (xs.length - 1)); })(); // One list. The village shapes are Drained like any other: the same three // hits, the same stagger, the same rules about being looked at. drained.push(...village); // The loss beat. No text, no shake, no sting — the light going out is the // whole thing. // The DEATH beat now, not the loss beat. Being caught costs a hit; the sixth // one is this, and it restarts the level. const loss = { active: false, timer: 0, torchWasUp: false, x: 0, y: 0, vx: 0, r: 0, by: null }; let grace = 0; /** * Caught. Knocked back, a moment to get clear, and one less. * * WANDER contact is still harmless — the whole level argues that nothing is * hostile until it is looked at, and a man standing in the dark doing nothing * has to stay a man standing in the dark doing nothing. */ function takeHit(fromX, knock) { if (grace > 0 || loss.active) return; prince.hits++; run.hitsTaken++; prince.x += (Math.sign(prince.x - fromX) || -1) * knock; prince.prevX = prince.x; prince.attacking = false; grace = TUNING.HURT_GRACE_MS; audio?.hit(); if (prince.hits < TUNING.PRINCE_HITS) return; // The sixth. The light goes out and the level starts again -- and nobody // goes on reading to a dead man. audio?.stopVoice?.(); voice.started = false; loss.active = true; loss.timer = 0; prince.down.reset(); loss.torchWasUp = torch.raise > 0.01; loss.x = torch.handX; loss.y = torch.handY; loss.vx = prince.facing * 120; loss.r = torch.radius; audio?.bodyFall(); } // THE MAP. Not a screen — a sheet he holds up in his own torchlight. It // records and it never reasons: it knows what he has looked at and nothing // else, and the player never does a moment's bookkeeping. // THE GATE. Shut, with four empty marks on it, and it does not open until he // is carrying four objects he has LOOKED at. Not four objects — take them // blind and he is holding four question marks and the gate stays shut. // // He has to look. That is the level. const gate = { filled: 0, open: 0, through: false, placed: 0 }; // THE SKY, when somebody gets their name back. `ms` counts up through the // strike and `thundered` makes sure the rumble fires once rather than every // frame the flash is still running. See FLASH in tuning.js for why it is a // flash and not a sound. const flash = { ms: 0, thundered: true }; /** * THE SHEAF. Loose pages he carries, and the map is one of them. * * Built here at L2 and paged at L3. What arrives when: * his orders at the start, and permanently page one * a fragment when he takes it * a key when he takes it off its hook * * NO HOUSE LETTERS AND NO NOTE. Those stay where they were left — on the * door and on the tree — and the band reads them there. * * FOUND ORDER. Not grouped, not sorted, no index and no tabs — the order he * picked them up in is the only order there is. */ // Last frame's take button, so an act fires on the edge. See where it is read. let prevPickup = false; // How long he has stood still, in ms. The band reads a door he has lit again // once this passes READ_STILL_MS. See the band. let still = 0; // Whether he wanted the light last frame, so the flame fires on the raise // and not on every frame of holding it. let litLast = false; const sheaf = { // NO MAP PAGE. It was page one from V3 until somebody played it and said // "I thought the first page was just the inventory index of stuff — I did // not realise it was a map." Which is the whole verdict on it: the sheet // that the V3 gate was built around never read as a map to anybody. // // So it is what it was being read as. A pile of what he is carrying, in the // order he found it, and nothing else. A real map that draws itself as he // solves things is a good idea and a separate one. pages: [], index: 0, // Whether he is holding it up. Lived on its own `map` object until the name // stopped being true. up: false, // Edge and repeat for the arrows. A turn is a FLIP WITH A BEAT, not a free // browse: it fires on the press, then not again until REPEAT_MS, so holding // an arrow walks the pile at reading speed rather than spinning it. heldDir: 0, repeat: 0, // Counts up on every turn. The band watches it rather than watching the // text, because paging from one page to another with the SAME words has to // still read as a turn. turns: 0, }; const SHEAF_REPEAT_MS = 260; /** * THE OPENING. He is reading his orders when the game starts. * * Not a splash and not narration: the page is in his hand, held up in the * light, in the exact posture the player will use for the whole game -- shown * before they know there is a button for it. */ // `heard`: his orders have started being read aloud. `clear`: every key has // been let go since. `askedAt`: when the first press asked for the reading, // in opening ms, or -1. See the opening in update(). const opening = { ms: 0, done: false, heard: false, clear: false, askedAt: -1 }; /** * What `update` is handed while the beat runs, INSTEAD of the real input. * * The death beat owns its frame with a bare `return` placed above every * input read. Copying that here would freeze the fire: it returns above * `state.clock += dt` and above the flame player, which cost nothing when * the torch was four SVG frames and costs everything now that it is a * 25-frame loop and the only colour on screen. Four seconds of a frozen * flame at the front of the game is worse than no opening. * * So nothing is skipped. The rest of update runs on these flags instead: the * sheaf goes up, the torch stays up, the band shows the order, the pose swaps * to the map pose, the clock advances and the fire burns -- and he does not * move, because left and right are false. "No input is accepted" ends up * literally true rather than approximately true. */ // NOT FROZEN, AND THAT COST A CRASH. The scene clears `input.pickup = false` // further down, every frame -- the edge detector was deliberately moved in // here so it would not trust the input layer. Object.freeze turns that write // into a TypeError under a module's strict mode, so the whole update died // mid-frame on the first frame of the game. It is a plain object because // something legitimately writes to it. const OPENING_INPUT = { torch: true, sheaf: true, attack: false, pickup: false, left: false, right: false, }; /** * Arm it. Sets the raise to 1 rather than letting it ramp: RAISE_MS is 250 * and the band needs raise > 0.9, so coming up through the input path gives * a quarter of a second of a page rising into view with no legible word on * it. The spec says he is ALREADY holding it up, so he is. */ const armOpening = () => { opening.ms = 0; opening.done = false; opening.heard = false; opening.clear = false; opening.askedAt = -1; torch.raise = 1; torch.prevRaise = 1; }; const page = () => sheaf.pages[sheaf.index] ?? null; /** * Has he looked at this page's contents? The one letter he carries is his * orders, which he is reading when the game starts. A FRAGMENT CAN BE TAKEN * BLIND and arrives unknown, and the whole gate at the end of the level * turns on that difference. */ const pageSeen = (pg) => { // A letter was read off a lit door and a key has nothing written on it, so // only a torn page can be a thing he owns without having looked at it. if (!pg || pg.kind !== 'fragment') return true; return !!prince.inventory.find((it) => it.kept === pg.kept)?.seen; }; /** Where this page is filed, if it is a fragment and he has filed it. */ const filedUnder = (pg) => { if (!pg || pg.kind !== 'fragment') return null; return HOUSES.find((h) => h.filed === pg.kept) ?? null; }; const textFor = (pg) => { if (!letters || !pg) return null; const h = letters.byHouse?.[pg.house ?? pg.kept]; if (!h) return null; // A KEY HAS NOTHING WRITTEN ON IT, so the band stays empty on one — which // is the difference between a key and a letter, shown rather than said. // (This used to say "a map", back when page zero was one.) return pg.kind === 'letter' ? h.letter : pg.kind === 'fragment' ? h.fragment : null; }; /** * Put a page on the pile and open on it. * * FOUND ORDER means it goes on the end. Moving the index to it is what a * person does with a handful of papers — the new one is the one in your * hand — and it is what makes pinning one gesture instead of two: take a * fragment off a body, walk to a door, press E, and it is already the page * he is holding. */ const filePage = (pg) => { sheaf.pages.push(pg); sheaf.index = sheaf.pages.length - 1; sheaf.turns++; }; /** * HIS ORDERS. Page one, from before the first frame, and permanently. * * The pile used to start empty and a check asserted it, under a comment * explaining that he begins carrying nothing. That was true of a pile of * things he had FOUND. This 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 by the end * of the village it sits under four fragments that, with the letters on the * doors they answer, contradict every word of it. Nothing has to say so. * * IT IS AN ORDINARY LETTER PAGE. Not its own kind: it is a page of writing * with a building behind it, which is what every letter in this game is, and * making it a fourth kind meant a branch in textFor, a branch in the hand, * and a row in every check that counts kinds -- all to say "paper" twice. * Its byHouse entry carries `origin: 'palace'`, and the index is FOUND by * that rather than counted to, so nobody has to keep a 5 in their head. * * Through filePage rather than a bare push, so it bumps `turns` -- which is * what zeroes band.alpha and gives the opening its fade-in for free. * * CALLED TWICE, because reset() is not called at construction; the state * literals are the initial state and only camera.reset runs down there. */ const ORDER_HOUSE = letters?.byHouse?.findIndex((h) => h.origin === 'palace') ?? -1; const seedOrder = () => { if (ORDER_HOUSE >= 0) filePage({ kind: 'letter', house: ORDER_HOUSE }); }; /** * THE VOICES. Every paper is read aloud by whoever wrote it: Mira reads her * letter, the smith his own last words, the palace its order. The band still * shows the document; the voice is the same document, word for word, and * never a voice that describes one. * * Named by what they read -- `letter 0` is the forge's letter, `fragment 0` * the smith's scrap -- so a voice is found the same way the band's text is. */ const voiceOf = (kind, i) => { const h = letters?.byHouse?.[i]; return (kind === 'letter' ? h?.letterVoice : h?.fragmentVoice) ? `${kind} ${i}` : null; }; letters?.byHouse?.forEach((h, i) => { if (h.letterVoice) audio?.useVoice?.(`letter ${i}`, { file: h.letterVoice }); if (h.fragmentVoice) audio?.useVoice?.(`fragment ${i}`, { file: h.fragmentVoice }); }); const ORDERS_VOICE = voiceOf('letter', ORDER_HOUSE); // Which document the voice belongs to (the band's key), how long it has been // up, and whether its voice has started. const voice = { key: null, ms: 0, started: false }; /** * What the band is showing, and it shows the document rather than describing * it: the page he is holding up, or the letter on the wall in front of him. * * `key` says WHICH document, so the band can blank between two of them — * a page turn, or looking from the door to his papers and back. See the band. */ const band = { text: null, alpha: 0, key: null }; /** * WHAT THIS BUILD IS FOR, counted. * * The question health is being added to answer is one thing: does he start * standing still and looking at something before walking up to it? So the * run keeps score of the only number that answers it — seconds spent with * the torch up, standing still, with nothing hostile near enough to reach * him. Watching from safety. * * Everything else here is context for that one figure. */ const run = { seconds: 0, fights: 0, hitsTaken: 0, deaths: 0, watching: 0, taken: 0 }; const WATCH_CLEAR_PX = 600; // Whether the sheaf is up. A dwell timer used to live here and resolved the // oldest unknown thing in his bag; pages replaced it, because what he // resolves is the one he turned to. const map = { up: false }; let lastRunFrame = -1; // so a step fires once per arrival, not once per tick const state = { prince, torch, camera, background, drained, loss, get grace() { return grace; }, audio, ground: clips.ground, kept: clips.kept, letterArt: clips.letter, keyArt: clips.key, patchArt, socketArt, lightning: clips.lightning, pageBlank: clips.page_blank, sheetClip, mapClip, hurtClip, run, sheaf, band, textFor, letters, page, pageSeen, filedUnder, canMark: false, markHouse: null, canTakeKey: false, canPlaceKey: false, // Whether there is anything at his feet to pick up. Computed once here // rather than re-scanned by main.js to decide what the button says. canTake: false, seed: SEED, houses: HOUSES, houseTint: PALETTE.WATCHERS, houseClip, palaceClip, houseArt, doorPoint, keyPoint, letterPoint, slotPoint, tree: TREE, treeClip, treeNoteDark, treePoint, // What the band is reading off a wall right now, if anything: the house or // the tree. Published for the checks, which ask whether it is the same // door E would act on. readingAt: null, // The voice that should be reading right now, if any: `letter 0`, // `fragment 2`. Set whether or not sound can play, so the checks can see // the decision. voice: null, // The house E would act on, if any. Same door the band reads. nearDoor: null, village, gateX: GATE_X, gateStopX: GATE_STOP_X, gateRoomX: GATE_ROOM_X, villageGap: VILLAGE_GAP, bowlX0: BOWL.X0, flame: { clip: clips.fire, player: createPlayer(clips.fire), frame: 0 }, torchArm: TORCH_ARM, clock: 0, gate, opening, fade: 0, }; /** * @param seed re-deals the village. Checks pass one so a run reproduces; * a player's reset keeps the seed the run started with. */ function reset(seed = null) { if (seed !== null) { state.seed = seed >>> 0; dealVillage(state.seed); } prince.x = prince.prevX = START_X; prince.facing = 1; prince.hasSword = true; prince.moving = false; prince.hits = 0; prince.keys = 0; prince.run.reset(); prince.idle.reset(); torch.reset(); camera.reset(START_X); state.clock = 0; for (const h of HOUSES) { h.seen = false; h.filed = null; h.open = false; h.settled = false; h.keyTaken = false; } if (TREE) TREE.seen = false; still = 0; state.readingAt = null; state.nearDoor = null; audio?.stopVoice?.(); voice.key = null; voice.ms = 0; voice.started = false; state.voice = null; prevPickup = false; prince.attacking = false; prince.attackTime = 0; prince.attackConnected = false; loss.active = false; loss.timer = 0; loss.by = null; grace = 0; sheaf.up = false; gate.filled = 0; gate.open = 0; gate.through = false; gate.placed = 0; flash.ms = FLASH.MS; flash.thundered = true; state.flash = 0; state.flashMs = -1; sheaf.pages.length = 0; sheaf.index = 0; sheaf.heldDir = 0; sheaf.repeat = 0; sheaf.turns = 0; band.text = null; band.alpha = 0; band.key = null; state.fade = 0; // Deaths survive a restart. Everything else about the run does not — that // is what "the findings are at stake" means. run.seconds = 0; run.fights = 0; run.hitsTaken = 0; run.watching = 0; run.taken = 0; lastRunFrame = -1; prince.inventory.length = 0; drained.forEach((d) => d.reset()); // His orders go back on the pile, because they are the one thing he cannot // lose. After the clears above, so they are not what gets cleared. seedOrder(); // AND THE OPENING DOES NOT PLAY AGAIN. reset() is what R calls and what // the death beat ends with, and four seconds of re-reading an order you // have read is a punishment for dying. Parked finished, which is the same // idiom the storm is parked with six lines up. opening.done = true; } seedOrder(); armOpening(); camera.reset(START_X); function update(dt, input) { prince.prevX = prince.x; // Where he started this frame, for "is he standing still". Not prevX: a // hit and a shove both reset that to stop the draw smearing, so a knock // would look like standing still. const xFrom = prince.x; torch.snapshot(); camera.snapshot(); state.clock += dt; // --- the opening ------------------------------------------------------ // He reads his orders, and then the game begins. The real input is looked // at exactly once, to see whether to cut it short; after that the frame // runs on OPENING_INPUT and he is holding the page up whatever anybody // presses. See the note on OPENING_INPUT for why this is not a `return`. if (!opening.done) { // SKIPPABLE, BY ANY OF THE SIX. A player who has seen it does not have // to sit through it, and one who has not will not know to press anything. const pressed = input.torch || input.sheaf || input.attack || input.pickup || input.left || input.right; let skipped = pressed; // BUT THE FIRST PRESS STARTS THE READING. A browser makes no sound before // a press, so if the first one also put the orders away nobody would // ever hear them read. So while his orders could be read aloud and are // not yet, a press asks for the voice and the page stays up; once it is // speaking, the NEXT press ends it -- a key still held from the first // does not. If the voice has not come VOICE_WAIT_MS after it was asked // for, a press ends it as before. With sound off, nothing has changed. if (ORDERS_VOICE && audio?.canSpeak?.(ORDERS_VOICE)) { if (!opening.heard) { if (pressed && opening.askedAt < 0) opening.askedAt = opening.ms; skipped = pressed && opening.askedAt >= 0 && opening.ms - opening.askedAt >= TUNING.VOICE_WAIT_MS; } else { if (!pressed) opening.clear = true; skipped = pressed && opening.clear; } } opening.ms += dt * 1000; // IT WAITS FOR HIM. The spec asked for a four-second hold that any input // could cut short; holding until a key is pressed is the same thing with // the impatient case removed, and it lets somebody read at their own // speed instead of at mine. ORDER_HOLD_MS is an upper bound for anyone // who wants one -- 0, the default, means no bound at all. const rangOut = TUNING.ORDER_HOLD_MS > 0 && opening.ms >= TUNING.ORDER_HOLD_MS; if (skipped || rangOut) { opening.done = true; // AND THE PRESS THAT SKIPPED DOES NOT ALSO SWING. attack and pickup // are one-shots the SCENE clears, further down; the frames above ran // on a different object, so the real one has never been cleared and // the key that ended the opening would land as a sword swing on the // frame control comes back. The loss beat has the same hole and never // has to care, because it ends in reset(). input.attack = false; input.pickup = false; } else { // A press the opening spent is spent: a swing or a take still set on // the real input would count as held for ever, and the next press // could never arrive. input.attack = false; input.pickup = false; input = OPENING_INPUT; } } // --- the loss beat ---------------------------------------------------- // Everything stops. If the torch was up it leaves his hand, falls, rolls // forward and goes out; the circle shrinks as it rolls. if (loss.active) { loss.timer += dt * 1000; // He goes down. The clip plays once and holds on the last frame, which is // what the light goes out over. prince.down.advance(dt); prince.clip = downClip; prince.frame = prince.down.index; // All the way out, over the clip. The last thing on screen before the // level starts again is the light leaving him. prince.eyeDim = Math.min(1, 0.6 + loss.timer / 900); if (loss.torchWasUp) { loss.vx += 260 * dt; loss.x += loss.vx * dt; loss.y = Math.min(GROUND_Y - 20, loss.y + 900 * dt * Math.min(1, loss.timer / 260)); loss.r = Math.max(0, loss.r - 300 * dt); torch.cx = loss.x; torch.cy = loss.y; torch.radius = loss.r; } // THE LEVEL RESTARTS. He does not get up where he fell any more. // // The spec's checkpoint at the mouth keeps the map, and a checkpoint that // keeps the findings is too forgiving to change how anybody plays. The // findings have to be at stake or the fights are not a cost. state.fade = Math.min(1, (loss.timer - TUNING.LOSS_HOLD_MS) / TUNING.DEATH_FADE_MS); if (loss.timer >= TUNING.LOSS_HOLD_MS + TUNING.DEATH_FADE_MS) { run.deaths++; reset(); } return; } // --- Torch ------------------------------------------------------------ // The whole swing is a commitment. No cancelling into the torch, and no // cancelling into another swing. const committed = prince.attacking; // Holding the map raises the torch. One button, and the cost is the torch's // cost exactly: he stops, the sword goes away, and he is lit up in the dark. sheaf.up = !!input.sheaf && !committed && !!mapClip; // FLAME AND AIR, ON THE WAY UP ONLY. Lowering it is him giving up the // light, which is a thing that should happen quietly. const wantsLight = (!!input.torch || sheaf.up) && !committed; litLast = wantsLight; torch.update(dt, wantsLight, state.clock, prince.x, GROUND_Y, prince.facing, HAND_DX, HAND_DY, TORCH_ARM, torchClip.fireCentre, TORCH_HANDS); prince.hasSword = torch.raise <= 0.001; // The crackle rides the raise, so it arrives with the light and leaves // with it. A loop rather than a one-shot on the press: a torch is not an // event, it is a thing that is burning. audio?.torchFire?.(torch.raise); // THE LIGHT IN HIM, every frame he is still up. Capped short of black so a // man on his last hit still has something in his face — going fully out is // what dying looks like, and it must not be what nearly-dying looks like. // SPREAD ACROSS THE HITS, not clamped at the end. Clamping made four hits // and five hits the same picture, which loses the one step that matters // most — the last one before the restart. prince.eyeDim = TUNING.EYE_DIM_MAX * Math.min(1, prince.hits / Math.max(1, TUNING.PRINCE_HITS - 1)); // --- Movement --------------------------------------------------------- // Torch up means he cannot move. To look, he stops. To move on, he stops // looking. Every second spent understanding something is a second not spent // leaving. if (grace > 0) grace -= dt * 1000; const wantDir = (input.right ? 1 : 0) - (input.left ? 1 : 0); const canMove = prince.hasSword && !committed; prince.moving = canMove && wantDir !== 0; if (prince.moving) { prince.facing = wantDir; prince.x += wantDir * TUNING.WALK_SPEED * dt; if (prince.x < 0) prince.x = 0; // He does not hit a boundary, he hits a shut gate. Once it is up, the // only thing left in his way is the man standing in it. // // AND THE GATEWAY IS HIS ONCE ITS KEEPER HAS GONE HOME. While the keeper // holds the post, the shut leaf stops him just inside the left post. // With the post empty he can walk the whole width of the leaf, under all // four sockets, to just inside the right one. Found in play: he stood // at the left edge and could not get close enough to see how many // sockets the gate wanted. const shut = keeperGone() ? GATE_ROOM_X : GATE_STOP_X; const stop = gate.open >= 1 ? Math.max(GATE_X + 260, shut) : shut; if (prince.x > stop) prince.x = stop; } camera.setBounds(0, keeperGone() ? GATE_ROOM_CAM_X : GATE_CAM_X); camera.follow(prince.x); // --- The sword -------------------------------------------------------- if (input.attack) { input.attack = false; if (prince.hasSword && !prince.attacking) { prince.attacking = true; prince.attackTime = 0; prince.attackConnected = false; audio?.swing(); } } if (prince.attacking) { // THE SWING SLOWS. A cost he feels in the one thing he was using to make // the cost not matter. const slow = TUNING.SWING_SLOW[Math.min(prince.hits, TUNING.SWING_SLOW.length - 1)]; prince.attackTime += dt / slow; const ms = prince.attackTime * 1000; const active = ms >= TUNING.ATTACK_WINDUP_MS && ms < TUNING.ATTACK_WINDUP_MS + TUNING.ATTACK_ACTIVE_MS; // One hitbox, one target, no multi-hit. if (active && !prince.attackConnected) { for (const d of drained) { // THE SWORD SWINGS ALONG THE ROAD. A figure in the back lane is // beside it, not on it, so the blade passes in front of it the way it // would in any beat 'em up — and one halfway through stepping down is // not there yet. Without this the lane is a picture: swing at a man // standing beside the road and he wakes, which is the exact fight // the lane exists to stop anyone starting. if (d.lane > 0) continue; const gap = (d.x - prince.x) * prince.facing; if (gap > -40 && gap <= attackClip.reach) { prince.attackConnected = true; d.takeHit(prince.x); audio?.hit(); break; } } } if (prince.attackTime >= ATTACK_TOTAL) prince.attacking = false; } // --- the Drained ------------------------------------------------------ const ctx = { princeX: prince.x, torch, groundY: GROUND_Y, inFrame: false }; for (const d of drained) { // Only what is near enough to matter. Downed bodies persist in world // space, so one can rise and come at him from behind. // // EXCEPT ONE ON ITS WAY HOME. This cull is what makes a village of twelve // cost nothing, and it is exactly wrong for the one body that has // somewhere to be: skipped here, it never takes a step, so the door it // was walking to never opens and the key behind it is lost. // // ON THE FLAG, NOT THE STATE. Gating on HOMING alone left it culled // during the 900ms of GETTING UP, which is a state it then never leaves // — filing correctly from the far side of the village put a body on its // knees for ever. The flag covers the whole journey; it is cleared when // the door opens, and the cull takes over again. // // AND ONE WOKEN BY A WRONG PIN, for the same reason: it is on its way to // him, and a body skipped here would sit in the street half risen. if (!d.homing && !d.woken && Math.abs(d.x - prince.x) > W * 1.4) continue; // On screen together? The camera follows the prince, so this is the same // question as "can it see him". const sx = d.x - camera.x; ctx.inFrame = sx > -260 && sx < W + 260; const wasState = d.state; const wasFrame = d.frame; const wasRevealed = d.revealed; const wasLit = d.dwell > 0; d.update(dt, ctx); // THE LIGHT LANDS AND IT BREATHES IN. Once per look, and not again for a // second and a half, or sweeping the torch across it would pant. // (A clock earlier than the last breath is a restart, not a short gap.) const sinceBreath = state.clock - (d.stirredAt ?? -9); if (!wasLit && d.dwell > 0 && !d.revealed && (sinceBreath > 1.5 || sinceBreath < 0)) { d.stirredAt = state.clock; audio?.drainedStir?.(Math.max(-0.85, Math.min(0.85, (d.x - prince.x) / 1400))); } if (d.state === DOWNED && wasState !== DOWNED) audio?.bodyFall(); // ARRIVED. The door is not open because the torch is on it — that is // LIT, and it has been true since L2. It is open because the person who // lived here opened it, and it is the first thing in this level that // somebody other than the prince does. if (d.state === HOME && wasState !== HOME) { const home = HOUSES.find((h) => h.x === d.homeX); if (home) home.open = true; } // One dragged foot when the foot arrives, off the clip's own footFrames, // exactly as his boots work. Only drained_walk carries any, so a rush is // silent and a body standing in the street is silent. // // ON SCREEN, OR ON ITS WAY HOME. This used to be on screen only — a drag // out of an empty frame is a thing that is not there. Except that the // one walking home IS there, a few screens off, and played through a // whole level nobody could tell it was happening. So that one is heard // wherever it is, quieter the further off (AUDIO.HOME_FOOT_FAR), and // every drag is placed on the side it is on. if (d.frame !== wasFrame && d.clip?.footFrames?.includes(d.frame) && (ctx.inFrame || d.state === HOMING)) { const off = d.x - prince.x; const beyond = Math.max(0, Math.abs(off) - W / 2); const near = Math.max(0, 1 - beyond / Math.max(1, AUDIO.HOME_FOOT_REACH)); const level = ctx.inFrame ? 1 : AUDIO.HOME_FOOT_FAR + (1 - AUDIO.HOME_FOOT_FAR) * near; audio?.drainedStep(level, Math.max(-0.85, Math.min(0.85, off / 1400))); } // AND ONE VOICE PER KIND. The clip's own name says which — a swing, a // haul, a bearing, one coming for him, the gatekeeper winding up — and // the clip's own soundFrames say when. Nothing here knows what any of // them sounds like, which is the same deal footFrames already has. // // They are BODY sounds and never tool sounds, and audio.js says at // length why: a hammer-on-anvil clang would send every player to the // swinging shape, and the forge's fragment is deliberately on a hauler. // // AND ONE COMING FOR HIM IS HEARD COMING. Woken by a wrong pin it rushes // from wherever it fell, so its breathing plays off screen too: faint far // off, louder as it closes, on the side it is on. The walk home's drag // does the same, off the same numbers. if (d.frame !== wasFrame && d.clip?.soundFrames?.includes(d.frame) && (ctx.inFrame || (d.woken && d.state === RUSH))) { const off = d.x - prince.x; const near = Math.max(0, 1 - Math.max(0, Math.abs(off) - W / 2) / Math.max(1, AUDIO.HOME_FOOT_REACH)); audio?.drainedVoice?.(d.clip.name, ctx.inFrame ? 1 : AUDIO.HOME_FOOT_FAR + (1 - AUDIO.HOME_FOOT_FAR) * near, Math.max(-0.85, Math.min(0.85, off / 1400))); } // THE INSTANT IT NOTICES HIM. `revealed` latches and never unlatches, so // this fires once per shape per run — about twelve times, and every one // of them is a fight he chose by deciding to see. if (d.revealed && !wasRevealed) audio?.drainedWake?.(); const kind = grace > 0 ? null : d.contactKind(prince.x, CONTACT_REACH); // Contact with a WANDER one is not a loss. He walks into someone standing // in the dark and nothing happens at all, which is worse. if (kind === 'shove' && grace <= 0) { // He loses ground AND one of his six. He was the only thing in the // village that could not hurt you, which made the hard fight free. loss.by = d; const back = d.x + Math.sign(prince.x - d.x || -1) * (TUNING.POST_REACH + TUNING.POST_SHOVE_PX); takeHit(d.x, 0); if (!loss.active) { prince.x = back; prince.prevX = back; } } if (kind === 'fatal' && !loss.active) { loss.by = d; takeHit(d.x, TUNING.HURT_KNOCK_PX); } } // --- reading it --------------------------------------------------------- // HE CAN LIGHT WHAT HE IS CARRYING, and this closes a hole rather than // adding a convenience. The gate wants four objects he has LOOKED at; take // four blind and the spec sent him 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. // // At L3 that is no longer a special case at all — a fragment he took is a // page in the sheaf like any other, and paging to it is the looking. // --- the sheaf ---------------------------------------------------------- // // PAGING. Left and right while the sheaf is up. He cannot walk with it // raised, so those keys are free — and on touch the walk arrows do the same // job, which is what a person does with a handful of letters: the pile // under one thumb and the turning under the other. // // A FLIP WITH A BEAT. The turn fires on the press and does not fire again // until SHEAF_REPEAT_MS, so holding an arrow walks the pile at reading // speed. Free-spinning it would make the sheaf a menu. // // IT DOES NOT WRAP. Running off the end of a pile of paper does nothing, // and wrapping would put the map next to the last fragment for no reason a // player could see. if (sheaf.up) { const dir = (input.right ? 1 : 0) - (input.left ? 1 : 0); if (dir === 0) { sheaf.heldDir = 0; sheaf.repeat = 0; } else { const fresh = dir !== sheaf.heldDir; if (fresh) { sheaf.heldDir = dir; sheaf.repeat = 0; } else sheaf.repeat -= dt * 1000; if (fresh || sheaf.repeat <= 0) { sheaf.repeat = SHEAF_REPEAT_MS; const next = Math.min(sheaf.pages.length - 1, Math.max(0, sheaf.index + dir)); if (next !== sheaf.index) { sheaf.index = next; sheaf.turns++; audio?.pageTurn?.(); } } } } else { // THE INDEX SURVIVES RELEASE. He reopens where he left off; resetting to // the map would make every glance start from page one, and browsing a // pile you have to walk back through is a chore rather than a document. sheaf.heldDir = 0; sheaf.repeat = 0; } // A `?` RESOLVES THE MOMENT HE PAGES TO IT, and there is no timer on it. // // Holding the sheaf raises the torch, so a page he is on is already being // held in his own light — the looking has happened, and charging him // another four hundred milliseconds for it would only mean that skimming // does not read, which is a rule nobody would ever discover. // // THE COST IS STILL REAL AND IT IS STILL THE SAME. The beat on a page turn // is 260ms, so walking four unknown pages is about a second stood still in // the open with the torch up — within a whisker of what the old timer // charged. What changed is that he resolves the one he turned to rather // than the oldest thing in his bag. // // This is why there is no visible unknown state on the sheet for a page he // has opened. The unknown drawing is for the pages BEHIND the one he is on: // take three blind and only the last is in his hand. if (sheaf.up && torch.raise > 0.99) { const pg = page(); if (!pageSeen(pg)) { const it = prince.inventory.find((o) => o.kept === pg.kept); if (it) { it.seen = true; sheaf.turns++; } } } // --- the doors ---------------------------------------------------------- // Lighting one is the only thing that says who lived here. It latches, the // same way seeing an object on the ground latches, and for the same reason: // he looked, and looking is not something he can un-do. // // THE DOOR OR ITS LETTER, EITHER ONE. The letter hangs left of the arch, // nearer him than the door, so there is a stretch of wall where the light // is on the letter and not yet on the door. If only the door counted he // could read a letter in the band and then find the door had never been // lit: the next read would not come free, and E would not pin. for (const h of HOUSES) { const [dx, dy] = doorPoint(h); const lp = letterPoint(h); if (torch.covers(dx, dy) || (lp && torch.covers(lp[0], lp[1]))) h.seen = true; } // THE NOTE ON THE TREE. No door to light first: the paper is all there is. if (TREE) { const p = treePoint(); if (p && torch.covers(p[0], p[1])) TREE.seen = true; } // THE DOOR HE IS AT, AND IT IS LATCHED RATHER THAN LIT. // // There used to be a `lit` house here, meaning "the torch is on this door // right now", and it was what marking tested — which only worked because // holding the sheaf forced the torch up. Seeing latches instead, and acting // on it only needs him to still be standing there. // // ONE DOOR, AND THE BAND AND E BOTH USE IT. The band never reads him one // door while E acts on another. The doors are about 4200px apart and the // reach is 440, so there is never more than one to choose from anyway. let nearDoor = null; for (const h of HOUSES) { if (!h.seen) continue; const p = slotPoint(h); if (p && Math.abs(prince.x - p[0]) <= TUNING.DOOR_REACH) { nearDoor = h; break; } } // And the tree, on the same terms. It reads like a door and it never joins // nearDoor: there is nothing on it to pin to. let nearTree = null; if (TREE?.seen) { const p = treePoint(); if (p && Math.abs(prince.x - p[0]) <= TUNING.DOOR_REACH) nearTree = TREE; } // AND THE KEY, WHICH NEEDS THE DOOR OPEN AND NOT THE TORCH UP. // // Two door states and this is the line between them. `seen` is what the // torch buys: you can look into the room and the key is hanging there in // the light. `open` is what the person buys by walking home: you can reach // in. It is visible from the first time the door is lit and it is not his // until somebody comes back and opens their own door, and nothing has to // say so — the halo is the game's word for "you can take this" and it // simply is not on yet. let litKey = null; for (const h of HOUSES) { if (!h.open || h.keyTaken) continue; const p = keyPoint(h); if (p && Math.abs(prince.x - p[0]) <= TUNING.DOOR_REACH) { litKey = h; break; } } // --- the band ----------------------------------------------------------- // // IT SHOWS A DOCUMENT, VERBATIM, and a document is in one of two places: in // his hand, or nailed to the wall in front of him. Nothing else in this // game gets text: not doors, not the gate, not objects and not the prince. // // 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 // // COMPARING IS LOOKING AT THE DOOR, THEN AT HIS PAPERS, THEN AT THE DOOR. // Hold Q and the band reads the fragment in his hand; let go and it reads // the door again. That is the whole deduction, stood in one place. // // THE FIRST READ NEEDS LIGHT, AND ONLY THE FIRST. The first draft had every // read need the torch, so comparing was SPACE held with Q tapped in // between. On a phone TORCH and PAGES are both on the left wheel and one // thumb cannot hold one while pressing the other — the trap taking a letter // fell into once already. So a door he has lit reads again for nothing, // and comparing is one button: PAGES down, PAGES up. // // STANDING STILL is what stops it opening at every door he walks past. Not // walking, not swinging and not knocked back, for READ_STILL_MS. Holding Q // counts — he cannot walk with it up — so letting go of Q goes straight // back to the door. const stood = !prince.moving && !prince.attacking && prince.x === xFrom; still = stood ? still + dt * 1000 : 0; const readAt = nearDoor ?? nearTree; const readPoint = readAt === TREE ? treePoint() : readAt ? letterPoint(readAt) : null; const reading = !!readAt && ((!!readPoint && torch.covers(readPoint[0], readPoint[1])) || still >= TUNING.READ_STILL_MS); state.readingAt = sheaf.up || !reading ? null : readAt; // Gated on the raise so the band and the page arrive together — the sheet // itself is not drawn until the arm is up, and a band that beat it there // would read as a subtitle rather than as the paper in his hand. // // THE SHEAF WINS FROM THE MOMENT Q GOES DOWN, not from when the page is // up. For the quarter-second of the raise the band is blank: he has looked // away from the wall and not yet down at his hand. const pg = sheaf.up && torch.raise > 0.9 ? page() : null; // AN UNKNOWN PAGE HAS NOTHING ON IT YET. He took it blind and he has not // looked; the band would be reading him words he cannot have. const want = sheaf.up ? (pg && pageSeen(pg) ? textFor(pg) : null) : state.readingAt ? textFor({ kind: 'letter', house: state.readingAt.index }) : null; // CLEARED AND REFILLED EVERY TIME IT CHANGES DOCUMENT, never swapped in // place. The blank between two pages is a fraction of a second and it is // the entire reason a turn reads as a turn — swap the text and two letters // in a row look like one letter changing its mind. Looking from the door to // the page in his hand is the same thing: two pieces of paper, not one // label flipping. // // A page is keyed by the turn count rather than by its words, because // paging from one page to another with the SAME words has to still read as // a turn. const key = !want ? null : pg ? `page ${sheaf.turns}` : `wall ${state.readingAt.index}`; if (key !== band.key) { band.key = key; if (key) band.alpha = 0; } band.text = want; band.alpha = Math.min(1, Math.max(0, band.alpha + (want ? dt * 5 : -dt * 9))); // --- the voice ---------------------------------------------------------- // // THE PAPER, READ ALOUD, while the band shows it. It follows the band's key // exactly: a new document stops the old voice, and looking back at one // reads it again from the top, the way reading it again does. // // NOT UNTIL IT HAS BEEN UP VOICE_DELAY_MS, which is longer than a page // turn's beat -- so paging through the pile with an arrow held flips past // every page in silence, and only the one he stops on speaks. A page he has // not looked at has nothing written on it, so it has nothing to say either. const said = !want ? null : pg ? (pg.kind === 'fragment' ? voiceOf('fragment', pg.kept) : pg.kind === 'letter' ? voiceOf('letter', pg.house) : null) : voiceOf('letter', state.readingAt.index); if (key !== voice.key) { if (voice.started) audio?.stopVoice?.(); voice.key = key; voice.ms = 0; voice.started = false; } else { voice.ms += dt * 1000; } // AND NOT WHILE HE DIES. The hit that kills him lands earlier in this same // frame and stops the voice; without this, the band -- still showing the // same page -- read it again from the top over his death. state.voice = said && voice.ms >= TUNING.VOICE_DELAY_MS && !loss.active ? said : null; // Asked every frame until it answers: before the first press, or before // the file has decoded, it cannot. if (state.voice && !voice.started) voice.started = !!audio?.voice?.(state.voice); if (voice.started && !opening.done && state.voice === ORDERS_VOICE) opening.heard = true; // --- marking the map ---------------------------------------------------- // // HE WRITES IT. THE GAME DOES NOT. Nothing on the map fills itself any // more: what goes under a plot is a claim he makes, and a claim only means // something if it can be wrong. // // THE ACT IS THE SAME POSTURE AS EVERYTHING ELSE. He stands at a door with // a piece of paper in his hand and presses take. No menu, no drag, no // assignment screen — and no new button, because TAKE is already the // contextual one and pinning is "put this where it goes". // // The door has to have been LIT ONCE — the same latch that reads it again // for free — and he has to be standing at it. Not lit now and not with Q // held: asking for both hands is the problem the hand addendum fixed. // ONE ACT PER PRESS, DECIDED HERE AND NOT UPSTREAM. // // input.js already makes `pickup` a one-shot, and that was enough while // taking was idempotent — pressing take at a door you had already filed // simply filed it again. Now that the same button also takes a page back // OUT of a frame, a flag that stays true is a toggle running at 60Hz, and // whether it lands filed or empty depends on whether the number of frames // was odd. // // The checks are what make that real rather than theoretical: they drive // these flags directly and hold as many as they like for as long as they // like, which is the same blind spot that let two impossible phone // controls pass every test in the suite. So the edge is found here, where // the act is, and the scene stops depending on how the flag was made. const pickupHeld = !!input.pickup; const wantsPickup = pickupHeld && !prevPickup; prevPickup = pickupHeld; input.pickup = false; // --- what is in his hand ------------------------------------------------ // // THE TOP PAGE OF THE PILE IS WHAT HE IS HOLDING, and the pile does not // have to be up for him to be holding it. // // It used to. Pinning a fragment meant holding Q, paging to it, and // pressing E at a door — three conditions and a mode, and it was played and // nobody did it. Nobody does that with paper either: you take the page you // want, put the rest away, walk over, and pin it. // // So the sheaf is for CHOOSING and E is for DOING, and E never asks about // the sheaf. Q still raises the torch, still stops him, still shows the // band and pages with the arrows. It has just stopped being a prerequisite. const handPage = page(); state.handPage = handPage; // THE SLOT TAKES A PIN FROM THE FIRST LIGHT. It used to wait for the letter // to come off: the patch was earned, and taking the letter was what showed // him where a fragment goes. The letter never comes off now, and the slot // is beside it from the start — a bare nail under a letter says something // goes here. // // AND NOT AT A DOOR THAT IS FINISHED WITH. Someone walked home to it; the // slot has the only thing it was ever going to hold. const patchTakes = !!nearDoor && !gate.through && !nearDoor.settled; const holding = handPage?.kind === 'fragment' ? handPage.kept : null; // PIN, or TAKE BACK, and which one is decided by what is already there. // // THE SAME PAGE THAT IS ALREADY PINNED TAKES IT BACK. The table this comes // from does not have a row for it — it has "a different fragment evicts and // pins" and "not a fragment takes it back" — and holding the page that is // already on the wall is neither. Evicting it and pinning it again is a // press that does nothing, so it is the other one: he is holding the thing // in front of him, and E takes it down. const canRetrieve = patchTakes && nearDoor.filed != null && (holding === null || holding === nearDoor.filed); const canPin = patchTakes && holding !== null && holding !== nearDoor.filed; const canMark = canPin || canRetrieve; // THE SLOT, THEN THE KEY. A claim about a door comes before the thing that // opens the gate: evidence before conclusions before rewards, which is the // order this whole game is arguing for. // // READING IS NOT ON THIS LIST. What the band shows decides nothing about E. // It used to — a letter in reach switched off pinning until it was taken — // and with letters never taken that would have switched pinning off at // exactly the moment he reads the letter he is trying to answer. state.canMark = canMark; state.canPin = canPin; state.nearDoor = nearDoor; // WHICH house it would go under. The sheet draws that house's plot beside // the page when this is set, so the paper itself says "this one, here". state.markHouse = state.canPin ? nearDoor.index : null; state.canTakeKey = !canMark && !!litKey; // AND PUTTING ONE IN. He stops 380px short of the leaf and can never get // closer, so the reach is the door's, not the ground's. state.canPlaceKey = !canMark && prince.keys > 0 && !gate.through && gate.placed < TUNING.GATE_WANTS && Math.abs(prince.x - GATE_X) <= TUNING.DOOR_REACH; if (wantsPickup && state.canMark && !prince.attacking) { if (canRetrieve) { // TAKE IT BACK DOWN. Either he is holding the page that is already // pinned here, or he is holding nothing that could go in — and in both // cases the only thing on that wall he can act on is what is on it. // // It never left the sheaf. The patch holds a CLAIM, not the paper, so // there is nothing to hand back. nearDoor.filed = null; audio?.mark?.(); } else { // MOVE, THEN REPLACE. Pinning a fragment somewhere else takes it off // wherever it was, and pinning a different one here pushes the old one // out. Two fragments can never sit in one patch and one fragment can // never sit in two, and neither rule needs a message: it is what // putting a piece of paper down on a table already does. for (const h of HOUSES) if (h.filed === holding) h.filed = null; nearDoor.filed = holding; audio?.mark?.(); // AND IF IT WAS THEIRS, THEY GO HOME. // // `h.index` and `HOUSE_OF[i]` have been the same number since the // fragments were written, so this is the whole correctness test and // there is nowhere for it to drift to. // // NOTHING IS SAID. No tick, no sound of being right, no change to the // patch. Somewhere out in the village a body gets up, and the only way // to find out is that it walks up the street. if (nearDoor.index === holding) { const owner = drained.find((d) => d.kept === holding && d.keptTaken); if (owner) { nearDoor.settled = true; owner.sendHome(nearDoor.x); // AND THE SKY GOES OFF. It replaces a silence: the payoff for the // one right act in this level happens somewhere else and up to a // minute later, so doing it correctly looked exactly like doing // nothing. A ten-year-old played it and stopped here. flash.ms = 0; flash.thundered = false; } } else { // AND IF IT WAS NOT, THEY OBJECT. Played through, the level was // finished without reading a word: pin, see who walks, try the next // door. The walk home confirmed every guess instantly and for free, // which made it an answer key. Now a stranger's house wakes the // owner, and it comes for him. Guessing is still allowed; it costs. // // The page stays on the wrong wall. Putting it down does not take it // back — he does that, with E, as always. const owner = drained.find((d) => d.kept === holding && d.keptTaken); if (owner && owner.wake()) { // HEARD AT FULL STRENGTH, WHEREVER IT IS. On the side it is on, but // never quieter for being far: a faint gasp from across the village // and a rush thirty seconds later are two things nobody connects. // The breathing as it comes can fall away with distance. The // moment it wakes cannot. audio?.drainedWake?.(1, Math.max(-0.85, Math.min(0.85, (owner.x - prince.x) / 1400))); } } } } // --- the objects on the ground ----------------------------------------- // Seeing one is latched separately from picking it up, because they are // different acts with different costs. The glow tells you something is // there without the torch; only the torch tells you what. let nearest = null; let nearestGap = TUNING.PICKUP_REACH; for (const d of drained) { if (!d.keptDropped || d.keptTaken) continue; if (torch.covers(d.keptX, GROUND_Y + d.keptY)) d.keptSeen = true; const gap = Math.abs(d.keptX - prince.x); if (gap <= nearestGap) { nearest = d; nearestGap = gap; } } // Published rather than re-derived. main.js used to run this same scan a // second time to decide whether to light the button, which is two places // that have to agree about what is in reach. state.canTake = !!nearest; if (wantsPickup && state.canPlaceKey && !prince.attacking) { // THE SAME POSTURE AS EVERYTHING ELSE. He stands at the thing and // presses take. Four times, one socket each, and the leaf lifts on the // fourth — no ceremony, no confirmation, and nothing said about the // fifth house whose key is still hanging behind a door that never opened. prince.keys--; gate.placed++; // Out of the inventory too, or he is holding a key that is in the gate. // The last one found, so the pile behaves like a pile. for (let i = sheaf.pages.length - 1; i >= 0; i--) { if (sheaf.pages[i].kind === 'key') { sheaf.pages.splice(i, 1); break; } } sheaf.index = Math.max(0, Math.min(sheaf.index, sheaf.pages.length - 1)); audio?.mark?.(); } else if (wantsPickup && state.canTakeKey && !prince.attacking) { // Off the hook and into his pocket. No page, no band, no entry in the // sheaf: there is nothing to read on a key and nothing to work out. litKey.keyTaken = true; prince.keys++; // AND IT GOES IN WITH EVERYTHING ELSE HE IS CARRYING. // // Keys were a number and nothing else — deliberately, because "a key is // not evidence, it does not go in the sheaf, there is nothing to work out // about one". All true, and it made them invisible: somebody collected // four and did not know until they put one in the gate by accident. A // thing you carry that you cannot look at is a thing you do not have. // // Nothing to read on it, so the band stays empty on a key page. That is // the difference between a key and a letter, said by showing you both. filePage({ kind: 'key' }); audio?.keyTake?.(); } else if (wantsPickup && !state.canMark && nearest && !prince.attacking) { nearest.keptTaken = true; run.taken++; prince.inventory.push({ kept: nearest.kept, answers: nearest.answers, seen: nearest.keptSeen, }); // Into the sheaf where he found it, looked at or not. A blind take gets a // page with nothing written on it yet, and paging to it is what writes it. filePage({ kind: 'fragment', kept: nearest.kept }); } // --- the score ---------------------------------------------------------- run.seconds += dt; run.fights = drained.filter((d) => d.revealed).length; if (torch.raise > 0.9 && !prince.moving) { const near = drained.some((d) => d.state !== DOWNED && Math.abs(d.x - prince.x) < WATCH_CLEAR_PX); if (!near) run.watching += dt; } // --- the storm ---------------------------------------------------------- // Light first, sound after, because that is the order they arrive in. if (!flash.thundered || flash.ms < FLASH.MS) { const was = flash.ms; flash.ms += dt * 1000; if (!flash.thundered && was < FLASH.THUNDER_MS && flash.ms >= FLASH.THUNDER_MS) { flash.thundered = true; audio?.thunder?.(); } } // THE VILLAGE, UNDER EVERYTHING. Up over the first stretch of the bowl and // down again at the gate, so walking in is walking into it and walking out // is not — the same shape the canopy already has, for the same reason. // // Forty-five people are standing in this place sixty years after she came // and until now the only sound in it was his own boots. { const u = (prince.x - BOWL.X0) / 1800; const out = (GATE_X - prince.x) / 1400; audio?.villageBed?.(Math.max(0, Math.min(1, u, out))); } state.flash = flashAt(flash.ms); // The bolt runs off the same clock. -1 while nothing is happening, so the // renderer does not have to know what "no strike" looks like. state.flashMs = flash.thundered && flash.ms >= FLASH.MS ? -1 : flash.ms; // --- the gate ----------------------------------------------------------- // // FOUR KEYS IN FOUR SOCKETS. The sockets were built at L1 and have had // nothing to go in them since. // // WHAT THIS REPLACES, and it is deleted rather than turned off: the gate // used to want four fragments he had LOOKED at. That was a magic lock — // the only person who could have known what the marks meant is lying in // front of it — and it was also the whole reason the level could be beaten // without reading a word, because looking at four pieces of paper is not // the same act as understanding one. // // A key is not a grade either. The gate still never sees the map, still // never checks an answer, and a player can put four keys in it holding a // completely wrong sheaf. What changed is where a key comes from: a door // somebody opened from the inside, because you gave them their name back. gate.filled = Math.min(TUNING.GATE_WANTS, gate.placed); if (gate.filled >= TUNING.GATE_WANTS && gate.open < 1) { gate.open = Math.min(1, gate.open + (dt * 1000) / TUNING.GATE_OPEN_MS); } // THROUGH IS A STEP, not a place. He can be standing inside the gateway // when the leaf lifts — it is his to walk once the keeper is home — and // the level must not end on him for standing still under it. if (gate.open >= 1 && prince.x >= GATE_X - 40 && prince.moving && prince.facing > 0) { // Through it, and the playthrough is over. No prompt, no score screen, // and no mention of the empty slot. The gate wanted four. It got four. gate.through = true; } if (gate.through) state.fade = Math.min(1, state.fade + dt / 1.6); // --- Prince animation -------------------------------------------------- if (prince.attacking) { prince.clip = attackClip; prince.frame = Math.min(Math.floor(prince.attackTime * attackClip.fps), attackClip.count - 1); } else if (!prince.hasSword) { const eased = torch.raise * torch.raise * (3 - 2 * torch.raise); // Same raise curve either way; only which pose it drives changes. const clip = sheaf.up && mapClip ? mapClip : torchClip; const player = sheaf.up && mapClip ? prince.mapPlayer : prince.torchPlayer; player.time = (eased * (clip.count - 1)) / clip.fps; prince.clip = clip; prince.frame = player.index; state.flame.player.advance(dt); state.flame.frame = state.flame.player.index; } else if (prince.moving) { const rate = runClip.groundSpeed ? TUNING.WALK_SPEED / runClip.groundSpeed : 1; // THE LIMP, AND IT IS HER OWN RUN FRAMES. // // A limp is an asymmetric rhythm, not a different drawing: the good foot // carries longer and the bad one is hurried off. So the cycle is slowed // through its second half and rushed through the first, by an amount that // grows with the hits he has taken. Nothing was redrawn for it, and it // reads because it is what a limp actually is. const hurt = Math.max(0, prince.hits - TUNING.LIMP_FROM); if (hurt > 0) { const half = prince.run.index >= runClip.count / 2; const hitch = 1 - Math.min(0.8, TUNING.LIMP_HITCH * (hurt / 3)) * (half ? 1 : -0.5); prince.run.advance(dt * rate / hitch); } else { prince.run.advance(dt * rate); } prince.clip = runClip; prince.frame = prince.run.index; // A footstep when the boot ARRIVES, not on a timer. runClip.footFrames is // measured off the drawings, so if the art changes the steps follow it. if (prince.frame !== lastRunFrame) { if (runClip.footFrames?.includes(prince.frame)) audio?.footstep(); lastRunFrame = prince.frame; } } else if (prince.hits >= TUNING.LIMP_FROM && hurtClip) { // Standing still with something left in him, he holds his side. // // NOT PART OF THE READOUT ANY MORE, and not currently drawn. The eye // dimming says how hurt he is now; this said it with his body, which is // better, but only with a real drawing behind it. // // Two stages. Which pair of frames he is in is how close he is — the // first is hurt, the second is spent, and the switch is at the halfway // point of what he can take. prince.hurtPlayer.advance(dt); const spent = prince.hits >= Math.ceil(TUNING.PRINCE_HITS / 2) + 1; prince.clip = hurtClip; prince.frame = (spent ? 2 : 0) + (prince.hurtPlayer.index % 2); } else if (drained.some((d) => d.revealed && d.state !== DOWNED && Math.abs(d.x - prince.x) < W)) { // Once one has been seen and is coming, he stands ready. prince.guard.advance(dt); prince.clip = guardClip; prince.frame = prince.guard.index; } else { prince.idle.advance(dt); prince.clip = idleClip; prince.frame = prince.idle.index; } } // openAgain is for the checks, and for nothing else in the game: reset() // deliberately does NOT re-arm the opening, so something has to be able to. // An explicit call beats a check reaching in and setting two fields. return { state, update, reset, camera, openAgain: armOpening }; }