diff --git a/src/styles/style.css b/src/styles/style.css
index d4b5291..86162c2 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -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)); }
+}
diff --git a/src/ui/info.js b/src/ui/info.js
index ebb665f..e444d0c 100644
--- a/src/ui/info.js
+++ b/src/ui/info.js
@@ -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;
diff --git a/src/ui/input.js b/src/ui/input.js
index 3f294d9..53220ab 100644
--- a/src/ui/input.js
+++ b/src/ui/input.js
@@ -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;
diff --git a/src/ui/loop.js b/src/ui/loop.js
index 2148d98..d79634b 100644
--- a/src/ui/loop.js
+++ b/src/ui/loop.js
@@ -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) {
diff --git a/src/ui/map.js b/src/ui/map.js
new file mode 100644
index 0000000..678f03e
--- /dev/null
+++ b/src/ui/map.js
@@ -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);
diff --git a/src/ui/notebook.js b/src/ui/notebook.js
index a5bfe45..30b65fa 100644
--- a/src/ui/notebook.js
+++ b/src/ui/notebook.js
@@ -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'],
diff --git a/src/ui/session.js b/src/ui/session.js
index e4646ed..c49954d 100644
--- a/src/ui/session.js
+++ b/src/ui/session.js
@@ -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 = '';
diff --git a/src/ui/settings.js b/src/ui/settings.js
index 90fc4f4..b799bf6 100644
--- a/src/ui/settings.js
+++ b/src/ui/settings.js
@@ -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' },
diff --git a/src/ui/state.js b/src/ui/state.js
index bb134f8..29df6d4 100644
--- a/src/ui/state.js
+++ b/src/ui/state.js
@@ -5,7 +5,7 @@
const SET_DEFAULT = {
zoom: 12, integerScale: true, renderScale: 1, uiScale: 1, fpsCap: 0, showPerf: false, shake: true, cameraSmooth: 0.7, cameraLead: true,
look: 'smooth', lighting: 'full', lightEvery: 1, ambient: 0.12, daylight: 1, glow: 'low', grain: 1, incandescence: true, particles: true,
- 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,
};
diff --git a/test/play.py b/test/play.py
index cc8f293..a8f3408 100644
--- a/test/play.py
+++ b/test/play.py
@@ -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')")