From aad86fb351c81a870d8bb83eccca9adbb030abaa Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Thu, 23 Jul 2026 15:15:35 -0400 Subject: [PATCH] pokecenter fix --- docs/new-features.md | 6 ++++-- src/core/ChipAudio.lua | 34 +++++++++++++++++++++++++++++++ src/core/Music.lua | 15 +++++++++++++- src/world/OverworldController.lua | 30 +++++++++++++-------------- tests/mod_audio_tests.lua | 18 ++++++++++++++++ 5 files changed, 84 insertions(+), 19 deletions(-) diff --git a/docs/new-features.md b/docs/new-features.md index 3e611a22..d4b747f7 100644 --- a/docs/new-features.md +++ b/docs/new-features.md @@ -42,8 +42,10 @@ Game Boy equivalent: rows above the player recede and rows below come toward the viewer. Only things that actually *stand* on the ground draw as upright billboards, unscaled and pixel-identical to flat mode: the player, NPCs, item balls, - and the screen-anchored FX attached to them (heal machine glow, emote - bubbles, the fishing rod, the FLY bird). An earlier revision tried + and the standing FX attached to them (emote bubbles, the fishing rod, + the FLY bird). The Poké Center heal-machine overlay stays on the ground + plane with the machine tiles (it is OAM glued to a BG graphic, not a + standing sprite). An earlier revision tried billboarding buildings/trees/signs too (cutting them out of the ground per hand-curated per-tileset tables); that chased an endless tail of special cases, dense tree canopy, fences fused into grass, building diff --git a/src/core/ChipAudio.lua b/src/core/ChipAudio.lua index 252fdc54..17f5f3e0 100644 --- a/src/core/ChipAudio.lua +++ b/src/core/ChipAudio.lua @@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying() end end +-- Threaded playMusic returns an empty QueueableSource and only calls +-- Source:play once the first worker buffer lands (~1 frame later). Until +-- then Source:isPlaying is false -- callers that treat that as "song over" +-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a +-- playOnce jingle like Music_PkmnHealed is cut off before it starts. +local forceAwaitingFirstBuffer -- test-only override (see _simulate*) + +function ChipAudio.awaitingFirstBuffer() + if forceAwaitingFirstBuffer then return true end + local m = currentMusic + if not (m and m.threaded and not m.started and not m.finished) then + return false + end + -- a dead worker will never deliver the first buffer + if workerReady == false then return false end + if worker and worker.getError and worker:getError() then return false end + return true +end + function ChipAudio.stopMusic() if currentMusic and currentMusic.source then pcall(currentMusic.source.stop, currentMusic.source) @@ -240,6 +259,7 @@ function ChipAudio.stopMusic() end pendingBuf = nil currentMusic = nil + forceAwaitingFirstBuffer = nil end -- hot reload: the next play re-reads programs.bin (a mod may have swapped the @@ -302,6 +322,20 @@ end -- test hooks (headless): synchronous synthesis straight through ChipSynth -- --------------------------------------------------------------------------- +-- Force the "threaded, first buffer not yet queued" window so Music's +-- playOnce / pendingRestore race can be asserted without love.thread. +-- Returns a clear() that drops the override (call after the assertion). +function ChipAudio._simulateAwaitingFirstBufferForTest() + local m = currentMusic + if not m or not m.source then return nil end + m.threaded = true + m.started = false + m.finished = false + pcall(function() m.source.playing = false end) + forceAwaitingFirstBuffer = true + return function() forceAwaitingFirstBuffer = nil end +end + function ChipAudio._renderMusicForTest(data, header, seconds) local engine = ChipSynth.newEngine(data, header, { allowLoops = true }) return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2) diff --git a/src/core/Music.lua b/src/core/Music.lua index f394eec2..be88fbd9 100644 --- a/src/core/Music.lua +++ b/src/core/Music.lua @@ -254,6 +254,7 @@ function Music.stop() require("src.core.ChipAudio").stopMusic() state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil state.chip = false + state.pendingRestore = nil if previous and Runtime.wants("music.stopped") then Runtime.emit("music.stopped", { song = previous }) end @@ -347,14 +348,24 @@ end function Music.playOnce(data, song) if not songDef(data, song) then return false end Music.play(data, song, false, { reason = "once" }) + -- play() can no-op (hook silence, failed def); only arm restore when + -- the jingle actually became current + if state.current ~= song then return false end state.pendingRestore = true return true end +local function chipAwaitingFirstBuffer() + return state.chip + and require("src.core.ChipAudio").awaitingFirstBuffer() +end + -- is a playOnce jingle still sounding? (AnimateHealingMachine's -- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends) function Music.oneShotPlaying() if not state.pendingRestore then return false end + -- threaded chip songs start silent for ~1 frame; that gap is not "over" + if chipAwaitingFirstBuffer() then return true end local src = state.source if not src then return false end local ok, playing = pcall(src.isPlaying, src) @@ -441,8 +452,10 @@ function Music.update(data) state.source = loopSrc pcall(loopSrc.play, loopSrc) end + -- do not treat "threaded source still waiting on its first buffer" as + -- ended, or playOnce jingles get restored over before they can sound if state.pendingRestore and sourceStopped(state.source) - and not state.loopSource then + and not state.loopSource and not chipAwaitingFirstBuffer() then Music.restoreMap(data) end end diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index d2b4f070..e525ac68 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -3415,8 +3415,11 @@ function OverworldState:drawWorld() love.graphics.setShader(shader) end end - local ox = ha.px - 64 - cam.x - local oy = ha.py - 64 - cam.y + -- TileRenderer windows with -floor(cam), so the overlay must use the + -- same snap or a fractional camera (odd fill/tilt view sizes) parks + -- the balls a pixel off the machine tiles + local ox = ha.px - 64 - math.floor(cam.x) + local oy = ha.py - 64 - math.floor(cam.y) love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20) for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do @@ -3632,11 +3635,13 @@ function OverworldState:drawWorld() else -- === TILT PATH: ground-hugging FX stay on the projected ground, all -- standing things billboard upright over it in a separate pass. ====== - -- Dust is ground-hugging smoke -> ground canvas (puts it - -- with the flat layer, so it projects with the ground). Flat mode - -- draws it last, over the sprites, in the same canvas; here the two + -- Dust / cut / the Poké Center heal overlay hug the BG (the heal + -- machine is a tileset graphic; its OAM balls must ride that plane or + -- they float off the machine once the ground foreshortens). Flat mode + -- draws them last, over the sprites, in the same canvas; here the two -- layers are separate and composited ground-under-upright, so drawing - -- it now into the still-active ground canvas is order-equivalent. + -- them now into the still-active ground canvas is order-equivalent. + fxHeal() fxDust() fxCutTree() @@ -3692,18 +3697,11 @@ function OverworldState:drawWorld() end end - -- Screen-anchored world FX : each billboards at the - -- ground foot of the character it belongs to, so it stands upright and - -- scales with that character's depth. - -- heal machine -> the healed player's foot (the machine stands on - -- the ground in front of where the player was) + -- Standing world FX: each billboards at the ground foot of the + -- character it belongs to, so it stays upright over the tilted ground. -- emote bubble -> the spotting NPC's foot (rides above its head) -- fly bird, rod -> the player's foot - if self.healAnim then - local fx = self.healAnim.px - cam.x + 8 - local fy = self.healAnim.py - cam.y + 16 - self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal) - end + -- (heal machine is ground-hugging -- drawn above with dust/cut) if self.emote and self.emote.npc then local fx = self.emote.npc.px - cam.x + 8 local fy = self.emote.npc.py - cam.y + 16 diff --git a/tests/mod_audio_tests.lua b/tests/mod_audio_tests.lua index 0c777729..d5f423e9 100644 --- a/tests/mod_audio_tests.lua +++ b/tests/mod_audio_tests.lua @@ -344,6 +344,24 @@ check(lastSource().queueable and lastSource().playing, "a chip song still plays after a file song") check(not body.playing, "the outgoing file song was stopped") +-- playOnce must survive the threaded "empty QueueableSource" window: +-- Source:isPlaying is false until the first worker buffer lands, and that +-- gap must not look like the jingle already ended (Poké Center heal). +data = reset(fixtureData()) +Music.playMap(data, "PALLET_TOWN", false, false) +check(Music.playOnce(data, "Music_Chip"), "playOnce starts a chip jingle") +local jingle = lastSource() +local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest() +check(clearAwait ~= nil, "test can force the awaiting-first-buffer window") +check(Music.oneShotPlaying(), + "oneShotPlaying stays true while the first buffer is still in flight") +Music.update(data) +check(jingle == lastSource() and jingle.queueable, + "pendingRestore does not swap the map theme over a pending chip jingle") +check(ChipAudio.awaitingFirstBuffer(), + "awaitingFirstBuffer reports the forced window") +clearAwait() + -- sfx shape dispatch check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing") check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")