World sounds are local: fade with distance and pan (distant sizzles snapped constantly)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 23:08:10 -04:00
co-authored by Claude Opus 5.5
parent 4d4d94cbd8
commit edcdf2f900
6 changed files with 60 additions and 7 deletions
+6
View File
@@ -177,6 +177,12 @@ above, including:
- **Fewer, smaller messages.** Molds show only a small percentage, red when blocked or spoiled and gold when a casting waits for F; details are in the hover panel. Bars dropping, blocked molds and set castings no longer pop up cards or toasts. Fan heat is a red marker on the fan; melted tubes and machines get a short red label where they were. Status lines are one short sentence. The owner found the messages too large and too many. - **Fewer, smaller messages.** Molds show only a small percentage, red when blocked or spoiled and gold when a casting waits for F; details are in the hover panel. Bars dropping, blocked molds and set castings no longer pop up cards or toasts. Fan heat is a red marker on the fan; melted tubes and machines get a short red label where they were. Status lines are one short sentence. The owner found the messages too large and too many.
- **World sounds are local.** Sounds from the world (water sizzling on hot metal or lava, and
any other event with a place) fade with distance from the view and pan left or right; they go
silent one screen width off-screen. The owner heard a constant snapping that followed the bot
in an older world but not in a new one. Most likely the sizzle: in a test, a lava and water
contact far from the bot sizzled about 580 times a minute, all played at full volume.
## Ideas not built yet ## Ideas not built yet
- **Concrete**, as a stronger wall material from rubble. - **Concrete**, as a stronger wall material from rubble.
+2 -2
View File
@@ -354,9 +354,9 @@ Smooth look: a WebGL2 pass that turns the per-grain buffers drawWorld fills (col
- functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace - functions: smCompile, smoothAvailable, smUpload, smoothDraw, smoothPace
- variables: SM, SM_VS, SM_FS, smBytes - variables: SM, SM_VS, SM_FS, smBytes
### src/ui/audio.js (209 lines) ### src/ui/audio.js (228 lines)
Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples. Audio: chiptune effects and generative music that changes with depth. Everything is synthesized with WebAudio, no samples.
- functions: auInit, auVolumes, rateOk, tone, noiseBurst, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler - functions: auInit, auVolumes, rateOk, tone, noiseBurst, sfxPlace, playSfx, auAmbient, mtof, zoneForDepth, scaleNote, musicScheduler
- variables: AU, SFX, ZONES - variables: AU, SFX, ZONES
### src/ui/state.js (67 lines) ### src/ui/state.js (67 lines)
+17
View File
@@ -4,6 +4,23 @@ 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-23: world sounds are local (Claude Code, with Codex)
Note: "there is a sound effect that seems to happen randomly but constantly… maybe the sound
effects are not local?" The owner then confirmed a new world is quiet.
- Measured headlessly over 5 game minutes: a fresh world emits no sounds; a lava and water
contact 720+ grains from the bot emitted 2,889 sizzles (about 580 a minute), and `events.js`
played every one at full volume. The sizzle (`heat.js` boiling and quenching) is the only sim
sound with a position.
- `src/ui/audio.js`: `sfxPlace(x, y)` gives gain (1 in view, 0 one view width beyond it) and
pan; `playSfx(name, arg, x, y)` skips silent ones before the rate limit and plays the rest
through a gain and stereo panner (`AU.bus`). `src/ui/events.js` passes positions.
`docs/DESIGN.md`, `docs/PROJECT_MAP.md`.
- `test/play.py`: far off-screen is silent, on screen plays, just off-screen is quieter and
panned. All 11 headless suites, `play.py` and `t_smooth.py` pass.
- Open: not tested against the owner's own save; the owner checks by ear.
## 2026-10-23: fewer, smaller messages (Claude Code, with Codex) ## 2026-10-23: fewer, smaller messages (Claude Code, with Codex)
Note: "lets change the engot mold and other status messages. they are way too large and just too Note: "lets change the engot mold and other status messages. they are way too large and just too
+22 -3
View File
@@ -75,7 +75,7 @@ function tone(freq, dur, opts = {}) {
g.gain.exponentialRampToValueAtTime(v, t + a); g.gain.exponentialRampToValueAtTime(v, t + a);
g.gain.setValueAtTime(v, t + Math.max(a, dur - rel)); g.gain.setValueAtTime(v, t + Math.max(a, dur - rel));
g.gain.exponentialRampToValueAtTime(0.0001, t + dur); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
o.connect(g); g.connect(opts.bus || AU.sfx); o.connect(g); g.connect(opts.bus || AU.bus || AU.sfx);
o.start(t); o.stop(t + dur + 0.02); o.start(t); o.stop(t + dur + 0.02);
} }
function noiseBurst(dur, opts = {}) { function noiseBurst(dur, opts = {}) {
@@ -87,7 +87,7 @@ function noiseBurst(dur, opts = {}) {
if (opts.sweep) f.frequency.exponentialRampToValueAtTime(opts.sweep, t + dur); if (opts.sweep) f.frequency.exponentialRampToValueAtTime(opts.sweep, t + dur);
const g = ctx.createGain(); const v = opts.vol || 0.08; const g = ctx.createGain(); const v = opts.vol || 0.08;
g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur); g.gain.setValueAtTime(v, t); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
s.connect(f); f.connect(g); g.connect(opts.bus || AU.sfx); s.connect(f); f.connect(g); g.connect(opts.bus || AU.bus || AU.sfx);
s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.02); s.start(t, Math.random() * 1.5); s.stop(t + dur + 0.02);
} }
const SFX = { const SFX = {
@@ -118,7 +118,26 @@ const SFX = {
sale() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [784, 988, 1175, 1568].forEach((f, k) => tone(f, 0.09, { wave: 'p25', vol: 0.05, at: t + k * 0.06 })); }, sale() { if (!AU.ctx) return; const t = AU.ctx.currentTime; [784, 988, 1175, 1568].forEach((f, k) => tone(f, 0.09, { wave: 'p25', vol: 0.05, at: t + k * 0.06 })); },
discover() { if (!rateOk('disc', 500)) return; const t = AU.ctx && AU.ctx.currentTime; tone(880, 0.06, { wave: 'p25', vol: 0.04, at: t }); tone(1175, 0.1, { wave: 'p25', vol: 0.04, at: t + 0.06 }); }, discover() { if (!rateOk('disc', 500)) return; const t = AU.ctx && AU.ctx.currentTime; tone(880, 0.06, { wave: 'p25', vol: 0.04, at: t }); tone(1175, 0.1, { wave: 'p25', vol: 0.04, at: t + 0.06 }); },
}; };
function playSfx(name, arg) { if (!AU.ctx || !SFX[name]) return; try { SFX[name](arg); } catch (e) { /* ignore */ } } function sfxPlace(x, y) {
if (typeof R === 'undefined' || !R.bw) return { gain: 1, pan: 0 };
const dx = Math.max(R.vx0 - x, 0, x - R.vx0 - R.bw), dy = Math.max(R.vy0 - y, 0, y - R.vy0 - R.bh);
return { gain: Math.max(0, 1 - Math.hypot(dx, dy) / R.bw), pan: Math.max(-1, Math.min(1, (x - R.vx0 - R.bw / 2) / (R.bw / 2))) * 0.6 };
}
function playSfx(name, arg, x, y) {
if (!AU.ctx || !SFX[name]) return;
const place = typeof x === 'number' && typeof y === 'number' ? sfxPlace(x, y) : null;
if (place && place.gain < 0.02) return;
const prev = AU.bus; let gain, pan;
try {
if (place) {
gain = AU.ctx.createGain(); pan = AU.ctx.createStereoPanner();
gain.gain.value = place.gain; pan.pan.value = place.pan;
gain.connect(pan); pan.connect(AU.sfx); AU.bus = gain;
}
SFX[name](arg);
} catch (e) { /* ignore */ }
finally { AU.bus = prev; if (gain || pan) setTimeout(() => { if (gain) gain.disconnect(); if (pan) pan.disconnect(); }, 1500); }
}
// Ambient layer driven by what is on screen. // Ambient layer driven by what is on screen.
function auAmbient(info) { function auAmbient(info) {
+2 -2
View File
@@ -11,7 +11,7 @@ function drainEvents() {
const m = MILESTONES.find(x => x.id === e.id); const m = MILESTONES.find(x => x.id === e.id);
if (m && !['bit1', 'bit2', 'bit3', 'tank'].includes(m.id)) { showCard({ kind: 'Milestone', title: m.title, body: [m.msg, m.reward], accent: 'milestone' }); playSfx('milestone'); } if (m && !['bit1', 'bit2', 'bit3', 'tank'].includes(m.id)) { showCard({ kind: 'Milestone', title: m.title, body: [m.msg, m.reward], accent: 'milestone' }); playSfx('milestone'); }
} else if (e.t === 'cast_ready') { } else if (e.t === 'cast_ready') {
playSfx('cast'); playSfx('cast', undefined, e.x, e.y);
} else if (e.t === 'upgrade') { } else if (e.t === 'upgrade') {
playSfx('upgrade'); if (SET.shake) R.shake = 1.4; playSfx('upgrade'); if (SET.shake) R.shake = 1.4;
showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone', showCard({ kind: e.kind === 'bit' ? 'Drill bit upgraded' : 'Bag upgraded', title: e.kind === 'bit' ? BIT_TIERS[e.tier].name : TANK_TIERS[e.tier].name, accent: 'milestone',
@@ -36,7 +36,7 @@ function drainEvents() {
} }
else if (e.t === 'claim_paid') { showCard({ kind: 'Claim', title: 'The land is yours', accent: 'milestone', hold: 1.5, body: ['You have paid off the claim on this land.', 'Keep filling orders: bigger buyers bring bigger ones. The ledger has bigger goals too.'] }); playSfx('milestone'); } else if (e.t === 'claim_paid') { showCard({ kind: 'Claim', title: 'The land is yours', accent: 'milestone', hold: 1.5, body: ['You have paid off the claim on this land.', 'Keep filling orders: bigger buyers bring bigger ones. The ledger has bigger goals too.'] }); playSfx('milestone'); }
else if (e.t === 'sprout') { showCard({ kind: 'Discovery', title: 'A sapling', swatch: ACORN, body: ['An acorn on soil under open sky has sprouted.', 'It grows into a tree in about a minute and a half. Keep the space above it clear.'] }); playSfx('discover'); } else if (e.t === 'sprout') { showCard({ kind: 'Discovery', title: 'A sapling', swatch: ACORN, body: ['An acorn on soil under open sky has sprouted.', 'It grows into a tree in about a minute and a half. Keep the space above it clear.'] }); playSfx('discover'); }
else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a); } else if (e.t === 'sfx') { if (e.s) playSfx(e.s, e.a, e.x, e.y); }
else if (e.t === 'toast') { if (e.text) toast(e.text); } else if (e.t === 'toast') { if (e.text) toast(e.text); }
} }
} }
+11
View File
@@ -26,6 +26,17 @@ with sync_playwright() as p:
pg.wait_for_timeout(500) pg.wait_for_timeout(500)
ev = pg.evaluate ev = pg.evaluate
ev("window._maxStatus = ''; const _ss = setStatus; setStatus = function (s, k) { if (s && s.length > _maxStatus.length) _maxStatus = s; return _ss(s, k); }; 0") ev("window._maxStatus = ''; const _ss = setStatus; setStatus = function (s, k) { if (s && s.length > _maxStatus.length) _maxStatus = s; return _ss(s, k); }; 0")
# World effects use the view, and inaudible ones must not throttle nearby sounds.
ev("auInit(); window._noiseCalls = 0; window._noiseBurst = noiseBurst; noiseBurst = function (...args) { _noiseCalls++; return _noiseBurst(...args); }; 0")
try:
far = ev("(() => { const n = _noiseCalls; AU.last.sizzle = 0; const x = R.vx0 + R.bw * 3, y = R.vy0 + R.bh / 2; playSfx('sizzle', 0, x, y); return [_noiseCalls - n, sfxPlace(x, y).gain, AU.last.sizzle]; })()")
check('a sound far off-screen is silent', far == [0, 0, 0], far)
near = ev("(() => { const n = _noiseCalls; AU.last.sizzle = 0; const x = R.vx0 + R.bw / 2, y = R.vy0 + R.bh / 2; playSfx('sizzle', 0, x, y); return [_noiseCalls - n, sfxPlace(x, y).gain, AU.bus == null]; })()")
check('a sound on screen plays', near == [1, 1, True], near)
quiet = ev('sfxPlace(R.vx0 + R.bw * 1.5, R.vy0 + R.bh / 2)')
check('a sound just off-screen is quieter', 0 < quiet['gain'] < 1 and quiet['pan'] > 0, quiet)
finally:
ev('noiseBurst = _noiseBurst; delete window._noiseBurst; delete window._noiseCalls; 0')
look = sys.argv[2] if len(sys.argv) > 2 else 'pixel' look = sys.argv[2] if len(sys.argv) > 2 else 'pixel'
check('a new game starts fully zoomed in', ev('SET.zoom') == 12, ev('SET.zoom')) check('a new game starts fully zoomed in', ev('SET.zoom') == 12, ev('SET.zoom'))
# Ctrl+W closes the tab; with a world open the browser must ask first # Ctrl+W closes the tab; with a world open the browser must ask first