Vacuum: deselect what is blown out, Ctrl + wheel scrolls the bag, Ctrl+click fix (t20)

Blowing out the last of a selected material removes it from the selection,
so an emptied selection picks up what you click again. Ctrl + wheel never
zooms the page and scrolls the bag list under the mouse. A bag click while
Ctrl is held adds to the selection even if the click lacks the Ctrl flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 14:16:47 -04:00
co-authored by Claude Opus 5.5
parent cd483dd546
commit 248aba439e
9 changed files with 56 additions and 12 deletions
+5
View File
@@ -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
+3 -3
View File
@@ -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
+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: 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).
+2
View File
@@ -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}`;
+9
View File
@@ -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);
+1 -1
View File
@@ -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.'],
+3 -2
View File
@@ -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 = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m] > 0 ? bag[m].toLocaleString('en-US') : '–'}</span>`;
// 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); }
+7 -4
View File
@@ -12,12 +12,13 @@ report: |
what you click (or the first loose grain it reaches). Right click blows the selected materials
that are in the bag, as a mix. Click a bag name or Q/E selects one, Ctrl+click adds or removes.
Hold Ctrl and the bag also lists everything you've found, under "Not in bag", so you can select
materials you don't have yet. Selected materials stay selected when they run out. Clear the
materials you don't have yet. Blowing out the last of a material deselects it (one picked
before you had any stays selected). Ctrl + wheel scrolls the bag list. Clear the
selection with the Clear button in the bag, by clicking the only selected row, or gamepad B.
Shift takes or blows everything.
To check: hold Ctrl, Ctrl+click two materials you don't have, then sweep a mixed pile (only
those come up); blow a coal + ore mix until the coal runs out (ore keeps going, coal stays
selected); Clear, then drag on something (only that comes up).
those come up); blow a coal + ore mix until the coal runs out (ore keeps going, coal is
deselected); Ctrl + wheel over the bag; Clear, then drag on something (only that comes up).
---
# Context
@@ -34,5 +35,7 @@ I dont like the current tool. I want to completely rethink how to pick up and dr
- [x] Remove the pick-up marks (R, Ctrl+right-click, the "Only marked" chip) and the F vacuum menu
- [x] One selection for both: a drag picks up only the selected materials (or what you click when nothing is selected), and right click blows out the selected ones in the bag
- [x] Holding Ctrl lists every material found so far in the bag, below a "Not in bag" divider, so several can be selected before any are picked up
- [x] Selected materials stay selected when they run out; blowing skips them
- [x] Blowing out the last of a selected material deselects it; materials selected before you have any stay selected
- [x] An easy way to clear the selection (a Clear button, clicking the only selected row, gamepad B)
- [x] Ctrl + wheel over the bag scrolls the list instead of zooming the page
- [x] Ctrl+click adds to the selection on setups where the click arrives without the Ctrl flag
+16 -2
View File
@@ -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)