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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"ui/gamepad.js",
|
||||
"ui/previews.js",
|
||||
"ui/events.js",
|
||||
"ui/map.js",
|
||||
"ui/loop.js"
|
||||
]
|
||||
}
|
||||
|
||||
+10
-1
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = '';
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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')")
|
||||
|
||||
Reference in New Issue
Block a user