From a730a7d73c083a5e67396571fc870bfade81f909 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sat, 24 Oct 2026 01:26:29 -0400 Subject: [PATCH] Material menu for walls and molds, opened with F (t5) Co-Authored-By: Claude Opus 5.5 --- docs/DESIGN.md | 6 ++++++ docs/PROJECT_MAP.md | 8 ++++---- docs/agent-log.md | 13 +++++++++++++ src/styles/style.css | 10 ++++++++++ src/ui/actions.js | 1 + src/ui/build-menu.js | 5 +++-- src/ui/notebook.js | 4 ++-- src/ui/selection.js | 24 ++++++++++++++++++++---- test/play.py | 21 +++++++++++++++++++++ 9 files changed, 80 insertions(+), 12 deletions(-) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 9865398..fbda544 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -205,6 +205,12 @@ above, including: - **The to-do list is a kandown board.** `NOTES.md` became one task file per note in `tasks/` (Todo for tasks, Backlog for discussions and ideas, Review for "done, please check"). The owner wanted a task tool built for AI agents. +- **Wall and mold materials have a menu (F).** It lists every wall material with what you have, + the cost and the heat it stands; R still cycles. The owner chose F to match the filter menu. +- **The camera's drift toward the pointer is a setting** ("Camera looks toward the pointer", on + by default). The owner doesn't like it. +- **Fog of war reveals by line of sight.** Unexplored ground is black with a soft edge; the sky + and the surface start seen. The owner chose line of sight over a plain circle around the bot. ## Ideas not built yet diff --git a/docs/PROJECT_MAP.md b/docs/PROJECT_MAP.md index 7ad11c8..9a6f78d 100644 --- a/docs/PROJECT_MAP.md +++ b/docs/PROJECT_MAP.md @@ -393,14 +393,14 @@ The notebook (N): milestones, market, wallet, blueprints, controls. The tool hotbar and what is selected. - functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, mixList, ensureDropMat, piece -### src/ui/build-menu.js (101 lines) +### src/ui/build-menu.js (102 lines) 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 (236 lines) +### src/ui/selection.js (252 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, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag +- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleCollect, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag - variables: lastBagKey ### src/ui/info.js (170 lines) @@ -412,7 +412,7 @@ Keyboard and mouse listeners. - functions: isTyping - variables: canvas -### src/ui/actions.js (242 lines) +### src/ui/actions.js (243 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 d82166e..e3efa17 100644 --- a/docs/agent-log.md +++ b/docs/agent-log.md @@ -4,6 +4,19 @@ 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: t5 Material selection menu (Claude Code, Codex) + +- Decided with the owner: F opens it for walls and molds, R keeps cycling. +- `src/ui/selection.js`: `openWallPicker` (a dialog of every wall material: what you have, the + cost for this piece, the heat it stands; owned first, unaffordable dimmed; glass says whether + it is from shards or bars); `pieceCostNow(P, m)` takes a material. +- `src/ui/actions.js`: F with a wall or mold piece opens it. `src/ui/build-menu.js`: the material + chip reads "· R · F" and opens it on click; placement help mentions F. `src/ui/notebook.js`: + Controls and Blueprints text. `src/styles/style.css`: `.wpick`. +- `test/play.py`: F and the chip open it, Brick picks RBRICK, R still cycles, molds have their own + menu and cost, it fits at 1280×800 and 900×700 (screenshots checked). +- Tests: `run_all.py --browser` pass, apart from the `t_smooth` half-resolution flake. + ## 2026-10-24: t10 World name box for a new world (Claude Code, Codex) - `src/shell.html`, `src/styles/style.css`: a Name box across the top of the New world panel. diff --git a/src/styles/style.css b/src/styles/style.css index 97329c8..1c268b4 100644 --- a/src/styles/style.css +++ b/src/styles/style.css @@ -315,6 +315,16 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border- @media (prefers-reduced-motion: reduce) { .toast { animation: none; } } +/* wall material picker */ +.wpick { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; max-height: min(60vh, 490px); overflow-y: auto; padding: 2px; } +.wpick .wp { display: flex; align-items: flex-start; gap: 8px; text-align: left; padding: 8px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 12px; cursor: pointer; min-width: 0; } +.wpick .wp > span:last-child { min-width: 0; overflow-wrap: anywhere; } +.wpick .wp span span { display: block; color: var(--ash); margin-top: 3px; } +.wpick .wp.short { opacity: 0.55; } +.wpick .wp:hover { background: #2d2724; } +.wpick .wp.on { border-color: #e59a52; background: #33261c; } +.wpick .wp:focus-visible, [data-wall-picker]:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; } +[data-wall-picker] { cursor: pointer; } /* filter picker */ .fpick { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 6px; max-height: min(52vh, 420px); overflow-y: auto; padding: 2px; } .fpick .fp { display: flex; align-items: center; gap: 8px; text-align: left; padding: 7px 9px; border-radius: 6px; border: 1px solid var(--soot-4); background: var(--soot-2); color: var(--bone); font: inherit; font-size: 13px; cursor: pointer; } diff --git a/src/ui/actions.js b/src/ui/actions.js index 12d76d1..0f1cb25 100644 --- a/src/ui/actions.js +++ b/src/ui/actions.js @@ -35,6 +35,7 @@ function doInteract() { GAME.editRun = run; return; } + if (toolId() === 'build' && pieceUsesWall(piece())) { openWallPicker(); return; } if (toolId() === 'build' && piece().filter) { openFilterPicker(Object.assign({}, GAME.filterCfg, { dir: GAME.pieceDir.filter || 0 }), f => { GAME.filterCfg = { m: f.m, inv: f.inv }; GAME.pieceDir.filter = f.dir; }, 'New filters'); return; } let msg = ''; outer: for (let r = 0; r <= 2; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) { diff --git a/src/ui/build-menu.js b/src/ui/build-menu.js index 4551ac7..71912d2 100644 --- a/src/ui/build-menu.js +++ b/src/ui/build-menu.js @@ -48,7 +48,7 @@ function buildVariantChips(P) { const dirChip = !P.dirs ? '' : dragging ? `${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag` : `${glyph(dir)} ${esc(dir)} · R`; const fc = GAME.filterCfg; const sws = ms => ms.slice(0, 6).map(m => ``).join('') + (ms.length > 6 ? `+${ms.length - 6}` : ''); - return pieceUsesWall(P) ? `${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R` + return pieceUsesWall(P) ? `` : P.filter ? `${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F${dirChip}` : dir ? dirChip : ''; } @@ -78,7 +78,7 @@ function renderBuildBar() { if (!tip) return; const size = tip.kind === 'machine' ? `${tip.bw}×${tip.bh} blocks` : tip.kind === 'mold' ? `${MOLDS[tip.mold].bw}×${MOLDS[tip.mold].bh} blocks` : '1 block'; pop.innerHTML = `
${pieceIcon(tip.id)}${esc(tip.name)}
${chipCost(pieceCostNow(tip))}${size}${tip.power ? `${tip.power} power` : ''}
-
${esc(tip.desc)}
How to place ${tip.kind === 'block' ? 'Drag for a line, Shift-drag for a rectangle; out of reach becomes ghosts.' : 'Click to place.'}${tip.dirs && tip.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses R.' : tip.dirs ? ' R turns it.' : pieceUsesWall(tip) ? ' R picks the material.' : ''}
+
${esc(tip.desc)}
How to place ${tip.kind === 'block' ? 'Drag for a line, Shift-drag for a rectangle; out of reach becomes ghosts.' : 'Click to place.'}${tip.dirs && tip.kind === 'block' ? ' A dragged line runs the way you drag it; a single click uses R.' : tip.dirs ? ' R turns it.' : pieceUsesWall(tip) ? ' R cycles materials; F opens the material menu.' : ''}
${tip.filter ? `
New ones will ${esc(filterSentence(GAME.filterCfg))}. F sets materials and mode (or click materials in the bag, or T over one); F on a placed filter changes its whole row.
` : ''}
hold G replace · Ctrl+Z undo · right click take back · middle mouse pick · hold C/X copy/cut · V paste
`; const tile = bar.querySelector(`[data-piece="${tip.id}"]`); if (!tile) { pop.hidden = true; return; } @@ -86,6 +86,7 @@ function renderBuildBar() { pop.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2)) - br.left}px`; } function setupBuildBar() { + $('selcard').addEventListener('click', e => { if (e.target.closest('[data-wall-picker]')) openWallPicker(); }); const bar = $('buildbar'); if (!bar) return; bar.addEventListener('click', e => { const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return; diff --git a/src/ui/notebook.js b/src/ui/notebook.js index 50a33af..d780bd0 100644 --- a/src/ui/notebook.js +++ b/src/ui/notebook.js @@ -41,7 +41,7 @@ function renderNotebook() { let h = '

Everything you build snaps to a grid of 4×4-grain blocks. Pieces only move, hold, blow or sense material; every change to the material itself comes from heat. Select pieces with the Build tool (3). Hover a tile for its cost, full description and placement help. Your bot flies straight through anything you build.

'; for (const P of PIECES) { if (P.hidden) continue; - const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R picks it)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1); + const cost = P.kind === 'mold' ? `${MOLDS[P.mold].walls} grains of wall material (R cycles, F opens the menu)` : P.code === BP_WALL ? '16 grains of rubble, lumps or chunks, or 2 bars' : costText(P.cost, 1); const afford = P.kind === 'mold' || P.code === BP_WALL ? true : canAfford(P.cost, 1); const size = P.kind === 'machine' ? `${P.bw}×${P.bh}` : P.kind === 'mold' ? `${MOLDS[P.mold].bw}×${MOLDS[P.mold].bh}` : '1×1'; h += `
${esc(P.name)}${P.power ? esc(P.power + ' power') : P.id === 'wheel' || P.id === 'engine' ? 'makes power' : size + (size === '1×1' ? ' block' : ' blocks')}
${esc(P.desc)}
Cost: ${esc(cost)}
`; @@ -169,7 +169,7 @@ function copyCode(code) { function controlsHtml() { const kb = [ ['Fly', 'W A S D'], ['Boost while flying', 'Shift'], ['Tool modifier (while not flying)', 'Shift'], ['Use tool', 'Left mouse'], ['Second action', 'Right mouse'], - ['Tools', '1 – 4'], ['Cycle what you blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], + ['Tools', '1 – 4'], ['Cycle what you blow · piece', 'Q / E'], ['Switch build tab', 'Shift + Q / E'], ['Variant: material · direction · flip', 'R'], ['Wall or mold material menu (Build)', 'F'], ['Tick/untick what is under the cursor (Vacuum)', 'R or T'], ['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'], diff --git a/src/ui/selection.js b/src/ui/selection.js index 274ec76..067b608 100644 --- a/src/ui/selection.js +++ b/src/ui/selection.js @@ -138,11 +138,27 @@ function openFilterPicker(cur, onDone, title) { box.querySelectorAll('[data-m]').forEach(b => b.onclick = () => { const m = +b.dataset.m; if (sel.has(m)) sel.delete(m); else sel.add(m); playSfx('ui'); sync(); }); sync(); } +function openWallPicker() { + const P = piece(), title = P.kind === 'mold' ? `${P.name} material` : 'Wall material'; + const list = WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m])); + const body = `
${list.map(m => { + const d = MD[WALL_OF[m]], cost = pieceCostNow(P, m), on = m === GAME.wallMat; + // two sources build the same wall (glass from shards or from bars): say which + const name = WALL_LIST.filter(k => WALL_OF[k] === WALL_OF[m]).length > 1 ? `${d.name} from ${MD[m].name.toLowerCase()}` : d.name; + const heat = d.ign ? `burns at ${fmtT(d.ign)}` : d.melt ? `melts at ${fmtT(d.melt)}` : "won't melt"; + return ``; + }).join('')}
`; + dialog(title, body, [{ label: 'Cancel' }]); + $('dlg-body').querySelectorAll('[data-wall]').forEach(b => b.onclick = () => { + GAME.wallMat = +b.dataset.wall; closeModal('m-dialog'); playSfx('ui'); hudDirty = true; lastBagKey = ''; + setStatus(`${P.kind === 'mold' ? P.name : 'Walls'}: ${MD[WALL_OF[GAME.wallMat]].name.toLowerCase()}.`, 'info'); + }); +} function filterBlockAt(x, y) { if (x < 0 || y < 0 || x >= W || y >= H) return -1; const b = ((y / BS) | 0) * BW + ((x / BS) | 0); return blockP[b] === BP_FILTER ? b : -1; } function chipCost(cost) { const ok = canAfford(cost, 1); return `${esc(costText(cost, 1))}`; } -function pieceCostNow(P) { - if (P.kind === 'mold') return wallCost(GAME.wallMat, MOLDS[P.mold].walls); - if (P.kind === 'block' && P.code === BP_WALL) return wallCost(GAME.wallMat, BS * BS); +function pieceCostNow(P, m = GAME.wallMat) { + if (P.kind === 'mold') return wallCost(m, MOLDS[P.mold].walls); + if (P.kind === 'block' && P.code === BP_WALL) return wallCost(m, BS * BS); return P.cost; } function renderSelCard() { @@ -176,7 +192,7 @@ function renderSelCard() { h = '
Build
' + (chips ? `
${chips}
` : '') + '
Place pieces from the menu below. Drag for a line, Shift-drag for a box; out of reach becomes ghosts.
' - + '
Q/E piece · Shift+Q/E tab · R turn or material · hold G replace · right click take back · hold C/X copy/cut · V paste · Ctrl+Z undo
'; + + '
Q/E piece · Shift+Q/E tab · R turn or material · F material menu · hold G replace · right click take back · hold C/X copy/cut · V paste · Ctrl+Z undo
'; } else if (t === 'vacuum') { ensureDropMat(); const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0; diff --git a/test/play.py b/test/play.py index 3e29684..a1e3bc6 100644 --- a/test/play.py +++ b/test/play.py @@ -342,6 +342,23 @@ with sync_playwright() as p: check('the build tool card describes the build tool, not the piece', pg.inner_text('#selcard .name') == 'Build' and 'menu below' in card and ev('piece().name') not in card and ev("costText(pieceCostNow(piece()), 1) || 'free'") not in card and ev('piece().desc') not in card, card[:160]) check('the build menu has no status row', pg.locator('#buildbar .bb-status').count() == 0) check('the build menu has no short description row', pg.locator('.bb-tip').count() == 0) + pg.keyboard.press('f'); settle(120) + check('F opens all wall materials', pg.is_visible('#m-dialog') and 'material' in pg.inner_text('#dlg-title') and pg.locator('.wp').count() == ev('WALL_LIST.length')) + check('wall material selection and costs match the build card', ev("[...document.querySelectorAll('.wp')].every(b => b.getAttribute('aria-checked') === String(+b.dataset.wall === GAME.wallMat) && b.classList.contains('short') === !canAfford(pieceCostNow(piece(), +b.dataset.wall), 1) && b.textContent.includes(costText(pieceCostNow(piece(), +b.dataset.wall), 1)))")) + check('owned wall materials come first', ev("[...document.querySelectorAll('.wp')].map(b => +b.dataset.wall).join() === WALL_LIST.filter(m => bag[m] > 0).concat(WALL_LIST.filter(m => !bag[m])).join()")) + for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]: + pg.set_viewport_size({'width': width, 'height': height}); settle(120) + check(f'wall picker fits at {width}', ev("$('dlg-body').scrollWidth <= $('dlg-body').clientWidth && document.querySelector('.wpick').scrollWidth <= document.querySelector('.wpick').clientWidth")) + pg.screenshot(path=str(out / f'wall-picker{suffix}.png')) + pg.set_viewport_size({'width': 1280, 'height': 800}) + pg.click(f'.wp[data-wall="{ev("RBRICK")}"]'); settle(120) + check('choosing Brick closes the material menu', ev("GAME.wallMat === RBRICK && $('m-dialog').hidden")) + pg.keyboard.press('r'); settle(120) + check('R still cycles after picking a material', ev('GAME.wallMat !== RBRICK')) + pg.click('#selcard [data-wall-picker]'); settle(120) + check('the material chip opens the menu', pg.is_visible('#m-dialog')) + pg.click('#dlg-foot button'); settle(120) + ev('GAME.wallMat = RSTONE; hudDirty = true') for id in ['wall', 'grate']: pg.hover(f'#buildbar [data-piece="{id}"]'); settle(120) check(f'hover shows the full {id} description and name', pg.is_visible('#bb-pop') and pg.inner_text('#bb-pop .bb-title') == ev(f'PIECE_BY_ID.{id}.name') and pg.inner_text('#bb-pop .bb-desc') == ev(f'PIECE_BY_ID.{id}.desc')) @@ -405,6 +422,9 @@ with sync_playwright() as p: # 6. ingot mold: click to place, pour, bars drop below, vacuum them ok = select('3', "piece().id === 'ingotmold'") check('E cycles to the ingot mold', ok) + pg.keyboard.press('f'); settle(120) + check('molds have a named material menu and their own cost', pg.inner_text('#dlg-title') == ev("piece().name + ' material'") and ev("[...document.querySelectorAll('.wp')].every(b => b.textContent.includes(costText(wallCost(+b.dataset.wall, MOLDS[piece().mold].walls), 1)))")) + pg.click('#dlg-foot button'); settle(120) spot = ev(f'''(() => {{ for (let dx = -2; dx <= 10; dx++) for (let dy = 6; dy < 14; dy++) {{ const ox = {bbx} + dx, oy = {bby} - dy; if (!canPlaceMold('ingot', ox - 1, oy) && [0, 1, 2].every(k => entFreeAt((ox - 1) * 4 + 1 + k * 4, oy * 4 + MOLDS.ingot.h)) && inReach(ox * 4 + 8, oy * 4 + 2)) return [ox, oy]; }} return null; }})()''') check('found a spot for the mold', spot is not None) mx, my = blk(spot[0], spot[1]) @@ -417,6 +437,7 @@ with sync_playwright() as p: check('bars set and drop on their own', ev("ents.length === 3 && molds[0].state === 'short' && molds[0].ready.length === 0"), ev('[molds[0].state, ents.length]')) pg.screenshot(path=str(out / '05-mold-ready.png')) check('three bars drop below the mold without F', ev('ents.every(e => e.m === BARCU && e.iy >= molds[0].y + molds[0].h)'), ev('ents.map(e => [e.ix, e.iy])')) + pg.keyboard.press('2'); settle(60) ev("GAME.status = ''; GAME.statusT = 0"); n_toasts = ev("$('toasts').children.length") pg.mouse.move(*cell_to_screen(ev('molds[0].x') + 3, ev('molds[0].y') + 3)); settle(150); pg.keyboard.press('f'); settle(150) check('F on an ingot mold says nothing', ev('GAME.status') == '' and ev("$('toasts').children.length") == n_toasts, ev('GAME.status'))