diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index a6bf7d50..a920731c 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -35,6 +35,32 @@ local mapScripts -- registry of hand-ported map scripts local COMPASS = { up = "north", down = "south", left = "west", right = "east" } 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 -- 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 @@ -907,10 +933,20 @@ function OverworldState:update(dt) return end if self.flyAnim then - self.flyAnim.frames = self.flyAnim.frames - 1 - if self.flyAnim.frames <= 0 then + -- DoFlyAnimation runs one coord pair every Delay3 (3 frames); the + -- 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.player.inputLocked = false local d = self.flyDest self.flyDest = nil if d then @@ -918,10 +954,21 @@ function OverworldState:update(dt) -- SFX_FLY (EnterMapAnim .flyAnimation) self.arriveWarp = "fly" self:startWarpTo(d.map, d.x, d.y, "down", nil, { via = "fly" }) + else + self.player.inputLocked = false end return 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 -- spins UP out of the map before the fade (player_animations.asm @@ -1600,14 +1647,15 @@ end function OverworldState:flyTo(mapId) local spot = Game.data.field.flyWarps[mapId] if not spot then return end - require("src.core.Sound").play(Game.data, "Fly") Game.save.onBike = false Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE self.player.surfing = false self:syncSurfingPikachu() - -- the bird carries the player off westward before the warp - -- (engine/overworld/player_animations.asm LoadBirdSpriteGraphics) - self.flyAnim = { frames = 48 } + -- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3), + -- then SFX_FLY and the up-right path, a 40-frame beat off screen, and + -- 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.flyDest = { map = mapId, x = spot.x, y = spot.y } end @@ -3878,6 +3926,10 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts) -- door warps never take this branch. if arriveWarp == "fly" then 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 require("src.core.Sound").play(Game.data, "Teleport_Enter1") -- ENTER_2 caps the spin-down a moment later @@ -4385,20 +4437,40 @@ function OverworldState:drawWorld() -- the FLY bird sweeping off with the player 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") if not self.birdSprite and birdId and Game.data.sprites[birdId] then local SR = require("src.render.SpriteRenderer") self.birdSprite = SR.new(Game.data.sprites[birdId]) end - if self.birdSprite then - local t = 48 - self.flyAnim.frames - local bx = self.player.px - t * 4 - local by = self.player.py - math.floor(t * 1.5) - love.graphics.setColor(1, 1, 1, 1) - self.birdSprite:draw(bx, by, cam.x, cam.y, "left", - math.floor(t / 4) % 2, false) + if not self.birdSprite then return end + -- DoFlyAnimation: the bird flaps its wings every Delay3; each path is + -- anchored on the player's cell (FLY_ANCHOR / FLY_ARRIVE_ANCHOR) so + -- the flight rides any screen position, and it faces its travel + -- direction (rightward travel flips the left-drawn sheet) + local phase = anim.phase or "arrive" + 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 + 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 -- fishing pose: the rod tile over the faced water (gfx/fishing.asm) @@ -4548,7 +4620,7 @@ 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 and e == self.player) then + if not ((self.flyAnim or self.flyArrive) 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 @@ -4599,7 +4671,7 @@ 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 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 } end end @@ -4650,7 +4722,7 @@ function OverworldState:drawWorld() local fy = self.emote.npc.py - cam.y + 16 self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxEmote) end - if self.flyAnim then + if self.flyAnim or self.flyArrive then local fx = self.player.px - cam.x + 8 local fy = self.player.py - cam.y + 16 self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxBird) diff --git a/tests/drivers/fly_anim_bug702_test.lua b/tests/drivers/fly_anim_bug702_test.lua new file mode 100644 index 00000000..20e8a8af --- /dev/null +++ b/tests/drivers/fly_anim_bug702_test.lua @@ -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 diff --git a/tests/parity_fly_anim.lua b/tests/parity_fly_anim.lua new file mode 100644 index 00000000..e3e14ef1 --- /dev/null +++ b/tests/parity_fly_anim.lua @@ -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()