astara / src /scene_level.js
system's picture
system HF Staff
d7dab35: Her own drawings for the sword, the fall and the torch
75ec82b verified
Raw History Blame Contribute Delete
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 };
}