mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
2b75c07571
The trainer intro was the only enemy send-out path that never set enemySendingOut, so the front sprite drew at full size the moment the trainer pic walked off, held through "X sent out Y!", and the grow-in then played over a mon that had already arrived. Set it with the pic teardown and clear it with startGrowIn, matching the mid-battle replacement and the player's own send-out. Also repairs two parity suites that could not load at all: their game stubs lacked input.isDown, which battle text has read every frame since typing started honouring PrintLetterDelay. intro_chrome additionally pressed A inside PromptText's ProtectedDelay3 hold, which ignores the button for TEXT_PRE_ADVANCE frames.
241 lines
10 KiB
Lua
241 lines
10 KiB
Lua
-- Parity test: the battle intro's pokeball rows, HUD chrome and pic slides
|
|
-- (#317). PrintBeginningBattleText calls DrawAllPokeballs right before
|
|
-- PrintText (engine/battle/common_text.asm:22-27), and a wild battle gets the
|
|
-- player's row only (engine/battle/draw_hud_pokeball_gfx.asm:1-7); the enemy
|
|
-- HUD comes up after, in _InitBattleCommon (engine/battle/core.asm:6755-6764).
|
|
-- Both pics slide off screen before their send-out text (core.asm:236-240, 1308-1310).
|
|
package.path = "./?.lua;./?/init.lua;" .. package.path
|
|
if not _G.love then _G.love = require("tests.love_stub") end
|
|
local S = require("tests.harness").suite("parity battle intro chrome")
|
|
local check, eq = S.check, S.eq
|
|
|
|
local Data = require("src.core.Data")
|
|
if not Data.maps then Data:load() end
|
|
local Font = require("src.render.Font")
|
|
Font.load(Data)
|
|
|
|
local BattleState = require("src.battle.BattleState")
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local SaveData = require("src.core.SaveData")
|
|
local Sound = require("src.core.Sound")
|
|
local Music = require("src.core.Music")
|
|
local Timing = require("src.core.Timing")
|
|
|
|
-- Silence audio: BattleState reaches both modules through require() at the
|
|
-- call site, so patching the fields here is what the battle ends up calling.
|
|
Sound.playCry = function() end
|
|
Sound.play = function() end
|
|
Sound.playMove = function() end
|
|
Sound.playMoveCry = function() end
|
|
Sound.stopLoop = function() end
|
|
Music.playBattle = function() end
|
|
Music.play = function() end
|
|
|
|
-- stub stack + input, like the other headless battle probes. `press` is the
|
|
-- one-frame button state the battle reads through input:wasPressed.
|
|
local press = {}
|
|
local function makeGame(party)
|
|
local save = SaveData.newGame()
|
|
save.party = party
|
|
local stack = { states = {} }
|
|
function stack:push(state) self.states[#self.states + 1] = state end
|
|
function stack:pop() return table.remove(self.states) end
|
|
function stack:top() return self.states[#self.states] end
|
|
-- isDown as well as wasPressed: battle text collapses PrintLetterDelay
|
|
-- while A or B is held, and the typing path reads it every frame
|
|
return { data = Data, save = save, stack = stack,
|
|
input = { wasPressed = function(_, b) return press[b] == true end,
|
|
isDown = function(_, b) return press[b] == true end } }
|
|
end
|
|
|
|
-- One fixed step with A held. updateQueue only reads the button once a page
|
|
-- has typed out, so an early press is ignored and these ordering checks stay
|
|
-- independent of the prompt flag they also assert.
|
|
local function step(battle)
|
|
press.a = true
|
|
battle:update(1 / 60)
|
|
press.a = false
|
|
end
|
|
|
|
-- the text of the message row currently on screen, or nil
|
|
local function currentText(battle)
|
|
local cur = battle.current
|
|
return cur and cur.text or nil
|
|
end
|
|
|
|
-- Record what drawHUDs actually puts on screen this frame. drawBallRow is a
|
|
-- method, so an instance field shadows it; Font.draw is the module upvalue
|
|
-- BattleState draws HUD strings through.
|
|
local function snapshotHUD(battle)
|
|
local rows, strings = {}, {}
|
|
local realRow = battle.drawBallRow
|
|
local realFont = Font.draw
|
|
battle.drawBallRow = function(_, party, x, y, dx)
|
|
rows[#rows + 1] = { count = #party, x = x, y = y, step = dx }
|
|
end
|
|
Font.draw = function(text, x, y) strings[#strings + 1] = tostring(text) end
|
|
local ok, err = pcall(battle.drawHUDs, battle, 0)
|
|
battle.drawBallRow = realRow
|
|
Font.draw = realFont
|
|
return ok, err, rows, strings
|
|
end
|
|
|
|
local function drewString(strings, want)
|
|
for _, s in ipairs(strings) do
|
|
if s:find(want, 1, true) then return true end
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- ------------------------------------------------------------------ wild
|
|
local party = { Pokemon.new(Data, "BULBASAUR", 50), Pokemon.new(Data, "PIDGEY", 5) }
|
|
local game = makeGame(party)
|
|
local wild = BattleState.newWild(game, "RATTATA", 2)
|
|
wild.onFinish = function() end
|
|
wild:enter()
|
|
|
|
eq(wild.introBalls, true, "the wild intro opens the DrawAllPokeballs window")
|
|
-- introBalls is a plain field, not a queue row, so it cannot displace the
|
|
-- wild cry PrintBeginningBattleText plays before PrintText (#303)
|
|
check(wild.queue[1] and wild.queue[1].fn ~= nil,
|
|
"the cry act is still the first queue row (#303)")
|
|
check(wild.queue[2] and wild.queue[2].text == wild.introText,
|
|
"the intro text is still the second queue row")
|
|
|
|
-- let the silhouette slide land so the intro box is genuinely up (the slide
|
|
-- now runs 80 frames at 2px/frame, matching the original ~2px/frame)
|
|
for _ = 1, 85 do wild:update(1 / 60) end
|
|
eq(wild.introSlide or 0, 0, "the silhouette slide has landed")
|
|
eq(wild.introBalls, true, "the window is still open under the intro text")
|
|
eq(currentText(wild), wild.introText, "the intro box is the row on screen")
|
|
|
|
local ok, err, rows, strings = snapshotHUD(wild)
|
|
check(ok, "drawHUDs runs during the wild intro: " .. tostring(err))
|
|
eq(#rows, 1, "a WILD intro draws exactly one ball row (SetupOwnPartyPokeballs)")
|
|
if rows[1] then
|
|
eq(rows[1].count, #party, "the row carries every party slot")
|
|
eq(rows[1].x, 88, "player ball row x = 88 (wBaseCoordX $60)")
|
|
eq(rows[1].y, 80, "player ball row y = 80 (wBaseCoordY $60)")
|
|
eq(rows[1].step, 8, "player ball row steps +8 rightward")
|
|
end
|
|
check(not drewString(strings, wild.enemy.name),
|
|
"the enemy HUD is NOT up during the intro box (DrawEnemyHUDAndHPBar "
|
|
.. "runs after PrintBeginningBattleText returns)")
|
|
|
|
-- the page finishes typing and PromptText's blinking arrow takes over
|
|
local prompted = false
|
|
for _ = 1, 200 do
|
|
if wild.msgPrompt then prompted = true break end
|
|
press.a = false
|
|
wild:update(1 / 60)
|
|
end
|
|
check(prompted, "a typed-out intro page raises the prompt flag (blinking arrow)")
|
|
eq(wild.introBalls, true, "the ball row is still up while the arrow blinks")
|
|
|
|
-- PromptText writes the arrow and then runs ProtectedDelay3 before
|
|
-- ManualTextScroll starts watching the joypad (home/text.asm:213-217), so the
|
|
-- page ignores the button for TEXT_PRE_ADVANCE frames. The loop above breaks
|
|
-- on the frame the arrow goes up, which is inside that hold.
|
|
for _ = 1, Timing.TEXT_PRE_ADVANCE do press.a = false wild:update(1 / 60) end
|
|
|
|
-- press A: ClearSprites + both ClearScreenAreas, then the enemy HUD
|
|
step(wild)
|
|
eq(wild.msgPrompt, nil, "the prompt flag clears on the A press")
|
|
for _ = 1, 2 do press.a = false wild:update(1 / 60) end
|
|
eq(wild.introBalls, nil, "dismissing the intro box closes the window")
|
|
local ok2, err2, rows2, strings2 = snapshotHUD(wild)
|
|
check(ok2, "drawHUDs runs after the intro box: " .. tostring(err2))
|
|
eq(#rows2, 0, "no ball row survives the dismissal (ClearSprites)")
|
|
check(drewString(strings2, wild.enemy.name),
|
|
"the enemy HUD appears once the intro box is gone")
|
|
|
|
-- the back pic walks off the LEFT edge before "Go! X!"
|
|
local slideLow, slideDone, goFrame, shownDuringSlide = 0, nil, nil, true
|
|
for f = 1, 240 do
|
|
step(wild)
|
|
local off = wild:picOffset("back")
|
|
if off < slideLow then slideLow = off end
|
|
if off < 0 and not wild.showPlayerBack then shownDuringSlide = false end
|
|
if not slideDone and off <= -72 then slideDone = f end
|
|
if not goFrame and currentText(wild) and currentText(wild):find("Go!", 1, true) then
|
|
goFrame = f
|
|
end
|
|
if goFrame then break end
|
|
end
|
|
eq(slideLow, -72, "the back pic walks a full 9 tiles off the left edge")
|
|
check(shownDuringSlide, "the back pic stays drawn for the whole slide")
|
|
check(slideDone ~= nil and goFrame ~= nil and slideDone < goFrame,
|
|
"the slide finishes BEFORE the send-out text (core.asm:236-240)")
|
|
eq(wild.showPlayerBack, false, "only then is the back pic taken down")
|
|
eq(wild:picOffset("back"), 0, "the slide program is cleared with the pic")
|
|
|
|
-- --------------------------------------------------------------- trainer
|
|
local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) })
|
|
local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1)
|
|
tr.onFinish = function() end
|
|
tr:enter()
|
|
eq(tr.introBalls, true, "the trainer intro opens the same window")
|
|
for _ = 1, 85 do tr:update(1 / 60) end
|
|
|
|
local ok3, err3, rows3 = snapshotHUD(tr)
|
|
check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3))
|
|
eq(#rows3, 2, "a TRAINER intro draws both ball rows")
|
|
if rows3[1] and rows3[2] then
|
|
eq(rows3[1].x, 64, "enemy ball row x = 64 (wBaseCoordX $48)")
|
|
eq(rows3[1].y, 16, "enemy ball row y = 16 (wBaseCoordY $20)")
|
|
eq(rows3[1].step, -8, "enemy ball row steps -8 leftward")
|
|
eq(rows3[2].x, 88, "player ball row x = 88")
|
|
eq(rows3[2].count, 1, "player row carries this save's one party slot")
|
|
end
|
|
|
|
-- the foe's pic walks off the RIGHT edge before "X sent out Y!"
|
|
local foeHigh, foeDone, sentFrame, foeShown = 0, nil, nil, true
|
|
for f = 1, 300 do
|
|
step(tr)
|
|
local off = tr:picOffset("foe")
|
|
if off > foeHigh then foeHigh = off end
|
|
if off > 0 and not tr.showEnemyTrainer then foeShown = false end
|
|
if not foeDone and off >= 64 then foeDone = f end
|
|
local t = currentText(tr)
|
|
if not sentFrame and t and t:find("sent", 1, true) then sentFrame = f end
|
|
if sentFrame then break end
|
|
end
|
|
eq(foeHigh, 64, "the foe's pic walks a full 8 tiles off the right edge")
|
|
check(foeShown, "the foe's pic stays drawn for the whole slide")
|
|
check(foeDone ~= nil and sentFrame ~= nil and foeDone < sentFrame,
|
|
"the slide finishes BEFORE TrainerSentOutText (core.asm:1308-1310)")
|
|
eq(tr.showEnemyTrainer, false, "only then is the trainer pic taken down")
|
|
|
|
-- ...and the mon is not standing in that slot yet. The pic walks off at
|
|
-- core.asm:1308-1310 but AnimateSendingOutMon does not run until :1421-1434,
|
|
-- so the slot is empty for the whole of TrainerSentOutText -- the mon is
|
|
-- still in its ball. It used to pop in full-size the instant the trainer
|
|
-- left and sit there through the text, so the grow-in played over a mon that
|
|
-- had already arrived; the mid-battle replacement always held it back.
|
|
check(tr.enemySendingOut, "the foe's mon stays in its ball while announced")
|
|
eq(tr:growInScale(tr.enemy), nil, "and nothing is growing into the slot yet")
|
|
|
|
local firstGrowScale
|
|
for _ = 1, 400 do
|
|
step(tr)
|
|
if tr.growIn and tr.growIn.battler == tr.enemy then
|
|
firstGrowScale = tr:growInScale(tr.enemy)
|
|
break
|
|
end
|
|
end
|
|
eq(firstGrowScale, 0, "the send-out opens on the ball beat, not a finished pic")
|
|
eq(tr.enemySendingOut, false, "which is when the slot is handed over")
|
|
|
|
-- and the window never reopens: drive the rest of the intro out
|
|
for _ = 1, 400 do
|
|
step(tr)
|
|
if tr.phase == "menu" then break end
|
|
end
|
|
eq(tr.phase, "menu", "the trainer intro reaches the battle menu")
|
|
eq(tr.introBalls, nil, "the DrawAllPokeballs window stays closed")
|
|
local ok4, err4, rows4 = snapshotHUD(tr)
|
|
check(ok4, "drawHUDs runs at the battle menu: " .. tostring(err4))
|
|
eq(#rows4, 0, "no ball row is drawn once the battle proper starts")
|
|
|
|
S.finish()
|