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