Fog of war: underground is black until the bot has seen it (t8)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 01:37:34 -04:00
co-authored by Claude Opus 5.5
parent da04e4e2a7
commit b045d8378b
21 changed files with 233 additions and 24 deletions
+17 -10
View File
@@ -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.
+16
View File
@@ -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.
+33
View File
@@ -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.
+1
View File
@@ -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",
+3 -2
View File
@@ -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);
+22 -2
View File
@@ -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;
+35
View File
@@ -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); }
+9 -1
View File
@@ -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;
}
+1
View File
@@ -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);
}
+1
View File
@@ -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);
+1
View File
@@ -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);
}
+1
View File
@@ -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 = '';
+1
View File
@@ -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 },
+1 -1
View File
@@ -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,
};
+11 -2
View File
@@ -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
+49 -3
View File
@@ -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)
+24
View File
@@ -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();
+2 -2
View File
@@ -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]'))
+3 -1
View File
@@ -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);
+1
View File
@@ -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')
+1
View File
@@ -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