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