Build tool: compact top-left card again; status row moved off the build bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 20:24:46 -04:00
co-authored by Claude Opus 5.5
parent da81de1b6c
commit 9addc43be1
7 changed files with 51 additions and 29 deletions
+6 -2
View File
@@ -147,8 +147,9 @@ above, including:
wall material and filter settings. G no longer picks, as the owner requested (it is now Replace).
- **The build menu** is a bar of tabs and icon tiles along the bottom: Structure, Moving,
Liquids, Machines, Molds. Hover a tile for its full description, cost and placement help;
gamepad players see the selected tile’s popup. The top-left card is hidden while building,
with variants, ghosts and status kept in the bar so the world stays clear.
gamepad players see the selected tile’s popup. While building, the top-left card shows a
compact summary (name, cost, variant chips, Replace, ghosts, how to place, status); the full
description is in the hover popup.
- **No length limit when dragging.** Blocks out of the bot's reach become ghosts that it builds
when it gets there.
- **Pipes are thin:** sand, water and anything else loose passes them. Pumps and outlets are
@@ -168,6 +169,9 @@ above, including:
- **Replace is held G, not Ctrl.** The owner hit the browser's Ctrl+W (close tab) and Ctrl+D
(bookmark) while replacing and flying with WASD, and the game ignored WASD while Ctrl was held.
- **The build tool has a compact top-left card** like the other tools. The owner asked for it
back after it was hidden; the long description stays in the hover popup.
## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble.
+3 -3
View File
@@ -392,12 +392,12 @@ 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 (104 lines)
### src/ui/build-menu.js (101 lines)
The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, renderBuildBar, setupBuildBar
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
### src/ui/selection.js (228 lines)
### src/ui/selection.js (234 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
- variables: lastBagKey
+16
View File
@@ -4,6 +4,22 @@ 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-23: compact build card top-left (Claude Code, with Codex)
Note: "build tool has no info box like the other tools." The owner chose a compact card.
- `src/ui/selection.js`: the build tool has a card again: Q, piece name, cost (red if short), E;
variant chips, Replace · G and ghosts waiting; one how-to line; the status line. No long
description; that stays in the hover popup.
- `src/ui/build-menu.js`: the `.bb-status` row is gone (its contents are on the card); the
variant chips are shared through `buildVariantChips(P)`. `src/styles/style.css`,
`docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- `test/play.py`: the card is visible while building with name and cost and without the
description; status and Replace · G are read from the card; no `.bb-status` (122 checks pass).
All 11 headless suites pass; `t_smooth.py`'s half-resolution check is still flaky under load.
- Shots: `shots/build-card-*` (belt and wall, both looks, 1280×800 and 900×700) look right.
- Open: at 900px wide the bottom-left hover panel still sits partly under the build bar.
## 2026-10-23: Replace is held G, not Ctrl (Claude Code, with Codex)
Note: "i see the issue was ctrl+w closing the tab… it happens for the bookmark hotkey too. maybe
-2
View File
@@ -63,8 +63,6 @@ canvas#game.nocursor { cursor: none; }
.bb-tile.on svg { color: var(--melt-hot); }
#buildbar { position: relative; }
.bb-menu { display: flex; flex-direction: column; gap: 6px; }
.bb-status { display: flex; align-items: center; gap: 4px; min-height: 1.4em; overflow-x: auto; }
.bb-status .sw { flex-shrink: 0; }
#bb-pop { position: absolute; bottom: calc(100% + 8px); width: min(calc(300px * var(--ui)), calc(100vw - 32px)); box-sizing: border-box; padding: calc(10px * var(--ui)); display: flex; flex-direction: column; gap: 8px; background: var(--plate); border: 1px solid var(--soot-3); border-radius: 5px; box-shadow: 0 4px 18px #0008; color: var(--bone); pointer-events: none; }
.bb-title { display: flex; align-items: center; gap: 8px; }
.bb-title svg { width: calc(26px * var(--ui)); height: calc(26px * var(--ui)); color: var(--melt-hot); flex-shrink: 0; }
+14 -17
View File
@@ -40,6 +40,18 @@ function cycleBuildCat(d) {
const c = GAME.buildCat != null ? GAME.buildCat : catOf(piece().id);
selectBuildCat((c + d + BUILD_CATS.length) % BUILD_CATS.length);
}
function buildVariantChips(P) {
const dir = P.dirs ? P.dirs[GAME.pieceDir[P.id] || 0] : null;
// while dragging a line of belts, filters or tubes, the direction is the drag's
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓' })[d] || '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(dir)} ${esc(dir)} · R</span>`;
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
return pieceUsesWall(P) ? `<span class="chip hot"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R</span>`
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
: dir ? dirChip : '';
}
function renderBuildBar() {
const bar = $('buildbar'); if (!bar) return;
const show = GAME.running && toolId() === 'build' && !GAME.paste && !GAME.selDrag && !selModeHeld();
@@ -47,10 +59,8 @@ function renderBuildBar() {
if (!show) { lastBuildKey = ''; return; }
const P = piece(), cat = GAME.buildCat != null ? GAME.buildCat : catOf(P.id), hov = GAME.bbHover;
const ids = BUILD_CATS[cat][1], tip = PIECE_BY_ID[GAME.usingPad ? P.id : hov && ids.includes(hov) ? hov : ''];
const status = performance.now() - GAME.statusT < 3200 ? GAME.status : '';
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
const afford = ids.map(id => canAfford(pieceCostNow(PIECE_BY_ID[id]), 1) ? 1 : 0).join('');
const key = [cat, P.id, tip && tip.id, afford, GAME.wallMat, Math.round(money), GAME.replace, GAME.pieceDir[P.id], !!GAME.drag, GAME.dragDir, GAME.filterCfg.inv, GAME.filterCfg.m.join('.'), ng, ghostMissing, status, GAME.statusKind, innerWidth, innerHeight, SET.uiScale].join('|');
const key = [cat, P.id, tip && tip.id, afford, GAME.wallMat, Math.round(money), GAME.pieceDir[P.id], GAME.filterCfg.inv, GAME.filterCfg.m.join('.'), innerWidth, innerHeight, SET.uiScale].join('|');
if (key === lastBuildKey) return;
lastBuildKey = key;
let h = '<div class="bb-head">' + BUILD_CATS.map(([name], k) => `<button class="bb-tab${k === cat ? ' on' : ''}" data-cat="${k}">${esc(name)}</button>`).join('')
@@ -61,22 +71,9 @@ function renderBuildBar() {
h += `<button class="bb-tile${id === P.id ? ' on' : ''}" data-piece="${id}" aria-pressed="${id === P.id}" aria-label="${esc(Q.name)}">${sw}${pieceIcon(id)}<span class="n">${esc(Q.name)}</span><span class="c${ok ? '' : ' bad'}">${esc(costText(cost, 1) || 'free')}</span></button>`;
}
h += '</div>';
if (!bar.querySelector('.bb-menu')) bar.innerHTML = '<div class="bb-menu"></div><div class="bb-status"></div><div id="bb-pop" hidden></div>';
if (!bar.querySelector('.bb-menu')) bar.innerHTML = '<div class="bb-menu"></div><div id="bb-pop" hidden></div>';
const menu = bar.querySelector('.bb-menu');
if (lastBuildMenu !== h) { menu.innerHTML = h; lastBuildMenu = h; }
const dir = P.dirs ? P.dirs[GAME.pieceDir[P.id] || 0] : null;
// while dragging a line of belts, filters or tubes, the direction is the drag's
const dragging = GAME.drag && GAME.dragDir >= 0 && P.dirs;
const glyph = d => ({ left: '←', right: '→', up: '↑', down: '↓' })[d] || '';
const dirChip = !P.dirs ? '' : dragging ? `<span class="chip ok">${glyph(P.dirs[GAME.dragDir])} ${esc(P.dirs[GAME.dragDir])} · from your drag</span>` : `<span class="chip hot">${glyph(dir)} ${esc(dir)} · R</span>`;
const fc = GAME.filterCfg;
const sws = ms => ms.slice(0, 6).map(m => `<span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:2px;background:${rgbOf(m)}"></span>`).join('') + (ms.length > 6 ? `+${ms.length - 6}` : '');
const variant = pieceUsesWall(P) ? `<span class="chip hot"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(WALL_OF[GAME.wallMat])}"></span>${esc(MD[WALL_OF[GAME.wallMat]].name.toLowerCase())} · R</span>`
: P.filter ? `<span class="chip hot">${fc.inv ? 'carries' : 'drops'} ${fc.m.length ? sws(fc.m) : 'nothing'} · F</span>${dirChip}`
: dir ? dirChip : '';
bar.querySelector('.bb-status').innerHTML = variant + (GAME.replace ? '<span class="chip hot">Replace · G</span>' : '')
+ (ng ? `<span class="chip ok">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</span>` : '')
+ (status ? `<span class="chip ${GAME.statusKind === 'info' ? 'ok' : 'bad'}">${esc(status)}</span>` : '');
const pop = $('bb-pop'); pop.hidden = !tip;
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';
+8 -2
View File
@@ -150,8 +150,7 @@ function renderSelCard() {
let h = '';
const status = performance.now() - GAME.statusT < 3200 ? GAME.status : '';
const sel = GAME.selDrag ? GAME.selDrag.mode : selModeHeld();
c.hidden = t === 'build' && !GAME.paste && !GAME.selDrag && !sel;
if (c.hidden) return;
c.hidden = false;
const ng = ghostBlocks.size + ghostMachines.length + ghostMolds.length;
if (sel === 'cut') {
h = `<div class="row"><span class="chip bad">✂ Cut</span><span class="name grow">Hold X and drag a box</span></div>
@@ -169,6 +168,13 @@ function renderSelCard() {
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">Resonance drill</span><span class="chip hot">${pw.name} pulse</span><kbd>E</kbd></div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">${esc(b.name.toLowerCase())}</span><span class="chip">hardness ≤ ${b.hard}</span><span class="chip">reach ${drillReach(GAME.drillW)}</span>${tn ? `<span class="chip ok"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(tn)}"></span>tuned: ${esc(MD[tn].name.toLowerCase())} · R</span>` : '<span class="chip">untuned · R tunes</span>'}</div>
<div class="sub">Hold to send pulses at the cursor: rock they shake hard enough shatters and falls. <kbd>Q</kbd>/<kbd>E</kbd> width: narrow bores a long tunnel fast, wide clears a big face up close. <kbd>R</kbd> over a material tunes to it, so only that shatters. Built blocks are never harmed.</div>`;
} else if (t === 'build') {
const P = piece(), cost = pieceCostNow(P);
const chips = buildVariantChips(P) + (GAME.replace ? '<span class="chip hot">Replace · G</span>' : '')
+ (ng ? `<span class="chip ok">${ng} ghost pieces waiting${ghostMissing ? ', some short on materials' : ''}.</span>` : '');
h = `<div class="row cycle"><kbd>Q</kbd><span class="name grow">${esc(P.name)}</span><span class="chip${canAfford(cost, 1) ? '' : ' bad'}">${esc(costText(cost, 1) || 'free')}</span><kbd>E</kbd></div>`
+ (chips ? `<div class="row" style="flex-wrap:wrap;gap:4px">${chips}</div>` : '')
+ `<div class="sub">${P.kind === 'block' ? 'Drag for a line, <kbd>Shift</kbd>-drag for a box.' : 'Click to place.'}${P.dirs ? ' <kbd>R</kbd> turns it.' : pieceUsesWall(P) ? ' <kbd>R</kbd> picks the material.' : ''} <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab.</div>`;
} else if (t === 'vacuum') {
ensureDropMat();
const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0;
+4 -3
View File
@@ -213,7 +213,8 @@ with sync_playwright() as p:
ev('GAME.wallMat = RSTONE; hudDirty = true')
check('build tool starts on wall blocks', ok)
settle(150)
check('the build tool hides the top-left card', not pg.is_visible('#selcard'))
check('the build tool shows a compact top-left card', pg.is_visible('#selcard') and pg.inner_text('#selcard .name') == ev('piece().name') and ev("costText(pieceCostNow(piece()), 1) || 'free'") in pg.inner_text('#selcard') and ev('piece().desc') not in pg.inner_text('#selcard'))
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)
for id in ['wall', 'grate']:
pg.hover(f'#buildbar [data-piece="{id}"]'); settle(120)
@@ -228,7 +229,7 @@ with sync_playwright() as p:
check(f'tool {key} restores the top-left card', pg.is_visible('#selcard'))
pg.keyboard.press('3'); settle(150)
pg.mouse.click(*cell_to_screen(bx, gy - 20), button='middle'); settle(120)
check('empty middle-click pick shows its status in the build bar', 'Nothing built here to pick.' in pg.inner_text('#buildbar .bb-status'))
check('empty middle-click pick shows its status in the top-left card', 'Nothing built here to pick.' in pg.inner_text('#selcard'))
c0 = ev('GAME.buildCat != null ? GAME.buildCat : catOf(piece().id)')
pg.keyboard.press('Shift+E')
check('Shift+E switches build tabs and selects a piece in the tab', ev('GAME.buildCat') == (c0 + 1) % ev('BUILD_CATS.length') and ev('BUILD_CATS[GAME.buildCat][1].includes(piece().id)'))
@@ -345,7 +346,7 @@ with sync_playwright() as p:
pg.mouse.down(); pg.mouse.up(); settle(100)
check('a fan on a belt says to hold G', 'Hold G' in ev('GAME.status'), ev('GAME.status'))
pg.keyboard.down('g'); settle(100); pg.mouse.move(*blk(bbx - 4, bby + 1)); settle(200)
check('holding G shows replace mode', 'Replace' in ev("$('buildbar').textContent"))
check('holding G shows replace mode', 'Replace · G' in ev("$('selcard').textContent"))
pg.screenshot(path=str(out / '07g-replace-fan.png'))
pg.mouse.down(); pg.mouse.up(); settle(100); pg.keyboard.up('g'); settle(100)
check('G-click puts the fan in place of the belt', ev(f"machines.some(m => m && m.type === 'fan' && m.x === ({bbx} - 4) * 4 && m.y === ({bby} + 1) * 4)") and ev(f'blockP[({bby} + 1) * BW + {bbx} - 4]') == 0, ev('GAME.status'))