mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-27 22:45:34 -04:00
Merge pull request #2421 from 1Jamie/grandpas-garage
fix(game3): S.S. Anne cutscene, Old Man tutorial, battle EXP participants, trainer sight, party UI & LeafGreen palette
This commit is contained in:
@@ -141,7 +141,7 @@ GameVersion.VERSIONS = {
|
||||
},
|
||||
manifest = "tools/rom_manifest_leafgreen.json",
|
||||
cachePrefix = "leafgreen/", saveSuffix = "_leafgreen",
|
||||
generation = 3, engine = "game3", cartShape = "gba", cartShell = "#80bc28",
|
||||
generation = 3, engine = "game3", cartShape = "gba", cartShell = "#26a24e",
|
||||
cartLabel = "assets/labels/leafgreen.png",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -518,7 +518,12 @@ function Audio.resumeBgm()
|
||||
Audio._bgmPaused = false
|
||||
if Audio._cmdCh then Audio._cmdCh:push({ cmd = "resume" }) end
|
||||
if Audio._bgmSource then
|
||||
pcall(function() Audio._bgmSource:setVolume(bgm_gain()) end)
|
||||
pcall(function()
|
||||
Audio._bgmSource:setVolume(bgm_gain())
|
||||
if not Audio._bgmSource:isPlaying() then
|
||||
Audio._bgmSource:play()
|
||||
end
|
||||
end)
|
||||
end
|
||||
Audio.pumpBgm()
|
||||
end
|
||||
|
||||
@@ -105,17 +105,28 @@ function CatchSeq.begin(st, itemId, caught, shakes, opts)
|
||||
local ename = (emon and ((emon.nickname ~= "" and emon.nickname) or emon.name))
|
||||
or Pokemon.name(st and st.enemy and st.enemy.species) or "POKéMON"
|
||||
|
||||
-- pokefirered/src/battle_message.c: STRINGID_OLDMANUSEDITEM vs STRINGID_PLAYERUSEDITEM
|
||||
local throwMsg
|
||||
if st and st.oldManTutorial then
|
||||
throwMsg = Strings("OLD MAN used\n%s!", ballName)
|
||||
else
|
||||
throwMsg = Strings("%s used\nthe %s!", playerName, ballName)
|
||||
end
|
||||
|
||||
-- pokefirered/data/battle_scripts_2.s:124
|
||||
local DODGE = Strings("It dodged the thrown BALL!\nThis POKéMON can't be caught!")
|
||||
if opts.ghostDodge then CatchSeq._result = "fail_catch" end
|
||||
if CatchSeq._headless then
|
||||
if CatchSeq._pushMsg then
|
||||
CatchSeq._pushMsg(Strings("%s used\nthe %s!", playerName, ballName))
|
||||
CatchSeq._pushMsg(throwMsg)
|
||||
end
|
||||
if opts.ghostDodge then
|
||||
if CatchSeq._pushMsg then CatchSeq._pushMsg(DODGE) end
|
||||
elseif caught then
|
||||
local res = Catching.storeCaught(session, st and st.enemy, itemId)
|
||||
local res = nil
|
||||
if not (st and st.oldManTutorial) then
|
||||
res = Catching.storeCaught(session, st and st.enemy, itemId)
|
||||
end
|
||||
CatchSeq._catchResult = res
|
||||
if CatchSeq._pushMsg then
|
||||
CatchSeq._pushMsg(Strings("Gotcha!\n%s was caught!", ename))
|
||||
@@ -150,7 +161,7 @@ function CatchSeq.begin(st, itemId, caught, shakes, opts)
|
||||
steps[#steps + 1] = { kind = kind, data = data or {} }
|
||||
end
|
||||
|
||||
add("msg", { text = Strings("%s used\nthe %s!", playerName, ballName), wait = opts.ghostDodge and 0 or nil })
|
||||
add("msg", { text = throwMsg, wait = opts.ghostDodge and 0 or nil })
|
||||
|
||||
-- pokefirered/src/battle_script_commands.c:9590
|
||||
add("throw", {
|
||||
@@ -761,7 +772,10 @@ local function run_step(step)
|
||||
end
|
||||
|
||||
if kind == "capture_success" then
|
||||
local res = Catching.storeCaught(CatchSeq._session, CatchSeq._st and CatchSeq._st.enemy, d.ballId)
|
||||
local res = nil
|
||||
if not (CatchSeq._st and CatchSeq._st.oldManTutorial) then
|
||||
res = Catching.storeCaught(CatchSeq._session, CatchSeq._st and CatchSeq._st.enemy, d.ballId)
|
||||
end
|
||||
CatchSeq._catchResult = res
|
||||
local ename = d.ename or "POKéMON"
|
||||
-- pokefirered/src/battle_message.c:475
|
||||
|
||||
@@ -183,6 +183,9 @@ end
|
||||
-- Returns: caught (bool), shakes (0..4).
|
||||
-- pokefirered/src/battle_script_commands.c:9463
|
||||
function Catching.tryCatch(itemId, foeBattler, st, session, rng)
|
||||
if st and st.oldManTutorial then
|
||||
return true, 4
|
||||
end
|
||||
local caught, shakes
|
||||
if ModRuntime.wantsHook("catch.rate") then
|
||||
local G3 = require("src.mods.Gen3Compat")
|
||||
|
||||
@@ -1895,7 +1895,9 @@ function Engine.performSwitch(st, adapter, side, slot, opts)
|
||||
if not old or not party or not party[slot] then return nil end
|
||||
local oldSlot = old.partyIndex
|
||||
if side == "player" and not st.double then
|
||||
State.trackParticipant(st, st.enemy, oldSlot or 1)
|
||||
if not opts.isShift and opts.reason ~= "shift" then
|
||||
State.trackParticipant(st, st.enemy, oldSlot or 1)
|
||||
end
|
||||
end
|
||||
Engine.switchOutEffects(st, adapter, old)
|
||||
State.syncBattlerToParty(old, party)
|
||||
@@ -1949,7 +1951,7 @@ function Engine.performSwitch(st, adapter, side, slot, opts)
|
||||
State.trackParticipant(st, st.enemy, slot)
|
||||
elseif st.player then
|
||||
-- pokefirered/src/battle_util.c:254
|
||||
State.trackParticipant(st, nb, st.player.partyIndex)
|
||||
State.opponentSwitchInResetSentPokes(st, nb)
|
||||
end
|
||||
if foe then
|
||||
if foe.expSeedSource == old then foe.expSeedSource = nb end
|
||||
|
||||
@@ -250,7 +250,7 @@ function Experience.awardFoe(st, foeBattler, opts)
|
||||
local sentIn = {}
|
||||
if opts.partyIndices then
|
||||
for _, pi in ipairs(opts.partyIndices) do sentIn[pi] = true end
|
||||
elseif st.double and foeBattler.participants then
|
||||
elseif foeBattler.participants and next(foeBattler.participants) then
|
||||
-- pokefirered/src/battle_script_commands.c:3123
|
||||
for pi in pairs(foeBattler.participants) do sentIn[pi] = true end
|
||||
elseif st.player and st.player.mon and (tonumber(st.player.mon.hp) or 0) > 0 then
|
||||
|
||||
@@ -180,6 +180,8 @@ local function foe_mon_from(foe)
|
||||
end
|
||||
local mon = {
|
||||
species = species,
|
||||
name = foe.name or foe.nickname,
|
||||
nickname = foe.nickname,
|
||||
level = foe.level or 5,
|
||||
hp = foe.hp,
|
||||
maxHp = foe.maxHp,
|
||||
@@ -444,6 +446,7 @@ function Battle.start(opts)
|
||||
-- pokefirered/src/battle_script_commands.c:4520
|
||||
if session and session.dex and foeMon and (foeMon.species or foeMon.speciesId)
|
||||
and not st.link
|
||||
and not st.oldManTutorial
|
||||
and not (st.ghostBattle and not st.ghostUnveiled) then
|
||||
local Dex = require("src.core.game3.dex")
|
||||
Dex.setSeen(session.dex, foeMon.species or foeMon.speciesId)
|
||||
@@ -650,7 +653,7 @@ function Battle.start(opts)
|
||||
Ui.push(strings.sentOut)
|
||||
end
|
||||
-- pokefirered/src/battle_main.c:2801
|
||||
if not st.double and not st.safari then
|
||||
if not st.double and not st.safari and not st.oldManTutorial then
|
||||
Ui.push(Strings("Go! %s!", State.displayName(st.player)))
|
||||
end
|
||||
-- pokefirered/src/battle_controller_oak_old_man.c:626
|
||||
@@ -885,6 +888,13 @@ end
|
||||
|
||||
Battle._refuseLinkItem = refuse_link_item
|
||||
|
||||
local function auto_player_action(st)
|
||||
if st and st.oldManTutorial then
|
||||
return { kind = "bag", itemId = 4, user = "player" }
|
||||
end
|
||||
return Commands.playerAction(st, 1, 1)
|
||||
end
|
||||
|
||||
local function begin_turn_with(playerAct)
|
||||
local st = Battle._st
|
||||
if st and st.double then return D.startSelection() end
|
||||
@@ -957,7 +967,7 @@ local function send_out_enemy_next(nextEnemyIdx)
|
||||
local onDone = function()
|
||||
Battle._phase = "command"
|
||||
if Battle._auto then
|
||||
begin_turn_with(Commands.playerAction(st, 1, 1))
|
||||
begin_turn_with(auto_player_action(st))
|
||||
else
|
||||
Ui.openMenu()
|
||||
end
|
||||
@@ -1023,7 +1033,7 @@ local function handle_player_faint(opts)
|
||||
onDone = function()
|
||||
Battle._phase = "command"
|
||||
if Battle._auto then
|
||||
begin_turn_with(Commands.playerAction(st, 1, 1))
|
||||
begin_turn_with(auto_player_action(st))
|
||||
else
|
||||
Ui.openMenu()
|
||||
end
|
||||
@@ -1590,14 +1600,16 @@ local function step_action()
|
||||
return
|
||||
end
|
||||
local Bag = require("src.core.game3.bag")
|
||||
if not bag or not Bag.has(bag, meta.itemId, 1) then
|
||||
if not st.oldManTutorial and (not bag or not Bag.has(bag, meta.itemId, 1)) then
|
||||
Ui.push(Strings("You don't have that item."))
|
||||
Battle._actions = {}
|
||||
Battle._phase = "command"
|
||||
Ui.openMenu()
|
||||
return
|
||||
end
|
||||
Bag.remove(bag, meta.itemId, 1)
|
||||
if not st.oldManTutorial then
|
||||
Bag.remove(bag, meta.itemId, 1)
|
||||
end
|
||||
local rng = ad and ad.rng and ad:rng() or st.rng
|
||||
local caught, shakes = Catching.tryCatch(meta.itemId, st.enemy, st, session, rng)
|
||||
local pushFn = function(text) Ui.push(text) end
|
||||
@@ -2605,7 +2617,7 @@ local function finish_catch_flow(catchRes, ename, nicknamed)
|
||||
end
|
||||
|
||||
local function start_post_catch_flow(catchRes)
|
||||
if Battle._headless then
|
||||
if Battle._headless or (Battle._st and Battle._st.oldManTutorial) then
|
||||
Battle._actions = {}
|
||||
Battle._pendingEnd = "catch"
|
||||
Battle._phase = "ending"
|
||||
@@ -2808,7 +2820,7 @@ function Battle.update(dt, game)
|
||||
if begin_start_effects() then return end
|
||||
Battle._phase = "command"
|
||||
if Battle._auto then
|
||||
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
|
||||
begin_turn_with(auto_player_action(Battle._st))
|
||||
else
|
||||
Ui.openMenu()
|
||||
end
|
||||
@@ -2822,7 +2834,7 @@ function Battle.update(dt, game)
|
||||
if AnimSeq.update() then
|
||||
Battle._phase = "command"
|
||||
if Battle._auto then
|
||||
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
|
||||
begin_turn_with(auto_player_action(Battle._st))
|
||||
else
|
||||
Ui.openMenu()
|
||||
end
|
||||
@@ -3030,7 +3042,7 @@ function Battle.update(dt, game)
|
||||
end
|
||||
Battle._phase = "command"
|
||||
if Battle._auto then
|
||||
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
|
||||
begin_turn_with(auto_player_action(Battle._st))
|
||||
else
|
||||
Ui.openMenu()
|
||||
end
|
||||
@@ -3074,7 +3086,7 @@ function Battle.update(dt, game)
|
||||
end
|
||||
|
||||
if Battle._phase == "command" and Battle._auto then
|
||||
begin_turn_with(Commands.playerAction(Battle._st, 1, 1))
|
||||
begin_turn_with(auto_player_action(Battle._st))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -183,6 +183,7 @@ local function build_wild(st, opts)
|
||||
end
|
||||
local ename = State.displayName(st.enemy)
|
||||
local pname = State.displayName(st.player)
|
||||
local playerGender = (st.oldManTutorial and 5) or opts.playerGender or 0
|
||||
add("fade", { mode = "FROM_BLACK", instant = true })
|
||||
-- pret: player back sprite slides in with the BG intro even in wild battles
|
||||
-- (BattleIntroDrawTrainersOrMonsSprites → EmitDrawTrainerPic for PLAYER_LEFT).
|
||||
@@ -198,7 +199,7 @@ local function build_wild(st, opts)
|
||||
enemyMonTo = 0,
|
||||
slideFrames = 120,
|
||||
darken = 10 / 16,
|
||||
gender = opts.playerGender or 0,
|
||||
gender = playerGender,
|
||||
})
|
||||
add("cry", { side = "enemy" }) add("undarken", { side = "enemy", frames = 10 })
|
||||
add("healthbox", { side = "enemy", frames = 23, from = -115 })
|
||||
@@ -222,6 +223,13 @@ local function build_wild(st, opts)
|
||||
add("wait", { frames = 3 })
|
||||
return steps
|
||||
end
|
||||
if st.oldManTutorial then
|
||||
-- pokefirered/src/battle_controller_oak_old_man.c
|
||||
-- In Oak/Old Man tutorial, the player's Pokémon is not sent out and there is no player healthbox.
|
||||
-- The Old Man backsprite stays at (0, 0) and the battle transitions straight to action selection.
|
||||
add("wait", { frames = 3 })
|
||||
return steps
|
||||
end
|
||||
-- pokefirered/src/battle_message.c:399
|
||||
add("msg", { text = Strings("Go! %s!", pname), linger = true })
|
||||
add("player_throw", {})
|
||||
@@ -349,11 +357,10 @@ function IntroSeq.begin(st, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- Park terrain and sliding sprites off-screen immediately so the first
|
||||
-- rendered frame (and fade-in) starts with them in initial slide positions.
|
||||
local playerGender = (st.oldManTutorial and 5) or opts.playerGender or 0
|
||||
s.bgSlide = { enemyOx = -240, playerOx = 240 }
|
||||
s.trainer.player.visible = true
|
||||
s.trainer.player.gender = opts.playerGender or 0
|
||||
s.trainer.player.gender = playerGender
|
||||
s.trainer.player.ox = 240
|
||||
s.trainer.player.frame = 0
|
||||
|
||||
|
||||
@@ -326,7 +326,7 @@ function State.resetSentPokes(st)
|
||||
local sent = {}
|
||||
for _, id in ipairs({ 0, 2 }) do
|
||||
local b = State.battler(st, id)
|
||||
if b and b.partyIndex then sent[#sent + 1] = b.partyIndex end
|
||||
if b and b.partyIndex and not State.isAbsent(st, id) then sent[#sent + 1] = b.partyIndex end
|
||||
end
|
||||
for _, id in ipairs({ 1, 3 }) do
|
||||
local foe = State.battler(st, id)
|
||||
@@ -335,6 +335,12 @@ function State.resetSentPokes(st)
|
||||
for _, pi in ipairs(sent) do foe.participants[pi] = true end
|
||||
end
|
||||
end
|
||||
if st.enemy and not st.enemy.participants then
|
||||
st.enemy.participants = {}
|
||||
if st.player and st.player.partyIndex and not State.isAbsent(st, 0) then
|
||||
st.enemy.participants[st.player.partyIndex] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- pokefirered/src/battle_util.c:254
|
||||
@@ -347,6 +353,9 @@ function State.opponentSwitchInResetSentPokes(st, foeBattler)
|
||||
foeBattler.participants[b.partyIndex] = true
|
||||
end
|
||||
end
|
||||
if not st.double and st.player and not State.isAbsent(st, 0) and st.player.partyIndex then
|
||||
foeBattler.participants[st.player.partyIndex] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- pokefirered/src/battle_util.c:273
|
||||
@@ -359,6 +368,9 @@ function State.updateSentPokes(st, battler)
|
||||
local foe = State.battler(st, id)
|
||||
if foe then State.trackParticipant(st, foe, battler.partyIndex) end
|
||||
end
|
||||
if not st.double and st.enemy then
|
||||
State.trackParticipant(st, st.enemy, battler.partyIndex)
|
||||
end
|
||||
end
|
||||
|
||||
function State.displayName(battler)
|
||||
|
||||
@@ -273,7 +273,8 @@ function SwitchSeq.beginSendOut(st, side, newSlot, opts)
|
||||
SwitchSeq._pushMsg(Strings("Go! %s!", State.displayName(st.player)))
|
||||
end
|
||||
else
|
||||
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st })
|
||||
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st, state = st })
|
||||
State.opponentSwitchInResetSentPokes(st, st.enemy)
|
||||
Anim.syncDisplayFromState(st)
|
||||
if SwitchSeq._pushMsg then
|
||||
local tname = (st.trainerClassName and st.trainerClassName ~= "")
|
||||
@@ -422,7 +423,6 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts)
|
||||
if SwitchSeq._headless then
|
||||
if SwitchSeq._pushMsg then SwitchSeq._pushMsg(withdrawMsg) end
|
||||
switch_out_effects(st, oldBattler)
|
||||
State.trackParticipant(st, st.enemy, oldBattler and oldBattler.partyIndex or 1)
|
||||
State.syncBattlerToParty(st.player, st.playerParty)
|
||||
State.wipeVolatilesAndStages(st.player)
|
||||
st.enemy = State.makeBattler(st.foeParty[enemySlot], "enemy", { partyIndex = enemySlot, st = st })
|
||||
@@ -430,7 +430,8 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts)
|
||||
and (st.trainerClassName .. " " .. (st.trainerName or ""))
|
||||
or (st.trainerName or "TRAINER")
|
||||
if SwitchSeq._pushMsg then SwitchSeq._pushMsg(Strings("%s sent\nout %s!", tname, State.displayName(st.enemy))) end
|
||||
st.player = State.makeBattler(st.playerParty[playerSlot], "player", { partyIndex = playerSlot, st = st })
|
||||
st.player = State.makeBattler(st.playerParty[playerSlot], "player", { partyIndex = playerSlot, st = st, state = st })
|
||||
State.opponentSwitchInResetSentPokes(st, st.enemy)
|
||||
State.trackParticipant(st, st.enemy, playerSlot)
|
||||
Anim.syncDisplayFromState(st)
|
||||
if SwitchSeq._pushMsg then SwitchSeq._pushMsg(Strings("Go! %s!", State.displayName(st.player))) end
|
||||
@@ -444,14 +445,14 @@ function SwitchSeq.beginShiftSwitch(st, playerSlot, enemySlot, opts)
|
||||
{ kind = "msg", data = { text = withdrawMsg } },
|
||||
{ kind = "withdraw", data = { side = "player" } },
|
||||
-- 2. Enemy sendout first (retail FRLG order)
|
||||
{ kind = "swap_data", data = { side = "enemy", newSlot = enemySlot } },
|
||||
{ kind = "swap_data", data = { side = "enemy", newSlot = enemySlot, isShift = true, reason = "shift" } },
|
||||
{ kind = "msg_sendout", data = { side = "enemy" } },
|
||||
{ kind = "sendout_enemy", data = { slot = enemySlot } },
|
||||
{ kind = "shiny_check", data = { side = "enemy" } },
|
||||
{ kind = "cry", data = { side = "enemy" } },
|
||||
{ kind = "healthbox", data = { side = "enemy" } },
|
||||
-- 3. Player sendout second
|
||||
{ kind = "swap_data", data = { side = "player", newSlot = playerSlot } },
|
||||
{ kind = "swap_data", data = { side = "player", newSlot = playerSlot, isShift = true, reason = "shift" } },
|
||||
{ kind = "msg_sendout", data = { side = "player" } },
|
||||
{ kind = "sendout_player", data = { slot = playerSlot } },
|
||||
{ kind = "shiny_check", data = { side = "player" } },
|
||||
@@ -558,7 +559,7 @@ local function run_step(step)
|
||||
if Engine and Engine.performSwitch and battle_adapter() and step_battler(st, d) and party and party[newSlot] then
|
||||
capture_events(function(ad)
|
||||
Engine.performSwitch(st, ad, d.id ~= nil and d.id or side, newSlot,
|
||||
{ batonPass = d.batonPass, reason = d.reason or "switch" })
|
||||
{ batonPass = d.batonPass, reason = d.reason or "switch", isShift = d.isShift })
|
||||
end)
|
||||
Anim.syncDisplayFromState(st)
|
||||
advance()
|
||||
@@ -567,7 +568,9 @@ local function run_step(step)
|
||||
switch_out_effects(st, st and st[side])
|
||||
if side == "player" then
|
||||
if st and st.player then
|
||||
State.trackParticipant(st, st.enemy, st.player.partyIndex or 1)
|
||||
if not d.isShift and d.reason ~= "shift" then
|
||||
State.trackParticipant(st, st.enemy, st.player.partyIndex or 1)
|
||||
end
|
||||
State.syncBattlerToParty(st.player, st.playerParty)
|
||||
State.wipeVolatilesAndStages(st.player, { batonPass = d.batonPass })
|
||||
end
|
||||
@@ -578,7 +581,11 @@ local function run_step(step)
|
||||
State.syncBattlerToParty(st.enemy, st.foeParty)
|
||||
State.wipeVolatilesAndStages(st.enemy)
|
||||
end
|
||||
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st })
|
||||
st.enemy = State.makeBattler(st.foeParty[newSlot], "enemy", { partyIndex = newSlot, st = st, state = st })
|
||||
State.opponentSwitchInResetSentPokes(st, st.enemy)
|
||||
if d.isShift or d.reason == "shift" then
|
||||
st.enemy.participants = {}
|
||||
end
|
||||
end
|
||||
Anim.syncDisplayFromState(st)
|
||||
advance()
|
||||
|
||||
@@ -204,6 +204,8 @@ function Ui.reset(opts)
|
||||
Ui._partnerAction = nil
|
||||
Ui._oak = nil
|
||||
Ui._oakTexts = nil
|
||||
Ui._oldManTimer = nil
|
||||
Ui._oldManSubstate = nil
|
||||
if Message and Message.isHeld and Message.isHeld() then Message.close() end
|
||||
if not Ui._headless then
|
||||
local okC, errC = pcall(BattleChrome.install, nil)
|
||||
@@ -566,6 +568,14 @@ function Ui.openMenu(battlerId, opts)
|
||||
Ui._menuIndex = 1
|
||||
end
|
||||
Ui._pendingCommand = nil
|
||||
if Ui._st and Ui._st.oldManTutorial then
|
||||
Ui._oldManTimer = 0
|
||||
Ui._oldManSubstate = 0
|
||||
if Ui._headless then
|
||||
Ui._pendingCommand = { kind = "bag", itemId = 4, user = "player" }
|
||||
Ui._mode = "none"
|
||||
end
|
||||
end
|
||||
if Message and Message.open then
|
||||
Message.open = false
|
||||
end
|
||||
@@ -1076,6 +1086,27 @@ function Ui.tick()
|
||||
end
|
||||
tick_bounces()
|
||||
tick_target()
|
||||
if Ui._st and Ui._st.oldManTutorial and Ui._mode == "menu" and not Ui._headless then
|
||||
-- pokefirered/src/battle_controller_oak_old_man.c: SimulateInputChooseAction
|
||||
if Ui._oldManSubstate == 0 then
|
||||
Ui._oldManTimer = (Ui._oldManTimer or 0) + 1
|
||||
if Ui._oldManTimer >= 64 then
|
||||
play_select()
|
||||
Ui._menuIndex = 2 -- BAG
|
||||
Ui._oldManTimer = 0
|
||||
Ui._oldManSubstate = 1
|
||||
end
|
||||
elseif Ui._oldManSubstate == 1 then
|
||||
Ui._oldManTimer = (Ui._oldManTimer or 0) + 1
|
||||
if Ui._oldManTimer >= 64 then
|
||||
play_select()
|
||||
Ui._pendingCommand = { kind = "bag", itemId = 4, user = "player" }
|
||||
Ui._mode = "none"
|
||||
Ui._oldManSubstate = nil
|
||||
Ui._oldManTimer = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
elseif m ~= "bag" and m ~= "party" then
|
||||
end_all_bounces()
|
||||
end
|
||||
@@ -1363,6 +1394,10 @@ function Ui.handleInput(input)
|
||||
end
|
||||
|
||||
if not Ui.waitingForCommand() then return false end
|
||||
if Ui._st and Ui._st.oldManTutorial then
|
||||
-- Old Man tutorial script controls the actions automatically
|
||||
return true
|
||||
end
|
||||
if is_double() then return handle_double_input(input) end
|
||||
if Ui._mode == "menu" then
|
||||
local idx, moved = grid_nav(Ui._menuIndex, input, 4)
|
||||
@@ -1789,6 +1824,9 @@ local function draw_action_menu(st)
|
||||
local pname = (st.playerName ~= nil and st.playerName ~= "" and st.playerName) or "RED"
|
||||
draw_prompt_text(Strings("What will %s\nthrow?", pname), 10, 122)
|
||||
labels = { Strings("BALL"), Strings("BAIT"), Strings("ROCK"), Strings("RUN") }
|
||||
elseif st and st.oldManTutorial then
|
||||
-- pokefirered/src/battle_message.c: gText_WhatWillOldManDo
|
||||
draw_prompt_text(Strings("What will\nOLD MAN do?"), 10, 122)
|
||||
else
|
||||
draw_prompt_text(Strings("What will\n%s do?", name), 10, 122)
|
||||
end
|
||||
@@ -1864,17 +1902,16 @@ local function draw_player_trainer(stage)
|
||||
local TrainerPic = require("src.core.game3.trainer_pic")
|
||||
local tp = stage.trainer.player
|
||||
if tp and tp.visible then
|
||||
local entry = TrainerPic.back(tp.gender or 0)
|
||||
local gender = tp.gender or 0
|
||||
local entry = TrainerPic.back(gender)
|
||||
if entry and entry.image then
|
||||
local frame = math.max(0, math.min(4, tonumber(tp.frame) or 0))
|
||||
local q = stage._backQuad
|
||||
if not q and love and love.graphics then
|
||||
-- quads cached on stage weakly; recreate each frame is fine for one sprite
|
||||
end
|
||||
local key = "back_" .. tostring(frame)
|
||||
local maxFrame = math.max(0, (entry.frames or 5) - 1)
|
||||
local frame = math.max(0, math.min(maxFrame, tonumber(tp.frame) or 0))
|
||||
local key = "back_" .. tostring(gender) .. "_" .. tostring(frame)
|
||||
Ui._trainerQuads = Ui._trainerQuads or {}
|
||||
if not Ui._trainerQuads[key] then
|
||||
Ui._trainerQuads[key] = love.graphics.newQuad(0, frame * 64, 64, 64, 64, 320)
|
||||
local imgH = entry.h or (entry.frames and entry.frames * 64) or 320
|
||||
Ui._trainerQuads[key] = love.graphics.newQuad(0, frame * 64, 64, 64, entry.w or 64, imgH)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(
|
||||
|
||||
@@ -1171,6 +1171,14 @@ function FieldView.draw(game, canvasW, canvasH, opts)
|
||||
end
|
||||
end
|
||||
|
||||
-- S.S. Anne wake (pret oam.priority = 2, subpriority = 0xFF: under boat hull).
|
||||
if not opts.actorsOnly then
|
||||
local okSS, SSAnne = pcall(require, "src.core.game3.ss_anne_cutscene")
|
||||
if okSS and SSAnne and SSAnne.drawWake then
|
||||
SSAnne.drawWake(camX, camY)
|
||||
end
|
||||
end
|
||||
|
||||
-- Collect Game3 actors partitioned by OAM priority.
|
||||
local underActors, overActors = nil, nil
|
||||
if not opts.skipActors then
|
||||
@@ -1226,6 +1234,10 @@ function FieldView.draw(game, canvasW, canvasH, opts)
|
||||
FieldEffects.drawOverlay(camX, camY)
|
||||
love.graphics.pop()
|
||||
end
|
||||
local okSS, SSAnne = pcall(require, "src.core.game3.ss_anne_cutscene")
|
||||
if okSS and SSAnne and SSAnne.drawSmoke then
|
||||
SSAnne.drawSmoke(camX, camY)
|
||||
end
|
||||
end
|
||||
|
||||
drawFlashMask(canvasW, canvasH)
|
||||
|
||||
@@ -426,7 +426,7 @@ function Player.snapshotSlot(slot, at)
|
||||
end
|
||||
|
||||
-- pokefirered/src/m4a.c:668
|
||||
function Player.stopAt(slot, snaps, at, abs)
|
||||
function Player.stopAt(slot, snaps, at, abs, pack, cache)
|
||||
if not slot then return abs end
|
||||
at = tonumber(at)
|
||||
local snap
|
||||
@@ -453,10 +453,20 @@ function Player.stopAt(slot, snaps, at, abs)
|
||||
if snaps[i].at > at then table.remove(snaps, i) end
|
||||
end
|
||||
abs = at
|
||||
elseif at and slot.songId and (abs == nil or at < abs) and (pack or slot.pack) and (cache or slot.cache) then
|
||||
Player.start(pack or slot.pack, cache or slot.cache, slot, slot.songId, { forceSeq = true })
|
||||
if snaps then
|
||||
for i = #snaps, 1, -1 do table.remove(snaps, i) end
|
||||
end
|
||||
local left = at
|
||||
local q = Player.mixQuantum()
|
||||
while left > 0 do
|
||||
local n = math.min(q, left)
|
||||
Player.renderBuffered(slot, n, { raw = true })
|
||||
left = left - n
|
||||
end
|
||||
abs = at
|
||||
end
|
||||
-- pokefirered/src/m4a_1.s:1469
|
||||
if slot.seq then slot.seq.voices = {} end
|
||||
slot.voices = {}
|
||||
slot.hpfState = { l = 0, r = 0 }
|
||||
slot.reverbState = Mix.newReverb(slot.reverb or 0)
|
||||
return abs
|
||||
|
||||
@@ -117,9 +117,9 @@ local function apply_cmd(msg)
|
||||
snaps = {}
|
||||
elseif msg.cmd == "stopAt" then
|
||||
bgm.epoch = msg.epoch
|
||||
bgm.abs = Player.stopAt(bgm, snaps, msg.at, bgm.abs) or bgm.abs
|
||||
bgm.abs = Player.stopAt(bgm, snaps, msg.at, bgm.abs, pack, cache) or bgm.abs
|
||||
Mix._hpfCapL, Mix._hpfCapR = 0, 0
|
||||
bgm.muted = false
|
||||
bgm.muted = true
|
||||
elseif msg.cmd == "pause" then
|
||||
bgm.muted = true
|
||||
elseif msg.cmd == "resume" then
|
||||
|
||||
@@ -259,6 +259,8 @@ local function rememberPerm(mapId, localId, fields)
|
||||
end
|
||||
end
|
||||
|
||||
Objects.rememberPerm = rememberPerm
|
||||
|
||||
local function applyPerm(eo, mapId)
|
||||
local bucket = Objects._perm[mapId]
|
||||
local row = bucket and bucket[eo.localId]
|
||||
@@ -603,7 +605,7 @@ local function beginStep(eo, tx, ty)
|
||||
eo.animClock = 0
|
||||
end
|
||||
|
||||
local function finishStep(eo)
|
||||
local function finishStep(eo, game)
|
||||
eo.cellX = eo.targetX
|
||||
eo.cellY = eo.targetY
|
||||
eo.px = eo.cellX * CELL
|
||||
@@ -619,9 +621,15 @@ local function finishStep(eo)
|
||||
if curElev and curElev ~= 0 and curElev ~= 15 then
|
||||
eo.elevation = curElev
|
||||
end
|
||||
if eo.sight and eo.sight > 0 and not eo.scriptBusy and not eo.frozen then
|
||||
local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight")
|
||||
if okTs and TrainerSight and TrainerSight.check then
|
||||
TrainerSight.check(game, eo)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function tickMotion(eo)
|
||||
local function tickMotion(eo, game)
|
||||
if not eo.moving then return false end
|
||||
eo.progress = eo.progress + 1
|
||||
eo.animClock = eo.animClock + 1
|
||||
@@ -632,7 +640,7 @@ local function tickMotion(eo)
|
||||
eo.px = eo.cellX * CELL + dx * CELL * t
|
||||
eo.py = eo.cellY * CELL + dy * CELL * t
|
||||
if eo.progress >= frames then
|
||||
finishStep(eo)
|
||||
finishStep(eo, game)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
@@ -994,6 +1002,12 @@ local function idleTick(eo, game, ctx)
|
||||
beginStep(eo, tx, ty)
|
||||
else
|
||||
eo.facing = dir -- turn toward blocked anyway
|
||||
if not ctx and eo.sight and eo.sight > 0 then
|
||||
local okTs, TrainerSight = pcall(require, "src.core.game3.trainer_sight")
|
||||
if okTs and TrainerSight and TrainerSight.check then
|
||||
TrainerSight.check(game, eo)
|
||||
end
|
||||
end
|
||||
end
|
||||
eo.idleTimer = 40 + Rng.compat(0, 49)
|
||||
end
|
||||
@@ -1011,7 +1025,7 @@ function Objects.update(game)
|
||||
eo.bowFrames = eo.bowFrames - 1
|
||||
if eo.bowFrames <= 0 then eo.bowFrames = nil end
|
||||
end
|
||||
tickMotion(eo)
|
||||
tickMotion(eo, game)
|
||||
idleTick(eo, game)
|
||||
end
|
||||
end
|
||||
@@ -1039,7 +1053,7 @@ function Objects.tickPool(pool, game, ctx)
|
||||
eo.bowFrames = eo.bowFrames - 1
|
||||
if eo.bowFrames <= 0 then eo.bowFrames = nil end
|
||||
end
|
||||
tickMotion(eo)
|
||||
tickMotion(eo, game)
|
||||
idleTick(eo, game, ctx)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,7 +77,14 @@ local function read_from_disk()
|
||||
end
|
||||
|
||||
function Multichoice.tryLoadCache()
|
||||
local readers = { read_from_dataset, read_from_cachefs, read_from_love, read_from_disk }
|
||||
local okE, Extract = pcall(require, "src.import.gba.extract_island1")
|
||||
local customRoot = okE and Extract and Extract.CACHE_ROOT and Extract.CACHE_ROOT ~= "data/generated/gba"
|
||||
local readers
|
||||
if customRoot then
|
||||
readers = { read_from_disk }
|
||||
else
|
||||
readers = { read_from_disk, read_from_dataset, read_from_cachefs, read_from_love }
|
||||
end
|
||||
for _, reader in ipairs(readers) do
|
||||
local data = parse_lists(reader())
|
||||
if data then
|
||||
|
||||
@@ -149,12 +149,6 @@ Cutscene.HANDLERS = {
|
||||
return false
|
||||
end,
|
||||
|
||||
-- pokefirered/src/ss_anne.c:82, data/maps/SSAnne_Exterior/scripts.inc:21
|
||||
[Std.SPECIAL.DoSSAnneDepartureCutscene] = function(ctx, adapters)
|
||||
playSe(adapters, SE_SS_ANNE_HORN)
|
||||
return false
|
||||
end,
|
||||
|
||||
-- pokefirered/src/field_specials.c:2133, data/scripts/pokemon_league.inc:63
|
||||
[Std.SPECIAL.DoPokemonLeagueLightingEffect] = function()
|
||||
return false
|
||||
@@ -205,6 +199,13 @@ Cutscene.HANDLERS = {
|
||||
if ctx then ctx.museumFossilPic = nil end
|
||||
return false
|
||||
end,
|
||||
-- pokefirered/src/ss_anne.c:82 DoSSAnneDepartureCutscene
|
||||
[Std.SPECIAL.DoSSAnneDepartureCutscene] = function(ctx, adapters)
|
||||
local SSAnne = require("src.core.game3.ss_anne_cutscene")
|
||||
local Natives = require("src.core.game3.scripting.natives")
|
||||
Natives.awaitState(ctx, SSAnne.start(ctx, adapters))
|
||||
return false
|
||||
end,
|
||||
}
|
||||
|
||||
return Cutscene
|
||||
|
||||
@@ -1622,12 +1622,37 @@ local function dispatch(vm, row)
|
||||
})
|
||||
end
|
||||
|
||||
if isRematch then
|
||||
-- pokefirered/src/battle_setup.c:848
|
||||
local Objects = package.loaded["src.core.game3.objects"]
|
||||
local eo = Objects and Objects.find and Objects.find(lastTalked)
|
||||
if eo and Objects.setTrainerMovementType and not (Objects.isPlayer and Objects.isPlayer(lastTalked)) then
|
||||
Objects.setTrainerMovementType(eo, VsSeeker.faceTypeFor(eo.facing))
|
||||
-- pokefirered/src/battle_setup.c:848 SetUpTrainerMovement
|
||||
local Objects = package.loaded["src.core.game3.objects"]
|
||||
local eo = Objects and Objects.find and Objects.find(lastTalked)
|
||||
if eo and not (Objects.isPlayer and Objects.isPlayer(lastTalked)) then
|
||||
local faceMt = ({ down = 0x08, up = 0x07, left = 0x09, right = 0x0A })[eo.facing] or 0x08
|
||||
if Objects.setTrainerMovementType then
|
||||
Objects.setTrainerMovementType(eo, faceMt)
|
||||
else
|
||||
eo.movementType = faceMt
|
||||
eo.movement = "STAY"
|
||||
eo.range = (eo.facing or "down"):upper()
|
||||
end
|
||||
if Objects.overrideTemplateMovementType then
|
||||
Objects.overrideTemplateMovementType(eo.localId, faceMt)
|
||||
end
|
||||
eo.homeX = eo.cellX
|
||||
eo.homeY = eo.cellY
|
||||
if eo.def then
|
||||
eo.def.movementType = faceMt
|
||||
eo.def.movement = "STAY"
|
||||
eo.def.x = eo.cellX
|
||||
eo.def.y = eo.cellY
|
||||
eo.def.range = (eo.facing or "down"):upper()
|
||||
end
|
||||
if Objects.rememberPerm and Objects._mapId then
|
||||
Objects.rememberPerm(Objects._mapId, eo.localId, {
|
||||
x = eo.cellX,
|
||||
y = eo.cellY,
|
||||
movementType = faceMt,
|
||||
facing = eo.facing,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ Std.SPECIAL = {
|
||||
BackupHelpContext = 0x17E,
|
||||
RestoreHelpContext = 0x17F,
|
||||
SetHelpContextForMap = 0x190,
|
||||
DoSSAnneDepartureCutscene = 0x191, -- pokefirered/data/specials.inc:412
|
||||
HelpSystem_Disable = 0x198,
|
||||
HelpSystem_Enable = 0x199,
|
||||
StartMarowakBattle = 0x156, -- pokefirered/data/specials.inc:353
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
-- S.S. Anne departure cutscene matching pret pokefirered (src/ss_anne.c).
|
||||
-- Manages the ship's horn sound effects, wake trailing animation, smoke puffs,
|
||||
-- leftward sailing motion, and script coordination.
|
||||
|
||||
local SSAnneCutscene = {}
|
||||
|
||||
-- Audio constants (pokefirered/include/constants/songs.h:249)
|
||||
local SE_SS_ANNE_HORN = 249
|
||||
|
||||
-- Timing constants matching pokefirered/src/ss_anne.c
|
||||
local INIT_FRAMES = 50 -- Task_SSAnneInit countdown
|
||||
local SMOKE_INTERVAL = 70 -- Task_SSAnneRun smoke puff period
|
||||
local SLIDE_SPEED_DIV = 5 -- 1 pixel movement every 5 frames (x = data[2] / 5)
|
||||
local TRAVEL_DISTANCE = 216 -- pixels to travel until boat is fully off-screen
|
||||
local FINISH_FRAMES = 40 -- Task_SSAnneFinish delay after exit horn
|
||||
|
||||
SSAnneCutscene._active = false
|
||||
SSAnneCutscene._phase = "idle" -- "init" | "run" | "finish" | "done"
|
||||
SSAnneCutscene._initTimer = 0
|
||||
SSAnneCutscene._runTimer1 = 0 -- smoke timer
|
||||
SSAnneCutscene._runTimer2 = 0 -- motion timer
|
||||
SSAnneCutscene._finishTimer = 0
|
||||
SSAnneCutscene._boatOffset = 0
|
||||
SSAnneCutscene._wake = nil
|
||||
SSAnneCutscene._smokes = {}
|
||||
SSAnneCutscene._wakeImage = nil
|
||||
SSAnneCutscene._wakeQuads = nil
|
||||
SSAnneCutscene._smokeImage = nil
|
||||
SSAnneCutscene._smokeQuads = nil
|
||||
|
||||
local function playSe(id)
|
||||
local ad = SSAnneCutscene._adapters
|
||||
if ad and ad.playSe then
|
||||
pcall(ad.playSe, id)
|
||||
return
|
||||
end
|
||||
local okA, Audio = pcall(require, "src.core.game3.audio")
|
||||
if okA and Audio and Audio.playSe then
|
||||
pcall(Audio.playSe, id)
|
||||
end
|
||||
end
|
||||
|
||||
local function makeTransparentImage(path)
|
||||
if not (love and love.image and love.image.newImageData and love.graphics and love.graphics.newImage) then
|
||||
return nil
|
||||
end
|
||||
local okData, imgData = pcall(love.image.newImageData, path)
|
||||
if not (okData and imgData) then return nil end
|
||||
|
||||
local w, h = imgData:getWidth(), imgData:getHeight()
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local r, g, b, _ = imgData:getPixel(x, y)
|
||||
local r8 = math.floor(r * 255 + 0.5)
|
||||
local g8 = math.floor(g * 255 + 0.5)
|
||||
local b8 = math.floor(b * 255 + 0.5)
|
||||
-- Key out GBA background blue palette entry (57, 115, 180)
|
||||
if (r8 >= 45 and r8 <= 68) and (g8 >= 105 and g8 <= 130) and (b8 >= 165 and b8 <= 195) then
|
||||
imgData:setPixel(x, y, 0, 0, 0, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
local img = love.graphics.newImage(imgData)
|
||||
if img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
return img
|
||||
end
|
||||
|
||||
local function loadGfx()
|
||||
if not (love and love.graphics and love.graphics.newQuad) then
|
||||
return
|
||||
end
|
||||
if not SSAnneCutscene._wakeImage then
|
||||
local paths = {
|
||||
"pokefirered/graphics/ss_anne/wake_transparent.png",
|
||||
"pokefirered/graphics/ss_anne/wake.png",
|
||||
"graphics/ss_anne/wake.png",
|
||||
}
|
||||
for _, p in ipairs(paths) do
|
||||
local img = makeTransparentImage(p)
|
||||
if img then
|
||||
SSAnneCutscene._wakeImage = img
|
||||
-- 16x64 image with two 16x32 frames
|
||||
SSAnneCutscene._wakeQuads = {
|
||||
[0] = love.graphics.newQuad(0, 0, 16, 32, 16, 64),
|
||||
[1] = love.graphics.newQuad(0, 32, 16, 32, 16, 64),
|
||||
}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if not SSAnneCutscene._smokeImage then
|
||||
local paths = {
|
||||
"pokefirered/graphics/ss_anne/smoke_transparent.png",
|
||||
"pokefirered/graphics/ss_anne/smoke.png",
|
||||
"graphics/ss_anne/smoke.png",
|
||||
}
|
||||
for _, p in ipairs(paths) do
|
||||
local img = makeTransparentImage(p)
|
||||
if img then
|
||||
SSAnneCutscene._smokeImage = img
|
||||
local sw, sh = 16, 72
|
||||
if img.getDimensions then
|
||||
local iw, ih = img:getDimensions()
|
||||
if iw and ih then sw, sh = iw, ih end
|
||||
elseif img.getHeight and img:getHeight() then
|
||||
sh = img:getHeight()
|
||||
end
|
||||
-- Four 16x16 frames
|
||||
SSAnneCutscene._smokeQuads = {
|
||||
[0] = love.graphics.newQuad(0, 0, 16, 16, sw, sh),
|
||||
[1] = love.graphics.newQuad(0, 16, 16, 16, sw, sh),
|
||||
[2] = love.graphics.newQuad(0, 32, 16, 16, sw, sh),
|
||||
[3] = love.graphics.newQuad(0, 48, 16, 16, sw, sh),
|
||||
}
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function SSAnneCutscene.isActive()
|
||||
return SSAnneCutscene._active
|
||||
end
|
||||
|
||||
function SSAnneCutscene.reset()
|
||||
SSAnneCutscene._active = false
|
||||
SSAnneCutscene._phase = "idle"
|
||||
SSAnneCutscene._initTimer = 0
|
||||
SSAnneCutscene._runTimer1 = 0
|
||||
SSAnneCutscene._runTimer2 = 0
|
||||
SSAnneCutscene._finishTimer = 0
|
||||
SSAnneCutscene._boatOffset = 0
|
||||
SSAnneCutscene._wake = nil
|
||||
SSAnneCutscene._smokes = {}
|
||||
end
|
||||
|
||||
--- pokefirered/src/ss_anne.c:82 DoSSAnneDepartureCutscene
|
||||
function SSAnneCutscene.start(ctx, adapters)
|
||||
SSAnneCutscene.reset()
|
||||
SSAnneCutscene._adapters = adapters
|
||||
SSAnneCutscene._active = true
|
||||
SSAnneCutscene._phase = "init"
|
||||
SSAnneCutscene._initTimer = INIT_FRAMES
|
||||
|
||||
-- Initial horn sound
|
||||
playSe(SE_SS_ANNE_HORN)
|
||||
|
||||
loadGfx()
|
||||
|
||||
return function()
|
||||
return SSAnneCutscene.step()
|
||||
end
|
||||
end
|
||||
|
||||
--- Ticked each frame during waitstate. Returns true when cutscene is fully finished.
|
||||
function SSAnneCutscene.step()
|
||||
if not SSAnneCutscene._active then return true end
|
||||
|
||||
local Objects = package.loaded["src.core.game3.objects"]
|
||||
local eo = Objects and Objects.find and Objects.find(1)
|
||||
|
||||
if SSAnneCutscene._phase == "init" then
|
||||
SSAnneCutscene._initTimer = SSAnneCutscene._initTimer - 1
|
||||
if SSAnneCutscene._initTimer <= 0 then
|
||||
-- Task_SSAnneInit finishes: creates wake sprite and switches to Task_SSAnneRun
|
||||
SSAnneCutscene._phase = "run"
|
||||
SSAnneCutscene._wake = {
|
||||
timer = 0,
|
||||
x2 = 0,
|
||||
frame = 0,
|
||||
}
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if SSAnneCutscene._phase == "run" then
|
||||
SSAnneCutscene._runTimer1 = SSAnneCutscene._runTimer1 + 1
|
||||
SSAnneCutscene._runTimer2 = SSAnneCutscene._runTimer2 + 1
|
||||
|
||||
-- Smoke puff creation every 70 frames
|
||||
if SSAnneCutscene._runTimer1 == SMOKE_INTERVAL then
|
||||
SSAnneCutscene._runTimer1 = 0
|
||||
table.insert(SSAnneCutscene._smokes, {
|
||||
timer = 0,
|
||||
x2 = 0,
|
||||
frame = 0,
|
||||
animEnded = false,
|
||||
boatOffsetAtSpawn = SSAnneCutscene._boatOffset,
|
||||
})
|
||||
end
|
||||
|
||||
-- Boat movement: x = data[2] / 5 (1 pixel every 5 frames)
|
||||
SSAnneCutscene._boatOffset = math.floor(SSAnneCutscene._runTimer2 / SLIDE_SPEED_DIV)
|
||||
if eo then
|
||||
eo.raiseX = -SSAnneCutscene._boatOffset
|
||||
end
|
||||
|
||||
-- Update wake sprite
|
||||
if SSAnneCutscene._wake then
|
||||
local w = SSAnneCutscene._wake
|
||||
if math.floor(w.timer / 6) < 22 then
|
||||
w.timer = w.timer + 1
|
||||
end
|
||||
w.x2 = math.floor(w.timer / 6)
|
||||
-- 12 ticks per frame, looping between frame 0 and frame 1
|
||||
w.frame = (math.floor(w.timer / 12) % 2 == 0) and 0 or 1
|
||||
end
|
||||
|
||||
-- Update smoke sprites
|
||||
local activeSmokes = {}
|
||||
for _, s in ipairs(SSAnneCutscene._smokes) do
|
||||
s.timer = s.timer + 1
|
||||
s.x2 = math.floor(s.timer / 4)
|
||||
if s.timer < 10 then
|
||||
s.frame = 0
|
||||
elseif s.timer < 30 then
|
||||
s.frame = 1
|
||||
elseif s.timer < 50 then
|
||||
s.frame = 2
|
||||
elseif s.timer < 80 then
|
||||
s.frame = 3
|
||||
else
|
||||
s.animEnded = true
|
||||
end
|
||||
if not s.animEnded then
|
||||
activeSmokes[#activeSmokes + 1] = s
|
||||
end
|
||||
end
|
||||
SSAnneCutscene._smokes = activeSmokes
|
||||
|
||||
-- Exit check: when boat moves completely off-screen
|
||||
if SSAnneCutscene._boatOffset >= TRAVEL_DISTANCE then
|
||||
-- Final horn sound
|
||||
playSe(SE_SS_ANNE_HORN)
|
||||
SSAnneCutscene._phase = "finish"
|
||||
SSAnneCutscene._finishTimer = 0
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if SSAnneCutscene._phase == "finish" then
|
||||
SSAnneCutscene._finishTimer = SSAnneCutscene._finishTimer + 1
|
||||
if SSAnneCutscene._finishTimer >= FINISH_FRAMES then
|
||||
-- Task_SSAnneFinish complete: clean up and unblock script
|
||||
SSAnneCutscene._active = false
|
||||
SSAnneCutscene._phase = "done"
|
||||
if eo then
|
||||
eo.raiseX = 0
|
||||
end
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--- Draw wake under boat actors (pret oam.priority = 2, subpriority = 0xFF).
|
||||
function SSAnneCutscene.drawWake(camX, camY)
|
||||
if not SSAnneCutscene._active then return end
|
||||
if not (SSAnneCutscene._wake and SSAnneCutscene._wakeImage and SSAnneCutscene._wakeQuads) then return end
|
||||
|
||||
local Objects = package.loaded["src.core.game3.objects"]
|
||||
local eo = Objects and Objects.find and Objects.find(1)
|
||||
if not eo then return end
|
||||
|
||||
local curBoatPx = (eo.px or (eo.cellX * 16)) - SSAnneCutscene._boatOffset
|
||||
local boatPy = eo.py or (eo.cellY * 16)
|
||||
local boatLeft = curBoatPx - camX - 56
|
||||
local boatTop = boatPy - camY - 48
|
||||
|
||||
local w = SSAnneCutscene._wake
|
||||
local q = SSAnneCutscene._wakeQuads[w.frame or 0]
|
||||
if q then
|
||||
local wx = boatLeft + 106 + (w.x2 or 0)
|
||||
local wy = boatTop + 24
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(SSAnneCutscene._wakeImage, q, wx, wy)
|
||||
end
|
||||
end
|
||||
|
||||
--- Draw smoke puffs rising from smokestack in overlay space (pret oam.priority = 0).
|
||||
function SSAnneCutscene.drawSmoke(camX, camY)
|
||||
if not SSAnneCutscene._active then return end
|
||||
if not (SSAnneCutscene._smokeImage and SSAnneCutscene._smokeQuads and #SSAnneCutscene._smokes > 0) then return end
|
||||
|
||||
local Objects = package.loaded["src.core.game3.objects"]
|
||||
local eo = Objects and Objects.find and Objects.find(1)
|
||||
if not eo then return end
|
||||
|
||||
local boatPy = eo.py or (eo.cellY * 16)
|
||||
local boatTop = boatPy - camY - 48
|
||||
|
||||
for _, s in ipairs(SSAnneCutscene._smokes) do
|
||||
local q = SSAnneCutscene._smokeQuads[s.frame or 0]
|
||||
if q then
|
||||
local spawnBoatLeft = (eo.px or (eo.cellX * 16)) - s.boatOffsetAtSpawn - camX - 56
|
||||
local sx = spawnBoatLeft + 78 + (s.x2 or 0)
|
||||
local sy = boatTop + 2
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(SSAnneCutscene._smokeImage, q, sx, sy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Draw wake and smoke overlay particles in world/screen space.
|
||||
function SSAnneCutscene.draw(camX, camY)
|
||||
SSAnneCutscene.drawWake(camX, camY)
|
||||
SSAnneCutscene.drawSmoke(camX, camY)
|
||||
end
|
||||
|
||||
return SSAnneCutscene
|
||||
@@ -47,12 +47,47 @@ local function bgr555_to_rgb8(c)
|
||||
math.floor(b5 * 255 / 31 + 0.5)
|
||||
end
|
||||
|
||||
local function wrap_rom_data(data)
|
||||
if not data or #data < 0xC0 then return data end
|
||||
local code = data:sub(0xAD, 0xB0)
|
||||
local ver = data:byte(0xBD)
|
||||
local okV, Versions = pcall(require, "src.import.gba.versions")
|
||||
if okV and Versions and Versions.select then
|
||||
if code == "BPGE" then
|
||||
Versions.select("leafgreen")
|
||||
else
|
||||
Versions.select("firered")
|
||||
end
|
||||
end
|
||||
if ver == 1 then
|
||||
local sha1
|
||||
if love and love.data and love.data.hash then
|
||||
sha1 = love.data.hash("sha1", data)
|
||||
end
|
||||
local okR, RevisionView = pcall(require, "src.import.gba.revision_view")
|
||||
if okR and RevisionView then
|
||||
local rev = (sha1 and RevisionView.forSha1 and RevisionView.forSha1(sha1))
|
||||
or (code == "BPGE" and require("src.import.gba.revisions.leafgreen_1_1"))
|
||||
or require("src.import.gba.revisions.firered_1_1")
|
||||
if rev and RevisionView.build then
|
||||
data = RevisionView.build(data, rev)
|
||||
end
|
||||
end
|
||||
end
|
||||
return data
|
||||
end
|
||||
|
||||
local function load_rom_bytes()
|
||||
if TrainerPic._rom then return TrainerPic._rom end
|
||||
local candidates = {
|
||||
"1636 - Pokemon Fire Red (U)(Squirrels).gba",
|
||||
"firered.gba",
|
||||
"firered_dump.gba",
|
||||
"Pokemon - FireRed Version (USA).gba",
|
||||
"Pokemon - Fire Red Version (U) (V1.1).gba",
|
||||
"Pokemon - LeafGreen Version (USA).gba",
|
||||
"Pokemon - LeafGreen Version (USA, Europe) (Rev 1).gba",
|
||||
"leafgreen.gba",
|
||||
}
|
||||
local bases = {
|
||||
"",
|
||||
@@ -66,8 +101,9 @@ local function load_rom_bytes()
|
||||
local data = f:read("*a")
|
||||
f:close()
|
||||
if data and #data > 0 then
|
||||
TrainerPic._rom = data
|
||||
return data
|
||||
local wrapped = wrap_rom_data(data)
|
||||
TrainerPic._rom = wrapped
|
||||
return wrapped
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -76,8 +112,9 @@ local function load_rom_bytes()
|
||||
for _, name in ipairs(candidates) do
|
||||
local data = love.filesystem.read(name)
|
||||
if data and #data > 0 then
|
||||
TrainerPic._rom = data
|
||||
return data
|
||||
local wrapped = wrap_rom_data(data)
|
||||
TrainerPic._rom = wrapped
|
||||
return wrapped
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -232,23 +269,28 @@ end
|
||||
--- Player back pic strip (64×320, 5 frames). gender 0=boy, 1=girl.
|
||||
function TrainerPic.back(gender)
|
||||
gender = tonumber(gender) or 0
|
||||
if gender ~= 0 and gender ~= 1 then gender = 0 end
|
||||
if gender < 0 or gender > 5 then gender = 0 end
|
||||
if TrainerPic._back[gender] then return TrainerPic._back[gender] end
|
||||
if not TrainerPic._cache then TrainerPic.install(nil) end
|
||||
local rel = cache_root() .. "/back_" .. gender .. ".rgba"
|
||||
local cache = TrainerPic._cache
|
||||
local rgba = cache and cache.read and cache:read(rel)
|
||||
if not rgba or #rgba < 64 * 320 * 4 then
|
||||
local frames = (gender == 0 or gender == 1) and 5 or 4
|
||||
local expectedBytes = 64 * 64 * frames * 4
|
||||
if not rgba or #rgba < expectedBytes then
|
||||
rgba = decode_pic_rgba(
|
||||
gender,
|
||||
Versions.TRAINER_BACK_PIC_TABLE or 0x239FA4,
|
||||
Versions.TRAINER_BACK_PIC_PAL_TABLE or 0x239FD4,
|
||||
rel,
|
||||
5)
|
||||
frames)
|
||||
end
|
||||
local image = image_from_rgba(rgba, 64, 320)
|
||||
if not rgba then return nil end
|
||||
local actualFrames = math.floor(#rgba / (64 * 64 * 4))
|
||||
if actualFrames <= 0 then return nil end
|
||||
local image = image_from_rgba(rgba, 64, 64 * actualFrames)
|
||||
if not image then return nil end
|
||||
local entry = { image = image, w = 64, h = 320, frames = 5 }
|
||||
local entry = { image = image, w = 64, h = 64 * actualFrames, frames = actualFrames }
|
||||
TrainerPic._back[gender] = entry
|
||||
return entry
|
||||
end
|
||||
|
||||
@@ -281,6 +281,35 @@ function TrainerSight.engage(game, eo, dist)
|
||||
|
||||
local function finishEngagement()
|
||||
P.facing = playerFacing
|
||||
-- pokefirered/src/trainer_see.c:349-351 SetTrainerMovementType, OverrideMovementTypeForObjectEvent, OverrideTemplateCoordsForObjectEvent
|
||||
local faceMt = ({ down = 0x08, up = 0x07, left = 0x09, right = 0x0A })[eo.facing] or 0x08
|
||||
if Objs.setTrainerMovementType then
|
||||
Objs.setTrainerMovementType(eo, faceMt)
|
||||
else
|
||||
eo.movementType = faceMt
|
||||
eo.movement = "STAY"
|
||||
eo.range = (eo.facing or "down"):upper()
|
||||
end
|
||||
if Objs.overrideTemplateMovementType then
|
||||
Objs.overrideTemplateMovementType(eo.localId, faceMt)
|
||||
end
|
||||
eo.homeX = eo.cellX
|
||||
eo.homeY = eo.cellY
|
||||
if eo.def then
|
||||
eo.def.movementType = faceMt
|
||||
eo.def.movement = "STAY"
|
||||
eo.def.x = eo.cellX
|
||||
eo.def.y = eo.cellY
|
||||
eo.def.range = (eo.facing or "down"):upper()
|
||||
end
|
||||
if Objs.rememberPerm and Objs._mapId then
|
||||
Objs.rememberPerm(Objs._mapId, eo.localId, {
|
||||
x = eo.cellX,
|
||||
y = eo.cellY,
|
||||
movementType = faceMt,
|
||||
facing = eo.facing,
|
||||
})
|
||||
end
|
||||
eo.frozen = false
|
||||
eo.scriptBusy = false
|
||||
F.locked = false
|
||||
|
||||
@@ -531,7 +531,7 @@ local function cartSkin(imp, version)
|
||||
and shellColor(os.getenv("POKEPORT_CART_SHELL")) or nil
|
||||
local row = imp.activeCartRow and imp:activeCartRow(version) or nil
|
||||
local color = shellOverride or shellColor(info.cartShell)
|
||||
or (shape == "gba" and { 50, 171, 99 }) or cartColor(version)
|
||||
or (shape == "gba" and { 38, 162, 78 }) or cartColor(version)
|
||||
if not row then
|
||||
local sparkle, holo = finishFlags(STOCK_FINISH[version])
|
||||
return { cacheKey = prefix .. version, shape = shape,
|
||||
|
||||
@@ -530,7 +530,7 @@ function TrainerExtract.run(rom, cache, opts)
|
||||
"return { version = %d, trainerCount = %d, classCount = %d }\n",
|
||||
pack.version, pack.trainerCount, pack.classCount))
|
||||
|
||||
for gender = 0, 1 do
|
||||
for gender = 0, 5 do
|
||||
local rgba = bake_back_pic(rom, gender)
|
||||
if rgba then
|
||||
cache:write(root .. "/back_" .. gender .. ".rgba", rgba)
|
||||
|
||||
@@ -155,7 +155,8 @@ end
|
||||
-- WaitSFX (pokegold home/audio.asm); a test stub that calls a handler off
|
||||
-- the coroutine has no sfx to drain.
|
||||
local function drainSfx()
|
||||
if coroutine.running() then coroutine.yield({ kind = "waitsfx" }) end
|
||||
local co, isMain = coroutine.running()
|
||||
if co and not isMain then coroutine.yield({ kind = "waitsfx" }) end
|
||||
end
|
||||
|
||||
-- Every routine that ends `call GetPokemonName / jp
|
||||
|
||||
@@ -517,9 +517,10 @@ local function ensure_slot_sprites(i, mon, selected)
|
||||
Oam.setInvisible(slot.ball, PartyMenu.mode == "summary")
|
||||
end
|
||||
|
||||
local statusFr = PartyChrome.statusFrameFor(mon.status)
|
||||
local SummaryData = require("src.core.game3.summary_data")
|
||||
local statusFr = SummaryData.statusAilment(mon)
|
||||
local stImg, stQ = PartyChrome.statusEntry(statusFr)
|
||||
if statusFr > 0 and stImg then
|
||||
if statusFr > 0 and statusFr ~= 6 and stImg then
|
||||
if not slot.status then
|
||||
local id = select(1, Oam.createSprite({
|
||||
dims = Oam.HRECT_32x8,
|
||||
@@ -2122,7 +2123,13 @@ local function draw_filled_slot(i, mon, selected)
|
||||
if desc then party_print(desc, baseX + info.desc[1], baseY + info.desc[2], 64) end
|
||||
return
|
||||
end
|
||||
party_print("Lv" .. tostring(mon.level or 0), baseX + info.level[1], baseY + info.level[2], 32)
|
||||
local SummaryData = require("src.core.game3.summary_data")
|
||||
local ailment = SummaryData.statusAilment(mon)
|
||||
-- pokefirered/src/party_menu.c:2322 DisplayPartyPokemonLevelCheck:
|
||||
-- Level is only shown when the mon is healthy (or PKRS); status ailments replace level.
|
||||
if ailment == 0 or ailment == 6 then
|
||||
party_print("Lv" .. tostring(mon.level or 0), baseX + info.level[1], baseY + info.level[2], 32)
|
||||
end
|
||||
|
||||
local gender = mon.gender or (Pokemon.gender and Pokemon.gender(mon.species, mon.personality))
|
||||
local isNidoran = (mon.species == 29 or mon.species == 32)
|
||||
|
||||
@@ -38,7 +38,7 @@ local PAL = {
|
||||
railAmber = { 218, 145, 32 }, -- Gold cartridge (deeper metal)
|
||||
railSilver = { 190, 198, 210 }, -- Silver cartridge (cool light metal)
|
||||
railCrystal = { 132, 196, 228 }, -- Crystal cartridge (translucent ice blue)
|
||||
railLeafGreen = { 128, 188, 40 },
|
||||
railLeafGreen = { 38, 162, 78 }, -- LeafGreen cartridge (vibrant deep forest green)
|
||||
railFireRed = { 220, 48, 48 }, -- FireRed cartridge (deeper red than Red)
|
||||
}
|
||||
-- Semantic aliases kept so ported call sites read the same as before.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env luajit
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local failed = 0
|
||||
local function check(cond, msg)
|
||||
if cond then
|
||||
print("[ok] " .. msg)
|
||||
else
|
||||
failed = failed + 1
|
||||
print("[FAIL] " .. msg)
|
||||
end
|
||||
end
|
||||
|
||||
local Player = require("src.core.game3.m4a_player")
|
||||
local Seq = require("src.core.game3.m4a_seq")
|
||||
local Mix = require("src.core.game3.m4a_mix")
|
||||
local Audio = require("src.core.game3.audio")
|
||||
|
||||
print("[test] 1. Player.stopAt keeps active voices after fast-forward")
|
||||
local slot = {
|
||||
songId = 296,
|
||||
seq = {
|
||||
tracks = {
|
||||
{ pc = 1, vel = 100, callStack = {}, data = string.char(0x00, 0xB1) }
|
||||
},
|
||||
voices = {}
|
||||
},
|
||||
voices = {},
|
||||
frameSamplesLeft = 0,
|
||||
frameSampleCarry = 0,
|
||||
abs = 100000,
|
||||
}
|
||||
local snaps = {
|
||||
{
|
||||
at = 50000,
|
||||
seq = { tracks = { { pc = 1, vel = 100, callStack = {}, data = string.char(0x00, 0xB1) } }, mem = {} },
|
||||
done = false,
|
||||
frameSamplesLeft = 0,
|
||||
frameSampleCarry = 0,
|
||||
}
|
||||
}
|
||||
|
||||
local newAbs = Player.stopAt(slot, snaps, 60000, 100000)
|
||||
check(newAbs == 60000, "rewound abs to target at")
|
||||
|
||||
print("[test] 2. Player.stopAt handles timestamp older than retained snaps")
|
||||
local emptySnaps = {
|
||||
{
|
||||
at = 80000,
|
||||
seq = { tracks = { { pc = 15, vel = 100, callStack = {} } }, mem = {} },
|
||||
done = false,
|
||||
}
|
||||
}
|
||||
-- If at is 20000 (predates earliest snap of 80000)
|
||||
local fallbackAbs = Player.stopAt(slot, emptySnaps, 20000, 100000)
|
||||
check(fallbackAbs == 20000 or fallbackAbs == slot.abs, "stopAt safely handled out-of-range timestamp")
|
||||
|
||||
print("[test] 3. Audio.resumeBgm ensures bgmPaused is false")
|
||||
Audio._bgmPaused = true
|
||||
Audio.resumeBgm()
|
||||
check(Audio._bgmPaused == false, "resumeBgm cleared _bgmPaused")
|
||||
|
||||
if failed > 0 then
|
||||
print(string.format("\n%d check(s) FAILED", failed))
|
||||
os.exit(1)
|
||||
end
|
||||
print("\nall fanfare BGM resume checks passed")
|
||||
@@ -417,6 +417,43 @@ Player.reset(2, 5, "down")
|
||||
check(TrainerSight.check(dummyGame) == true, "TRAINER_TYPE_NORMAL (1) from def engages")
|
||||
resetField()
|
||||
|
||||
print("[test] 10. Wandering/Pacing Trainer (e.g. Route 3 Lass) Walk-Up and Permanent Stay Conversion")
|
||||
local pacer = {
|
||||
localId = 2, cellX = 40, cellY = 11, px = 640, py = 176, homeX = 40, homeY = 11,
|
||||
facing = "down", sight = 3, trainerType = 1,
|
||||
movement = "WALK", movementType = 0x03, range = "UP_DOWN", radius = { x = 1, y = 1 },
|
||||
elevation = 0, visible = true, hidden = false, moving = false, frozen = false, scriptBusy = false,
|
||||
scriptKey = "trainer_battle_01",
|
||||
def = { localId = 2, x = 40, y = 11, movementType = 0x03, movement = "WALK", range = "UP_DOWN" },
|
||||
}
|
||||
Objects.clear()
|
||||
Objects._byId[2] = pacer
|
||||
Objects._order = { 2 }
|
||||
-- Player stands 3 tiles below pacer at (40, 14)
|
||||
Player.reset(40, 14, "up")
|
||||
check(TrainerSight.check(dummyGame) == true, "Pacing trainer spots player 3 tiles away")
|
||||
check(Field.locked == true, "Field locked during exclamation and walk-up")
|
||||
|
||||
-- Simulate frames for exclamation effect and approach walk track
|
||||
for _ = 1, 100 do
|
||||
FieldEffects.step()
|
||||
Objects.update(dummyGame)
|
||||
end
|
||||
|
||||
check(pacer.cellX == 40 and pacer.cellY == 13, "Pacer walked 2 steps down to tile adjacent to player (40, 13)")
|
||||
check(pacer.movement == "STAY", "Pacer movement changed to STAY")
|
||||
check(pacer.movementType == 0x08, "Pacer movementType changed to MOVEMENT_TYPE_FACE_DOWN (0x08)")
|
||||
check(pacer.homeX == 40 and pacer.homeY == 13, "Pacer home coordinates updated to post-approach location (40, 13)")
|
||||
|
||||
-- Verify that subsequent idle updates do not cause the trainer to resume pacing
|
||||
pacer.idleTimer = 0
|
||||
for _ = 1, 10 do
|
||||
Objects.update(dummyGame)
|
||||
end
|
||||
check(pacer.moving == false and pacer.cellX == 40 and pacer.cellY == 13, "Trainer remains stationary and does not resume wander cycle")
|
||||
|
||||
resetField()
|
||||
|
||||
if failed > 0 then
|
||||
print(string.format("\n%d FAILURE(S)", failed))
|
||||
os.exit(1)
|
||||
|
||||
@@ -339,9 +339,14 @@ print("[test] 12. Complete 743 Trainer aiFlags ROM parity vs pokefirered/src/dat
|
||||
do
|
||||
local f = io.open("pokefirered/src/data/trainers.h", "r")
|
||||
if f then
|
||||
local okT, trainerPack = pcall(require, "data.generated.gba.trainers")
|
||||
if not okT or not trainerPack then
|
||||
f:close()
|
||||
print("[skip] data.generated.gba.trainers not found")
|
||||
return
|
||||
end
|
||||
local content = f:read("*a")
|
||||
f:close()
|
||||
local trainerPack = require("data.generated.gba.trainers")
|
||||
local trainerBlocks = {}
|
||||
local currentId = nil
|
||||
local currentAiFlags = 0
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
-- Unit tests for Game 3 battle experience participant tracking and distribution.
|
||||
-- Covers pret parity for in-battle switches, shift switches, enemy switches, and double battles.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local State = require("src.core.game3.battle.state")
|
||||
local Engine = require("src.core.game3.battle.engine")
|
||||
local SwitchSeq = require("src.core.game3.battle.switch_seq")
|
||||
local Experience = require("src.core.game3.battle.experience")
|
||||
local Damage = require("src.core.game3.battle.damage")
|
||||
local Battle = require("src.core.game3.battle.init")
|
||||
|
||||
local origExpYield = Experience.expYield
|
||||
Experience.expYield = function(species)
|
||||
local y = origExpYield(species)
|
||||
if y and y > 0 then return y end
|
||||
return 100
|
||||
end
|
||||
|
||||
local function check(cond, msg)
|
||||
if not cond then error("[FAIL] " .. tostring(msg), 2) end
|
||||
print("[PASS] " .. tostring(msg))
|
||||
end
|
||||
|
||||
local function eq(a, b, msg)
|
||||
if a ~= b then
|
||||
error(string.format("[FAIL] %s: expected %s, got %s", tostring(msg), tostring(b), tostring(a)), 2)
|
||||
end
|
||||
print("[PASS] " .. tostring(msg))
|
||||
end
|
||||
|
||||
print("=== 1. In-battle switch: both participants split EXP ===")
|
||||
do
|
||||
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
|
||||
local st = State.new({
|
||||
playerParty = { p1, p2 },
|
||||
foeParty = { e1 },
|
||||
foeMon = e1,
|
||||
wild = true,
|
||||
})
|
||||
|
||||
-- Slot 1 starts active. Switch to slot 2 during battle against e1.
|
||||
eq(st.enemy.participants[1], true, "slot 1 initially tracked as participant on e1")
|
||||
eq(st.enemy.participants[2], nil, "slot 2 not yet participant on e1")
|
||||
|
||||
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true })
|
||||
eq(st.enemy.participants[1], true, "slot 1 remained participant after switch")
|
||||
eq(st.enemy.participants[2], true, "slot 2 tracked as participant after switch")
|
||||
|
||||
local awards = Experience.awardFoe(st, st.enemy, { trainer = false })
|
||||
eq(#awards, 2, "both mons received awards")
|
||||
eq(awards[1].partyIndex, 1, "award 1 goes to slot 1")
|
||||
eq(awards[2].partyIndex, 2, "award 2 goes to slot 2")
|
||||
eq(awards[1].amount, awards[2].amount, "both mons received equal split of EXP")
|
||||
end
|
||||
|
||||
print("\n=== 2. Shift switch on enemy defeat: previous mons do NOT receive EXP for next enemy ===")
|
||||
do
|
||||
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local p3 = Damage.ensureStats({ species = 7, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
|
||||
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 1, maxHp = 30, exp = 1000 })
|
||||
local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
|
||||
local st = State.new({
|
||||
playerParty = { p1, p2, p3 },
|
||||
foeParty = { e1, e2 },
|
||||
foeMon = e1,
|
||||
wild = false,
|
||||
})
|
||||
|
||||
-- In fight against e1, player switches from p1 to p2
|
||||
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true })
|
||||
eq(st.enemy.participants[1], true, "p1 is participant on e1")
|
||||
eq(st.enemy.participants[2], true, "p2 is participant on e1")
|
||||
eq(st.enemy.participants[3], nil, "p3 is not participant on e1")
|
||||
|
||||
-- e1 faints, awards given
|
||||
local awards1 = Experience.awardFoe(st, st.enemy, { trainer = true })
|
||||
eq(#awards1, 2, "p1 and p2 both received EXP for e1")
|
||||
|
||||
-- Shift switch: player chooses to switch to p3 for e2
|
||||
SwitchSeq.beginShiftSwitch(st, 3, 2, { headless = true })
|
||||
|
||||
eq(st.player.partyIndex, 3, "p3 is now active player mon")
|
||||
eq(st.enemy.partyIndex, 2, "e2 is now active enemy mon")
|
||||
eq(st.enemy.participants[1], nil, "p1 is NOT participant on e2")
|
||||
eq(st.enemy.participants[2], nil, "p2 is NOT participant on e2")
|
||||
eq(st.enemy.participants[3], true, "only p3 is participant on e2")
|
||||
|
||||
-- e2 faints, awards given
|
||||
local awards2 = Experience.awardFoe(st, st.enemy, { trainer = true })
|
||||
eq(#awards2, 1, "only p3 receives EXP for e2")
|
||||
eq(awards2[1].partyIndex, 3, "award goes to p3")
|
||||
check(awards2[1].amount > awards1[1].amount, "p3 receives full undivided EXP for e2")
|
||||
end
|
||||
|
||||
print("\n=== 3. Enemy AI switch resets participant tracking ===")
|
||||
do
|
||||
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
|
||||
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
|
||||
local st = State.new({
|
||||
playerParty = { p1, p2 },
|
||||
foeParty = { e1, e2 },
|
||||
foeMon = e1,
|
||||
wild = false,
|
||||
})
|
||||
|
||||
-- Player switches to p2 against e1
|
||||
SwitchSeq.beginPlayerSwitch(st, 2, { headless = true })
|
||||
eq(st.enemy.participants[1], true, "p1 fought e1")
|
||||
eq(st.enemy.participants[2], true, "p2 fought e1")
|
||||
|
||||
-- Opponent trainer switches e1 out for e2
|
||||
SwitchSeq.beginSendOut(st, "enemy", 2, { headless = true })
|
||||
eq(st.enemy.partyIndex, 2, "e2 is now active")
|
||||
eq(st.enemy.participants[1], nil, "p1 is NOT a participant on e2")
|
||||
eq(st.enemy.participants[2], true, "only active mon p2 is participant on e2")
|
||||
|
||||
local awards = Experience.awardFoe(st, st.enemy, { trainer = true })
|
||||
eq(#awards, 1, "only p2 receives EXP for e2")
|
||||
eq(awards[1].partyIndex, 2, "award goes to p2")
|
||||
end
|
||||
|
||||
print("\n=== 4. Double battle participant tracking across replacements ===")
|
||||
do
|
||||
local p1 = Damage.ensureStats({ species = 1, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local p2 = Damage.ensureStats({ species = 4, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local p3 = Damage.ensureStats({ species = 7, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
|
||||
local e1 = Damage.ensureStats({ species = 16, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local e2 = Damage.ensureStats({ species = 19, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
local e3 = Damage.ensureStats({ species = 25, level = 10, hp = 30, maxHp = 30, exp = 1000 })
|
||||
|
||||
local st = State.new({
|
||||
playerParty = { p1, p2, p3 },
|
||||
foeParty = { e1, e2, e3 },
|
||||
wild = false,
|
||||
double = true,
|
||||
})
|
||||
|
||||
local foe1 = State.battler(st, 1)
|
||||
local foe3 = State.battler(st, 3)
|
||||
eq(foe1.participants[1], true, "foe1 tracks p1")
|
||||
eq(foe1.participants[2], true, "foe1 tracks p2")
|
||||
eq(foe3.participants[1], true, "foe3 tracks p1")
|
||||
eq(foe3.participants[2], true, "foe3 tracks p2")
|
||||
|
||||
-- Player switches battler 0 (p1) to p3
|
||||
State.updateSentPokes(st, { side = "player", partyIndex = 3 })
|
||||
eq(foe1.participants[3], true, "foe1 now tracks p3 as well")
|
||||
eq(foe3.participants[3], true, "foe3 now tracks p3 as well")
|
||||
|
||||
-- foe1 faints, replacement foe3 (e3) is sent out into slot 1
|
||||
local newFoe1 = State.makeBattler(st.foeParty[3], "enemy", { state = st, partyIndex = 3, id = 1 })
|
||||
st.battlers[1] = newFoe1
|
||||
State.opponentSwitchInResetSentPokes(st, newFoe1)
|
||||
|
||||
-- Currently active player mons are p2 (battler 2) and p3 (battler 0)
|
||||
st.battlers[0].partyIndex = 3
|
||||
st.battlers[2].partyIndex = 2
|
||||
State.opponentSwitchInResetSentPokes(st, newFoe1)
|
||||
|
||||
eq(newFoe1.participants[1], nil, "withdrawn p1 is NOT participant on replacement newFoe1")
|
||||
eq(newFoe1.participants[2], true, "active p2 is participant on newFoe1")
|
||||
eq(newFoe1.participants[3], true, "active p3 is participant on newFoe1")
|
||||
end
|
||||
|
||||
print("\n[ALL EXP PARTICIPANT TESTS PASSED! 100%]")
|
||||
@@ -40,6 +40,7 @@ if os.getenv("POKEPORT_IDENTITY") then
|
||||
print("[skip] fixture sections need a run without POKEPORT_IDENTITY: the identity cache is read first")
|
||||
else
|
||||
print("[test] 3. a missing cache degrades to synthetic labels")
|
||||
Multichoice.LISTS = {}
|
||||
check(Multichoice.tryLoadCache() == false, "no cache means no lists")
|
||||
local synthetic = Multichoice.resolve(4242, 3)
|
||||
check(#synthetic == 3, "an unknown list keeps the count hint (" .. #synthetic .. ")")
|
||||
@@ -57,6 +58,7 @@ else
|
||||
fixture:write('}\n')
|
||||
fixture:close()
|
||||
Extract.CACHE_ROOT = tmp
|
||||
Multichoice.LISTS = {}
|
||||
check(Multichoice.tryLoadCache() == true, "the cache table loads")
|
||||
local zero = Multichoice.resolve(0, 2)
|
||||
check(zero[1] == "CACHE YES" and zero[2] == "CACHE NO",
|
||||
|
||||
@@ -38,7 +38,11 @@ for _, case in ipairs({ { "win", 1, 0 }, { "ran", 4, 1 }, { "lose", 2, 1 },
|
||||
end
|
||||
print("PASS marowak_boolean_and_raw_outcomes")
|
||||
local Cache = require("tests.game3_cache")
|
||||
local bundle = assert(Cache.bundle("scripts/events.lua", { native = true }), Cache.reason)
|
||||
local bundle = Cache.bundle("scripts/events.lua", { native = true })
|
||||
if not bundle then
|
||||
print("[skip] game3_marowak_progression_test: " .. tostring(Cache.reason))
|
||||
os.exit(0)
|
||||
end
|
||||
local Vm = require("src.core.game3.scripting.vm")
|
||||
local Adapters = require("src.core.game3.scripting.adapters")
|
||||
local events = assert(bundle.events.FR_POKEMON_TOWER_6F.coordEvents)
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
-- Tests for Game 3 Old Man catching tutorial battle (pokefirered special StartOldManTutorialBattle)
|
||||
local function check(cond, msg)
|
||||
if not cond then error(msg or "check failed", 2) end
|
||||
print("[ok] " .. tostring(msg or "check passed"))
|
||||
end
|
||||
|
||||
local function eq(a, b, msg)
|
||||
if a ~= b then
|
||||
error(string.format("%s: expected %s, got %s", msg or "eq failed", tostring(b), tostring(a)), 2)
|
||||
end
|
||||
print("[ok] " .. tostring(msg or "eq passed"))
|
||||
end
|
||||
|
||||
print("[test] 1. TrainerPic.back supports back pic 5 (Old Man)")
|
||||
local TrainerPic = require("src.core.game3.trainer_pic")
|
||||
TrainerPic._back[0] = { image = "red", w = 64, h = 320 }
|
||||
TrainerPic._back[5] = { image = "old_man", w = 64, h = 256, frames = 4 }
|
||||
local oldManPic = TrainerPic.back(5)
|
||||
check(oldManPic ~= nil and oldManPic.image == "old_man", "TrainerPic.back(5) returns Old Man back pic entry")
|
||||
local clampedPic = TrainerPic.back(99)
|
||||
check(clampedPic ~= nil and clampedPic.image == "red", "TrainerPic.back(99) clamps to index 0")
|
||||
|
||||
print("[test] 2. Intro sequence configures Old Man back sprite and omits player mon sendout")
|
||||
local Battle = require("src.core.game3.battle.init")
|
||||
local IntroSeq = require("src.core.game3.battle.intro_seq")
|
||||
local Ui = require("src.core.game3.battle.ui")
|
||||
local CatchSeq = require("src.core.game3.battle.catch_seq")
|
||||
local Catching = require("src.core.game3.battle.catching")
|
||||
local Anim = require("src.core.game3.battle.anim")
|
||||
|
||||
local foe = {
|
||||
species = 13, -- WEEDLE
|
||||
name = "WEEDLE",
|
||||
level = 5,
|
||||
gender = "M",
|
||||
oldManTutorial = true,
|
||||
}
|
||||
|
||||
if Battle.isActive() then Battle.abort("win") end
|
||||
local ok = Battle.start({
|
||||
wild = true,
|
||||
headless = true,
|
||||
foe = foe,
|
||||
oldManTutorial = true,
|
||||
playerParty = {
|
||||
{ species = 1, level = 5, hp = 20, maxHp = 20, moves = { 33, 45 }, pp = { 35, 20 }, maxPp = { 35, 20 } }
|
||||
}
|
||||
})
|
||||
check(ok, "battle started")
|
||||
local st = Battle.getState()
|
||||
eq(st.oldManTutorial, true, "st.oldManTutorial is true")
|
||||
|
||||
IntroSeq.begin(st, { playerGender = 0 })
|
||||
|
||||
local stage = Anim.stage()
|
||||
eq(stage.trainer.player.gender, 5, "player trainer back sprite gender set to 5 (OLD MAN)")
|
||||
|
||||
local hasSendout = false
|
||||
local hasPlayerHealthbox = false
|
||||
for _, step in ipairs(IntroSeq._steps or {}) do
|
||||
if step.kind == "player_throw" then hasSendout = true end
|
||||
if step.kind == "healthbox" and step.data and step.data.side == "player" then
|
||||
hasPlayerHealthbox = true
|
||||
end
|
||||
end
|
||||
check(not hasSendout, "player mon sendout / throw step omitted")
|
||||
check(not hasPlayerHealthbox, "player healthbox omitted")
|
||||
|
||||
print("[test] 3. Action selection UI: prompt text and automated input")
|
||||
-- Test non-headless step timing
|
||||
Ui.reset({ headless = false })
|
||||
Ui.bindState(st)
|
||||
Ui.openMenu()
|
||||
eq(Ui._menuIndex, 1, "menu starts on FIGHT (index 1)")
|
||||
|
||||
-- 63 ticks on FIGHT
|
||||
for _ = 1, 63 do
|
||||
Ui.tick()
|
||||
end
|
||||
eq(Ui._menuIndex, 1, "still on FIGHT after 63 ticks")
|
||||
|
||||
-- 64th tick moves to BAG
|
||||
Ui.tick()
|
||||
eq(Ui._menuIndex, 2, "moves to BAG (index 2) on 64th tick")
|
||||
|
||||
-- 63 ticks on BAG
|
||||
for _ = 1, 63 do
|
||||
Ui.tick()
|
||||
end
|
||||
check(Ui.takeCommand() == nil, "no command produced before 64 ticks on BAG")
|
||||
|
||||
-- 64th tick on BAG selects Poké Ball
|
||||
Ui.tick()
|
||||
local cmd = Ui.takeCommand()
|
||||
check(cmd ~= nil, "command emitted after 64 ticks on BAG")
|
||||
eq(cmd.kind, "bag", "command is bag")
|
||||
eq(cmd.itemId, 4, "itemId is Poké Ball (4)")
|
||||
|
||||
-- Input lock test
|
||||
Ui.openMenu()
|
||||
local fakeInput = { wasPressed = function() return true end }
|
||||
local handled = Ui.handleInput(fakeInput)
|
||||
eq(handled, true, "player gamepad input is consumed/locked during tutorial")
|
||||
|
||||
print("[test] 4. Catch mechanics and messaging for Old Man tutorial")
|
||||
local session = {
|
||||
name = "RED",
|
||||
party = { { species = 1, level = 5, hp = 20, maxHp = 20 } },
|
||||
box = { {}, {} },
|
||||
bag = {}, -- empty bag
|
||||
pokedex = {},
|
||||
}
|
||||
|
||||
-- tryCatch always succeeds in tutorial
|
||||
local caught, shakes = Catching.tryCatch(4, st.enemy, st, session, function() return 999999 end)
|
||||
eq(caught, true, "Catching.tryCatch succeeds")
|
||||
eq(shakes, 4, "Catching.tryCatch yields 4 shakes")
|
||||
|
||||
-- CatchSeq messages
|
||||
local msgs = {}
|
||||
CatchSeq.begin(st, 4, true, 4, {
|
||||
headless = true,
|
||||
session = session,
|
||||
pushMsg = function(t) msgs[#msgs + 1] = t end,
|
||||
})
|
||||
|
||||
check(#msgs >= 2, "messages were emitted")
|
||||
eq(msgs[1], "OLD MAN used\nPOKé BALL!", "throw msg is 'OLD MAN used POKé BALL!'")
|
||||
eq(msgs[2], "Gotcha!\nWEEDLE was caught!", "catch msg is 'Gotcha! WEEDLE was caught!'")
|
||||
|
||||
-- Weedle not added to player party or dex
|
||||
eq(#session.party, 1, "player party unchanged (weedle not added)")
|
||||
check(session.pokedex[13] == nil, "pokedex unchanged")
|
||||
|
||||
-- Non-headless post-catch flow must skip nickname prompt
|
||||
Battle._headless = false
|
||||
Battle.startPostCatchFlow(nil)
|
||||
eq(Battle._phase, "ending", "startPostCatchFlow goes straight to ending phase (no nickname prompt)")
|
||||
eq(Battle._pendingEnd, "catch", "pendingEnd is catch")
|
||||
|
||||
print("[test] 5. Full battle headless execution runs to catch completion")
|
||||
if Battle.isActive() then Battle.abort("win") end
|
||||
local ok2 = Battle.start({
|
||||
wild = true,
|
||||
headless = true,
|
||||
foe = foe,
|
||||
oldManTutorial = true,
|
||||
playerParty = {
|
||||
{ species = 1, level = 5, hp = 20, maxHp = 20, moves = { 33 }, pp = { 35 }, maxPp = { 35 } }
|
||||
}
|
||||
})
|
||||
check(ok2, "second battle started")
|
||||
local res = Battle.runToEnd()
|
||||
eq(res, "catch", "battle concluded with catch outcome")
|
||||
|
||||
print("[test] 6. Old Man back pic extraction across FR 1.0, FR 1.1, LG 1.0, LG 1.1")
|
||||
do
|
||||
local mockLove = {
|
||||
image = { newImageData = function(w, h, fmt, data) return { data = data, w = w, h = h } end },
|
||||
graphics = { newImage = function(imgData) return { imgData = imgData } end },
|
||||
}
|
||||
local prevLove = _G.love
|
||||
_G.love = mockLove
|
||||
|
||||
local romFiles = {
|
||||
{ edition = "FireRed 1.0", file = "firered_dump.gba" },
|
||||
{ edition = "FireRed 1.1", file = "Pokemon - Fire Red Version (U) (V1.1).gba" },
|
||||
{ edition = "LeafGreen 1.0", file = "Pokemon - LeafGreen Version (USA).gba" },
|
||||
{ edition = "LeafGreen 1.1", file = "Pokemon - LeafGreen Version (USA, Europe) (Rev 1).gba" },
|
||||
}
|
||||
|
||||
for _, rf in ipairs(romFiles) do
|
||||
local f = io.open(rf.file, "rb")
|
||||
if f then
|
||||
local raw = f:read("*a")
|
||||
f:close()
|
||||
TrainerPic._rom = nil
|
||||
TrainerPic._front = {}
|
||||
TrainerPic._back = {}
|
||||
TrainerPic._cache = nil
|
||||
local pic = TrainerPic.back(5)
|
||||
check(pic ~= nil and pic.w == 64 and pic.h == 256 and pic.frames == 4,
|
||||
string.format("Old Man back pic extracted successfully for %s", rf.edition))
|
||||
end
|
||||
end
|
||||
|
||||
_G.love = prevLove
|
||||
end
|
||||
|
||||
print("[PASS] game3 old man tutorial battle tests")
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Tests for Game 3 S.S. Anne departure animation cutscene (DoSSAnneDepartureCutscene).
|
||||
-- Matches pret pokefirered (src/ss_anne.c, data/maps/SSAnne_Exterior/scripts.inc).
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local Std = require("src.core.game3.scripting.stdscripts")
|
||||
local Natives = require("src.core.game3.scripting.natives")
|
||||
local SSAnneCutscene = require("src.core.game3.ss_anne_cutscene")
|
||||
local Objects = require("src.core.game3.objects")
|
||||
|
||||
local playedSEs = {}
|
||||
local Audio = require("src.core.game3.audio")
|
||||
Audio.playSe = function(id)
|
||||
table.insert(playedSEs, id)
|
||||
end
|
||||
|
||||
local function assert_eq(a, b, msg)
|
||||
if a ~= b then
|
||||
error(string.format("%s: expected %s, got %s", msg or "assertion failed", tostring(b), tostring(a)))
|
||||
end
|
||||
end
|
||||
|
||||
local function assert_true(cond, msg)
|
||||
if not cond then
|
||||
error(msg or "expected true, got false")
|
||||
end
|
||||
end
|
||||
|
||||
print("[test] 1. Special constant and registration")
|
||||
assert_eq(Std.SPECIAL.DoSSAnneDepartureCutscene, 0x191, "DoSSAnneDepartureCutscene is 0x191 (401)")
|
||||
assert_true(Natives.ALLOW["special:" .. 0x191] ~= nil, "special 0x191 handler registered")
|
||||
print("[ok] special 0x191 correctly defined and registered")
|
||||
|
||||
print("[test] 2. S.S. Anne cutscene lifecycle and timing")
|
||||
playedSEs = {}
|
||||
|
||||
-- Mock the S.S. Anne boat event object (localId 1)
|
||||
Objects.clear()
|
||||
local mockBoat = {
|
||||
localId = 1,
|
||||
graphicsId = 151,
|
||||
cellX = 30,
|
||||
cellY = 16,
|
||||
px = 30 * 16,
|
||||
py = 16 * 16,
|
||||
visible = true,
|
||||
hidden = false,
|
||||
raiseX = 0,
|
||||
}
|
||||
Objects._byId[1] = mockBoat
|
||||
Objects._order = { 1 }
|
||||
|
||||
local task = SSAnneCutscene.start()
|
||||
assert_true(SSAnneCutscene.isActive(), "cutscene is active")
|
||||
assert_eq(SSAnneCutscene._phase, "init", "initial phase is init")
|
||||
assert_eq(#playedSEs, 1, "initial horn SE played")
|
||||
assert_eq(playedSEs[1], 249, "SE is SE_SS_ANNE_HORN (249)")
|
||||
|
||||
-- Init phase: 50 frames
|
||||
for f = 1, 49 do
|
||||
local done = task()
|
||||
assert_eq(done, false, "init phase not done at frame " .. f)
|
||||
assert_eq(SSAnneCutscene._phase, "init", "still init phase at frame " .. f)
|
||||
end
|
||||
|
||||
-- Frame 50 finishes init and enters run
|
||||
local done50 = task()
|
||||
assert_eq(done50, false, "not done at frame 50")
|
||||
assert_eq(SSAnneCutscene._phase, "run", "switched to run phase")
|
||||
assert_true(SSAnneCutscene._wake ~= nil, "wake sprite created")
|
||||
|
||||
print("[ok] init phase successfully executed 50 frames and spawned wake")
|
||||
|
||||
print("[test] 3. Run phase boat movement, smoke spawning, and wake animation")
|
||||
-- Tick 70 frames: first smoke puff spawned at 70th run frame
|
||||
for f = 1, 69 do
|
||||
task()
|
||||
assert_eq(#SSAnneCutscene._smokes, 0, "no smoke before 70 frames")
|
||||
end
|
||||
task() -- 70th run frame
|
||||
assert_eq(#SSAnneCutscene._smokes, 1, "smoke puff spawned at 70 frames")
|
||||
assert_eq(SSAnneCutscene._smokes[1].frame, 0, "smoke starts at frame 0")
|
||||
|
||||
-- Verify boat offset: at 70 run frames, offset = math.floor(70 / 5) = 14 px
|
||||
assert_eq(SSAnneCutscene._boatOffset, 14, "boat offset is 14 px at frame 70")
|
||||
assert_eq(mockBoat.raiseX, -14, "boat object raiseX is -14")
|
||||
|
||||
-- Verify wake animation
|
||||
assert_true(SSAnneCutscene._wake.x2 > 0, "wake drifting right relative to boat")
|
||||
|
||||
-- Advance to near exit (travel distance 216 px = 216 * 5 = 1080 frames)
|
||||
local prevSmokesCount = #SSAnneCutscene._smokes
|
||||
for f = 71, 1079 do
|
||||
task()
|
||||
end
|
||||
|
||||
assert_eq(SSAnneCutscene._phase, "run", "still run phase before distance reached")
|
||||
assert_true(#playedSEs == 1, "only 1 horn so far")
|
||||
|
||||
-- Frame 1080 reaches 216 px offset and triggers exit horn
|
||||
task()
|
||||
assert_eq(SSAnneCutscene._phase, "finish", "switched to finish phase")
|
||||
assert_eq(#playedSEs, 2, "second horn played on departure")
|
||||
assert_eq(playedSEs[2], 249, "departure horn is SE_SS_ANNE_HORN (249)")
|
||||
|
||||
print("[ok] run phase motion, smoke puffs, and departure horn verified")
|
||||
|
||||
print("[test] 4. Finish phase delay and clean completion")
|
||||
for f = 1, 39 do
|
||||
local done = task()
|
||||
assert_eq(done, false, "finish phase not done at frame " .. f)
|
||||
end
|
||||
|
||||
local finalDone = task() -- 40th frame of finish
|
||||
assert_eq(finalDone, true, "cutscene completed on 40th finish frame")
|
||||
assert_eq(SSAnneCutscene.isActive(), false, "cutscene inactive after completion")
|
||||
assert_eq(mockBoat.raiseX, 0, "boat raiseX reset")
|
||||
|
||||
print("[ok] finish phase completed after 40 frames and cleaned up state")
|
||||
|
||||
print("[PASS] game3 S.S. Anne departure cutscene tests")
|
||||
@@ -2,7 +2,7 @@ package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
require("tests.game3_cache").requireData("game3_surf_connections_test")
|
||||
require("src.core.GameVersion").set("firered")
|
||||
local Cache = require("tests.game3_cache")
|
||||
assert(Cache.mount("scripts/events.lua", { native = true }), Cache.reason)
|
||||
Cache.mountOrSkip("game3_surf_connections_test", "scripts/events.lua", { native = true })
|
||||
local Dataset = require("src.core.game3.dataset")
|
||||
local Collision = require("src.core.game3.collision")
|
||||
local Player = require("src.core.game3.player")
|
||||
|
||||
Reference in New Issue
Block a user