// Two passes per frame. // // Pass 1 — the dark world: background, ground and every character as solid // black shapes on flat grey. No interior detail anywhere. // Pass 2 — the lit reveal: the same scene drawn with the lit art on an // offscreen canvas, masked to the torch shape with a soft radial // falloff, then composited over the dark world. // // The offscreen canvas and both gradients are allocated once, at construction. // Nothing here allocates inside the frame loop. // // Pass 2 only ever touches the light's bounding box. At a 300px radius that is // about a ninth of the screen, so the expensive pass costs a ninth of what a // full-canvas redraw would. import { W, H, GROUND_Y } from './scene.js'; import { createDepth } from './depth.js'; import { drawBand } from './band.js'; import { TUNING, TORCH, depthAt, DEPTH, TONE, FLASH } from './tuning.js'; import { armPose } from './torch.js'; import { laneLook } from './drained.js'; const BG_DARK = '#8a8a86'; // spec 6 const BG_LIT = '#a39684'; // warmer; only ever seen inside the torch const GLOW_RGB = '255,180,90'; // #ffb45a const GLOW_SPREAD = 1.35; // the glow reaches past the reveal edge const lerp = (a, b, t) => a + (b - a) * t; export function createRenderer(ctx) { const depth = createDepth(); // VIGNETTE. Newly legal, and the single most Limbo-defining cue in the // reference frames — in both of them the light is a POOL in the middle and // every edge falls to near black. It was banned outright before, because // dragging a 163 sky down to 0 across a large area is the forbidden zone by // definition. // // Allocated once. The gradient never moves, so rebuilding it per frame would // be the same mistake the torch mask avoided. const vignette = (() => { const g = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.30, W / 2, H / 2, Math.hypot(W, H) * 0.56); g.addColorStop(0.00, 'rgba(0,0,0,0)'); g.addColorStop(0.55, 'rgba(0,0,0,0.30)'); g.addColorStop(1.00, 'rgba(0,0,0,1)'); return g; })(); function drawVignette(c) { if (TONE.VIGNETTE <= 0) return; c.save(); c.globalAlpha = Math.min(1, TONE.VIGNETTE); c.fillStyle = vignette; c.fillRect(0, 0, W, H); c.restore(); } ctx.imageSmoothingEnabled = true; const off = document.createElement('canvas'); off.width = W; off.height = H; const octx = off.getContext('2d'); octx.imageSmoothingEnabled = true; // Unit-space gradients: defined once at the origin with radius 1, then moved // and scaled into place each frame with setTransform. Canvas gradients live // in user space, so they transform with the matrix — which is why a moving, // breathing light needs no reallocation. const maskGradient = octx.createRadialGradient(0, 0, 0, 0, 0, 1); maskGradient.addColorStop(0.00, 'rgba(0,0,0,1)'); maskGradient.addColorStop(0.65, 'rgba(0,0,0,1)'); // spec: full opacity to 65% maskGradient.addColorStop(0.78, 'rgba(0,0,0,0.86)'); maskGradient.addColorStop(0.88, 'rgba(0,0,0,0.55)'); maskGradient.addColorStop(0.95, 'rgba(0,0,0,0.22)'); maskGradient.addColorStop(1.00, 'rgba(0,0,0,0)'); // Darkens everything the torch is NOT lighting. Transparent in the middle, // opaque at the rim, and the caller fills the rest of the screen solid. const dimGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1); dimGradient.addColorStop(0.00, `rgba(${TORCH.DIM_RGB},0)`); dimGradient.addColorStop(TORCH.DIM_START, `rgba(${TORCH.DIM_RGB},0)`); dimGradient.addColorStop(0.82, `rgba(${TORCH.DIM_RGB},${TORCH.DIM * 0.55})`); dimGradient.addColorStop(1.00, `rgba(${TORCH.DIM_RGB},${TORCH.DIM})`); // Halo behind the one kept object, so it is the brightest thing in the circle. const keptGlow = octx.createRadialGradient(0, 0, 0, 0, 0, 1); keptGlow.addColorStop(0.00, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW})`); keptGlow.addColorStop(0.50, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW * 0.72})`); keptGlow.addColorStop(0.80, `rgba(${TORCH.KEPT_RIM},${TORCH.KEPT_GLOW * 0.24})`); keptGlow.addColorStop(1.00, `rgba(${TORCH.KEPT_RIM},0)`); const glowGradient = ctx.createRadialGradient(0, 0, 0, 0, 0, 1); glowGradient.addColorStop(0.00, `rgba(${GLOW_RGB},0.22)`); glowGradient.addColorStop(0.45, `rgba(${GLOW_RGB},0.12)`); glowGradient.addColorStop(0.80, `rgba(${GLOW_RGB},0.04)`); glowGradient.addColorStop(1.00, `rgba(${GLOW_RGB},0)`); // D walks right now, so the debug readout moved to the backtick key. // // IT MUST BE OFF WHENEVER THE FRAME IS MEASURED. It is antialiased black text // on a bright sky, so it sits in the forbidden zone by construction, and it // covers enough of the frame to swamp what it is measuring — a baseline that // read 0.78% was really 0.199%. Worse, the line gets LONGER when a cue is // switched on, because it prints that cue's name, so every cue was charged // for its own label. setDebug exists so a test can never forget. // OFF BY DEFAULT, AND THAT IS NOT A TIDYING-UP. // // It was on, everywhere, including on the public link — and it prints // "[E] PIN IT HERE" the moment you are standing at a door holding a page. // Which means nobody who was ever sent that link played this game: they were // told the one thing the whole build exists to find out whether a person can // work out on their own. The person who designed it could not get a clean // first impression from anybody, including himself. // // It is a dev overlay — fps, pass timings, world x, canopy percent, clip // names — and it stays exactly as it was. Backtick toggles it, ?debug=1 // brings it up from the address bar. It has just stopped being the first // thing a stranger sees. // // (A phone has no backtick key and no address bar worth typing in, so on a // handset this is now simply gone, which it should always have been: eleven // lines of instrumentation over the top of the game.) let showDebug = false; if (typeof location !== 'undefined') { const want = new URLSearchParams(location.search).get('debug'); if (want !== null) showDebug = want !== '0' && want !== 'false'; } window.addEventListener('keydown', (e) => { if (e.code === 'Backquote') showDebug = !showDebug; }); // Anchor a clip's frame at a world point. The pivot comes from the manifest, // so art of any size drops in without touching this. // // Mirrors only when the character is facing the opposite way to how its art // was DRAWN. Not every sprite is drawn facing right: the prince is, the // Drained is drawn facing left, and a blanket "mirror when facing is -1" rule // drew the Drained backwards every time it moved. The manifest says which way // each clip was authored. // // docs/ART_HANDOFF.md used to promise nothing was mirrored in code. That stopped // being true the moment the prince got a facing and could be attacked from // either side. The pivot stays on the anchor through the flip. function drawClip(c, clip, index, mode, worldX, worldY, camX = 0, facing = 1, dimEye = 0) { const f = clip.frames[index]; if (!f) return; const sx = Math.round(worldX - camX); const sy = Math.round(worldY); // THE EYE IS PAINTED OVER, not painted in. It is already white in both // passes; this is a black stencil of exactly those pixels, drawn on top at // the alpha the health asks for. At 0 it is not drawn at all, so the // ordinary case costs nothing. const dim = f.eyes && dimEye > 0.01 ? Math.min(1, dimEye) : 0; if (facing !== (clip.authoredFacing ?? 1)) { c.save(); c.translate(sx, sy); c.scale(-1, 1); c.drawImage(f[mode] ?? f.dark, -clip.pivotX, -clip.pivotY); if (dim) { c.globalAlpha = dim; c.drawImage(f.eyes, -clip.pivotX, -clip.pivotY); } c.restore(); } else { c.drawImage(f[mode] ?? f.dark, sx - clip.pivotX, sy - clip.pivotY); if (dim) { c.save(); c.globalAlpha = dim; c.drawImage(f.eyes, sx - clip.pivotX, sy - clip.pivotY); c.restore(); } } } // The one thing each of them kept. Drawn ON the figure in both passes: in // silhouette it merges into the black mass and says nothing, and in the light // it is the only thing that says who this was. function drawKept(c, keptClip, d, mode, worldX, camX, clock = 0) { // Eight of the village's twelve kept nothing. A fight that drops nothing is // the negative result and it needs no marking — but without this the lit // pass still painted the halo, so an empty-handed shape glowed at the exact // spot where an object it does not have would be. if (!keptClip || d.kept == null || d.keptTaken) return; const face = d.facing === (d.clip.authoredFacing ?? 1) ? 1 : -1; // Two placements, and which one applies is a fact about the body. // // Still standing, it rides the figure at keptAnchor: part of the black mass, // saying nothing until the torch finds it. // // Once it has been put down, the object is out and on the ground at a point // frozen at the moment it fell. It does not travel with the body, and it // does not climb back on when the body gets up an hour later. let px, oy; if (d.keptDropped) { px = d.keptX; oy = d.keptY; } else { // One anchor, or one per frame. A trade loop swings its working hand all // the way over the head, so a single point would leave the hammer hanging // in mid-air while the arm went past it. let a = d.clip.keptAnchor; if (!a) return; if (Array.isArray(a[0])) a = a[Math.min(d.frame, a.length - 1)]; px = worldX + (a[0] - d.clip.pivotX) * face; oy = a[1] - d.clip.pivotY; } const sx = Math.round(px - camX); const sy = Math.round(GROUND_Y + oy); // A spear is not the size of a hammer. Per variant, so the one object that // has to out-reach a sword in silhouette can, without inflating the rest. const kScale = keptClip.variantScale ? (keptClip.variantScale[d.kept] ?? 1) : 1; // The halo. Inside the torch it separates the object from the body's own // line work. Outside it, on a dropped object only, it is the thing that // tells you from across the screen that there is something there at all. if (mode === 'lit' || d.keptDropped) { const R = (keptClip.width / 2) * TORCH.KEPT_GLOW_R * kScale; // Breathing, not steady. Alpha on the pre-allocated gradient rather than // rebuilding it, so the pulse costs nothing. const pulse = (1 - TORCH.KEPT_PULSE_DEPTH * (0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ))) * (mode === 'lit' ? 1 : TORCH.KEPT_GLOW_DARK); c.save(); c.globalCompositeOperation = 'lighter'; c.globalAlpha = pulse; // COMPOSED, not set. A page on a figure in the back lane is drawn under // that lane's scale and lift, and setTransform threw both away: the // page went up with the body and its halo stayed 56px below it. c.translate(sx, sy); c.scale(R, R); c.fillStyle = keptGlow; c.fillRect(-1, -1, 2, 2); c.restore(); } // The object keeps its own black line work. The colour is all in the halo // behind it, so it has a hot pool to sit in and an edge to read against. // // AND IT TURNS WITH THE HAND. A trade loop swings a hammer through 100 // degrees; drawn axis-aligned the head stays pointing at the sky while the // arm comes down, and the whole thing reads as a shape floating near a man // rather than a thing he is holding. Negated when he is mirrored, or the // hammer turns the wrong way on half the village. // Held, it turns with the hand. Dropped, it keeps the art's own angle. const angles = d.keptDropped ? null : d.clip.keptAngle; const ang = angles ? angles[Math.min(d.frame, angles.length - 1)] : 0; if (ang || kScale !== 1) { c.save(); c.translate(sx, sy); if (ang) c.rotate(face >= 0 ? ang : -ang); if (kScale !== 1) c.scale(kScale, kScale); c.translate(-sx / kScale, -sy / kScale); drawClip(c, keptClip, d.kept, mode, px / kScale, (GROUND_Y + oy) / kScale, camX / kScale, face * (keptClip.authoredFacing ?? 1)); c.restore(); } else { drawClip(c, keptClip, d.kept, mode, px, GROUND_Y + oy, camX, face * (keptClip.authoredFacing ?? 1)); } } /** * The tree with the note on it. On the road at his own scale, so it draws * with the houses: black in the dark pass, its bark in the lit one. * * THE NOTE, BOTH WAYS. In the dark the scrap shows pale — paper catching * what light there is, so a lone tree says "something is pinned here" the * way a house's lit doorway does. Under the torch it is the note on its * nail, drawn by drawNail like every letter. It stays there, and there is * no slot under it: nothing is ever pinned back to a tree. */ function drawTree(c, s, camX, mode) { const t = s.tree; const clip = s.treeClip; if (!t || !clip) return; const sx = Math.round(t.x - camX); if (sx < -clip.width || sx > W + clip.width) return; const x = sx - clip.pivotX; const y = GROUND_Y - clip.pivotY; c.drawImage(clip.frames[0][mode === 'lit' ? 'lit' : 'dark'], x, y); if (mode === 'lit') { drawNail(c, s, clip, t, x, y, { slot: false }); } else if (s.treeNoteDark) { const art = s.letterArt; const b = nailBox(clip.letterAt?.[t.variant], x, y, art); if (b) { c.drawImage(s.treeNoteDark, b.px - b.ink.x * b.k, b.py - b.ink.y * b.k, art.width * b.k, art.height * b.k); } } } /** * One Drained, in whichever lane it is standing in. * * THE BACK LANE IS A TRANSFORM ABOUT ITS FEET. Scaled and lifted around the * point it stands on, so the figure, the page it carries and the page's own * anchor all go together and nothing below here has to know. On the road it * is the identity and costs one branch. * * In the dark pass it also takes the lane's VALUE: the tinted `back` frames, * with the black ones laid over them at the share of the way it has stepped * down. Both have the same alpha, so the blend is a value and never a * see-through figure. The lit pass keeps its lit art in either lane — the * torch shows what they are, and value is a thing only a silhouette has. */ function drawDrained(c, s, d, mode, x, camX) { // THE STARE, BUILDING. Nothing while the light is off it, and nothing for // the first STIR_TREMBLE_FROM of the stare either: that part is the gasp // and the turn. Then a shiver, growing to a hard shake by the moment it // wakes. Squared, so it starts small. Offset per body so two lit at once // do not shake in step. const from = TUNING.STIR_TREMBLE_FROM; const k = d.stir > from ? (d.stir - from) / (1 - from) : 0; if (k > 0) x += Math.sin((s.clock ?? 0) * 71 + d.spawnX * 0.37) * TUNING.STIR_TREMBLE_PX * k * k; const L = laneLook(d.lane); if (L.lane <= 0) { drawClip(c, d.clip, d.frame, mode, x, GROUND_Y, camX, d.facing); drawKept(c, s.kept, d, mode, x, camX, s.clock); return; } const sx = x - camX; c.save(); c.translate(sx, GROUND_Y - L.rise); c.scale(L.scale, L.scale); c.translate(-sx, -GROUND_Y); if (mode === 'dark') { drawClip(c, d.clip, d.frame, 'back', x, GROUND_Y, camX, d.facing); drawKept(c, s.kept, d, 'back', x, camX, s.clock); if (L.lane < 1) { c.globalAlpha = 1 - L.lane; drawClip(c, d.clip, d.frame, 'dark', x, GROUND_Y, camX, d.facing); drawKept(c, s.kept, d, 'dark', x, camX, s.clock); } } else { drawClip(c, d.clip, d.frame, mode, x, GROUND_Y, camX, d.facing); drawKept(c, s.kept, d, mode, x, camX, s.clock); } c.restore(); } /** * Where the back lane is standing this frame, as screen spans, for the * watchers to keep out of. Off each figure's own ink rather than its canvas * — a loop's canvas is 380 wide to give the swing room, and the body in it * is a third of that — and mirrored with it. One buffer, reused. */ const laneSpans = new Float32Array(32 * 3); function backLaneSpans(s, camX, alpha) { let n = 0; for (const d of s.drained) { if (!(d.lane > 0) || n >= 32) continue; const x = lerp(d.prevX, d.x, alpha) - camX; if (x < -400 || x > W + 400) continue; const L = laneLook(d.lane); const ink = d.clip.ink; let l = ink ? ink.x - d.clip.pivotX : -60; let r = ink ? ink.x + ink.w - d.clip.pivotX : 60; if (d.facing !== (d.clip.authoredFacing ?? 1)) { const t = l; l = -r; r = -t; } laneSpans[n * 3] = x + l * L.scale; laneSpans[n * 3 + 1] = x + r * L.scale; laneSpans[n * 3 + 2] = L.lane; n++; } return n; } /** * Every Drained on screen, BACK LANE FIRST. Whatever stands beside the road * is behind whatever is on it, and one partway through stepping down is * between the two. Three sweeps over a dozen bodies rather than a sort, so * the frame still allocates nothing. */ function drawAllDrained(c, s, mode, camX, alpha) { for (let pass = 0; pass < 3; pass++) { for (const d of s.drained) { const lane = d.lane ?? 0; if (pass === 0 ? lane < 1 : pass === 1 ? (lane <= 0 || lane >= 1) : lane > 0) continue; if (d.x - camX < -400 || d.x - camX > W + 400) continue; drawDrained(c, s, d, mode, lerp(d.prevX, d.x, alpha), camX); } } } // The village. Five houses and a shut gate, at the actor's own parallax rate // because they stand on the same ground he does. // // V1 IS DELIBERATELY BOXES. A gable and a body, pure black, sitting on the // ground line. Art is V2 and the door that lights is the whole of V2, but the // question V1 asks — is the room the right size, and does a building tell you // the mode changed without a word — is answerable from a silhouette. // // Pure black, like every other built thing at this distance. The palette rule // lets the BACKGROUND carry tone; anything standing on the ground line stays // black, or the prince stops being the darkest shape in his own frame. function drawVillage(c, s, camX, mode = 'dark') { if (!s.houses) return; const clip = s.houseClip; if (clip && s.houseArt) { for (const h of s.houses) { const sx = Math.round(h.x - camX); if (sx < -clip.width || sx > W + clip.width) continue; const x = sx - clip.pivotX; const y = GROUND_Y - clip.pivotY; // DARK: the tinted silhouette, one solid mass, saying nothing. // LIT: the art itself — the wall at the same value, and the door open // with a trade standing in it. The torch mask does the rest, so a door // only shows where the light actually falls. if (mode === 'lit') { c.drawImage(clip.frames[h.variant].lit, x, y); drawNail(c, s, clip, h, x, y); drawRoomKey(c, s, clip, h, x, y, s.clock ?? 0); } else { c.drawImage(s.houseArt[h.variant], x, y); drawDoorGlow(c, s, clip, h, x, y, s.clock ?? 0); } } } else { // Before the art exists: boxes, which is enough to judge the size of the // room but not the mechanic. c.fillStyle = s.houseTint; for (const h of s.houses) { const sx = Math.round(h.x - camX); if (sx < -h.w || sx > W + h.w) continue; c.fillRect(sx - h.w / 2, GROUND_Y - 560, h.w, 560); } c.fillStyle = '#000'; } // The gate. Palace-built, shut, and it does not open until he is carrying // four objects he has looked at. V1 is the barrier only — the four marks // that state its terms, and the opening, are V5. // // NOT ONE BLACK SLAB. The first pass was, and the gatekeeper standing at // his own post disappeared into it exactly the way the prince disappeared // into the houses. So it is built like a gate: black posts, a black lintel // and black wall running off past the frame, with the shut LEAF between // them at the same set-back value the houses use. Everything that stands in // front of the gate stands in front of the leaf, and reads. const gx = Math.round(s.gateX - camX); if (gx > W + 520) return; // 420, not 280. At 280 the opening was 560px wide and the prince stopped // INSIDE the left post — pure black behind a pure black figure again, one // layer further in. The gateway has to be wide enough to stand in. const half = 420, post = 100; const leafTop = 200, leafBot = GROUND_Y + 40; // THE LEAF LIFTS. Straight up and out of sight, the way a thing that heavy // is raised rather than swung. It is the only moving piece of the village // and it moves once. const open = s.gate?.open ?? 0; const lift = open * (leafBot - leafTop + 40); c.save(); c.beginPath(); c.rect(gx - half, leafTop, half * 2, leafBot - leafTop); c.clip(); c.fillStyle = s.houseTint; c.fillRect(gx - half, leafTop - lift, half * 2, leafBot - leafTop); // FOUR MARKS, and this is how the gate states its terms. A door that simply // does nothing reads as broken; four empty sockets read as four of // something wanted. They are on the GATE rather than in the map because // they need no interaction, and because being blocked is the moment the // player asks the question. // // Live, not latched: it counts what he is carrying and has looked at, so // putting one down puts a mark out again. const filled = s.gate?.filled ?? 0; // HIGH ON THE LEAF, well above head height. At chest height the leftmost // one sat behind the gatekeeper and the prince standing in front of him, // so the gate asked for three things instead of four. const mw = 130, mh = 150, mgap = 80; // OFF GATE_WANTS, not off a literal four. The leaf and the requirement // used to be two separate fours and changing one left the other lying. const n = TUNING.GATE_WANTS; const row = gx - ((mw * n + mgap * (n - 1)) / 2); const my = leafTop + 70 - lift; // AN EMPTY SOCKET BREATHES, the same as an empty patch on a door and for // the same reason. Four holes in a leaf have always asked their own // question — but only of somebody who already knows the answer is a key. // The first person to reach this gate carrying four of them put one in by // accident, pressing the button to see what it would do. const sock = 1 - TORCH.KEPT_PULSE_DEPTH * (0.5 - 0.5 * Math.cos((s.clock ?? 0) * Math.PI * 2 * TORCH.KEPT_PULSE_HZ)); for (let i = 0; i < TUNING.GATE_WANTS; i++) { const mx = row + i * (mw + mgap); if (i >= filled) { const scx = mx + mw / 2; const scy = my + mh / 2; const sr = Math.max(mw, mh) * 0.95; const sg = c.createRadialGradient(scx, scy, 0, scx, scy, sr); sg.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * sock * 0.8).toFixed(3)})`); sg.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`); const wasOp = c.globalCompositeOperation; c.globalCompositeOperation = 'lighter'; c.fillStyle = sg; c.fillRect(scx - sr, scy - sr, sr * 2, sr * 2); c.globalCompositeOperation = wasOp; } c.fillStyle = '#000'; c.fillRect(mx, my, mw, mh); if (i >= filled) { // EMPTY: A KEY-SHAPED HOLE, not a rectangular one. // // Four rectangles ask a question and do not say what the answer is. // Somebody arrived here carrying four keys and put one in by accident, // pressing the button to see what it would do. A lock the shape of its // key needs no telling — and it is the same idea as the mark on a door // being the shape of the page. if (s.socketArt && s.keyArt) { const ka = s.keyArt; const kin = ka.ink ?? { x: 0, y: 0, w: ka.width, h: ka.height }; const kk = Math.min((mw - 30) / kin.w, (mh - 24) / kin.h); c.drawImage(s.socketArt, mx + (mw - kin.w * kk) / 2 - kin.x * kk, my + (mh - kin.h * kk) / 2 - kin.y * kk, ka.width * kk, ka.height * kk); } else { c.fillStyle = s.houseTint; c.fillRect(mx + 12, my + 12, mw - 24, mh - 24); } } } c.restore(); c.fillStyle = '#000'; c.fillRect(gx - half - post, 120, post, leafBot - 120); c.fillRect(gx + half, 120, post, leafBot - 120); c.fillRect(gx - half - post - 20, 80, (half + post) * 2 + 40, 130); c.fillRect(gx + half + post, 0, W + 520 - (gx + half + post), GROUND_Y + 200); } /** * The map, and it is a prop rather than a screen. * * A sheet anchored at the pose's mapPivot, the same way the flame is anchored * at handPivot — which is what lets the sheet be LIT while he stays a * silhouette inside his own torchlight. In pass 1 it is a black rectangle in * his hand, because a sheet of paper is a thing that blocks light. In pass 2 * it is paper with a village drawn on it. * * IT RECORDS AND IT NEVER REASONS. Five plots along a street in the order he * walks past them; a building appears when he has lit its door, and its * object drops into the slot beneath when he has taken it AND looked at it. * Anything taken blind sits along the top, filed against nothing. * * No error state, no completion state, and an empty slot is never flagged. * The empty slot is the finding. */ // THE PAGE IN HIS HAND IS GONE, and it used to be drawn here. // // It came in with the top-page model, to say what he was holding without // making him raise anything. It worked, and it looked wrong: a small pale // shape at hip height on a black silhouette, and there is exactly one thing // a person reads that as. // // It is also redundant now. When it was added the pile was still called // "pages" and keys were an invisible number; the inventory holds everything // he has and choosing in it is how he picks what to use. A shape at the hip // was answering a question the inventory now answers properly. // // `state.handPage` stays. It is still the top of the pile, still what E acts // on at a door, and still what the wall previews. It is simply not drawn on // his body. /** * THE SKY GOING OFF, and where it is drawn is the whole trick. * * AFTER the ground and the houses, BEFORE the people. So the sky, the hills, * the road and the walls go white and every figure in front of them stays * pure black — which is the picture this game is already built out of, at * maximum contrast, for a quarter of a second. You see the entire street and * everyone standing in it, with no torch. * * A wash over the FINISHED frame would lift the silhouettes with everything * else and flatten the one thing that matters. This is the difference * between lightning and a camera flash. */ /** * THE BOLT, and it is the only thing in this game that is light. * * Everything else is a black shape stopping light. This is the source, so it * is the one drawing that is never silhouetted and never tinted — it goes * down exactly as it was drawn, pale blue-white on a dark sky. * * BEFORE THE HOUSES, so a building stands in front of it the way a building * stands in front of weather. And before the flash, so the white sky washes * it out at the peak rather than the bolt sitting on top of a blown-out sky * — which is the order these two things happen in. * * SMOOTHED, which is the opposite of what pixel art usually wants. At the * size it has to be to read, nearest-neighbour steps are obvious and there is * no other pixel art anywhere in this game. Interpolated, the steps become * the soft glow a bolt has anyway. */ function drawBolt(c, s) { const clip = s.lightning; const ms = s.flashMs ?? -1; if (!clip || !(ms >= 0)) return; // Number, not NaN. These constants live on FLASH and were read off TUNING // once: `ms / undefined` is NaN, every comparison against NaN is false, so // it fell through to `frames[NaN]` and returned without drawing and // without complaining. A guard that cannot be satisfied looks exactly like // an effect that is switched off. const i = Math.floor(ms / FLASH.BOLT_FRAME_MS); if (!Number.isFinite(i) || i < 0 || i >= clip.count) return; const frame = clip.frames[i]; if (!frame) return; const h = FLASH.BOLT_H; const w = h * (clip.width / clip.height); const was = c.imageSmoothingEnabled; c.imageSmoothingEnabled = true; c.drawImage(frame.lit, Math.round(W * FLASH.BOLT_AT - w / 2), 30, w, h); c.imageSmoothingEnabled = was; } /** * A warm pulse at a door, OUT IN THE DARK, with the torch down — for a key * whose door somebody opened, and nothing else. * * The key only ever existed inside the torchlight, because everything in a * doorway is drawn in the lit pass. So the street said nothing: you had to * already be holding the light on a door to learn there was anything at it, * which is the wrong way round — the light is what you spend, and something * has to be worth spending it on. * * THE EMPTY SLOT USED TO PULSE HERE TOO, and does not any more. It is a * value, not a glow: a mark on a wall that breathes in the dark is UI, and * the loom house's slot, which nothing ever fills, would have been asking * for the whole level in a voice the game uses for "take this". * * THE DROPPED SCRAPS HAVE ALWAYS DONE THIS. "A warm pulse out there is a * thing you notice from across the screen, and it is the only reason to walk * over; the torch is what turns the glowing dot into a page with writing on * it." Same tuning value, same rhythm, same bargain — the glow says HERE and * only the torch says WHAT. * * Glow only, no art. In the dark a black key on a wall at luminance 26 is * nothing; the pool is the whole signal. */ function drawDoorGlow(c, s, clip, house, x, y, clock) { // THE SAME GLOW A DROPPED PAGE HAS, off the same gradient and the same // maths — because the first version was not. It took the rect's LONGEST // side, multiplied by KEPT_GLOW_R and then again by 1.6, and drew no object // at all: a 350px formless brown smear on a wall. The tuning note says // exactly why that is wrong — "multiples of the object's HALF-WIDTH. // Tighter = hotter" — and the pool is meant to have a thing sitting in it. // // So: half the art's width, one pool, and the black shape inside it. A // page-shaped dark mark in a hot spot, which is a dropped scrap with a nail // through it. const pulse = (1 - TORCH.KEPT_PULSE_DEPTH * (0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ))) * TORCH.KEPT_GLOW_DARK; const at = (rect, art) => { if (!rect || !art) return; const [rx, ry, rw, rh] = rect; const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height }; const k = Math.min(rw / ink.w, rh / ink.h); const w = ink.w * k; const h = ink.h * k; const dx = x + rx + (rw - w) / 2; const dy = y + ry + (rh - h) / 2; const sx = dx + w / 2; const sy = dy + h / 2; // Half the drawn width, exactly as the dropped page measures it. const R = (w / 2) * TORCH.KEPT_GLOW_R; c.save(); c.globalCompositeOperation = 'lighter'; c.globalAlpha = pulse; c.setTransform(R, 0, 0, R, sx, sy); c.fillStyle = keptGlow; c.fillRect(-1, -1, 2, 2); c.restore(); // And the thing itself, black, sitting in its own pool — the object is // what makes it a page on a wall rather than a light on a wall. const frame = art.frames[0]; if (frame) { c.drawImage(frame.dark, dx - ink.x * k, dy - ink.y * k, art.width * k, art.height * k); } }; // A key whose door somebody opened. if (house.open && !house.keyTaken) at(clip.keyAt?.[house.variant], s.keyArt); } /** * A low mist on the ground: three layers of it, drifting, in front of * everybody. * * THE FIRST VERSION WAS A FLAT LINEAR GRADIENT and it was judged and * rejected in one sentence: "it looks like the sky." Which it did, and for * the obvious reason — a smooth band of even tone that never moves is * shading, not weather. Two things separate fog from a wash, and it needs * both: it is PATCHY, and it MOVES. * * So it is built the way everything else in this file is built: one soft * blob gradient made once in unit space, then stamped with setTransform at * whatever size and place each puff wants — the same trick the dropped * pages' halo uses, for the same reason, which is that building a gradient * per puff per frame is forty gradients a frame for nothing. * * THREE LAYERS, AND THE PARALLAX IS THE POINT. Each drifts at its own rate * against the camera and slides on its own slow clock, so the near mist * overtakes the far mist and the bank never reads as one sheet. Position and * size come off a hash of the puff's index, so it is patchy in a way that is * the same every run and never repeats inside a screen. * * IN FRONT AND NOT BEHIND. Fog behind everybody is a backdrop; fog in front * of them is weather, and only weather takes anybody's feet. */ const fogPuff = octx.createRadialGradient(0, 0, 0, 0, 0, 1); fogPuff.addColorStop(0.00, 'rgba(150,168,186,0.42)'); fogPuff.addColorStop(0.30, 'rgba(142,160,180,0.30)'); fogPuff.addColorStop(0.58, 'rgba(132,150,170,0.12)'); fogPuff.addColorStop(0.82, 'rgba(126,144,164,0.03)'); fogPuff.addColorStop(1.00, 'rgba(126,144,164,0)'); // LUMPS, NOT A BAND. The first pass spaced the puffs closer together than // they were wide, so they merged into one even wash — which is the flat // gradient again wearing a costume. These are spaced wider than their own // radius, so the top edge dips between them and you can see the crests. // // far, mid, near: [parallax against the camera, drift px/s, puffs across the // view, wide, tall, how far the centre sinks below the ground line, weight] const FOG_BANDS = [ [0.30, 7, 4, 0.62, 0.78, 0.10, 0.50], [0.62, 16, 5, 0.54, 0.58, 0.18, 0.78], [1.00, 34, 7, 0.44, 0.42, 0.26, 1.00], ]; function drawFog(c, camX, clock) { const k = TONE.FOG; if (!(k > 0)) return; const H0 = TONE.FOG_H; const was = c.globalAlpha; for (let b = 0; b < FOG_BANDS.length; b++) { const [par, drift, n, wide, tall, sink, weight] = FOG_BANDS[b]; const span = W / n; const shift = camX * par + clock * drift; const period = span * (n + 2); for (let i = -1; i <= n; i++) { // Deterministic per puff, so the bank is the same bank every run and // no two in a band share a size, a height or a spacing. const id = i + b * 97; const h1 = ((Math.sin(id * 12.9898) * 43758.5453) % 1 + 1) % 1; const h2 = ((Math.sin(id * 78.233) * 12345.6789) % 1 + 1) % 1; const h3 = ((Math.sin(id * 39.425) * 24634.6345) % 1 + 1) % 1; const rx = span * wide * (0.74 + h1 * 0.52); const ry = H0 * tall * (0.62 + h2 * 0.76); // The extra h3 jitter on x is what stops the crests arriving on a beat. const slot = i * span + (h3 - 0.5) * span * 0.55; const x = ((slot - shift) % period + period) % period - span; const y = GROUND_Y + ry * sink + (h2 - 0.5) * H0 * 0.10; c.globalAlpha = k * weight; c.save(); c.setTransform(rx, 0, 0, ry, x, y); c.fillStyle = fogPuff; c.fillRect(-1, -1, 2, 2); c.restore(); } } c.globalAlpha = was; } /** * THE ARM THAT HOLDS THE TORCH, and it is a placeholder. * * Her standing drawing has both arms down, so the flame was pinned to the * far hand at hip height and a burning torch sat at his crotch. This draws * that same arm jointed, from the geometry the manifest publishes, so it can * go up: two tapered bones and a fist, angles lerped by the raise. * * LIVE RATHER THAN BAKED TO FRAMES, because the raise is a smooth 250ms ramp * and six frames would step through it. Delete the whole thing the day the * raised-torch pose is drawn. * * It takes the passes the way the body does: flat black in the dark, and in * the lit pass white with an ink line round it, which is how her drawing is * built. */ /** * A TAPERED LIMB, filled, the way gen_placeholders.js draws every other one. * * Stamped rather than stroked: a quad between each pair of spine points at * their own half-widths, plus a disc at each point. The discs round the * joints and paper over the pinch an offset curve gets at a sharp elbow, and * since it is all one flat black fill the overlap costs nothing. * * A constant-width stroke was the first attempt and it read as a stick, * because an arm is not a constant width: it is thick at the shoulder, bulges * at the bicep, narrows at the elbow, bulges again at the forearm and ends * thin at the wrist. That profile IS the difference between a limb and a line. */ function fillLimb(c, spine, w) { for (let i = 0; i < spine.length - 1; i++) { const [ax, ay] = spine[i]; const [bx, by] = spine[i + 1]; const dx = bx - ax, dy = by - ay; const len = Math.hypot(dx, dy) || 1; const nx = -dy / len, ny = dx / len; c.beginPath(); c.moveTo(ax + nx * w[i], ay + ny * w[i]); c.lineTo(bx + nx * w[i + 1], by + ny * w[i + 1]); c.lineTo(bx - nx * w[i + 1], by - ny * w[i + 1]); c.lineTo(ax - nx * w[i], ay - ny * w[i]); c.closePath(); c.fill(); } for (let i = 0; i < spine.length; i++) { c.beginPath(); c.arc(spine[i][0], spine[i][1], w[i], 0, Math.PI * 2); c.fill(); } } function drawTorchArm(c, s, mode, px, camX, face, eased) { const arm = s.torchArm; if (!arm || eased <= 0.001) return; const p = armPose(arm, eased); const x0 = Math.round(px - camX); const y0 = GROUND_Y; const at = (o) => [x0 + face * o[0], y0 + o[1]]; const S = at(p.shoulder), E = at(p.elbow), Wr = at(p.wrist), Fi = at(p.fist); // Midpoints carry the bicep and the forearm, bowed a little off the bone so // the limb is not two straight runs meeting at a corner. const bow = (a, b, k) => { const dx = b[0] - a[0], dy = b[1] - a[1]; const len = Math.hypot(dx, dy) || 1; return [(a[0] + b[0]) / 2 - (dy / len) * k * face, (a[1] + b[1]) / 2 + (dx / len) * k * face]; }; const spine = [S, bow(S, E, 2.5), E, bow(E, Wr, -2), Wr]; // shoulder, bicep, elbow, forearm, wrist -- HALF-widths, from the manifest. const w = arm.widths; c.save(); // FLAT BLACK IN BOTH PASSES. He is never lit by his own torch -- his body // goes into the lit pass with its DARK art for exactly that reason -- and // an arm painted white with an ink line round it was a pale stick glowing // on a black silhouette. `mode` is taken and ignored on purpose, so the // call sites read like every other draw in here. c.fillStyle = '#000'; fillLimb(c, spine, w); // THE BRAND, drawn here rather than baked into the fire sprite: the fire is // a photograph of fire and the stick under it is ours, and they move on // different rules -- the stick follows the wrist, the fire just sits on it. const B = at(p.butt), Hd = at(p.head); const ux = (Hd[0] - B[0]) / (arm.shaftLen + arm.bindLen); const uy = (Hd[1] - B[1]) / (arm.shaftLen + arm.bindLen); const sh = [B[0] + ux * arm.shaftLen, B[1] + uy * arm.shaftLen]; fillLimb(c, [B, sh], arm.shaftW); fillLimb(c, [sh, Hd], [arm.bindW, arm.bindW]); // The fist LAST, so his hand closes over the shaft instead of behind it. const gx = Fi[0] - Wr[0], gy = Fi[1] - Wr[1]; c.translate(Fi[0], Fi[1]); c.rotate(Math.atan2(gy, gx)); c.beginPath(); c.ellipse(0, 0, arm.hand * 1.3, arm.hand, 0, 0, Math.PI * 2); c.fill(); c.restore(); } /** * The sky losing its light as he goes deeper. * * The canopy already closes on depthAt(). The sky behind it is pixel * identical from the first step to the gate, so walking a hundred seconds * into something never looks like walking into anything. One wash over the * sky band, on the curve that already exists. */ function drawSkySink(c, worldX) { const k = TONE.SKY_SINK; if (!(k > 0)) return; c.fillStyle = `rgba(6,10,18,${(depthAt(worldX) * k * 0.55).toFixed(3)})`; c.fillRect(0, 0, W, GROUND_Y); } function drawFlash(c, v) { if (!(v > 0.004)) return; c.fillStyle = `rgba(${FLASH.RGB},${v.toFixed(3)})`; c.fillRect(0, 0, W, H); } function drawSheet(c, s, mode, px, camX, face) { const sheet = s.sheetClip; const pose = s.prince.clip; if (!sheet || !pose?.mapPivot || !s.sheaf?.up) return; const raise = s.torch.raise; if (raise < 0.02) return; const ox = (pose.mapPivot[0] - pose.pivotX) * face; const oy = pose.mapPivot[1] - pose.pivotY; const sx = Math.round(px - camX + ox); const sy = Math.round(GROUND_Y + oy); // Held up as the arm comes up, rather than appearing whole. const w = sheet.width * raise; const h = sheet.height * raise; // Mirrored with him, so it is always between him and the camera. const left = face >= 0 ? sx : sx - w; // ALWAYS THE BLANK FACE NOW. The other face has a street and five plots // printed on it and is not drawn any more — see the note on `sheetFace`. const faces = sheet.sheetFace ?? { map: 0, blank: 0 }; const sheetFrame = sheet.frames[faces.blank] ?? sheet.frames[0]; c.drawImage(sheetFrame[mode], left, sy - h, w, h); if (mode !== 'lit' || raise < 0.98) return; const x0 = left; const y0 = sy - h; const pg = s.page ? s.page() : null; // HOW FAR DOWN THE PILE HE IS. Plain "3/7" in the corner, and it replaced a // row of little marks along the bottom edge that read as a second index // sitting under the first one. A player called that row confusing and was // right: two rows of small marks on one sheet, meaning different things. if (s.sheaf.pages.length) { const fs = Math.round(sheet.height * 0.085); c.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`; c.textAlign = 'right'; c.textBaseline = 'top'; c.fillStyle = 'rgba(0,0,0,0.5)'; c.fillText(`${s.sheaf.index + 1}/${s.sheaf.pages.length}`, x0 + sheet.width - 14, y0 + 12); c.textAlign = 'left'; c.textBaseline = 'alphabetic'; } // AN EMPTY PILE IS BLANK PAPER, which is the true answer to "what am I // carrying" before he has picked anything up. if (!pg) return; // THE SHEET STAYS WHERE IT IS. WHAT IS DRAWN ON IT CHANGES. He is holding // up a pile of loose paper in one hand, not opening a book, so the paper // does not move — only the page on top of it does. // // The sheet carries the SHAPE and the band carries the WORDS. A letter is a // whole page, a fragment is a torn one, and neither is legible at this size // on purpose: reading happens in the band and the drawing only has to say // which kind of thing he is holding. drawSheetPage(c, s, sheet, x0, y0, pg); drawFiledLine(c, s, sheet, x0, y0, pg); drawLetterHouse(c, s, sheet, x0, y0, pg); } /** * WHICH HOUSE THIS LETTER CAME OFF. * * There was never a puzzle in hiding it. A letter IS the house's — he took it * off that wall — and the deduction is which FRAGMENT belongs under it. All * five letters being one shape only made the player do bookkeeping: take * three, and the pile is three identical pages with no way back to the door * each came from. * * The torn pages stay identical, and that is the opposite case: telling those * apart by their outline would hand over the answer without reading a word. */ function drawLetterHouse(c, s, sheet, x0, y0, pg) { if (!pg || pg.kind !== 'letter') return; const house = s.houses?.[pg.house]; // WHERE IT CAME FROM. The only letter he carries is his orders, and they // came from the palace. House letters and the note on the tree stay where // they were left, so they are never a page here — but a letter with a // house behind it would still draw that house. const hc = house ? s.houseClip : s.palaceClip; if (!hc) return; const frame = house ? house.variant : 0; const bx = x0 + sheet.width / 2; const base = y0 + sheet.height - 14; const k = Math.min(58 / hc.width, 38 / (hc.height * 0.86)); const dw = hc.width * k; const dh = hc.height * k; c.drawImage(hc.frames[frame].dark, bx - dw / 2, base - 2 - dh, dw, dh); c.fillStyle = '#000'; c.fillRect(Math.round(bx - 32), Math.round(base - 2), 64, 2); } /** * WHETHER THIS PAGE IS FILED, AND WHERE, along the sheet's bottom edge. * * It used to be visible only on the map page, which meant paging past a * fragment told you nothing about what you had already claimed with it. Now * it travels with the page, because "have I used this one yet" is a question * you ask WHILE scrolling. * * A filed page shows the house it went to, drawn from that house's own art. * An unpinned fragment shows the bare patch — the same mark that is waiting * beside every door he has taken a letter off. Still no verdict anywhere: it * says where he put it, never whether that was right. */ function drawFiledLine(c, s, sheet, x0, y0, pg) { if (!pg || pg.kind !== 'fragment') return; if (s.canMark) return; // the destination is being drawn instead const house = s.filedUnder ? s.filedUnder(pg) : null; const bx = x0 + sheet.width / 2; const base = y0 + sheet.height - 14; if (house && s.houseClip) { const hc = s.houseClip; const k = Math.min(58 / hc.width, 38 / (hc.height * 0.86)); const dw = hc.width * k; const dh = hc.height * k; c.drawImage(hc.frames[house.variant].dark, bx - dw / 2, base - 2 - dh, dw, dh); c.fillStyle = '#000'; c.fillRect(Math.round(bx - 32), Math.round(base - 2), 64, 2); return; } // NOT PINNED ANYWHERE: the bare patch, exactly as it looks on a wall he // has taken a letter off. It was a stroked box while the wall had a frame // on it; the wall has a mark now, so this is a mark. c.fillStyle = '#6f6553'; c.fillRect(Math.round(bx - 19), Math.round(base - 36), 38, 34); c.fillStyle = '#000'; c.fillRect(Math.round(bx) - 1, Math.round(base - 36) + 2, 3, 4); } /** * A letter or a fragment, drawn large in the middle of the sheet. * * A page he has not looked at is drawn as PAPER WITH NOTHING ON IT YET — the * silhouette of the thing without its contents. It fills in when he has held * it in his own light long enough to have read it, which is the same cost * every other act of looking in this game charges. */ function drawSheetPage(c, s, sheet, x0, y0, pg) { const whole = pg.kind === 'letter' || pg.kind === 'key'; const known = s.pageSeen ? s.pageSeen(pg) : true; // A page he has not turned to is the SHAPE OF THE THING WITHOUT ITS // CONTENTS — the same contour with nothing written inside it. Drawn from // its own art rather than from the silhouette pass, which would put a solid // black page on the paper and read as a hole in the sheet. // A KEY IS IN HERE TOO. It carries nothing to read and never will, but a // thing you are carrying that you cannot look at is a thing you do not // know you have — which is exactly what happened: somebody collected four // and only found out at the gate, by pressing the button to see. const art = pg.kind === 'key' ? s.keyArt : known ? (whole ? s.letterArt : s.kept) : s.pageBlank; if (!art) return; const i = pg.kind === 'key' ? 0 : known ? (pg.house ?? pg.kept ?? 0) : (art.pageFace ? (whole ? art.pageFace.whole : art.pageFace.torn) : 0); const frame = art.frames[i]; if (!frame) return; // FITTED BY ITS INK, NOT BY ITS CANVAS. The page art uses about half of its // 96x96 canvas, so scaling the canvas to the sheet drew a small page in a // large empty frame. The loader measures where the drawing actually is. // // As big as the paper will take, then: it is the page he is holding up, not // an icon of one, and the row of marks along the bottom edge is the only // thing it has to stay clear of. // // AND IT MAKES ROOM WHEN THERE IS SOMEWHERE FOR IT TO GO. Stood at a lit // door holding a page that can be filed, the sheet shrinks the page and // draws that house's plot beside it: the paper itself says "this one, // here", with no words and no verdict. It shows what pressing take would // do, never whether doing it is right. // // It is the only moment the affordance can be taught, because it is the // only moment the action exists. const target = s.canMark && s.markHouse != null ? s.houses[s.markHouse] : null; const room = target ? 0.56 : 1; // AND IT GIVES UP THE BOTTOM OF THE SHEET when there is a filed line to // draw there. Fitted to the full height, the page sat on top of the mark // saying where it had gone. const foot = !target && (pg.kind === 'fragment' || pg.kind === 'letter') ? 48 : 0; const box = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height }; const k = Math.min((sheet.width - 30) * room / box.w, (sheet.height - 34 - foot) / box.h); const dw = art.width * k; const dh = art.height * k; // Centre the INK in the room it has, which is not the same as centring the // canvas when the drawing sits off to one side of it. const dx = x0 + 15 + ((sheet.width - 30) * room - box.w * k) / 2 - box.x * k; const dy = y0 + (sheet.height - 14 - foot - box.h * k) / 2 - box.y * k; c.drawImage(frame.lit, dx, dy, dw, dh); if (target) drawDestination(c, s, sheet, x0, y0, target); } /** * The plot this page would go under, drawn small beside it: the building, the * street it stands on, and the empty slot below with a page waiting in it. * * A MINIATURE OF WHAT THE MAP WILL LOOK LIKE AFTERWARDS, which is why it * needs no label. Same drawing, same sheet, one step ahead. */ function drawDestination(c, s, sheet, x0, y0, house) { const pw = 56, ph = 38; const cx = x0 + sheet.width * 0.775; const top = y0 + (sheet.height - 14 - (ph + 44)) / 2; // The house, from its own art, so it is the building he is standing at. if (s.houseClip) { const hc = s.houseClip; const k = Math.min(pw / hc.width, ph / (hc.height * 0.86)); const dw = hc.width * k; const dh = hc.height * k; c.drawImage(hc.frames[house.variant].dark, cx - dw / 2, top + ph - dh, dw, dh); } c.fillStyle = '#000'; c.fillRect(Math.round(cx - pw / 2), Math.round(top + ph), pw, 2); // And under it, THE BARE PATCH — the same mark that is on that house's // wall right now, with a page going into it. The sheet and the wall say the // same thing in the same drawing, which is what makes either readable. const fy = top + ph + 12; c.fillStyle = '#6f6553'; c.fillRect(Math.round(cx - 17), Math.round(fy), 34, 30); c.fillStyle = '#000'; c.fillRect(Math.round(cx) - 1, Math.round(fy) + 2, 3, 4); if (s.pageBlank?.pageFace) { const torn = s.pageBlank.frames[s.pageBlank.pageFace.torn]; const ib = s.pageBlank.ink ?? { x: 0, y: 0, w: s.pageBlank.width, h: s.pageBlank.height }; if (torn) { const k = Math.min(24 / ib.w, 22 / ib.h); c.globalAlpha = 0.55; c.drawImage(torn.lit, cx - (ib.w * k) / 2 - ib.x * k, fy + 15 - (ib.h * k) / 2 - ib.y * k, s.pageBlank.width * k, s.pageBlank.height * k); c.globalAlpha = 1; } } } /** * THE LETTER ON THE DOOR, and it never comes down. * * Letters stay where they were left; fragments come off bodies. He does not * pocket a stranger's mail, and that puts the deduction where it belongs: * the question is nailed in front of him, the fragment is in his hand, and * the door is where he judges. * * NO GLOW. The objects on the ground pulse because a dark street gives no * other reason to walk over to one; a door is already that reason, and a glow * bleeding through an unlit house would break the one rule the whole look * rests on — that a building is a black mass until he lights it. */ // The same value a lit doorway is drawn at, and it has to be: a page held in // that light is the same paper as the light falling out of the room. const DOORLIGHT = '#d8c3a0'; /** * THE SLOT, and it is what a deleted frame left behind. * * Their own paper hung on a second nail under the letter left for them, and * kept the weather off the wall behind it. The fragment on a body is that * paper, and the lighter patch is where it goes back. * * IT IS A VALUE, NOT AN OUTLINE. No border, no socket, no glow, and it must * never read as UI. The palette makes that a narrow window: the wall sits at * 26 and nothing may live between 30 and 90, so a patch that reads at all has * to clear 90. This is just above that floor and far under the doorway's 197, * which is the whole of the space the rule leaves. */ const PATCH = '#6f6553'; // lum 101 — over the forbidden zone, under the door /** Where a rect on the wall lands on screen, for whatever is hanging in it. */ function nailBox(rect, x, y, art) { if (!rect || !art) return null; const [nx, ny, nw, nh] = rect; const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height }; const k = Math.min(nw / ink.w, nh / ink.h); const w = ink.w * k; const h = ink.h * k; return { k, w, h, px: x + nx + (nw - w) / 2, py: y + ny + (nh - h) / 2, rx: x + nx, ry: y + ny, rw: nw, rh: nh, ink }; } /** * WHAT IS ON THE WALL BESIDE A DOOR, top to bottom: the letter somebody * left, and under it the slot — the patch, the bare nail, and whatever he * pins there. The question above the answer, and the eye reads down. * * LIT PASS ONLY, so all of it lives and dies with the torch like everything * else in a doorway. * * THE TREE HAS NO SLOT (`slot: false`). It carries the note and nothing * else: nothing is ever pinned back to a tree. */ function drawNail(c, s, clip, house, x, y, { slot = true } = {}) { // 1. THE LETTER. Always: nothing takes it down. const paper = s.letterArt; const written = paper?.frames[house.index]; const lb = nailBox(clip.letterAt?.[house.variant], x, y, paper); if (lb && written) { // PAPER FIRST. The page art is contour and writing on nothing, so on a // dark wall it came out dark-on-dark. It is a sheet of paper in // torchlight, so it gets the value a lit doorway gets. c.fillStyle = DOORLIGHT; c.fillRect(Math.round(lb.px - 3), Math.round(lb.py - 3), Math.round(lb.w + 6), Math.round(lb.h + 6)); c.drawImage(written.lit, lb.px - lb.ink.x * lb.k, lb.py - lb.ink.y * lb.k, paper.width * lb.k, paper.height * lb.k); } if (!slot) return; // 2. THE SLOT, UNDER IT. Flat, borderless, and slightly bigger than the // page that made it, because paper curls and the weather got in at the // edges. There from the first light, whether or not anything is in it. const rect = clip.slotAt?.[house.variant]; if (!rect) return; const [nx, ny, nw, nh] = rect; const px0 = Math.round(x + nx); const py0 = Math.round(y + ny); // IT DOES NOT BREATHE. It used to, while it was empty: the patch only // appeared once the letter came off, and nothing walked a player over to a // door to find it. It is under the letter from the first light now, and a // bare nail under a letter asks its own question. A pulse on a wall reads // as UI, and the loom house's empty slot has to read as a letter nobody // answered, not as a button nobody pressed. // // IT IS THE SHAPE OF WHAT GOES IN IT. // // A rectangle says "a place" without saying what for, and that is exactly // how this failed: somebody lit a door, saw a slightly cleaner rectangle, // and had no reason to connect it to the torn paper in their pocket. Cut // to the page's own outline it says both things at once, with no words. // // The whole letter and the torn scrap share one contour but for the tear, // so this doubles as the mark the letter left AND the hole the scrap fits. if (s.patchArt && s.kept) { const pk = s.kept; const pink = pk.ink ?? { x: 0, y: 0, w: pk.width, h: pk.height }; const pkk = Math.min(nw / pink.w, nh / pink.h); c.drawImage(s.patchArt, px0 + (nw - pink.w * pkk) / 2 - pink.x * pkk, py0 + (nh - pink.h * pkk) / 2 - pink.y * pkk, pk.width * pkk, pk.height * pkk); } else { c.fillStyle = PATCH; c.fillRect(px0, py0, nw, nh); } // The nail itself, bare now. Four pixels of iron, and the only reason it is // drawn at all is that a patch with nothing at the top of it reads as a // stain rather than as a place something hung. c.fillStyle = '#000'; c.fillRect(Math.round(px0 + nw / 2) - 2, py0 + 8, 4, 5); // 2b. AND IF HE IS STANDING HERE HOLDING A PAGE, THE WALL SHOWS WHERE IT // WOULD GO. A ghost of the exact page in his hand, faint, in the patch. // // THIS IS WHY IT IS HERE. A ten-year-old played the level, picked the // scraps up, and never worked out that they go on a wall — and she was // right to miss it, because nothing said so. The patch was made // deliberately quiet ("a value, not an outline, it must not read as UI") // and it succeeded: it is a rectangle of slightly cleaner wall and it // asks nothing. The only thing that had ever said "[E] PIN IT HERE" was // the debug readout, which was on the whole time anybody tested it. // // NOT A NEW IDEA, AND NOT A PROMPT. The sheaf sheet has always drawn this // exact ghost — a page at 0.55 alpha, going into a plot — to say "this // one, here". It was on the paper in his hand instead of on the wall he // was looking at. This puts it where his eyes are. // // AND IT IS NOT A VERDICT. It appears for the wrong page just as readily, // because it is a preview of an act and not an opinion about it. if (house.filed == null) { if (!s.canPin || s.markHouse !== house.index) return; const held = s.handPage; const ghostArt = held?.kind === 'fragment' ? s.kept : null; const gf = ghostArt?.frames[held.kept]; const gb = nailBox(rect, x, y, ghostArt); if (!gf || !gb) return; c.globalAlpha = 0.5; c.drawImage(gf.lit, gb.px - gb.ink.x * gb.k, gb.py - gb.ink.y * gb.k, ghostArt.width * gb.k, ghostArt.height * gb.k); c.globalAlpha = 1; return; } // 3. AND THE PAGE HE PINNED INTO IT. // // It is the only acknowledgement filing gets and it must stay one. The // patch looks exactly the same whichever page went into it and whether or // not that was the right one — all five pages are one shape by design, so // the wall can never be read as a verdict. What answers is a person walking // up the street, and that takes as long as it takes. const art = s.kept; const frame = art?.frames[house.filed]; const b = nailBox(rect, x, y, art); if (!b || !frame) return; c.drawImage(frame.lit, b.px - b.ink.x * b.k, b.py - b.ink.y * b.k, art.width * b.k, art.height * b.k); } /** * THE KEY ON ITS HOOK, and the whole two-door-states idea is in this one * function. * * LIT is what the torch does: you can see in. OPEN is what the person does: * you can reach in. Both states draw the same key in the same place, and the * only difference is the halo — flat black on the lit doorway while the door * is shut, and the warm breathing pool once somebody has come home to it. * * THAT HALO IS NOT A NEW IDEA. It is what a dropped fragment on the ground * has always done, at the same numbers, and it already means exactly one * thing in this game: you can pick this up. So a key that starts wearing it * needs no message, no prompt and no drawn door — a player who has taken one * page off the ground has already been taught what it says. * * WHICH IS ALSO WHY NO DOOR IS DRAWN. A shut leaf across the arch would * cover the anvil or the loom or the rack, and the room's trade is the only * thing that says which house this is. The picture of "open" is a person * standing in the doorway who was not standing there a minute ago. */ function drawRoomKey(c, s, clip, house, x, y, clock) { if (house.keyTaken || !clip.keyAt || !s.keyArt) return; const rect = clip.keyAt[house.variant]; const art = s.keyArt; const frame = art.frames[0]; if (!rect || !frame) return; const [kx, ky, kw, kh] = rect; const ink = art.ink ?? { x: 0, y: 0, w: art.width, h: art.height }; const k = Math.min(kw / ink.w, kh / ink.h); const w = ink.w * k; const h = ink.h * k; const px = x + kx + (kw - w) / 2; // IT HANGS ON ITS OWN PLATE OF LIGHT, and that is not decoration. // // The key is black line work hanging inside the doorway — where the anvil // and the oven and the buckets and the loom also are, and those are black // too. Measured: 48% of the key is drawn over the anvil, 87% over a bucket, // 96% over the oven dome and over the loom. A black key on a black oven is // not a dim key, it is no key, and the first people to play this never saw // one at all. // // THE LETTER SOLVED THIS ALREADY: it paints its own patch of doorway light // before drawing the page, "because on a dark wall it came out // dark-on-dark". Same fix, same value. The key is now always a black shape // on light, whatever it is hanging in front of. // // AND IT SWINGS WHEN IT IS YOURS. A warm halo is this game's word for "you // can pick this up", but a warm halo on an already-bright doorway barely // moves the picture — the same mistake as a white bolt on a white sky. What // reads against any background is motion. const swing = house.open ? Math.sin(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ) * 4 : 0; const py = y + ky + (kh - h) / 2 + swing; c.fillStyle = DOORLIGHT; c.fillRect(Math.round(px - 7), Math.round(py - 7), Math.round(w + 14), Math.round(h + 14)); if (house.open) { // The same breath the dropped pages take, off the same tuning values, so // the two can never drift into meaning different things. const pulse = 1 - TORCH.KEPT_PULSE_DEPTH * (0.5 - 0.5 * Math.cos(clock * Math.PI * 2 * TORCH.KEPT_PULSE_HZ)); const cx = px + w / 2; const cy = py + h / 2; const r = Math.max(w, h) * TORCH.KEPT_GLOW_R; const g = c.createRadialGradient(cx, cy, 0, cx, cy, r); g.addColorStop(0, `rgba(${TORCH.KEPT_RGB},${(TORCH.KEPT_GLOW * pulse).toFixed(3)})`); g.addColorStop(1, `rgba(${TORCH.KEPT_RGB},0)`); const was = c.globalCompositeOperation; c.globalCompositeOperation = 'lighter'; c.fillStyle = g; c.fillRect(cx - r, cy - r, r * 2, r * 2); c.globalCompositeOperation = was; } c.drawImage(frame.lit, px - ink.x * k, py - ink.y * k, art.width * k, art.height * k); } // The keys, in the order a hand finds them. Not a narrator: it names buttons, // which is the one thing in this game that is allowed to be told rather than // discovered, because a button that cannot be found does not exist. // // "E take" AND NOTHING ELSE. It said "E take/MARK" until MARK stopped being // a thing: E is one verb now and what it does is decided by where he is // standing, which is the whole point. A line naming a mode that no longer // exists is worse than no line. const HELP = 'A/D or arrows walk — hold SPACE torch — F swing — E take' + ' — hold Q inventory, arrows scroll — M mute — R reset — ` readout'; // Keyboards only. On a touch screen every one of these is a labelled button // already, and a line about SPACE and backtick is noise on a device with // neither. const hasKeyboard = !(typeof matchMedia === 'function' && matchMedia('(hover: none) and (pointer: coarse)').matches); /** * ONE LINE, AT THE FOOT OF THE FRAME, and it gets out of the way of reading. * * The band is bottom-anchored and grows upward over this, so rather than * fight it for the space they take turns: while there is a page open the keys * fade out, because a player reading a letter is not looking for the swing * button. Everything else in the game is discovered; this is the doorway in. */ function drawHelp(c, bandAlpha) { const alpha = 0.55 * (1 - Math.min(1, bandAlpha * 1.6)); if (!hasKeyboard || alpha <= 0.02) return; // MEASURED DOWN TO FIT, never wrapped. It is one line or it is not this // line — the readout's help row ran 329px off the right edge once and the // only part anybody needed was the part past the edge. const room = W - 48; let size = 21; c.font = `${size}px ui-monospace, Menlo, Consolas, monospace`; const wide = c.measureText(HELP).width; if (wide > room) { size = Math.floor(size * room / wide); c.font = `${size}px ui-monospace, Menlo, Consolas, monospace`; } c.save(); c.globalAlpha = alpha; c.fillStyle = 'rgba(226, 208, 178, 1)'; c.fillText(HELP, 24, H - 16); c.restore(); } /** * A LINE OF THE READOUT, legible on anything underneath it. * * It was black at 65%, which is fine over the open sky at the start of the * level and invisible by the end of it: the canopy closes as he walks in, and * black text on black leaves is no text. White alone has the opposite * problem — the sky runs to luminance 205 near the road. * * So: white, cut out of a dark stroke. Costs one extra pass per line, reads * over sky, leaves, ground and the black gate alike, and this is the one * instrument the game has. */ function readoutLine(c, text, x, y) { // Saved, because this is the only place in the renderer that sets a wide // lineWidth and everything after it would inherit one. c.save(); // TOP, not baseline, so `y` is the top of the ink and the first line can sit // hard against the edge of the frame. Measuring from a baseline leaves a // quarter of the font's height of air above it that nobody asked for. c.textBaseline = 'top'; c.lineJoin = 'round'; c.lineWidth = 4; c.strokeStyle = 'rgba(0,0,0,0.85)'; c.strokeText(text, x, y); c.fillStyle = 'rgba(255,255,255,0.94)'; c.fillText(text, x, y); c.restore(); } /** Screen y the ground TILE is drawn at, so its surface lands on GROUND_Y. */ function groundTop(clip) { return GROUND_Y - (clip.pivotY || 0); } function drawGround(c, clip, mode, x0, x1, camX = 0) { const tileW = clip.width; const top = groundTop(clip); // Tile in WORLD space so the torn edge stays pinned to the ground as the // camera moves, rather than sliding along with the screen. // // Integer-aligned on purpose. The camera position is interpolated and so // lands on fractions; drawing tiles at fractional x leaves a hairline seam // between them where the smoothing does not quite meet. A sub-pixel camera // is not perceptible at walking speed, a one-pixel gap in the ground is. const cam = Math.round(camX); const start = Math.floor((x0 + cam) / tileW) * tileW - cam; const img = clip.frames[0][mode]; for (let x = start; x < x1; x += tileW) { c.drawImage(img, x, top); // The tile is shorter than the gap below the ground line, so the rest is // filled flat. The art only has to carry the torn top edge. if (top + clip.height < H) { c.fillStyle = '#000'; c.fillRect(x, top + clip.height, tileW, H - top - clip.height); } } } function drawDemo(scene, alpha, stats) { const s = scene.state; const px = lerp(s.prince.prevX, s.prince.x, alpha); const dx = lerp(s.drained.prevX, s.drained.x, alpha); const radius = lerp(s.torch.prevRadius, s.torch.radius, alpha); const cx = lerp(s.torch.prevCx, s.torch.cx, alpha); const cy = lerp(s.torch.prevCy, s.torch.cy, alpha); const handX = lerp(s.torch.prevHandX, s.torch.handX, alpha); const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha); const armEase = lerp(s.torch.prevEased ?? 0, s.torch.eased ?? 0, alpha); const fade = lerp(s.prevFade, s.fade, alpha); const torchUp = !s.prince.hasSword; ctx.setTransform(1, 0, 0, 1, 0, 0); // --- Pass 1: the dark world ----------------------------------------- ctx.fillStyle = BG_DARK; ctx.fillRect(0, 0, W, H); drawGround(ctx, s.ground, 'dark', 0, W); if (s.drained.active) drawClip(ctx, s.drained.clip, s.drained.frame, 'dark', dx, GROUND_Y); drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, 0, 1, s.prince.eyeDim); drawTorchArm(ctx, s, 'dark', px, 0, 1, armEase); if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY); // --- Pass 2: the lit reveal ------------------------------------------ if (radius > 0.5) { const x0 = Math.max(0, Math.floor(cx - radius) - 2); const y0 = Math.max(0, Math.floor(cy - radius) - 2); const x1 = Math.min(W, Math.ceil(cx + radius) + 2); const y1 = Math.min(H, Math.ceil(cy + radius) + 2); const rw = x1 - x0; const rh = y1 - y0; if (rw > 0 && rh > 0) { octx.setTransform(1, 0, 0, 1, 0, 0); octx.save(); octx.beginPath(); octx.rect(x0, y0, rw, rh); octx.clip(); octx.clearRect(x0, y0, rw, rh); octx.fillStyle = BG_LIT; octx.fillRect(x0, y0, rw, rh); if (y1 > GROUND_Y) drawGround(octx, s.ground, 'lit', x0, x1); // The Drained is the only thing with lit art in v1. if (s.drained.active) drawClip(octx, s.drained.clip, s.drained.frame, 'lit', dx, GROUND_Y); // The prince is not lit by his own torch. His body stays silhouette for // the whole demo. (spec 5.3) drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y, 0, 1, s.prince.eyeDim); drawTorchArm(octx, s, 'lit', px, 0, 1, armEase); if (torchUp) drawClip(octx, s.flame.clip, s.flame.frame, 'lit', handX, handY); octx.globalCompositeOperation = 'destination-in'; octx.setTransform(radius, 0, 0, radius, cx, cy); octx.fillStyle = maskGradient; octx.fillRect(-1, -1, 2, 2); octx.setTransform(1, 0, 0, 1, 0, 0); octx.globalCompositeOperation = 'source-over'; octx.restore(); ctx.drawImage(off, x0, y0, rw, rh, x0, y0, rw, rh); } // The only warmth in the demo. Keep it subtle. ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.setTransform(radius * GLOW_SPREAD, 0, 0, radius * GLOW_SPREAD, cx, cy); ctx.fillStyle = glowGradient; ctx.fillRect(-1, -1, 2, 2); ctx.restore(); } ctx.setTransform(1, 0, 0, 1, 0, 0); if (fade > 0) { ctx.fillStyle = `rgba(0,0,0,${fade})`; ctx.fillRect(0, 0, W, H); } if (showDebug) { ctx.font = '20px ui-monospace, Menlo, Consolas, monospace'; const lines = [ `${stats.fps} fps draw ${stats.drawMs.toFixed(2)}ms panics ${stats.panics}`, `phase ${s.phase} loop ${s.loopElapsed.toFixed(1)}s` + (s.lastLoopLength ? ` last ${s.lastLoopLength.toFixed(1)}s` : ''), `torch ${(s.torch.raise * 100).toFixed(0)}% r ${radius.toFixed(1)} sword ${s.prince.hasSword ? 'out' : 'away'}`, `drained ${s.drained.revealed ? 'REVEALED' : 'unseen'} ${s.drained.speed.toFixed(0)}px/s` + (s.drained.stun > 0 ? ' knocked back' : '') + (s.prince.attacking ? ' swinging' : ''), 'hold left = torch · tap right = swing (SPACE / F / R reset / backtick hides this)', ]; lines.forEach((line, i) => readoutLine(ctx, line, 24, 8 + i * 26)); } } // --- the level ----------------------------------------------------------- // Same two passes, same gradients, same offscreen. The only new idea is that // world coordinates are converted to screen once, here, using the camera. function drawLevel(scene, alpha, stats) { const s = scene.state; const camX = lerp(s.camera.prevX, s.camera.x, alpha); const px = lerp(s.prince.prevX, s.prince.x, alpha); const radius = lerp(s.torch.prevRadius, s.torch.radius, alpha); const cx = lerp(s.torch.prevCx, s.torch.cx, alpha) - camX; const cy = lerp(s.torch.prevCy, s.torch.cy, alpha); const handX = lerp(s.torch.prevHandX, s.torch.handX, alpha); const handY = lerp(s.torch.prevHandY, s.torch.handY, alpha); const armEase = lerp(s.torch.prevEased ?? 0, s.torch.eased ?? 0, alpha); const face = s.prince.facing; const torchUp = !s.prince.hasSword; // The weight going out of the bad leg. Half a limp is the rhythm, which the // scene does by hurrying one half of the cycle; the other half is this — // he drops a few px while that foot is down. const hurt = Math.max(0, s.prince.hits - TUNING.LIMP_FROM); const limpDip = (hurt > 0 && s.prince.moving && s.prince.frame >= s.prince.clip.count / 2) ? Math.min(TUNING.LIMP_DIP_PX, TUNING.LIMP_DIP_PX * (hurt / 3)) : 0; ctx.setTransform(1, 0, 0, 1, 0, 0); // --- Pass 1: the dark world ------------------------------------------ // Measured separately from pass 2. The dirty-rect win that took the demo // from 34 to 60fps was measured on a STATIC pass 1; a scrolling five-layer // background redraws the whole frame every tick, so that result does not // carry over and this build has to state its own numbers. const t1 = performance.now(); // Before either pass draws a watcher. Both passes read the same spans, so // the torch cannot show a watcher the dark pass faded. s.background.keepClear?.(camX, laneSpans, backLaneSpans(s, camX, alpha), s.clock ?? 0); // The sky IS the background now, not a flat fill. It is the only thing on // screen above luminance 160, and without it a silhouette has nothing to // read against. s.background.drawSky(ctx, camX, W); // Shafts sit ON the sky and fade as the canopy eats it — the same curve // that drives the canopy and the watcher density. The light runs out. depth.drawShafts(ctx, camX, W, GROUND_Y, 1 - depthAt(px)); depth.drawParticlesBehind(ctx, camX, W, H, s.clock); stats.bgDrawn = s.background.drawFar(ctx, camX, W, H, 'dark', px); drawSkySink(ctx, s.prince.x); drawGround(ctx, s.ground, 'dark', 0, W, camX); drawBolt(ctx, s); drawVillage(ctx, s, camX, 'dark'); drawTree(ctx, s, camX, 'dark'); drawFlash(ctx, s.flash ?? 0); drawAllDrained(ctx, s, 'dark', camX, alpha); drawClip(ctx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim); drawSheet(ctx, s, 'dark', px, camX, face); drawTorchArm(ctx, s, 'dark', px, camX, face, armEase); if (torchUp) drawClip(ctx, s.flame.clip, s.flame.frame, 'dark', handX, handY, camX, face); drawFog(ctx, camX, s.clock ?? 0); stats.bgDrawn += s.background.drawNear(ctx, camX, W, H, 'dark', px); depth.drawParticlesFront(ctx, camX, W, H, s.clock); stats.pass1Ms += (performance.now() - t1 - stats.pass1Ms) * 0.1; const t2 = performance.now(); // --- Pass 2: the lit reveal ------------------------------------------- if (radius > 0.5) { const x0 = Math.max(0, Math.floor(cx - radius) - 2); const y0 = Math.max(0, Math.floor(cy - radius) - 2); const x1 = Math.min(W, Math.ceil(cx + radius) + 2); const y1 = Math.min(H, Math.ceil(cy + radius) + 2); const rw = x1 - x0; const rh = y1 - y0; if (rw > 0 && rh > 0) { octx.setTransform(1, 0, 0, 1, 0, 0); octx.save(); octx.beginPath(); octx.rect(x0, y0, rw, rh); octx.clip(); octx.clearRect(x0, y0, rw, rh); // Sky first, unchanged, so the circle passing over it does nothing — // a torch does not light the sky. s.background.drawSky(octx, camX, W); octx.fillStyle = BG_LIT; const gTop = groundTop(s.ground) - 4; octx.fillRect(x0, gTop, rw, H - gTop); // Each layer decides what it hands the lit pass. The far ones hand over // their dark art, so the circle passes across them and changes nothing. s.background.drawFar(octx, camX, W, H, 'lit', px); if (y1 > GROUND_Y) drawGround(octx, s.ground, 'lit', x0, x1, camX); // And here the doors open. This is the one thing in the level the // torch reveals that is not a person. drawVillage(octx, s, camX, 'lit'); drawTree(octx, s, camX, 'lit'); // He is never lit by his own torch, so he goes in with his DARK art — // the same trick the far layers use. // The Drained are the thing the torch is for. drawAllDrained(octx, s, 'lit', camX, alpha); drawClip(octx, s.prince.clip, s.prince.frame, 'dark', px, GROUND_Y + limpDip, camX, face, s.prince.eyeDim); // He is never lit by his own torch. The SHEET is — it is the one thing // he carries that the light is for. drawSheet(octx, s, 'lit', px, camX, face); drawTorchArm(octx, s, 'lit', px, camX, face, armEase); if (torchUp) drawClip(octx, s.flame.clip, s.flame.frame, 'lit', handX, handY, camX, face); // The same bank of puffs, in the same places, in both passes. Without // this the torch punches a clean round hole in the mist, which reads as // a bug — and a torch is exactly where real mist shows up most. drawFog(octx, camX, s.clock ?? 0); s.background.drawNear(octx, camX, W, H, 'lit', px); octx.globalCompositeOperation = 'destination-in'; octx.setTransform(radius, 0, 0, radius, cx, cy); octx.fillStyle = maskGradient; octx.fillRect(-1, -1, 2, 2); octx.setTransform(1, 0, 0, 1, 0, 0); octx.globalCompositeOperation = 'source-over'; octx.restore(); ctx.drawImage(off, x0, y0, rw, rh, x0, y0, rw, rh); } // Dim everything the light is not on. Four rects for the world outside // the circle's box, and the falloff gradient inside it — far cheaper than // masking a full-screen overlay, and it is the same shape as the light so // the two edges agree. const dimA = TORCH.DIM * (s.torch.raise); if (dimA > 0.002) { const R = radius * GLOW_SPREAD; const bx0 = cx - R, by0 = cy - R, bx1 = cx + R, by1 = cy + R; ctx.fillStyle = `rgba(${TORCH.DIM_RGB},${dimA})`; if (by0 > 0) ctx.fillRect(0, 0, W, Math.min(by0, H)); if (by1 < H) ctx.fillRect(0, Math.max(by1, 0), W, H - Math.max(by1, 0)); const ty = Math.max(0, by0), th = Math.min(H, by1) - ty; if (th > 0) { if (bx0 > 0) ctx.fillRect(0, ty, Math.min(bx0, W), th); if (bx1 < W) ctx.fillRect(Math.max(bx1, 0), ty, W - Math.max(bx1, 0), th); } ctx.save(); ctx.globalAlpha = s.torch.raise; ctx.setTransform(R, 0, 0, R, cx, cy); ctx.fillStyle = dimGradient; ctx.fillRect(-1, -1, 2, 2); ctx.restore(); } ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.setTransform(radius * GLOW_SPREAD, 0, 0, radius * GLOW_SPREAD, cx, cy); ctx.fillStyle = glowGradient; ctx.fillRect(-1, -1, 2, 2); ctx.restore(); } stats.pass2Ms += (performance.now() - t2 - stats.pass2Ms) * 0.1; ctx.setTransform(1, 0, 0, 1, 0, 0); // Over the finished frame, torch included. This is the cue most likely to // spend budget invisibly: it touches every pixel, including the quarter of // the frame that is pure black, and noise over black lands some of it // above 30. // Vignette before grain: the grain is a film artefact and film grain sits on // top of the exposure, not under it. drawVignette(ctx); depth.drawGrain(ctx, W, H); // The words. Over the vignette and the grain, because it is a page being // held up in front of the world rather than a thing in it — and under the // fade, because the ending takes everything with it. drawBand(ctx, s.band?.text, s.band?.alpha ?? 0); // AND THE KEYS, ALWAYS. This was the last line of the debug readout, which // meant the one thing a new player actually needs was behind the same // toggle as the frame timings — and off by default on a phone, where // `?debug=0` is the only way to get rid of the rest of it. drawHelp(ctx, s.band?.alpha ?? 0); // Through the gate and the playthrough is over. No prompt, no score screen, // and no mention of the empty slot — the gate wanted four and it got four. if (s.fade > 0) { ctx.fillStyle = `rgba(0,0,0,${Math.min(1, s.fade)})`; ctx.fillRect(0, 0, W, H); } if (showDebug) { // LEGIBLE ON A PHONE. The canvas is a fixed 1920 scaled to fit, so a 20px // line comes out about four pixels across on a handset — which made the // one instrument this game has useless on exactly the device that has no // console and no backtick key to toggle it with. // // AND IT HAS TO STAY OUT OF THE PICTURE. Thirteen lines at 20px and 1.3 // line height reached 40% down the frame — the readout was sitting on the // thing it was reporting on. 16px at 1.14 keeps the same information in // roughly half the height. const shrink = ctx.canvas.clientWidth ? W / ctx.canvas.clientWidth : 1; const fs = Math.round(16 * Math.min(2.0, Math.max(1, shrink * 0.5))); const step = fs * 1.1; ctx.font = `${fs}px ui-monospace, Menlo, Consolas, monospace`; // Against the GATE, not the road. Measuring him against LEVEL_LENGTH_PX // reads past 100% for most of the level now that the road is an approach. const pct = (s.prince.x / (s.gateStopX || TUNING.LEVEL_LENGTH_PX) * 100).toFixed(1); const standing = s.village ? s.village.filter((d) => d.state !== 'DOWNED').length : 0; const doors = (s.houses ?? []).map((h) => (h.seen ? h.kind : '·')).join(' '); const nearKept = s.drained.some((d) => d.keptDropped && !d.keptTaken && Math.abs(d.keptX - s.prince.x) <= TUNING.PICKUP_REACH); [ `${stats.fps} fps pass1 ${stats.pass1Ms.toFixed(2)}ms pass2 ${stats.pass2Ms.toFixed(2)}ms trees ${stats.bgDrawn}`, `x ${s.prince.x.toFixed(0)} / ${s.gateStopX ?? TUNING.LEVEL_LENGTH_PX} (${pct}%) cam ${camX.toFixed(0)} canopy ${(depthAt(s.prince.x) * 100).toFixed(0)}%`, // The numbers V1 is judging, on screen while you judge them. // The village's width and crossing time used to be spelled out here every // frame. They are constants — a fact you read once, not an instrument. `${s.prince.x < s.bowlX0 ? 'road' : 'village'}` + ` shapes ${standing}/${s.village ? s.village.length : 0} up, ${s.villageGap ?? 0}px apart`, // NO HEALTH BAR ON SCREEN. This line is for judging the build, not for // playing it — the player is meant to read it off how he is standing. `hits ${s.prince.hits}/${TUNING.PRINCE_HITS}` + (s.prince.hits >= TUNING.LIMP_FROM ? ' limping' : '') + ` swing x${TUNING.SWING_SLOW[Math.min(s.prince.hits, TUNING.SWING_SLOW.length - 1)].toFixed(2)}` + (s.run ? ` run ${s.run.seconds.toFixed(0)}s fights ${s.run.fights}` + ` took ${s.run.taken} hit ${s.run.hitsTaken} deaths ${s.run.deaths}` + ` WATCHING ${s.run.watching.toFixed(1)}s` : ''), `keys ${s.prince.keys ?? 0} in hand` + ` home ${s.houses ? s.houses.filter((h) => h.open).length : 0}/4` + ` walking ${s.drained ? s.drained.filter((d) => d.homing).length : 0} ` + `gate ${s.gate ? `${s.gate.filled}/${TUNING.GATE_WANTS}` : '—'}` + (s.gate?.open >= 1 ? ' OPEN' : ' shut') + (s.gate?.through ? ' — through' : '') + ` doors lit: ${doors || '—'}` + (s.sheaf?.up ? ` INVENTORY ${s.sheaf.index + 1}/${s.sheaf.pages.length}` : ` [Q] inventory (${s.sheaf?.pages.length ?? 0})`), `${face > 0 ? '→' : '←'} ${s.prince.moving ? 'walking' : 'still'} sword ${s.prince.hasSword ? 'out' : 'away'}` + ` torch ${(s.torch.raise * 100).toFixed(0)}% r ${radius.toFixed(1)}` + ` ${s.prince.clip.name} f${s.prince.frame}` + (s.prince.attacking ? ' SWINGING' : ''), s.drained.filter((d) => d.answers) .map((d) => `${d.state[0]}${d.hits}:${d.answers}`).join(' ') + (s.loss.active ? ' — LOSS' : ''), // An object taken in the dark is listed as "?". He has it; he never // looked at it, so he does not know whose it was. `carrying ${s.prince.inventory.length}: ` + (s.prince.inventory.length ? s.prince.inventory.map((it) => (it.seen ? it.answers : '?')).join(', ') : '—') + (s.canPin ? ' [E] PIN IT HERE' : s.canMark ? ' [E] take it back down' : s.canPlaceKey ? ` [E] put a key in (${s.prince.keys} in hand)` : s.canTakeKey ? ' [E] take the key' : nearKept ? ' [E] take' : '') + // ONLY WHEN IT IS NOT WORKING. Spelled out because a phone has no // console and no backtick key — "running / playback" and still silent // on an iPhone means the ringer switch, not the game. Once it is // playing there is nothing to say, and it was the longest line here. (s.audio && !(s.audio.enabled && s.audio.ready) ? ` sound ${s.audio.enabled ? 'waiting for a keypress' : 'MUTED'}` + ` [${s.audio.state}/${s.audio.session}]` : ''), // WHAT HE HAS CLAIMED, against what he is carrying. Its own line — // fillText draws one per array entry and swallows a newline inside one. // Five slots, and the empty one is the finding. `filed ${s.houses.map((hh) => (hh.filed == null ? '—' : `${hh.kind}<${hh.filed}`)).join(' ')}`, // Only when somebody is actually tuning it from the address bar. DEPTH.label ? `depth: ${DEPTH.label}` + (DEPTH.OCC > 1 ? ` occ ${DEPTH.OCC}x` : '') + (DEPTH.PART > 0 ? ` part ${DEPTH.PART}${DEPTH.PART_HARD ? ' hard' : ' soft'} (${depth.stats.particles} drawn)` : '') + (DEPTH.BLUR > 0 ? ` blur ${DEPTH.BLUR}` : '') + (DEPTH.GRAIN > 0 ? ` grain ${DEPTH.GRAIN}` : '') + (DEPTH.SHAFT > 0 ? ` shafts ${depth.stats.shafts}` : '') : '', ] // WRAPPED, BECAUSE ONE OF THESE RAN OFF THE SCREEN AND IT WAS THE ONE // THAT MATTERED. The help line overflowed the right edge by 329px, so // "arrows page" — the only thing in the game that says how to turn a // page — was drawn past the end of the canvas and nobody ever saw it. // // The readout grows with the build and its font grows on a small // display, so a line that fits today does not stay fitting. Wrapping on // the separators it already uses costs nothing and closes the whole // class of it. .flatMap((line) => { const max = W - 48; if (ctx.measureText(line).width <= max) return [line]; const out = []; let cur = ''; for (const part of String(line).split(' — ')) { const next = cur ? `${cur} — ${part}` : part; if (cur && ctx.measureText(next).width > max) { out.push(cur); cur = part; } else cur = next; } out.push(cur); return out; }) .filter((line) => line !== '') .forEach((line, i) => readoutLine(ctx, line, 20, 4 + i * step)); } } return { drawDemo, drawLevel, depth, /** Measuring code calls this. Never leave it off for the player. */ setDebug(v) { showDebug = !!v; }, /** For checks.js, which composes a backdrop-only frame. */ drawVignette, get debugShown() { return showDebug; }, }; }