diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 9a6f78d..f471d03 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -75,6 +75,7 @@ dist/, shots/ build output and screenshots (not committed) only those are simulated. `setCell` wakes what it touches; after writing arrays directly, call `wakeXY` / `wakeHeatXY`. - **Other state:** + - `fog`: exploration at one cell per 2×2 grains; see [fog](systems/fog.md); - `machines`: index = machine id; removed ones are `null`. Each is `{ type, x, y, w, h, dir, on, ... }`. Its footprint is hull cells (solid) and void cells (open inside). @@ -215,11 +216,16 @@ Starter furnace designs and the milestone list shown in the notebook. - functions: smelterBlueprint - variables: STARTER_DESIGNS, MILESTONES -### src/sim/world.js (96 lines) +### src/sim/world.js (97 lines) World state: the per-cell typed arrays, alloc(), ambient temperature by depth, waking. - functions: emit, bagCap, discover, alloc, depthU, depthY, computeAmbient, fillNoise, wakeXY, wakeHeatXY, wakeIdx, wakeHeatIdx - variables: W, H, N, CW, CH, NCH, mat, temp, life, flags, stamp, part, mach, digp, noiseB, airS, netId, isPow, occ, surfY, dirtY, graniteY, basaltY, magmaY, roofY, AMB, worldSeed, surfBase, underD, shallowD, lapse, BW, BH, NB, blockP, blockV, ents, bgm, flyers, ghostBlocks, ghostMolds, awake, hot, hotLvl, tick, tickStamp, machines, molds, nets, pcells, powerDirty, beltL, beltR, tubeLists, brokenMachines, ghostMachines, ghostBlocked, EV, bot, bag, bagTotal, player, seen, ledger, stats +### src/sim/fog.js (35 lines) +Exploration only: never changes grains or the random stream. +- functions: fogAlloc, fogMark, fogSeen, fogInit, fogLook, fogRevealAround, fogRevealAll +- variables: FOG, fog, FW, FH + ### src/sim/cells.js (176 lines) Cell helpers (setCell, move, enter) and the falling-sand movement of powders, liquids and gases (simMove). - functions: setCell, swapCells, move, enter, powder, passable, liquid, gas, simMove @@ -314,16 +320,16 @@ Weather: clouds from steam, rain and snow, and the world water count. - functions: wrnd, resetWeather, rainGap, worldWater, absorbSteam, spawnRain, rainAt, weatherTick - variables: WBAND, WS, weather -### src/sim/tick.js (25 lines) +### src/sim/tick.js (26 lines) simTick: one simulation step, in order. - functions: simTick -### src/sim/worldgen.js (315 lines) +### src/sim/worldgen.js (316 lines) World generation: noise, layers, ores, caves, water, trees, and clearing tiny hard specks. - functions: makeNoise, fbm, clearHardSpecks, generateWorld - variables: SPECK_MIN -### src/sim/save.js (112 lines) +### src/sim/save.js (120 lines) Saving and loading a world (serializeWorld / deserializeWorld) and old-save migration. - functions: serializeWorld, deserializeWorld - variables: SAVE_VERSION, TOFF @@ -337,16 +343,16 @@ Drawing (src/render/): browser only. This file: the renderer state R, colour tab Propagated coloured light from glowing and burning cells. - functions: computeLight -### src/render/world.js (277 lines) -drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look. -- functions: drawWorld +### src/render/world.js (297 lines) +Scale colour without changing the smooth-look class. +- functions: fogColor, drawWorld - variables: TUBE_CLASS ### src/render/machines.js (197 lines) Machine overlays: fans, engines, refiner, cutter, silo and the rest, drawn on top of the world. - functions: cellRect, drawHazardRims, drawMachines, drawSilo, drawSiloPixel -### src/render/overlays.js (228 lines) +### src/render/overlays.js (229 lines) Other overlays: floating text, rain and snow, flow arrows, filter tags, molds, the bot, particles. - functions: spawnFloater, drawRain, drawFlowArrows, drawRunOutline, drawFilterTags, drawFloaters, moldLabel, drawMolds, drawBot, spawnDebris, spawnSpark, spawnSuck, drawParticles @@ -379,7 +385,7 @@ Saves in IndexedDB, world files (export / import) and the blueprint library. Starting, loading and quitting a game; modal dialogs; the pause menu. - functions: showLoading, enterGame, startNewWorld, startFromSave, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds -### src/ui/settings.js (123 lines) +### src/ui/settings.js (124 lines) The settings screen. - functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe - variables: SET_TABS, setTab @@ -403,7 +409,7 @@ The selected tool and piece: R / Q / E, drill width, filter picker, the selectio - functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag - variables: lastBagKey -### src/ui/info.js (170 lines) +### src/ui/info.js (171 lines) Discovery cards, hover info (I) and the cursor label. - functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel @@ -447,6 +453,7 @@ The frame loop and boot. - `test/t_conveyors.js`: Screw tubes that never carry liquid (it flows through), tube and belt throughput, filters. - `test/t_drill.js`: The resonance drill: pulses from the bot shake rock until it shatters. Widths trade area for speed, tuning shatters only one material and drains it out of the wall, built blocks are left alone, and the drill says why when nothing breaks. - `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_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_pipes.js`: Pipes, pumps and outlets move liquids; the Refiner grinds waste rock for trace ore. diff --git a/docs/agent-log.md b/docs/agent-log.md index e3efa17..f7e0949 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,22 @@ 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-24: t8 Fog of war for unexplored areas (Claude Code, Codex) + +- Decided with the owner: revealed by line of sight; unseen is black with a soft edge. +- New `src/sim/fog.js` (2×2-grain cells, 0-255, only ever rises): `fogInit` (sky and surface + seen), `fogLook` (256 rays, 48 grains, through air, gas and liquid, 4 grains into rock), called + every 4 ticks from `simTick`. Never read by the simulation. +- `src/sim/save.js`: fog saved; saves without it rebuild it (surface, 12 grains around built + things, the bot's view). `src/sim/world.js`, `worldgen.js`: allocate and initialize. +- `src/render/world.js`: grains scaled by a bilinear fog value; fully unseen grains drawn like + out-of-world grains; no glow from fog; thermal lens fogged too. `overlays.js`: rain and + particles hidden in fog. `src/ui/info.js`: "Unexplored". Setting `fog` (Game tab). +- Browser tests and tools turn fog off (`play.py`, `t_smooth.py`, `shot.py`, `perf.py`, + `playthrough.py`) except the fog checks. New `test/t_fog.js`; `docs/systems/fog.md`. +- Tests: all 12 headless suites, `play.py` (185 checks), `t_smooth` pass. Screenshots checked in + both looks. `tools/perf.py`: drawWorld 0.34 → 0.43 ms with fog on (no change with light off). + ## 2026-10-24: t5 Material selection menu (Claude Code, Codex) - Decided with the owner: F opens it for walls and molds, R keeps cycling. diff --git a/docs/systems/fog.md b/docs/systems/fog.md new file mode 100644 index 0000000..edff906 --- /dev/null +++ b/docs/systems/fog.md @@ -0,0 +1,33 @@ +# Fog of war + +Unexplored ground is black with a soft edge. The sky and the first few grains of the +surface start seen. Exploration never changes the simulation. + +## Data and revealing + +`src/sim/fog.js` keeps a `Uint8Array` at half the grain resolution (`FOG = 2`). +Values run from 0 (unseen) to 255 (seen) and only increase. `alloc` allocates it with +the other world arrays. `fogInit` reveals above `surfY` and fades eight grains below it. + +Every four ticks, 256 rays from the bot's centre reveal up to 48 grains. The last eight +grains fade out. Solid and powder stop sight after a four-grain fade into their face; +air, gas and liquid pass sight. Bars and machine voids do not block it. + +## Saving + +The fog buffer is saved and exported with the other typed arrays, without a save version +change. Older saves rebuild the surface, reveal a radius of 12 grains around built blocks, +machine footprints and molds, then look from the bot. Loading and generating also look +immediately from the bot. + +## Drawing and setting + +Both looks and the thermal lens sample fog bilinearly. Seen grains use the usual drawing; +unseen grains use the out-of-world colour and smooth-look buffers, hiding their shapes. +Partial values dim foreground, background and trees. Bloom sources need at least 128; +bars, flyers, rain and particles need at least 64. Hover info and the cursor label say +only “Unexplored” below 64. + +Game settings → Fog of war is on by default. Turning it off shows the whole world while +exploration continues to be recorded and saved. Browser scenes and performance tools turn +it off in their shared setup; dedicated fog checks enable it explicitly. diff --git a/src/manifest.json b/src/manifest.json index 6e5aace..0ea648f 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -10,6 +10,7 @@ "sim/economy.js", "sim/progress.js", "sim/world.js", + "sim/fog.js", "sim/cells.js", "sim/heat.js", "sim/machines.js", diff --git a/src/render/overlays.js b/src/render/overlays.js index 542ff1a..eed5880 100644 --- a/src/render/overlays.js +++ b/src/render/overlays.js @@ -11,7 +11,7 @@ function drawRain() { ctx.beginPath(); let snow = false; for (const f of flyers) { - if (!f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue; + if ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue; if (f.m !== WATER) { snow = true; continue; } const a = worldToScreenPx(f.x, f.y), len = Math.max(1.5, Math.hypot(f.vx, f.vy) * 3.2); ctx.moveTo(a.x, a.y); ctx.lineTo(a.x - f.vx / Math.max(0.2, f.vy) * len * zp * 0.5, a.y - len * zp); @@ -20,7 +20,7 @@ function drawRain() { if (snow) { ctx.fillStyle = 'rgba(244,247,252,0.9)'; for (const f of flyers) { - if (!f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue; + if ((SET.fog && fogSeen(f.x, f.y) < 64) || !f.rain || f.m === WATER || f.x < x0 || f.x > x1 || f.y < y0 || f.y > y1) continue; const a = worldToScreenPx(f.x + Math.sin(f.age * 0.07 + f.x) * 0.6, f.y), r = Math.max(1, zp * 0.42); ctx.beginPath(); ctx.arc(a.x, a.y, r, 0, Math.PI * 2); ctx.fill(); } @@ -220,6 +220,7 @@ function drawParticles() { p.x += p.vx; p.y += p.vy; p.vy += 0.04; p.life--; if (p.life <= 0) { ps.splice(k, 1); continue; } } + if (SET.fog && fogSeen(p.x, p.y) < 64) continue; const s = worldToScreenPx(p.x, p.y); ctx.fillStyle = `rgba(${p.r | 0},${p.g | 0},${p.b | 0},${Math.min(1, p.life / 10)})`; const sz = p.spark ? Math.max(1, zp * 0.5) : p.suck ? zp : Math.max(1, zp * 0.7); diff --git a/src/render/world.js b/src/render/world.js index 70bbc02..1d9a989 100644 --- a/src/render/world.js +++ b/src/render/world.js @@ -1,3 +1,5 @@ +// Scale colour without changing the smooth-look class. +function fogColor(c, k) { return (c & 0xff000000) | (((c >>> 16 & 255) * k) << 16) | (((c >>> 8 & 255) * k) << 8) | ((c & 255) * k); } // drawWorld: the per-grain pixel buffer (pixel look), also the input of the smooth look. // ---------- World pixels ---------- // smooth-look class of a screw tube cell by direction: 9 up, 10 right, 11 down, 12 left @@ -20,6 +22,7 @@ function drawWorld() { for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) { const sx = (i % W) - vx0, sy = ((i / W) | 0) - vy0; if (sx >= 0 && sy >= 0 && sx < bw && sy < bh) mmask[sy * bw + sx] = 1; } } } + const useFog = SET.fog; const OUT = sm ? 0x040a0a0c : 0xff0a0a0c; const lighting = SET.lighting !== 'off' && !R.lens; if (lighting && (R.frame % SET.lightEvery === 0 || !R.lw)) computeLight(); @@ -33,12 +36,20 @@ function drawWorld() { const y = vy0 + by; const row = by * bw; if (y < 0 || y >= H) { for (let bx = 0; bx < bw; bx++) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } } continue; } + const fy = y / FOG, fj = Math.floor(fy), ft = fy - fj, fr = fj * FW, fn = Math.min(FH - 1, fj + 1) * FW; const yw = y * W; let skr = SKY_R[y], skg = SKY_G[y], skb = SKY_B[y]; if (rk > 0.01) { const gy = (skr + skg + skb) / 3 * 0.82; skr += (gy - skr) * rk * 0.7; skg += (gy - skg) * rk * 0.7; skb += (gy - skb) * rk * 0.65; } for (let bx = 0; bx < bw; bx++) { const x = vx0 + bx; if (x < 0 || x >= W) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; } + let fv = 255; + if (useFog) { + const fx = x / FOG, j = Math.floor(fx), t = fx - j, j1 = Math.min(FW - 1, j + 1); + const a = fog[fr + j], b = fog[fr + j1], c = fog[fn + j], d = fog[fn + j1]; + if ((a & b & c & d) !== 255) fv = (a + (b - a) * t) * (1 - ft) + (c + (d - c) * t) * ft; + if (!fv) { px[row + bx] = OUT; if (sm) { bpx[row + bx] = 0x000a0a0c; tpx[row + bx] = 0; fpx[row + bx] = 0xff; } continue; } + } const i = yw + x, m = mat[i], nz = noiseB[i] - 128; const T = temp[i]; let r, g, b; @@ -48,6 +59,7 @@ function drawWorld() { 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]; 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); continue; } @@ -167,7 +179,7 @@ function drawWorld() { if (T > 450 && m !== EMPTY && incand) { const gi = glowIndex(PH[m] === PH_SOLID ? T + nz * 0.9 : m === MAGMA ? T + Math.sin(x * 0.19 + y * 0.11 + anim * 0.03 + nz * 0.04) * 55 - 30 : T), w = GLOW_W[gi]; r = r * (1 - w) + GLOW_R[gi] * w; g = g * (1 - w) + GLOW_G[gi] * w; b = b * (1 - w) + GLOW_B[gi] * w; - if (glowOn && w > 0.3 && ((bx | by) & 1) === 0) { + if (glowOn && fv >= 128 && w > 0.3 && ((bx | by) & 1) === 0) { const gk = (by >> 1) * gw + (bx >> 1), s = (w - 0.3) * 1.4; gpx[gk] = 0xff000000 | (Math.min(255, GLOW_B[gi] * s) << 16) | (Math.min(255, GLOW_G[gi] * s) << 8) | Math.min(255, GLOW_R[gi] * s); } @@ -198,11 +210,19 @@ function drawWorld() { 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); } + if (fv < 255) { + const k = fv / 255; r *= k; g *= k; b *= k; + if (sm) { + const c = bpx[row + bx], t = tpx[row + bx]; + bpx[row + bx] = fogColor(c, k); tpx[row + bx] = fogColor(t, k); + } + } px[row + bx] = top | ((b > 255 ? 255 : b) << 16) | ((g > 255 ? 255 : g) << 8) | (r > 255 ? 255 : r); } } // bars and other loose objects if (!lens) for (const e of ents) { + if (useFog && fogSeen(e.ix, e.iy) < 64) continue; const ex = e.ix - vx0, ey = e.iy - vy0; if (ex + BAR_W < 0 || ey + BAR_H < 0 || ex >= bw || ey >= bh) continue; const m = e.m; @@ -219,7 +239,7 @@ function drawWorld() { } // grains in flight from the vacuum nozzle, a tube or a belt (rain and snow are drawn over the top, see drawRain) if (!lens) for (const f of flyers) { - if (f.rain) continue; + if (f.rain || (useFog && fogSeen(f.x, f.y) < 64)) continue; const sx = Math.floor(f.x) - vx0, sy = Math.floor(f.y) - vy0; if (sx < 0 || sy < 0 || sx >= bw || sy >= bh) continue; const m = f.m, v = ((sx * 7 + sy * 13) & 15) - 8; diff --git a/src/sim/fog.js b/src/sim/fog.js new file mode 100644 index 0000000..638c4a8 --- /dev/null +++ b/src/sim/fog.js @@ -0,0 +1,35 @@ +// Exploration only: never changes grains or the random stream. +const FOG = 2; +let fog, FW, FH; +function fogAlloc() { FW = Math.ceil(W / FOG); FH = Math.ceil(H / FOG); fog = new Uint8Array(FW * FH); } +function fogMark(x, y, v) { + if (x < 0 || y < 0 || x >= W || y >= H) return; + const i = Math.floor(y / FOG) * FW + Math.floor(x / FOG); + if (v > fog[i]) fog[i] = v; +} +function fogSeen(x, y) { + if (x < 0 || y < 0 || x >= W || y >= H) return 255; + return fog[Math.floor(y / FOG) * FW + Math.floor(x / FOG)]; +} +function fogInit() { + for (let x = 0; x < W; x++) for (let y = 0; y < Math.min(H, surfY[x] + 8); y++) fogMark(x, y, y <= surfY[x] ? 255 : (surfY[x] + 8 - y) * 255 / 8); +} +function fogLook(cx, cy) { + for (let a = 0; a < 256; a++) { + const dx = Math.cos(a * Math.PI / 128), dy = Math.sin(a * Math.PI / 128); + let hit = -1; + for (let d = 0; d < 48; d++) { + const x = Math.floor(cx + dx * d), y = Math.floor(cy + dy * d); + if (x < 0 || y < 0 || x >= W || y >= H) break; + const ph = PH[mat[y * W + x]]; + if (hit < 0 && (ph === PH_SOLID || ph === PH_POWDER)) hit = d; + const v = Math.min(255, (48 - d) * 255 / 8, hit < 0 ? 255 : (4 - d + hit) * 255 / 4); + if (v <= 0) break; + fogMark(x, y, v); + } + } +} +function fogRevealAround(x, y, r) { + for (let yy = Math.max(0, Math.floor(y - r)); yy <= Math.min(H - 1, y + r); yy++) for (let xx = Math.max(0, Math.floor(x - r)); xx <= Math.min(W - 1, x + r); xx++) if ((xx - x) ** 2 + (yy - y) ** 2 <= r * r) fogMark(xx, yy, 255); +} +function fogRevealAll() { fog.fill(255); } diff --git a/src/sim/save.js b/src/sim/save.js index d77e354..51f92ca 100644 --- a/src/sim/save.js +++ b/src/sim/save.js @@ -25,7 +25,7 @@ function serializeWorld() { pipes: (() => { if (pipesDirty) rebuildPipes(); return pipeNets.filter(pn => pn.n > 0).map(pn => [pn.blocks[0], pn.m, pn.n, Math.round(pn.T)]); })(), cols: { surfY: Array.from(surfY), dirtY: Array.from(dirtY), graniteY: Array.from(graniteY), basaltY: Array.from(basaltY), magmaY: Array.from(magmaY) }, }; - return { meta, buffers: { mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } }; + return { meta, buffers: { fog: fog.slice(), mat: mat.slice(), temp: t16, life: life.slice(), flags: flags.slice(), part: part.slice(), blockP: blockP.slice(), blockV: blockV.slice(), bgm: bgm.slice() } }; } function deserializeWorld(meta, buffers) { if (meta.v !== SAVE_VERSION) throw new Error('This world was saved by an older version of the game and cannot be loaded.'); @@ -108,5 +108,13 @@ function deserializeWorld(meta, buffers) { if (!meta.specks) clearHardSpecks(true); // older saves: tidy away tiny specks of too-hard rock once rebuildPipes(); for (const [b, m, n, T] of meta.pipes || []) { const id = pipeNetAt[b]; if (id >= 0) { const pn = pipeNets[id]; pn.m = m; pn.n = Math.min(n, pn.cap); pn.T = T; } } + if (buffers.fog && buffers.fog.length === fog.length) fog.set(buffers.fog); + else { + fogInit(); + for (let b = 0; b < NB; b++) if (blockP[b]) fogRevealAround((b % BW) * BS + BS / 2, ((b / BW) | 0) * BS + BS / 2, 12); + for (const m of machines) if (m) for (let y = m.y; y < m.y + m.h; y += BS) for (let x = m.x; x < m.x + m.w; x += BS) fogRevealAround(x, y, 12); + for (const mo of molds) for (const i of moldCellsAt(mo.kind, mo.x, mo.y).walls) fogRevealAround(i % W, (i / W) | 0, 12); + } + fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2); EV.length = 0; } diff --git a/src/sim/tick.js b/src/sim/tick.js index dc27d44..2d0b164 100644 --- a/src/sim/tick.js +++ b/src/sim/tick.js @@ -22,4 +22,5 @@ function simTick() { if (tick % 60 === 17) { marketTick(); ordersTick(); } if (tick % 30 === 11 && saplings.length) saplingTick(); ghostTick(); + if (tick % 4 === 0) fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2); } diff --git a/src/sim/world.js b/src/sim/world.js index e6d4126..6a1a3b6 100644 --- a/src/sim/world.js +++ b/src/sim/world.js @@ -39,6 +39,7 @@ function discover(m, x, y, from, how, T) { function alloc(w, h) { W = w; H = h; N = w * h; + fogAlloc(); CW = Math.ceil(w / 32); CH = Math.ceil(h / 32); NCH = CW * CH; mat = new Uint8Array(N); temp = new Float32Array(N); life = new Uint8Array(N); flags = new Uint8Array(N); stamp = new Uint8Array(N); part = new Uint8Array(N); mach = new Uint16Array(N); digp = new Uint8Array(N); diff --git a/src/sim/worldgen.js b/src/sim/worldgen.js index 185ce7b..12db774 100644 --- a/src/sim/worldgen.js +++ b/src/sim/worldgen.js @@ -311,5 +311,6 @@ function generateWorld(seedIn, w, h, onProgress) { seen = new Uint8Array(NMAT); ledger = {}; stats.dug = stats.moved = stats.placed = stats.ignitions = stats.cast = stats.bars = 0; EV.length = 0; + fogInit(); fogLook(bot.x + bot.w / 2, bot.y + bot.h / 2); if (onProgress) onProgress(1); } diff --git a/src/ui/info.js b/src/ui/info.js index 804b690..e3cfed6 100644 --- a/src/ui/info.js +++ b/src/ui/info.js @@ -76,6 +76,7 @@ function reactionsOf(m, forNotebook) { // What is under the cursor: t1 is the name, t2 the details, warn a short problem // (too hard, out of reach) for the label next to the cursor. function hoverInfo(ax, ay) { + if (SET.fog && fogSeen(ax, ay) < 64) return { t1: 'Unexplored', t2: [], t3: [] }; if (ax < 0 || ay < 0 || ax >= W || ay >= H) return { t1: '', t2: ['Outside the world'], warn: '', air: true }; const i = ay * W + ax, m = mat[i]; let warn = ''; diff --git a/src/ui/settings.js b/src/ui/settings.js index 4f5fc86..90fc4f4 100644 --- a/src/ui/settings.js +++ b/src/ui/settings.js @@ -45,6 +45,7 @@ const SET_TABS = { { html: () => controlsHtml() }, ], Game: [ + { k: 'fog', label: 'Fog of war', d: 'Places you have not seen yet are black. Off shows the whole world.', type: 'toggle' }, { k: 'cardSeconds', label: 'Discovery cards', d: 'How long a card stays when you find or make something new. Everything is also in the notebook.', type: 'select', opts: [[0, 'Off'], [5, '5 seconds'], [9, '9 seconds'], [15, '15 seconds'], [-1, 'Until I close them']] }, { k: 'autosave', label: 'Autosave', type: 'select', opts: [[0, 'Off'], [1, 'Every minute'], [3, 'Every 3 minutes'], [5, 'Every 5 minutes'], [10, 'Every 10 minutes']] }, { k: 'save', label: 'Save', d: 'Saves to this browser.', type: 'button', text: 'Save now', fn: () => saveWorld('main'), ingame: true }, diff --git a/src/ui/state.js b/src/ui/state.js index d0c69fe..689e54e 100644 --- a/src/ui/state.js +++ b/src/ui/state.js @@ -5,7 +5,7 @@ const SET_DEFAULT = { zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true, look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true, - thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, + fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3, volMaster: 0.8, volMusic: 0.5, volSfx: 0.8, muted: false, muteUnfocused: true, deadzone: 0.18, aimSpeed: 1, autosave: 3, cardSeconds: 9, cursorLabel: true, sv: 2, }; diff --git a/tasks/t8_fog_war_unexplored.md b/tasks/t8_fog_war_unexplored.md index a8927a7..27605d8 100644 --- a/tasks/t8_fog_war_unexplored.md +++ b/tasks/t8_fog_war_unexplored.md @@ -1,9 +1,18 @@ --- id: t8 title: Fog of war for unexplored areas -status: Todo +status: Review created: 2026-10-24 -updated: 2026-10-24T04:41:45Z +updated: 2026-10-24T05:37:34Z +order: 2 +report: | + (agent) Fog of war. Underground is black until the bot has seen it: it sees in straight + lines through air, water and gas, about 48 grains, and a few grains into the rock it looks + at. What you've seen stays seen and is saved with the world. The sky and surface start + seen. Hover info says "Unexplored" in the black. Settings > Game > Fog of war turns the + drawing off (exploration is still kept). Older worlds: the area around everything you've + built and around the bot is revealed when loaded. + To check: new world, dig down, look along a tunnel and into a cave. Try both looks. --- ## Context diff --git a/test/play.py b/test/play.py index a1e3bc6..4bc18e5 100644 --- a/test/play.py +++ b/test/play.py @@ -20,7 +20,7 @@ with sync_playwright() as p: logs = [] pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None) - pg.goto(url); pg.wait_for_timeout(300) + pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300) check('storage transactions roll back when a write throws', pg.evaluate("""async () => { try { await DB.op('readwrite', st => { st.put('partial', 'abort-test'); throw new Error('test abort'); }); } catch (e) { return await DB.get('abort-test') === undefined; } @@ -669,10 +669,12 @@ with sync_playwright() as p: dl = dl_info.value wf = json.loads(pathlib.Path(dl.path()).read_text()) check('Export world file downloads a world file', wf.get('format') == 'deepgrain-world' and wf['meta']['W'] == ev('W'), dl.suggested_filename) + fog_want = ev('Array.from(fog)') + check('world export includes fog bytes', wf['buffers']['fog']['type'] == 'Uint8Array') want = ev('JSON.stringify([W, H, Math.round(money), Math.round(debt), bagTotal])') pg3 = ctx.new_page() pg3.on('pageerror', lambda e: logs.append(f'PAGEERROR(import): {e}')) - pg3.goto(url); pg3.wait_for_timeout(300) + pg3.goto(url); pg3.evaluate('SET.fog = false'); pg3.wait_for_timeout(300) worlds_before_import = ev('listWorlds().then(w => w.length)') pg3.set_input_files('#importfile', dl.path()) pg3.wait_for_function('GAME.running === true', timeout=60000) @@ -680,13 +682,57 @@ with sync_playwright() as p: check('importing it on the title screen brings the same world back', got == want, f'{want} -> {got}') pg3.wait_for_function("async () => (await listWorlds()).length === " + str(worlds_before_import + 1)) check('import adds an independent saved world', pg3.evaluate('GAME.worldId') != ev('GAME.worldId') and pg3.evaluate('listWorlds().then(w => w.length)') == worlds_before_import + 1) + check('world file keeps fog bytes', pg3.evaluate('Array.from(fog)') == fog_want) pg3.close() pg.keyboard.press('Escape'); settle(150) + # Fog has its own paused scene; existing play checks keep the whole world visible. + fb = p.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']) + fp = fb.new_page(viewport={'width': 1280, 'height': 800}) + fp.on('pageerror', lambda e: logs.append(f'PAGEERROR(fog): {e}')) + fp.add_init_script('window.__allowSoftwareGL = true') + fp.goto(url) + fp.evaluate('startNewWorld(4242, 512, 384)') + fp.wait_for_function('GAME.running') + fp.evaluate("""() => { + GAME.paused = true; SET.fog = true; SET.look = 'pixel'; SET.zoom = 6; SET.lighting = 'off'; + SET.cameraLead = false; SET.cameraSmooth = 0; resizeRender(); + for (let y = 130; y < 280; y++) for (let x = 160; x < 350; x++) setCell(y * W + x, STONE, 20, F_NAT); + for (let y = 140; y < 205; y++) for (let x = 240; x < 252; x++) setCell(y * W + x, EMPTY, 20, 0); + for (let y = 192; y < 205; y++) for (let x = 240; x < 280; x++) setCell(y * W + x, EMPTY, 20, 0); + bot.x = 243; bot.y = 192; fog.fill(0); fogInit(); fogLook(bot.x + 2.5, bot.y + 2.5); + snapCamera(); GAME.hidePreviews = true; $('cards').replaceChildren(); + }""") + fp.wait_for_timeout(300) + sample = "(() => { drawWorld(); const k = (235 - R.vy0) * R.bw + 300 - R.vx0; return R.px[k] & 0xffffff; })()" + check('unexplored pixel is out-of-world black', fp.evaluate(sample) == 0x0a0a0c) + check('sky starts seen and draws colour', fp.evaluate("(() => { const cy=R.camY; R.camY=0; drawWorld(); const c=R.px[20*R.bw+20]&0xffffff; R.camY=cy; drawWorld(); return fogSeen(300,0)===255 && c!==0x0a0a0c; })()")) + check('unexplored canvas pixel is black', fp.evaluate("(() => {const s=worldToScreenPx(300.5,235.5);return Array.from(R.ctx.getImageData(s.x,s.y,1,1).data).slice(0,3).every(v=>v<=12)})()")) + check('hover and cursor share only Unexplored', fp.evaluate("JSON.stringify(hoverInfo(300,235)) === JSON.stringify({t1:'Unexplored',t2:[],t3:[]})")) + fp.evaluate("GAME.aim = {x:300,y:235}; renderInfo()") + check('info box says only Unexplored', fp.locator('#info').inner_text() == 'Unexplored') + check('cursor draws only Unexplored', fp.evaluate("""() => { + const old=R.ctx.fillText, inside=GAME.mouse.in, texts=[]; + R.ctx.fillText=function(t) { texts.push(t); }; GAME.mouse.in=true; + drawCursorLabel(500,400,1); R.ctx.fillText=old; GAME.mouse.in=inside; + return texts.length===1 && texts[0]==='Unexplored'; + }""")) + fp.screenshot(path=str(out / 'fog-pixel.png')) + fp.evaluate('R.lens = true') + check('thermal lens hides unexplored grains', fp.evaluate(sample) == 0x0a0a0c) + fp.evaluate('R.lens = false; SET.fog = false') + check('fog toggle shows the same rock', fp.evaluate(sample) != 0x0a0a0c) + fp.evaluate("SET.fog = true; SET.look = 'smooth'; SM.t0 = performance.now() + 100000") + fp.wait_for_timeout(500) + check('fog draws in the smooth look', fp.evaluate('R.smoothOn && SM.ok')) + check('unseen smooth grains have no terrain shape', fp.evaluate("(() => {const k=(235-R.vy0)*R.bw+300-R.vx0;return R.px[k]===0x040a0a0c && R.backPx[k]===0x000a0a0c && R.treePx[k]===0 && R.fieldPx[k]===255})()")) + fp.screenshot(path=str(out / 'fog-smooth.png')) + fb.close() + # 11. the largest world from the title screen pg2 = ctx.new_page() pg2.on('pageerror', lambda e: logs.append(f'PAGEERROR(large): {e}')) - pg2.goto(url); pg2.wait_for_timeout(300) + pg2.goto(url); pg2.evaluate('SET.fog = false'); pg2.wait_for_timeout(300) check('the size menu offers small, medium and large, and no deep world', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')") == '2048x1024,3072x1280,4096x1536', pg2.evaluate("[...document.querySelectorAll('#nw-size option')].map(o => o.value).join(',')")) pg2.click('#btn-new'); pg2.fill('#nw-seed', '4242'); pg2.select_option('#nw-size', '4096x1536'); pg2.click('#btn-generate') pg2.wait_for_function('GAME.running === true', timeout=90000) diff --git a/test/t_fog.js b/test/t_fog.js new file mode 100644 index 0000000..10de206 --- /dev/null +++ b/test/t_fog.js @@ -0,0 +1,24 @@ +// Fog: surface, sight, range, saves and simulation independence. +const { load, suite, flatWorld, rect, ticks } = require('./harness'); +const { run } = load(), { check, done } = suite(); +run('generateWorld(4242, 512, 384, null)'); +check('sky and surface start seen', run('fogSeen(20, 0) >= 200 && fogSeen(20, surfY[20]) >= 200')); +check('deep ground starts unseen', run('fogSeen(20, surfY[20] + 60) === 0')); +flatWorld(run, 256, 256, 40); +rect(run, 40, 100, 140, 110, run('EMPTY')); +rect(run, 40, 125, 140, 135, run('EMPTY')); +run('bot.x = 60; bot.y = 102; fogLook(62.5, 104.5)'); +check('open pocket is visible', run('fogSeen(82, 104) >= 200')); +check('rock hides separate pocket', run('fogSeen(62, 130) === 0')); +check('one look has limited range', run('fogSeen(124, 104) === 0')); +check('fog only increases', run('(()=>{const f = fog.slice(); fogLook(90,104); return f.every((v,i)=>fog[i]>=v)})()')); +check('save/load keeps fog', run('(()=>{fogLook(bot.x+bot.w/2,bot.y+bot.h/2);const s=serializeWorld(), f=fog.slice();deserializeWorld(s.meta,s.buffers);return f.every((v,i)=>fog[i]===v)})()')); +rect(run, 200, 160, 203, 163, run('EMPTY')); +run('placeBlock(BP_WALL, RSTONE, 50, 40, true)'); +check('old save rebuilds exploration', run('(()=>{const s=serializeWorld();delete s.buffers.fog;deserializeWorld(s.meta,s.buffers);return fogSeen(62,104)>=200 && fogSeen(202,162)===255 && fogSeen(20,220)===0})()')); +check('wrong length fog rebuilds exploration', run('(()=>{const s=serializeWorld();s.buffers.fog=new Uint8Array(1);deserializeWorld(s.meta,s.buffers);return fogSeen(202,162)===255})()')); +const hash = () => run('mat.reduce((h,v)=>Math.imul(h^v,16777619)>>>0,2166136261)'); +run('generateWorld(42, 256, 256, null)'); ticks(run, 300); const h = hash(); +run('generateWorld(42, 256, 256, null); fogLook = function () {}'); ticks(run, 300); +check('fog leaves simulation unchanged', hash() === h); +done(); diff --git a/test/t_smooth.py b/test/t_smooth.py index f4ee9a3..332f99d 100644 --- a/test/t_smooth.py +++ b/test/t_smooth.py @@ -16,7 +16,7 @@ with sync_playwright() as p: pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type in ('error', 'warning') else None) pg.add_init_script('window.__allowSoftwareGL = true; localStorage.clear();') - pg.goto((root / 'dist' / 'index.html').as_uri()); pg.wait_for_timeout(300) + pg.goto((root / 'dist' / 'index.html').as_uri()); pg.evaluate('SET.fog = false'); pg.wait_for_timeout(300) pg.click('#btn-new'); pg.fill('#nw-seed', '777'); pg.click('#btn-generate') pg.wait_for_function('GAME.running === true', timeout=20000); pg.wait_for_timeout(1500) ev = pg.evaluate @@ -38,7 +38,7 @@ with sync_playwright() as p: # 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();') - pg2.goto((root / 'dist' / 'index.html').as_uri()); pg2.wait_for_timeout(300) + pg2.goto((root / 'dist' / 'index.html').as_uri()); pg2.evaluate('SET.fog = false'); pg2.wait_for_timeout(300) pg2.click('#btn-new'); pg2.fill('#nw-seed', '5'); pg2.click('#btn-generate') pg2.wait_for_function('GAME.running === true', timeout=20000); pg2.wait_for_timeout(1200) check('software rendering falls back to pixels with a note', pg2.evaluate("SET.look === 'pixel' && !R.smoothOn && SM.why === 'software'") and pg2.locator('.toast', has_text='graphics card').count() >= 1, pg2.evaluate('[SET.look, SM.why]')) diff --git a/tools/perf.py b/tools/perf.py index d573347..9f05c13 100644 --- a/tools/perf.py +++ b/tools/perf.py @@ -12,7 +12,7 @@ url = (root / 'dist' / 'index.html').as_uri() with sync_playwright() as p: b = p.chromium.launch(args=['--enable-gpu-rasterization','--ignore-gpu-blocklist']) pg = b.new_page(viewport={'width': 1440, 'height': 860}) - pg.goto(url); pg.wait_for_timeout(300) + pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300) pg.evaluate("startNewWorld(424242, 1024, 640)") pg.wait_for_function('GAME.running === true', timeout=20000) pg.wait_for_timeout(500) @@ -23,6 +23,8 @@ with sync_playwright() as p: out.drawWorld = t(() => drawWorld(), 20); SET.lighting = 'off'; out.drawWorldNoLight = t(() => drawWorld(), 20); SET.lighting='full'; SET.glow = 'off'; out.drawWorldNoGlow = t(() => drawWorld(), 20); SET.glow='low'; + SET.fog = true; out.drawWorldFog = t(() => drawWorld(), 100); + SET.lighting = 'off'; out.drawWorldFogNoLight = t(() => drawWorld(), 100); SET.lighting = 'full'; SET.fog = false; out.put = t(() => { R.bctx.putImageData(R.img,0,0); }, 20); out.blit = t(() => { R.ctx.drawImage(R.buf, 0, 0, R.bw, R.bh, 0, 0, R.bw*R.zp, R.bh*R.zp); }, 20); out.sim = t(() => simTick(), 60); diff --git a/tools/playthrough.py b/tools/playthrough.py index 1a298d4..69aa36e 100644 --- a/tools/playthrough.py +++ b/tools/playthrough.py @@ -16,6 +16,7 @@ with sync_playwright() as p: pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}')) pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.goto(url) + pg.evaluate("SET.fog = false") pg.wait_for_timeout(800) pg.screenshot(path=str(out / '01-title.png')) pg.click('#btn-new') diff --git a/tools/shot.py b/tools/shot.py index dfd9509..93b2494 100644 --- a/tools/shot.py +++ b/tools/shot.py @@ -79,6 +79,7 @@ def main(): pg.on('console', lambda m: errors.append('console: ' + m.text) if m.type == 'error' else None) pg.add_init_script('window.__allowSoftwareGL = true;' + (FREEZE if a.freeze else '')) pg.goto(pathlib.Path(a.page).resolve().as_uri()) + pg.evaluate("SET.fog = false") pg.wait_for_timeout(300) # pause the moment the world exists, so the shot depends only on the seed, the setup # code and --ticks (simTick is deterministic), not on how fast the browser ran