astara / src /render.js
system's picture
system HF Staff
77dd24d: A source check that checks, and a dev server that answers
45cc183 verified
Raw History Blame Contribute Delete
89.4 kB
// 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; },
};
}