fix(game3): battle UI, Pyramid battles, Daycare, Faraway Mew, status timing, Solar Beam, and Seagallop cutscene

- Fixes #2667: Align FireRed battle UI text and cursor positions with vanilla layout.
- Fixes #2663: Fix Battle Pyramid double trainer sight crash and party resolution.
- Fixes #2651: Fix crash when picking up Pokemon at the daycare (native step-counting and exp/level sync).
- Fixes #2668: Fix Faraway Island Mew encounter and grass rustle collision/despawn state.
- Fixes #2647: Fix status display and Leech Seed absorption/healing sequence timing.
- Fixes #2673: Set animTurn = 1 for instant Solar Beam execution in sunlight.
- Fix Cinnabar Island Seagallop boat cutscene off-map culling and sprite fallback.
This commit is contained in:
1jamie
2026-10-04 11:00:11 -05:00
parent 340e2567ec
commit b4809beedb
18 changed files with 396 additions and 73 deletions
+11
View File
@@ -248,6 +248,12 @@ function Adapter.new(battleState, sayFn)
local Engine = package.loaded["src.core.game3.battle.engine"]
if Engine and Engine.cancelMultiTurnMoves then Engine.cancelMultiTurnMoves(battler) end
end
self:pushEvent({
kind = "status_apply",
battler = id_of(battler),
side = side_of(battler),
status = status,
})
-- pokefirered/src/battle_script_commands.c:2110
if ModRuntime.wants("battle.status_inflicted") then
ModRuntime.emit("battle.status_inflicted", {
@@ -264,6 +270,11 @@ function Adapter.new(battleState, sayFn)
battler.toxicCounter = nil
battler.sleepTurns = nil
if battler.mon then battler.mon.status = nil end
self:pushEvent({
kind = "status_clear",
battler = id_of(battler),
side = side_of(battler),
})
end
function a:stages(battler) return battler and battler.stages end
function a:changeStages(battler, changes)
+2
View File
@@ -138,6 +138,7 @@ local function default_present(id)
displayMaxHp = nil,
displayExp = nil,
displayLevel = nil,
displayStatus = nil,
flash = 0,
}
end
@@ -504,6 +505,7 @@ function Anim.syncDisplayFromState(st)
p.displayHp = tonumber(b.mon.hp) or 0
p.displayMaxHp = tonumber(b.mon.maxHp) or 1
p.displayLevel = tonumber(b.mon.level) or 1
p.displayStatus = b.status or (b.mon and (b.mon.status or b.mon.status1))
local prog = Experience.progress(b.mon)
p.displayExp = prog.progressPercent or 0
end
+20
View File
@@ -315,6 +315,10 @@ function AnimSeq.buildSteps(events, meta)
else
add("switch_in", d)
end
elseif k == "status_apply" then
add("status_apply", { side = ev.side, battler = ev_id(ev, "battler", "side"), status = ev.status })
elseif k == "status_clear" then
add("status_clear", { side = ev.side, battler = ev_id(ev, "battler", "side") })
elseif k == "end" then
add("end", { result = ev.result, reason = ev.reason })
end
@@ -660,6 +664,7 @@ local function run_switch_in(d)
p.displayHp = tonumber(d.hp) or tonumber(b.mon.hp) or 0
p.displayMaxHp = tonumber(b.mon.maxHp) or 1
p.displayLevel = tonumber(b.mon.level) or 1
p.displayStatus = b.status or (b.mon and (b.mon.status or b.mon.status1))
end
end
local SwitchSeq = require("src.core.game3.battle.switch_seq")
@@ -733,12 +738,27 @@ local function run_step(step)
launch_done()
return
end
if kind == "status_apply" then
local id = ev_id(d, "battler", "side")
local p = Anim.present(id)
if p then p.displayStatus = d.status end
advance()
return
end
if kind == "status_clear" then
local id = ev_id(d, "battler", "side")
local p = Anim.present(id)
if p then p.displayStatus = false end
advance()
return
end
if kind == "faint" then
local id = ev_id(d, "battler", "side") or 1
local p = Anim.present(id)
if p then
Anim.setSubstitute(id, false)
p.blinkHidden = false
p.displayStatus = false
end
wait_anim()
Anim.faintMon(id, {
+1 -4
View File
@@ -78,10 +78,7 @@ function Healing.rest(ctx)
if hp >= maxHp then return hp_full(ctx, user) end
local hadStatus = ad:status(user) ~= nil
ad:clearStatus(user)
user.status = "SLP"
if user.mon then user.mon.status = "SLP" end
-- pokefirered/src/battle_script_commands.c:6480
user.sleepTurns = 3
ad:applyStatus(user, "SLP", user, { force = true, turns = 3 })
if hadStatus then
ad:sayText("STRINGID_PKMNSLEPTHEALTHY", { atk = user })
else
+2
View File
@@ -935,6 +935,7 @@ local function charge_turn(M)
if eff == E.SOLAR_BEAM and Rules.weather.effective(M.st, ad) == "SUN" then
M:ppReduce()
M.noPP = true
M.animTurn = 1
return false
end
if ModRuntime.wantsHook("battle.charge_required") then
@@ -946,6 +947,7 @@ local function charge_turn(M)
if required == false then
M:ppReduce()
M.noPP = true
M.animTurn = 1
return false
end
end
+17 -14
View File
@@ -371,7 +371,9 @@ local function draw_doubles(id, battler, st, opts)
local SummaryChrome = require("src.ui.game3.summary_chrome")
local SummaryData = require("src.core.game3.summary_data")
local stObj = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1))
local p = Anim.present and Anim.present(id)
local stObj = (p and p.displayStatus ~= nil) and p.displayStatus or (battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)))
if stObj == false or stObj == 0 then stObj = nil end
local ailment = SummaryData.statusAilment({ status = stObj, hp = battler.mon and battler.mon.hp })
local statused = ailment >= 1 and ailment <= 6
local hpText = isPlayer and Healthbox.hpTextShown(st, id)
@@ -388,9 +390,7 @@ local function draw_doubles(id, battler, st, opts)
end
local name = State.displayName(battler)
local lv = battler.mon and battler.mon.level or 1
local p = Anim.present and Anim.present(id)
if p and p.displayLevel then lv = p.displayLevel end
local lv = (p and p.displayLevel) or (battler.mon and battler.mon.level) or 1
local ty = tlY + TEXT_Y
local gender = healthbox_gender(battler.mon)
-- pokefirered/src/battle_interface.c:1531
@@ -450,12 +450,21 @@ function Healthbox.draw(side, battler, opts)
return
end
local p = nil
do
local ok, AnimP = pcall(require, "src.core.game3.battle.anim")
if ok and AnimP and AnimP.present then
p = AnimP.present(side)
end
end
local barCx, barCy = hp_bar_center(side, c.x + ox, c.y)
local bx, by = hp_bar_top_left(barCx, barCy)
local statusBorder = false
if not isPlayer then
local SummaryData = require("src.core.game3.summary_data")
local st1 = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1))
local st1 = (p and p.displayStatus ~= nil) and p.displayStatus or (battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)))
if st1 == false or st1 == 0 then st1 = nil end
local a = SummaryData.statusAilment({ status = st1, hp = battler.mon and battler.mon.hp })
-- pokefirered/src/battle_interface.c:1668
statusBorder = a >= 1 and a <= 6
@@ -464,14 +473,7 @@ function Healthbox.draw(side, battler, opts)
BattleChrome.drawHpBar(bx, by, hpNow, hpMax, statusBorder)
local name = State.displayName(battler)
local lv = battler.mon and battler.mon.level or 1
do
local ok, AnimP = pcall(require, "src.core.game3.battle.anim")
if ok and AnimP and AnimP.present then
local p = AnimP.present(side)
if p and p.displayLevel then lv = p.displayLevel end
end
end
local lv = (p and p.displayLevel) or (battler.mon and battler.mon.level) or 1
local ty = tlY + TEXT_Y
local lvlX = isPlayer and PLAYER_LVL_X or ENEMY_LVL_X
@@ -493,7 +495,8 @@ function Healthbox.draw(side, battler, opts)
local SummaryChrome = require("src.ui.game3.summary_chrome")
local SummaryData = require("src.core.game3.summary_data")
local stObj = battler.status or (battler.mon and (battler.mon.status or battler.mon.status1))
local stObj = (p and p.displayStatus ~= nil) and p.displayStatus or (battler.status or (battler.mon and (battler.mon.status or battler.mon.status1)))
if stObj == false or stObj == 0 then stObj = nil end
local ailment = SummaryData.statusAilment({ status = stObj, hp = battler.mon and battler.mon.hp })
if isPlayer then
+4 -4
View File
@@ -2291,13 +2291,13 @@ local function draw_action_menu(st)
local labels = menu_labels((st and st.safari) and "gText_SafariZoneMenu" or "gText_BattleMenu")
draw_prompt_text(action_prompt(st, ab), 10, 122)
local positions = {
{ 136, 122 }, { 184, 122 },
{ 136, 138 }, { 184, 138 },
{ 136, 122 }, { 192, 122 },
{ 136, 138 }, { 192, 138 },
}
local c = Ui._menuIndex - 1
local cursorPos = {
{ 128, 122 }, { 176, 122 },
{ 128, 138 }, { 176, 138 },
{ 128, 122 }, { 184, 122 },
{ 128, 138 }, { 184, 138 },
}
local cp = cursorPos[c + 1] or cursorPos[1]
Window.cursorPx(cp[1], cp[2], { colors = FrlgFont.COLOR.NORMAL })
+3 -3
View File
@@ -383,10 +383,10 @@ function BattleBridge.start(mod, game, foe, opts)
end
if #battleParty == 0 then return nil, "empty party" end
local foeHalf
if opts.twoOpponents and opts.trainerIdB and not opts.wild then
foe, foeHalf = BattleBridge.twoOpponentFoe(foe, opts.trainerIdB)
elseif opts.twoOpponents and opts.frontierFoeHalf and not opts.wild then
if opts.twoOpponents and opts.frontierFoeHalf and not opts.wild then
foeHalf = opts.frontierFoeHalf
elseif opts.twoOpponents and opts.trainerIdB and not opts.wild then
foe, foeHalf = BattleBridge.twoOpponentFoe(foe, opts.trainerIdB)
end
local isDouble = (not opts.wild) and (opts.double or foeHalf or (foe and foe.doubleBattle)) and true or false
if isDouble and Party.monsStateToDoubles(linkParty or session.party) ~= Party.PLAYER_HAS_TWO_USABLE_MONS then
+32 -14
View File
@@ -166,25 +166,43 @@ function Daycare.teachMove(mon, moveId)
mon.moves = mon.moves or {}
mon.pp = mon.pp or {}
mon.maxPp = mon.maxPp or {}
for i = 1, #mon.moves do
if mon.moves[i] == moveId then return false end
local moves = {}
local pps = {}
local maxPps = {}
for i = 1, MAX_MON_MOVES do
local m = tonumber(mon.moves[i]) or 0
if m > 0 then
if m == moveId then return false end
moves[#moves + 1] = m
pps[#pps + 1] = tonumber(mon.pp[i]) or 0
maxPps[#maxPps + 1] = tonumber(mon.maxPp[i]) or 0
end
end
local Pokemon = pokemonMod()
local maxPp = 0
if Pokemon.movePp then maxPp = tonumber(Pokemon.movePp(moveId)) or 0 end
if #mon.moves < MAX_MON_MOVES then
mon.moves[#mon.moves + 1] = moveId
mon.pp[#mon.moves] = maxPp
mon.maxPp[#mon.moves] = maxPp
return true
if #moves < MAX_MON_MOVES then
moves[#moves + 1] = moveId
pps[#pps + 1] = maxPp
maxPps[#maxPps + 1] = maxPp
else
-- pokefirered/src/daycare.c:495 DeleteFirstMoveAndGiveMoveToMon
table.remove(moves, 1)
table.remove(pps, 1)
table.remove(maxPps, 1)
moves[MAX_MON_MOVES] = moveId
pps[MAX_MON_MOVES] = maxPp
maxPps[MAX_MON_MOVES] = maxPp
end
for i = 1, MAX_MON_MOVES do
mon.moves[i] = moves[i] or 0
mon.pp[i] = pps[i] or 0
mon.maxPp[i] = maxPps[i] or 0
end
-- pokefirered/src/daycare.c:495 DeleteFirstMoveAndGiveMoveToMon
table.remove(mon.moves, 1)
table.remove(mon.pp, 1)
table.remove(mon.maxPp, 1)
mon.moves[MAX_MON_MOVES] = moveId
mon.pp[MAX_MON_MOVES] = maxPp
mon.maxPp[MAX_MON_MOVES] = maxPp
return true
end
+12 -3
View File
@@ -34,13 +34,22 @@ local function counter()
end
local function mewGfx()
local Constants = lazyReq("src.core.game3.constants")
return Constants.of(Constants.versionOf(session())):require("event_objects", "OBJ_EVENT_GFX_MEW")
local ok, res = pcall(function()
local Constants = lazyReq("src.core.game3.constants")
return Constants.of(Constants.versionOf(session())):require("event_objects", "OBJ_EVENT_GFX_MEW")
end)
return ok and res or nil
end
-- pokeemerald/src/faraway_island.c:335
function FarawayIsland.isMew(eo)
return onMap() and eo ~= nil and tonumber(eo.graphicsId) == mewGfx()
if not (onMap() and eo) then return false end
local mgfx = mewGfx()
local gid = tonumber(eo.graphicsId)
local dgid = eo.def and tonumber(eo.def.graphicsId or eo.def.gfx)
if mgfx and (gid == mgfx or dgid == mgfx) then return true end
if eo.graphicsId == "OBJ_EVENT_GFX_MEW" or (eo.def and eo.def.graphicsId == "OBJ_EVENT_GFX_MEW") then return true end
return false
end
-- pokeemerald/src/faraway_island.c:347
+8
View File
@@ -901,6 +901,14 @@ function Field.interact(game)
-- 1) EventObject (nurse behind counter uses doubled cell; Cut tree / Rock / Boulder)
local ox, oy = facing_object_cell(fx, fy, P.facing)
local eo = Objects.at(ox, oy)
if not eo then
local under = Objects.at(P.cellX, P.cellY)
local Faraway = package.loaded["src.core.game3.faraway_island"]
if under and (under.copy or (Faraway and Faraway.isMew and Faraway.isMew(under))) then
eo = under
ox, oy = P.cellX, P.cellY
end
end
if eo and eo.def then
local gfx = eo.def.graphicsId or eo.def.gfx
local FP = lazyReq("src.core.game3.profile").forSession(Field._session)
+2 -2
View File
@@ -418,8 +418,8 @@ local function collectNeighborActors(actors, baseIndex, hostMapId, hostDef, camX
local p = nb.perm[lid]
local ox = p and p.x or tonumber(obj.x) or 0
local oy = p and p.y or tonumber(obj.y) or 0
local out = bounds and (ox < 0 or oy < 0
or ox >= bounds.w or oy >= bounds.h)
local out = bounds and (ox < -16 or oy < -16
or ox >= bounds.w + 16 or oy >= bounds.h + 16)
-- src/event_object_movement.c:8014
if tonumber(obj.movementType) == 0x4C then out = true end
local gid = obj.graphicsId or obj.graphics
+1 -1
View File
@@ -844,7 +844,7 @@ function Objects.forDraw()
local eo = Objects._byId[lid]
-- src/event_object_movement.c:8014
if eo and eo.visible and not eo.hidden and not eo.invisible
and (eo.foreignMap ~= nil or not offMap(Objects._bounds, eo)) then
and (eo.foreignMap ~= nil or eo.moving or eo.scriptBusy or (Objects._tracks and Objects._tracks[eo.localId] ~= nil) or not offMap(Objects._bounds, eo)) then
n = n + 1
list[n] = eo
end
+2
View File
@@ -44,6 +44,8 @@ GfxIds.TO_SPRITE = {
[88] = "SPRITE_MOM", -- player's PC / mom-adjacent
[89] = "SPRITE_SUPER_NERD", -- CELIO
[92] = "SPRITE_POKE_BALL",
[108] = "SPRITE_SAILOR", -- OBJ_EVENT_GFX_SEAGALLOP fallback
[109] = "SPRITE_SAILOR", -- OBJ_EVENT_GFX_SS_ANNE fallback
}
local S, N, W, E = "down", "up", "left", "right"
+8 -3
View File
@@ -1,4 +1,3 @@
local Strings = require("src.core.Strings")
local Std = require("src.core.game3.scripting.stdscripts")
local Model = require("src.core.game3.daycare")
local Breeding = require("src.core.game3.breeding")
@@ -202,7 +201,10 @@ Daycare.BY_NAME = {
return false, SPECIES_NONE
end
setStringVar(ctx, adapters, 1, nicknameOf(mon))
return false, Model.take(session, index)
local species = Model.take(session, index)
local speciesId = tonumber(species) or tonumber(mon.species or mon.speciesId) or 0
setResult(ctx, speciesId)
return false, speciesId
end,
-- pokefirered/src/daycare.c:1588 TakePokemonFromRoute5Daycare
TakePokemonFromRoute5Daycare = function(ctx, adapters)
@@ -219,7 +221,10 @@ Daycare.BY_NAME = {
return false, SPECIES_NONE
end
setStringVar(ctx, adapters, 1, nicknameOf(mon))
return false, Model.takeRoute5(session)
local species = Model.takeRoute5(session)
local speciesId = tonumber(species) or tonumber(mon.species or mon.speciesId) or 0
setResult(ctx, speciesId)
return false, speciesId
end,
-- pokefirered/src/daycare.c:594 GetDaycareCost
GetDaycareCost = function(ctx, adapters)
+133 -25
View File
@@ -560,14 +560,61 @@ function TrainerSight.engagePair(game, a, b)
local Sp = Space()
F.locked = true
a.eo.frozen, b.eo.frozen = true, true
local tidA, tidB = TrainerSight.getTrainerId(a.eo), TrainerSight.getTrainerId(b.eo)
local okPyr, Pyramid = pcall(require, "src.core.game3.rse.frontier.pyramid")
local inPyramid = okPyr and Pyramid and Pyramid.inPyramid and Pyramid.inPyramid()
local tidA, tidB
local sess = nil
local D = nil
local partyA, partyB = {}, {}
local combinedParty = {}
local half = 0
local foeAInfo, foeBInfo = {}, {}
local nameA, nameB = "", ""
local defeatTextA, defeatTextB = nil, nil
if inPyramid then
local Rse = require("src.core.game3.rse.init")
sess = Rse.session()
D = require("src.core.game3.rse.frontier.data")
local F_FACILITY = D.FACILITY
tidA = Pyramid.localIdToTrainerId(sess, a.eo.localId)
tidB = Pyramid.localIdToTrainerId(sess, b.eo.localId)
sess.frontierOpponentA = tidA
sess.frontierOpponentB = tidB
D.fillTrainerParty(sess, tidA, 0, 1, partyA, { facility = F_FACILITY.PYRAMID })
D.fillTrainerParty(sess, tidB, 0, 1, partyB, { facility = F_FACILITY.PYRAMID })
for _, m in ipairs(partyA) do combinedParty[#combinedParty + 1] = m end
half = #combinedParty
for _, m in ipairs(partyB) do combinedParty[#combinedParty + 1] = m end
foeAInfo = D.trainerClass(sess, tidA, F_FACILITY.PYRAMID) or {}
foeBInfo = D.trainerClass(sess, tidB, F_FACILITY.PYRAMID) or {}
nameA = D.trainerName(sess, tidA, F_FACILITY.PYRAMID) or ""
nameB = D.trainerName(sess, tidB, F_FACILITY.PYRAMID) or ""
defeatTextA = Pyramid.speech(sess, tidA, 1)
defeatTextB = Pyramid.speech(sess, tidB, 1)
else
tidA = TrainerSight.getTrainerId(a.eo)
tidB = TrainerSight.getTrainerId(b.eo)
end
local rowA, rowB = battleRow(a.eo), battleRow(b.eo)
TrainerSight._pair = { a = tidA, b = tidB }
local function finishBattle(result)
local store = Sp and Sp.store
local ctx = Sp and Sp.vm and Sp.vm.ctx
TrainerSight._pair = nil
F.locked = false
a.eo.frozen, b.eo.frozen = false, false
if inPyramid then
Pyramid.markBattled(sess, tidA, a.eo.localId)
Pyramid.markBattled(sess, tidB, b.eo.localId)
local N = require("src.core.game3.scripting.natives")
sess.battleOutcome = N.outcome_to_code(result or "win")
return
end
if result == "lose" or result == "whiteout" or result == "blackout" then return end
-- pokeemerald/src/battle_setup.c:1245
for _, tid in ipairs({ tidB, tidA }) do
@@ -593,41 +640,102 @@ function TrainerSight.engagePair(game, a, b)
end
end
end
local function startBattle()
local okT, Trainers = pcall(require, "src.core.game3.scripting.trainers")
local foe = okT and Trainers.foeFromId(tidA)
local dlgA, dlgB = Trainers.dialogs(tidA) or {}, Trainers.dialogs(tidB) or {}
local function text(row, key, dlg)
if row and row[key] and Sp and Sp.vm and Sp.vm.getText then
local t = Sp.vm:getText(row[key])
if t then return t end
end
return dlg
end
local Runtime = package.loaded["src.core.game3.runtime"] or require("src.core.game3.runtime")
local foe
local battleOpts
if inPyramid then
foe = {
party = combinedParty,
trainerId = tidA,
trainerClass = foeAInfo.class,
trainerClassName = foeAInfo.className,
trainerName = nameA,
trainerPicId = foeAInfo.pic,
}
battleOpts = {
wild = false,
trainerId = tidA,
trainerIdB = tidB,
twoOpponents = true,
double = true,
frontierFoeHalf = half,
pyramid = true,
frontier = true,
frontierTrainer = { class = foeAInfo.class, className = foeAInfo.className, name = nameA, pic = foeAInfo.pic },
frontierTrainerB = { class = foeBInfo.class, className = foeBInfo.className, name = nameB, pic = foeBInfo.pic },
defeatText = defeatTextA,
defeatTextB = defeatTextB,
transitionId = D and D.specialTransition(sess, "B_PYRAMID", combinedParty) or nil,
done = finishBattle,
}
else
local okT, Trainers = pcall(require, "src.core.game3.scripting.trainers")
foe = okT and Trainers.foeFromId(tidA)
local dlgA, dlgB = Trainers.dialogs(tidA) or {}, Trainers.dialogs(tidB) or {}
local function text(row, key, dlg)
if row and row[key] and Sp and Sp.vm and Sp.vm.getText then
local t = Sp.vm:getText(row[key])
if t then return t end
end
return dlg
end
battleOpts = {
wild = false,
trainerId = tidA,
trainerIdB = tidB,
twoOpponents = true,
double = true,
defeatText = text(rowA, "defeatText", dlgA.defeat),
defeatTextB = text(rowB, "defeatText", dlgB.defeat),
done = finishBattle,
}
end
-- pokeemerald/src/battle_setup.c:1272
local ok, err = require("src.core.game3.battle_bridge").start(Runtime._mod, game or Runtime._game, foe, {
wild = false,
trainerId = tidA,
trainerIdB = tidB,
twoOpponents = true,
double = true,
defeatText = text(rowA, "defeatText", dlgA.defeat),
defeatTextB = text(rowB, "defeatText", dlgB.defeat),
done = finishBattle,
})
local ok, err = require("src.core.game3.battle_bridge").start(Runtime._mod, game or Runtime._game, foe, battleOpts)
if not ok then
print("[game3/trainer_sight] two-trainer battle did not start: " .. tostring(err))
finishBattle("lose")
end
end
playEncounterMusic(tidA, rowA)
local function showIntroA(onDone)
if inPyramid then
local introA = Pyramid.speech(sess, tidA, 0)
require("src.ui.game3.hud").openMessage(nil, introA, { done = onDone })
else
introSpeech(a.eo, tidA, rowA, onDone)
end
end
local function showIntroB(onDone)
if inPyramid then
local introB = Pyramid.speech(sess, tidB, 0)
require("src.ui.game3.hud").openMessage(nil, introB, { done = onDone })
else
introSpeech(b.eo, tidB, rowB, onDone)
end
end
if inPyramid then
local Audio = require("src.core.game3.audio")
Audio.playSong(Pyramid.encounterMusic(sess, tidA))
else
playEncounterMusic(tidA, rowA)
end
approach(a.eo, a.dist, a.facing, function()
introSpeech(a.eo, tidA, rowA, function()
showIntroA(function()
-- pokeemerald/src/trainer_see.c:666
playEncounterMusic(tidB, rowB)
if inPyramid then
local Audio = require("src.core.game3.audio")
Audio.playSong(Pyramid.encounterMusic(sess, tidB))
else
playEncounterMusic(tidB, rowB)
end
approach(b.eo, b.dist, b.facing, function()
introSpeech(b.eo, tidB, rowB, startBattle)
showIntroB(startBattle)
end)
end)
end)
+27
View File
@@ -776,6 +776,33 @@ do
check(st.player.twoTurnMove == nil, "twoTurnMove is cleared after unleash")
end
do
-- Solarbeam in Sun (e.g. Groudon Drought) fires in 1 turn and uses unleash animation (turn=1)
local sbId = Moves.numForName("SOLARBEAM") or Moves.numForName("SOLAR_BEAM")
local st = State.new({
wild = true,
weather = "SUN",
playerParty = { { species = 383, level = 50, hp = 200, maxHp = 200, moves = { sbId }, pp = { 10 },
attack = 150, defense = 140, spAtk = 100, spDef = 90, speed = 90 } },
foeMon = { species = 19, level = 20, hp = 100, maxHp = 100, moves = { 33 }, pp = { 35 },
attack = 20, defense = 50, spAtk = 20, spDef = 50, speed = 20 },
})
local ad = setup_test_battle(st)
local out = {}
Engine.resolveMove(st.player, st.enemy, sbId, 1, ad, st, out)
check(ad:hp(st.enemy) < 100, "Foe takes damage immediately in Sun without a charge turn")
check(st.player.twoTurnMove == nil, "No twoTurnMove locked in Sun")
local moveEvents = {}
for _, ev in ipairs(ad:events()) do
if ev.kind == "move" and ev.moveId == sbId then
moveEvents[#moveEvents + 1] = ev
end
end
check(#moveEvents == 1, "Exactly one move event generated for instant Solar Beam")
check(moveEvents[1] and moveEvents[1].turn == 1,
"Solar Beam animation event uses turn=1 (unleash beam), got turn=" .. tostring(moveEvents[1] and moveEvents[1].turn))
end
do
-- Skull Bash raises defense on charge turn 1
local sbashId = Moves.numForName("SKULL_BASH")
+111
View File
@@ -0,0 +1,111 @@
#!/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 Objects = require("src.core.game3.objects")
local GfxIds = require("src.core.game3.scripting.gfx_ids")
local Flags = require("src.core.game3.scripting.flags")
local Space = require("src.core.game3.scripting.space")
print("[test] 1. GfxIds maps OBJ_EVENT_GFX_SEAGALLOP (108)")
local sprName = GfxIds.spriteFor(108)
check(sprName == "SPRITE_SAILOR", "108 maps to SPRITE_SAILOR fallback (got " .. tostring(sprName) .. ")")
print("[test] 2. offMap bounds checking")
local bounds = { w = 24, h = 20 }
check(not Objects.offMap(bounds, { cellX = 10, cellY = 12 }), "x=10 is on-map")
check(not Objects.offMap(bounds, { cellX = 0, cellY = 0 }), "x=0, y=0 is on-map")
check(Objects.offMap(bounds, { cellX = 24, cellY = 12 }), "x=24 is off-map on 24x20 map")
check(Objects.offMap(bounds, { cellX = -1, cellY = 12 }), "x=-1 is off-map")
print("[test] 3. Cinnabar Island Seagallop spawn & arrival movement")
local cinnabarMapDef = {
midLayout = { width = 24, height = 20 },
objects = {
{ localId = 1, graphicsId = 23, x = 14, y = 6, movementType = 5, flag = 0 },
{ localId = 2, graphicsId = 32, x = 11, y = 11, movementType = 1, flag = 0 },
{ localId = 3, graphicsId = 73, x = 20, y = 7, movementType = 10, flag = 0x27 }, -- Bill
{ localId = 4, graphicsId = 108, x = 23, y = 7, elevation = 1, movementType = 9, flag = 0x28 }, -- Seagallop
}
}
local store = Flags.newStore()
Flags.setFlag(store, nil, 0x28, true) -- FLAG_HIDE_CINNABAR_SEAGALLOP = true
Space.store = store
Space.mapId = "CinnabarIsland"
Objects.clear()
Objects.loadMap(nil, "CinnabarIsland", cinnabarMapDef)
-- Initially Seagallop is hidden by flag 0x28
local drawList = Objects.forDraw()
local foundSeagallop = false
for _, eo in ipairs(drawList) do
if eo.localId == 4 then foundSeagallop = true end
end
check(not foundSeagallop, "Seagallop is initially hidden before addobject")
-- Script runs: setobjectxyperm 4, 30, 12 then addobject 4
Objects.setObjectXY(4, 30, 12)
Objects.addObject(4)
local boatEo = Objects.find(4)
check(boatEo and boatEo.cellX == 30 and boatEo.cellY == 12, "Seagallop coordinates set to (30, 12)")
-- Script runs: applymovement 4, CinnabarIsland_Movement_BoatArrive (5 steps left: 30 -> 25)
local arriveBytes = {
0x1B, -- delay_16
0x1B, -- delay_16
0x1F, -- walk_fast_left
0x1F, -- walk_fast_left
0x12, -- walk_left
0x0A, -- walk_slower_left
0x0A, -- walk_slower_left
0xFE -- step_end
}
local movementFinished = false
Objects.applyMovement(4, arriveBytes, function()
movementFinished = true
end)
drawList = Objects.forDraw()
foundSeagallop = false
for _, eo in ipairs(drawList) do
if eo.localId == 4 then foundSeagallop = true end
end
check(foundSeagallop, "Seagallop moving from off-map (30, 12) is included in forDraw() while tracked")
-- Step through frames until movement completes
local maxFrames = 300
local frames = 0
while not movementFinished and frames < maxFrames do
Objects.update(nil)
frames = frames + 1
end
check(movementFinished, "Boat arrival movement completed in " .. frames .. " frames")
check(boatEo and boatEo.cellX == 25 and boatEo.cellY == 12,
"Boat arrived at shoreline (25, 12), got (" .. tostring(boatEo and boatEo.cellX) .. ", " .. tostring(boatEo and boatEo.cellY) .. ")")
drawList = Objects.forDraw()
foundSeagallop = false
for _, eo in ipairs(drawList) do
if eo.localId == 4 then foundSeagallop = true end
end
check(foundSeagallop, "Seagallop at (25, 12) is visible and drawn at shoreline")
if failed > 0 then
print("\nFAIL: " .. failed .. " assertions failed")
os.exit(1)
else
print("\nPASS: all Cinnabar Seagallop tests passed")
end