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 945e5205b8
commit c8d397e19a
15 changed files with 219 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,
};
+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')")