diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index ccd63c3..3e67937 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -356,7 +356,7 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab Propagated coloured light from glowing and burning cells. - functions: builtForLight, computeLight -### src/render/world.js (308 lines) +### src/render/world.js (321 lines) Scale colour without changing the smooth-look class. - functions: fogColor, drawWorld - variables: TUBE_CLASS @@ -374,7 +374,7 @@ Smooth look: WebGL setup, texture uploads, drawing and adaptive resolution. - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - variables: SM, smBytes -### src/render/smooth-shaders.js (325 lines) +### src/render/smooth-shaders.js (330 lines) Smooth shaders: vertex and fragment programs for the rounded world look. - variables: SM_VS, SM_FS @@ -543,3 +543,4 @@ The frame loop and boot. - `tools/scenes/mold-labels.js`: Run after clear-sky.js: filling, blocked, set, and hot machinery in one view. - `tools/scenes/silo.js`: Run after clear-sky.js; leave paused so the open gate keeps its demonstration charge. - `tools/scenes/stone-uses.js`: The stone and sand machines: a half-full silo with its gate open over a belt, a stone cutter fed with rubble, and cut stone and glass brick walls. Run clear-sky.js first, e.g. python3 tools/shot.py /tmp/stone.png --setup tools/scenes/clear-sky.js --js "$(cat tools/scenes/stone-uses.js)" --ticks 240 --zoom 7 +- `tools/scenes/t33-ground.js`: Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket. diff --git a/docs/agent-log.md b/docs/agent-log.md index 598a997..5f0b0e7 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,19 @@ 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: t33: loose grains and solid ground look different (Claude Code, Codex) + +- Smooth look (`src/render/smooth-shaders.js`): natural ground gets a soft seam where it meets + loose grains as well as air; packed grains keep the pebble shading and are slightly lighter. + Pixel look and thermal lens (`drawWorld()` in `src/render/world.js`): loose grains a touch + lighter and speckled, solid cells touching loose grains darker. +- Smooth frame 1.08 -> 1.10 ms. `t_smooth.py` checks the seam and the lighter grains; + `tools/scenes/t33-ground.js` for screenshots (`shots/t33/`). +- play.py: 'F on an automatic ingot mold stays quiet' counted toasts on screen, and failed + now and then when an older toast expired or a new one arrived in the same 150 ms; it now + records the toasts the key press makes (none). `run_all.py --browser` ALL PASSED apart from + that check, which passes in the new form. + ## 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 diff --git a/src/render/smooth-shaders.js b/src/render/smooth-shaders.js index d448efb..7fca332 100644 --- a/src/render/smooth-shaders.js +++ b/src/render/smooth-shaders.js @@ -176,9 +176,11 @@ void main() { float band = 1.0 - smoothstep(0.5, 0.9, M); float lit = dot(nrm, normalize(vec2(-0.5, -1.0))); tc *= 1.0 + 0.42 * lit * band; - // the outline follows where ground and grains meet open space, never where rock meets gravel + // Keep the air outline; a softer, screen-width seam also separates rock from loose grains. float dm = (M - 0.5) / fwM; tc *= mix(0.4, 1.0, smoothstep(0.5, 1.4 + zp * 0.08, dm)); + float dt = (F.r - 0.5) / fwT; + if (nc == 1 && pw > 0.0) tc *= mix(0.65, 1.0, smoothstep(0.0, 1.5, dt)); C = mix(C, tc, smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT)); } @@ -200,10 +202,13 @@ void main() { } float cover = np == 4 ? 1.0 : 1.0 - smoothstep(1.0 - px / gr, 1.0 + px / gr, best); if (closed && F.r < 0.5) cover = 1.0; + // Pebbles stop at the rock face so they cannot paint over its thin seam. + cover *= 1.0 - smoothstep(-0.6, 0.6, (F.r - 0.5) / fwT); if (cover > 0.0) { float r2 = min(1.0, dot(gd, gd)); vec3 n3 = normalize(vec3(gd * 0.85, sqrt(1.0 - r2) + 0.25)); - float sh = np == 4 ? 0.9 + 0.08 * vn(w * 0.9) : 0.8 + 0.3 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6)))); + float sh = 0.98 + 0.18 * max(0.0, dot(n3, normalize(vec3(-0.45, -0.8, 0.6)))); + sh *= mix(1.0, 0.94, smoothstep(0.7 - px * 0.2, 1.15 + px * 0.2, best)); vec3 pc = (np == 4 ? (k0.rgb * wq.x + k1.rgb * wq.y + k2.rgb * wq.z + k3.rgb * wq.w) : gc) * sh; if (!closed) pc *= mix(1.0, 0.72, smoothstep(0.7, 1.0, best)); C = mix(C, pc, cover); diff --git a/src/render/world.js b/src/render/world.js index 255371e..bd4c611 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -59,12 +59,24 @@ function drawWorld() { } const i = yw + x, m = mat[i], nz = noiseB[i] - 128; const T = temp[i]; + // Also used by the thermal lens, which always draws in pixels. + let looseShade = 1; + if (!sm && m !== EMPTY && !builtForLight(i, x, y)) { + if (PH[m] === PH_POWDER) looseShade = 1.1 + nz * 0.00065; + else if (PH[m] === PH_SOLID && ( + (x > 0 && PH[mat[i - 1]] === PH_POWDER && !part[i - 1]) || + (x + 1 < W && PH[mat[i + 1]] === PH_POWDER && !part[i + 1]) || + (y > 0 && PH[mat[i - W]] === PH_POWDER && !part[i - W]) || + (y + 1 < H && PH[mat[i + W]] === PH_POWDER && !part[i + W]) + )) looseShade = 0.72; + } let r, g, b; if (lens) { const ti = thermIdx(T); if (m === EMPTY) { r = tl[ti] * 0.55; g = tl[ti + 1] * 0.55; b = tl[ti + 2] * 0.55; } else { const v = (COLR[m] + COLG[m] + COLB[m]) / 3 * 0.25; r = tl[ti] * 0.78 + v; g = tl[ti + 1] * 0.78 + v; b = tl[ti + 2] * 0.78 + v; } const p = part[i]; + r *= looseShade; g *= looseShade; b *= looseShade; if (p && ((x + y) & 1)) { r = r * 0.7 + 40; g = g * 0.7 + 40; b = b * 0.7 + 40; } if (fv < 255) { r *= fv / 255; g *= fv / 255; b *= fv / 255; } px[row + bx] = 0xff000000 | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); @@ -93,6 +105,7 @@ function drawWorld() { else { const v = nz * CVAR[m] * (sm ? (PH[m] === PH_SOLID ? 0 : grain * 0.35) : grain) / 128; r = COLR[m] + v; g = COLG[m] + v; b = COLB[m] + v; + r *= looseShade; g *= looseShade; b *= looseShade; if (m === FIRE) { const f = (life[i] & 15) * 6; r = 255; g = 120 + f; b = 30 + f * 0.5; } else if (m === WATER && !sm) { const s = ((x * 3 + y * 5 + (anim >> 2)) & 15) === 0 ? 14 : 0; r += s; g += s; b += s; } else if (m === BELTL || m === BELTR) { diff --git a/test/play.py b/test/play.py index 1f51797..9e5b9be 100644 --- a/test/play.py +++ b/test/play.py @@ -695,9 +695,13 @@ with sync_playwright() as p: pg.screenshot(path=str(out / '05-mold-ready.png')) check('three bars drop below the mold without F', ev('ents.every(e => e.m === BARCU && e.iy >= molds[0].y + molds[0].h)'), ev('ents.map(e => [e.ix, e.iy])')) pg.keyboard.press('2'); settle(60) - ev("GAME.status = ''; GAME.statusT = 0"); n_toasts = ev("$('toasts').children.length") - pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 3, ev('molds[0].y') + 3)); settle(150); pg.keyboard.press('f'); settle(150) - check('F on an automatic ingot mold stays quiet without a toast', ev("$('m-dialog').hidden") and ev("$('toasts').children.length") == n_toasts) + ev("GAME.status = ''; GAME.statusT = 0") + pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 3, ev('molds[0].y') + 3)); settle(150) + # count toasts made by the key press itself: counting the ones on screen also caught an older toast expiring + ev("window._toastsMade = []; window._toastFn = toast; toast = function (t, o) { _toastsMade.push(t); return _toastFn(t, o); }; 0") + pg.keyboard.press('f'); settle(150) + made = ev("(() => { toast = _toastFn; return _toastsMade; })()") + check('F on an automatic ingot mold stays quiet without a toast', ev("$('m-dialog').hidden") and made == [], made) ev("closeModal('m-dialog')") check('F with vacuum still uses what is in reach', ev("""(() => { $('toasts').innerHTML = ''; diff --git a/test/t_smooth.py b/test/t_smooth.py index 332f99d..ea91c1e 100644 --- a/test/t_smooth.py +++ b/test/t_smooth.py @@ -35,6 +35,47 @@ with sync_playwright() as p: ev("SET.look = 'smooth'"); pg.wait_for_timeout(1500) ev("SM.scale = 0.5"); pg.wait_for_timeout(1500) st = ev('[R.smoothOn, SM.scale, SM.canvas.width, R.canvas.width, SET.look, SM.ok, SM.why]'); check('half-resolution smooth still draws', (st[0] and st[2] == round(st[3] * st[1])) or st[1] > 0.5, st) + # Uniform colours isolate the solid/loose seam from material noise and lighting. + ground = ev('''() => { + GAME.paused = true; requestAnimationFrame = () => 0; + SET.lighting = 'off'; SET.glow = 'off'; SET.grain = 0; SET.zoom = 6; + resizeRender(); bot.x = 138; bot.y = 118; snapCamera(); + for (let y = 60; y < 220; y++) for (let x = 40; x < 240; x++) { + const i = y * W + x; + setCell(i, STONE, 22, F_NAT); bgm[i] = 0; noiseB[i] = 128; + } + const fill = m => { for (let y = 100; y < 140; y++) for (let x = 100; x < 180; x++) setCell(y * W + x, m, 22); }; + const at = (x, y) => { const c = R.px[(y - R.vy0) * R.bw + x - R.vx0]; return (c & 255) + (c >>> 8 & 255) + (c >>> 16 & 255); }; + const out = {}; + for (const lens of [false, true]) { + R.lens = lens; SET.look = 'pixel'; fill(RSTONE); drawWorld(); + const edge = at(140, 140), inside = at(140, 142), loose = at(140, 130); + fill(STONE); drawWorld(); + out[lens ? 'thermal' : 'pixel'] = [edge, inside, loose, at(140, 140)]; + } + R.lens = false; + const i = 140 * W + 140; + flags[i] = 0; fill(RSTONE); drawWorld(); const built = at(140, 140); + fill(STONE); drawWorld(); out.built = [built, at(140, 140)]; flags[i] = F_NAT; + fill(RSTONE); SET.look = 'smooth'; SM.scale = 1; drawWorld(); + const sample = (x, y) => { + const p = worldToScreenPx(x, y); + const c = R.ctx.getImageData(Math.floor(p.x), Math.floor(p.y), 1, 1).data; + return c[0] + c[1] + c[2]; + }; + out.smooth = [sample(140.5, 140.08), sample(140.5, 142.5)]; + const packed = []; + for (let y = 120; y < 121; y += 1 / 6) for (let x = 140; x < 141; x += 1 / 6) packed.push(sample(x, y)); + out.texture = Math.max(...packed) - Math.min(...packed); + return out; + }''') + for look in ['pixel', 'thermal']: + edge, inside, loose, solid = ground[look] + check(f'{look}: ground has a darker edge against loose grains', edge < inside * 0.85 and inside == solid, ground[look]) + check(f'{look}: loose rubble is lighter than solid stone', loose > inside, ground[look]) + check('built solids get no loose-grain outline', ground['built'][0] == ground['built'][1], ground['built']) + check('smooth: a thin darker seam separates ground and rubble', ground['smooth'][0] < ground['smooth'][1] * 0.9, ground['smooth']) + check('smooth: packed rubble keeps pebble shading', ground['texture'] > 30, ground['texture']) # a browser without hardware drawing gets the pixel look and a note pg2 = b.new_page(viewport={'width': 800, 'height': 500}) pg2.add_init_script('localStorage.clear();') diff --git a/tools/scenes/t33-ground.js b/tools/scenes/t33-ground.js new file mode 100644 index 0000000..2b1783a --- /dev/null +++ b/tools/scenes/t33-ground.js @@ -0,0 +1,24 @@ +// Paused dug pocket: copper ore, rubble and dirt against stone, granite and dirt. +// Use --js "..." to set daylight or R.lens after this setup; CAM frames the pocket. +const cx = 360, cy = 300; +for (let y = 0; y < 480; y++) for (let x = 100; x < 620; x++) { + const dx = x - cx, dy = y - cy; + const floor = 20 + Math.round(3 * Math.sin(dx * 0.11)); + const wall = Math.abs(dx) > 68 || dy < -42 || dy >= floor; + const ground = dx < -23 ? STONE : dx < 23 ? GRANITE : DIRT; + let m = wall ? ground : EMPTY; + if (!wall && dy > floor - 11 - Math.round(3 * Math.sin(dx * 0.18))) { + m = dx < -23 ? RSTONE : dx < 23 ? CUORE : LDIRT; + if ((x * 7 + y * 11) % 19 === 0) m = CUORE; + } + const i = y * W + x; + bgm[i] = 0; + setCell(i, m, 22, wall ? F_NAT : 0); +} +for (let x = 100; x < 620; x++) { + surfY[x] = 100; dirtY[x] = 180; graniteY[x] = 400; +} +updateRoof(); +window.CAM = [cx, cy]; +SET.daylight = 0; +SET.lighting = 'full';