Merge pull request #747 from johnjohto/fix-fly-animation-702

Play the full Fly departure and landing animation (#702)
This commit is contained in:
bryanthaboi
2026-08-03 16:11:40 -04:00
committed by GitHub
3 changed files with 231 additions and 18 deletions
+90 -18
View File
@@ -37,6 +37,32 @@ local mapScripts -- registry of hand-ported map scripts
local COMPASS = { up = "north", down = "south", left = "west", right = "east" } local COMPASS = { up = "north", down = "south", left = "west", right = "east" }
local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } } local DIRVEC = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
-- Fly animation coord paths (engine/overworld/player_animations.asm):
-- y/x pairs in GB screen pixels, one pair every 3 frames (DoFlyAnimation's
-- Delay3). The port anchors a path on the player's own position instead
-- of the GB screen center: FLY_ANCHOR is the pair where the original has
-- the player's sprite, so path1 starts exactly on the player.
local FLY_ANCHOR = { 0x3C, 0x48 }
local FLY_PATH1 = { -- FlyAnimationScreenCoords1: up and off to the right
{ 0x3C, 0x48 }, { 0x3C, 0x50 }, { 0x3B, 0x58 }, { 0x3A, 0x60 },
{ 0x39, 0x68 }, { 0x37, 0x70 }, { 0x37, 0x78 }, { 0x33, 0x80 },
{ 0x30, 0x88 }, { 0x2D, 0x90 }, { 0x2A, 0x98 }, { 0x27, 0xA0 },
}
local FLY_PATH2 = { -- FlyAnimationScreenCoords2: out over the top-left;
-- the 11th step reads the ($F0,$00) terminator, fully off screen
{ 0x1A, 0x90 }, { 0x19, 0x80 }, { 0x17, 0x70 }, { 0x15, 0x60 },
{ 0x12, 0x50 }, { 0x0F, 0x40 }, { 0x0C, 0x30 }, { 0x09, 0x20 },
{ 0x05, 0x10 }, { 0x00, 0x00 }, { -16, 0x00 },
}
-- FlyAnimationEnterScreenCoords: in from off the top-right. Its own last
-- pair is ($3C,$40), so the arrival anchors there and lands on the player.
local FLY_ARRIVE_ANCHOR = { 0x3C, 0x40 }
local FLY_PATH_IN = {
{ 0x05, 0x98 }, { 0x0F, 0x90 }, { 0x18, 0x88 }, { 0x20, 0x80 },
{ 0x27, 0x78 }, { 0x2D, 0x70 }, { 0x32, 0x68 }, { 0x36, 0x60 },
{ 0x39, 0x58 }, { 0x3B, 0x50 }, { 0x3C, 0x48 }, { 0x3C, 0x40 },
}
-- healing machine ball screen positions (PokeCenterOAMData dbsprite -- healing machine ball screen positions (PokeCenterOAMData dbsprite
-- rows are raw shadow-OAM bytes, so the hardware's -8/-16 OAM origin -- rows are raw shadow-OAM bytes, so the hardware's -8/-16 OAM origin
-- applies: screen = tile*8 + pixel offset - 8/16); [3] = OAM_XFLIP -- applies: screen = tile*8 + pixel offset - 8/16); [3] = OAM_XFLIP
@@ -916,10 +942,20 @@ function OverworldState:update(dt)
return return
end end
if self.flyAnim then if self.flyAnim then
self.flyAnim.frames = self.flyAnim.frames - 1 -- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the
if self.flyAnim.frames <= 0 then -- in-place flap is 8 pairs, then the two paths with a 40-frame beat
-- while the bird is parked off screen between them
local anim = self.flyAnim
anim.t = anim.t + 1
if anim.phase == "flap" and anim.t >= 8 * 3 then
anim.phase, anim.t = "path1", 0
require("src.core.Sound").play(Game.data, "Fly")
elseif anim.phase == "path1" and anim.t >= #FLY_PATH1 * 3 then
anim.phase, anim.t = "hold", 0
elseif anim.phase == "hold" and anim.t >= 40 then
anim.phase, anim.t = "path2", 0
elseif anim.phase == "path2" and anim.t >= #FLY_PATH2 * 3 then
self.flyAnim = nil self.flyAnim = nil
self.player.inputLocked = false
local d = self.flyDest local d = self.flyDest
self.flyDest = nil self.flyDest = nil
if d then if d then
@@ -927,10 +963,21 @@ function OverworldState:update(dt)
-- SFX_FLY (EnterMapAnim .flyAnimation) -- SFX_FLY (EnterMapAnim .flyAnimation)
self.arriveWarp = "fly" self.arriveWarp = "fly"
self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" }) self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" })
else
self.player.inputLocked = false
end end
return return
end end
end end
if self.flyArrive then
-- EnterMapAnim .flyAnimation: one swoop in from the top-right, then
-- LoadPlayerSpriteGraphics -- the player reappears where it lands
self.flyArrive.t = self.flyArrive.t + 1
if self.flyArrive.t >= #FLY_PATH_IN * 3 then
self.flyArrive = nil
self.player.inputLocked = false
end
end
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite -- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
-- spins UP out of the map before the fade (player_animations.asm -- spins UP out of the map before the fade (player_animations.asm
@@ -1609,14 +1656,15 @@ end
function OverworldState:flyTo(mapId) function OverworldState:flyTo(mapId)
local spot = Game.data.field.flyWarps[mapId] local spot = Game.data.field.flyWarps[mapId]
if not spot then return end if not spot then return end
require("src.core.Sound").play(Game.data, "Fly")
Game.save.onBike = false Game.save.onBike = false
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
self.player.surfing = false self.player.surfing = false
self:syncSurfingPikachu() self:syncSurfingPikachu()
-- the bird carries the player off westward before the warp -- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3),
-- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics) -- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
self.flyAnim = { frames = 48 } -- the exit over the top-left -- the warp fades only once the bird is
-- gone (#702). fxBird draws it; the player hides for the whole flight.
self.flyAnim = { phase = "flap", t = 0 }
self.player.inputLocked = true self.player.inputLocked = true
self.flyDest = { map = mapId, x = spot.x, y = spot.y } self.flyDest = { map = mapId, x = spot.x, y = spot.y }
end end
@@ -3887,6 +3935,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
-- door warps never take this branch. -- door warps never take this branch.
if arriveWarp == "fly" then if arriveWarp == "fly" then
require("src.core.Sound").play(Game.data, "Fly") require("src.core.Sound").play(Game.data, "Fly")
-- EnterMapAnim .flyAnimation: the bird swoops in off the top-right
-- edge and the player reappears where it lands (#702); the input
-- lock from flyTo releases when the swoop finishes
self.flyArrive = { t = 0 }
elseif arriveWarp == "teleport" then elseif arriveWarp == "teleport" then
require("src.core.Sound").play(Game.data, "Teleport_Enter1") require("src.core.Sound").play(Game.data, "Teleport_Enter1")
-- ENTER_2 caps the spin-down a moment later -- ENTER_2 caps the spin-down a moment later
@@ -4394,20 +4446,40 @@ function OverworldState:drawWorld()
-- the FLY bird sweeping off with the player -- the FLY bird sweeping off with the player
local function fxBird() local function fxBird()
if not self.flyAnim then return end local anim = self.flyAnim or self.flyArrive
if not anim then return end
local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly") local birdId = FieldDefaults.fieldValue(Game.data, "playerSprites", "fly")
if not self.birdSprite and birdId and Game.data.sprites[birdId] then if not self.birdSprite and birdId and Game.data.sprites[birdId] then
local SR = require("src.render.SpriteRenderer") local SR = require("src.render.SpriteRenderer")
self.birdSprite = SR.new(Game.data.sprites[birdId]) self.birdSprite = SR.new(Game.data.sprites[birdId])
end end
if self.birdSprite then if not self.birdSprite then return end
local t = 48 - self.flyAnim.frames -- DoFlyAnimation: the bird flaps its wings every Delay3; each path is
local bx = self.player.px - t * 4 -- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so
local by = self.player.py - math.floor(t * 1.5) -- the flight rides any screen position, and it faces its travel
love.graphics.setColor(1, 1, 1, 1) -- direction (rightward travel flips the left-drawn sheet)
self.birdSprite:draw(bx, by, cam.x, cam.y, "left", local phase = anim.phase or "arrive"
math.floor(t / 4) % 2, false) if phase == "hold" then return end -- parked off screen between paths
local path, anchor, facing
if phase == "path1" then
path, anchor, facing = FLY_PATH1, FLY_ANCHOR, "right"
elseif phase == "path2" then
path, anchor, facing = FLY_PATH2, FLY_ANCHOR, "left"
elseif phase == "arrive" then
path, anchor, facing = FLY_PATH_IN, FLY_ARRIVE_ANCHOR, "left"
end end
local step = math.floor(anim.t / 3)
local sx, sy
if path then
local pair = path[math.min(#path, step + 1)]
sx, sy = pair[2] - anchor[2], pair[1] - anchor[1]
else
sx, sy = 0, 0 -- the in-place flap sits on the player
facing = "right"
end
love.graphics.setColor(1, 1, 1, 1)
self.birdSprite:draw(self.player.px + sx, self.player.py + sy,
cam.x, cam.y, facing, step % 2, false)
end end
-- fishing pose: the rod tile over the faced water (gfx/fishing.asm) -- fishing pose: the rod tile over the faced water (gfx/fishing.asm)
@@ -4557,7 +4629,7 @@ function OverworldState:drawWorld()
g.npc:draw(cam.x - g.ox, cam.y - g.oy) g.npc:draw(cam.x - g.ox, cam.y - g.oy)
end end
for _, e in ipairs(self.entities) do for _, e in ipairs(self.entities) do
if not (self.flyAnim and e == self.player) then if not ((self.flyAnim or self.flyArrive) and e == self.player) then
e:draw(cam.x, cam.y) e:draw(cam.x, cam.y)
-- tall grass overdraws the sprite's feet (GB sprite priority); -- tall grass overdraws the sprite's feet (GB sprite priority);
-- the overdraw is BG tiles, so it rides the shake offset too -- the overdraw is BG tiles, so it rides the shake offset too
@@ -4608,7 +4680,7 @@ function OverworldState:drawWorld()
items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g } items[#items + 1] = { y = g.npc.py + g.oy + 16, kind = "ghost", g = g }
end end
for _, e in ipairs(self.entities) do for _, e in ipairs(self.entities) do
if not (self.flyAnim and e == self.player) then if not ((self.flyAnim or self.flyArrive) and e == self.player) then
items[#items + 1] = { y = e.py + 16, kind = "entity", e = e } items[#items + 1] = { y = e.py + 16, kind = "entity", e = e }
end end
end end
@@ -4659,7 +4731,7 @@ function OverworldState:drawWorld()
local fy = self.emote.npc.py - cam.y + 16 local fy = self.emote.npc.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote) self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote)
end end
if self.flyAnim then if self.flyAnim or self.flyArrive then
local fx = self.player.px - cam.x + 8 local fx = self.player.px - cam.x + 8
local fy = self.player.py - cam.y + 16 local fy = self.player.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird) self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird)
+50
View File
@@ -0,0 +1,50 @@
-- Driver: Fly overworld animation (#702).
--
-- POKEPORT_DRIVER=tests/drivers/fly_anim_bug702_test.lua \
-- POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
--
-- Teleports to Route 17, starts Fly to Pallet Town and captures the
-- departure (in-place flap, up-right path, top-left exit) and the
-- landing swoop.
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
U.teleport(game, "ROUTE_17", 4, 10, "down")
local ow = game.stack:top()
ow:flyTo("PALLET_TOWN")
local function waitFrames(n)
for _ = 1, n do coroutine.yield() end
end
waitFrames(12) -- mid in-place flap
U.shot(game, DIR .. "/fly_1_flap.png")
waitFrames(24) -- path1 ~half-way (24 + 12 = 36 into the anim)
U.shot(game, DIR .. "/fly_2_path1.png")
waitFrames(50) -- hold + start of path2
U.shot(game, DIR .. "/fly_3_path2.png")
-- wait out the warp transition, then catch the swoop mid-flight
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 600 do
guard = guard + 1
coroutine.yield()
end
guard = 0
while not ow.flyArrive and guard < 600 do
guard = guard + 1
coroutine.yield()
end
waitFrames(12) -- mid swoop
U.shot(game, DIR .. "/fly_4_arrive.png")
guard = 0
while ow.flyArrive and guard < 600 do
guard = guard + 1
coroutine.yield()
end
U.shot(game, DIR .. "/fly_5_landed.png")
U.log("Screenshots are under " .. DIR)
while true do coroutine.yield() end
end
+91
View File
@@ -0,0 +1,91 @@
-- Parity: the Fly overworld animation (#702).
--
-- Oracle: engine/overworld/player_animations.asm. Departure
-- (_LeaveMapAnim .flyAnimation) flaps the bird in place for 8 x Delay3,
-- plays SFX_FLY, flies FlyAnimationScreenCoords1 up and off to the right
-- (12 pairs, 3 frames each), waits 40 frames, then exits over the
-- top-left along FlyAnimationScreenCoords2 (11 pairs). Arrival
-- (EnterMapAnim .flyAnimation) plays SFX_FLY again and swoops in along
-- FlyAnimationEnterScreenCoords (12 pairs), and only then does
-- LoadPlayerSpriteGraphics bring the player back.
--
-- Self-contained: `luajit tests/parity_fly_anim.lua`; also globbed by
-- tests/run_tests.lua.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local Data = require("src.core.Data")
if not (Data.maps and Data.maps.PALLET_TOWN) then Data:load() end
local S = require("tests.harness").suite("parity fly anim (#702)")
local check, eq = S.check, S.eq
require("src.render.Font").load(Data)
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 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()
-- record SFX without touching the audio backend
local plays = {}
local Sound = require("src.core.Sound")
local realPlay = Sound.play
Sound.play = function(_, key) plays[#plays + 1] = key end
local function popAll() while Game.stack:top() do Game.stack:pop() end end
local function frame()
Input.pressed = {}
StateStack:update(1 / 60)
end
local function frames(n) for _ = 1, n do frame() end end
Game.stack:push(OW, "ROUTE_17", 4, 10, "down")
local ow = Game.stack:top()
ow:flyTo("PALLET_TOWN")
check(ow.flyAnim ~= nil, "the bird lead-in starts on FLY")
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "the bird flaps in place first")
eq(ow.player.inputLocked, true, "input is locked for the flight")
eq(#plays, 0, "no SFX during the in-place flap")
frames(23)
eq(ow.flyAnim and ow.flyAnim.phase, "flap", "still flapping 23 frames in")
frame()
eq(ow.flyAnim and ow.flyAnim.phase, "path1",
"the up-right path starts after 8 x Delay3")
eq(plays[#plays], "Fly", "SFX_FLY plays as the bird takes off")
frames(36)
eq(ow.flyAnim and ow.flyAnim.phase, "hold",
"the bird parks off screen after the 12-pair path")
frames(40)
eq(ow.flyAnim and ow.flyAnim.phase, "path2",
"the top-left exit follows the 40-frame beat")
frames(33)
check(ow.flyAnim == nil, "the departure ends after the 11-pair exit")
-- the warp transition runs its fade out/in; the map switches inside it
local guard = 0
while ow.map.id == "ROUTE_17" and guard < 400 do
guard = guard + 1
frame()
end
eq(ow.map.id, "PALLET_TOWN", "the warp lands in Pallet Town")
check(ow.flyArrive ~= nil, "the landing swoop starts on arrival")
eq(plays[#plays], "Fly", "SFX_FLY plays again for the landing")
eq(ow.player.inputLocked, true, "input stays locked for the swoop")
frames(35)
check(ow.flyArrive ~= nil, "the swoop is still flying 35 frames in")
frame()
check(ow.flyArrive == nil, "the swoop ends after the 12-pair path")
eq(ow.player.inputLocked, false, "and hands input back")
Sound.play = realPlay
S.finish()