diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 47363a4..8e6f4b9 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -250,6 +250,11 @@ above, including: (no menu). Selected materials stay selected when they run out, and picking up never selects anything (both reverse the first t20 version). Clear: a button in the bag, clicking the only selected row, or gamepad B. + Then, after playing: blowing out the last of a material deselects it again (an empty selection + goes back to picking what you click; a selection kept on an empty material got in the way). A + material selected before you have any stays selected. Ctrl + wheel scrolls the bag instead of + zooming the page, and a click while Ctrl is held adds to the selection even when the click + arrives without the Ctrl flag (seen on the owner's Linux setup). - **Fog of war also reveals the ground around the bot (t17, 2026-10-24).** About 20 grains in every direction, through rock, fully clear with a soft edge. Line of sight alone hid everything inside diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 2c1b6ad..0ab7773 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -404,7 +404,7 @@ The build menu along the bottom: categories, piece tiles and their icons. - functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar - variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu -### src/ui/selection.js (241 lines) +### src/ui/selection.js (242 lines) The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. - functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag - variables: lastBagKey @@ -413,12 +413,12 @@ 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 (80 lines) +### src/ui/input.js (89 lines) Keyboard and mouse listeners. - functions: isTyping - variables: canvas -### src/ui/actions.js (237 lines) +### src/ui/actions.js (239 lines) What input does: overlays, F, pick, copy / cut / paste, undo, building and tool use each frame. Which overlays are on, shown as a coloured frame round the screen and a named badge - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick - variables: ovShown diff --git a/docs/agent-log.md b/docs/agent-log.md index 7168f06..d3a96a7 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -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: t20 follow-ups: deselect when blown out, Ctrl + wheel, Ctrl+click (Claude Code) + +- Blowing the last of a selected material removes it from the selection (`toolTick`); materials + selected before you have any stay. Window `wheel` listener: Ctrl + wheel never zooms the page + and scrolls the scrollable panel under the mouse (the Ctrl bag list). Bag clicks add when + `GAME.ctrl` is set even if the click has no `ctrlKey` (owner had to hold Ctrl+Shift; not + reproduced in headless Chromium, which passes ctrlKey). +- Files: `src/ui/{actions,input,selection,notebook}.js`, `test/play.py` (229 checks). +- Tests: `run_all.py --browser` all passed. + ## 2026-10-24: t20 follow-up: one selection, full list while Ctrl is held (Claude Code, Codex) - `GAME.blow` is now the selection for picking up and blowing out (`vacFilter` -2 = the selection). diff --git a/src/ui/actions.js b/src/ui/actions.js index 75b0cb4..8724308 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -222,6 +222,8 @@ function toolTick() { const list = GAME.blowAll ? blowAllList() : blowList(); if (!GAME.blowAll && !list.length) { setStatus('None of the selected materials are in the bag.'); return; } const r = GAME.blowAll && !list.length ? { placed: 0, status: 'Nothing loose in the bag to blow.' } : toolBlow(ax, ay, GAME.brush[1], list[0] || 0, list.length > 1 ? list : null); + // blown down to nothing: it leaves the selection, so an empty selection picks up what you click again + for (const m of list) if (bag[m] <= 0 && GAME.blow.delete(m)) { lastBagKey = ''; hudDirty = true; } if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status); } else if (t === 'build') { const key = `${(ax / BS) | 0},${(ay / BS) | 0}`; diff --git a/src/ui/input.js b/src/ui/input.js index 8532b1a..3f294d9 100644 --- a/src/ui/input.js +++ b/src/ui/input.js @@ -68,6 +68,15 @@ canvas.addEventListener('wheel', e => { if (e.shiftKey || e.ctrlKey) setBrush(d); else { SET.zoom = Math.max(2, Math.min(12, SET.zoom + d)); saveSettings(); resizeRender(); R.snapNext = true; } }, { passive: false }); +// Ctrl + wheel would zoom the browser page (Ctrl shows the full bag list): scroll the list under the mouse instead +window.addEventListener('wheel', e => { + if (!e.ctrlKey || e.target === canvas) return; + e.preventDefault(); + for (let n = e.target; n && n !== document.body; n = n.parentElement) { + const oy = getComputedStyle(n).overflowY; + if ((oy === 'auto' || oy === 'scroll') && n.scrollHeight > n.clientHeight) { n.scrollTop += e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? n.clientHeight : 1); break; } + } +}, { passive: false }); window.addEventListener('resize', () => { if (R.canvas) resizeRender(); }); document.addEventListener('visibilitychange', () => { if (document.hidden && GAME.running) saveWorld('auto', true); diff --git a/src/ui/notebook.js b/src/ui/notebook.js index a37f8d6..1a9a09b 100644 --- a/src/ui/notebook.js +++ b/src/ui/notebook.js @@ -181,7 +181,7 @@ function controlsHtml() { ]; const tools = [ ['1 Resonance drill', 'Hold left click to send pulses from the bot toward the cursor: rock they shake hard enough shatters and falls as rubble, ore, coal or scrap, and trees come apart into wood chunks and acorns. Q and E set the pulse width: narrow bores a tunnel fast, wide clears a big cone slowly, medium is in between. R over a material tunes the drill to it, so only that shatters (great for pulling a vein out of the rock); R again untunes. Built blocks are never harmed. Better bits dig harder rock, faster and further.'], - ['2 Vacuum', "Left drag picks up the selection, or what you click if nothing is selected. Right click blows the selection. Click a bag name or Q/E selects one; Ctrl+click mixes. Hold Ctrl to see everything found. Clear removes the selection. Bars blow alone. Selections stay when materials run out. Shift while not flying takes everything; Shift + right click blows everything loose in the bag."], + ['2 Vacuum', "Left drag picks up the selection, or what you click if nothing is selected. Right click blows the selection. Click a bag name or Q/E selects one; Ctrl+click mixes. Hold Ctrl to see everything found. Clear removes the selection. Bars blow alone. Blowing out the last of a material deselects it. Shift while not flying takes everything; Shift + right click blows everything loose in the bag."], ['3 Build', 'Places pieces on a grid of 4×4 blocks: wall blocks, belts, tubes, filters, grates, gates, pipes, pumps and outlets, machines (fans, the Refiner) and molds. Drag for a line, hold Shift while dragging for a filled rectangle. Right click takes a piece back. Your bot flies through anything you build.'], ['4 Igniter', 'Heats a small spot to 640°C, and its flame reaches a few grains into a pile. Enough to light coal and wood that touch air.'], ['Copy, cut and paste', 'Hold C (copy) or X (cut) and drag a box over what you built; let go and it is picked up, ready to paste. Cut takes it down and refunds it to your bag. Click to place ghosts: your bot builds them from your bag when it is close. R flips, V pastes again, right click or Esc stops. Ctrl+Z undoes building. The notebook (B) keeps saved blueprints and a few starter designs.'], diff --git a/src/ui/selection.js b/src/ui/selection.js index a893407..e62a7ff 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -217,10 +217,11 @@ function renderBag() { const row = el('div', { class: 'bagrow' + (bag[m] > 0 ? '' : ' none') + (sel ? ' on' : '') }); const b = el('button', { class: 'pick', title: 'Select ' + MD[m].name }); b.innerHTML = `${esc(MD[m].name)}${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}`; - // Ctrl+click adds to or removes from the selection. + // Ctrl+click adds to or removes from the selection. GAME.ctrl too: on some Linux setups the click + // arrives without ctrlKey even though the Ctrl keydown (which opened the full list) was seen. b.oncontextmenu = e => e.preventDefault(); b.onclick = e => { - if (e.ctrlKey || e.metaKey) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; } + if (e.ctrlKey || e.metaKey || GAME.ctrl) { toggleBlow(m); if (t !== 'vacuum') selectTool(1); return; } if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); return; } else if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; } else { GAME.blow = GAME.blow.size === 1 && GAME.blow.has(m) ? new Set() : new Set([m]); if (t !== 'vacuum') selectTool(1); } diff --git a/test/play.py b/test/play.py index 54b3f04..ef52dcd 100644 --- a/test/play.py +++ b/test/play.py @@ -333,10 +333,12 @@ with sync_playwright() as p: ev('GAME.blow.clear(); lastBagKey = ""; hudDirty = true'); settle(80) pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80) hold(dx_, dy_, 400, 'right'); settle(400) - check('blowing the last coal keeps it selected', ev('bag[COAL] === 0 && GAME.blow.has(COAL)')) + check('blowing the last coal deselects it', ev('bag[COAL] === 0 && !GAME.blow.size')) + check('a selection emptied by blowing picks up what you click again', ev("(() => { GAME.vacFilter = 0; primaryPress(false); const f = GAME.vacFilter; primaryRelease(false); return f !== -2; })()")) + check('a material selected before you have any stays selected', ev("(() => { GAME.blow = new Set([COAL]); const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; return GAME.blow.has(COAL); })()")) check('selected but none in bag blows nothing', ev("(() => { const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; renderSelCard(); return bagTotal === n && flyers.length === f && GAME.status === 'None of the selected materials are in the bag.' && $('selcard').querySelector('.vacchoice').textContent === 'none in bag'; })()")) check('nothing selected blows nothing and explains how to choose', ev("(() => { GAME.blow.clear(); const n = bagTotal, f = flyers.length; GAME.rmb = true; secondaryPress(false); toolTick(); GAME.rmb = false; return bagTotal === n && flyers.length === f && GAME.status === 'Nothing selected: click or Q/E in the bag.'; })()")) - check('a mix keeps exhausted coal selected and continues blowing ore', ev("(() => { const b = bag.slice(), total = bagTotal, f = flyers.length; bag.fill(0); bag[COAL] = 1; bag[CUORE] = 100; bagTotal = 101; GAME.blow = new Set([COAL, CUORE]); GAME.rmb = true; secondaryPress(false); toolTick(); const ore = bag[CUORE]; toolTick(); GAME.rmb = false; const ok = !bag[COAL] && ore > 0 && bag[CUORE] < ore && GAME.blow.size === 2 && GAME.blow.has(COAL) && GAME.blow.has(CUORE); bag.set(b); bagTotal = total; flyers.length = f; return ok; })()")) + check('a mix drops exhausted coal and continues blowing ore', ev("(() => { const b = bag.slice(), total = bagTotal, f = flyers.length; bag.fill(0); bag[COAL] = 1; bag[CUORE] = 100; bagTotal = 101; GAME.blow = new Set([COAL, CUORE]); GAME.rmb = true; secondaryPress(false); toolTick(); const ore = bag[CUORE]; toolTick(); GAME.rmb = false; const ok = !bag[COAL] && ore > 0 && bag[CUORE] < ore && GAME.blow.size === 1 && GAME.blow.has(CUORE); bag.set(b); bagTotal = total; flyers.length = f; return ok; })()")) # Shift + right click blows an even mix of everything loose in the bag, without changing the choice refill('bag[COAL] = 60; bag[CUORE] = 60') ev('GAME.blow = new Set([COAL]); lastBagKey = ""; hudDirty = true;') @@ -386,6 +388,11 @@ with sync_playwright() as p: pg.locator('#baglist .none .pick[title="Select Sand"]').click(modifiers=['Control']); settle(80) check('Ctrl click selects a material not in the bag and keeps its row after release', ev("GAME.blow.has(SAND) && !GAME.ctrl && !!$('baglist').querySelector('.bagrow.none.on .pick[title=\"Select Sand\"]')")) check('drag picks the newly selected missing material', ev("(() => { const aim = GAME.aim, x = Math.floor(bot.x)+6, y = Math.floor(bot.y)-6, i = y*W+x, old = [mat[i],temp[i],flags[i]]; setCell(i,SAND,20,0); GAME.aim = {x,y}; GAME.lmb = true; primaryPress(false); toolTick(); primaryRelease(false); GAME.lmb = false; GAME.aim = aim; const ok = bag[SAND] > 0; setCell(i,...old); return ok; })()")) + # Ctrl held (keydown seen) but the click itself arrives without ctrlKey, as on some Linux setups: still adds + ev("GAME.blow = new Set([COAL]); seen[GRAVEL] = 1; bagTotal -= bag[GRAVEL]; bag[GRAVEL] = 0; window.dispatchEvent(new KeyboardEvent('keydown', {key:'Control', ctrlKey:true})); renderBag()") + pg.locator('#baglist .none .pick[title="Select Gravel"]').click(); settle(80) + check('a click while Ctrl is held adds even without ctrlKey on the click', ev('GAME.blow.has(COAL) && GAME.blow.has(GRAVEL)'), ev('[...GAME.blow]')) + ev("GAME.blow.delete(GRAVEL); GAME.blow.add(SAND)") ev("GAME.blow.delete(SAND); bagTotal -= bag[SAND]; bag[SAND] = 0; window.dispatchEvent(new KeyboardEvent('keyup', {key:'Control'})); renderBag()") check('Ctrl release hides unselected missing materials', ev("""!GAME.ctrl && !$('baglist').querySelector('.bagrow.none .pick[title="Select Sand"]')""")) ev("window.dispatchEvent(new KeyboardEvent('keydown', {key:'Meta', metaKey:true})); renderBag()") @@ -394,6 +401,13 @@ with sync_playwright() as p: check('Meta release hides unselected missing materials', ev('!GAME.ctrl')) ev("window.dispatchEvent(new KeyboardEvent('keydown', {key:'Control', ctrlKey:true})); window.dispatchEvent(new Event('blur')); renderBag()") check('blur clears the modifier', ev('!GAME.ctrl')) + # Ctrl + wheel over the bag scrolls the list instead of zooming the page + r = ev("""(() => { const b = $('baglist'), mh = b.style.maxHeight; b.style.maxHeight = '40px'; b.scrollTop = 0; + const w = new WheelEvent('wheel', { deltaY: 30, ctrlKey: true, bubbles: true, cancelable: true }); b.querySelector('.pick').dispatchEvent(w); + const out = [w.defaultPrevented, b.scrollTop]; b.style.maxHeight = mh; b.scrollTop = 0; return out; })()""") + check('Ctrl + wheel over the bag scrolls it and never zooms the page', r[0] and r[1] > 0, r) + r = ev("(() => { const w = new WheelEvent('wheel', { deltaY: 30, ctrlKey: true, bubbles: true, cancelable: true }); $('selcard').dispatchEvent(w); return w.defaultPrevented; })()") + check('Ctrl + wheel over other HUD panels never zooms the page', r) pg.locator('[data-bag-clear]').click(); settle(80) check('Clear clears the selection', ev('!GAME.blow.size')) pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80)