diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 5ee11b7..ccd63c3 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -352,9 +352,9 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab - functions: glowIndex, thermIdx, pipeCellColor, pipeBodyCell, thinPipeColor, beltLike, buildSky, initRender, resizeRender, viewCells, updateCamera, snapCamera, screenToWorld, worldToScreenPx, renderMonoFont - variables: R, GLOW_W, GLOW_R, GLOW_G, GLOW_B, THERM, PC, TREE_R, TREE_G, TREE_B, BG, SKY_R, SKY_G, SKY_B -### src/render/light.js (79 lines) +### src/render/light.js (95 lines) Propagated coloured light from glowing and burning cells. -- functions: computeLight +- functions: builtForLight, computeLight ### src/render/world.js (308 lines) Scale colour without changing the smooth-look class. @@ -509,6 +509,7 @@ The frame loop and boot. - `test/t_econ.js`: Economy: the dock (instant sale), orders, prices, the market dip, money for machines, alloys. - `test/t_fog.js`: Fog: surface, sight, range, saves and simulation independence. - `test/t_furnace_repeat.js`: The starter furnaces keep working when fired again and again without cleaning, however the charge goes in: mixed, ore first with coal on top, or blown in through the vent. Each case plays furnace_play.js, which builds the design and fires it like a player. +- `test/t_light.js`: Run the renderer's lighting against real simulation data, with only canvas storage stubbed. - `test/t_load.js`: Reload wakes nearby areas first, without losing distant water, fire or saved state. - `test/t_molds.js`: Molds and furnaces stay usable: ingots drop below and keep their stubs, plugs over molds are cleared, spoiled castings can be knocked out, grains never drop through a grate by teleporting, and slag lets the fire breathe. - `test/t_nozzle.js`: Blowing aims within the nozzle's reach, with room for the brush and settling. diff --git a/docs/agent-log.md b/docs/agent-log.md index 39365b5..598a997 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,15 @@ What agents have done, newest first. Add an entry for every piece of work: the d note or request it was for, what changed, the files touched, the tests run, and anything left open or not verified. Keep each entry short; the git history has the details. +## 2026-10-25: t37: buildings cast no shadows (Claude Code, Codex) + +- `computeLight()` (`src/render/light.js`): a per-view-column light roof that skips player-built + cells (`builtForLight`: unnatural solids, block, machine or part cells), and built cells pass + light like air. `drawWorld()` stops marking built cells as shading terrain for the smooth look. + The simulation's `roofY` is untouched (built roofs still condense steam). +- `test/t_light.js`; screenshots `shots/t37-before-*.png`, `shots/t37-after-*.png`. + `run_all.py --browser` ALL PASSED. + ## 2026-10-25: t34: the nozzle blows no further than it sucks (Claude Code, Codex) - `toolBlow()` (`src/sim/vacuum.js`) aims at the capped point from `nozzle()` when the cursor is diff --git a/src/render/light.js b/src/render/light.js index 57f271d..eeca94a 100644 --- a/src/render/light.js +++ b/src/render/light.js @@ -2,6 +2,9 @@ // ---------- Lighting ---------- // Light is computed on a coarse grid (2 or 4 grains per cell), written into a // small canvas, then the GPU upscales it smoothly and multiplies it over the world. +function builtForLight(i, x, y) { + return (PH[mat[i]] === PH_SOLID && !(flags[i] & F_NAT)) || blockP[(y >> 2) * BW + (x >> 2)] || mach[i] || part[i]; +} function computeLight() { const res = SET.lighting === 'fast' ? 4 : 2; const M = Math.ceil(28 / res); @@ -13,6 +16,7 @@ function computeLight() { R.lightC.width = lw; R.lightC.height = lh; R.lightImg = R.lightX.createImageData(lw, lh); R.lightPx = new Uint32Array(R.lightImg.data.buffer); + R.lightRoof = new Int32Array(lw); } R.lx0 = lx0; R.ly0 = ly0; const lr = R.lr, lg = R.lg, lb = R.lb, la = R.latt; @@ -20,15 +24,27 @@ function computeLight() { const P = res; const aAir = Math.pow(0.978, P), aGas = Math.pow(0.96, P), aLiq = Math.pow(0.93, P), aSol = Math.pow(0.9, P); const skyK = SET.daylight * (1 - 0.32 * (R.rain || 0)); // overcast while it rains + // Only sampled view columns, starting at the simulation roof; nothing below the view matters. + const roofEnd = Math.min(H, (ly0 + lh) * res), lightRoof = R.lightRoof; + for (let lx = 0; lx < lw; lx++) { + const x = (lx0 + lx) * res; + if (x < 0 || x >= W) continue; + let y = roofY[x]; + for (; y < roofEnd; y++) { + const i = y * W + x, ph = PH[mat[i]]; + if ((ph === PH_SOLID || ph === PH_POWDER || ph === PH_LIQUID) && !builtForLight(i, x, y)) break; + } + lightRoof[lx] = y; + } for (let ly = 0; ly < lh; ly++) { const wy = (ly0 + ly) * res; for (let lx = 0; lx < lw; lx++) { const wx = (lx0 + lx) * res, k = ly * lw + lx; if (wx < 0 || wy < 0 || wx >= W || wy >= H) { la[k] = 0; lr[k] = lg[k] = lb[k] = 0; continue; } const i = wy * W + wx, m = mat[i], ph = PH[m]; - la[k] = ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol; + la[k] = builtForLight(i, wx, wy) ? aAir : ph === PH_EMPTY ? (bgm[i] ? aGas : aAir) : ph === PH_GAS ? aGas : ph === PH_LIQUID ? aLiq : m === GLASS || m === ICE ? aGas : aSol; let r = 0, g = 0, b = 0; - if (wy < roofY[wx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; } + if (wy < lightRoof[lx]) { r = skyK; g = skyK * 0.98; b = skyK * 0.94; } else if (wy < surfY[wx] && ph !== PH_SOLID) { const v = skyK * 0.9; r = v; g = v * 0.98; b = v * 0.95; } // emission from heat: hottest grain in the block let T = 0; diff --git a/src/render/world.js b/src/render/world.js index 1a8052c..255371e 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -219,7 +219,7 @@ function drawWorld() { else cl = 5; } top = cl << 24; - fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 || cl === 4 || cl === 6 ? 0xff : 0); + fpx[row + bx] = (tb ? 0xff000000 : 0) | (cl === 5 ? 0xff0000 : 0) | (cl === 3 ? 0xff00 : 0) | (cl === 1 && !builtForLight(i, x, y) ? 0xff : 0); } if (fv < 255) { const k = fv / 255; r *= k; g *= k; b *= k; diff --git a/test/t_light.js b/test/t_light.js new file mode 100644 index 0000000..b25acb6 --- /dev/null +++ b/test/t_light.js @@ -0,0 +1,63 @@ +// Run the renderer's lighting against real simulation data, with only canvas storage stubbed. +const fs = require('fs'); +const path = require('path'); +const { load, suite, flatWorld } = require('./harness'); +const { run } = load(); +const { check, done } = suite(); +for (const f of ['setup.js', 'light.js']) run(fs.readFileSync(path.join(__dirname, '../src/render', f), 'utf8')); +run(` + const SET = { lighting: 'full', daylight: 1, ambient: 0.12 }; + R.lightC = {}; + R.lightX = { createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }), putImageData() {} }; + R.vx0 = 0; R.vy0 = 100; R.bw = 320; R.bh = 80; + function lightAt(a, x, y) { return a[(Math.floor(y / R.lres) - R.ly0) * R.lw + Math.floor(x / R.lres) - R.lx0]; } +`); +flatWorld(run, 320, 200, 160); +run(` + bot.lamp = false; + for (let bx = 8; bx < 30; bx++) placeBlock(BP_WALL, RSTONE, bx, 20, true); + for (let by = 21; by < 39; by++) { + placeBlock(BP_WALL, RSTONE, 8, by, true); + placeBlock(BP_WALL, RSTONE, 29, by, true); + } + placeBlock(BP_BELT, 1, 12, 30, true); + placeBlock(BP_TUBE, 0, 14, 30, true); + addMachine('silo', 18, 30, 0); + addMold('bit', 24, 30, RSTONE, true); + for (let y = 80; y < 88; y++) for (let x = 180; x < 280; x++) setCell(y * W + x, STONE, AMB[y], F_NAT); + updateRoof(); + const simRoof = Array.from(roofY); +`); +for (const mode of ['full', 'fast']) { + run(`SET.lighting = '${mode}'; computeLight();`); + for (const channel of ['lr', 'lg']) { + const [inside, open, rock, ground, openGround] = run(`[lightAt(R.${channel}, 80, 144), lightAt(R.${channel}, 152, 144), lightAt(R.${channel}, 232, 144), lightAt(R.${channel}, 80, 160), lightAt(R.${channel}, 152, 160)]`); + check(`${mode} ${channel}: under a built roof matches open daylight`, Math.abs(inside / open - 1) < 0.03, `${inside} / ${open}`); + check(`${mode} ${channel}: ground under the base matches outside`, Math.abs(ground / openGround - 1) < 0.03, `${ground} / ${openGround}`); + check(`${mode} ${channel}: natural overhang still shades`, rock < open * 0.95, `${rock} / ${open}`); + } + // Directly check transmission as daylight can hide an opaque piece in the open. + for (const [name, x, y] of [['wall', 32, 120], ['belt', 48, 120], ['tube', 56, 120], ['machine', 72, 120], ['mold', 96, 120]]) { + const a = run(`lightAt(R.latt, ${x}, ${y})`); + check(`${mode}: ${name} transmits like air`, Math.abs(a - Math.pow(0.978, mode === 'fast' ? 4 : 2)) < 1e-6, a); + } +} +check('lighting leaves the simulation roof unchanged', run('simRoof.every((y, x) => roofY[x] === y) && roofY[80] === 80')); +for (const material of ['STONE', 'SAND', 'WATER']) { + run(`setCell(96 * W + 80, ${material}, AMB[96], F_NAT); updateRoof(); computeLight();`); + check(`${material} above the view still blocks daylight`, run('lightAt(R.lr, 80, 144) < 0.95')); + run('setCell(96 * W + 80, EMPTY, AMB[96], 0);'); +} +run('updateRoof(); computeLight();'); +check('removing an obstruction restores daylight', run('lightAt(R.lr, 80, 144) === 1')); +for (const field of ['blockP[24 * BW + 20]', 'mach[96 * W + 80]', 'part[96 * W + 80]']) { + run(`setCell(96 * W + 80, STONE, AMB[96], F_NAT); ${field} = 1; updateRoof(); computeLight();`); + check(`${field}: built contents do not shade or absorb light`, run('lightAt(R.lr, 80, 144) === 1 && Math.abs(lightAt(R.latt, 80, 96) - Math.pow(0.978, 4)) < 1e-6')); + run(`${field} = 0; setCell(96 * W + 80, EMPTY, AMB[96], 0);`); +} +run('updateRoof();'); +run(`SET.daylight = 0; temp[140 * W + 80] = 1500; computeLight();`); +check('hot material inside the base lights its surroundings', run('lightAt(R.lr, 88, 140) > 0.2')); +run(`temp[140 * W + 80] = AMB[140]; bot.x = 72; bot.y = 130; bot.lamp = true; computeLight();`); +check('the bot lamp still lights the base', run('lightAt(R.lr, 80, 140) > 0.2')); +done();