Pneumatic nozzle: air streaks while sucking and blowing (t25)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 15:27:18 -04:00
co-authored by Claude Opus 5.5
parent c95cc48340
commit 01403bb95e
5 changed files with 79 additions and 5 deletions
+6
View File
@@ -235,6 +235,12 @@ above, including:
the bag; Ctrl+click a bag name adds it to the blow mix and Ctrl+right-click to pick-up; the menu the bag; Ctrl+click a bag name adds it to the blow mix and Ctrl+right-click to pick-up; the menu
is one line per material because the first version was too large. is one line per material because the first version was too large.
- **Air streaks for the nozzle (t25, 2026-10-24).** On top of the grains that fly in and out, the
owner chose air streaks: while sucking, short teal streaks swirl in toward the ring; while
blowing, amber streaks run from the bot to the cursor and spread to the brush width. Drawn only
(time-based, no random numbers, so the simulation is unchanged), only in reach, and off with
"Sparks and debris" or in unexplored fog.
- **The Vacuum is the Pneumatic nozzle (t26, 2026-10-24).** The owner's choice of name, since the - **The Vacuum is the Pneumatic nozzle (t26, 2026-10-24).** The owner's choice of name, since the
tool blows as much as it sucks. It shows as the tool card's title row, with the nozzle width as a tool blows as much as it sucks. It shows as the tool card's title row, with the nozzle width as a
chip, and in the hotbar tooltip and all player text ("the nozzle" after the first mention). Code chip, and in the hotbar tooltip and all player text ("the nozzle" after the first mention). Code
+5 -5
View File
@@ -366,8 +366,8 @@ 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 (75 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 tool status.
- functions: loadSettings, saveSettings, el, esc, rgbOf, fmtT, toast, setStatus - functions: loadSettings, saveSettings, 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, $
@@ -404,7 +404,7 @@ 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 (243 lines) ### src/ui/selection.js (242 lines)
The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel. The selected tool and piece: R / Q / E, drill width, filter picker, the selection card, the bag panel.
- functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag - functions: pieceUsesWall, setDrillWidth, tuneUnderCursor, cycleItem, cycleWall, rotate, toggleBlow, pickFilterUnderCursor, toggleFilterMat, setBrush, pieceV, pieceExtra, filterChoices, matList, filterParts, filterSentence, applyFilterRun, openFilterPicker, openWallPicker, filterBlockAt, chipCost, pieceCostNow, renderSelCard, renderBag
- variables: lastBagKey - variables: lastBagKey
@@ -428,9 +428,9 @@ Gamepad input.
- functions: padPressed, pollPad - functions: padPressed, pollPad
- variables: PAD - variables: PAD
### src/ui/previews.js (334 lines) ### src/ui/previews.js (371 lines)
Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste. Previews drawn under the cursor: build ghosts, drill aim and pulses, selections, paste.
- functions: blockRectPx, drawGrid, previewColor, crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim, drawPreviews, drawTag, drawSelection, drawPastePreview - functions: blockRectPx, drawGrid, previewColor, crackSeg, drawCracks, pulseRGB, fanPoints, drawDrillPulses, drawDrillAim, drawAirStreaks, drawPreviews, drawTag, drawSelection, drawPastePreview
- variables: CRACK_N, CRACK_LV, SEL_COL - variables: CRACK_N, CRACK_LV, SEL_COL
### src/ui/events.js (42 lines) ### src/ui/events.js (42 lines)
+9
View File
@@ -4,6 +4,15 @@ 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: t25: air streaks for the Pneumatic nozzle (Claude Code, Codex)
- `drawAirStreaks` in `src/ui/previews.js`: 12 teal streaks spiral into the ring while sucking, 10
amber streaks run from the nozzle to the cursor in a cone while blowing. `performance.now()`
only, no `rnd()`; skipped with particles off or in fog. `test/play.py`: drawing both doesn't
change the sim RNG. Also commits the `docs/PROJECT_MAP.md` refresh missed in t27/t26.
- Tests: `run_all.py --browser` passed (play.py 240; t_smooth's half-resolution flake at load 9
passed on rerun). Screenshots `shots/t25-{suck,blow}.png`.
## 2026-10-24: t26: the Vacuum is renamed the Pneumatic nozzle (Claude Code, Codex) ## 2026-10-24: t26: the Vacuum is renamed the Pneumatic nozzle (Claude Code, Codex)
- `TOOLS[1].name`, a title row in the tool card ("Pneumatic nozzle" plus a `width N` chip, moved - `TOOLS[1].name`, a title row in the tool card ("Pneumatic nozzle" plus a `width N` chip, moved
+37
View File
@@ -121,6 +121,42 @@ function drawDrillAim(ax, ay) {
if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); } if (GAME.drillTune) { const sz = Math.max(6, zp * 1.4); ctx.fillStyle = rgbOf(GAME.drillTune); ctx.fillRect(p.x + k * 1.8, p.y - k * 1.8 - sz, sz, sz); ctx.strokeStyle = 'rgba(0,0,0,0.5)'; ctx.lineWidth = 1; ctx.strokeRect(p.x + k * 1.8 + 0.5, p.y - k * 1.8 - sz + 0.5, sz - 1, sz - 1); }
ctx.restore(); ctx.restore();
} }
// Air swirls into the pickup ring or spreads from the nozzle toward the cursor.
function drawAirStreaks(ax, ay, p, r, blowing) {
if (!SET.particles || (SET.fog && fogSeen(ax, ay) < 64)) return;
const ctx = R.ctx, zp = R.zp, now = performance.now();
const cx = p.x + zp / 2, cy = p.y + zp / 2, ring = (r + 0.5) * zp;
ctx.save(); ctx.lineWidth = Math.max(1, zp * 0.2); ctx.lineCap = 'round';
if (blowing) {
const bx = bot.x + bot.w / 2, by = bot.y + bot.h / 2;
const dx = ax + 0.5 - bx, dy = ay + 0.5 - by, d = Math.hypot(dx, dy) || 1;
const o = worldToScreenPx(bx + dx / d * 3, by + dy / d * 3);
const px = cx - o.x, py = cy - o.y, len = Math.hypot(px, py) || 1;
const ux = px / len, uy = py / len;
for (let i = 0; i < 10; i++) {
const phase = (now * 0.0016 + i * 0.618) % 1;
const side = ((i * 0.37) % 1 - 0.5) * 2 * ring * phase;
const x = o.x + px * phase - uy * side, y = o.y + py * phase + ux * side;
ctx.strokeStyle = `rgba(255,210,122,${Math.sin(phase * Math.PI) * 0.55})`;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + ux * 1.6 * zp, y + uy * 1.6 * zp); ctx.stroke();
}
} else {
const travel = ring * 0.65 + 3 * zp;
for (let i = 0; i < 12; i++) {
const phase = (now * 0.0012 + i * 0.618) % 1;
const dist = ring + 3 * zp - travel * phase;
const angle = i * Math.PI * 2 / 12 + now * 0.0004 + phase * 1.2;
const co = Math.cos(angle), si = Math.sin(angle);
const x = cx + co * dist, y = cy + si * dist;
const vx = -travel * co - dist * (1.2 + 1 / 3) * si;
const vy = -travel * si + dist * (1.2 + 1 / 3) * co;
const scale = 1.2 * zp / Math.hypot(vx, vy);
ctx.strokeStyle = `rgba(92,186,166,${Math.sin(phase * Math.PI) * 0.5})`;
ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + vx * scale, y + vy * scale); ctx.stroke();
}
}
ctx.restore();
}
function drawPreviews() { function drawPreviews() {
const ctx = R.ctx, zp = R.zp; const ctx = R.ctx, zp = R.zp;
const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y); const t = toolId(), ax = Math.floor(GAME.aim.x), ay = Math.floor(GAME.aim.y);
@@ -161,6 +197,7 @@ function drawPreviews() {
ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6); ctx.strokeStyle = blowing ? 'rgba(255,210,122,0.22)' : 'rgba(92,186,166,0.22)'; ctx.lineWidth = Math.max(1, zp * (r + 0.5) * 0.6);
ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(c.x, c.y); ctx.lineTo(p.x + zp / 2, p.y + zp / 2); ctx.stroke();
} }
if (reachOk && (blowing || GAME.lmb)) drawAirStreaks(ax, ay, p, r, blowing);
} }
} else if (t === 'build') { } else if (t === 'build') {
const P = piece(), b = aimBlock(); const P = piece(), b = aimBlock();
+22
View File
@@ -321,6 +321,28 @@ with sync_playwright() as p:
pg.mouse.wheel(0, 100); settle(300) pg.mouse.wheel(0, 100); settle(300)
refill('bag.fill(0)') refill('bag.fill(0)')
# t25: draw both nozzle effects without advancing the simulation RNG.
check('nozzle air streak previews draw and preserve the simulation RNG', ev("""(() => {
const aim = GAME.aim, filter = GAME.vacFilter, blow = GAME.blow, consumed = GAME.rmbConsumed;
const particles = SET.particles, fog = SET.fog, coal = bag[COAL];
try {
SET.particles = true; SET.fog = false;
GAME.aim = { x: Math.floor(bot.x + bot.w / 2 + 16), y: Math.floor(bot.y + bot.h / 2) };
GAME.vacFilter = -1; GAME.rmbConsumed = false;
bag[COAL] = 10; GAME.blow = new Set([COAL]);
const before = RS;
for (let i = 0; i < 5; i++) {
GAME.lmb = true; GAME.rmb = false; drawPreviews();
GAME.lmb = false; GAME.rmb = true; drawPreviews();
}
return toolId() === 'vacuum' && RS === before;
} finally {
GAME.lmb = false; GAME.rmb = false;
GAME.aim = aim; GAME.vacFilter = filter; GAME.blow = blow; GAME.rmbConsumed = consumed;
SET.particles = particles; SET.fog = fog; bag[COAL] = coal;
}
})()"""))
# 3. right button blows the selection; exhausted materials stay selected # 3. right button blows the selection; exhausted materials stay selected
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.blow.size === 1 && GAME.blow.has(COAL)') ok = select('2', 'GAME.blow.size === 1 && GAME.blow.has(COAL)')