// The forest, in layers. // // THE TWO-SIDED VALUE RULE, measured off Blade Warrior. See PALETTE in // tuning.js for the numbers. // // 1. Nothing lives between luminance 30 and 90. A layer is either pure black // or it is well clear of black. The middle is banned. // 2. Something substantial has to sit above 160, or a silhouette has nothing // to read against. // // The build this replaces broke both: 30.4% of the frame in the forbidden zone, // and not one pixel above 160. Three near-identical dark greys under a flat // dead sky. That was the mud. // // So there are three values and black: a sky range, one mid band for the hills, // and everything from the watchers inward pure black. What separates a watcher // from a trunk is SHAPE, not tone — in Blade Warrior the foreground reeds are // the same black as the hero and are never once confused with him, because thin // spikes and solid human mass are different grammars. // // WHAT GETS LIT. The torch's mask is a flat screen-space circle with no depth // information, so "that layer is far away" does not protect it. Each layer says // which art it hands to pass 2; the ones that must not be lit hand over their // dark art, the same trick the prince uses to stay a silhouette inside his own // torchlight. import { TUNING, BOWL, PALETTE, SKY, depthAt, DEPTH, TONE, TERRACE, atLuminance, luminance } from './tuning.js'; const SKY_TILE_W = 2048; /** Blend two [r,g,b] palette entries. */ const mix3 = (a, b, t) => [ a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, ]; const rgb = (hex) => { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; }; /** Deterministic 0..1 from an integer. No state, no list, no allocation. */ function hash01(i, salt) { let h = Math.imul((i | 0) ^ salt, 2654435761) >>> 0; h ^= h >>> 15; h = Math.imul(h, 2246822507) >>> 0; h ^= h >>> 13; return (h >>> 0) / 4294967296; } /** * The sky, built once into a tiling texture. * * A dithered field quantised to three palette entries rather than a smooth * gradient, plus a moon and two cloud masses. Cloud masses are placed with * wrap-around so the tile is seamless, because at 0.06x parallax the seam would * otherwise cross the screen once during a full level. */ function buildSky(h) { const c = document.createElement('canvas'); c.width = SKY_TILE_W; c.height = h; const x = c.getContext('2d'); const img = x.createImageData(SKY_TILE_W, h); const d = img.data; const low = rgb(PALETTE.SKY_LOW); const high = rgb(PALETTE.SKY_HIGH); const cloud = rgb(PALETTE.CLOUD); // NO CLOUD BLOBS. Soft radial gradients in a sky read as glowing orbs, not as // cloud — they look like light sources sitting in mid-air. // // The brightness is a horizontal BAND instead, brightest just above the // horizon and falling away both up and down. That is where it is wanted // anyway: the figures stand against the horizon, so the light belongs behind // them rather than at the top of the frame. It also leaves the zenith darker, // which is where the canopy arrives from. const peakY = SKY.CENTRE * h; const spread = SKY.SPREAD * h; // The moon is measured in PIXELS, not in tile fractions. In fractions it // comes out an ellipse, because the tile is not square. const moonX = 0.17 * SKY_TILE_W; const moonY = 0.15 * h; const moonR = 52; for (let py = 0; py < h; py++) { // One band of brightness sitting just above the horizon. const t0 = (py - peakY) / spread; const v0 = SKY.BASE + SKY.PEAK * Math.exp(-t0 * t0); for (let px = 0; px < SKY_TILE_W; px++) { const v = v0; let mdx = px - moonX; if (mdx > SKY_TILE_W / 2) mdx -= SKY_TILE_W; if (mdx < -SKY_TILE_W / 2) mdx += SKY_TILE_W; const md = Math.hypot(mdx, py - moonY); // a crescent: the disc, minus a disc offset up and to the right const shadow = Math.hypot(mdx - moonR * 0.46, py - moonY + moonR * 0.34); let vv = v; if (md < moonR && shadow > moonR * 0.86) vv = 1.4; else if (md < moonR) vv = Math.max(v, 0.62); // CONTINUOUS TONE. This used to quantise to three palette entries through // an ordered 4x4 Bayer matrix at 3px cells, to imitate a 16-colour Amiga // game. It produced a visible quilt, and the maths says why: where the // gradient sits halfway between two entries a Bayer threshold is a // perfect checkerboard, so the sky carried a 6px weave with the matrix // repeating every 12px. Measured at 0.765 autocorrelation at lag 6. // // Limbo is the opposite aesthetic and that is the direction now. The // gradient is written straight, interpolating the same three palette // entries instead of choosing between them — the colours and the band // shape are unchanged, only the steps are gone. const col = vv < 0.5 ? mix3(low, high, vv / 0.5) : mix3(high, cloud, Math.min(1, (vv - 0.5) / 0.5)); const i = (py * SKY_TILE_W + px) * 4; d[i] = col[0]; d[i + 1] = col[1]; d[i + 2] = col[2]; d[i + 3] = 255; } } x.putImageData(img, 0, 0); return c; } /** * Recolour a black silhouette once, at load, into a cached bitmap. * * The old build alpha-blended black onto the background to land on a target * grey. That works for greys and not for the blue, because the hills are not a * darker version of the sky — they are a different mix. Tinting once costs * nothing per frame. */ export async function tintFrames(clip, hex, which = 'dark') { // An undefined colour would leave fillStyle untouched and silently paint the // previous one, which is how this quietly did nothing the first time. if (typeof hex !== 'string') throw new Error(`tintFrames: no colour for "${clip.name}"`); const out = []; for (const f of clip.frames) { const c = document.createElement('canvas'); c.width = clip.width; c.height = clip.height; const x = c.getContext('2d'); x.drawImage(f[which], 0, 0, clip.width, clip.height); x.globalCompositeOperation = 'source-in'; x.fillStyle = hex; x.fillRect(0, 0, c.width, c.height); out.push(await createImageBitmap(c)); } return out; } export async function createBackground(clips, groundY, viewH) { const sky = buildSky(viewH); /** * Blur, and the constraint IS the finding. * * Hills (97) against sky (163) is a gradient from 97 to 163 — entirely above * 90, so it costs nothing. Canopy (0) against sky runs straight through the * forbidden zone, and so does any black trunk, actor or ground. * * So: the hills and the sky, and nothing else, ever. * * Baked once when the strip is made. The strip is already blitted once per * layer per frame, so at runtime this is free. */ const blurred = (src, px) => { if (!px) return src; const pad = Math.ceil(px * 3); const cv = document.createElement('canvas'); cv.width = src.width + pad * 2; cv.height = src.height + pad * 2; const x = cv.getContext('2d'); x.filter = `blur(${px}px)`; // Drawn three times across the seam so a tiling strip still tiles: a blur // at the edge would otherwise pull in transparent pixels and leave a seam. x.drawImage(src, pad - src.width, pad); x.drawImage(src, pad, pad); x.drawImage(src, pad + src.width, pad); x.filter = 'none'; const out = document.createElement('canvas'); out.width = src.width; out.height = src.height; out.getContext('2d').drawImage(cv, -pad, -pad); return out; }; /** * Haze: fade a layer toward the sky it sits against. The further away, the * more of the air is between you and it. * * Toward SKY_HIGH rather than toward the sky's exact local value, because the * sky is a gradient and a layer is one bitmap — SKY_HIGH is what sits behind * the horizon band where the hills actually are. */ const haze = (hex, amount) => { if (!amount) return hex; const a = rgb(hex), b = rgb(PALETTE.SKY_HIGH); const m = mix3(a, b, Math.min(1, amount)).map((v) => Math.round(v)); return `#${m.map((v) => v.toString(16).padStart(2, '0')).join('')}`; }; /** * Soften and re-hue a whole clip's frames, once, at load. * * The canopy and the near trunks are PURE BLACK, which is exactly why neither * could be blurred before: a soft edge from 0 to a 163 sky runs straight * through the old forbidden zone. In Limbo the extreme foreground is out of * focus and only the boy is sharp, so this is the cue that was most obviously * missing and most firmly banned. * * Warmth is a hue change at the SAME darkness, never a lift. A lighter trunk * in front of a black boy tints instead of blocks, which reads as a rendering * fault, and pure black against a lit background is what says "close". */ const soften = (clip, blurPx, warm) => { if (!clip || (!blurPx && !warm)) return null; // BOTH passes. The near trunks hand the lit pass their own art, so softening // only the dark one left the torch revealing a sharp trunk inside a blurred // one — the defocus would switch off wherever the light landed. const one = (f, which) => { const cv = document.createElement('canvas'); cv.width = clip.width; cv.height = clip.height; const g = cv.getContext('2d'); g.drawImage(f[which], 0, 0); if (warm) { g.globalCompositeOperation = 'source-in'; // Luminance about 12 — still black at a glance, warm not neutral. g.fillStyle = `rgb(${Math.round(24 * warm)},${Math.round(14 * warm)},${Math.round(7 * warm)})`; g.fillRect(0, 0, cv.width, cv.height); g.globalCompositeOperation = 'source-over'; } return blurPx ? blurred(cv, blurPx) : cv; }; return clip.frames.map((f) => ({ dark: one(f, 'dark'), lit: one(f, 'lit') })); }; const hillsTint = await tintFrames(clips.bg_treeline, haze(PALETTE.HILLS, TONE.HILLS_HAZE), 'dark'); const watcherTint = await tintFrames(clips.drained_watcher, haze(PALETTE.WATCHERS, TONE.WATCHER_HAZE), 'dark'); let canopySoft = soften(clips.bg_canopy, Math.round(TONE.CANOPY_BLUR), 0); let trunkSoft = soften(clips.bg_trunks_near, Math.round(TONE.TRUNK_BLUR), TONE.TRUNK_WARM); /** * Rebuild the softened strips. Blurring is a load-time cost, so this is not * something to call per frame — but it has to exist, because judging a soft * foreground means A/B-ing it, and reloading the page between every pair is * how a sweep stops happening. */ function setSoften(canopyBlur, trunkBlur, trunkWarm) { canopySoft = soften(clips.bg_canopy, Math.round(canopyBlur), 0); trunkSoft = soften(clips.bg_trunks_near, Math.round(trunkBlur), trunkWarm); layers.find((l) => l.key === 'canopy').soft = canopySoft; layers.find((l) => l.key === 'trunks_near').soft = trunkSoft; } // Sharp originals kept, so the toggle is reversible without a reload. const sharpSky = sky; const sharpHills = hillsTint[0]; let skyStrip = sharpSky; let hillsStrip = sharpHills; let blurPx = -1, blurSkyPx = -1; // Separately, because they turn out to cost completely different things: the // hills are an edge against the sky and blurring them is nearly free, while // the sky is a vertical gradient and blurring it smears the bright band down // into the dark end and throws bright mass away. function setBlur(px, skyPx = px) { if (px === blurPx && skyPx === blurSkyPx) return; blurPx = px; blurSkyPx = skyPx; skyStrip = blurred(sharpSky, skyPx); hillsStrip = blurred(sharpHills, px); layers.find((l) => l.key === 'hills').tinted = [hillsStrip]; } // How much sky is open above a given spot on the walk. // // The sky itself has no horizontal variation any more — it is a band, and // blobs in it read as orbs hanging in mid-air. So the only thing that makes // one stretch brighter than another is the CANOPY: its bottom edge is // ragged, and where a tooth hangs low there is less sky behind a figure. // // Measured once, off the canopy bitmap, into a per-column profile of where // its ink ends. Encounter placement reads this so fights happen under the // open patches, which is the one thing Blade Warrior does that costs nothing. /** Topmost ink row per column of a clip's dark art. */ const topProfile = (cl) => { if (!cl) return null; const cv = document.createElement('canvas'); cv.width = cl.width; cv.height = cl.height; const g = cv.getContext('2d', { willReadFrequently: true }); g.drawImage(cl.frames[0].dark, 0, 0); const px = g.getImageData(0, 0, cl.width, cl.height).data; const prof = new Float32Array(cl.width); for (let x = 0; x < cl.width; x++) { let top = cl.height; for (let y = 0; y < cl.height; y++) { if (px[(y * cl.width + x) * 4 + 3] > 16) { top = y; break; } } prof[x] = top; } return prof; }; const hillProfile = topProfile(clips.bg_treeline); // Which rows of the sky are actually BRIGHT. Open sky below the band is still // sky, but a silhouette does not read against it, so placement must not count // it. Measured off the built texture rather than re-deriving the gradient, // so it cannot drift from what is on screen. const brightBand = (() => { const g = document.createElement('canvas'); g.width = 1; g.height = sky.height; const gx = g.getContext('2d', { willReadFrequently: true }); gx.drawImage(sky, 0, 0); const px = gx.getImageData(0, 0, 1, sky.height).data; let top = sky.height, bot = 0; for (let y = 0; y < sky.height; y++) { const i = y * 4; if (0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2] > 160) { if (y < top) top = y; bot = y; } } return top <= bot ? { top, bot } : { top: 0, bot: sky.height }; })(); const canopyProfile = (() => { const cl = clips.bg_canopy; if (!cl) return null; const cv = document.createElement('canvas'); cv.width = cl.width; cv.height = cl.height; const cx2 = cv.getContext('2d', { willReadFrequently: true }); cx2.drawImage(cl.frames[0].dark, 0, 0); const px = cx2.getImageData(0, 0, cl.width, cl.height).data; const prof = new Float32Array(cl.width); for (let x = 0; x < cl.width; x++) { let low = 0; for (let y = cl.height - 1; y >= 0; y--) { if (px[(y * cl.width + x) * 4 + 3] > 16) { low = y; break; } } prof[x] = low; } return prof; })(); // THE BACK LANE KEEPS ITS OWN SPACE. // // A Drained standing beside the road and a watcher standing in the trees are // the same drawing at two sizes, and where one stood in front of the other // the two outlines ran together into a single shape with too many arms. No // placement can prevent it: the watchers scroll at 0.65 and the lane at 1.0, // so every watcher slides behind every Drained somewhere along the walk. // // So the renderer hands over, each frame, the screen span each back-lane // figure covers, and a watcher that comes within CLEAR_MARGIN of one fades // out before the two can touch, and back in once it is clear. // // FADED OVER TIME, NOT OVER DISTANCE. The first version faded by how close // it was, which is right while he walks and wrong the moment he stops: a // watcher parked inside the fade band stayed half there, a ghost, for as // long as he stood still. Now each one is either wanted or not, and walks // its alpha there over CLEAR_FADE_S. const CLEAR_MARGIN = 40; const CLEAR_FADE_S = 0.25; const CLEAR_MAX = 32; const clear = { camX: 0, clock: -1, n: 0, spans: new Float32Array(CLEAR_MAX * 3) }; const watcherFade = new Map(); // watcher slot -> alpha const watcherInk = (() => { const cl = clips.drained_watcher; const ink = cl && cl.ink; return ink ? { l: ink.x - cl.pivotX, r: ink.x + ink.w - cl.pivotX } : { l: -40, r: 40 }; })(); /** Whether a watcher at screen x should be showing, given the back lane. */ function watcherWanted(sx, sc) { const wl = sx + watcherInk.l * sc; const wr = sx + watcherInk.r * sc; for (let k = 0; k < clear.n; k++) { if (!(clear.spans[k * 3 + 2] > 0)) continue; const gap = Math.max(clear.spans[k * 3] - wr, wl - clear.spans[k * 3 + 1]); if (gap < CLEAR_MARGIN) return 0; } return 1; } /** How visible watcher slot i is right now. 0..1. */ function watcherAlpha(i, sx, sc) { const a = watcherFade.get(i); return a === undefined ? watcherWanted(sx, sc) : a; } /** * Whether watcher slot i has anyone in it, and if so where and how big. * ONE ROLL, used by the draw and by sample(). The two drifting apart would * make the watcher check measure a layer that is not on screen. */ function watcherRoll(l, i) { // Density at this watcher's own world position, not the prince's. // // And thinner again inside the village, on top of what the clearing // already takes off. Six of them on screen plus the shapes standing // about read as a crowd, and this place has been empty sixty years — // the crowd belongs to the trees, not to the street. const wx0 = (i * l.cell) / l.rate; const density = depthAt(wx0) * (wx0 >= BOWL.X0 && wx0 <= BOWL.X1 ? TUNING.BOWL_WATCHERS : 1); if (hash01(i, 0x5eed) >= density) return null; return { wx: i * l.cell + hash01(i, 0x77f1) * l.jitter, variant: Math.floor(hash01(i, 0x1b9d) * l.clip.count) % l.clip.count, sc: l.scale * (0.86 + hash01(i, 0x3a5f) * 0.28), }; } // --- the terraces ----------------------------------------------------------- // What they are and why they look like this is in TERRACE, in tuning.js. const HILLS_DRAWN = haze(PALETTE.HILLS, TONE.HILLS_HAZE); const hexMix = (a, b, t) => { const m = mix3(rgb(a), rgb(b), t).map((v) => Math.round(Math.max(0, Math.min(255, v)))); return `#${m.map((v) => v.toString(16).padStart(2, '0')).join('')}`; }; const lighter = (hex, d) => atLuminance(hex, luminance(hex) + d); const laneTop = groundY - TUNING.BACK_LANE_RISE; /** The edge of step k, near to far. Step 0 is the back lane itself. */ const stepTop = (k) => laneTop - TERRACE.STEPS[k].above; const STEP_COL = TERRACE.STEPS.map((S) => ({ wall: hexMix(atLuminance(TERRACE.WALL, TERRACE.WALL_LUM + S.wallUp), HILLS_DRAWN, S.blend), lip: hexMix(atLuminance(TERRACE.FIELD, TERRACE.WALL_LUM + S.wallUp + S.lip), lighter(HILLS_DRAWN, 3), S.blend), shadow: hexMix(atLuminance(TERRACE.WALL, TERRACE.SHADOW_LUM), HILLS_DRAWN, S.blend * 0.5), })); // Under the torch the first step is a retaining wall of laid stone, the way // the road under it is earth with stones in it. const LIT_WALL = '#a39684'; const LIT_LIP = '#c9b89c'; /** * One flat step, drawn fresh: a handful of fields per screen, each a few px * up or down from the next on the steps that wobble. The field edge first, * then the wall under it, lipH lower. */ function drawStep(c, k, camX, pass, viewW, viewH2) { const S = TERRACE.STEPS[k]; const rate = S.rate ?? layers.find((l) => l.key === 'watchers').rate; const shift = camX * rate; const top = stepTop(k); const lit = pass === 'lit' && k === 0; const SEG = S.seg ?? 520; const j0 = Math.floor((shift - 60) / SEG); const j1 = Math.ceil((shift + viewW + 60) / SEG); // Three fills of the same edge, each a little lower: the field top, a // two-pixel shadow where the wall starts, then the wall. for (let layer = 0; layer < 3; layer++) { const dy = layer === 0 ? 0 : layer === 1 ? S.lipH : S.lipH + 2; c.beginPath(); c.moveTo(j0 * SEG - shift, viewH2); for (let j = j0; j <= j1; j++) { const off = S.wobble ? Math.round((hash01(j, 0x51 + k * 97) - 0.5) * 2 * S.wobble) : 0; const xa = j * SEG - shift; const y = top + off + dy; c.lineTo(xa, y); // Grass on the field edge, on the back lane's own step only. if (S.tufts && layer === 0) { const n = 1 + Math.floor(hash01(j, 0x77) * 3); for (let t = 0; t < n; t++) { const tx = xa + SEG * (t + 0.3 + 0.4 * hash01(j * 13 + t, 0x99)) / n; const h = 5 + 7 * hash01(j * 17 + t, 0x3c); c.lineTo(tx - 3, y); c.lineTo(tx - 1, y - h); c.lineTo(tx + 1, y - h * 0.6); c.lineTo(tx + 3, y); } } c.lineTo(xa + SEG, y); } c.lineTo((j1 + 1) * SEG - shift, viewH2); c.closePath(); c.fillStyle = layer === 0 ? (lit ? LIT_LIP : STEP_COL[k].lip) : layer === 1 ? (lit ? '#000' : STEP_COL[k].shadow) : (lit ? LIT_WALL : STEP_COL[k].wall); c.fill(); } if (lit) { c.strokeStyle = '#000'; c.lineWidth = 2; c.beginPath(); for (let j = Math.floor((shift - 50) / 46); j * 46 - shift < viewW + 20; j++) { const x = j * 46 - shift + hash01(j, 0xab) * 14; const row = hash01(j, 0xcd) < 0.5 ? 0 : 1; c.moveTo(x, top + S.lipH + 2 + row * 22); c.lineTo(x, top + 28 + row * 22); } c.moveTo(0, top + 30); c.lineTo(viewW, top + 30); c.stroke(); } return 1; } /** * The rows of fields up the hills' lower slopes, baked once into a strip the * width of one hills tile and drawn with the hills, at their rate — they are * ON the hills, so they move with them. Each row is the ridge again, scaled * down toward the third step: flatter and browner the lower it is, closer to * the hills' own colour the higher. The top of every hill is left alone. */ let rowsTile = null; function buildRowsTile(hl) { const sc = hl.scale ?? 1; const cl = hl.clip; const tileW = Math.round(cl.width * sc); const hillsTop = hl.baseY - cl.pivotY * sc; const bottom = stepTop(2); const ridgeAt = (x) => hillsTop + hillProfile[Math.min(cl.width - 1, Math.floor(x / sc))] * sc; let y0 = bottom; for (let x = 0; x < tileW; x += 2) y0 = Math.min(y0, bottom - (bottom - ridgeAt(x)) * TERRACE.ROWS[0] - 4); y0 = Math.floor(y0) - 2; const cv = document.createElement('canvas'); cv.width = tileW; cv.height = Math.max(1, viewH - y0); const g = cv.getContext('2d'); TERRACE.ROWS.forEach((h, k) => { const t = 1 - h / TERRACE.ROWS[0]; // 0 on the top row, near 1 on the bottom const face = hexMix(HILLS_DRAWN, STEP_COL[2].wall, 0.25 + 0.7 * t); const lip = lighter(face, 3 + 4 * t); const lipH = 3 + 3 * t; const fieldW = 150 + 90 * t; // lower down is nearer, so wider const yAt = (x) => { const off = Math.round((hash01(Math.floor(x / fieldW), 0x61 + k * 7) - 0.5) * 6); return Math.min(bottom, bottom - (bottom - ridgeAt(x)) * h + off) - y0; }; for (const [col, dy] of [[lip, 0], [face, lipH]]) { g.beginPath(); g.moveTo(0, cv.height); for (let x = 0; x <= tileW; x += 3) g.lineTo(x, yAt(Math.min(x, tileW - 1)) + dy); g.lineTo(tileW, cv.height); g.closePath(); g.fillStyle = col; g.fill(); } }); return { img: cv, y: y0, w: tileW }; } function drawRows(c, camX, viewW) { const hl = layers.find((l) => l.key === 'hills'); if (!hl || !hillProfile) return 0; rowsTile ??= buildRowsTile(hl); const shift = Math.round(camX * hl.rate); const start = Math.floor(shift / rowsTile.w) * rowsTile.w - shift; let n = 0; for (let x = start; x < viewW; x += rowsTile.w) { c.drawImage(rowsTile.img, x, rowsTile.y); n++; } return n; } const layers = [ { key: 'hills', clip: clips.bg_treeline, rate: 0.15, kind: 'tile', scale: 1.25, baseY: groundY + 24, tinted: hillsTint, litMode: 'dark', order: 0, }, // The fields on the hills' slopes. They are the hills, so they move with // them; see drawRows. { key: 'terrace_rows', kind: 'rows', rate: 0.15, order: 0.5 }, { // Overhead foliage is NEAR, so it scrolls fast even though it is a // background element. Slow would read as a distant ceiling. key: 'canopy', clip: clips.bg_canopy, rate: 0.55, soft: canopySoft, kind: 'canopy', scale: 1, litMode: 'dark', order: 1, }, { key: 'terrace_3', kind: 'terrace', step: 2, order: 1.9 }, { // Background people. They never move, they never react, and because this // layer hands the lit pass its DARK art the torch can never reach one — // a watcher stays a black shape for ever and the player will not learn // who a single one of them was. // // Density rides the SAME depth curve as the canopy. One curve, two // systems: the forest fills up at exactly the rate the sky runs out. // Existence is rolled per cell against the density at that cell's OWN // world position, so a watcher that is there is always there — walking // back and forth cannot make one appear or vanish. key: 'watchers', clip: clips.drained_watcher, rate: 0.65, kind: 'watchers', cell: 200, jitter: 185, scale: 0.44, // Their feet sit ABOVE the ground line, not on it. Shrinking them alone // reads as small people standing next to him; standing them further up // the frame is what reads as distance. // // AND ON THE THIRD TERRACE, which scrolls at their own rate. They stood // on nothing at 26px up; with the back lane on the first step at 56 they // were higher up the screen than the drained in front of them. tinted: watcherTint, baseY: stepTop(2), litMode: 'dark', order: 2, }, // The two nearer steps, in front of the crowd. The first IS the back lane. { key: 'terrace_2', kind: 'terrace', step: 1, order: 2.5 }, { key: 'terrace_1', kind: 'terrace', step: 0, order: 2.9 }, { // The near layer is what makes it read as a forest rather than a // backdrop. One trunk every few seconds is enough. key: 'trunks_near', clip: clips.bg_trunks_near, rate: 1.80, soft: trunkSoft, kind: 'scatter', cell: 2400, jitter: 1100, scale: 1, baseY: groundY + 210, litMode: 'lit', near: true, order: 9, }, ]; /** The sky itself, which is the background fill rather than a layer. */ function drawSky(c, camX, viewW) { const shift = Math.round(camX * 0.06) % SKY_TILE_W; const start = -((shift % SKY_TILE_W) + SKY_TILE_W) % SKY_TILE_W; for (let x = start; x < viewW; x += SKY_TILE_W) c.drawImage(skyStrip, x, 0); } function drawLayer(c, layer, camX, viewW, viewH2, pass, worldX) { if (layer.kind === 'rows') return drawRows(c, camX, viewW); if (layer.kind === 'terrace') return drawStep(c, layer.step, camX, pass, viewW, viewH2); const clip = layer.clip; if (!clip) return 0; const mode = pass === 'lit' ? layer.litMode : 'dark'; const shift = Math.round(camX * layer.rate); let drawn = 0; if (layer.kind === 'canopy') { // Slides down as he goes deeper. At depth 0 it is entirely off the top of // the frame; at depth 1 it has eaten nearly all the sky. const depth = depthAt(worldX); if (depth <= 0.001) return 0; const top = -clip.height + depth * TUNING.CANOPY_MAX_DROP; const img = layer.soft ? layer.soft[0][mode] : clip.frames[0][mode]; const tileW = clip.width; const start = Math.floor(shift / tileW) * tileW - shift; for (let x = start; x < viewW; x += tileW) { c.drawImage(img, x, top); drawn++; } return drawn; } if (layer.kind === 'tile') { const sc = layer.scale ?? 1; // The hills use the tinted bitmap; there is no lit variant of a mid band. const img = layer.tinted ? layer.tinted[0] : clip.frames[0][mode]; const tileW = Math.round(clip.width * sc); const start = Math.floor(shift / tileW) * tileW - shift; for (let x = start; x < viewW; x += tileW) { c.drawImage(img, x, layer.baseY - clip.pivotY * sc, tileW, clip.height * sc); drawn++; } return drawn; } if (layer.kind === 'watchers') { const { cell, scale } = layer; const margin = clip.width * scale; const first = Math.floor((shift - margin) / cell); const last = Math.ceil((shift + viewW + margin) / cell); const alpha0 = c.globalAlpha; for (let i = first; i <= last; i++) { const w = watcherRoll(layer, i); if (!w) continue; const sx = Math.round(w.wx - shift); if (sx < -margin || sx > viewW + margin) continue; // Out of the way of anyone standing in the back lane. See CLEAR_MARGIN. const a = watcherAlpha(i, sx, w.sc); if (a <= 0.01) continue; c.globalAlpha = alpha0 * a; // Never mirrored. Every one of them is already facing the way he is // coming from before it enters the frame. Nothing turns to look. // Always the tinted bitmap, in both passes. The torch cannot change // one of these and it cannot brighten one. c.drawImage(layer.tinted[w.variant], sx - clip.pivotX * w.sc, layer.baseY - clip.pivotY * w.sc, clip.width * w.sc, clip.height * w.sc); drawn++; } c.globalAlpha = alpha0; return drawn; } // Occlusion: more near-layer objects, same value, same rate. The cheapest // depth cue there is and it spends nothing — no new values, no soft edges. const { jitter, scale } = layer; const cell = layer.near ? layer.cell / Math.max(1, DEPTH.OCC) : layer.cell; const margin = clip.width * scale; const first = Math.floor((shift - margin) / cell); const last = Math.ceil((shift + viewW + margin) / cell); for (let i = first; i <= last; i++) { const wx = i * cell + hash01(i, 0x9e37) * jitter; const sx = Math.round(wx - shift); if (sx < -margin || sx > viewW + margin) continue; const variant = Math.floor(hash01(i, 0x85eb) * clip.count) % clip.count; const sc = scale * (0.86 + hash01(i, 0xc2b2) * 0.3); c.drawImage((layer.soft ? layer.soft[variant][mode] : clip.frames[variant][mode]), sx - clip.pivotX * sc, layer.baseY - clip.pivotY * sc, clip.width * sc, clip.height * sc); drawn++; } return drawn; } // Hills blur is tone now, not an experiment toggle. DEPTH.BLUR still adds on // top so the depth A/B keeps working. setBlur(Math.round(TONE.HILLS_BLUR + DEPTH.BLUR), Math.round(DEPTH.BLUR_SKY ?? 0)); return { layers, setBlur, setSoften, get blurPx() { return blurPx; }, drawSky, /** Everything behind the actors. */ drawFar(c, camX, viewW, viewH2, pass = 'dark', worldX = 0) { let n = 0; for (const l of layers) if (!l.near) n += drawLayer(c, l, camX, viewW, viewH2, pass, worldX); return n; }, /** Everything in front of the actors. */ drawNear(c, camX, viewW, viewH2, pass = 'dark', worldX = 0) { let n = 0; for (const l of layers) if (l.near) n += drawLayer(c, l, camX, viewW, viewH2, pass, worldX); return n; }, /** * How many screen pixels of bright SKY a fight at worldX would have behind * it. This is the placement score, and it has to account for all three * things that eat the bright band, because scoring on the canopy alone put * three encounters out of five BELOW the average of the spots they were * picked over: * * the canopy, closing down from the top * the hills, whose ridge line rises into it from below * a near trunk, pure black and full height, stood across the action * * Columns are sampled across the width the figures occupy, not one point. */ skyOpenAt(worldX, viewW = 1920) { const canopy = layers.find((l) => l.key === 'canopy'); const hills = layers.find((l) => l.key === 'hills'); const trunks = layers.find((l) => l.key === 'trunks_near'); const cc = canopy && canopy.clip; const hc = hills && hills.clip; const depth = depthAt(worldX); const camX = worldX - viewW / 2; const canopyTop = cc ? -cc.height + depth * TUNING.CANOPY_MAX_DROP : -1e6; const hSc = hills ? (hills.scale ?? 1) : 1; const hTileW = hc ? Math.round(hc.width * hSc) : 1; const hTop = hc ? hills.baseY - hc.pivotY * hSc : 1e6; // trunk columns to strike out entirely const blocked = []; if (trunks) { const shift = camX * trunks.rate; for (const wx of this.sample('trunks_near', shift - 600, shift + viewW + 600)) { blocked.push([wx - shift - trunks.clip.width / 2, wx - shift + trunks.clip.width / 2]); } } let open = 0; for (let sx = viewW / 2 - 300; sx <= viewW / 2 + 300; sx += 6) { if (blocked.some(([a, b]) => sx >= a && sx <= b)) continue; let top = 0; if (cc && depth > 0.001) { const col = Math.floor(((sx + camX * canopy.rate) % cc.width + cc.width) % cc.width); top = canopyTop + canopyProfile[col]; } let bottom = 1e6; if (hc && hillProfile) { const tx = ((sx + camX * hills.rate) % hTileW + hTileW) % hTileW; const col = Math.min(hc.width - 1, Math.floor(tx / hSc)); bottom = hTop + hillProfile[col] * hSc; } // Clip to the rows that are actually above luminance 160. const a = Math.max(top, brightBand.top); const b = Math.min(bottom, brightBand.bot); open += Math.max(0, b - a); } return open; }, /** * Screen y of the canopy's lowest edge over the spot a fight at worldX * would happen, averaged across the width of the action so one stray tooth * does not decide it. Smaller means the canopy is higher and there is more * bright sky behind the figures. Null before the canopy has closed at all. */ canopyBottomAt(worldX, viewW = 1920) { const cl = clips.bg_canopy; if (!canopyProfile || !cl) return null; const depth = depthAt(worldX); if (depth <= 0.001) return null; const top = -cl.height + depth * TUNING.CANOPY_MAX_DROP; const layerShift = (worldX - viewW / 2) * 0.55; let sum = 0, n = 0; for (let sx = viewW / 2 - 320; sx <= viewW / 2 + 320; sx += 16) { const col = Math.floor(((sx + layerShift) % cl.width + cl.width) % cl.width); sum += top + canopyProfile[col]; n++; } return sum / n; }, /** * The screen spans the back lane is standing in, for this frame. Flat * triples — left, right, weight — so handing them over allocates nothing. */ keepClear(camX, spans, n, clock = 0) { // A cut rather than a frame: a restart, a check staging a scene, a // reset clock. Everything goes straight to where it should be. const dt = clock - clear.clock; const cut = !(dt >= 0 && dt < 0.25) || Math.abs(camX - clear.camX) > 300; clear.camX = camX; clear.clock = clock; clear.n = Math.min(n, CLEAR_MAX); for (let k = 0; k < clear.n * 3; k++) clear.spans[k] = spans[k]; // Walk every watcher on screen toward wanted or not. Once per frame, // here, and not in the draw — the draw runs twice a frame, once for // each pass, and a check can call it again. const wl = layers.find((l) => l.key === 'watchers'); if (!wl) return; const shift = Math.round(camX * wl.rate); const margin = wl.clip.width * wl.scale; const step = dt > 0 ? dt / CLEAR_FADE_S : 0; for (let i = Math.floor((shift - margin) / wl.cell); i <= Math.ceil((shift + 1920 + margin) / wl.cell); i++) { const w = watcherRoll(wl, i); if (!w) continue; const want = watcherWanted(Math.round(w.wx - shift), w.sc); const was = watcherFade.get(i); if (cut || was === undefined) { watcherFade.set(i, want); continue; } watcherFade.set(i, want > was ? Math.min(want, was + step) : Math.max(want, was - step)); } }, /** * For the determinism check: trunk positions in a world range. * * For watchers, `shownOnly` leaves out any the back lane has faded, even * partly. A faded one is not a watcher on screen, and measuring its torso * measures whatever is behind it. */ sample(key, fromX, toX, shownOnly = false) { const l = layers.find((x) => x.key === key); if (!l) return []; const out = []; if (l.kind === 'watchers') { for (let i = Math.floor(fromX / l.cell); i <= Math.ceil(toX / l.cell); i++) { const w = watcherRoll(l, i); if (!w) continue; if (shownOnly && watcherAlpha(i, Math.round(w.wx - clear.camX * l.rate), w.sc) < 0.999) continue; out.push(Math.round(w.wx)); } return out; } if (l.kind !== 'scatter') return []; for (let i = Math.floor(fromX / l.cell); i <= Math.ceil(toX / l.cell); i++) { out.push(Math.round(i * l.cell + hash01(i, 0x9e37) * l.jitter)); } return out; }, }; }