Minimap with zoom in the bottom right, full map on M; cards move to the bottom centre (t32)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 19:23:43 -04:00
co-authored by Claude Opus 5.5
parent c069abe2d2
commit 8d12b67c08
16 changed files with 244 additions and 18 deletions
+8
View File
@@ -252,6 +252,14 @@ above, including:
career and come along; buildings stay on their claim and wait (they don't run) until you
travel back from the notebook's Claims page.
- **A minimap and a full map (t32, 2026-10-24).** Bottom right: a minimap around the bot with
zoom (1 to 16 grains per pixel) and a Map button; M opens the full map (wheel zoom, drag to
pan, centre on bot, markers for the bot and the dock). Both draw from one low-resolution colour
image of the world (1 pixel per 2 grains, 4 on worlds wider than 4096) that refreshes a few
32-grain areas a frame, nearest the bot first, and respects fog of war. Onboarding cards moved
to the bottom centre to make room; mute moved to Shift+M. Bigger worlds and several claims
made it easy to get lost.
- **Long waits show what's happening (t30, 2026-10-24).** Generating, loading, opening a claim
and travelling show the loading overlay with a title, a moving bar and the current step
("Laying rock and ore", "Unpacking", "Building the world"...). Generation runs as steps that
+8 -3
View File
@@ -390,7 +390,7 @@ Shared player state; the world save remains a complete standalone fallback.
Starting, loading and quitting a game; modal dialogs; the pause menu.
- functions: showLoading, enterGame, setupNewWorld, startNewWorld, startFromSave, loadSavedWorld, quitToTitle, refreshContinue, anyModal, openModal, closeModal, closeAllModals, topModal, dialog, openPause, renderWorlds
### src/ui/settings.js (124 lines)
### src/ui/settings.js (125 lines)
The settings screen.
- functions: openSettings, renderSettings, applySettings, toggleFullscreen, confirmWipe
- variables: SET_TABS, setTab
@@ -418,7 +418,7 @@ The selected tool and piece: R / Q / E, drill width, filter picker, the selectio
Discovery cards, hover info (I) and the cursor label.
- functions: showCard, howText, discoveryCard, reactionsOf, hoverInfo, renderInfo, drawCursorLabel
### src/ui/input.js (89 lines)
### src/ui/input.js (91 lines)
Keyboard and mouse listeners.
- functions: isTyping
- variables: canvas
@@ -442,7 +442,12 @@ Previews drawn under the cursor: build ghosts, drill aim and pulses, selections,
Events from the simulation (emit) turned into sounds, toasts and cards.
- functions: drainEvents
### src/ui/loop.js (133 lines)
### src/ui/map.js (93 lines)
Shared world colours, minimap and full map.
- functions: resetMap, mapArea, updateMap, drawMap, mapFit, drawFullMap, openMap, zoomMap, zoomMinimap
- variables: MAP_S, MAP
### src/ui/loop.js (134 lines)
The frame loop and boot.
- functions: scanVisible, frame, boot
- variables: lastT, acc, lastRender, frameMs, simMs, fpsSm, ambientT
+10
View File
@@ -4,6 +4,16 @@ 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: t32: minimap and full map (Claude Code, Codex)
- `src/ui/map.js`: colour cache (`resetMap`, `mapArea`, `updateMap` from the frame loop),
minimap (`#minimap` in `#hud .br`, `SET.minimap`, `SET.minimapZoom`), full map modal `m-map`
(`openMap`, `zoomMap`, drag pan). `input.js`: M map, Shift+M mute. `#cards` moved to `.bc`.
- Frame time on Medium: 2.18 ms with the minimap off, 2.29 ms on.
- Tests: play.py map checks (buttons, zoom setting, cache updates after digging, fog black, M/Esc,
Shift+M, cards in .bc, setting hides it, frame time). `run_all.py --browser` ALL PASSED.
Screenshots `shots/t32-hud.png`, `shots/t32-map.png`, `shots/t32-map-fog.png`.
## 2026-10-24: New worlds get camp names too (Claude Code, Codex)
- The new-world dialog prefills `claimName(seed, existing names)` instead of "World N", and
+1
View File
@@ -59,6 +59,7 @@
"ui/gamepad.js",
"ui/previews.js",
"ui/events.js",
"ui/map.js",
"ui/loop.js"
]
}
+10 -1
View File
@@ -32,9 +32,10 @@
<div id="info" class="plate"></div>
</div>
<div class="corner br">
<div id="cards" aria-live="polite"></div>
<div id="minimap" class="plate interactive"><canvas id="mini-canvas" width="200" height="130" aria-label="Minimap"></canvas><div class="map-buttons"><button id="mini-minus" class="iconbtn" aria-label="Zoom out minimap">−</button><button id="mini-plus" class="iconbtn" aria-label="Zoom in minimap">+</button><button id="mini-map" class="iconbtn">Map <kbd>M</kbd></button></div></div>
</div>
<div class="corner bc">
<div id="cards" aria-live="polite"></div>
<div id="buildbar" class="plate interactive" hidden role="toolbar" aria-label="Build menu"></div>
<div id="ovbadges" aria-live="polite"></div>
</div>
@@ -113,6 +114,14 @@
</div>
</div>
<div id="m-map" class="modal" hidden>
<div class="plate">
<div class="mhead"><h2>World map</h2><button class="iconbtn" data-close aria-label="Close map">Close <kbd>M / Esc</kbd></button></div>
<canvas id="map-canvas" aria-label="World map. Drag to pan; wheel to zoom."></canvas>
<div class="mfoot"><button id="map-minus" class="iconbtn" aria-label="Zoom out map">−</button><button id="map-plus" class="iconbtn" aria-label="Zoom in map">+</button><button id="map-centre" class="iconbtn">Centre on bot</button><span class="note">Drag to pan · wheel to zoom</span></div>
</div>
</div>
<div id="m-dialog" class="modal small" hidden>
<div class="plate">
<div class="mhead"><h2 id="dlg-title"></h2></div>
+24 -3
View File
@@ -192,7 +192,7 @@ table.mt td.dip { color: var(--melt); }
#info .t2 { color: var(--ash); }
#info .t3 { color: var(--melt-hot); opacity: 0.85; font-size: 0.94em; }
#cards { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }
#cards { display: flex; flex-direction: column; gap: 6px; align-items: center; }
.card-pop { width: calc(290px * var(--ui)); padding: 8px 11px 10px; display: flex; flex-direction: column; gap: 3px; position: relative; overflow: hidden; animation: toastIn 0.25s ease-out; }
.card-pop .top { display: flex; align-items: center; gap: 6px; }
.card-pop .x { margin-left: auto; background: none; border: none; color: var(--ash); cursor: pointer; font-size: 1.15em; line-height: 1; padding: 0 2px; }
@@ -356,7 +356,7 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
.fdir .fd.on { border-color: #e59a52; background: #33261c; }
.fdir .fd:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
@media (max-width: 1100px) { #hud.building .br { bottom: calc(160px * var(--ui)); } }
/* saved worlds */
.world-row { padding: 12px 0; border-bottom: 1px solid var(--soot-3); }
@@ -375,4 +375,25 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
@keyframes saving-pulse { to { opacity: 0.3; } }
@media (prefers-reduced-motion: reduce) { #saving i { animation: none; } }
#loadstep { color: var(--ash); font-size: 13px; }
#hud:has(~ #saving:not([hidden])) .br { padding-bottom: 72px; }
/* Maps share the same grain colours and exploration. */
#minimap { padding: 5px; }
#mini-canvas { display: block; width: calc(200px * var(--ui)); height: calc(130px * var(--ui)); image-rendering: pixelated; }
.map-buttons { display: flex; justify-content: flex-end; gap: 4px; margin-top: 4px; }
.map-buttons button { padding: 2px 8px; }
#hud:has(#minimap:not([hidden])) ~ #perf { bottom: calc(190px * var(--ui) + 16px); }
#hud:has(#minimap:not([hidden])) ~ #saving { bottom: calc(190px * var(--ui) + 58px); }
#m-map > .plate { width: min(1200px, 96vw); height: 88vh; max-height: none; }
#map-canvas { display: block; width: 100%; flex: 1; min-height: 0; cursor: grab; touch-action: none; }
#m-map .mfoot { align-items: center; gap: 8px; flex-wrap: wrap; }
@media (max-width: 900px) {
#mini-canvas { width: calc(150px * var(--ui)); height: calc(98px * var(--ui)); }
}
@media (max-width: 720px) { #mini-plus, #mini-minus { display: none; } }
@media (max-width: 1100px) {
#hud.building .br { bottom: calc(160px * var(--ui)); }
#hud.building:has(#minimap:not([hidden])) ~ #perf { bottom: calc(350px * var(--ui)); }
#hud.building:has(#minimap:not([hidden])) ~ #saving { bottom: calc(390px * var(--ui)); }
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Discovery cards, hover info (I) and the cursor label.
// ---------- Discovery cards ----------
// Short cards in the lower right when you find or make something new. They fade
// Short cards at the bottom centre when you find or make something new. They fade
// on their own (hover holds them); everything they say is also in the notebook.
function showCard(c) {
if (!SET.cardSeconds) return;
+3 -1
View File
@@ -6,8 +6,10 @@ window.addEventListener('keydown', e => {
if ((e.key === 'Control' || e.key === 'Meta') && !GAME.ctrl) { GAME.ctrl = true; lastBagKey = ''; hudDirty = true; }
if (isTyping(e)) { if (e.key === 'Escape') e.target.blur(); return; }
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (GAME.running && k === 'm' && e.shiftKey && !e.repeat) { SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); return; }
if (anyModal()) {
if (k === 'Escape') { e.preventDefault(); const t = topModal(); closeModal(t); }
else if (k === 'm' && !e.repeat && topModal() === 'm-map') closeModal('m-map');
else if (k === 'n' && !$('m-notebook').hidden) closeModal('m-notebook');
return;
}
@@ -36,7 +38,7 @@ window.addEventListener('keydown', e => {
case 'l': bot.lamp = !bot.lamp; playSfx('ui'); break;
case 'n': openNotebook(); break;
case 'b': openNotebook('Blueprints'); break;
case 'm': SET.muted = !SET.muted; saveSettings(); auVolumes(); toast(SET.muted ? 'Sound muted.' : 'Sound on.'); break;
case 'm': openMap(); break;
case '[': setBrush(-1); break;
case ']': setBrush(1); break;
case 'Delete': if (ghostBlocks.size || ghostMachines.length || ghostMolds.length) { clearAllGhosts(); toast('Blueprint ghosts cleared.'); } break;
+1
View File
@@ -82,6 +82,7 @@ function frame(now) {
if (document.body.classList.contains('paused') !== GAME.paused) document.body.classList.toggle('paused', GAME.paused);
updateOverlayUI();
if (now > ambientT) { ambientT = now + 200; auAmbient(scanVisible()); }
updateMap(now);
frameMs = frameMs * 0.9 + (performance.now() - t0) * 0.1;
fpsSm = fpsSm * 0.95 + (1 / Math.max(0.001, dt)) * 0.05;
if (SET.showPerf && R.frame % 15 === 0) {
+93
View File
@@ -0,0 +1,93 @@
// Shared world colours, minimap and full map.
let MAP_S = 2;
const MAP = { canvas: document.createElement('canvas'), image: null, w: 0, h: 0, init: 0, rr: 0, near: 0, miniT: 0, fullT: 0, x: 0, y: 0, zoom: 1, drag: null };
function resetMap() {
MAP_S = W > 4096 ? 4 : 2; MAP.w = W; MAP.h = H; MAP.fog = SET.fog;
MAP.canvas.width = Math.ceil(W / MAP_S); MAP.canvas.height = Math.ceil(H / MAP_S);
MAP.ctx = MAP.canvas.getContext('2d'); MAP.image = MAP.ctx.createImageData(MAP.canvas.width, MAP.canvas.height);
MAP.init = MAP.rr = MAP.near = MAP.miniT = MAP.fullT = 0;
if (![1, 2, 4, 8, 16].includes(SET.minimapZoom)) SET.minimapZoom = 4;
}
function mapArea(cx, cy) {
const s = MAP_S, x0 = cx * 32, y0 = cy * 32, x1 = Math.min(W, x0 + 32), y1 = Math.min(H, y0 + 32), data = MAP.image.data, mw = MAP.canvas.width;
for (let y = y0; y < y1; y += s) for (let x = x0; x < x1; x += s) {
const xx = Math.min(W - 1, x + s / 2), yy = Math.min(H - 1, y + s / 2), seen = SET.fog ? fogSeen(xx, yy) : 255;
let m = mat[yy * W + xx];
if (!m || PH[m] === PH_GAS) for (let c = 0; c < 4; c++) {
const k = mat[Math.min(H - 1, y + (c >> 1) * (s - 1)) * W + Math.min(W - 1, x + (c & 1) * (s - 1))];
if (k && PH[k] !== PH_GAS) { m = k; break; }
}
const sky = yy < surfY[xx], f = seen < 64 ? 0 : seen / 255, i = (y / s * mw + x / s) * 4;
data[i] = (m ? COLR[m] : sky ? 78 : 20) * f;
data[i + 1] = (m ? COLG[m] : sky ? 114 : 18) * f;
data[i + 2] = (m ? COLB[m] : sky ? 142 : 17) * f; data[i + 3] = 255;
}
MAP.ctx.putImageData(MAP.image, 0, 0, x0 / s, y0 / s, (x1 - x0) / s, (y1 - y0) / s);
}
function updateMap(now) {
if (!GAME.running || GAME.loading) return;
$('minimap').hidden = !SET.minimap;
if (!SET.minimap && $('m-map').hidden) return;
if (MAP.w !== W || MAP.h !== H || MAP.fog !== SET.fog) resetMap();
const nx = Math.ceil(W / 32), ny = Math.ceil(H / 32), total = nx * ny;
if (MAP.init < total) {
const end = performance.now() + 8;
do { const n = MAP.init++; mapArea(n % nx, Math.floor(n / nx)); } while (MAP.init < total && performance.now() < end);
if (MAP.init === total) MAP.fullT = MAP.miniT = 0;
} else {
mapArea(Math.max(0, Math.min(nx - 1, Math.floor(bot.x / 32))), Math.max(0, Math.min(ny - 1, Math.floor(bot.y / 32))));
const rx = Math.ceil(R.bw / 32), ry = Math.ceil(R.bh / 32), span = rx * 2 + 1;
for (let n = 0; n < 15; n++) {
const k = MAP.near++ % (span * (ry * 2 + 1)), x = Math.floor(bot.x / 32) - rx + k % span, y = Math.floor(bot.y / 32) - ry + Math.floor(k / span);
if (x >= 0 && y >= 0 && x < nx && y < ny) mapArea(x, y);
}
for (let n = 0; n < 8; n++) { const k = MAP.rr++ % total; mapArea(k % nx, Math.floor(k / nx)); }
}
if (SET.minimap && now >= MAP.miniT) { MAP.miniT = now + 100; drawMap($('mini-canvas'), bot.x, bot.y, 1 / SET.minimapZoom, false); }
if (!$('m-map').hidden && now >= MAP.fullT) { MAP.fullT = now + 1000; drawFullMap(); }
}
function drawMap(c, x, y, z, full) {
const ctx = c.getContext('2d'), w = c.width, h = c.height;
ctx.imageSmoothingEnabled = false; ctx.fillStyle = '#080706'; ctx.fillRect(0, 0, w, h);
ctx.drawImage(MAP.canvas, w / 2 - x * z, h / 2 - y * z, W * z, H * z);
const marker = (mx, my, colour, label) => {
const px = w / 2 + (mx - x) * z, py = h / 2 + (my - y) * z;
if (px < 0 || py < 0 || px > w || py > h) return;
ctx.fillStyle = '#080706'; ctx.fillRect(px - 4, py - 4, 8, 8); ctx.fillStyle = colour; ctx.fillRect(px - 2, py - 2, 4, 4);
if (full) { ctx.font = '13px sans-serif'; ctx.lineWidth = 3; ctx.strokeStyle = '#080706'; const ly = label === 'Trade dock' ? py + 17 : py - 7; ctx.strokeText(label, px + 7, ly); ctx.fillText(label, px + 7, ly); }
};
for (const m of machines) if (m && m.type === 'dock' && (!SET.fog || fogSeen(m.x, m.y) >= 64)) marker(m.x + m.w / 2, m.y + m.h / 2, '#e59a52', 'Trade dock');
marker(bot.x, bot.y, '#c9fff2', 'You are here');
}
function mapFit() { const c = $('map-canvas'); return Math.min(c.clientWidth / W, c.clientHeight / H); }
function drawFullMap() {
if (GAME.loading || !GAME.running || $('m-map').hidden || !MAP.image) return;
const c = $('map-canvas'); c.width = Math.max(1, c.clientWidth); c.height = Math.max(1, c.clientHeight);
MAP.zoom = Math.max(mapFit(), Math.min(4, MAP.zoom));
const hx = c.width / (2 * MAP.zoom), hy = c.height / (2 * MAP.zoom);
MAP.x = hx >= W / 2 ? W / 2 : Math.max(hx, Math.min(W - hx, MAP.x));
MAP.y = hy >= H / 2 ? H / 2 : Math.max(hy, Math.min(H - hy, MAP.y));
drawMap(c, MAP.x, MAP.y, MAP.zoom, true);
}
function openMap() {
if (!GAME.running || GAME.loading) return;
openModal('m-map'); MAP.x = bot.x; MAP.y = bot.y; MAP.zoom = mapFit(); drawFullMap();
}
function zoomMap(factor, px, py) {
const c = $('map-canvas'), old = MAP.zoom, z = Math.max(mapFit(), Math.min(4, old * factor));
MAP.x += ((px === undefined ? c.width / 2 : px) - c.width / 2) * (1 / old - 1 / z);
MAP.y += ((py === undefined ? c.height / 2 : py) - c.height / 2) * (1 / old - 1 / z);
MAP.zoom = z; drawFullMap();
}
function zoomMinimap(d) { SET.minimapZoom = Math.max(1, Math.min(16, SET.minimapZoom * (d > 0 ? 0.5 : 2))); saveSettings(); MAP.miniT = 0; }
$('mini-plus').onclick = () => zoomMinimap(1);
$('mini-minus').onclick = () => zoomMinimap(-1);
$('mini-map').onclick = openMap;
$('map-plus').onclick = () => zoomMap(2);
$('map-minus').onclick = () => zoomMap(0.5);
$('map-centre').onclick = () => { MAP.x = bot.x; MAP.y = bot.y; drawFullMap(); };
$('map-canvas').addEventListener('wheel', e => { e.preventDefault(); const r = e.currentTarget.getBoundingClientRect(); zoomMap(e.deltaY < 0 ? 1.25 : 0.8, e.clientX - r.left, e.clientY - r.top); }, { passive: false });
$('map-canvas').onpointerdown = e => { if (e.button !== 0) return; MAP.drag = [e.clientX, e.clientY]; e.currentTarget.setPointerCapture(e.pointerId); };
$('map-canvas').onpointermove = e => { if (!MAP.drag) return; MAP.x -= (e.clientX - MAP.drag[0]) / MAP.zoom; MAP.y -= (e.clientY - MAP.drag[1]) / MAP.zoom; MAP.drag = [e.clientX, e.clientY]; drawFullMap(); };
$('map-canvas').onpointerup = $('map-canvas').onpointercancel = () => { MAP.drag = null; };
window.addEventListener('resize', drawFullMap);
+1 -1
View File
@@ -174,7 +174,7 @@ function controlsHtml() {
['Tools', '1 – 4'], ['Select one to blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall or mold material menu (Build)', 'F'],
['Pick up selection or what you click (Pneumatic nozzle)', 'Left drag · Shift: everything'], ['Select (Pneumatic nozzle)', 'Click a bag name · Q/E'], ['Add to selection (Pneumatic nozzle)', 'Ctrl+click a bag name'], ['Show everything found (Pneumatic nozzle)', 'Hold Ctrl in the bag'], ['Clear selection (Pneumatic nozzle)', 'Clear in the bag'], ['Blow everything loose in the bag', 'Shift + right click'], ['Nozzle or brush size', '[ ] or Shift + wheel'],
['Replace what is built (Build)', 'Hold G'], ['Copy a box of what you built', 'Hold C, drag'], ['Cut a box (refunded, paste it back)', 'Hold X, drag'], ['Paste again', 'V'], ['Undo building', 'Ctrl + Z'], ['Blueprint library', 'B'], ['Zoom', 'Mouse wheel'], ['Interact (gates, molds, fans, filters)', 'F'], ['Thermal lens', 'H'], ['Info overlay: filters, belt and tube directions', 'I'], ['Bot lamp', 'L'],
['Pick the piece under the cursor (with its direction and settings)', 'Middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'M'],
['Pick the piece under the cursor (with its direction and settings)', 'Middle mouse'], ['Notebook', 'N'], ['Clear all blueprint ghosts', 'Delete'], ['Save', 'Ctrl + S'], ['Pause · menu', 'Esc'], ['Mute', 'Shift + M'], ['Full map', 'M'], ['Minimap zoom', '+ / − on minimap'],
];
const pad = [
['Fly', 'Left stick'], ['Boost', 'Left stick pushed all the way'], ['Aim', 'Right stick'], ['Use tool · suck', 'RT'], ['Second action · blow', 'LT'], ['Tools', 'LB / RB'],
+4 -4
View File
@@ -16,7 +16,7 @@ function enterGame() {
GAME.status = ''; GAME.statusT = 0;
$('title').hidden = true; $('hud').hidden = false;
GAME.running = true; GAME.paused = false;
buildSky(); resizeRender(); snapCamera();
buildSky(); resizeRender(); snapCamera(); resetMap();
GAME.lastAutosave = performance.now();
GAME.drag = null; GAME.lmb = GAME.rmb = false; lastBagKey = '';
hudDirty = true; renderHotbar();
@@ -97,11 +97,11 @@ async function refreshContinue() {
}
// ---------- Modals ----------
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-dialog'].some(id => !$(id).hidden); }
function anyModal() { return ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-map', 'm-dialog'].some(id => !$(id).hidden); }
function openModal(id) { $(id).hidden = false; GAME.paused = true; setTimeout(() => { const f = $(id).querySelector('button, select, input'); if (f && !GAME.usingPadMenu) { /* keep focus neutral for mouse users */ } }, 0); }
function closeModal(id) { $(id).hidden = true; if (id === 'm-dialog') GAME.editRun = null; if (!anyModal()) GAME.paused = false; }
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
function topModal() { for (const id of ['m-dialog', 'm-worlds', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
function closeAllModals() { ['m-pause', 'm-settings', 'm-notebook', 'm-worlds', 'm-map', 'm-dialog'].forEach(id => $(id).hidden = true); GAME.paused = false; }
function topModal() { for (const id of ['m-dialog', 'm-map', 'm-worlds', 'm-settings', 'm-notebook', 'm-pause']) if (!$(id).hidden) return id; return null; }
function dialog(title, bodyHtml, buttons) {
$('dlg-title').textContent = title; $('dlg-body').innerHTML = bodyHtml;
const foot = $('dlg-foot'); foot.innerHTML = '';
+1
View File
@@ -9,6 +9,7 @@ const SET_TABS = {
{ k: 'fpsCap', label: 'Frame cap', d: 'Uncapped follows your display refresh rate.', type: 'select', opts: [[30, '30 fps'], [60, '60 fps'], [120, '120 fps'], [0, 'Uncapped']] },
{ k: 'fullscreen', label: 'Fullscreen', type: 'button', text: 'Toggle fullscreen', fn: toggleFullscreen },
{ k: 'cursorLabel', label: 'Name next to the cursor', d: 'Shows what you are pointing at right beside the cursor.', type: 'toggle' },
{ k: 'minimap', label: 'Show minimap', type: 'toggle' },
{ k: 'showPerf', label: 'Performance stats', d: 'Frame time, simulation time and active areas.', type: 'toggle' },
{ k: 'cameraSmooth', label: 'Camera smoothing', type: 'range', min: 0, max: 0.95, step: 0.05, fmt: v => Math.round(v * 100) + '%' },
{ k: 'cameraLead', label: 'Camera looks toward the pointer', d: 'The view drifts toward the mouse or right stick. Turn off to keep the bot centred.', type: 'toggle' },
+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,
fog: true, thermalPalette: 'inferno', simSpeed: 1, maxSteps: 3,
minimap: true, minimapZoom: 4, 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,
};
+25
View File
@@ -0,0 +1,25 @@
---
id: t32
title: Minimap with zoom, and a full map
report: |
(agent) A minimap sits in the bottom-right corner, centred on your bot, with − and + zoom and a
Map button. M opens the full map of the world: scroll to zoom, drag to pan, "Centre on bot",
and markers for you and the trade dock. Unexplored ground is black on both. The onboarding
cards now pop up at the bottom centre, mute moved to Shift+M, and "Show minimap" in settings
hides it. It costs about 0.1 ms a frame. Please try it on a Huge world, and say whether the
minimap's size and position work.
status: Review
created: 2026-10-24
updated: 2026-10-24T23:23:43Z
---
## Context
- Big worlds and several claims make it easy to lose your way. Add a small minimap in the bottom-right corner, with zoom buttons and a button that opens a full map of the world. The full map opens with M too (mute moves to Shift+M and stays in settings). Onboarding cards move from the bottom right to the bottom centre to make room. Unexplored ground stays black on both maps.
## Subtasks
- [x] Minimap in the bottom-right corner around the bot, with + and − zoom and a button for the full map
- [x] Full map (M): the whole world, zoom with the wheel or buttons, drag to pan, markers for the bot and the dock
- [x] Both maps respect fog of war and keep up with digging and building without slowing the game
- [x] Onboarding cards move to the bottom centre; mute moves to Shift+M
+53 -3
View File
@@ -175,6 +175,55 @@ with sync_playwright() as p:
ev("SET.zoom = 4; resizeRender(); R.snapNext = true;") # the rest of this play-through was laid out at zoom 4
ev(f"SET.look = '{look}'"); pg.wait_for_timeout(200)
check(f'the {look} look is drawing', ev('R.smoothOn') == (look == 'smooth'), ev('[SET.look, R.smoothOn, SM.ok]'))
# Maps use the real frame loop and keyboard, including settings persistence.
pg.wait_for_function('MAP.image && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
check('minimap lives in bottom right with its controls', ev("!!document.querySelector('#hud .br #minimap canvas') && ['mini-plus', 'mini-minus', 'mini-map'].every(id => $(id).tagName === 'BUTTON')"))
check('cards live in bottom centre above build bar', ev("$('cards').parentElement.matches('#hud .bc') && $('cards').nextElementSibling === $('buildbar')"))
ev('resetMap(); openMap()')
pg.wait_for_function('MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
check('open map redraws when the initial cache fill finishes', ev("(() => { const c = $('map-canvas'), x = Math.floor(c.width / 2 + (W / 2 - MAP.x) * MAP.zoom), y = Math.floor(c.height / 2 + (10 - MAP.y) * MAP.zoom); return c.getContext('2d').getImageData(x, y, 1, 1).data[2] === 142; })()"))
pg.keyboard.press('Escape')
mz = ev('SET.minimapZoom')
pg.click('#mini-plus'); check('minimap plus zooms in', ev('SET.minimapZoom') == mz / 2)
pg.click('#mini-minus'); check('minimap minus restores saved zoom', ev("JSON.parse(localStorage.getItem('deepgrain.settings')).minimapZoom") == mz)
pg.keyboard.press('m'); check('M opens map and pauses', ev("!$('m-map').hidden && GAME.paused"))
pg.screenshot(path=str(out / 'map-full.png'))
z = ev('MAP.zoom'); pg.click('#map-plus'); check('full map zooms in', ev('MAP.zoom') > z)
pg.mouse.move(740, 400)
anchor = ev('[MAP.x + 100 / MAP.zoom, MAP.y]')
pg.mouse.wheel(0, -100); pg.wait_for_timeout(100)
check('wheel zoom stays anchored to the cursor', ev(f'Math.abs(MAP.x + 100 / MAP.zoom - {anchor[0]}) < 2'))
pan = ev('[MAP.x, MAP.y]')
pg.mouse.move(640, 400); pg.mouse.down(); pg.mouse.move(690, 430); pg.mouse.up()
check('left drag pans the map', ev(f'MAP.x < {pan[0]} && MAP.y < {pan[1]}'))
pg.click('#map-centre'); check('centre button finds bot', ev('Math.abs(MAP.x - bot.x) < 2'))
pg.keyboard.press('Escape'); check('Esc closes map', ev("$('m-map').hidden && !GAME.paused"))
pg.keyboard.press('m'); pg.keyboard.press('m'); check('M closes map', ev("$('m-map').hidden && !GAME.paused"))
muted = ev('SET.muted'); pg.keyboard.press('Shift+m'); check('Shift M toggles mute', ev('SET.muted') != muted)
pg.keyboard.press('Shift+m')
ev("openSettings('Display')"); pg.click('#set-minimap'); pg.wait_for_timeout(150)
check('Show minimap setting hides panel', not pg.is_visible('#minimap'))
pg.click('#set-minimap'); pg.keyboard.press('Escape')
check('map cache tracks nearby changed grains', ev("""async () => {
GAME.paused = true;
const x = Math.floor(bot.x / MAP_S) * MAP_S, y = Math.floor(bot.y / MAP_S) * MAP_S;
const saved = []; for (let yy = y; yy < y + MAP_S; yy++) for (let xx = x; xx < x + MAP_S; xx++) saved.push([xx, yy, mat[yy * W + xx], temp[yy * W + xx], flags[yy * W + xx]]);
const pixel = () => Array.from(MAP.ctx.getImageData(x / MAP_S, y / MAP_S, 1, 1).data).join(',');
const before = pixel(); for (const [xx, yy] of saved) setCell(yy * W + xx, GOLD, 20);
await new Promise(r => setTimeout(r, 200)); const changed = pixel() !== before;
for (const [xx, yy, m, t, fl] of saved) setCell(yy * W + xx, m, t, fl);
GAME.paused = false; return changed;
}"""))
ev('SET.fog = true'); pg.wait_for_function('MAP.fog && MAP.init >= Math.ceil(W / 32) * Math.ceil(H / 32)')
check('unexplored map pixel is black', ev("Array.from(MAP.ctx.getImageData(0, Math.floor((H - 32) / MAP_S), 1, 1).data).slice(0, 3).every(v => v === 0)"))
ev('SET.fog = false'); pg.wait_for_timeout(400)
pg.screenshot(path=str(out / 'map-hud.png'))
timings = ev("""async () => {
const sample = async on => { SET.minimap = on; let sum = 0; for (let n = 0; n < 150; n++) { await new Promise(requestAnimationFrame); if (n >= 30) sum += frameMs; } return sum / 120; };
const off = await sample(false), on = await sample(true); return [off, on];
}""")
check('frame comparison uses a Medium world', ev('W === 3072 && H === 1280'))
check('Medium minimap frame cost stays within 5 ms', timings[1] <= timings[0] + 5, f'off {timings[0]:.2f} ms; on {timings[1]:.2f} ms')
# Camera lead changes the view, not the bot or its aim.
mouse = ev('[GAME.mouse.x, GAME.mouse.y]')
ev("openSettings('Display')")
@@ -184,8 +233,8 @@ with sync_playwright() as p:
pg.keyboard.press('Escape')
paused = ev('GAME.paused')
ev("GAME.paused = true; SET.cameraLead = false; $('cards').replaceChildren()") # cards cover the corner
pg.mouse.move(1240, 740); pg.wait_for_timeout(1000)
check('camera test pointer reaches the world', ev('GAME.mouse.in && GAME.mouse.x === 1240 && GAME.mouse.y === 740'))
pg.mouse.move(1000, 740); pg.wait_for_timeout(1000)
check('camera test pointer reaches the world', ev('GAME.mouse.in && GAME.mouse.x === 1000 && GAME.mouse.y === 740'))
camera_offset = """(() => {
const v = viewCells(), x = bot.x + bot.w / 2 - v.w / 2, y = bot.y + bot.h / 2 - v.h / 2;
const tx = v.w >= W ? (W - v.w) / 2 : Math.max(0, Math.min(W - v.w, x));
@@ -256,6 +305,7 @@ with sync_playwright() as p:
pg.screenshot(path=str(out / '01-dug.png'))
check('trees live in the background layer', ev('bgm.some(v => v === WOOD)') and ev('(() => { for (let i = 0; i < N; i++) if (mat[i] === LEAVES) return false; return true; })()'))
ev("SET.cardSeconds = 0; $('cards').replaceChildren()") # centred cards cover the mouse work area; discovery was checked above
# 2. vacuum, left button: lock onto what you click
pg.keyboard.press('2'); settle(100)
check('Pneumatic nozzle card title and width match the selected tool', ev("GAME.tool === 1 && $('selcard').querySelector('.name').textContent === 'Pneumatic nozzle' && $('selcard').firstElementChild.querySelector('.chip').textContent === `width ${GAME.brush[1]}` && !$('selcard').querySelector('.sub .chip')"))
@@ -865,7 +915,7 @@ with sync_playwright() as p:
cp = cc.new_page(); cp.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
cp.goto(url); cp.wait_for_function("!$('title').hidden")
ev = cp.evaluate
ev("async () => { SET.fog = false; await startNewWorld(424242, 512, 384, 'First claim'); GAME.paused = true; }")
ev("async () => { SET.cardSeconds = 9; SET.fog = false; await startNewWorld(424242, 512, 384, 'First claim'); GAME.paused = true; }")
check('fresh world has a career with claim 1', ev("async () => { const c = await DB.get('career:' + GAME.careerId); return c.claimNo === 1 && c.claims.length === 1 && c.claims[0] === GAME.worldId && (await listWorlds())[0].claimNo === 1; }"))
check('unpaid claim cannot open another', ev('async () => !await openNewClaim() && career.claims.length === 1'))
ev("openNotebook('Claims')")