Compare commits

...

1 Commits

Author SHA1 Message Date
bryanthaboi aad86fb351 pokecenter fix 2026-07-23 15:15:35 -04:00
5 changed files with 84 additions and 19 deletions
+4 -2
View File
@@ -42,8 +42,10 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards, things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls, unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the screen-anchored FX attached to them (heal machine glow, emote and the standing FX attached to them (emote bubbles, the fishing rod,
bubbles, the fishing rod, the FLY bird). An earlier revision tried 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 billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building special cases, dense tree canopy, fences fused into grass, building
+34
View File
@@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying()
end end
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() function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source) pcall(currentMusic.source.stop, currentMusic.source)
@@ -240,6 +259,7 @@ function ChipAudio.stopMusic()
end end
pendingBuf = nil pendingBuf = nil
currentMusic = nil currentMusic = nil
forceAwaitingFirstBuffer = nil
end end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the -- 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 -- 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) function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true }) local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2) return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
+14 -1
View File
@@ -254,6 +254,7 @@ function Music.stop()
require("src.core.ChipAudio").stopMusic() require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous }) Runtime.emit("music.stopped", { song = previous })
end end
@@ -347,14 +348,24 @@ end
function Music.playOnce(data, song) function Music.playOnce(data, song)
if not songDef(data, song) then return false end if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" }) 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 state.pendingRestore = true
return true return true
end end
local function chipAwaitingFirstBuffer()
return state.chip
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's -- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends) -- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
function Music.oneShotPlaying() function Music.oneShotPlaying()
if not state.pendingRestore then return false end 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 local src = state.source
if not src then return false end if not src then return false end
local ok, playing = pcall(src.isPlaying, src) local ok, playing = pcall(src.isPlaying, src)
@@ -441,8 +452,10 @@ function Music.update(data)
state.source = loopSrc state.source = loopSrc
pcall(loopSrc.play, loopSrc) pcall(loopSrc.play, loopSrc)
end 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) if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource then and not state.loopSource and not chipAwaitingFirstBuffer() then
Music.restoreMap(data) Music.restoreMap(data)
end end
end end
+14 -16
View File
@@ -3415,8 +3415,11 @@ function OverworldState:drawWorld()
love.graphics.setShader(shader) love.graphics.setShader(shader)
end end
end end
local ox = ha.px - 64 - cam.x -- TileRenderer windows with -floor(cam), so the overlay must use the
local oy = ha.py - 64 - cam.y -- 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.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20) love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
@@ -3632,11 +3635,13 @@ function OverworldState:drawWorld()
else else
-- === TILT PATH: ground-hugging FX stay on the projected ground, all -- === TILT PATH: ground-hugging FX stay on the projected ground, all
-- standing things billboard upright over it in a separate pass. ====== -- standing things billboard upright over it in a separate pass. ======
-- Dust is ground-hugging smoke -> ground canvas (puts it -- Dust / cut / the Poké Center heal overlay hug the BG (the heal
-- with the flat layer, so it projects with the ground). Flat mode -- machine is a tileset graphic; its OAM balls must ride that plane or
-- draws it last, over the sprites, in the same canvas; here the two -- 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 -- 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() fxDust()
fxCutTree() fxCutTree()
@@ -3692,18 +3697,11 @@ function OverworldState:drawWorld()
end end
end end
-- Screen-anchored world FX : each billboards at the -- Standing world FX: each billboards at the ground foot of the
-- ground foot of the character it belongs to, so it stands upright and -- character it belongs to, so it stays upright over the tilted ground.
-- 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)
-- emote bubble -> the spotting NPC's foot (rides above its head) -- emote bubble -> the spotting NPC's foot (rides above its head)
-- fly bird, rod -> the player's foot -- fly bird, rod -> the player's foot
if self.healAnim then -- (heal machine is ground-hugging -- drawn above with dust/cut)
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
if self.emote and self.emote.npc then if self.emote and self.emote.npc then
local fx = self.emote.npc.px - cam.x + 8 local fx = self.emote.npc.px - cam.x + 8
local fy = self.emote.npc.py - cam.y + 16 local fy = self.emote.npc.py - cam.y + 16
+18
View File
@@ -344,6 +344,24 @@ check(lastSource().queueable and lastSource().playing,
"a chip song still plays after a file song") "a chip song still plays after a file song")
check(not body.playing, "the outgoing file song was stopped") 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 -- sfx shape dispatch
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing") check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source") check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")