CLOSES #644, CLOSES #703, CLOSES #726, CLOSES #737, CLOSES #750, CLOSES #752, CLOSES #764, CLOSES #765, CLOSES #768, CLOSES #773, CLOSES #774, CLOSES #775, CLOSES #777, CLOSES #780, CLOSES #782

This commit is contained in:
bryanthaboi
2026-08-04 09:25:28 -04:00
parent 6cfc85ca83
commit f2d9b74490
52 changed files with 3099 additions and 252 deletions
+85 -14
View File
@@ -528,6 +528,10 @@ BattleState.StatBox = StatBox -- the level-up stat window (PrintStatsBox)
local function newBattle(game)
local self = setmetatable({}, BattleState)
self.game = game
-- InitBattleVariables (engine/battle/init_battle_variables.asm) zeroes
-- wPartyAndBillsPCSavedMenuItem, so entering a battle drops the party
-- cursor the field menu has been carrying (src/ui/PartyMenu.lua). #768
game.partyMenuSavedIndex = nil
self.data = game.data
-- ruleset from the merged registry (the requires above are the same
-- records on a mod-free boot); an unknown save value falls back to the
@@ -644,6 +648,9 @@ function BattleState.newTrainer(game, oppClass, partyIndex)
local self = newBattle(game)
self.kind = "trainer"
self.oppClass = oppClass
-- the object_event trainer arg (roster index). computeMusicKind keys
-- data/scripts/victories.lua on class#party, so keep it on the battle (#782).
self.partyIndex = partyIndex or 1
self.trainer = game.data.trainers[oppClass]
assert(self.trainer, "unknown trainer class " .. tostring(oppClass))
-- pret GetTrainerName_: RIVAL1/2/3 copy wRivalName into wTrainerName
@@ -824,6 +831,16 @@ function BattleState:say(text)
table.insert(self.queue, { text = text })
end
-- A message whose ROM tail is `text_end` / `done` rather than `prompt`:
-- NextTextCommand returns straight out of PrintText on TX_END
-- (home/text.asm:328-334) and only TX_PROMPT_BUTTON blinks the arrow and
-- runs ManualTextScroll (home/text.asm:434-446), so these pages never wait
-- on the player. autoDelay is the frame hold before the queue moves on
-- (0 = the next row starts immediately, as PrintText returning does) (#765).
function BattleState:sayAuto(text, delay)
table.insert(self.queue, { text = text, auto = true, autoDelay = delay or 0 })
end
-- Message that opens YES/NO once typed out, keeping the text visible
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
function BattleState:sayChoice(text, onChoose)
@@ -864,6 +881,13 @@ function BattleState:sayNext(text)
table.insert(self.queue, self.nextInsert, { text = text })
end
-- sayNext for a page that ends in `text_end` (see sayAuto) (#765)
function BattleState:sayNextAuto(text, delay)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert,
{ text = text, auto = true, autoDelay = delay or 0 })
end
-- insert a UI push right after the current queue item (dex page, the
-- level-up stat box -- anything that must keep queue order)
function BattleState:uiNext(factory)
@@ -1014,6 +1038,8 @@ function BattleState:startMessage(item)
self.charIndex = 0
self.msgWaiting = nil
self.msgPrompt = nil
self.msgAutoWait = nil
self.msgHold = nil
self.scrollPx = nil
self:beginMsgLine()
end
@@ -1268,7 +1294,25 @@ function BattleState:updateQueue()
end))
return true
end
if not (item and item.choice) then
if item and item.auto then
-- No prompt: this page's ROM tail is `text_end`, so PrintText is
-- already back with the box still on screen -- pokered's used-move
-- line (engine/battle/used_move_text.asm EndUsedMove1Text..
-- EndUsedMove5Text) and the item-use line (ItemUseText00,
-- engine/items/item_effects.asm) are both of that kind. Only
-- TX_PROMPT_BUTTON waits on A/B (home/text.asm:434-446) (#765).
self.msgAutoWait = self.msgAutoWait or item.autoDelay or 0
if self.msgAutoWait > 0 then
self.msgAutoWait = self.msgAutoWait - 1
else
self.msgAutoWait = nil
-- the typed page stays drawn behind whatever runs next (the move
-- animation, the ball toss): PrintText leaves the textbox tilemap
-- alone and animations only touch sprites (#296)
self.msgHold = true
self.current = nil
end
elseif not (item and item.choice) then
-- The page is typed out and waiting on the player: PromptText
-- (home/text.asm:209-217) writes '▼' at (18,16) and ManualTextScroll
-- blinks it until A/B, so the arrow belongs on a finished page and not
@@ -1320,14 +1364,17 @@ end
-- gets the final-battle theme
function BattleState:computeMusicKind()
local isBoss = false
if self.kind == "trainer" and self.trainer then
if self.kind == "trainer" and self.oppClass then
-- wGymLeaderNo is written only by the eight gym scripts
-- (scripts/PewterGym.asm .. ViridianGym.asm), so the badge rosters in
-- victories.lua are exactly the fights that set it. The lookup must
-- include the party index: a class-wide prefix match also caught
-- Giovanni's Rocket Hideout (#1) and Silph Co (#2) battles, which never
-- touch wGymLeaderNo and take MUSIC_TRAINER_BATTLE like any other
-- trainer (#782).
local victories = require("data.scripts.victories")
for key, reward in pairs(victories) do
if reward.badge and key:find(self.trainer.id .. "#", 1, true) == 1 then
isBoss = true
break
end
end
local reward = victories[self.oppClass .. "#" .. tostring(self.partyIndex or 1)]
isBoss = reward ~= nil and reward.badge ~= nil
end
-- init_battle.asm: challenging a gym leader (wGymLeaderNo, the badge
-- fights only -- not Lance or the Champion) bumps the companion's
@@ -1575,6 +1622,9 @@ end
-- (end_of_battle.asm clears wLowHealthAlarm when a battle ends)
function BattleState:exit()
require("src.core.Sound").stopLoop("Low_Health_Alarm")
-- end_of_battle.asm clears wPartyAndBillsPCSavedMenuItem as well, so the
-- field party menu comes back on slot 1 after a battle. #768
self.game.partyMenuSavedIndex = nil
-- Free this battle's own GPU objects now rather than waiting on a GC
-- finalizer: the two full-screen wavy-effect canvases (colorMode) and
-- the AnimPlayer's per-instance tilesheet images/quads. The shared
@@ -1601,6 +1651,19 @@ local function clearTrapping(battler)
battler.trapDamage = nil
end
-- SendOutMon (core.asm:1733-1735) clears both battle cursors, though the
-- disassembly only names one of them: `ld hl, wBattleAndStartSavedMenuItem /
-- ld [hli], a / ld [hl], a` writes zero to that byte AND to the byte behind
-- it, which is wPlayerMoveListIndex (wram.asm:242-244). So every player
-- send-out puts the main menu back on FIGHT and the move list back on the
-- first slot; the cursors are only remembered across sub-menus of the mon
-- that is already out (#737). Enemy send-outs run EnemySendOutFirstMon,
-- not SendOutMon, and leave both alone.
local function sendOutMonCursors(self)
self.menuIndex = 1
self.moveIndex = 1
end
-- core.asm:297-300: both sides' FLINCHED bits are cleared as a turn's move
-- selection opens, but the clear is skipped for a mon that must recharge or
-- is locked into Rage (core.asm:293-295 -- the Hyper Beam flinch-recharge
@@ -2084,7 +2147,7 @@ function BattleState:oldManThrow()
self.phase = "messages"
self.afterQueue = "finish"
self.result = "run" -- nothing is kept; wBattleResult only ends the demo
self:say(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:sayAuto(Strings("%s used\nPOKé BALL!", self.demoName or "OLD MAN"))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
-- ItemUseBall's beat before the toss chain (like throwBall)
@@ -2278,6 +2341,7 @@ function BattleState:resolveSwitch(newMon)
previous = previous,
})
self:markParticipant()
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -3252,9 +3316,13 @@ end
-- damaging pipeline (EffectRegistry.runDamaging).
-- Gen 1 status/stat primary effects call PlayCurrentMoveAnimation only
-- after they land; these failure texts print with no animation.
-- after they land; these failure texts print with no animation. Failures
-- whose text is an ordinary sentence rather than one of the shared fail
-- lines set msgs.failed instead of relying on this sniffer -- Substitute's
-- two failure lines name the move, not the failure (#644).
local function primaryEffectFailed(msgs)
if not msgs or #msgs == 0 then return true end
if msgs.failed then return true end
local m = msgs[1]
if m == "But, it failed!" or m == "Nothing happened!" then return true end
if m:find("didn't affect", 1, true) then return true end
@@ -3300,7 +3368,7 @@ function BattleState:performMove(user, target, moveInst, isCalled)
self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
self:sayNext(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
-- the move's animation plays right after the announcement; the
-- damage path attaches the target's hit blink to this row so the
-- blink follows the animation (pokered's order). Mimic is the
@@ -3796,6 +3864,7 @@ function BattleState:enemyMonFainted()
self.participants = {}
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -3988,6 +4057,7 @@ function BattleState:openReplacementMenu()
})
self:markParticipant()
self.nextInsert = 0
sendOutMonCursors(self)
self.sendingOut = true
self:sayNext(self:sendOutText(self.player.name))
self:animNext("POOF_ANIM", false)
@@ -4026,7 +4096,7 @@ function BattleState:safariAction(choice)
if choice == "ball" then
st.balls = st.balls - 1
self:say(Strings("%s used\nSAFARI BALL!", playerName))
self:sayAuto(Strings("%s used\nSAFARI BALL!", playerName))
self:act(function()
require("src.core.Sound").play(self.data, "Ball_Toss")
self.lastBall = "SAFARI_BALL"
@@ -4348,7 +4418,7 @@ function BattleState:throwBall(ball)
-- "<PLAYER> used <ITEM>!" line (#291). Safari and the old man demo are
-- still wIsInBattle == 1, and this port models both as kind == "wild".
if self.kind == "wild" then
self:say(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self:sayAuto(self:romText("_ItemUseText001", "%s used\n%s!", self.game.save.player.name,
self.data.items[ball].name))
end
self:act(function()
@@ -5348,7 +5418,8 @@ end
function BattleState:drawTextArea()
Font.drawBox(0, 12, 20, 6)
love.graphics.setColor(0, 0, 0, 1)
if self.phase == "messages" and (self.current or self.animPlaying) then
if self.phase == "messages"
and (self.current or self.animPlaying or self.msgHold) then
-- during the move animation self.current is nil but shown still holds
-- the "used X!" lines; keep drawing them like pokered, whose move
-- animations only touch sprites and never the textbox tilemap (#296)
+16 -2
View File
@@ -259,15 +259,29 @@ MoveEffects.primary = {
return { romText(battle.data, "_StatusChangesEliminatedText", "All STATUS changes\nare eliminated!") }
end,
-- substitute.asm reaches its PlayCurrentMoveAnimation / AnimationSubstitute
-- Bankswitch only inside the success branch, after `set HAS_SUBSTITUTE_UP`;
-- .alreadyHasSubstitute and .notEnoughHP fall straight through to PrintText,
-- so both failures print with no animation at all. That is load bearing
-- here: the SUBSTITUTE animation opens with SE_SLIDE_MON_OFF, which leaves
-- the user's pic hidden (BattleState.lua slideOff end state) until the doll
-- is drawn in its place -- and with no substituteHP raised there is no doll,
-- so a failed Substitute used to erase the user's sprite for the rest of the
-- battle (#644). The failed flag rides the message list so performMove can
-- peel the announcement-time anim row without matching on printed text.
SUBSTITUTE_EFFECT = function(battle, user)
if user.substituteHP then return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)) } end
if user.substituteHP then
return { romText(battle.data, "_HasSubstituteText", "%s\nhas a SUBSTITUTE!", displayName(user)),
failed = true }
end
local cost = math.floor(user.mon.stats.hp / 4)
-- substitute.asm only fails on subtraction underflow (current HP
-- strictly below maxHP/4); at equality the substitute is built and
-- the user is left standing on exactly 0 HP (it faints only when
-- the engine next checks HP, not here)
if user.mon.hp < cost then
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!") }
return { romText(battle.data, "_TooWeakSubstituteText", "Too weak to make\na SUBSTITUTE!"),
failed = true }
end
user.mon.hp = user.mon.hp - cost
user.substituteHP = cost + 1