CLOSES #916: hide trainer sprite through the Fly/Dig warp fade

After the Fly departure animation finished (bird off-screen) and during
Dig/teleport, the trainer sprite popped back in standing at the old cell
for the whole 32-frame black fade-out before the transition.  The
player-hide guard only held while a departure animation was live:
flyAnim went nil the instant path2 completed and the teleportOut
countdown cleared the spin fields at 0, but startWarpTo's Transition
(not isOpaque) keeps the overworld drawing beneath the veil, and the
arrival animation is not armed until setMap's midpoint.

Add a playerHidden flag on OverworldState that bridges the gap:
- set when each departure completes (flyAnim path2 / teleportOut hit 0),
  immediately before the warp starts;
- cleared in startWarpTo's Transition enter callback, synchronously
  after setMap and before the arrival arms flyArrive / spinDrop, so the
  player is never drawable mid-fade and never bare on the landing frame;
- folded into both player-draw guards.

ROM-free regression test (tests/engine/warp_sprite_hidden_bug916.lua)
drives the REAL Transition + setMap headlessly for Dig and Fly and
asserts zero fade frames leave the player drawable bare (would have
observed 31/32 gap frames before the fix).  Runs in the CI headless T2
tier.

Dig spin timing/lift and the black fade color are left as-is (fade is
intentional per #607).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Shane McGovern
2026-08-07 11:14:20 +01:00
parent 112120e8fe
commit ce204aaf16
2 changed files with 170 additions and 2 deletions
+19 -2
View File
@@ -969,8 +969,14 @@ function OverworldState:update(dt)
-- the bird carries the player in on landing, with its own
-- SFX_FLY (EnterMapAnim .flyAnimation)
self.arriveWarp = "fly"
-- keep the sprite hidden through the warp fade-out (#916): flyAnim
-- just went nil but flyArrive is not armed until startWarpTo's
-- midpoint, and the overworld keeps drawing beneath the veil, so
-- without this the trainer pops back in at the old cell for 32 frames
self.playerHidden = true
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
else
self.playerHidden = false
self.player.inputLocked = false
end
return
@@ -1002,6 +1008,10 @@ function OverworldState:update(dt)
self.player.spinFrames = nil
self.player.spinRise = nil
self.player.inputLocked = false
-- keep the sprite hidden through the warp fade-out (#916): the spin is
-- over but the arrival spin-drop is not armed until startWarpTo's
-- midpoint, so without this the standing trainer shows under the veil
self.playerHidden = true
self:warpToHealPoint(onDone, { arrive = "teleport" })
return
end
@@ -4136,6 +4146,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
self.arriveWarp = nil
Game.stack:push(Transition.new(Game, function()
self:setMap(mapId, x, y, facing or "down", opts)
-- the departure-side hide from flyAnim/teleportOut ends here, on the new
-- map; the arrival arms its own cover (flyArrive / spinDrop) a few lines
-- down, so the player is never drawable mid-fade nor standing bare on the
-- landing frame (#916)
self.playerHidden = false
-- The warp we land ON stays inert for the completed-step check until we
-- physically step off it, so a warp whose destination cell is itself a
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
@@ -4860,7 +4875,8 @@ function OverworldState:drawWorld()
g.npc:draw(cam.x - g.ox, cam.y - g.oy)
end
for _, e in ipairs(self.entities) do
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
and e == self.player) then
e:draw(cam.x, cam.y)
-- tall grass overdraws the sprite's feet (GB sprite priority);
-- the overdraw is BG tiles, so it rides the shake offset too
@@ -4911,7 +4927,8 @@ function OverworldState:drawWorld()
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
end
for _, e in ipairs(self.entities) do
if not ((self.flyAnim or self.flyArrive) and e == self.player) then
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
and e == self.player) then
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
end
end
+151
View File
@@ -0,0 +1,151 @@
-- Engine invariant (#916): after the Fly / Dig departure animation ends, the
-- trainer sprite must stay hidden through the warp fade-out and only become
-- visible again when the arrival animation (flyArrive / teleport spin-down)
-- plays on the new map.
--
-- Root cause: the player-hide guard only held while a departure animation
-- was live. flyAnim was nil'd the instant the bird finished path2, and the
-- teleportOut countdown cleared the spin fields at 0, but startWarpTo's
-- 32-frame fade keeps the overworld drawing beneath the veil (the Transition
-- is not isOpaque), so with the departure guard gone and the arrival not yet
-- armed, the standing sprite popped back in at the old cell for the whole
-- fade.
--
-- The fix is a playerHidden flag on OverworldState: set when the departure
-- completes (flyAnim path2 / teleportOut countdown), cleared in startWarpTo's
-- midpoint the same tick the arrival arms, and folded into both player-draw
-- guards. This suite runs the REAL Transition + setMap headlessly and
-- asserts there is no fade frame where the player would draw bare.
--
-- ROM-free (fixture dataset, no ROM boot): lives in tests/engine so the CI
-- headless tier runs it; also runnable standalone via
-- `luajit tests/engine/warp_sprite_hidden_bug916.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local T = require("tests.modkit")
local check, eq = T.check, T.eq
local Data = T.fixtures.fresh()
-- fixture patches that let the overworld boot and run headlessly
Data.tilesets.FIX_OUT.tilesPerRow = 16
Data.field.flyWarps = Data.field.flyWarps or {}
Data.field.playerSprites = { walk = "SPRITE_FIX_PLAYER" }
Data.field.waterTilesets = {}
Data.field.forcedMovement = { tiles = {} }
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local Pokemon = require("src.pokemon.Pokemon")
local OW = require("src.world.OverworldController")
Game.data = Data
Game.input = Input; Input:init()
Game.renderer = Renderer; Renderer:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
local stack = Game.stack
-- The draw guard both entity passes use: the player sprite is skipped while
-- any of flyAnim / flyArrive / playerHidden is set.
local function playerHidden(ow)
return ow.flyAnim ~= nil or ow.flyArrive ~= nil or ow.playerHidden == true
end
local function newOW()
stack:push(OW, "FIX_TOWN", 5, 6, "down")
local ow = stack:top()
Game.overworld = ow
return ow
end
-- Drive `ow` until its departure + warp + arrival all complete, tracking the
-- fade window. Returns counters: fadeFrames / fadeFramesHidden (frames the
-- Transition was on top; of those, frames the player was hidden), gapFrames
-- (fade frames where NO arrival was active AND the player was NOT hidden --
-- the regression this suite guards), arrivalFrame (first frame an arrival
-- animation armed), warpFrame (first frame a fade is up).
--
-- Breaks once an arrival armed and then fully finished (no stale departure
-- or arrival animation, OW back on top); `maxFrames` is the safety net.
local function drive(ow, maxFrames)
local st = { fadeFrames = 0, fadeFramesHidden = 0, gapFrames = 0,
arrivalFrame = nil, warpFrame = nil }
for i = 1, maxFrames or 260 do
local fading = stack:top() ~= ow
stack:update()
if fading then
st.fadeFrames = st.fadeFrames + 1
if playerHidden(ow) then st.fadeFramesHidden = st.fadeFramesHidden + 1 end
local arrivalActive = ow.flyArrive ~= nil or ow.player.spinDrop == true
if not arrivalActive and not playerHidden(ow) then
st.gapFrames = st.gapFrames + 1
end
if st.warpFrame == nil then st.warpFrame = i end
end
if st.arrivalFrame == nil
and (ow.flyArrive ~= nil or ow.player.spinDrop == true) then
st.arrivalFrame = i
end
if st.arrivalFrame and stack:top() == ow
and ow.flyArrive == nil and ow.player.spinDrop ~= true
and not ow.player.inputLocked then
break -- departure + fade + arrival all finished
end
end
return st
end
-- ------------------------------------------------------------------ dig/teleport
-- Departure spin (48) -> warp fade -> arrival spin-down. From the moment
-- the spin ends until the arrival arms, the sprite must never draw bare.
local ow = newOW()
local doneFired = false
ow:beginTeleportOut(function()
doneFired = true
ow.player.inputLocked = false -- the party-menu caller unlocks after the warp
end)
local st = drive(ow, 260)
check(st.warpFrame ~= nil, "dig departure ends and the warp fade begins")
check(st.fadeFrames > 0, "dig warp fade ran (" .. st.fadeFrames .. " frames)")
eq(st.gapFrames, 0,
"no dig fade frame leaves the player standing bare (#916)")
check(st.fadeFramesHidden >= st.fadeFrames - 1,
"dig fade hidden on every frame but the arrival-arming midpoint ("
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
check(st.arrivalFrame ~= nil, "dig arrival spin-down arms")
check(ow.playerHidden == false, "dig hide cleared on the new map")
check(doneFired, "dig onDone fires after the warp")
check(ow.player.spinDrop ~= true and ow.player.spinning == false,
"dig arrival spin-down completes")
check(not playerHidden(ow), "player drawable again after the dig landing")
-- ------------------------------------------------------------------ fly
-- flap (24) + path1 (36) + hold (40) + path2 (33) = 133 frames of flyAnim,
-- then the fade, then the bird swoops in (flyArrive). Same invariant.
Data.field.flyWarps.FIX_ROUTE = { x = 4, y = 6 }
ow = newOW()
ow:flyTo("FIX_ROUTE")
st = drive(ow, 260)
check(st.warpFrame ~= nil, "fly departure ends and the warp fade begins")
-- flap (8*3) + path1 (12*3) + hold (40) + path2 (11*3) = 133 frames; the
-- warp fires on frame 133's update, so the fade is on top from loop frame 134
eq(st.warpFrame, 134, "fly fade begins right after the bird''s exit path")
check(st.fadeFrames > 0, "fly warp fade ran (" .. st.fadeFrames .. " frames)")
eq(st.gapFrames, 0,
"no fly fade frame leaves the player standing bare (#916)")
check(st.fadeFramesHidden >= st.fadeFrames - 1,
"fly fade hidden on every frame but the arrival-arming midpoint ("
.. st.fadeFramesHidden .. "/" .. st.fadeFrames .. ")")
check(st.arrivalFrame ~= nil, "fly arrival swoop arms")
check(ow.playerHidden == false, "fly hide cleared on the new map")
check(ow.flyArrive == nil, "fly arrival swoop completes")
check(not ow.player.inputLocked, "fly landing releases player input")
check(not playerHidden(ow), "player drawable again after the fly landing")
T.finish("warp_sprite_hidden_bug916")