Vacuum controls: pick-up modes and separate pick-up and blow marks in the bag (t6, t15)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 01:54:12 -04:00
co-authored by Claude Opus 5.5
parent 275d9c4008
commit 6d2fe2fdf6
21 changed files with 239 additions and 124 deletions
+6 -3
View File
@@ -43,7 +43,7 @@ decides something new, add it under "Decisions" with the date.
## The core loop ## The core loop
1. Dig with the resonance drill. Rock shatters into loose grains: rubble, ore, coal, clay, sand. 1. Dig with the resonance drill. Rock shatters into loose grains: rubble, ore, coal, clay, sand.
2. Vacuum up what you want (tick materials to take only those), and blow it out where you need it. 2. Vacuum up what you want (mark materials with ⬇ to take only those), and blow it out where you need it.
3. Build a furnace from wall blocks, with a grate floor over a mold. Charge it with ore and coal, 3. Build a furnace from wall blocks, with a grate floor over a mold. Charge it with ore and coal,
light it, and let the molten metal run into the mold. light it, and let the molten metal run into the mold.
4. Bars drop out of the bottom of the mold; collect them and sell them at the trade dock, or fill buyers' orders, which pay more. 4. Bars drop out of the bottom of the mold; collect them and sell them at the trade dock, or fill buyers' orders, which pay more.
@@ -127,8 +127,8 @@ above, including:
- **Holding Ctrl** while placing replaces what is built there (moved to G on 2026-10-23). - **Holding Ctrl** while placing replaces what is built there (moved to G on 2026-10-23).
- **Fans melt like copper (1085°C).** Put the fan outside the furnace, blowing in through a grate. - **Fans melt like copper (1085°C).** Put the fan outside the furnace, blowing in through a grate.
The game warns when a fan gets close to melting. The game warns when a fan gets close to melting.
- **The vacuum takes only ticked materials** when anything is ticked, even if the drag starts on - **The vacuum takes only marked materials** in Only marked mode, even if the drag starts on
something else. something else. The t6 controls below replace the shared ticks.
- **The Refiner** grinds stone, granite and basalt rubble, gravel, sand and dirt for trace ore, - **The Refiner** grinds stone, granite and basalt rubble, gravel, sand and dirt for trace ore,
roughly halving the waste. Ore and anything else it cannot grind drop straight through. roughly halving the waste. Ore and anything else it cannot grind drop straight through.
- **Pipes, pumps and outlets** move liquids up to 500°C, one liquid per line. A line is stored as - **Pipes, pumps and outlets** move liquids up to 500°C, one liquid per line. A line is stored as
@@ -222,6 +222,9 @@ above, including:
- **The bag is the vacuum's control panel (t6).** Each bag row has separate pick-up and blow - **The bag is the vacuum's control panel (t6).** Each bag row has separate pick-up and blow
marks; a chip on the tool card sets pick-up to everything, only marked, or the same as the marks; a chip on the tool card sets pick-up to everything, only marked, or the same as the
first click; R over a grain toggles its pick-up mark. The owner found the shared ticks confusing. first click; R over a grain toggles its pick-up mark. The owner found the shared ticks confusing.
As built: T or the chip cycles What I click → Only marked → Everything; adding a mark switches
to Only marked and removing the last goes back to What I click. A bag name or Q/E blows one
material; ⬆ marks build an even mix (bars are never mixed).
## Ideas not built yet ## Ideas not built yet
+7 -7
View File
@@ -366,7 +366,7 @@ Audio: chiptune effects and generative music that changes with depth. Everything
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler - functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
- variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES - variables: AU, SFX, SFX_NEAR, SFX_FAR, ZONES
### src/ui/state.js (67 lines) ### src/ui/state.js (69 lines)
Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and the status line. Game shell (src/ui/): browser only. This file: settings, the GAME state object, DOM helpers, toasts and the status line.
- functions: loadSettings, saveSettings, loadCollect, el, esc, rgbOf, fmtT, toast, setStatus - functions: loadSettings, saveSettings, loadCollect, el, esc, rgbOf, fmtT, toast, setStatus
- variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $ - variables: SET_DEFAULT, SET, TOOLS, ICONS, BAG_ORDER, GAME, keys, hudDirty, $
@@ -395,19 +395,19 @@ The notebook (N): milestones, market, wallet, blueprints, controls.
- functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml - functions: openNotebook, renderNotebook, fmtClock, orderName, orderLeft, renderMarketTab, fmtPrice, itemsText, renderWallet, renderBlueprintTab, copyCode, controlsHtml
- variables: nbTab, MARKET_GROUPS - variables: nbTab, MARKET_GROUPS
### src/ui/hud.js (40 lines) ### src/ui/hud.js (37 lines)
The tool hotbar and what is selected. The tool hotbar and what is selected.
- functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, mixList, ensureDropMat, piece - functions: renderHotbar, toolId, steering, modShift, grainAt, selectTool, bagMaterials, blowList, ensureBlow, piece
### src/ui/build-menu.js (102 lines) ### src/ui/build-menu.js (102 lines)
The build menu along the bottom: categories, piece tiles and their icons. The build menu along the bottom: categories, piece tiles and their icons.
- functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar - functions: pieceIcon, catOf, selectBuildCat, cycleBuildCat, buildVariantChips, renderBuildBar, setupBuildBar
- variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu - variables: BUILD_CATS, BUILD_ORDER, PIECE_ICONS, lastBuildKey, lastBuildMenu
### src/ui/selection.js (252 lines) ### src/ui/selection.js (263 lines)
The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. 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, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag - functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, setPickMode, cyclePickMode, toggleCollect, toggleBlow, pickFilterUnderCursor, toggleFilterMat, toggleCollectUnderCursor, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag
- variables: lastBagKey - variables: PICK_MODES, PICK_LABELS, lastBagKey
### src/ui/info.js (171 lines) ### src/ui/info.js (171 lines)
Discovery cards, hover info (I) and the cursor label. Discovery cards, hover info (I) and the cursor label.
@@ -418,7 +418,7 @@ Keyboard and mouse listeners.
- functions: isTyping - functions: isTyping
- variables: canvas - variables: canvas
### src/ui/actions.js (243 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 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 - functions: updateOverlayUI, toggleLens, doInteract, pickUnderCursor, aimBlock, stampOrigin, selModeHeld, startPaste, stopSelectAndPaste, plural, piecesIn, doUndo, endRmbRecord, dragDirFor, blockLabel, primaryPress, primaryRelease, secondaryPress, toolTick
- variables: ovShown - variables: ovShown
+14
View File
@@ -4,6 +4,20 @@ 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 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. open or not verified. Keep each entry short; the git history has the details.
## 2026-10-24: t6 Rework the vacuum/blower tool, with t15 (Claude Code, Codex)
- The owner folded t15 (a material menu for the vacuum) into t6's chosen design.
- `src/ui/state.js`, `hud.js`, `actions.js`, `selection.js`: `GAME.pickMode` ('first', 'marked',
'all', saved in localStorage) and `GAME.blow` (a set) replace `dropMat`, `blowMix`,
`matChosen` and the Ticked mix. Pick-up marks stay in `GAME.collect` (same storage key).
- Bag rows with the vacuum: ⬇ pick-up toggle, name (blow only this), ⬆ blow toggle. Tool card:
a pick-up mode chip (T or click cycles) and the blow choice (Q/E). Gamepad button 1 cycles the
mode when nothing else uses it. Text updated in `info.js`, `notebook.js`, `progress.js` (the
copper hint), `src/sim/vacuum.js` (a status line), `docs/DESIGN.md`.
- `test/play.py`: modes, automatic switching, independent marks, mixes, persistence, toggles only
with the vacuum; `test/t_basics.js`: the new status. Screenshots checked at 1280 and 900.
- Tests: `run_all.py --browser` all pass (214 `play.py` checks).
## 2026-10-24: decisions for t1, t4, t6 (Claude Code) ## 2026-10-24: decisions for t1, t4, t6 (Claude Code)
- Options given in chat; the owner chose: t1 neat bar piles plus a Bar rack; t4 Grinder and Saw - Options given in chat; the owner chose: t1 neat bar piles plus a Bar rack; t4 Grinder and Saw
+1 -1
View File
@@ -27,7 +27,7 @@ const MILESTONES = [
{ id: 'brick', title: 'Fired brick', hint: 'Clay turns into brick after a while above about 900°C, hotter than an open wood fire gets. Blow clay lumps into a coal fire, best walled in like a furnace, and vacuum up the brick rubble. Brick rubble builds brick blocks.', reward: 'Brick insulates far better than stone and survives 1750°C.' }, { id: 'brick', title: 'Fired brick', hint: 'Clay turns into brick after a while above about 900°C, hotter than an open wood fire gets. Blow clay lumps into a coal fire, best walled in like a furnace, and vacuum up the brick rubble. Brick rubble builds brick blocks.', reward: 'Brick insulates far better than stone and survives 1750°C.' },
{ id: 'charcoal', title: 'Charcoal', hint: 'Wood that gets hot without air turns into charcoal instead of burning. Bury a burning pile of wood chunks under dirt, or heat a sealed box of wood chunks from outside.', reward: 'A fuel you can grow: nearly as hot as coal.' }, { id: 'charcoal', title: 'Charcoal', hint: 'Wood that gets hot without air turns into charcoal instead of burning. Bury a burning pile of wood chunks under dirt, or heat a sealed box of wood chunks from outside.', reward: 'A fuel you can grow: nearly as hot as coal.' },
{ id: 'grow', title: 'Plant a tree', msg: 'Wood is renewable. Plant rows of acorns on dirt to farm it.', hint: 'Cutting down a tree drops acorns. Leave one on dirt or grass under open sky and it sprouts, then grows into a tree in about a minute and a half.' }, { id: 'grow', title: 'Plant a tree', msg: 'Wood is renewable. Plant rows of acorns on dirt to farm it.', hint: 'Cutting down a tree drops acorns. Leave one on dirt or grass under open sky and it sprouts, then grows into a tree in about a minute and a half.' },
{ id: 'copper', title: 'Molten copper', msg: 'Molten copper sinks to the bottom of the charge. Get it into a mold before it sets.', hint: 'Copper ore melts at 1085°C. An open fire loses too much heat, so wall coal and ore in with blocks and leave a small vent at the top for smoke. Mix ore and coal about half and half: tick both in your bag and pick Ticked mix with Q/E, and right click blows an even mix.' }, { id: 'copper', title: 'Molten copper', msg: 'Molten copper sinks to the bottom of the charge. Get it into a mold before it sets.', hint: 'Copper ore melts at 1085°C. An open fire loses too much heat, so wall coal and ore in with blocks and leave a small vent at the top for smoke. Mix ore and coal about half and half: click coal in your bag, then mark copper ore with ⬆. Right click blows an even mix.' },
{ id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, they drop out of the bottom of the mold onto whatever is under it; a belt carries them off. Leave space below, or vacuum up the bars. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap. Copper that sets in the furnace melts again next firing.' }, { id: 'bars', title: 'Copper bars', msg: 'Bars sell for far more than the ore in them, and make metal wall blocks.', hint: 'Build an Ingot mold (Build, 3) right under the furnace with a floor of clay Grates between them: molten copper drips through, while the charge and the thick slag stay up. When the bars set, they drop out of the bottom of the mold onto whatever is under it; a belt carries them off. Leave space below, or vacuum up the bars. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap. Copper that sets in the furnace melts again next firing.' },
{ id: 'bit1', title: 'Copper drill bit', hint: 'Cast copper in a Bit mold. Building the furnace right on top works. A Grate over the funnel keeps the charge out. Let it set, then press F.', reward: 'Copper bit. You can now dig iron ore.' }, { id: 'bit1', title: 'Copper drill bit', hint: 'Cast copper in a Bit mold. Building the furnace right on top works. A Grate over the funnel keeps the charge out. Let it set, then press F.', reward: 'Copper bit. You can now dig iron ore.' },
{ id: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw tubes push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' }, { id: 'auto', title: 'Moving parts', msg: 'Machines can now haul for you. Feed furnaces automatically, and lift ore out of your mines.', hint: 'Belts carry grains and bars resting on them. Screw tubes push loose material along (never liquids); stacked pointing up they lift it out of a mine. A Filter lets one material fall through and carries the rest along, so you can sort ore from rubble. Move 300 grains with machines.' },
+1 -1
View File
@@ -45,7 +45,7 @@ function toolBlow(cx, cy, r, m, mix) {
let pickM = null; let pickM = null;
if (mix && mix.length) { if (mix && mix.length) {
const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask)); const avail = mix.filter(k => bag[k] > 0 && PH[k] !== PH_ITEM && (PH[k] !== PH_LIQUID || player.flask));
if (!avail.length) return { placed: 0, status: 'Tick materials in your bag to mix.' }; if (!avail.length) return { placed: 0, status: 'No marked materials left to blow.' };
pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; }; pickM = () => { for (let k = 0; k < avail.length; k++) { const c = avail[(blowRR++) % avail.length]; if (bag[c] > 0) return c; } return 0; };
m = avail[0]; m = avail[0];
} }
+4 -10
View File
@@ -177,10 +177,10 @@ table.mt td.dip { color: var(--melt); }
#baglist .bagrow.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); } #baglist .bagrow.on { border-color: var(--patina-dim); background: rgba(92, 186, 166, 0.08); }
#baglist .bagrow .pick { flex: 1; min-width: 0; } #baglist .bagrow .pick { flex: 1; min-width: 0; }
#baglist .bagrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #baglist .bagrow .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#baglist .tick { flex: none; width: 20px; height: 20px; padding: 0; justify-content: center; } #baglist .mark { flex: none; width: 20px; height: 20px; padding: 0; justify-content: center; color: var(--ash-dim); }
#baglist .tick::before { content: ''; width: 10px; height: 10px; border: 1.5px solid var(--ash-dim); border-radius: 2px; box-sizing: border-box; } #baglist .mark.on { color: var(--patina); }
#baglist .tick:hover::before { border-color: var(--ash); } #selcard .vacrow { flex-wrap: wrap; gap: 4px; }
#baglist .tick.on::before { border-color: var(--patina); background: var(--patina); box-shadow: inset 0 0 0 1.5px var(--soot-1, #15120f); } #selcard .vacrow .sw { width: 9px; height: 9px; }
#baglist .baghint { color: var(--ash-dim); font-size: 0.82em; padding: 4px 4px 0; } #baglist .baghint { color: var(--ash-dim); font-size: 0.82em; padding: 4px 4px 0; }
#bagtoggle { background: none; border: none; color: var(--ash); cursor: pointer; padding: 0 2px; } #bagtoggle { background: none; border: none; color: var(--ash); cursor: pointer; padding: 0 2px; }
@@ -351,12 +351,6 @@ input.text { background: var(--soot-0); border: 1px solid var(--soot-4); border-
.fdir .fd.on { border-color: #e59a52; background: #33261c; } .fdir .fd.on { border-color: #e59a52; background: #33261c; }
.fdir .fd:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; } .fdir .fd:focus-visible { outline: 2px solid #e59a52; outline-offset: 1px; }
/* the ticked-mix choice at the bottom of the bag */
#baglist .mixrow { margin-top: 3px; border-top: 1px solid var(--soot-3); padding-top: 3px; border-radius: 0 0 3px 3px; }
#baglist .mixrow .pick { gap: 6px; }
#baglist .mixsw { display: flex; gap: 1px; flex: none; }
#baglist .mixsw .sw { width: 7px; height: 14px; border-radius: 1px; }
@media (max-width: 1100px) { #hud.building .br { bottom: calc(160px * var(--ui)); } } @media (max-width: 1100px) { #hud.building .br { bottom: calc(160px * var(--ui)); } }
/* saved worlds */ /* saved worlds */
+12 -16
View File
@@ -71,7 +71,7 @@ function pickUnderCursor() {
return; return;
} }
const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0; const t = PH[m] === PH_POWDER || PH[m] === PH_LIQUID ? m : 0;
if (t && bag[t] > 0) { GAME.dropMat = t; GAME.matChosen = true; GAME.blowMix = false; selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); } if (t) { GAME.blow = new Set([t]); selectTool(1); hudDirty = true; playSfx('ui'); setStatus(`Picked: ${MD[t].name} to blow out.`, 'info'); }
else setStatus('Nothing built here to pick.', 'info'); else setStatus('Nothing built here to pick.', 'info');
} }
function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); } function aimBlock() { return blockOf(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); }
@@ -118,11 +118,10 @@ function primaryPress(shift) {
return; return;
} }
if (t === 'vacuum') { if (t === 'vacuum') {
// with anything ticked, the vacuum takes only the ticked materials, wherever you start; const g = GAME.pickMode === 'first' ? grainAt(ax, ay) : 0;
// with nothing ticked, it takes what you start on GAME.vacFilter = shift || GAME.pickMode === 'all' ? -1 : g;
const g = GAME.collect.size ? 0 : grainAt(ax, ay); GAME.vacLocked = shift || GAME.pickMode !== 'first' || !!g;
GAME.vacFilter = shift ? -1 : g; if (!shift && GAME.pickMode === 'marked' && !GAME.collect.size) setStatus('Nothing marked to pick up. R over something, or ⬇ in the bag.', 'info');
GAME.vacLocked = shift || !!g || GAME.collect.size > 0;
hudDirty = true; hudDirty = true;
} else if (t === 'build') { } else if (t === 'build') {
const P = piece(); const P = piece();
@@ -208,12 +207,10 @@ function toolTick() {
const r = toolPulse(ax, ay, GAME.drillW, GAME.drillTune); const r = toolPulse(ax, ay, GAME.drillW, GAME.drillTune);
if (r.fired) { playSfx('pulse', GAME.drillW); if (r.status) setStatus(r.status); } if (r.fired) { playSfx('pulse', GAME.drillW); if (r.status) setStatus(r.status); }
} else if (t === 'vacuum') { } else if (t === 'vacuum') {
// started on empty space: the first loose thing you sweep over (that is not ticked) becomes the filter if (!GAME.vacLocked && GAME.pickMode === 'first') { const g = grainAt(ax, ay); if (g) { GAME.vacFilter = g; GAME.vacLocked = true; hudDirty = true; } }
// clicked on empty space: the first loose thing that comes under the cursor sets the filter, once const f = GAME.vacFilter, marked = GAME.pickMode === 'marked', list = GAME.collect;
if (!GAME.vacLocked && !GAME.collect.size) { const g = grainAt(ax, ay); if (g) { GAME.vacFilter = g; GAME.vacLocked = true; hudDirty = true; } } if (f || marked && list.size) {
const f = GAME.vacFilter, list = GAME.collect; const r = toolVacuum(ax, ay, GAME.brush[1], f < 0 ? () => true : marked ? m => list.has(m) : m => m === f);
if (f || list.size) {
const r = toolVacuum(ax, ay, GAME.brush[1], f < 0 ? () => true : list.size ? m => list.has(m) : m => m === f);
if (r.got) { playSfx('suck'); hudDirty = true; } if (r.got) { playSfx('suck'); hudDirty = true; }
if (r.status) setStatus(r.status); if (r.status) setStatus(r.status);
} }
@@ -224,10 +221,9 @@ function toolTick() {
} }
if (GAME.rmb && !GAME.rmbConsumed) { if (GAME.rmb && !GAME.rmbConsumed) {
if (t === 'vacuum') { if (t === 'vacuum') {
ensureDropMat(); ensureBlow();
// what right click blows is only ever what is selected (and shown by the cursor): one material, or the ticked mix const list = blowList();
const mix = GAME.blowMix && GAME.collect.size ? [...GAME.collect] : null; const r = toolBlow(ax, ay, GAME.brush[1], list[0] || 0, list.length > 1 ? list : null);
const r = toolBlow(ax, ay, GAME.brush[1], GAME.dropMat, mix);
if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status); if (r.placed) { playSfx('blow'); hudDirty = true; } else if (r.status) setStatus(r.status);
} else if (t === 'build') { } else if (t === 'build') {
const key = `${(ax / BS) | 0},${(ay / BS) | 0}`; const key = `${(ax / BS) | 0},${(ay / BS) | 0}`;
+1 -1
View File
@@ -86,7 +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`; pop.style.left = `${Math.max(8, Math.min(innerWidth - w - 8, r.left + r.width / 2 - w / 2)) - br.left}px`;
} }
function setupBuildBar() { function setupBuildBar() {
$('selcard').addEventListener('click', e => { if (e.target.closest('[data-wall-picker]')) openWallPicker(); }); $('selcard').addEventListener('click', e => { if (e.target.closest('[data-wall-picker]')) openWallPicker(); else if (e.target.closest('[data-pick-mode]')) cyclePickMode(); });
const bar = $('buildbar'); if (!bar) return; const bar = $('buildbar'); if (!bar) return;
bar.addEventListener('click', e => { bar.addEventListener('click', e => {
const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return; const t = e.target.closest('[data-cat],[data-piece]'); if (!t) return;
+1 -1
View File
@@ -45,7 +45,7 @@ function pollPad() {
if (padPressed(gp, 0)) doInteract(); if (padPressed(gp, 0)) doInteract();
if (padPressed(gp, 2)) rotate(); if (padPressed(gp, 2)) rotate();
if (padPressed(gp, 3)) toggleLens(); if (padPressed(gp, 3)) toggleLens();
if (padPressed(gp, 1)) { if (GAME.drag) GAME.drag = null; else if (GAME.paste) { GAME.paste = false; hudDirty = true; } } if (padPressed(gp, 1)) { if (GAME.drag) GAME.drag = null; else if (GAME.paste) { GAME.paste = false; hudDirty = true; } else if (toolId() === 'vacuum') cyclePickMode(); }
if (padPressed(gp, 10)) bot.lamp = !bot.lamp; if (padPressed(gp, 10)) bot.lamp = !bot.lamp;
if (padPressed(gp, 11)) { SET.zoom = SET.zoom >= 10 ? 2 : SET.zoom + 2; saveSettings(); resizeRender(); R.snapNext = true; } if (padPressed(gp, 11)) { SET.zoom = SET.zoom >= 10 ? 2 : SET.zoom + 2; saveSettings(); resizeRender(); R.snapNext = true; }
if (padPressed(gp, 8)) openNotebook(); if (padPressed(gp, 8)) openNotebook();
+7 -10
View File
@@ -11,7 +11,7 @@ function renderHotbar() {
} }
function toolId() { return TOOLS[GAME.tool].id; } function toolId() { return TOOLS[GAME.tool].id; }
// Shift does two jobs. While you are steering the bot it boosts; while you are not, // Shift does two jobs. While you are steering the bot it boosts; while you are not,
// it is the tool modifier (vacuum everything, blow a mix, fill a rectangle). // it is the tool modifier (vacuum everything, fill a rectangle).
function steering() { return ['w', 'a', 's', 'd', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].some(k => keys.has(k)) || GAME.padRun > 0; } function steering() { return ['w', 'a', 's', 'd', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].some(k => keys.has(k)) || GAME.padRun > 0; }
function modShift() { return keys.has('Shift') && !steering(); } function modShift() { return keys.has('Shift') && !steering(); }
// The loose material exactly under the cursor, or the nearest one touching it. // The loose material exactly under the cursor, or the nearest one touching it.
@@ -25,16 +25,13 @@ function grainAt(ax, ay) {
function selectTool(k) { function selectTool(k) {
if (k === GAME.tool) return; if (k === GAME.tool) return;
GAME.tool = k; GAME.drag = null; hudDirty = true; renderHotbar(); playSfx('ui'); GAME.tool = k; GAME.drag = null; hudDirty = true; renderHotbar(); playSfx('ui');
if (toolId() === 'vacuum') ensureDropMat(); if (toolId() === 'vacuum') ensureBlow();
} }
function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); } function bagMaterials() { return BAG_ORDER.filter(m => bag[m] > 0); }
// Only auto-pick a material when the player has not chosen one. A chosen function blowList() { return [...GAME.blow]; }
// material stays selected even after it runs out. // Empty materials stay chosen until the player changes the marks.
function mixList() { return [...GAME.collect].filter(m => bag[m] > 0 && PH[m] !== PH_ITEM); } function ensureBlow() {
function ensureDropMat() { if (GAME.blow.size) return;
if (GAME.blowMix && mixList().length < 1) GAME.blowMix = false; const l = bagMaterials(); if (l.length) GAME.blow.add(l[0]);
if (bag[GAME.dropMat] > 0) return;
if (GAME.matChosen && GAME.dropMat) return;
const l = bagMaterials(); GAME.dropMat = l.length ? l[0] : 0;
} }
function piece() { return PIECES[GAME.pieceIdx]; } function piece() { return PIECES[GAME.pieceIdx]; }
+1 -1
View File
@@ -118,7 +118,7 @@ function hoverInfo(ax, ay) {
t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`); t2.push(HARD[m] > 6 ? 'cannot be drilled' : ok ? `drillable (hardness ${HARD[m]})` : `too hard (${HARD[m]})`);
if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard'; if (!ok && toolId() === 'drill') warn = HARD[m] > 6 ? 'cannot be drilled' : 'too hard';
} }
if (!bar && PH[m] === PH_POWDER) t2.push(GAME.collect.has(m) ? 'loose · ticked' : 'loose'); if (!bar && PH[m] === PH_POWDER) t2.push(GAME.collect.has(m) ? 'loose · marked to pick up' : 'loose');
if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); } if (!bar && m === EMPTY && bgm[i]) { t1 = bgm[i] === WOOD ? 'Tree trunk' : 'Leaves'; t2.unshift(bgm[i] === WOOD ? 'drill it for wood chunks' : 'water and sand pass through'); }
if (flags[i] & F_BURN) t2.push('burning'); if (flags[i] & F_BURN) t2.push('burning');
const mo = moldAt(ax, ay); const mo = moldAt(ax, ay);
+1 -1
View File
@@ -27,7 +27,7 @@ window.addEventListener('keydown', e => {
case 'q': if (e.shiftKey && toolId() === 'build') cycleBuildCat(-1); else cycleItem(-1); break; case 'q': if (e.shiftKey && toolId() === 'build') cycleBuildCat(-1); else cycleItem(-1); break;
case 'e': if (e.shiftKey && toolId() === 'build') cycleBuildCat(1); else cycleItem(1); break; case 'e': if (e.shiftKey && toolId() === 'build') cycleBuildCat(1); else cycleItem(1); break;
case 'r': rotate(); break; case 'r': rotate(); break;
case 't': if (toolId() === 'vacuum') toggleCollectUnderCursor(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break; case 't': if (toolId() === 'vacuum') cyclePickMode(); else if (toolId() === 'build' && piece().filter) pickFilterUnderCursor(); break;
case 'f': doInteract(); break; case 'f': doInteract(); break;
case 'g': GAME.replace = true; hudDirty = true; break; case 'g': GAME.replace = true; hudDirty = true; break;
case 'h': toggleLens(); break; case 'h': toggleLens(); break;
+4 -4
View File
@@ -170,18 +170,18 @@ function controlsHtml() {
const kb = [ 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'], ['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'], ['Wall or mold material menu (Build)', 'F'], ['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'], ['Mark/unmark what is under the cursor (Vacuum)', 'R'], ['Pick-up mode (Vacuum)', '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'], ['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'], ['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'],
]; ];
const pad = [ const pad = [
['Fly', 'Left stick'], ['Boost', 'Left stick pushed all the way'], ['Aim', 'Right stick'], ['Use tool · suck', 'RT'], ['Second action · blow', 'LT'], ['Tools', 'LB / RB'], ['Fly', 'Left stick'], ['Boost', 'Left stick pushed all the way'], ['Aim', 'Right stick'], ['Use tool · suck', 'RT'], ['Second action · blow', 'LT'], ['Tools', 'LB / RB'],
['Cycle what you blow · piece', 'D-pad ← →'], ['Brush size', 'D-pad ↑ ↓'], ['Interact', 'A'], ['Variant · tick material', 'X'], ['Thermal lens', 'Y'], ['Cycle what you blow · piece', 'D-pad ← →'], ['Brush size', 'D-pad ↑ ↓'], ['Interact', 'A'], ['Variant · mark material', 'X'], ['Pick-up mode (Vacuum)', 'B'], ['Thermal lens', 'Y'],
['Cancel', 'B'], ['Zoom', 'R3'], ['Lamp', 'L3'], ['Notebook', 'View / Back'], ['Menu', 'Menu / Start'], ['Cancel', 'B'], ['Zoom', 'R3'], ['Lamp', 'L3'], ['Notebook', 'View / Back'], ['Menu', 'Menu / Start'],
]; ];
const tools = [ 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.'], ['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 click sucks up what you start on; if anything is ticked in your bag (R over it, or its tick box), it takes only the ticked materials instead. Hold Shift while not flying to take everything. Right click blows the chosen material out of the bot toward the cursor; Q and E choose it, and it stays chosen. Q and E (or clicking in your bag) can also pick Ticked mix, an even mix of everything ticked, which is how you charge a furnace. The swatches next to the cursor always show what right click will blow. R ticks or unticks what is under the cursor.'], ['2 Vacuum', 'Left click picks up what you click, only marked materials, or everything: T or the card chip changes the mode. R over a grain or ⬇ in the bag marks it for pick-up. Adding a mark switches to Only marked; removing the last returns to What I click. Hold Shift while not flying to take everything. Right click blows the materials marked ⬆ in the bag as an even mix. Bars blow one at a time. Click a name or use Q/E to blow only one material; it stays chosen when empty. Pick-up and blow marks are separate.'],
['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.'], ['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.'], ['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.'], ['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.'],
@@ -193,7 +193,7 @@ function controlsHtml() {
<p class="note">1. Drill down a little. Stone falls as rubble; coal (dark seams), copper ore (green speckles) and clay (reddish) come loose too.<br> <p class="note">1. Drill down a little. Stone falls as rubble; coal (dark seams), copper ore (green speckles) and clay (reddish) come loose too.<br>
2. With the Vacuum, click on coal to suck up only coal, then the copper ore, rubble and clay you want.<br> 2. With the Vacuum, click on coal to suck up only coal, then the copper ore, rubble and clay you want.<br>
3. Build (3) a chamber of stone wall blocks with a one-block vent in the roof, a floor of clay Grates, and an Ingot mold right under the grates. The notebook's Blueprints tab has this as a starter design.<br> 3. Build (3) a chamber of stone wall blocks with a one-block vent in the roof, a floor of clay Grates, and an Ingot mold right under the grates. The notebook's Blueprints tab has this as a starter design.<br>
4. Tick coal and copper ore in your bag and pick Ticked mix (Q/E, or click it at the bottom of the bag). Fly inside (the bot passes through what you build) and right click to blow an even mix onto the grates: about 50 of each. (Ore first and coal on top works too; coal buried under ore will not light.) Light a coal grain on top with the Igniter.<br> 4. Click coal in your bag, then mark copper ore with ⬆ to blow both. Fly inside (the bot passes through what you build) and right click to blow an even mix onto the grates: about 50 of each. (Ore first and coal on top works too; coal buried under ore will not light.) Light a coal grain on top with the Igniter.<br>
5. When the ingot sets, bars drop out of the bottom of the mold onto whatever is under it. Leave one open block under it: a belt below that carries them off, or vacuum them up. Leftover metal stays for the next firing. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.<br> 5. When the ingot sets, bars drop out of the bottom of the mold onto whatever is under it. Leave one open block under it: a belt below that carries them off, or vacuum them up. Leftover metal stays for the next firing. Ingot molds empty themselves: bars, junk and scrap drop below, slag is thrown away and the grate above is raked. Removing any mold returns its set metal to your bag: whole bars as bars, the rest as scrap.<br>
Screw tubes carry grains and whole bars: feed bars in from a belt, turn corners and unload at the mouth.<br> Screw tubes carry grains and whole bars: feed bars in from a belt, turn corners and unload at the mouth.<br>
6. Blow the bars into the trade dock: they sell the moment they drop through its grate, and bars pay far more than ore. Watch the Market page for orders that pay double. Money buys belts, screw tubes and fans; pay down the claim there too.<br> 6. Blow the bars into the trade dock: they sell the moment they drop through its grate, and bars pay far more than ore. Watch the Market page for orders that pay double. Money buys belts, screw tubes and fans; pay down the claim there too.<br>
+1 -1
View File
@@ -153,7 +153,7 @@ function drawPreviews() {
if (t === 'vacuum') { if (t === 'vacuum') {
// what the right button would blow, next to the cursor; what the left is sucking, while it sucks // what the right button would blow, next to the cursor; what the left is sucking, while it sucks
const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp; const sz = Math.max(6, zp * 1.6), qx = p.x + (r + 1.2) * zp, qy = p.y - (r + 1.2) * zp;
const swatches = GAME.lmb && GAME.vacFilter > 0 ? [GAME.vacFilter] : GAME.blowMix ? mixList() : GAME.dropMat && bag[GAME.dropMat] > 0 ? [GAME.dropMat] : []; const swatches = GAME.lmb && GAME.pickMode === 'first' && GAME.vacFilter > 0 ? [GAME.vacFilter] : GAME.lmb && GAME.pickMode === 'marked' && GAME.vacFilter >= 0 ? [...GAME.collect] : blowList();
swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); }); swatches.slice(0, 4).forEach((m, k) => { ctx.fillStyle = rgbOf(m); ctx.fillRect(qx + k * (sz + 2), qy, sz, sz); });
if (blowing || (GAME.lmb && GAME.vacFilter)) { if (blowing || (GAME.lmb && GAME.vacFilter)) {
// faint line from the nozzle: the path the grains take // faint line from the nozzle: the path the grains take
+47 -36
View File
@@ -21,13 +21,10 @@ function cycleItem(d) {
const t = toolId(); const t = toolId();
if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; } if (t === 'drill') { setDrillWidth(GAME.drillW + d); return; }
if (t === 'vacuum') { if (t === 'vacuum') {
// what right click blows: each material in the bag, plus an even mix of the ticked ones const l = bagMaterials();
const l = bagMaterials().map(String);
if (mixList().length >= 2) l.push('mix');
if (!l.length) return; if (!l.length) return;
let k = l.indexOf(GAME.blowMix ? 'mix' : String(GAME.dropMat)); k = (k + d + l.length) % l.length; if (k < 0) k = 0; const k = l.indexOf(blowList()[0]);
if (l[k] === 'mix') GAME.blowMix = true; else { GAME.blowMix = false; GAME.dropMat = +l[k]; } GAME.blow = new Set([l[(k + d + l.length) % l.length]]);
GAME.matChosen = true;
} else if (t === 'build') { } else if (t === 'build') {
let k = BUILD_ORDER.indexOf(piece().id); k = (k + d + BUILD_ORDER.length) % BUILD_ORDER.length; let k = BUILD_ORDER.indexOf(piece().id); k = (k + d + BUILD_ORDER.length) % BUILD_ORDER.length;
GAME.pieceIdx = PIECE_BY_ID[BUILD_ORDER[k]].index; GAME.buildCat = catOf(BUILD_ORDER[k]); GAME.pieceIdx = PIECE_BY_ID[BUILD_ORDER[k]].index; GAME.buildCat = catOf(BUILD_ORDER[k]);
@@ -56,12 +53,32 @@ function rotate() {
else return; else return;
playSfx('ui'); hudDirty = true; playSfx('ui'); hudDirty = true;
} }
const PICK_MODES = ['first', 'marked', 'all'];
const PICK_LABELS = { first: 'What I click', marked: 'Only marked', all: 'Everything' };
function setPickMode(mode) {
GAME.pickMode = mode;
try { localStorage.setItem('deepgrain.pickMode', mode); } catch (e) { /* storage unavailable */ }
hudDirty = true;
}
function cyclePickMode() {
setPickMode(PICK_MODES[(PICK_MODES.indexOf(GAME.pickMode) + 1) % PICK_MODES.length]);
playSfx('ui');
}
function toggleCollect(m) { function toggleCollect(m) {
if (!m) return; if (!m) return;
if (GAME.collect.has(m)) GAME.collect.delete(m); else GAME.collect.add(m); const add = !GAME.collect.has(m);
if (add) GAME.collect.add(m); else GAME.collect.delete(m);
if (add && GAME.pickMode !== 'marked') { setPickMode('marked'); setStatus('Picking up only marked materials.', 'info'); }
else if (!GAME.collect.size && GAME.pickMode === 'marked') setPickMode('first');
try { localStorage.setItem('deepgrain.collect', JSON.stringify([...GAME.collect])); } catch (e) { /* storage unavailable */ } try { localStorage.setItem('deepgrain.collect', JSON.stringify([...GAME.collect])); } catch (e) { /* storage unavailable */ }
lastBagKey = ''; hudDirty = true; playSfx('ui'); lastBagKey = ''; hudDirty = true; playSfx('ui');
} }
function toggleBlow(m) {
if (GAME.blow.has(m)) GAME.blow.delete(m);
else if (PH[m] === PH_ITEM) GAME.blow = new Set([m]);
else { for (const k of GAME.blow) if (PH[k] === PH_ITEM) GAME.blow.delete(k); GAME.blow.add(m); }
lastBagKey = ''; hudDirty = true; playSfx('ui');
}
function pickFilterUnderCursor() { function pickFilterUnderCursor() {
const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); const ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
const bar = ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H); const bar = ents.find(e => ax >= e.ix && ax < e.ix + BAR_W && ay >= e.iy && ay < e.iy + BAR_H);
@@ -78,9 +95,8 @@ function toggleFilterMat(m) {
} }
function toggleCollectUnderCursor() { function toggleCollectUnderCursor() {
const m = grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y)); const m = grainAt(Math.floor(GAME.aim.x), Math.floor(GAME.aim.y));
if (!m) { setStatus('Aim at something loose to tick it.', 'info'); return; } if (!m) { setStatus('Aim at something loose to mark it.', 'info'); return; }
toggleCollect(m); toggleCollect(m);
setStatus(`${MD[m].name} ${GAME.collect.has(m) ? 'ticked for vacuuming' : 'unticked'}.`, 'info');
} }
function setBrush(d) { function setBrush(d) {
const t = toolId(); const t = toolId();
@@ -194,18 +210,14 @@ function renderSelCard() {
+ '<div class="sub">Place pieces from the menu below. Drag for a line, <kbd>Shift</kbd>-drag for a box; out of reach becomes ghosts.</div>' + '<div class="sub">Place pieces from the menu below. Drag for a line, <kbd>Shift</kbd>-drag for a box; out of reach becomes ghosts.</div>'
+ '<div class="sub"><kbd>Q</kbd>/<kbd>E</kbd> piece · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab · <kbd>R</kbd> turn or material · <kbd>F</kbd> material menu · hold <kbd>G</kbd> replace · right click take back · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo</div>'; + '<div class="sub"><kbd>Q</kbd>/<kbd>E</kbd> piece · <kbd>Shift</kbd>+<kbd>Q</kbd>/<kbd>E</kbd> tab · <kbd>R</kbd> turn or material · <kbd>F</kbd> material menu · hold <kbd>G</kbd> replace · right click take back · hold <kbd>C</kbd>/<kbd>X</kbd> copy/cut · <kbd>V</kbd> paste · <kbd>Ctrl</kbd>+<kbd>Z</kbd> undo</div>';
} else if (t === 'vacuum') { } else if (t === 'vacuum') {
ensureDropMat(); ensureBlow();
const m = GAME.dropMat, f = GAME.lmb ? GAME.vacFilter : 0; const list = blowList(), f = GAME.lmb && GAME.pickMode === 'first' ? GAME.vacFilter : 0;
const list = [...GAME.collect]; const marks = GAME.pickMode === 'marked' ? [...GAME.collect] : f > 0 ? [f] : [];
const mixL = mixList(); const sws = ms => ms.slice(0, 4).map(m => `<span class="sw" title="${esc(MD[m].name)}" style="background:${rgbOf(m)}"></span>`).join('') + (ms.length > 4 ? `+${ms.length - 4}` : '');
const blowRow = GAME.blowMix ? `<div class="row cycle"><kbd>Q</kbd>${mixL.map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}<span class="name grow">Ticked mix</span><span class="num chip">${mixL.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span><kbd>E</kbd></div>` const count = list.reduce((n, m) => n + bag[m], 0);
: m ? `<div class="row cycle"><kbd>Q</kbd><span class="sw" style="background:${rgbOf(m)}"></span><span class="name grow">${esc(MD[m].name)}</span><span class="num chip ${bag[m] ? '' : 'bad'}">${bag[m] ? bag[m].toLocaleString('en-US') : 'none left'}</span><kbd>E</kbd></div>` h = `<div class="row vacrow"><b>Pick up</b><button class="chip hot" data-pick-mode title="Cycle pick-up mode">${PICK_LABELS[GAME.pickMode]} · T</button>${sws(marks)}</div>
: `<div class="row"><span class="name">Vacuum</span><span class="sub">bag empty</span></div>`; <div class="row vacrow"><b>Blow out</b><kbd>Q</kbd>${sws(list)}<span class="name grow">${list.length > 1 ? `Mix of ${list.length}` : list.length ? esc(MD[list[0]].name) : 'bag empty'}</span><span class="num chip${count ? '' : ' bad'}">${count ? count.toLocaleString('en-US') : 'none left'}</span><kbd>E</kbd></div>
const sucking = f < 0 ? 'everything' : list.length ? 'only the ticked materials' : f > 0 ? 'only ' + esc(MD[f].name.toLowerCase()) : 'what you start on'; <div class="sub">Left picks up, right blows out. R marks what is under the cursor. Shift takes everything. <span class="chip">nozzle ${GAME.brush[1]}</span></div>`;
h = `${blowRow}
<div class="sub"><b>Left</b> sucks up ${sucking}${list.length && f >= 0 ? ' (untick them all to pick by what you click on)' : ''}. Standing still, <kbd>Shift</kbd> takes everything.</div>
<div class="sub"><b>Right</b> blows ${GAME.blowMix ? 'an even mix of the ticked materials' : m ? 'only ' + esc(MD[m].name.toLowerCase()) : 'the chosen material'} out. ${mixL.length > 1 || GAME.blowMix ? '<kbd>Q</kbd>/<kbd>E</kbd> switch between single materials and the ticked mix.' : 'Tick 2+ materials to blow them as a mix.'}</div>
<div class="row" style="flex-wrap:wrap;gap:4px"><span class="chip">nozzle ${GAME.brush[1]}</span>${list.map(k => `<span class="chip"><span class="sw" style="display:inline-block;vertical-align:-2px;width:9px;height:9px;margin-right:4px;background:${rgbOf(k)}"></span>${esc(MD[k].name)}</span>`).join('')}${list.length ? '' : '<span class="sub">Nothing ticked. <kbd>R</kbd> ticks what is under the cursor.</span>'}</div>`;
} else if (t === 'ignite') { } else if (t === 'ignite') {
h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`; h = `<div class="row"><span class="name">Igniter</span><span class="chip hot">up to 640°C</span></div><div class="sub">Hold on fuel to light it. Coal catches around 420°C, wood around 300°C. Fuel needs open air next to it.</div>`;
} }
@@ -218,35 +230,34 @@ function renderBag() {
$('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`; $('bagcap').textContent = `${bagTotal.toLocaleString('en-US')} / ${cap.toLocaleString('en-US')}`;
$('bagbar').firstChild.style.width = pct + '%'; $('bagbar').firstChild.style.width = pct + '%';
$('bagbar').classList.toggle('full', bagTotal >= cap); $('bagbar').classList.toggle('full', bagTotal >= cap);
const l = BAG_ORDER.filter(m => bag[m] > 0 || GAME.collect.has(m)); const l = BAG_ORDER.filter(m => bag[m] > 0 || GAME.collect.has(m) || GAME.blow.has(m));
const t = toolId(); const t = toolId();
const key = l.map(m => m + ':' + bag[m] + (GAME.collect.has(m) ? 'c' : '')).join(',') + '|' + GAME.dropMat + '|' + GAME.blowMix + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx; const key = l.map(m => m + ':' + bag[m] + (GAME.collect.has(m) ? 'c' : '') + (GAME.blow.has(m) ? 'b' : '')).join(',') + '|' + GAME.wallMat + '|' + GAME.filterCfg.m.join('.') + '|' + t + '|' + GAME.pieceIdx;
if (key === lastBagKey) return; if (key === lastBagKey) return;
lastBagKey = key; lastBagKey = key;
const box = $('baglist'); box.innerHTML = ''; const box = $('baglist'); box.innerHTML = '';
if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Vacuum.')); return; } if (!l.length) { box.appendChild(el('div', { class: 'empty' }, 'Empty. Break rock with the Drill, then use the Vacuum.')); return; }
for (const m of l) { for (const m of l) {
const sel = (t === 'vacuum' && (GAME.blowMix ? GAME.collect.has(m) : m === GAME.dropMat)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m)); const sel = (t === 'vacuum' && GAME.blow.has(m)) || (t === 'build' && m === GAME.wallMat && pieceUsesWall(piece())) || (t === 'build' && piece().filter && GAME.filterCfg.m.includes(m));
const row = el('div', { class: 'bagrow' + (sel ? ' on' : '') }); const row = el('div', { class: 'bagrow' + (sel ? ' on' : '') });
const tick = el('button', { class: 'tick' + (GAME.collect.has(m) ? ' on' : ''), title: GAME.collect.has(m) ? 'Ticked: the vacuum takes only ticked materials, and they make up the Ticked mix' : 'Tick to vacuum only ticked materials', 'aria-label': `Tick ${MD[m].name}`, 'aria-pressed': GAME.collect.has(m) }); if (t === 'vacuum') {
tick.onclick = () => toggleCollect(m); const down = el('button', { class: 'mark' + (GAME.collect.has(m) ? ' on' : ''), title: `Pick up ${MD[m].name}`, 'aria-label': `Pick up ${MD[m].name}`, 'aria-pressed': GAME.collect.has(m), 'data-pick': m }, '⬇');
down.onclick = () => toggleCollect(m); row.appendChild(down);
}
const b = el('button', { class: 'pick', title: WALL_OF[m] && t === 'build' ? `Build with ${MD[m].name}` : `Blow out ${MD[m].name}` }); const b = el('button', { class: 'pick', title: WALL_OF[m] && t === 'build' ? `Build with ${MD[m].name}` : `Blow out ${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].toLocaleString('en-US')}</span>`; b.innerHTML = `<span class="sw" style="background:${rgbOf(m)}"></span><span class="nm">${esc(MD[m].name)}</span><span class="n num">${bag[m].toLocaleString('en-US')}</span>`;
b.onclick = () => { b.onclick = () => {
if (t === 'build' && piece().filter && filterable(m)) { toggleFilterMat(m); 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 if (t === 'build' && WALL_OF[m]) { GAME.wallMat = m; if (!pieceUsesWall(piece())) GAME.pieceIdx = 0; }
else { GAME.dropMat = m; GAME.matChosen = true; GAME.blowMix = false; if (t !== 'vacuum') selectTool(1); } else { GAME.blow = new Set([m]); if (t !== 'vacuum') selectTool(1); }
hudDirty = true; lastBagKey = ''; playSfx('ui'); hudDirty = true; lastBagKey = ''; playSfx('ui');
}; };
row.appendChild(tick); row.appendChild(b); box.appendChild(row); row.appendChild(b);
if (t === 'vacuum') {
const up = el('button', { class: 'mark' + (GAME.blow.has(m) ? ' on' : ''), title: `Blow out ${MD[m].name}`, 'aria-label': `Blow out ${MD[m].name}`, 'aria-pressed': GAME.blow.has(m), 'data-blow': m }, '⬆');
up.onclick = () => toggleBlow(m); row.appendChild(up);
} }
if (t === 'vacuum' && mixList().length > 1) { box.appendChild(row);
// the ticked mix is a choice of its own, like any material
const mx = mixList(), row = el('div', { class: 'bagrow mixrow' + (GAME.blowMix ? ' on' : '') });
const b = el('button', { class: 'pick', title: 'Blow an even mix of the ticked materials' });
b.innerHTML = `<span class="mixsw">${mx.slice(0, 4).map(k => `<span class="sw" style="background:${rgbOf(k)}"></span>`).join('')}</span><span class="nm">Ticked mix</span><span class="n num">${mx.reduce((a, k) => a + bag[k], 0).toLocaleString('en-US')}</span>`;
b.onclick = () => { GAME.blowMix = true; GAME.matChosen = true; hudDirty = true; lastBagKey = ''; playSfx('ui'); };
row.appendChild(b); box.appendChild(row);
} }
if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, 'Click a material to blow only that, or Ticked mix to blow an even mix of the ticked ones. Ticked ones always come along when you vacuum.')); if (t === 'vacuum') box.appendChild(el('div', { class: 'baghint' }, '⬇ what you pick up · ⬆ what you blow out · click a name to blow only that'));
} }
+2 -2
View File
@@ -24,7 +24,7 @@ function startNewWorld(seed, w, h, name = '') {
GAME.worldId = newWorldId(); GAME.worldName = cleanWorldName(name, `World ${worlds.length + 1}`); GAME.worldId = newWorldId(); GAME.worldName = cleanWorldName(name, `World ${worlds.length + 1}`);
await putWorldEntry({ id: GAME.worldId, name: GAME.worldName, seed, w, h, createdAt: Date.now(), savedAt: 0, playTime: 0 }); await putWorldEntry({ id: GAME.worldId, name: GAME.worldName, seed, w, h, createdAt: Date.now(), savedAt: 0, playTime: 0 });
generateWorld(seed, w, h, null); generateWorld(seed, w, h, null);
GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.dropMat = 0; GAME.matChosen = false; GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0; GAME.playTime = 0; GAME.clip = null; GAME.paste = false; GAME.selDrag = null; GAME.tool = 0; GAME.blow.clear(); GAME.wallMat = RSTONE; GAME.filterCfg = { m: [], inv: 0 }; GAME.pieceIdx = 0;
showLoading(false); showLoading(false);
enterGame(); enterGame();
showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 }); showCard({ kind: 'Welcome', title: 'The dig site', body: ['Fly with WASD. The Drill (1) breaks rock loose. The Vacuum (2) sucks up what you click on and blows it back out with right click.', 'You owe $20,000 on this claim. Sell what you dig and make at the trade dock next to you: anything that drops through its grate is paid for on the spot, and buyers post orders that pay far more. Every tip lives in the notebook (N).'], hold: 1.8 });
@@ -42,7 +42,7 @@ function startFromSave(sv) {
} }
GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || ''; GAME.worldId = sv.worldId || ''; GAME.worldName = sv.worldName || '';
GAME.playTime = sv.playTime || 0; GAME.playTime = sv.playTime || 0;
GAME.drag = null; GAME.matChosen = false; GAME.dropMat = 0; GAME.drag = null; GAME.blow.clear();
enterGame(); enterGame();
return true; return true;
} }
+4 -2
View File
@@ -38,12 +38,14 @@ const BAG_ORDER = [BARAU, BARAG, BARBZ, BARBS, BARST, BARFE, BARCU, BARSN, BARZN
SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS]; SCRCU, SCRFE, SCRST, SCRSN, SCRZN, SCRAG, SCRAU, SCRBZ, SCRBS, ASH, SLAG, SNOW, WATER, MAGMA, MCU, MFE, MST, MGL, MSN, MZN, MAG, MAU, MBZ, MBS];
function loadCollect() { try { const a = JSON.parse(localStorage.getItem('deepgrain.collect') || '[]'); return new Set(a.filter(m => m > 0 && m < NMAT && BAGOK[m])); } catch (e) { return new Set(); } } function loadCollect() { try { const a = JSON.parse(localStorage.getItem('deepgrain.collect') || '[]'); return new Set(a.filter(m => m > 0 && m < NMAT && BAGOK[m])); } catch (e) { return new Set(); } }
const GAME = { const GAME = {
running: false, paused: false, tool: 0, dropMat: 0, matChosen: false, wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {}, running: false, paused: false, tool: 0, blow: new Set(), wallMat: RSTONE, filterCfg: { m: [], inv: 0 }, pieceIdx: 0, pieceDir: {},
brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 }, brush: [3, 2, 0, 0, 0], collect: loadCollect(), tipsOpen: new Set(), cards: [], vacFilter: 0, vacLocked: false, rmbConsumed: false, rmbSeen: new Set(), lookHold: { x: 0, y: 0 },
paste: false, selDrag: null, rmbRec: null, dragDir: -1, replaceShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false }, paste: false, selDrag: null, rmbRec: null, dragDir: -1, replaceShown: false, clip: null, drag: null, aim: { x: 0, y: 0 }, mouse: { x: 0, y: 0, in: false },
lmb: false, rmb: false, blowMix: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 }, lmb: false, rmb: false, drillW: 1, drillTune: 0, lastDug: 0, padRun: 0, status: '', statusKind: 'err', statusT: 0, usingPad: false, padAim: { x: 12, y: 0 },
lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '', lastAutosave: 0, playTime: 0, lastHud: 0, worldId: '', worldName: '',
}; };
try { GAME.pickMode = localStorage.getItem('deepgrain.pickMode'); } catch (e) { /* storage unavailable */ }
if (!['first', 'marked', 'all'].includes(GAME.pickMode)) GAME.pickMode = GAME.collect.size ? 'marked' : 'first';
const keys = new Set(); const keys = new Set();
let hudDirty = true; let hudDirty = true;
+14
View File
@@ -0,0 +1,14 @@
---
id: t15
status: Review
order: 3
priority: P3
created: 2026-10-24
updated: 2026-10-24T05:54:12Z
title: material selection menu for vacuum tool
report: |
(agent) The owner chose to fold this into t6: the bag rows are the vacuum's material
selection (⬇ pick up, ⬆ blow) instead of a separate menu. Check it with t6.
---
+12 -6
View File
@@ -1,13 +1,19 @@
--- ---
id: t6 id: t6
title: Rework the vacuum/blower tool title: Rework the vacuum/blower tool
status: Todo status: Review
created: 2026-10-24 created: 2026-10-24
updated: 2026-10-24T04:52:40Z updated: 2026-10-24T05:54:12Z
tags: [discussion] tags: [discussion]
order: 6 order: 6
report: | report: |
(agent) Decided 2026-10-24 (docs/DESIGN.md); not built yet. (agent) Built (t15 folded in). With the Vacuum (2) selected, each bag row has ⬇ (pick it up)
and ⬆ (blow it out); click a name to blow only that. The tool card shows "Pick up" with a
mode chip: What I click / Only marked / Everything (T or click the chip cycles). R over a
grain marks it for pick-up and switches to Only marked; unmark the last one and it goes back.
"Blow out" shows what right click blows (Q/E cycles one material; ⬆ on several = even mix).
To check: mark coal and copper ore with ⬆ and blow a mix into a furnace; mark one thing with R
and vacuum a mixed pile; press T to try Everything.
--- ---
## Context ## Context
@@ -16,6 +22,6 @@ report: |
## Subtasks ## Subtasks
- [ ] Bag rows get separate pick-up and blow marks (no more shared ticks) - [x] Bag rows get separate pick-up and blow marks (no more shared ticks)
- [ ] Tool card chip: pick up everything / only marked / same as the first click - [x] Tool card chip: pick up everything / only marked / same as the first click
- [ ] R over a grain toggles its pick-up mark; blowing uses the rows marked to blow - [x] R over a grain toggles its pick-up mark; blowing uses the rows marked to blow
+97 -20
View File
@@ -21,6 +21,15 @@ with sync_playwright() as p:
pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}')) pg.on('pageerror', lambda e: logs.append(f'PAGEERROR: {e}'))
pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None) pg.on('console', lambda m: logs.append(f'{m.type}: {m.text}') if m.type == 'error' else None)
pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300) pg.goto(url); pg.evaluate("SET.fog = false"); pg.wait_for_timeout(300)
check('empty marks default to first mode', pg.evaluate("GAME.pickMode === 'first'"))
pg.evaluate("toggleCollect(CUORE); setPickMode('all')")
pg.reload(); pg.wait_for_timeout(300)
check('pick-up marks and a manual mode persist across reload', pg.evaluate("GAME.collect.has(CUORE) && GAME.pickMode === 'all'"))
pg.evaluate("localStorage.removeItem('deepgrain.pickMode')")
pg.reload(); pg.wait_for_timeout(300)
check('old pick-up marks default to marked mode', pg.evaluate("GAME.collect.has(CUORE) && GAME.pickMode === 'marked'"))
pg.evaluate("localStorage.removeItem('deepgrain.collect'); localStorage.removeItem('deepgrain.pickMode')")
pg.reload(); pg.wait_for_timeout(300)
check('storage transactions roll back when a write throws', pg.evaluate("""async () => { check('storage transactions roll back when a write throws', pg.evaluate("""async () => {
try { await DB.op('readwrite', st => { st.put('partial', 'abort-test'); throw new Error('test abort'); }); } try { await DB.op('readwrite', st => { st.put('partial', 'abort-test'); throw new Error('test abort'); }); }
catch (e) { return await DB.get('abort-test') === undefined; } catch (e) { return await DB.get('abort-test') === undefined; }
@@ -228,20 +237,39 @@ with sync_playwright() as p:
others = ev(f'bagTotal - bag[{tgt[2]}]') others = ev(f'bagTotal - bag[{tgt[2]}]')
check('vacuum (left) picks the clicked material', got > 0, f"{ev(f'MD[{tgt[2]}].name')} x{got}") check('vacuum (left) picks the clicked material', got > 0, f"{ev(f'MD[{tgt[2]}].name')} x{got}")
check('vacuum (left) leaves other materials', others == 0, others) check('vacuum (left) leaves other materials', others == 0, others)
# R ticks what is under the cursor; ticked materials come along with any left-click suck check('vacuum defaults to What I click', ev("GAME.pickMode === 'first'"))
check('first mode locks onto dirt and leaves other grains', ev("""(() => {
const x = Math.floor(bot.x) + 6, y = Math.floor(bot.y) - 6, cells = [], dirt = bag[LDIRT], stone = bag[RSTONE];
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) { const i = (y+dy)*W+x+dx; cells.push([i, mat[i], temp[i], flags[i]]); setCell(i, dx < 0 ? RSTONE : LDIRT, 20, 0); }
const aim = GAME.aim; GAME.aim = { x, y }; GAME.lmb = true; primaryPress(false); toolTick();
const ok = GAME.vacFilter === LDIRT && bag[LDIRT] > dirt && bag[RSTONE] === stone;
primaryRelease(false); GAME.lmb = false; GAME.aim = aim;
for (const [i, m, t, f] of cells) setCell(i, m, t, f);
bagTake(LDIRT, bag[LDIRT] - dirt); return ok;
})()"""))
check('middle click chooses a loose material even with none in the bag', ev("""(() => {
const x = Math.floor(bot.x) + 6, y = Math.floor(bot.y) - 6, i = y*W+x;
const old = [mat[i], temp[i], flags[i]], aim = GAME.aim, blow = GAME.blow, n = bag[LDIRT];
setCell(i, LDIRT, 20, 0); bag[LDIRT] = 0; GAME.aim = { x, y }; pickUnderCursor();
const ok = GAME.blow.size === 1 && GAME.blow.has(LDIRT);
setCell(i, ...old); bag[LDIRT] = n; GAME.aim = aim; GAME.blow = blow; return ok;
})()"""))
# R marks pick-up without changing the blow choice
blow_before = ev('[...GAME.blow]')
tgt2 = ev(f'''(() => {{ for (let r = 0; r < 40; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{ tgt2 = ev(f'''(() => {{ for (let r = 0; r < 40; r++) for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {{
const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''') const x = {bx + 6} + dx, y = {gy + 3} + dy, m = mat[y*W+x]; if (PH[m] === PH_POWDER && inReach(x, y)) return [x, y, m]; }} return null; }})()''')
check('a loose grain is available for R marking', tgt2 is not None)
if tgt2: if tgt2:
cx, cy = cell_to_screen(tgt2[0], tgt2[1]) cx, cy = cell_to_screen(tgt2[0], tgt2[1])
pg.mouse.move(cx, cy); settle(80); pg.keyboard.press('r'); settle(80) pg.mouse.move(cx, cy); settle(80); pg.keyboard.press('r'); settle(80)
check('R ticks the material under the cursor', ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]')) check('R marks pick-up and switches mode without changing blow', ev(f"GAME.collect.has({tgt2[2]}) && GAME.pickMode === 'marked'") and ev('[...GAME.blow]') == blow_before)
ev('renderBag()'); settle(50) ev('renderBag()'); settle(50)
ticks = pg.locator('#baglist .tick.on') marks = pg.locator('#baglist [data-pick][aria-pressed=true]')
if ticks.count(): ticks.first.click(); settle(80) if marks.count(): marks.first.click(); settle(80)
check('bag tick unticks it', not ev(f'GAME.collect.has({tgt2[2]})'), ev('[...GAME.collect]')) check('removing the last pick-up mark returns to first', ev(f"!GAME.collect.has({tgt2[2]}) && GAME.pickMode === 'first'"))
pg.screenshot(path=str(out / '02-vacuum.png')) pg.screenshot(path=str(out / '02-vacuum.png'))
# 2b. the vacuum filter is fixed when you click: ore with stone around it, ore ticked # 2b. the vacuum filter is fixed when you click: ore with stone around it, ore marked
def mixed_pile(x0, y0): def mixed_pile(x0, y0):
ev(f"{{ const r = mulberry32(3); for (let y = {y0}; y < {y0} + 6; y++) for (let x = {x0}; x < {x0} + 14; x++) setCell(y*W+x, r() < 0.5 ? CUORE : RSTONE, AMB[y], 0); }}") ev(f"{{ const r = mulberry32(3); for (let y = {y0}; y < {y0} + 6; y++) for (let x = {x0}; x < {x0} + 14; x++) setCell(y*W+x, r() < 0.5 ? CUORE : RSTONE, AMB[y], 0); }}")
def suck_at(x, y, ms=900, shift=False, fly=None): def suck_at(x, y, ms=900, shift=False, fly=None):
@@ -262,14 +290,14 @@ with sync_playwright() as p:
mixed_pile(px0, py0); settle(400) mixed_pile(px0, py0); settle(400)
ore = find_ore() ore = find_ore()
refill('bag.fill(0)') refill('bag.fill(0)')
ev('GAME.collect = new Set([CUORE]); lastBagKey = "";') ev('GAME.collect = new Set([CUORE]); setPickMode("marked"); lastBagKey = "";')
suck_at(*ore) suck_at(*ore)
check('with ore ticked, clicking ore never pulls in stone', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}") check('with ore marked, clicking ore never pulls in stone', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)') mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
st = ev(f'(() => {{ for (let y = {py0} - 8; y < {gy} + 4; y++) for (let x = {px0} - 6; x < {px0} + 20; x++) if (mat[y*W+x] === RSTONE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()') st = ev(f'(() => {{ for (let y = {py0} - 8; y < {gy} + 4; y++) for (let x = {px0} - 6; x < {px0} + 20; x++) if (mat[y*W+x] === RSTONE && mat[(y-1)*W+x] === EMPTY && inReach(x, y)) return [x, y]; return null; }})()')
suck_at(*st) suck_at(*st)
check('with ore ticked, starting on stone rubble still takes only the ore', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}") check('with ore marked, starting on stone rubble still takes only the ore', ev('bag[CUORE]') > 0 and ev('bag[RSTONE]') == 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
ev('GAME.collect = new Set(); lastBagKey = "";') ev('GAME.collect = new Set(); setPickMode("first"); lastBagKey = "";')
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)') mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore() ore = find_ore()
suck_at(*ore, ms=500, shift=True, fly='d') suck_at(*ore, ms=500, shift=True, fly='d')
@@ -279,6 +307,26 @@ with sync_playwright() as p:
ore = find_ore() ore = find_ore()
suck_at(*ore, ms=500, shift=True) suck_at(*ore, ms=500, shift=True)
check('Shift held standing still takes everything', ev('bag[RSTONE]') > 0 and ev('bag[CUORE]') > 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}") check('Shift held standing still takes everything', ev('bag[RSTONE]') > 0 and ev('bag[CUORE]') > 0, f"ore {ev('bag[CUORE]')}, stone {ev('bag[RSTONE]')}")
pg.keyboard.press('t'); settle(80)
check('T cycles first to marked', ev("GAME.pickMode === 'marked'"))
before = ev('bagTotal')
suck_at(*ore, ms=100)
check('empty marked mode picks up nothing and explains why', ev('bagTotal') == before and 'Nothing marked to pick up. R over something, or ⬇ in the bag.' in ev('GAME.status'))
pg.keyboard.press('t'); settle(80)
check('T cycles marked to all', ev("GAME.pickMode === 'all'"))
mixed_pile(px0, py0); settle(400); refill('bag.fill(0)')
ore = find_ore(); suck_at(*ore, ms=500)
check('Everything mode picks up both materials', ev('bag[RSTONE] > 0 && bag[CUORE] > 0'))
pg.keyboard.press('t'); settle(80)
check('T cycles all to first', ev("GAME.pickMode === 'first'"))
for mode in ['marked', 'all', 'first']:
pg.click('#selcard [data-pick-mode]'); settle(80)
check(f'card chip cycles to {mode}', ev(f'GAME.pickMode === "{mode}"'))
ev("toggleCollect(CUORE); setPickMode('all'); toggleCollect(RSTONE)")
check('adding another mark switches a manual mode back to marked', ev("GAME.pickMode === 'marked'"))
ev("setPickMode('all'); toggleCollect(RSTONE)")
check('removing a mark keeps a manual mode', ev("GAME.pickMode === 'all'"))
ev("GAME.collect.clear(); setPickMode('first'); lastBagKey = ''; hudDirty = true")
# zoom jumps straight to the bot # zoom jumps straight to the bot
pg.mouse.move(640, 400); settle(600) pg.mouse.move(640, 400); settle(600)
pg.mouse.wheel(0, -100); pg.wait_for_timeout(60) pg.mouse.wheel(0, -100); pg.wait_for_timeout(60)
@@ -289,8 +337,8 @@ with sync_playwright() as p:
# 3. right button blows the chosen material out of the bot; the choice is sticky # 3. right button blows the chosen material out of the bot; the choice is sticky
refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120') refill('bag[COAL] = 120; bag[RSTONE] = 600; bag[CUORE] = 60; bag[LCLAY] = 200; bag[LWOOD] = 120')
ok = select('2', 'GAME.dropMat === COAL && !GAME.blowMix') ok = select('2', 'GAME.blow.size === 1 && GAME.blow.has(COAL)')
check('Q/E chooses coal to blow', ok, ev('GAME.dropMat')) check('Q/E chooses coal to blow', ok, ev('[...GAME.blow]'))
dx_, dy_ = cell_to_screen(bx - 14, gy - 6) dx_, dy_ = cell_to_screen(bx - 14, gy - 6)
c0 = ev('bag[COAL]') c0 = ev('bag[COAL]')
pg.mouse.move(dx_, dy_); pg.mouse.down(button='right'); settle(150) pg.mouse.move(dx_, dy_); pg.mouse.down(button='right'); settle(150)
@@ -300,21 +348,43 @@ with sync_playwright() as p:
check('right click blows coal out of the bag onto the ground', ev('bag[COAL]') < c0 and ev('flyers.length') == 0, f"{c0}->{ev('bag[COAL]')}") check('right click blows coal out of the bag onto the ground', ev('bag[COAL]') < c0 and ev('flyers.length') == 0, f"{c0}->{ev('bag[COAL]')}")
refill('bag[COAL] = 3') refill('bag[COAL] = 3')
hold(dx_, dy_, 400, 'right'); settle(400) hold(dx_, dy_, 400, 'right'); settle(400)
check('chosen material stays chosen after it runs out', ev('bag[COAL] === 0 && GAME.dropMat === COAL'), ev('[bag[COAL], GAME.dropMat]')) check('chosen material stays chosen after it runs out', ev('bag[COAL] === 0 && GAME.blow.size === 1 && GAME.blow.has(COAL)'), ev('[bag[COAL], ...GAME.blow]'))
# tick coal and ore: Shift no longer changes what is blown, the selection does # mark coal and ore: Shift no longer changes what is blown, the selection does
refill('bag[COAL] = 60; bag[CUORE] = 60') refill('bag[COAL] = 60; bag[CUORE] = 60')
ev('GAME.collect = new Set([COAL, CUORE]); GAME.dropMat = COAL; GAME.blowMix = false; lastBagKey = ""; hudDirty = true;') ev('GAME.collect = new Set([COAL, CUORE]); GAME.blow = new Set([COAL]); lastBagKey = ""; hudDirty = true;')
mx_, my_ = cell_to_screen(bx - 26, gy - 6) mx_, my_ = cell_to_screen(bx - 26, gy - 6)
pg.mouse.move(mx_, my_); pg.keyboard.down('Shift'); pg.mouse.down(button='right'); settle(400); pg.mouse.up(button='right'); pg.keyboard.up('Shift'); settle(600) pg.mouse.move(mx_, my_); pg.keyboard.down('Shift'); pg.mouse.down(button='right'); settle(400); pg.mouse.up(button='right'); pg.keyboard.up('Shift'); settle(600)
check('Shift + right click still blows only the chosen coal', ev('bag[CUORE]') == 60 and ev('bag[COAL]') < 60, ev('[bag[COAL], bag[CUORE]]')) check('Shift + right click still blows only the chosen coal', ev('bag[CUORE]') == 60 and ev('bag[COAL]') < 60, ev('[bag[COAL], bag[CUORE]]'))
refill('bag[COAL] = 60; bag[CUORE] = 60'); settle(200) refill('bag[COAL] = 60; bag[CUORE] = 60'); settle(200)
pg.click('#baglist .mixrow .pick'); settle(200) pg.locator('#baglist .pick').filter(has_text='Coal').click(); settle(80)
check('the bag offers the ticked mix as a choice', ev('GAME.blowMix') and 'Ticked mix' in pg.inner_text('#selcard'), pg.inner_text('#selcard')[:120]) check('clicking a bag name chooses one blow material', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
pick_before = ev('[...GAME.collect]')
pg.click(f'#baglist [data-blow="{ev("CUORE")}"]'); settle(100)
check('blow marks make a mix without changing pick-up marks', ev('GAME.blow.has(COAL) && GAME.blow.has(CUORE) && GAME.blow.size === 2') and ev('[...GAME.collect]') == pick_before and 'Mix of 2' in pg.inner_text('#selcard'))
blow_before = ev('[...GAME.blow]')
pg.click(f'#baglist [data-pick="{ev("RSTONE")}"]'); settle(80)
check('pick-up marks do not change blow marks', ev('[...GAME.blow]') == blow_before)
check('vacuum bag rows have both toggles', pg.locator('#baglist [data-pick]').count() == pg.locator('#baglist .bagrow').count() == pg.locator('#baglist [data-blow]').count())
pg.mouse.move(mx_, my_); pg.mouse.down(button='right'); settle(500); pg.mouse.up(button='right'); settle(600) pg.mouse.move(mx_, my_); pg.mouse.down(button='right'); settle(500); pg.mouse.up(button='right'); settle(600)
dc, do_ = 60 - ev('bag[COAL]'), 60 - ev('bag[CUORE]') dc, do_ = 60 - ev('bag[COAL]'), 60 - ev('bag[CUORE]')
check('choosing Ticked mix, right click blows an even ore/coal mix', dc > 5 and abs(dc - do_) <= 2, f'coal {dc}, ore {do_}') check('right click blows an even marked ore/coal mix', dc > 5 and abs(dc - do_) <= 2, f'coal {dc}, ore {do_}')
ev('GAME.blowMix = false;') pg.keyboard.press('e'); settle(80)
ev('GAME.collect = new Set(); lastBagKey = "";') check('Q/E replaces the mix with one material', ev('GAME.blow.size === 1'))
ev("GAME.collect = new Set([COAL, CUORE]); setPickMode('marked'); GAME.blow = new Set([COAL, RSTONE]); GAME.status = ''; lastBagKey = ''; hudDirty = true")
refill('bag[COAL] = 120; bag[CUORE] = 100; bag[RSTONE] = 600')
for width, height, suffix in [(1280, 800, ''), (900, 700, '-900')]:
pg.set_viewport_size({'width': width, 'height': height}); settle(200)
pg.screenshot(path=str(out / f'vacuum-card{suffix}.png'))
check(f'vacuum panel fits at {width}', ev("(() => { const c = $('selcard'), b = $('bagpanel'); return [c, b].every(e => { const r = e.getBoundingClientRect(); return r.left >= 0 && r.right <= innerWidth && r.bottom <= innerHeight && e.scrollWidth <= e.clientWidth; }) && [...$('baglist').querySelectorAll('.bagrow')].every(e => e.scrollWidth <= e.clientWidth); })()"))
pg.set_viewport_size({'width': 1280, 'height': 800})
pg.keyboard.press('3'); settle(80)
check('build bag has no vacuum toggles', pg.locator('#baglist .mark').count() == 0)
pg.keyboard.press('2'); settle(80)
ev("toggleBlow(BARCU)")
check('a bar blow mark replaces the mix', ev('GAME.blow.size === 1 && GAME.blow.has(BARCU)'))
ev("toggleBlow(COAL)")
check('a grain blow mark replaces a bar', ev('GAME.blow.size === 1 && GAME.blow.has(COAL)'))
ev("GAME.collect.clear(); setPickMode('first'); lastBagKey = ''; hudDirty = true")
refill('bag[COAL] = 120') refill('bag[COAL] = 120')
# 4. igniter lights it # 4. igniter lights it
@@ -654,6 +724,13 @@ with sync_playwright() as p:
check('gamepad RT digs where the right stick aims', ev('stats.dug') > d0, f"{d0}->{ev('stats.dug')}") check('gamepad RT digs where the right stick aims', ev('stats.dug') > d0, f"{d0}->{ev('stats.dug')}")
ev("__pad.buttons[5].pressed = true;"); settle(100); ev("__pad.buttons[5].pressed = false;"); settle(100) ev("__pad.buttons[5].pressed = true;"); settle(100); ev("__pad.buttons[5].pressed = false;"); settle(100)
check('gamepad RB switches to the vacuum', ev('GAME.tool') == 1) check('gamepad RB switches to the vacuum', ev('GAME.tool') == 1)
ev("GAME.collect.clear(); setPickMode('first'); GAME.aim = { x: Math.floor(bot.x), y: Math.floor(bot.y) }; setCell(Math.floor(bot.y)*W+Math.floor(bot.x), COAL, 20, 0); __pad.buttons[2].pressed = true; pollPad(); __pad.buttons[2].pressed = false; pollPad()")
check('gamepad X keeps R pick-up marking', ev("GAME.collect.has(COAL) && GAME.pickMode === 'marked'"))
ev("__pad.buttons[1].pressed = true; pollPad(); __pad.buttons[1].pressed = false; pollPad()")
check('gamepad B cycles pick-up mode', ev("GAME.pickMode === 'all'"))
ev("__pad.buttons[15].pressed = true; pollPad(); __pad.buttons[15].pressed = false; pollPad()")
check('gamepad d-pad chooses a single blow material', ev('GAME.blow.size === 1'))
ev("__pad.buttons[9].pressed = true;"); settle(100); ev("__pad.buttons[9].pressed = false;"); settle(100) ev("__pad.buttons[9].pressed = true;"); settle(100); ev("__pad.buttons[9].pressed = false;"); settle(100)
check('gamepad Start opens menu', ev("!$('m-pause').hidden")) check('gamepad Start opens menu', ev("!$('m-pause').hidden"))
ev("__pad.buttons[13].pressed = true;"); settle(80); ev("__pad.buttons[13].pressed = false;"); settle(80) ev("__pad.buttons[13].pressed = true;"); settle(80); ev("__pad.buttons[13].pressed = false;"); settle(80)
+1
View File
@@ -25,6 +25,7 @@ run('for (let k=0;k<5;k++) { toolBlow(80, 70, 1, COAL); simTick(); }');
check('blown coal flies out of the bot', run('flyers.length') > 0, run('flyers.length')); check('blown coal flies out of the bot', run('flyers.length') > 0, run('flyers.length'));
ticks(run, 120); ticks(run, 120);
check('blown coal leaves the bag and lands on the ground', run('bag[COAL]') < c0 && run('flyers.length') === 0 && count(run, run('COAL'), 60, 80, 100, 99) > 0, `${c0}->${run('bag[COAL]')} landed ${count(run, run('COAL'), 60, 80, 100, 99)}`); check('blown coal leaves the bag and lands on the ground', run('bag[COAL]') < c0 && run('flyers.length') === 0 && count(run, run('COAL'), 60, 80, 100, 99) > 0, `${c0}->${run('bag[COAL]')} landed ${count(run, run('COAL'), 60, 80, 100, 99)}`);
check('empty blow marks explain that nothing is left', run('toolBlow(80, 70, 1, COAL, [CUORE, BARCU]).status') === 'No marked materials left to blow.');
// bot flies through built walls but not through natural rock // bot flies through built walls but not through natural rock
run('bag[RSTONE] += 64; bagTotal += 64; placeBlocks(BP_WALL, RSTONE, blockRect(30, 14, 30, 17))'); run('bag[RSTONE] += 64; bagTotal += 64; placeBlocks(BP_WALL, RSTONE, blockRect(30, 14, 30, 17))');
check('bot can overlap a built wall', !run('botOverlapSolid(121, 58)')); check('bot can overlap a built wall', !run('botOverlapSolid(121, 58)'));