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 638fd3bdb7
commit e2c9bc9c58
6 changed files with 100 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();
+21
View File
@@ -0,0 +1,21 @@
---
id: t25
status: Review
order: 2
priority: P3
created: 2026-10-24
updated: 2026-10-24T19:27:18Z
title: visual effect for vac/blow tool
report: |
(agent) While sucking, short teal streaks of air swirl in toward the nozzle ring; while blowing,
amber streaks run from the bot toward the cursor and widen to the nozzle width. They're on top
of the grains already flying in and out, and they turn off with "Sparks and debris" in
settings. Please check whether they're too faint, too busy or too fast.
---
## Subtasks
- [ ] add visual effects for suck and blow
- [ ]
- [x] While sucking, short teal streaks of air swirl in toward the nozzle ring
- [x] While blowing, amber streaks push out from the bot toward the cursor and spread into a cone
+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)')