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/tasks/t20.md b/tasks/t20.md index adaa068..b016617 100644 --- a/tasks/t20.md +++ b/tasks/t20.md @@ -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 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)