Spaces:
Running
Running
Download src/scene_level.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 97.8 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/scene_level.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/scene_level.js
-
curl -L -o scene_level.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/scene_level.js
97.8 kB
| // 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 }; | |
| } | |