CLOSES #1474, CLOSES #1475, CLOSES #1477, CLOSES #1482, CLOSES #1512, CLOSES #1516, CLOSES #1558, CLOSES #1569, CLOSES #1578, CLOSES #1579

This commit is contained in:
bryanthaboi
2026-08-20 16:04:12 -04:00
parent 83463a5a59
commit 0f8f6d0e4f
51 changed files with 2331 additions and 205 deletions
+18 -2
View File
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
return true
end
-- The leader's badge line, armed for the battle screen the way
-- scripts/PewterGym.asm:117 / CeruleanGym.asm:111 SaveEndBattleTextPointers do
local function badgeEndBattleText(game, victoryKey)
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
if not (reward and reward.dialogue) then return nil end
local text = game.data.text or {}
local pages = {}
for _, label in ipairs(reward.dialogue) do
if text[label] and text[label] ~= "" then
pages[#pages + 1] = text[label]
end
end
if #pages == 0 then return nil end
return table.concat(pages, "\f")
end
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
-- _PewterGymBrockPreBattleText and engages the leader battle
@@ -58,7 +74,7 @@ M.PEWTER_GYM.talk = {
game.data.text._PewterGymBrockPostBattleAdviceText
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
else
ow:engageTrainer(npc, done)
ow:engageTrainer(npc, done, badgeEndBattleText(game, "OPP_BROCK#1"))
end
end,
}
@@ -91,7 +107,7 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
game.stack:push(TextBox.new(game,
game.data.text[adviceLabel] or fallback, finish))
else
ow:engageTrainer(npc, done)
ow:engageTrainer(npc, done, badgeEndBattleText(game, victoryKey))
end
end
end
+17 -15
View File
@@ -647,27 +647,29 @@ end
local rocketRows = {
{ "face_player" }, -- 1
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 9 }, -- 5
{ "jump_if_true", 10 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
{ "jump_if_false", "end" }, -- 8
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
{ "fade", "out" }, -- 15 GBFadeOutToBlack
-- scripts/CeruleanCity.asm:295 SaveEndBattleTextPointers
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
{ "jump_if_false", "end" }, -- 9
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
{ "fade", "out" }, -- 16 GBFadeOutToBlack
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
-- door neighbour -- the swap reconnects the city (Bill's ticket does
-- the same in story.lua; either route is enough).
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
{ "fade", "in" }, -- 19 GBFadeInFromBlack
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
{ "fade", "in" }, -- 20 GBFadeInFromBlack
}
M.CERULEAN_CITY = {
+83 -19
View File
@@ -1364,7 +1364,7 @@ function BattleState:updateQueue()
-- subanimation (or just the coarse fx when animations are off).
-- item.hit carries the target's blink + damage sound, applied when
-- the animation ends (hitRow rows carry a hit with no animation --
-- thrash/rage continuation turns that skip the announcement).
-- Mimic, whose animation waits on a successful copy).
if item.anim or item.hitRow then
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
@@ -2708,9 +2708,11 @@ function BattleState:resolveSwitch(newMon)
self.afterQueue = "menu"
self:act(function()
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
-- outgoing pic and holds 50 frames before the mon is recalled
-- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
-- runs before the mon is recalled
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:queueRetreatAnim()
self:actNext(function()
self:restoreMimicked(self.player) -- the battle copy leaves with it
local previous = self.player
@@ -3322,6 +3324,23 @@ function BattleState:queueSendOutAnim(append)
if append then self:act(fn) else self:actNext(fn) end
end
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
function BattleState:queueRetreatAnim()
if self:starterPikachuSendOut() then
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 24 })
self:actNext(function() self:slidePic("playerMon") end)
else
self:actNext(function()
self.shrinkOut = { battler = self.player, frame = 0 }
end)
self.nextInsert = (self.nextInsert or 0) + 1
table.insert(self.queue, self.nextInsert, { wait = 7 })
end
end
-- Should the low-health alarm sound this frame? pokered keys it off
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
@@ -3538,6 +3557,12 @@ function BattleState:updateFx()
self.growIn.frame = self.growIn.frame + 1
if self.growIn.frame >= 12 then self.growIn = nil end
end
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
-- 7x7 area is cleared for good
if self.shrinkOut then
self.shrinkOut.frame = self.shrinkOut.frame + 1
if self.shrinkOut.frame >= 7 then self.shrinkOut = nil end
end
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
-- loops while the player's bar is red; see lowHealthAlarmActive
local Sound = require("src.core.Sound")
@@ -3802,6 +3827,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
{ rng = self.rng, forceCrit = false, typeless = true,
screens = target })
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
self:animNext("POUND", not user.isPlayer)
self:clearVolatiles(user, true)
self:applyDamage(user, dmg)
if user.mon.hp <= 0 then self:onFaint(user) end
@@ -3894,25 +3921,36 @@ function BattleState:performMove(user, target, moveInst, isCalled)
end
self.moveAnimRow = nil
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
local thrashing = user.thrashTurns and moveInst == user.thrashMove
and user.thrashAnnounced or false
if thrashing then
-- .ThrashingAboutCheck (core.asm:3531-3552)
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
displayName(user)))
user.thrashTurns = user.thrashTurns - 1
if user.thrashTurns <= 0 then
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
end
else
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
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
-- only after a successful copy, never on a miss -- applyMimic queues it
end
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
-- = false) queues it from applyMimic after a successful copy
if not (record and record.announceAnim == false) then
self.nextInsert = (self.nextInsert or 0) + 1
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
end
end
Runtime.emit("battle.move_used", {
battle = self, user = user, target = target, move = move,
isCalled = isCalled or false,
})
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
ctx.thrashing = thrashing
-- Metronome / Mirror Move re-entry; a nil pick means the record
-- already said its failure text
@@ -4185,8 +4223,9 @@ function BattleState:awardExp()
end
local function applyShare(mon, split, announce)
local playerId = self.game.save.player and self.game.save.player.id
local traded = mon.traded == true
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
-- GainExperience (engine/battle/experience.asm:69-88) compares the
-- stored MON_OTID against wPlayerID every award; no persistent flag
local traded = mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, traded)
@@ -4338,21 +4377,35 @@ function BattleState:enemyMonFainted()
-- the battle queue's own \f handling (not TextBox.lua's) does not
-- page a sayChoice string the same way -- left as two calls.
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
-- HasMonFainted's NoWillText (core.asm:1473-1488)
self:sayChoice(
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
function(yes)
if not yes then return end
local game = self.game
Screens.push(game, "PartyMenu", {
local shiftOpts, reopenShift
reopenShift = function(text)
table.insert(self.queue, 1, { ui = function()
return self:buildScreen("PartyMenu", shiftOpts)
end })
table.insert(self.queue, 1, { text = text })
end
shiftOpts = {
battle = self,
party = self:playerPartyView(),
forceSwitch = true,
onSwitch = function(mon)
if mon ~= self.player.mon and mon.hp > 0 then
if mon == self.player.mon then
reopenShift(Strings("%s is\nalready out!", self.player.name))
elseif mon.hp <= 0 then
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
else
shiftSwitchMon = mon
end
end,
})
}
Screens.push(game, "PartyMenu", shiftOpts)
end, { box = Theme.trainerSwitchBox })
end
self:act(function()
@@ -4395,10 +4448,11 @@ function BattleState:enemyMonFainted()
local mon = shiftSwitchMon
if not mon then return end
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
-- hold, then the recall and the send-out
-- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
self.nextInsert = 0
self:sayNextAuto(self:withdrawText(self.player.name),
Timing.SWITCH_PLAYER_MON)
self:queueRetreatAnim()
self:actNext(function()
local previous = self.player
self.player = makeBattler(self.data, mon, true, self.game.save)
@@ -5088,10 +5142,13 @@ function BattleState:openParty()
battle = self,
party = self:playerPartyView(),
onSwitch = function(mon)
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
if mon == self.player.mon then
self:say(Strings("%s is\nalready out!", self.player.name))
self:act(function() self:openParty() end)
elseif mon.hp <= 0 then
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
self:act(function() self:openParty() end)
else
self:resolveSwitch(mon)
end
@@ -5235,6 +5292,14 @@ function BattleState:growInScale(battler)
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
end
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
-- (core.asm:1769-1796)
function BattleState:shrinkOutScale(battler)
local shrink = self.shrinkOut
if not shrink or shrink.battler ~= battler then return nil end
return shrink.frame < 4 and 5 / 7 or 3 / 7
end
-- battler hidden this frame? (damage blink)
--
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
@@ -5871,11 +5936,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
local s = BattleState.resolveBattleScale(self.data, "back",
imagePathOf(self.player.sprite),
self.player.mon and self.player.mon.species)
local gs = self:growInScale(self.player)
local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
if gs then
-- the player-side AnimateSendingOutMon grow (after the poof,
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
-- pinned, mod scale composed with the grow stage
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
local eff = s * gs
if eff > 0 then
love.graphics.draw(img,
+1 -1
View File
@@ -221,7 +221,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
-- replay PlayMoveAnimation per strike (pokered: GetPlayerAnimationType
-- / GetEnemyAnimationType loop on wNumAttacksLeft); hit 1 reuses the
-- announcement-time moveAnimRow, later hits queue fresh anim rows.
-- Thrash/rage continuations have no announcement anim -- a bare
-- Mimic queues no announcement anim (announceAnim = false) -- a bare
-- hitRow carries the blink instead.
-- PlayApplyingAttackSound (engine/battle/animations.asm, the routine after
-- PlayApplyingAttackAnimation) picks the sound off wDamageMultipliers -- 10
+4 -19
View File
@@ -582,30 +582,15 @@ MoveEffects.full = {
},
THRASH_PETAL_DANCE_EFFECT = {
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
-- (data/battle/special_effects.asm:22) and animates the setup turn
-- (data/battle/special_effects.asm:22, core.asm:3531-3552)
beforeAccuracy = function(ctx)
local user = ctx.user
if not user.thrashTurns then
ctx.battle:animBeforeMove(
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
end
end,
afterDamage = function(ctx)
local user = ctx.user
if not user.thrashTurns then
if ctx.thrashing or user.thrashTurns then return end
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
user.thrashMove = ctx.moveInst
user.thrashAnnounced = true
else
user.thrashTurns = user.thrashTurns - 1
if user.thrashTurns <= 0 then
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
if not user.confusedTurns then
user.confusedTurns = ctx.rng(2, 5)
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
end
end
end
ctx.battle:animBeforeMove(
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
end,
},
JUMP_KICK_EFFECT = {
+35 -13
View File
@@ -166,6 +166,9 @@ Battle.SUBSTATUS_ITEMS = {
-- be run from or Roared away.
Battle.BATTLETYPE_FORCESHINY = 7
Battle.BATTLETYPE_TRAP = 9
-- LostBattle's .canlose arm (engine/battle/core.asm:2766): the only battle
-- type whose loss still prints the trainer's own line instead of a whiteout.
Battle.BATTLETYPE_CANLOSE = 1
-- BadgeStatBoosts (engine/battle/core.asm:6534): each of these Johto badges
-- raises the PLAYER's in-battle stat by 1/8. The routine walks every other
@@ -889,10 +892,11 @@ Battle.PRIORITY = {
EFFECT_ENDURE = 3,
EFFECT_COUNTER = -1,
EFFECT_MIRROR_COAT = -1,
EFFECT_VITAL_THROW = -1,
}
function Battle:movePriority(moveId)
-- engine/battle/core.asm:786 GetMovePriority, `cp VITAL_THROW / ld a, 0`.
if moveId == "VITAL_THROW" then return -1 end
local def = self:moveDef(moveId)
return (def and Battle.PRIORITY[def.effect]) or 0
end
@@ -2403,10 +2407,10 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
self.enemy = party[target]
self.enemy.volatile = carried
end
self:emit({ kind = "send", side = side,
mon = side == "player" and self.player or self.enemy,
text = "Go! " .. self:monName(side == "player" and self.player
or self.enemy) .. "!" })
local sent = side == "player" and self.player or self.enemy
self:emit({ kind = "send", side = side, mon = sent,
hp = sent.hp or 0, status = sent.status or false,
text = "Go! " .. self:monName(sent) .. "!" })
end
-- BattleCommand_TrapTarget's .Traps table, one line per move: target first,
@@ -2665,6 +2669,7 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
self.stages.enemy = Battle.newStages()
end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
hp = incoming.hp or 0, status = incoming.status or false,
text = self:monName(incoming) .. " was dragged out!" })
self:breakTrapsOnSend(incoming)
self:spikesDamage(incoming)
@@ -3084,6 +3089,7 @@ function Battle:resolveFaints()
if self.trainer then
self:emit({ kind = "message",
text = (self.trainer.name or "TRAINER") .. " was defeated!" })
self:printWinLossText("win")
self:awardPrizeMoney()
end
-- CheckPayDay, on the win arm only (engine/battle/core.asm:7971-7976,
@@ -3110,6 +3116,7 @@ function Battle:resolveFaints()
-- can offer a shift on (engine/battle/core.asm:2241-2278).
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
replacement = true,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
@@ -3152,6 +3159,11 @@ function Battle:resolveFaints()
local nextIndex = Battle.firstHealthy(self.party)
if not nextIndex then
self:emit({ kind = "message", text = "You have no more POKéMON!" })
-- LostBattle (engine/battle/core.asm:2763-2782): only BATTLETYPE_CANLOSE
-- reaches PrintWinLossText on a loss; every other loss whites out.
if self.battleType == Battle.BATTLETYPE_CANLOSE then
self:printWinLossText("lose")
end
self:endBattle("lose")
return true
end
@@ -3177,14 +3189,22 @@ function Battle:resolveFaints()
return false
end
-- WinTrainerBattle's money arm, which runs after BattleText_EnemyWasDefeated
-- and the frontpic slide: the four quarters are dealt between the wallet and
-- Mom's savings and then one StdBattleTextbox names the figure.
--
-- The `ld a, [wDebugFlags] / bit DEBUG_BATTLE_F` skip in front of
-- PrintWinLossText is the trainer's own after-battle line, which this port
-- runs from the script on the way out of the battle rather than from here.
-- The payout is not gated on it either way.
-- WinTrainerBattle (engine/battle/core.asm:2310-2323), LostBattle's .canlose
-- arm (:2769-2782), PrintWinLossText (home/trainers.asm:230)
function Battle:printWinLossText(result)
local trainer = self.trainer
if not trainer then return end
-- The DEBUG_BATTLE_F skip sits in front of PrintWinLossText alone, behind
-- the slide (engine/battle/core.asm:2310, :2320-2323).
self:emit({ kind = "trainer-return" })
local text = (result == "lose") and trainer.lossText or trainer.winText
if type(text) ~= "string" or text == "" then return end
-- FarPrintText prints the pointer alone: no trainer-name tag in front of it,
-- unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:341).
self:emit({ kind = "win-text", text = text })
end
-- WinTrainerBattle's money arm (engine/battle/core.asm:2310-2323)
function Battle:awardPrizeMoney()
local save = self.save
if not (save and save.player) then return nil end
@@ -3505,6 +3525,7 @@ function Battle:switch(index)
self.participants[index] = true
self.stages.player = Battle.newStages()
self:emit({ kind = "send", side = "player", mon = mon,
hp = mon.hp or 0, status = mon.status or false,
text = "Go! " .. self:monName(mon) .. "!" })
-- battle.battler_switched, the payload BattleState:resolveSwitch emits on
-- Gen 1: the side record, whoever walked in, and whoever walked out.
@@ -3969,6 +3990,7 @@ function Battle:enemyTrySwitchOrItem()
self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
text = (self.trainer.name or "TRAINER") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
+26
View File
@@ -3439,6 +3439,18 @@ function RomExtractorGen2:extractScriptsAndText(maps, stdScripts)
elseif info.name == "givepoke" then
cmd.species, cmd.level, cmd.item, cmd.trainer =
args[1], args[2], args[3], args[4]
-- Script_givepoke (engine/overworld/scripting.asm:1806)
if size == 8 then
local function readAt(lo, hi)
local addr = (args[lo] or 0) + (args[hi] or 0) * 0x100
if not romAddrOk(bank, addr) then return nil end
local okStr, str = pcall(self.rom.readString, self.rom,
bank, addr, charmap, 0x50, 16)
return okStr and str or nil
end
cmd.name = readAt(5, 6)
cmd.otName = readAt(7, 8)
end
elseif info.name == "pokepic" or info.name == "disappear" then
cmd.species = args[1] -- pokepic
cmd.object = args[1] -- disappear (same byte)
@@ -5231,6 +5243,20 @@ function RomExtractorGen2:extractMenuGfx()
end
if eggHatch.egg or eggHatch.shell then out.eggHatch = eggHatch end
-- StatsScreenPageTilesGFX (gfx/font.asm:23), the 17 tiles
-- LoadStatsScreenPageTilesGFX lands at vTiles2 $31 (engine/gfx/load_font.asm:90).
local hpBarBorder = self.symbols["EnemyHPBarBorderGFX"]
if hpBarBorder then
local address = hpBarBorder[2] - 17 * 16
self:write2bpp(self.rom:bytes(hpBarBorder[1], address, 17 * 16),
17 * 8, 8, "menu/stats_tiles.png")
out.stats = {
sheet = "assets/generated/menu/stats_tiles.png",
tiles = 17,
firstTile = 0x31,
}
end
-- Goldenrod Game Corner: Slot Machine graphics assets
if self.symbols["Slots1LZ"] then
local raw1 = self:decompressLz3Symbol("Slots1LZ")
+6
View File
@@ -93,6 +93,12 @@ function ItemEffects.healsHP(id)
or id == "REVIVE" or id == "MAX_REVIVE"
end
-- .useRareCandy prints over the still-drawn party menu
-- (engine/items/item_effects.asm:1392-1418)
function ItemEffects.keepsPartyMenuOpen(id)
return ItemEffects.healsHP(id) or id == "RARE_CANDY"
end
function ItemEffects.isBattleMedicine(id)
return HEAL_AMOUNT[id] ~= nil or STATUS_HEAL[id] ~= nil
or id == "MAX_POTION" or id == "FULL_RESTORE"
+16 -8
View File
@@ -19,6 +19,9 @@ local romText = require("src.core.RomText")
local Evolution = {}
-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames)
local EVOLVING_TEXT_FRAMES = 50
Evolution.METHODS = {
LEVEL = {
check = function(game, mon, evo, trigger)
@@ -157,27 +160,32 @@ end
-- Play the evolution movie (flashing forms), then apply + text.
-- Headless (no real graphics) falls back to the plain text flow.
function Evolution.evolve(game, mon, newSpecies, onDone, via)
local oldName = mon.nickname or game.data.pokemon[mon.species].name
-- IsEvolvingText, DelayFrames 50, then ClearScreenArea before EvolveMon
-- (engine/pokemon/evos_moves.asm:120-134)
local isEvolving = romText(game.data, "_IsEvolvingText",
"What?\n%s is\nevolving!", oldName)
if love.image and love.image.newImageData then
game.stack:push(TextBox.new(game, isEvolving, function()
-- forward `via` so EvolutionState can keep trade evolutions
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
end, { auto = { delay = EVOLVING_TEXT_FRAMES } }))
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
local oldName = mon.nickname or game.data.pokemon[mon.species].name
Evolution.apply(game, mon, newSpecies, via)
-- the congrats page keeps the engine wording: _EvolvedText extracts
-- truncated (it stops at a dynamic marker the decoder does not follow)
local msg = romText(game.data, "_IsEvolvingText",
"What?\n%s is\nevolving!", oldName)
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
oldName, game.data.pokemon[newSpecies].name)
-- EvolvedText then IntoText in the same box (evos_moves.asm:136-150)
local msg = isEvolving .. "\f"
.. romText(game.data, "_EvolvedText", "%s evolved", oldName)
.. romText(game.data, "_IntoText", "\ninto %s!",
game.data.pokemon[newSpecies].name)
game.stack:push(TextBox.new(game, msg, function()
Music.restoreMap(game.data)
-- re-run the evolved species' level-up learn check before onDone
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
Evolution.learnEvolutionMoves(game, mon, onDone)
end))
end, TextBox.soundOpts(game, "Get_Item2")))
end
-- Entry point for mods whose methods fire outside the vanilla moments
+7 -3
View File
@@ -543,10 +543,14 @@ local function runCmd(self, cmd, op)
local species = cmd.species or arg1(cmd)
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
if self.givePokeFn then
local mon = self.givePokeFn(species, level, item)
-- engine/pokemon/move_mon.asm:1753-1757
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
if self.givePokeFn then
-- engine/pokemon/move_mon.asm:1695-1736: the trainer arm copies the
-- script's own nickname and OT name in instead of asking for one.
local named = trainer ~= 0
and { nickname = cmd.name, otName = cmd.otName } or nil
local mon = self.givePokeFn(species, level, item, named)
-- engine/pokemon/move_mon.asm:1753-1757
if mon and trainer == 0 then
Specials.askNickname(self, mon)
end
+13 -6
View File
@@ -17,10 +17,13 @@ local function buildItems(game)
local items = {}
for _, id in ipairs(Bag.order(game.save)) do
local def = game.data.items[id]
-- PrintListMenuEntries skips the quantity for anything IsKeyItem_ owns:
-- the KeyItemFlags bitfield plus the HMs (item_effects.asm:2616-2641)
local unsellable = (def and def.keyItem) or id:find("^HM_") ~= nil
table.insert(items, {
value = id,
label = def and def.name or id,
right = "x" .. game.save.inventory[id],
right = (not unsellable) and ("x" .. game.save.inventory[id]) or nil,
})
end
return items
@@ -334,6 +337,9 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
local Evolution = require("src.pokemon.Evolution")
local evoTo, evo = Evolution.pendingFor(game, target,
{ kind = "levelup" })
-- the party menu stays up through TryEvolvingMon and only
-- comes down at RemoveUsedItem (item_effects.asm:1392-1418)
closePicker()
if evoTo then
Evolution.evolve(game, target, evoTo, nil, evo and evo.method)
end
@@ -399,10 +405,9 @@ local function pickTargetAndUse(game, battle, id, list)
local opts = {
pickOnly = true,
battle = battle,
-- HP medicine animates its bar with the picker still up (#252). Only
-- out of battle: the in-battle tail closes the bag list underneath
-- first, which needs the picker already gone.
keepOpen = (not battle) and ItemEffects.healsHP(id),
-- HP medicine animates with the picker up (#252), RARE CANDY prints over
-- the party menu (item_effects.asm:1392-1418)
keepOpen = (not battle) and ItemEffects.keepsPartyMenuOpen(id),
onSwitch = function(mon, picker)
if not wantsMove then
useOn(game, battle, id, mon, list, nil, picker)
@@ -470,7 +475,9 @@ function BagMenu.new(game, opts)
local list
list = ListMenu.new(game, "ITEMS", buildItems(game), {
kind = "bag",
footer = ("¥%d"):format(game.save.money),
-- StartMenu_Item zeroes wPrintItemPrices and draws no money box: the
-- LIST_MENU_BOX floats over the map (engine/menus/start_sub_menus.asm)
itemBox = true,
-- B returns to the start menu when the bag was opened from it
onCancel = opts.onCancel,
-- SELECT reorders items like the original bag (swap_items.asm)
+9 -16
View File
@@ -1,6 +1,7 @@
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
-- flashes back and forth with the evolved form, speeding up, then the
-- new form appears with its cry and the congratulations text.
-- new form appears with its cry and the "evolved into" text
-- (engine/pokemon/evos_moves.asm:120-128).
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
@@ -9,9 +10,7 @@
-- and stone evolutions, where the B press is read but thrown away because
-- ItemUseEvoStone left wForceEvolution set (#290).
local Font = require("src.render.Font")
local Music = require("src.core.Music")
local Strings = require("src.core.Strings")
local romText = require("src.core.RomText")
local EvolutionState = {}
@@ -127,11 +126,11 @@ function EvolutionState:update(dt)
require("src.core.Sound").playCry(game.data, self.newSpecies)
local TextBox = require("src.render.TextBox")
local newName = game.data.pokemon[self.newSpecies].name
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
-- decoder does not follow), so the engine's wording stands here
game.stack:push(TextBox.new(game,
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
self.oldName, newName),
-- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox),
-- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153)
local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
.. romText(game.data, "_IntoText", "\ninto %s!", newName)
game.stack:push(TextBox.new(game, msg,
function()
Music.restoreMap(game.data)
game.stack:pop() -- the evolution screen itself
@@ -141,7 +140,8 @@ function EvolutionState:update(dt)
-- first so the "learned MOVE!" text / forget prompt push onto the
-- overworld / battle-return, not this state.
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
end))
end,
TextBox.soundOpts(game, "Get_Item2")))
end
end
@@ -172,13 +172,6 @@ function EvolutionState:draw()
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end
end
love.graphics.setColor(0, 0, 0, 1)
if not self.done then
Font.draw(Strings("What?"), 8, 104)
Font.draw(self.oldName .. " is", 8, 114)
Font.draw(Strings("evolving!"), 8, 124)
end
love.graphics.setColor(1, 1, 1, 1)
end
+55 -1
View File
@@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game)
end
local ROWS = 7
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
local ITEM_BOX = { tx = 4, ty = 2, tw = 16, th = 11 }
local ITEM_ROWS = 4
local ITEM_NAME_X, ITEM_TOP_Y = 48, 32
local ITEM_CURSOR_X = 40
local ITEM_QTY_X, ITEM_QTY_END = 112, 136
local ITEM_MORE_X, ITEM_MORE_Y = 144, 88
-- frames to wait before key-repeat kicks in, then between repeats
local REPEAT_DELAY = 16
local REPEAT_RATE = 4
@@ -83,7 +91,12 @@ function ListMenu.new(game, title, items, opts)
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
-- so their lists opt out of the A/B beep the same way Menu's noSound does
self.noSound = opts.noSound or false
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
-- the bag's item list: a partial box the map stays visible around, not a
-- screen of its own (home/list_menu.asm:29-31)
self.itemBox = opts.itemBox or false
if self.itemBox then self.isOpaque = false end
self.rows = opts.rows or (self.itemBox and ITEM_ROWS)
or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self
end
@@ -206,7 +219,48 @@ function ListMenu:close()
if top == self then self.game.stack:pop() end
end
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
function ListMenu:drawItemBox()
love.graphics.setColor(1, 1, 1, 1)
Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th)
love.graphics.setColor(0, 0, 0, 1)
if #self.items == 0 then
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
end
local shown = 0
for row = 1, self.rows do
local i = self.scroll + row
local item = self.items[i]
if not item then break end
shown = shown + 1
local y = ITEM_TOP_Y + (row - 1) * 16
Font.draw(item.label, ITEM_NAME_X, y)
if item.right then
-- '×' at column 14, PrintNumber's two right-aligned digits after it
-- (home/list_menu.asm:479-490)
local count = item.right:sub(2)
Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8)
Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8)
end
if i == self.index then
Font.drawCode(self.hollowIndex == i
and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y)
end
if self.swapIndex == i and i ~= self.index then
Font.drawCode(Theme.cursorHollow, ITEM_CURSOR_X, y)
end
end
-- the terminator prints CANCEL and returns before the '▼'
-- (home/list_menu.asm:372, 518-524)
if shown == self.rows then
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
end
love.graphics.setColor(1, 1, 1, 1)
end
function ListMenu:draw()
if self.itemBox then return self:drawItemBox() end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
+15 -5
View File
@@ -34,6 +34,9 @@ function Menu.new(game, items, opts)
if self.tx + self.tw > 20 then self.tx = math.max(0, 20 - self.tw) end
end
self.rowStep = opts.rowStep or 2
-- engine/movie/oak_speech/oak_speech2.asm:162 (DisplayIntroNameTextBox)
self.title = opts.title
self.itemY = opts.itemY
-- maxVisible: cap the box to this many rows and scroll the rest instead
-- of growing past it (e.g. the start menu, whose row count varies with
-- save state and mod hooks); nil/unset keeps every caller's old
@@ -117,6 +120,10 @@ function Menu:draw()
end
Font.drawBox(self.tx, self.ty, self.tw, self.th)
love.graphics.setColor(0, 0, 0, 1)
-- PlaceString at hlcoord 3,0 writes over the border row it was just drawn on
if self.title then
Font.draw(self.title, (self.tx + 3) * 8, self.ty * 8)
end
local visible = (self.maxVisible and math.min(self.maxVisible, #self.items))
or #self.items
-- Row Y: pokered's boxed menus anchor the choices to the BOTTOM interior
@@ -130,15 +137,18 @@ function Menu:draw()
-- USE/TOSS is th = 5 for two choices (#284, matching text_boxes.asm's
-- USE_TOSS_MENU_TEMPLATE rows 10..14), and a top anchor pushed TOSS onto
-- the bottom border (#564, #572).
local function rowY(row)
if self.itemY then
return (self.ty + self.itemY + (row - 1) * self.rowStep) * 8
end
return (self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8
end
for row = 1, visible do
local item = self.items[self.scroll + row]
if not item then break end
Font.draw(item.label, (self.tx + 2) * 8,
(self.ty + self.th - 2 - (visible - row) * self.rowStep) * 8)
Font.draw(item.label, (self.tx + 2) * 8, rowY(row))
end
local cursorRow = self.index - self.scroll
Font.drawCode(Theme.cursor, (self.tx + 1) * 8,
(self.ty + self.th - 2 - (visible - cursorRow) * self.rowStep) * 8)
Font.drawCode(Theme.cursor, (self.tx + 1) * 8, rowY(self.index - self.scroll))
-- moreArrow ($EE): the same "more below" glyph OptionRows/ManagerState
-- use, sat on the bottom border like TextBox's page-advance cursor. It
-- has to be the border row, not ty + th - 2: that is the last interior
+10
View File
@@ -67,6 +67,7 @@ function NamingScreen.new(game, opts)
self.game = game
self.title = opts.title or Strings("YOUR NAME?")
self.presets = opts.presets
self.introBox = opts.introBox
self.maxLen = opts.maxLen or 7
self.default = opts.default
self.onDone = opts.onDone
@@ -99,10 +100,19 @@ function NamingScreen:enter()
end,
})
end
if self.introBox then
-- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at
-- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2
self.game.stack:push(Menu.new(self.game, items, {
tx = 0, ty = 0, tw = 11, th = #items * 2 + 4,
itemY = 2, title = Strings("NAME"), cancelable = false,
}))
else
self.game.stack:push(Menu.new(self.game, items, {
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
}))
end
end
end
function NamingScreen:confirm()
+55 -2
View File
@@ -35,6 +35,23 @@ OakSpeech.letterboxWhite = true
local FADE_FRAMES = 24
local WIPE_FRAMES = 32
-- OakSpeechSlidePicRight / OakSpeechSlidePicLeft (oak_speech2.asm:67-89)
local SLIDE_TILES = 6
local SLIDE_FRAMES = 3
local PicSlide = {}
PicSlide.__index = PicSlide
function PicSlide:update(dt)
self.t = self.t + 1
local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES))
self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8
if tiles >= SLIDE_TILES then
self.game.stack:pop()
if self.onDone then self.onDone() end
end
end
-- naming presets are boot config (field.boot.namePresets), which a total
-- conversion replaces; the Red/Blue lists remain the fallback
local function namePresets(game, who, fallback)
@@ -155,6 +172,10 @@ function OakSpeech.defaultSteps(speech)
kind = "say",
textKey = "_IntroducePlayerText",
pic = "player",
-- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText ends in
-- text_end, so PrintText returns with the box still up under the names
reveal = "wipe",
stay = true,
},
{
id = "name_player",
@@ -177,6 +198,10 @@ function OakSpeech.defaultSteps(speech)
kind = "say",
textKey = "_IntroduceRivalText",
pic = "rival",
-- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's
-- text_end leaves the box up for ChooseRivalName
reveal = "fade",
stay = true,
},
{
id = "name_rival",
@@ -378,7 +403,14 @@ function OakSpeech:runStep(step)
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
if step.stay then
local box = TextBox.new(self.game, self:stepText(step), nil,
{ stay = { onShown = function() self:advance() end } })
self.holdBox = box
self.game.stack:push(box)
else
self:sayText(self:stepText(step), function() self:advance() end)
end
end)
elseif kind == "demo" then
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
@@ -394,9 +426,11 @@ function OakSpeech:runStep(step)
local presets = step.presets
or namePresets(self.game, step.presetsWho or who,
step.presetsFallback or { "RED" })
local function openNaming()
require("src.ui.Screens").push(self.game, "NamingScreen", {
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
presets = presets,
introBox = true,
maxLen = step.maxLen or self.nameLen,
onDone = function(name)
if who == "rival" then
@@ -405,9 +439,13 @@ function OakSpeech:runStep(step)
self.game.save.player.name = name
end
self:recordAnswer(step, 1, name, name)
self:advance()
-- YourNameIsText/HisNameIsText print into the box this one held
self:closeHoldBox()
self:slidePic(-1, function() self:advance() end)
end,
})
end
self:slidePic(1, openNaming)
elseif kind == "choice" then
self:applyPic(step)
self:afterReveal(step, function()
@@ -518,6 +556,21 @@ function OakSpeech:revealPic(kind, next)
}
end
-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67)
function OakSpeech:slidePic(dir, onDone)
self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8)
self.game.stack:push(setmetatable({
game = self.game, speech = self, dir = dir, t = 0, onDone = onDone,
}, PicSlide))
end
-- IntroducePlayerText's text_end box (oak_speech.asm:90) is ours to close
function OakSpeech:closeHoldBox()
local box = self.holdBox
self.holdBox = nil
if box and self.game.stack:top() == box then self.game.stack:pop() end
end
function OakSpeech:advance()
self.step = self.step + 1
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
@@ -615,7 +668,7 @@ function OakSpeech:draw()
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
-- bottom-aligned
local w, h = self.pic:getDimensions()
local x = 48 + math.floor((8 - w / 8) / 2) * 8
local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0)
local y = 32 + (7 - h / 8) * 8
local reveal = self.picReveal
local off = 0
+29 -6
View File
@@ -59,14 +59,37 @@ function StartMenu.new(game)
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
owned = owned + 1
end
-- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and
-- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405
local panel = {
update = function() end,
draw = function()
local t = math.floor(game.save.playTime or 0)
local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
game.save.player.name or "RED", badges, owned,
math.floor(t / 3600), math.floor(t / 60) % 60)
Font.drawBox(4, 0, 16, 10)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8)
Font.draw(game.save.player.name or "RED", 12 * 8, 2 * 8)
Font.draw(Strings("BADGES"), 5 * 8, 4 * 8)
Font.draw(("%2d"):format(badges), 17 * 8, 4 * 8)
Font.draw(Strings("POKéDEX"), 5 * 8, 6 * 8)
Font.draw(("%3d"):format(owned), 16 * 8, 6 * 8)
Font.draw(Strings("TIME"), 5 * 8, 8 * 8)
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
math.floor(t / 60) % 60), 13 * 8, 8 * 8)
love.graphics.setColor(1, 1, 1, 1)
end,
}
local function closePanel()
if game.stack:top() == panel then game.stack:pop() end
end
game.stack:push(panel)
game.stack:push(TextBox.new(game,
panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
Strings("Would you like to\nSAVE the game?"), nil, {
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather
-- than the shared right-hand one -- engine/menus/save.asm:186-192
choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
choice = function(yes)
if not yes then return end
if not yes then closePanel() return end
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
@@ -78,7 +101,7 @@ function StartMenu.new(game)
game:writeSave()
game.stack:push(TextBox.new(game,
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
nil, { auto = {
closePanel, { auto = {
sound = function()
return require("src.core.Sound").play(game.data, "Save")
end,
+28 -5
View File
@@ -511,7 +511,10 @@ function TitleState:openMenu()
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
if self.onNewGame then self.onNewGame() end
end })
table.insert(items, { label = Strings("OPTION"), onSelect = function()
-- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box
-- (engine/menus/main_menu.asm ln 87-90)
table.insert(items, { label = Strings("OPTION"), keepOpen = true,
onSelect = function()
require("src.ui.Screens").push(game, "OptionsMenu")
end })
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
@@ -530,6 +533,12 @@ function TitleState:openMenu()
end
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
-- .mainMenuLoop's B branch jumps back to DisplayTitleScreen, which opens
-- with GBPalWhiteOut (engine/menus/main_menu.asm:69, title.asm:29)
menu.onCancel = function()
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
function() self.menuOpen = false end))
end
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
-- recolor zone follows the box's real width instead of the vanilla 13.
@@ -537,6 +546,17 @@ function TitleState:openMenu()
game.stack:push(menu)
end
-- .finishedWaiting: GBPalWhiteOutWithDelay3 then ClearScreen before MainMenu,
-- which clears again itself (engine/movie/title.asm ln 243, main_menu.asm ln 26)
function TitleState:toMenu()
local game = self.game
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
function()
self.menuOpen = true
self:openMenu()
end))
end
-- ..(engine/movie/title.asm ln 271)
function TitleState:pickNewMon()
if #self.cycleSpecies < 2 then return end
@@ -602,7 +622,7 @@ function TitleState:update(dt)
if not Sound.playPikaCry(self.game.data, 11) then
Sound.playCry(self.game.data, "PIKACHU")
end
self:openMenu()
self:toMenu()
end
return
end
@@ -616,20 +636,23 @@ function TitleState:update(dt)
require("src.core.Sound").playCry(self.game.data,
self.yellowLayout and "PIKACHU"
or self.cycleSpecies[self.cycleIndex])
self:openMenu()
self:toMenu()
end
end
-- ..(engine/movie/title.asm ln 28)
function TitleState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- MainMenu's own ClearScreen wipes the logo, mon and sprites before the
-- CONTINUE / NEW GAME border is drawn (engine/menus/main_menu.asm ln 26)
if self.menuOpen then return end
local PaletteFX = require("src.render.PaletteFX")
local playerImage = self.player
if playerImage and PaletteFX.usesSpriteObp() then
playerImage = require("src.render.SpriteRenderer").obpImage(
self.playerPath, PaletteFX.ogObj())
end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local scrollY = -(self.scy or 0)
-- ..(engine/movie/title.asm ln 28)
local preRibbon = not self.yellowLayout
+107 -13
View File
@@ -38,6 +38,9 @@ local Sound = require("src.core.Sound")
-- Only for playerPic: the player.sprite raiser both generations share.
local Sprites = require("src.pokemon.Sprites")
local Strings = require("src.core.Strings")
-- Only for TextBox.substitute: the {PLAYER} / {RIVAL} markers a map text
-- carries into the battle box (PrintWinLossText, home/trainers.asm:230).
local TextBox = require("src.render.TextBox")
local Unown = require("src.core.gen2.Unown")
local BattleState = {}
@@ -87,6 +90,20 @@ local TRAINER_SLIDE_STEPS = 8
local TRAINER_SLIDE_FRAMES_PER_STEP = 2
local TRAINER_SLIDE_FRAMES = TRAINER_SLIDE_STEPS * TRAINER_SLIDE_FRAMES_PER_STEP
-- BattleWinSlideInEnemyTrainerFrontpic (engine/battle/core.asm:6279-6318) and
-- WinTrainerBattle's DelayFrames 40 (:2311)
local WIN_SLIDE_STEPS = 6
local WIN_SLIDE_FRAMES_PER_STEP = 4
local WIN_SLIDE_FRAMES = WIN_SLIDE_STEPS * WIN_SLIDE_FRAMES_PER_STEP
local WIN_SLIDE_REST_TILES = 2
local WIN_SLIDE_DELAY_FRAMES = 40
local function winSlideTiles(frames)
local step = math.min(WIN_SLIDE_STEPS,
math.floor(frames / WIN_SLIDE_FRAMES_PER_STEP) + 1)
return WIN_SLIDE_STEPS + WIN_SLIDE_REST_TILES - step
end
-- MonFaintedAnimation (engine/battle/core.asm), which PlayerMonFaintedAnimation
-- and EnemyMonFaintedAnimation both fall into with the fainted side's pic
-- corner: the pic's tilemap rows are copied DOWN one row per step and the row
@@ -141,6 +158,10 @@ local MENU_COL_SPACING = 6
-- the count PrintNum writes after it (two digits, leading zeros) at (13,16).
local CONTEST_MENU_BOX_X = 2
local CONTEST_MENU_COL_SPACING = 12
-- PrintMoveType prints the type table's own names; only these two differ from
-- the constant (data/types/names.asm).
local TYPE_NAMES = { PSYCHIC_TYPE = "PSYCHIC", CURSE_TYPE = "???" }
-- charmap.asm's quantity glyph, spelled the way MartMenu spells it.
local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97"
@@ -508,6 +529,14 @@ function BattleState:pushAll(events)
for _, event in ipairs(events or {}) do self:push(event) end
end
-- LearnMove returns before HandleEnemyMonFaint's send-out/prize arms
-- (engine/battle/core.asm:1959-2010).
function BattleState:pushFront(events)
for i = #(events or {}), 1, -1 do
table.insert(self.queue, 1, events[i])
end
end
function BattleState:pic(mon, back)
local def = self.pokemon and mon and self.pokemon[mon.species]
local path = def and (back and def.spriteBack or def.spriteFront)
@@ -686,6 +715,8 @@ function BattleState:drawPic(mon, back)
-- One tile per two frames to the right, SlideBattlePicOut's own step.
if enemyTrainer and self.trainerSlide then
px = px + math.floor(self.trainerSlide / TRAINER_SLIDE_FRAMES_PER_STEP) * 8
elseif enemyTrainer and self.winSlide then
px = px + winSlideTiles(self.winSlide) * 8
end
-- The pic's own scale (battle_sprite_scales, then the species record, then
-- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has
@@ -1371,7 +1402,7 @@ function BattleState:advanceQueue()
and self.shownHp[event.side] ~= event.hp then
self.hpAnim = { side = event.side, to = event.hp }
elseif event.kind == "send" and event.side and event.mon and self.shownHp then
self.shownHp[event.side] = event.mon.hp or 0
self.shownHp[event.side] = event.hp or event.mon.hp or 0
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
end
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
@@ -1379,8 +1410,9 @@ function BattleState:advanceQueue()
if self.shownStatus and event.side
and (event.kind == "status"
or (event.kind == "send" and event.mon)) then
self.shownStatus[event.side] =
(event.kind == "send" and event.mon.status or event.status) or false
local shown = event.status
if event.kind == "send" and shown == nil then shown = event.mon.status end
self.shownStatus[event.side] = shown or false
end
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
@@ -1435,6 +1467,24 @@ function BattleState:advanceQueue()
self.trainerSlide = 0
return
end
-- BattleWinSlideInEnemyTrainerFrontpic and the DelayFrames 40 behind it
-- (engine/battle/core.asm:2310-2312)
if event.kind == "trainer-return" then
if not self.enemyTrainerImage then return self:advanceQueue() end
self.showEnemyTrainer = true
self.picHidden.enemy = false
self.winSlide = 0
self.winSliding = true
return
end
-- PrintWinLossText (home/trainers.asm:230): one FarPrintText of the trainer
-- struct's own line, paged and held for A/B like any other map text.
if event.kind == "win-text" then
local text = event.text
if self.game then text = TextBox.substitute(self.game, text) end
self:showPages(text)
return
end
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
if event.kind == "shiny-flash" then
@@ -1917,6 +1967,17 @@ function BattleState:update(_dt)
return
end
-- BattleWinSlideInEnemyTrainerFrontpic plus WinTrainerBattle's DelayFrames
-- 40 (engine/battle/core.asm:6279-6318, :2310-2312)
if self.winSliding then
self.winSlide = self.winSlide + 1
if self.winSlide >= WIN_SLIDE_FRAMES + WIN_SLIDE_DELAY_FRAMES then
self.winSliding = nil
self:advanceQueue()
end
return
end
-- SlideBattlePicOut is a plain loop with DelayFrames in it, so it owns the
-- screen the same way (engine/battle/core.asm:2882).
if self.trainerSlide then
@@ -2004,6 +2065,8 @@ function BattleState:update(_dt)
self.phase = "stats-box"
return
end
-- PrintWinLossText's line pages like any map text (home/text.asm:403-448)
if self:nextPage() then return end
self:advanceQueue()
return
end
@@ -2292,7 +2355,7 @@ function BattleState:update(_dt)
learn.move, learn.moveName)
self.pendingLearn = nil
self.phase = "resolving"
self:pushAll(self.battle:takeEvents())
self:pushFront(self.battle:takeEvents())
self:advanceQueue()
end
return
@@ -2872,7 +2935,7 @@ function BattleState:finishDecline()
self.pendingLearn = nil
self.phase = "resolving"
if learn then self.battle:declineForget(learn.index, learn.moveName) end
self:pushAll(self.battle:takeEvents())
self:pushFront(self.battle:takeEvents())
self:advanceQueue()
end
@@ -3475,6 +3538,23 @@ function BattleState:printMessage()
end
end
-- MoveInfoBox (engine/battle/core.asm:5403-5478): "TYPE/" at (1,9), the type
-- at (2,10), cur/max PP at (5,11), or "Disabled!" at (1,10).
function BattleState:drawMoveInfoBox(move)
if not move then return end
local fighter = self.battle and self.battle.player
if fighter and self.battle:moveDisabled(fighter, move.id) then
Chrome.print("Disabled!", 1, 10)
return
end
local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id]
Chrome.print("TYPE/", 1, 9)
local moveType = def and def.type
Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10)
Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11)
end
function BattleState:drawPanel()
Chrome.clear()
-- A tutorial battle legitimately has no player mon, so only the enemy is
@@ -3494,7 +3574,15 @@ function BattleState:drawPanel()
-- Message box across the bottom, with the menu window over its right half --
-- the cart draws the prompt into the full-width box and then opens the menu
-- on top, so the tail of a long name is simply covered.
-- MoveSelectionScreen type 0 is two boxes: the name-only list
-- (engine/battle/core.asm:5074-5084) and MoveInfoBox's (:5407-5410).
local moveMenu = self.phase == "moves"
if moveMenu then
Chrome.box(0, 8, 11, 5)
Chrome.box(4, 12, 16, 6)
else
Chrome.box(0, 12, 20, 6)
end
if self.phase == "menu" then
self:printMessage()
local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X
@@ -3520,24 +3608,30 @@ function BattleState:drawPanel()
moves = (mon and mon.moves) or moves
end
local cursorRow = forgetting and self.forgetIndex or self.moveIndex
-- w2DMenuCursorInitX 5 with the names at hlcoord 6 (core.asm:5086-5107).
local cursorCol = moveMenu and 5 or 1
local nameCol = moveMenu and 6 or 2
for i, move in ipairs(moves) do
local ty = 13 + (i - 1)
-- Cursor in the box's own gutter, not clipped against the border.
if i == cursorRow then Chrome.cursor(1, ty) end
if i == cursorRow then Chrome.cursor(cursorCol, ty) end
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
-- picked up for a swap is visible while the cursor moves off it. It
-- sits a column right of the cursor gutter, where the cart puts it
-- (hlcoord 5, 13 against the cursor's own column), and only while the
-- move list itself is up -- the forget picker has no swapping.
if not forgetting and self.moveSwapIndex == i then
Chrome.print("\u{25B7}", 0, ty)
-- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
-- the marker on the cursor's row.
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
Chrome.print("\u{25B7}", cursorCol, ty)
end
local def = self.game and self.game.data and self.game.data.moves
and self.game.data.moves[move.id]
Chrome.print((def and def.name) or move.id, 2, ty)
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 19, ty)
Chrome.print((def and def.name) or move.id, nameCol, ty)
if not moveMenu then
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
19, ty)
end
end
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
else
-- Battle messages wrap inside the box rather than running off the frame.
self:printMessage()
+6 -1
View File
@@ -337,10 +337,15 @@ function PackMenu:useSelected()
return
end
local world = self.world
local result = world and world.useFieldItem and world:useFieldItem(row.id)
local result, extra = nil, nil
if world and world.useFieldItem then result, extra = world:useFieldItem(row.id) end
if result then
if result == "nowhere" then
self.message = OAK_THIS_ISNT_THE_TIME
elseif result == "coin_case" then
-- _CoinCaseCountText (data/text/common_3.asm:336): "Coins:" then the
-- count, text_decimal 4 digits with PRINTNUM_LEFTALIGN_F so no padding.
self.message = { "Coins:", tostring(extra or 0) }
elseif result == "repel_used" then
-- ItemUsedText (data/text/common_3.asm): "<PLAYER> used the\n<ITEM>."
-- World already wrote the counter and took the item out of the bag, so
+14 -3
View File
@@ -150,7 +150,11 @@ function PokedexMenu.new(game, opts)
self.pokemon = opts.pokemon or data.pokemon
self.palettes = opts.palettes or data.gen2Palettes
self.onClose = opts.onClose
-- InitPokedex: wLastDexMode -> wCurDexMode (engine/pokedex/pokedex.asm:97).
self.modeIndex = 1
for i, name in ipairs(MODES) do
if self.save and name == self.save.lastDexMode then self.modeIndex = i end
end
self.index = 1
self.scroll = 0
self.view = "list" -- list | entry | area | option | search | results | unown
@@ -317,6 +321,13 @@ function PokedexMenu:cursorVisible()
return ((self.entryBlink or 0) % 32) < 20
end
-- Pokedex: wCurDexMode -> wLastDexMode on the way out
-- (engine/pokedex/pokedex.asm:60), which lives in the saved game data.
function PokedexMenu:close()
if self.save then self.save.lastDexMode = MODES[self.modeIndex] end
if self.onClose then self.onClose() end
end
function PokedexMenu:update(_dt)
self.entryBlink = (self.entryBlink or 0) + 1
local input = self.game and self.game.input
@@ -328,8 +339,8 @@ function PokedexMenu:update(_dt)
if input:wasPressed("a") or input:wasPressed("b") then
if self.page == 1 then
self.page = 2
elseif self.onClose then
self.onClose()
else
self:close()
end
end
return
@@ -367,7 +378,7 @@ function PokedexMenu:update(_dt)
if self.view == "search" then return self:updateSearch(input) end
if self.view == "unown" then return self:updateUnown(input) end
if input:wasPressed("b") then
if self.onClose then self.onClose() end
self:close()
return
elseif input:wasPressed("select") then
-- Pokedex_UpdateMainScreen: SELECT opens the OPTION screen and START the
+3 -1
View File
@@ -2280,7 +2280,9 @@ function Pokegear:drawPanel()
self:drawClock()
end
-- Last: the arrow is an OBJ and composites over whatever the card drew.
self:drawModeArrow()
-- _FlyMap has no card strip and never animates it
-- (engine/pokegear/pokegear.asm:1999).
if not self.fly then self:drawModeArrow() end
G.setColor(1, 1, 1, 1)
end
+58 -8
View File
@@ -36,9 +36,8 @@
-- $3f the shiny ⁂ icon (stats_tiles tile 14)
-- $40 / $41 the left and right HP/exp bar end caps
--
-- The extractor does not carry that sheet yet, so `pageTile` draws those seven
-- shapes directly and takes the sheet the moment menu_gfx grows a `stats`
-- entry. Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed),
-- The extractor writes that sheet as menu_gfx.stats, which `pageTile` draws.
-- Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed),
-- № ($74), <ID> ($73), <LV> ($6e) and the row-7 rule's $62 (the empty HP/exp
-- bar cell, which is FontBattleExtra's -- hence Font.useBattleExtra(true)
-- around the whole screen, exactly as the party menu does).
@@ -95,6 +94,14 @@ local TILE_BAR_CAP_RIGHT = 0x41
-- made of (StatsScreen_PlaceHorizontalDivider).
local TILE_HORIZONTAL_DIVIDER = 0x62
-- gfx/stats/pages.pal, the three palettes _CGB_StatsScreenHPPals copies to
-- wBGPals1 slots 3-5 (engine/gfx/cgb_layouts.asm:199-212)
local PAGE_PALETTES = {
{ { 255, 255, 255 }, { 255, 156, 255 }, { 255, 123, 255 }, { 0, 0, 0 } },
{ { 255, 255, 255 }, { 173, 255, 115 }, { 140, 255, 0 }, { 0, 0, 0 } },
{ { 255, 255, 255 }, { 140, 255, 255 }, { 140, 255, 255 }, { 0, 0, 0 } },
}
-- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside
-- them. <NEXT> steps two rows, so the five labels are 2 rows apart and the
-- values start one row below the first label.
@@ -821,12 +828,37 @@ end
-- ----------------------------------------------------------------- drawing
-- A tile out of StatsScreenPageTilesGFX. The extractor does not carry that
-- sheet, so each of the seven shapes it needs is drawn here; the moment
-- menu_gfx grows a `stats` entry this can take the real tiles instead.
-- menu_gfx.stats, the 17 tiles LoadStatsScreenPageTilesGFX lands at vTiles2
-- tile $31 (engine/gfx/load_font.asm:90-95)
function SummaryMenu:statsTiles()
if self.statsSheet ~= nil then return self.statsSheet or nil end
local gfx = (self.menuGfx or {}).stats
local image = gfx and self:picImage(gfx.sheet)
if not image then
self.statsSheet = false
return nil
end
local w, h = image:getDimensions()
local quads = {}
for index = 0, (gfx.tiles or 17) - 1 do
quads[(gfx.firstTile or 0x31) + index] =
love.graphics.newQuad(index * 8, 0, 8, 8, w, h)
end
self.statsSheet = { image = image, quads = quads }
return self.statsSheet
end
-- A tile out of StatsScreenPageTilesGFX. The fallback arm draws each of the
-- seven shapes by hand for a cache built before menu_gfx.stats existed.
function SummaryMenu:pageTile(id, tx, ty)
local G = love.graphics
local px, py = tx * 8, ty * 8
local sheet = self:statsTiles()
if sheet and sheet.quads[id] then
G.setColor(1, 1, 1, 1)
G.draw(sheet.image, sheet.quads[id], px, py)
return
end
G.setColor(0, 0, 0, 1)
if id == TILE_VERTICAL_DIVIDER then
G.rectangle("fill", px + 3, py, 2, 8)
@@ -847,11 +879,29 @@ end
-- (17,5), all small ($36) first, then the one for this page redrawn large
-- ($3a). The routine writes the four tiles as [hli]/[hld], a row down, then
-- [hli]/[hl] -- which is why it is a 2x2 block and not a 2x1 strip.
function SummaryMenu:drawPageSquare(tx, ty, large)
function SummaryMenu:drawPageSquare(tx, ty, large, colors)
local G = love.graphics
local px, py = tx * 8, ty * 8
-- $3a..$3d for the page that is up, $36..$39 for the other two.
local first = large and TILE_SQUARE_LARGE or TILE_SQUARE_SMALL
local sheet = self:statsTiles()
if sheet and sheet.quads[first] then
-- [hli] / [hld], a row down, [hli] / [hl]: the four tiles in that order.
local function body()
G.setColor(1, 1, 1, 1)
G.draw(sheet.image, sheet.quads[first], px, py)
G.draw(sheet.image, sheet.quads[first + 1], px + 8, py)
G.draw(sheet.image, sheet.quads[first + 2], px, py + 8)
G.draw(sheet.image, sheet.quads[first + 3], px + 8, py + 8)
end
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
G.setColor(1, 1, 1, 1)
return
end
local inset = first == TILE_SQUARE_LARGE and 2 or 5
local size = 16 - inset * 2
G.setColor(0, 0, 0, 1)
@@ -862,7 +912,7 @@ end
function SummaryMenu:drawPageIndicators()
local columns = { 13, 15, 17 }
for i, tx in ipairs(columns) do
self:drawPageSquare(tx, 5, i == self.page)
self:drawPageSquare(tx, 5, i == self.page, PAGE_PALETTES[i])
end
end
+29 -3
View File
@@ -2969,6 +2969,25 @@ function OverworldState:openPC(onDone)
})
end
-- PKMN LEAGUE sits between PROF.OAK's PC and LOG OFF once wNumHoFTeams
-- is nonzero (engine/pokemon/bills_pc.asm:5, :49)
if #(Game.save.hallOfFame or {}) > 0 then
table.insert(items, {
label = Strings("<PK><MN>LEAGUE"),
keepOpen = true,
onSelect = function()
-- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints
-- AccessedHoFPCText (engine/menus/pc.asm:67, league_pc.asm:2)
require("src.core.Sound").play(Game.data, "Enter_PC")
Game.stack:push(TextBox.new(Game,
romText(Game.data, "_AccessedHoFPCText",
"Accessed POKéMON\nLEAGUE's site.\fAccessed the HALL\nOF FAME List."),
function() Screens.push(Game, "LeaguePC") end))
done()
end,
})
end
local hooked = Runtime.call("ui.pc.items", sameItems, Game, items)
if type(hooked) == "table" then
items = hooked
@@ -3366,7 +3385,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
-- checkVictoryRewards pushes the badge/prize box and starts the map's
-- onVictory script UNDER whatever runs next, so the player still sees
-- EndBattle (now inside the battle), then the reward, then AfterBattle
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
self:checkVictoryRewards(d.trainerClass, d.trainerParty,
endBattleText ~= nil)
self:afterBattle(result, battle)
if onDone then onDone() end
else
@@ -3477,7 +3497,10 @@ end
-- SetEvent / SetEventRange do after the leader victory.
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
-- `shownOnBattleScreen`: `dialogue` already rode the battle screen as the
-- armed end-battle line (scripts/CeruleanGym.asm:113)
function OverworldState:checkVictoryRewards(trainerClass, partyIndex,
shownOnBattleScreen)
local victories = require("data.scripts.victories")
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
if not reward then return self:runVictoryHook() end
@@ -3510,7 +3533,9 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
end
local chain = rewardChain()
if reward.dialogue then
if not shownOnBattleScreen then
chain.add(reward.dialogue, reward.badgeSound)
end
if reward.item then
chain.add(reward.tmPre)
if tmGiven then
@@ -4423,7 +4448,8 @@ function OverworldState:restoreBattleContinuation(battle, origin)
if result == "win" then
game.save.defeatedTrainers[origin.npcId] = true
if origin.event then game.save.flags[origin.event] = true end
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
self:checkVictoryRewards(battle.oppClass, battle.partyIndex,
battle.endBattleText ~= nil)
end
self:afterBattle(result, battle)
self.engaging = false
+37 -9
View File
@@ -391,15 +391,19 @@ end
-- Gen 2 moveset lives in `levelMoves` (EvosAttacks). So every scripted gift,
-- the STARTER included, arrived knowing nothing: FIGHT listed no moves and the
-- battle had no legal action left in it.
local function givePokeMon(data, speciesIndex, level, itemIndex)
local function givePokeMon(data, speciesIndex, level, itemIndex, opts)
local id = speciesByIndex(data.pokemon, speciesIndex)
if not id then return nil end
return Mon.new(data, id, level or 5, {
item = itemIndex and itemIndex ~= 0
and itemByIndex(data.items, itemIndex) or nil,
nickname = opts and opts.nickname or nil,
})
end
-- GivePoke's trainer arm (engine/pokemon/move_mon.asm:1698-1736)
local RANDY_OT_ID = 1001
local function loadGenerated(path)
-- Same NX gold/ fallback Game2 uses. World:load is what surfaces
-- "Gold cache incomplete" when maps.lua is invisible at the unprefixed path.
@@ -997,13 +1001,18 @@ function World:load()
setStringBuffer = function(value)
if self.game then self.game.stringBuffer = value end
end,
givePoke = function(speciesIndex, level, item)
givePoke = function(speciesIndex, level, item, opts)
local data = self.game and self.game.data
local save = self.game and self.game.save
if not (data and save) then return end
save.party = save.party or {}
local mon = givePokeMon(data, speciesIndex, level, item)
local mon = givePokeMon(data, speciesIndex, level, item, opts)
if mon then
if opts and opts.otName then
mon.ot = opts.otName
mon.otName = opts.otName
mon.otId = RANDY_OT_ID
end
-- GivePoke -> TryAddMonToParty -> AddPartyMon (move_mon.asm:44-56, :143-149).
Mon.stampOT(save, mon)
Party.add(save.party, mon)
@@ -1458,13 +1467,11 @@ function World:mapSceneOf(group, mapNum)
end
-- wTimeOfDay (constants/ram_constants.asm): MORN_F 0, DAY_F 1, NITE_F 2,
-- DARKNESS_F 3. Palettes.daytimeFor has already resolved the clock and the
-- map's own PALETTE_* override into one of four names, so this is a rename
-- rather than a second clock.
-- DARKNESS_F 3, off the RTC hour (engine/tilesets/timeofday_pals.asm:5-11)
local TIME_OF_DAY_ID = { MORN = 0, DAY = 1, NITE = 2, DARK = 3 }
function World:timeOfDayId()
return TIME_OF_DAY_ID[self.daytime or "DAY"] or 1
return TIME_OF_DAY_ID[self.tod or self.daytime or "DAY"] or 1
end
-- GetWeekday -> wCurDay, which the RTC counts SUNDAY 0 .. SATURDAY 6 -- the
@@ -4369,6 +4376,8 @@ function World:useFieldItem(itemId)
if itemId == "SACRED_ASH" then return self:useSacredAsh() end
if itemId == "ESCAPE_ROPE" then return self:useEscapeRope(itemId) end
if itemId == "SQUIRTBOTTLE" then return self:useSquirtbottle() end
-- CoinCaseEffect (engine/items/item_effects.asm:2243).
if itemId == "COIN_CASE" then return "coin_case", self:coins() end
if REPEL_STEPS[itemId] then return self:useRepel(itemId) end
if TROPHY_BOXES[itemId] then return self:openTrophyBox(itemId) end
if not World.isRod(itemId, items) then return nil end
@@ -5802,7 +5811,9 @@ function World:battleMusicContext(opts)
members = trainer and trainer.classId and members
and members[trainer.classId] or nil,
landmark = self.map and self.map.def and self.map.def.landmark,
daytime = self.daytime,
-- PlayBattleMusic reads wTimeOfDay (engine/battle/start_battle.asm:24),
-- not the map's pinned palette set.
daytime = self.tod,
}
end
@@ -5999,6 +6010,18 @@ function World:startBattle(opts, onDone)
return true
end
-- wWinTextPointer / wLossTextPointer (home/trainers.asm:120), overwritten by
-- `winlosstext` (engine/overworld/scripting.asm:651)
function World:trainerWinLossText()
local vm = self.vm
if not vm then return nil, nil end
local obj = vm.trainerObject or {}
local text = self.text or {}
local winKey = vm.winTextOverride or obj.winText
local lossKey = vm.lossTextOverride or obj.lossText
return winKey and text[winKey] or nil, lossKey and text[lossKey] or nil
end
-- `startbattle` from a script: a trainer record (class + member) or a
-- loadwildmon pair. The VM is parked on the yield until onDone fires, so the
-- rest of the trainer script (flag set, after-battle text) runs on return.
@@ -6057,6 +6080,9 @@ function World:startScriptedBattle(record, wild, onDone)
attributes = record.attributes,
items = record.items,
}
-- wWinTextPointer / wLossTextPointer, read by PrintWinLossText on the
-- battle screen (home/trainers.asm:230) (#1512)
opts.trainer.winText, opts.trainer.lossText = self:trainerWinLossText()
elseif wild and wild.species then
local id, def = speciesByIndex(data and data.pokemon, wild.species)
-- InitEnemyMon `.NotRoaming` / BATTLETYPE.FORCESHINY: the DV pair is
@@ -9194,8 +9220,10 @@ function World:stepContext()
local def = self.map and self.map.def
return {
data = self.game and self.game.data,
-- CheckTime reads wTimeOfDay (engine/events/checktime.asm:2), so the
-- caller windows follow the clock even inside a pinned-palette room.
phone = {
map = def, maps = self.maps, daytime = self.daytime,
map = def, maps = self.maps, daytime = self.tod,
clock = self.game and self.game.clock,
},
-- GetMapPhoneService: zero means the map HAS service, which maps.lua has
+46
View File
@@ -0,0 +1,46 @@
-- #1479: TILESET_KANTO roof probe (LoadMapGroupRoof, home/map.asm:1738-1749)
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bug1479_probe.lua love .
local U = require("tests.drivers.util")
local SPOTS = {
{ "ROUTE_28", 6, 6 },
{ "SILVER_CAVE_OUTSIDE", 10, 20 },
{ "SILVER_CAVE_OUTSIDE", 10, 8 },
{ "ROUTE_22", 10, 8 },
{ "VIRIDIAN_CITY", 10, 10 },
}
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-kanto"
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
local failed = false
for i, spot in ipairs(SPOTS) do
local def = world.maps[spot[1]]
if not def then
U.log("FAIL no map", spot[1])
failed = true
else
world:setMap(spot[1], spot[2], spot[3], "down")
U.wait(10)
U.shot(game, ("%s/%02d-%s.png"):format(out, i, spot[1]:lower()))
-- home/map.asm:1738-1749: a Kanto-tileset map takes no map-group roof,
-- so its atlas is cached under the bare tileset name.
if def.tileset == "TILESET_KANTO" then
for key in pairs(world.atlasCache or {}) do
if key:find("TILESET_KANTO|", 1, true) then
U.log("FAIL Kanto atlas took a roof:", key)
failed = true
end
end
end
U.log("shot", spot[1], def.tileset)
end
end
U.log(failed and "RESULT FAIL" or "RESULT PASS", "shots in", out)
love.event.quit(failed and 1 or 0)
end
+70
View File
@@ -0,0 +1,70 @@
-- #1569 givepoke names (scripting.asm:1817, move_mon.asm:1698-1736)
-- POKEPORT_GAME=gold POKEPORT_DRIVER=tests/drivers/gold_bug1569_test.lua love .
local U = require("tests.drivers.util")
local function findGivepoke(scripts)
for key, rows in pairs(scripts) do
if type(rows) == "table" then
for _, row in ipairs(rows) do
if row.op == "givepoke" and (row.trainer or 0) ~= 0 then
return key, row
end
end
end
end
return nil
end
return function(game)
U.wait(45)
local world = game.world
assert(world and world.vm, "gold world did not boot")
local failed = false
local key, row = findGivepoke(world.scripts or {})
if not row then
U.log("FAIL no trainer-form givepoke in the extracted scripts")
failed = true
else
U.log("givepoke row from", tostring(key))
if row.name ~= "KENYA" then
U.log("FAIL nickname is", tostring(row.name), "want KENYA")
failed = true
end
if row.otName ~= "RANDY" then
U.log("FAIL OT name is", tostring(row.otName), "want RANDY")
failed = true
end
end
game.save.party = {}
local give = world.vm.givePokeFn
assert(give, "the VM has no givePoke hook")
local mon = give(row and row.species or 21, row and row.level or 10, 0,
{ nickname = "KENYA", otName = "RANDY" })
if not mon then
U.log("FAIL givePoke made no mon")
failed = true
else
if mon.nickname ~= "KENYA" then
U.log("FAIL mon nickname is", tostring(mon.nickname))
failed = true
end
if mon.otName ~= "RANDY" or mon.ot ~= "RANDY" then
U.log("FAIL mon OT is", tostring(mon.ot), tostring(mon.otName))
failed = true
end
if mon.otId ~= 1001 then
U.log("FAIL mon OT id is", tostring(mon.otId), "want RANDY_OT_ID 1001")
failed = true
end
if mon.species ~= "SPEAROW" then
U.log("FAIL species is", tostring(mon.species))
failed = true
end
end
U.log(failed and "RESULT FAIL" or "RESULT PASS")
love.event.quit(failed and 1 or 0)
end
+105
View File
@@ -0,0 +1,105 @@
-- The bag's item list is LIST_MENU_BOX (#1521): home/list_menu.asm:29-31,
-- :51-52, :364-365, :471-479, :518-521, data/text_boxes.asm:13
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
-- Font wants a real atlas; the geometry is what this suite is about, so it
-- records the calls instead. ListMenu and Theme bind Font at require time.
local realFont = package.loaded["src.render.Font"]
local calls = {}
package.loaded["src.render.Font"] = {
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
width = function(text) return #tostring(text) * 8 end,
}
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
local ListMenu = require("src.ui.ListMenu")
local Theme = require("src.ui.Theme")
local function found(kind, pred)
for _, c in ipairs(calls) do
if c[1] == kind and pred(c) then return c end
end
return nil
end
local function newList(count)
local items = {}
for i = 1, count do
items[i] = { value = "ITEM_" .. i, label = "ITEM " .. i, right = "x" .. i }
end
return ListMenu.new({}, "ITEMS", items, { kind = "bag", itemBox = true })
end
do
local list = newList(6)
eq(list.rows, 4, "PrintListMenuEntries prints 4 names, not 7")
eq(list.isOpaque, false,
"the box is partial, so the map keeps drawing behind it")
calls = {}
list:draw()
local box = found("box", function(c) return true end)
if check(box ~= nil, "the list draws LIST_MENU_BOX") then
eq(box[2], 4, "upper-left X 4")
eq(box[3], 2, "upper-left Y 2")
eq(box[4], 16, "through lower-right X 19")
eq(box[5], 11, "through lower-right Y 12")
end
-- names at hlcoord 6, 4 and every two rows after it
for row = 1, 4 do
local y = 32 + (row - 1) * 16
check(found("draw", function(c)
return c[2] == "ITEM " .. row and c[3] == 48 and c[4] == y
end) ~= nil, "name " .. row .. " sits at (48, " .. y .. ")")
end
check(found("draw", function(c) return c[2] == "ITEM 5" end) == nil,
"the fifth name is scrolled out, not printed below the box")
-- the quantity: '×' at column 14, the count right-aligned after it
check(found("draw", function(c)
return c[2] == "x" and c[3] == 112 and c[4] == 40
end) ~= nil, "the first quantity's '×' is a row down at column 14")
check(found("draw", function(c)
return c[2] == "1" and c[3] == 128 and c[4] == 40
end) ~= nil, "with the count right-aligned in the two columns after it")
check(found("code", function(c)
return c[2] == Theme.cursor and c[3] == 40 and c[4] == 32
end) ~= nil, "the cursor is in column 5 (wTopMenuItemX)")
check(found("code", function(c)
return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88
end) ~= nil, "a full page ends with the '▼'")
-- nothing else: no title, no money footer (wPrintItemPrices = 0)
check(found("draw", function(c) return tostring(c[2]):find("¥") end) == nil,
"the everyday bag has no money box (that is the mart's screen)")
check(found("draw", function(c) return c[2] == "ITEMS" end) == nil,
"and no title row: the box carries no header text")
end
-- a short list stops at its last name, and the terminator's CANCEL row is
-- what would follow -- never the '▼'
do
local list = newList(2)
calls = {}
list:draw()
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
"a page that runs out of names prints no '▼' (:372)")
end
package.loaded["src.render.Font"] = realFont
package.loaded["src.ui.ListMenu"] = nil
package.loaded["src.ui.Theme"] = nil
require("src.ui.Screens").invalidate()
T.finish()
@@ -0,0 +1,112 @@
-- SwitchPlayerMon (core.asm:2419-2423), AnimateRetreatingPlayerMon (:1769-1796)
-- (#1563); pokeyellow core.asm:1862-1866 (#1545); core.asm:1471-1488 (#1608)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40), Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
battle.rng = function(a) if a then return a end return 0 end
return battle
end
-- ---------------------------------------------------------------------
-- the shrink stages and their frame budget
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle:queueRetreatAnim()
T.eq(#battle.queue, 2, "the retreat queues its start act plus the hold")
T.eq(battle.queue[2].wait, 7, "4 frames at 5x5 then Delay3 at 3x3")
T.check(battle:shrinkOutScale(battle.player) == nil, "nothing shrinks yet")
battle.queue[1].fn()
T.eq(battle.shrinkOut.battler, battle.player, "the outgoing pic is the one drawn")
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "wDownscaledMonSize 0 -> 5x5")
battle.shrinkOut.frame = 3
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "for four frames")
battle.shrinkOut.frame = 4
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "wDownscaledMonSize 1 -> 3x3")
battle.shrinkOut.frame = 6
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "through Delay3")
T.check(battle:shrinkOutScale(battle.enemy) == nil,
"AnimateRetreatingPlayerMon is player-side only")
end
-- ---------------------------------------------------------------------
-- the Yellow starter slides off instead of shrinking
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.starterPikachuSendOut = function() return true end
battle.queue, battle.nextInsert = {}, 0
battle:queueRetreatAnim()
T.eq(#battle.queue, 3, "start act, the slide's hold, then the slot clear")
battle.queue[1].fn()
local slide = battle.picOff and battle.picOff.playerMon
T.check(slide ~= nil, "the back pic slot is sliding")
T.eq(slide.to, -64, "8 tiles off the left edge")
T.eq(slide.hold, 3, "wSlideMonDelay 3 V-blanks per tile")
T.eq(battle.queue[2].wait, 24, "8 tiles x 3 frames")
T.check(battle:shrinkOutScale(battle.player) == nil, "and no downscale stage")
battle.queue[3].fn()
T.check((battle.picOff or {}).playerMon == nil, "the slot clears afterwards")
end
-- ---------------------------------------------------------------------
-- resolveSwitch runs the retreat between the withdraw text and the swap
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
local outgoing = battle.player.mon
battle:resolveSwitch(battle.game.save.party[2])
local withdraw = table.remove(battle.queue, 1)
battle.nextInsert = 0
withdraw.fn()
T.check(battle.queue[1] and battle.queue[1].text ~= nil, "RetreatMon text first")
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "then the retreat start")
T.eq(battle.queue[3] and battle.queue[3].wait, 7, "then its hold")
T.eq(battle.player.mon, outgoing, "the party slot has not been swapped yet")
end
-- ---------------------------------------------------------------------
-- a fainted pick reopens the party list (#1608)
-- ---------------------------------------------------------------------
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.buildScreen = function(_, _, opts) return opts end
battle:openParty()
local opts = battle.queue[1].ui()
battle.queue, battle.nextInsert = {}, 0
battle.game.save.party[2].hp = 0
opts.onSwitch(battle.game.save.party[2])
T.check(battle.queue[1] and battle.queue[1].text
and battle.queue[1].text:find("no will", 1, true) ~= nil,
"HasMonFainted prints NoWillText")
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "and queues the reprompt")
battle.queue[2].fn()
T.check(battle.queue[3] and battle.queue[3].ui ~= nil,
"GoBackToPartyMenu puts the list back up")
battle.queue, battle.nextInsert = {}, 0
opts.onSwitch(battle.player.mon)
T.check(battle.queue[1] and battle.queue[1].text
and battle.queue[1].text:find("already out", 1, true) ~= nil,
"AlreadyOutText for the mon that is already out")
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "which also reprompts")
end
T.finish("retreat animation and switch reprompt (#1563, #1545, #1608)")
@@ -0,0 +1,64 @@
-- HandleSelfConfusionDamage (engine/battle/core.asm:3672-3714, enemy side
-- :5806-5811) (#1578)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function newBattle()
local save = SaveData.newGame()
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 40)
-- cp 50 percent + 1: rand < 128 hurts the user
battle.rng = function() return 0 end
return battle
end
local function rowsOf(battle)
local out = {}
for _, item in ipairs(battle.queue) do
if item.anim then out[#out + 1] = item end
end
return out
end
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.player.confusedTurns = 3
T.eq(battle:statusInterrupt(battle.player, battle.enemy, nil), true,
"the self-hit interrupts the player's action")
local anims = rowsOf(battle)
T.eq(#anims, 2, "the IsConfusedText onomatopoeia, then the self-hit's own")
T.eq(anims[1].anim, "CONF_PLAYER_ANIM", "CONF_PLAYER_ANIM rides IsConfusedText")
T.eq(anims[2].anim, "POUND", "wAnimationID 1 is POUND")
T.eq(anims[2].attackerIsPlayer, false,
"hWhoseTurn is flipped to the opponent, so it plays against the player")
T.check(anims[2].hit == nil, "wAnimationType 0 adds no shake or blink layer")
local text, anim
for i, item in ipairs(battle.queue) do
if item.text and item.text:find("confusion", 1, true) then text = text or i end
if item.anim == "POUND" then anim = i end
end
T.check(text and anim and text < anim, "HurtItselfText prints before the animation")
end
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.enemy.confusedTurns = 3
battle:statusInterrupt(battle.enemy, battle.player, nil)
local anims = rowsOf(battle)
T.eq(anims[#anims].anim, "POUND", "the enemy side animates too")
T.eq(anims[#anims].attackerIsPlayer, true, "from the player's side of hWhoseTurn")
end
T.finish("confusion self-hit animation (#1578)")
@@ -126,6 +126,17 @@ local function useStone(game)
game.input.pressed = "a"
picker:update(1 / 60)
game.input.pressed = nil
-- IsEvolvingText hands off to the movie when it closes
-- (engine/pokemon/evos_moves.asm:120-134)
local intro = game.stack:top()
if not (intro and intro.textBox and intro.done) then
return nil, "the \"is evolving!\" box never opened"
end
if not tostring(intro.text):find("evolving") then
return nil, "the box before the movie is not _IsEvolvingText"
end
game.stack:pop()
intro.done()
return list
end
@@ -0,0 +1,133 @@
-- The evolution dialogue is the cart's, in the cart's order (#1596):
-- engine/pokemon/evos_moves.asm:120-128, :136-150, :151-153
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local played = {}
package.loaded["src.core.Sound"] = {
play = function(_, id) played[#played + 1] = id end,
playCry = function() end,
}
local Fixtures = require("tests.modkit.fixtures")
local Evolution = require("src.pokemon.Evolution")
local EvolutionState = require("src.ui.EvolutionState")
local Input = require("src.core.Input")
local Pokemon = require("src.pokemon.Pokemon")
local StateStack = require("src.core.StateStack")
local TextBox = require("src.render.TextBox")
local Data = Fixtures.fresh()
require("src.render.Font").load(Data)
local EVO_LEVEL = 16
local function newGame()
local game = { data = Data }
local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL)
game.save = {
party = { mon },
player = { name = "RED", id = 1 },
options = { textSpeed = 5 },
flags = {},
pokedex = { seen = {}, owned = {} },
}
game.stack = setmetatable({}, { __index = StateStack })
game.stack:init()
game.input = Input
Input:init()
return game, mon
end
local function step(game)
game.input:step()
game.stack:update(1 / 60)
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- The box that goes up before the movie, and the frames it holds for.
do
local game, mon = newGame()
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
local intro = game.stack:top()
if check(getmetatable(intro) == TextBox,
"_IsEvolvingText goes up in a real bordered text box first") then
check(textOf(intro):find("is evolving"),
"and it is the cart's line: " .. textOf(intro))
check(intro.auto ~= nil and not intro.auto.wait,
"which waits for no button (IsEvolvingText ends in `done`)")
eq(intro.auto.delay, 50,
"and holds DelayFrames 50 before it clears (evos_moves.asm:122)")
end
-- it hands off to the movie on its own, with no input at all
local top
for _ = 1, 900 do
top = game.stack:top()
if getmetatable(top) == EvolutionState then break end
step(game)
end
check(getmetatable(top) == EvolutionState,
"the flash movie opens once that box has cleared itself")
eq(mon.species, "FIXMON_A",
"and nothing has evolved yet while the box was up")
end
-- What closes the movie: EvolvedText + IntoText, and the jingle.
do
local game, mon = newGame()
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
local evo
for _ = 1, 900 do
evo = game.stack:top()
if getmetatable(evo) == EvolutionState then break end
step(game)
end
assert(getmetatable(evo) == EvolutionState, "the movie never opened")
played = {}
for _ = 1, 600 do
if evo.done then break end
step(game)
end
eq(mon.species, "FIXMON_B", "the mon evolved")
local box = game.stack:top()
if check(getmetatable(box) == TextBox, "and the result text is a text box") then
local said = textOf(box)
check(said:find("evolved") and said:find("into"),
"_EvolvedText + _IntoText print together: " .. said)
check(not said:find("Congratulations"),
"no fabricated \"Congratulations!\" line (it is in no ROM)")
check(box.auto ~= nil and box.auto.sound ~= nil,
"and the box carries a jingle the way sound_get_item_1 boxes do")
-- type it out; auto.sound fires once the last page has landed
for _ = 1, 900 do
if box.autoStarted then break end
step(game)
end
local heard = false
for _, id in ipairs(played) do
if id == "Get_Item2" then heard = true end
end
check(heard, "SFX_GET_ITEM_2 plays on that box (evos_moves.asm:151)")
end
end
-- The flash itself carries no text: EvolutionState draws sprites only.
do
local source = assert(io.open("src/ui/EvolutionState.lua")):read("*a")
check(not source:find("Font%.draw"),
"EvolutionState draws no bare text over the flash "
.. "(ClearScreenArea ran before EvolveMon, evos_moves.asm:128)")
end
T.finish()
+23 -9
View File
@@ -52,6 +52,14 @@ local function step(game)
game.stack:update(1 / 60)
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- the post-battle sequence: grew-to-level box, then Evolution.checkParty
local function levelUpBox(game, mon)
game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!",
@@ -69,21 +77,27 @@ local function dismissWithB(game, mon)
if not box.done then return nil, "the level-up text never finished typing" end
Input:keypressed(B_KEY)
step(game)
local top = game.stack:top()
-- IsEvolvingText holds its own box for DelayFrames 50 before EvolveMon
-- runs (engine/pokemon/evos_moves.asm:120-134)
local intro = game.stack:top()
if getmetatable(intro) ~= TextBox then
return nil, "the \"is evolving!\" box never opened"
end
if not textOf(intro):find("is evolving") then
return nil, "the box before the movie is not _IsEvolvingText"
end
local top
for _ = 1, 900 do
top = game.stack:top()
if getmetatable(top) == EvolutionState then break end
step(game)
end
if getmetatable(top) ~= EvolutionState then
return nil, "the evolution screen never opened"
end
return top
end
local function textOf(box)
local out = {}
for _, page in ipairs(box.pages) do
for _, line in ipairs(page) do out[#out + 1] = line end
end
return table.concat(out, " ")
end
-- B held out of the text box: the movie must run to the end and evolve.
do
local game, mon = newGame()
@@ -0,0 +1,38 @@
-- wLastDexMode (engine/pokedex/pokedex.asm:60, :97) (#1474)
-- luajit tests/engine/gen2_dex_mode_persist_bug1474.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
local save = { position = { map = "ROUTE_30" } }
local game = { data = {}, save = save }
local first = PokedexMenu.new(game, {})
eq(first.modeIndex, 1, "a save with no remembered mode opens in NEW")
-- what the OPTION screen's .ChangeMode leaves behind
first.modeIndex = 3
first:close()
eq(save.lastDexMode, "A-Z", "closing the dex writes the live mode into the save")
local second = PokedexMenu.new(game, {})
eq(second.modeIndex, 3, "reopening the dex restores the remembered mode")
second.modeIndex = 2
second:close()
eq(PokedexMenu.new(game, {}).modeIndex, 2, "OLD survives the same way")
local fresh = PokedexMenu.new({ data = {}, save = {} }, {})
eq(fresh.modeIndex, 1, "a fresh save still starts on NEW")
local closed = false
local menu = PokedexMenu.new(game, { onClose = function() closed = true end })
menu:close()
check(closed, "close still runs the caller's onClose")
T.finish("gen2 pokedex mode persistence bug 1474")
@@ -0,0 +1,32 @@
-- The Fly map draws no Pokegear mode arrow (engine/pokegear/pokegear.asm:1999) (#1477)
-- luajit tests/engine/gen2_fly_map_arrow_bug1477.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check = T.check
love = love or require("tests.love_stub")
local Pokegear = require("src.ui.gen2.Pokegear")
local function drawnArrow(fly)
local drew = false
local self = setmetatable({
fly = fly,
styled = function() return true end,
groundColor = function() return { 0, 0, 0 } end,
card = function() return { id = "map", label = fly and "FLY" or "MAP" } end,
drawMap = function() end,
drawModeArrow = function() drew = true end,
}, { __index = Pokegear })
local realRect = love.graphics.rectangle
love.graphics.rectangle = function() end
self:drawPanel()
love.graphics.rectangle = realRect
return drew
end
check(drawnArrow(false), "the Pokegear MAP card still animates the arrow")
check(not drawnArrow(true), "_FlyMap draws no mode-indicator arrow")
T.finish("gen2 fly map arrow bug 1477")
@@ -0,0 +1,37 @@
-- #1479 / #1449: TILESET_KANTO takes no map-group roof (home/map.asm:1738-1749)
-- luajit tests/engine/gen2_kanto_no_roof_bug1479.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local MapPreview = require("src.world.gen2.MapPreview")
local baker = {
tilesets = {
TILESET_KANTO = { image = "assets/generated/tilesets/kanto.png" },
TILESET_JOHTO = { image = "assets/generated/tilesets/johto.png" },
},
roofs = {
mapGroupRoofs = { [19] = "ROOF_SILVER" },
roofs = { ROOF_SILVER = {} },
},
atlasCache = {},
mapImages = {},
}
-- data/maps/maps.asm:396: SilverCaveOutside and Route28 are both group 19
-- (MapGroup_Silver) and both TILESET_KANTO.
MapPreview.atlasFor(baker, { tileset = "TILESET_KANTO", group = 19 })
MapPreview.atlasFor(baker, { tileset = "TILESET_JOHTO", group = 19 })
local keys = {}
for key in pairs(baker.atlasCache) do keys[key] = true end
T.check(keys["TILESET_KANTO"],
"the Kanto atlas is cached under the bare tileset, with no roof")
T.check(not keys["TILESET_KANTO|ROOF_SILVER"],
"and never under a map-group roof")
T.check(keys["TILESET_JOHTO|ROOF_SILVER"],
"while TILESET_JOHTO still takes its group's roof")
print("gen2 Kanto roof gate (#1479): ok")
+205
View File
@@ -0,0 +1,205 @@
-- engine/battle/core.asm:2310-2323 (WinTrainerBattle), :2763-2782 (LostBattle),
-- home/trainers.asm:120 and :230 (PrintWinLossText)
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local T = require("tests.harness")
local Battle = require("src.battle.gen2.Battle")
local Mon = require("src.battle.gen2.Mon")
local World = require("src.world.gen2.World")
local LEVEL = 10
local MOVES = {
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
}
local POKEMON = {
growthRates = {
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
linear = 0, constant = 0 },
},
RATTATA = {
id = "RATTATA", index = 19, name = "RATTATA",
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
specialAttack = 25, specialDefense = 35 },
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51,
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
},
}
local DATA = {
pokemon = POKEMON,
moves = MOVES,
type_chart = { types = { NORMAL = { id = "NORMAL", index = 0,
category = "physical" } }, matchups = {} },
items = {},
}
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
perfect.hp = Mon.hpDV(perfect)
local function mon()
local m = Mon.new(DATA, "RATTATA", LEVEL, { dvs = perfect })
m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
return m
end
local function newSave()
return { player = { name = "GOLD", money = 1000, id = 4242 },
mom = { savedMoney = 0 }, party = {} }
end
local function newBattle(trainer, battleType)
local save = newSave()
local battle = Battle.new({ data = DATA, party = { mon() },
trainer = trainer, battleType = battleType, save = save,
random = function(n) return 99 % math.max(1, n or 1) end })
return battle, save
end
local function trainerRecord(extra)
local record = { name = "SAGE CHOW", baseMoney = 3, party = { mon() } }
for key, value in pairs(extra or {}) do record[key] = value end
return record
end
local function kinds(events)
local out = {}
for i, event in ipairs(events or {}) do out[i] = event.kind end
return table.concat(out, ",")
end
local function indexOf(events, kind)
for i, event in ipairs(events or {}) do
if event.kind == kind then return i, event end
end
return nil
end
-- The win arm: defeated line, frontpic slide-in, the trainer's own line, money.
do
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
battle.enemy.hp = 0
T.check(battle:resolveFaints(), "the last enemy mon ends the battle")
local events = battle:takeEvents()
local defeated = indexOf(events, "faint")
local ret = indexOf(events, "trainer-return")
local win, winEvent = indexOf(events, "win-text")
local money = indexOf(events, "money")
T.check(ret and win and money, "all three win rows are queued: " ..
kinds(events))
T.check(defeated < ret, "BattleText_EnemyWasDefeated comes first")
T.check(ret < win, "the frontpic slides back in before the line")
T.check(win < money, "and PrintWinLossText runs before the payout")
T.eq(winEvent.text, "Th-Thank you!", "the struct's win text is printed")
end
-- The pic comes back whether or not there is a line: the DEBUG_BATTLE_F skip
-- sits in front of PrintWinLossText alone.
do
local battle = newBattle(trainerRecord())
battle.enemy.hp = 0
battle:resolveFaints()
local events = battle:takeEvents()
T.check(indexOf(events, "trainer-return"), "the slide-in is unconditional")
T.eq(indexOf(events, "win-text"), nil, "with no line to print")
end
-- A wild battle has no trainer and no line.
do
local save = newSave()
local wild = mon()
local battle = Battle.new({ data = DATA, party = { mon() }, wild = wild,
save = save, random = function(n) return 99 % math.max(1, n or 1) end })
battle.enemy.hp = 0
battle:resolveFaints()
local events = battle:takeEvents()
T.eq(indexOf(events, "trainer-return"), nil, "no frontpic to slide back in")
T.eq(indexOf(events, "win-text"), nil, "and nothing to print")
end
-- The loss arm: only BATTLETYPE_CANLOSE reaches PrintWinLossText.
do
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!",
lossText = "...Too weak..." }), Battle.BATTLETYPE_CANLOSE)
battle.player.hp = 0
T.check(battle:resolveFaints(), "the whiteout ends the battle")
local events = battle:takeEvents()
local _, lossEvent = indexOf(events, "win-text")
T.check(lossEvent, "the loss line is printed: " .. kinds(events))
T.eq(lossEvent.text, "...Too weak...", "wLossTextPointer, not the win one")
end
do
local battle = newBattle(trainerRecord({ lossText = "...Too weak..." }))
battle.player.hp = 0
battle:resolveFaints()
local events = battle:takeEvents()
T.eq(indexOf(events, "win-text"), nil, "an ordinary loss whites out instead")
end
-- wWinTextPointer / wLossTextPointer: the map object's struct, or whatever
-- `winlosstext` overwrote the pair with.
do
local world = { text = { ["3:4000"] = "Th-Thank you!",
["3:4100"] = "...Too weak...", ["3:4200"] = "Scripted win." } }
world.vm = { trainerObject = { winText = "3:4000", lossText = "3:4100" } }
local win, loss = World.trainerWinLossText(world)
T.eq(win, "Th-Thank you!", "the struct's win text is decoded")
T.eq(loss, "...Too weak...", "and its loss text with it")
world.vm.winTextOverride = "3:4200"
win = World.trainerWinLossText(world)
T.eq(win, "Scripted win.", "`winlosstext` overwrites the pointer")
world.vm.trainerObject = nil
world.vm.winTextOverride = nil
win, loss = World.trainerWinLossText(world)
T.eq(win, nil, "a battle with no trainer object has no line")
T.eq(loss, nil, "on either side")
world.vm = nil
T.eq(World.trainerWinLossText(world), nil, "and neither has one with no VM")
end
-- The screen side: the slide-in owns the frames the way SlideBattlePicOut
-- does, and the line pages like the map text it is.
local BattleState = require("src.ui.gen2.BattleState")
local function newScreen(battle, queue, image)
return setmetatable({
battle = battle, queue = queue, picHidden = {}, evolvable = {},
phase = "resolving", messageTimer = 0, enemyTrainerImage = image,
}, { __index = BattleState })
end
do
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
local screen = newScreen(battle, { { kind = "trainer-return" },
{ kind = "win-text", text = "Th-Thank you!" } }, {})
screen:advanceQueue()
T.eq(screen.winSlide, 0, "the slide starts on the frame the event runs")
T.check(screen.winSliding, "and owns the screen while it runs")
T.check(screen.showEnemyTrainer, "the beaten trainer is back on the field")
T.eq(screen.picHidden.enemy, false, "in the box the fainted mon left empty")
T.eq(#screen.queue, 1, "the line is still waiting behind it")
end
do
local battle = newBattle(trainerRecord())
local screen = newScreen(battle, { { kind = "trainer-return" },
{ kind = "win-text", text = "Th-Thank you!\fReally." } }, nil)
screen:advanceQueue()
T.eq(screen.winSlide, nil, "no cached pic, no slide")
T.eq(screen.message, "Th-Thank you!", "the line runs straight away")
T.check(screen.messagePages, "with its `para` page held back")
T.check(screen:nextPage(), "which the queue waits for")
T.eq(screen.message, "Really.", "before the second page shows")
end
T.finish("gen2 win loss text bug 1512")
@@ -0,0 +1,139 @@
-- A gym leader's badge line prints from TrainerBattleVictory (#1606):
-- scripts/CeruleanGym.asm:111, PewterGym.asm:117, home/trainers.asm:341
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local gyms = require("data.scripts.gyms")
local victories = require("data.scripts.victories")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
-- one page of text per label, so a joined chain is readable in a failure
local text = {}
for _, key in ipairs({ "OPP_BROCK#1", "OPP_MISTY#1", "OPP_LT_SURGE#1",
"OPP_ERIKA#1", "OPP_KOGA#1", "OPP_SABRINA#1",
"OPP_BLAINE#1", "OPP_GIOVANNI#3" }) do
for _, label in ipairs(victories[key].dialogue or {}) do
text[label] = "text:" .. label
end
for _, label in ipairs(victories[key].tmPre or {}) do
text[label] = "text:" .. label
end
for _, label in ipairs(victories[key].tmDialogue or {}) do
text[label] = "text:" .. label
end
end
local fakeGame = { data = { text = text }, save = { flags = {} } }
local armed
local fakeOw = {
engageTrainer = function(_, _, _, endBattleText) armed = endBattleText end,
}
local function armedFor(mapId, textId, victoryKey)
armed = nil
gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end)
local labels = victories[victoryKey].dialogue
local want = {}
for i, label in ipairs(labels) do want[i] = text[label] end
return armed, table.concat(want, "\f")
end
-- every leader, in badge order
local leaders = {
{ "PEWTER_GYM", "TEXT_PEWTERGYM_BROCK", "OPP_BROCK#1" },
{ "CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", "OPP_MISTY#1" },
{ "VERMILION_GYM", "TEXT_VERMILIONGYM_LT_SURGE", "OPP_LT_SURGE#1" },
{ "CELADON_GYM", "TEXT_CELADONGYM_ERIKA", "OPP_ERIKA#1" },
{ "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_KOGA", "OPP_KOGA#1" },
{ "SAFFRON_GYM", "TEXT_SAFFRONGYM_SABRINA", "OPP_SABRINA#1" },
{ "CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", "OPP_BLAINE#1" },
{ "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", "OPP_GIOVANNI#3" },
}
for _, entry in ipairs(leaders) do
local got, want = armedFor(entry[1], entry[2], entry[3])
T.eq(got, want, entry[3] .. " arms its badge line for the battle screen")
end
-- Brock's armed label is one text chain of two text_far pages
-- (PewterGymBrockReceivedBoulderBadgeText), so both ride the battle screen
local brock = select(1, armedFor("PEWTER_GYM", "TEXT_PEWTERGYM_BROCK",
"OPP_BROCK#1"))
T.check(brock:find("\f", 1, true) ~= nil,
"Brock's badge line keeps its BoulderBadgeInfo page")
-- the beaten branch still talks instead of re-engaging
armed = nil
fakeGame.save.flags.EVENT_BEAT_MISTY = true
fakeGame.save.flags.EVENT_GOT_TM11 = true
local realStack = { push = function() end }
gyms.CERULEAN_GYM.talk.TEXT_CERULEANGYM_MISTY(
{ data = { text = text }, save = fakeGame.save, stack = realStack },
fakeOw, { id = "npc#1" }, function() end)
T.eq(armed, nil, "a beaten leader does not re-arm the badge line")
fakeGame.save.flags.EVENT_BEAT_MISTY = nil
fakeGame.save.flags.EVENT_GOT_TM11 = nil
-- checkVictoryRewards must not reprint what the battle screen showed
local boxes
local textBoxStub = {
new = function(_, str, onDone)
boxes[#boxes + 1] = str
return { onDone = onDone }
end,
soundOpts = function() return nil end,
}
local pushed
local rewardGame = {
data = { text = text, items = { TM_BUBBLEBEAM = { name = "TM11" } } },
save = { flags = {}, inventory = {}, player = { name = "RED" } },
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
}
T.check(setUpvalue(OW.checkVictoryRewards, "Game", rewardGame),
"Game upvalue on checkVictoryRewards")
-- TextBox is only named inside the rewardChain closure; the chunk-level
-- upvalue cell is shared, so any closure that names it will do
T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub),
"TextBox upvalue on the reward chain")
local fakeSelf = setmetatable({
map = { id = "CERULEAN_GYM", def = { label = "CeruleanGym" } },
runVictoryHook = function() end,
}, { __index = OW })
local function rewardPages(shownOnBattleScreen)
boxes, pushed = {}, {}
rewardGame.save.flags = {}
rewardGame.save.inventory = {}
fakeSelf:checkVictoryRewards("OPP_MISTY", 1, shownOnBattleScreen)
-- the chain pushes one box at a time; walk it to the end
local i = 1
while pushed[i] do
local box = pushed[i]
i = i + 1
if box.onDone then box.onDone() end
end
return table.concat(boxes, "\f")
end
local badge = text["_CeruleanGymMistyReceivedCascadeBadgeText"]
local onMap = rewardPages(false)
T.check(onMap:find(badge, 1, true) ~= nil,
"without the battle-screen line the reward chain still shows the badge text")
local afterBattleScreen = rewardPages(true)
T.eq(afterBattleScreen:find(badge, 1, true), nil,
"the badge line is not reprinted on the map once the battle screen showed it")
T.check(afterBattleScreen:find(text["_CeruleanGymMistyCascadeBadgeInfoText"],
1, true) ~= nil,
"the TM hand-over still runs on the map")
T.check(rewardGame.save.inventory.CASCADEBADGE == 1, "the badge is still given")
@@ -0,0 +1,163 @@
-- Title -> main menu handoff (#1510) and the Oak-intro naming layout (#1511):
-- title.asm .finishedWaiting, oak_speech2.asm ChoosePlayerName
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local Menu = require("src.ui.Menu")
local NamingScreen = require("src.ui.NamingScreen")
local OakSpeech = require("src.ui.OakSpeech")
local TitleState = require("src.ui.TitleState")
local function newStack()
local stack = { states = {} }
function stack:push(state, ...)
table.insert(self.states, state)
if type(state.enter) == "function" then state:enter(...) end
end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return stack
end
local function newGame()
return {
data = Data,
save = SaveData.newGame(),
stack = newStack(),
input = { wasPressed = function() return false end,
isDown = function() return false end },
}
end
local function run(state, frames)
for _ = 1, frames do
if state.game.stack:top() ~= state then return end
state:update(1 / 60)
end
end
-- ------------------------------------------------- #1510: title -> menu
local game = newGame()
local title = TitleState.new(game, {})
game.stack:push(title)
title:toMenu()
local flash = game.stack:top()
T.check(flash ~= title, "START pushes a state before the menu")
T.eq(flash.isOpaque, true, "GBPalWhiteOutWithDelay3 covers the title art")
T.check(not title.menuOpen, "the title is still drawing itself mid-blink")
run(flash, 60)
local menu = game.stack:top()
T.check(getmetatable(menu) == Menu, "the blink hands off to the main menu")
T.eq(title.menuOpen, true, "MainMenu's ClearScreen takes the title art down")
T.eq(menu.tx, 0, "the CONTINUE / NEW GAME box is still at hlcoord 0,0")
-- OPTION returns to .mainMenuLoop, so its row must not close the box
local option
for _, item in ipairs(menu.items) do
if item.label == "OPTION" then option = item end
end
T.check(option ~= nil and option.keepOpen == true,
"OPTION keeps the main menu on the stack")
-- B: DisplayTitleScreen opens with GBPalWhiteOut
T.check(type(menu.onCancel) == "function", "the main menu cancels back out")
game.stack:pop()
menu.onCancel()
local back = game.stack:top()
T.eq(back.isOpaque, true, "backing out blinks white too")
T.eq(title.menuOpen, true, "the title art stays down until the blink ends")
run(back, 60)
T.eq(title.menuOpen, false, "and comes back once the blink is over")
T.eq(game.stack:top(), title, "leaving the menu lands back on the title")
-- ------------------------------------------- #1511: the intro NAME box
local ngame = newGame()
local naming = NamingScreen.new(ngame, {
presets = { "RED", "ASH", "JACK" }, introBox = true,
})
ngame.stack:push(naming)
local box = ngame.stack:top()
T.check(getmetatable(box) == Menu, "the preset list is a bordered menu")
-- DisplayIntroNameTextBox: TextBoxBorder at hlcoord 0,0 with b=$a, c=$9
T.eq(box.tx, 0, "the name box starts at column 0")
T.eq(box.ty, 0, "the name box starts at row 0")
T.eq(box.tw, 11, "c=$9 plus both border columns is 11 tiles wide")
T.eq(box.th, 12, "b=$a plus both border rows is 12 tiles tall")
T.eq(box.title, "NAME", "the NAME label rides the box's top border")
T.eq(box.itemY, 2, "wTopMenuItemY 2: the list is anchored down from the top")
T.eq(box.cancelable, false, "there is no way out of the naming choice")
-- every other NamingScreen caller keeps the old preset box
local plain = NamingScreen.new(newGame(), { presets = { "RED" } })
local pgame = plain.game
pgame.stack:push(plain)
T.eq(pgame.stack:top().tx, 4, "a non-intro preset list is unchanged")
T.eq(pgame.stack:top().title, nil, "and carries no header")
-- ------------------------------------------ #1511: the pic slide + box
local steps = OakSpeech.defaultSteps({})
local byId = {}
for _, step in ipairs(steps) do byId[step.id] = step end
-- oak_speech.asm:86-91 MovePicLeft, then a text_end box that stays up
T.eq(byId.ask_player_name.reveal, "wipe", "the player pic wipes in")
T.eq(byId.ask_player_name.stay, true, "and its question box stays on screen")
T.eq(byId.ask_rival_name.reveal, "fade", "the rival pic fades in")
T.eq(byId.ask_rival_name.stay, true, "and its box stays on screen too")
local sgame = newGame()
local speech = OakSpeech.new(sgame, function() end)
sgame.stack:push(speech)
speech.steps = steps
speech.step = 0
speech:runStep(byId.name_player)
local slide = sgame.stack:top()
T.check(slide ~= speech, "the name beat slides the pic before the box opens")
T.eq(speech.picSlide, 0, "the slide starts where the pic already sat")
-- six tiles, one per Delay3
run(slide, 6 * 3)
T.eq(speech.picSlide, 48, "OakSpeechSlidePicRight ends six tiles across")
T.check(sgame.stack:top() ~= slide, "the slide pops itself when it lands")
-- ------------------------------------- #1511: the question box stays up
local fgame = newGame()
local flow = OakSpeech.new(fgame, function() end)
fgame.stack:push(flow)
flow.steps = OakSpeech.defaultSteps(flow)
for i, step in ipairs(flow.steps) do
if step.id == "ask_player_name" then flow.step = i - 1 end
end
flow:advance()
local function pump(frames)
for _ = 1, frames do
local top = fgame.stack:top()
if top.update then top:update(1 / 60) end
if getmetatable(fgame.stack:top()) == Menu then return end
end
end
pump(400)
T.check(getmetatable(fgame.stack:top()) == Menu, "the preset list opens")
T.eq(fgame.stack.states[2], flow.holdBox,
"IntroducePlayerText's box is still on the stack under the name list")
T.check(flow.holdBox.done and flow.holdBox.stay,
"it is a `stay` box: text_end returns from PrintText without a wait")
local preset = fgame.stack:top()
preset.index = 2
fgame.input.wasPressed = function(_, b) return b == "a" end
preset:update(1 / 60)
fgame.input.wasPressed = function() return false end
T.eq(fgame.save.player.name, "RED", "picking a preset names the player")
T.eq(flow.holdBox, nil, "and takes the question box down with the list")
run(fgame.stack:top(), 6 * 3)
T.eq(flow.picSlide, 0, "OakSpeechSlidePicLeft puts the pic back")
T.finish("intro_title_naming_bug1510_1511")
+95
View File
@@ -0,0 +1,95 @@
-- The PC gains a PKMN LEAGUE row after the Hall of Fame (#1566):
-- DisplayPCMainMenu (engine/pokemon/bills_pc.asm:5), PKMNLeague (pc.asm:67)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local OW = require("src.world.OverworldController")
local function setUpvalue(fn, name, val)
local i = 1
while true do
local n = debug.getupvalue(fn, i)
if not n then return false end
if n == name then debug.setupvalue(fn, i, val); return true end
i = i + 1
end
end
local pushed = {}
local fakeGame = {
data = { text = {
_TurnedOnPC1Text = "RED turned on\nthe PC.",
_AccessedHoFPCText = "Accessed POKéMON\nLEAGUE's site.",
} },
save = {
flags = { EVENT_GOT_POKEDEX = true },
player = { name = "RED" },
},
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
}
local sounds = {}
package.loaded["src.core.Sound"] = {
play = function(_, name) sounds[#sounds + 1] = name end,
}
local menuItems
package.loaded["src.ui.Menu"] = {
new = function(_, items) menuItems = items; return { menu = true } end,
}
local opened = {}
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
T.check(setUpvalue(OW.openPC, "Screens", {
push = function(_, id) opened[#opened + 1] = id end,
}), "Screens upvalue on openPC")
T.check(setUpvalue(OW.openPC, "TextBox", {
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
}), "TextBox upvalue on openPC")
local fakeSelf = setmetatable({}, { __index = OW })
local function labels()
pushed, menuItems, sounds, opened = {}, nil, {}, {}
fakeSelf:openPC(function() end)
pushed[1].onDone() -- close TurnedOnPC1Text; the menu goes up behind it
local names = {}
for i, item in ipairs(menuItems or {}) do names[i] = item.label end
return names
end
local function indexOf(list, label)
for i, name in ipairs(list) do
if name == label then return i end
end
end
-- wNumHoFTeams == 0: three rows plus LOG OFF (bills_pc.asm .noLeaguePC)
local before = labels()
T.eq(indexOf(before, "<PK><MN>LEAGUE"), nil,
"no PKMN LEAGUE row before the Hall of Fame")
T.eq(#before, 4, "BILL's PC, the player's PC, PROF.OAK's PC and LOG OFF")
-- one recorded team is enough, and it stays for good
fakeGame.save.hallOfFame = { { { species = "PIKACHU", level = 80 } } }
local after = labels()
local iLeague = indexOf(after, "<PK><MN>LEAGUE")
T.check(iLeague, "PKMN LEAGUE appears once a team is in the Hall of Fame")
T.eq(after[iLeague - 1], "PROF.OAK's PC", "it follows PROF.OAK's PC")
T.eq(after[iLeague + 1], "LOG OFF", "and LOG OFF still closes the menu")
T.check(menuItems[iLeague].keepOpen,
"B returns to the PC menu (ReloadMainMenu), it does not log off")
-- selecting it: SFX_ENTER_PC, AccessedHoFPCText, then the roster screen
menuItems[iLeague].onSelect()
T.eq(sounds[#sounds], "Enter_PC", "PKMNLeague plays SFX_ENTER_PC")
local box = pushed[#pushed]
T.eq(box.text, fakeGame.data.text._AccessedHoFPCText,
"PKMNLeaguePC prints AccessedHoFPCText first")
box.onDone()
T.same(opened, { "LeaguePC" }, "the Hall of Fame roster screen opens")
-- without the Pokedex the league row still shows (bills_pc.asm checks
-- wNumHoFTeams before EVENT_GOT_POKEDEX)
fakeGame.save.flags.EVENT_GOT_POKEDEX = nil
local noDex = labels()
T.check(indexOf(noDex, "<PK><MN>LEAGUE"),
"the league row does not depend on EVENT_GOT_POKEDEX")
+3 -2
View File
@@ -136,8 +136,9 @@ do
local list, why = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
check(not inStack(game.stack, isPicker),
"the pickOnly picker popped itself before onSwitch")
-- .useRareCandy over the party list (item_effects.asm:1392-1418) #1594
check(inStack(game.stack, isPicker),
"the party picker is still up under the level text (#1594)")
check(inStack(game.stack, function(s) return s == list end),
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
@@ -0,0 +1,64 @@
-- SAVE confirmation layout (#1522): PrintSaveScreenText's own box and
-- SaveTheGame_YesOrNo's TWO_OPTION_MENU at hlcoord 0, 7
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local SaveData = require("src.core.SaveData")
local StartMenu = require("src.ui.StartMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local function newStack()
local stack = { states = {} }
function stack:push(state) table.insert(self.states, state) end
function stack:pop() return table.remove(self.states) end
function stack:top() return self.states[#self.states] end
return stack
end
local game = {
data = Data,
save = SaveData.newGame(),
stack = newStack(),
input = { wasPressed = function() return false end,
isDown = function() return false end },
}
game.save.player.name = "RED"
game.save.playTime = 3 * 3600 + 7 * 60
local menu = StartMenu.new(game)
local save
for _, item in ipairs(menu.items) do
if item.label == "SAVE" then save = item end
end
T.check(save ~= nil, "the start menu lists SAVE")
save.onSelect()
T.eq(#game.stack.states, 2, "the panel and the prompt are two separate states")
local panel, prompt = game.stack.states[1], game.stack.states[2]
T.check(type(panel.draw) == "function" and not panel.isTextBox,
"the info panel is its own drawn state, not a TextBox page")
T.check(prompt.isTextBox == true, "the prompt is the dialogue box on top")
T.eq(#prompt.pages, 1, "the prompt is one page: no \\f-merged info panel")
T.check(prompt.pages[1][1]:find("Would you like to"),
"the prompt page is WouldYouLikeToSaveText")
-- save.asm:187 hlcoord 0, 7
T.eq(prompt.choiceBox.tx, 0, "the save Yes/No box sits at column 0 (left)")
T.eq(prompt.choiceBox.ty, 7, "the save Yes/No box sits at row 7")
local choice = ChoiceBox.new(game, function() end, { box = prompt.choiceBox })
T.eq(choice.tx, 0, "ChoiceBox honours the save-specific left placement")
-- answering NO takes the panel back down with the prompt
prompt.done = true
prompt:update(1 / 60)
local yesno = game.stack:top()
T.check(getmetatable(yesno) == ChoiceBox, "the prompt pushes the Yes/No box")
T.eq(yesno.tx, 0, "the pushed Yes/No box is the left-hand one")
game.stack:pop()
game.stack:pop()
prompt.choice(false)
T.eq(#game.stack.states, 0, "declining closes the info panel too")
T.finish("save_confirm_layout_bug1522")
@@ -49,6 +49,13 @@ local function indexOf(rows, name)
return nil
end
local function hasText(battle, needle)
for _, item in ipairs(battle.queue) do
if item.text and item.text:find(needle, 1, true) then return true end
end
return false
end
-- ---------------------------------------------------------------------
-- the player's setup turn: the effect animation precedes the move's own
-- ---------------------------------------------------------------------
@@ -73,6 +80,16 @@ do
battle:performMove(battle.player, battle.enemy, slot)
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
"a locked-in Thrash queues no setup animation")
-- .ThrashingAboutCheck falls into PlayerCalcMoveDamage, the same
-- animation pipeline every other move uses (core.asm:3540) (#1577)
T.check(indexOf(animRows(battle), "FIX_THRASH") ~= nil,
"but the move's own animation still plays on a continuation turn")
T.check(hasText(battle, "thrashing about"),
"ThrashingAboutText prints in place of the used-move line")
T.check(not hasText(battle, "used FIX THRASH"),
"and the used-move line does not")
T.eq(battle.player.thrashTurns, 1,
"the continuation turn runs wPlayerNumAttacksLeft down")
end
-- ---------------------------------------------------------------------
@@ -104,6 +121,11 @@ do
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
"the setup animation survives a miss")
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
-- ThrashPetalDanceEffect commits before MoveHitTest
-- (core.asm:3129-3133, effects.asm:791-808) (#1565)
T.eq(battle.player.thrashTurns, 2, "the miss still rolls wPlayerNumAttacksLeft")
T.check(battle:menuLockedAction(battle.player) ~= nil,
"and the user is locked into Thrash next turn")
end
T.finish("thrash setup animation (#1532)")
+40
View File
@@ -0,0 +1,40 @@
-- GainExperience compares MON_OTID against wPlayerID at every award
-- (engine/battle/experience.asm:69-88) (#1488)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.fresh()
require("src.render.Font").load(Data)
local BattleState = require("src.battle.BattleState")
local Pokemon = require("src.pokemon.Pokemon")
local SaveData = require("src.core.SaveData")
local TypeChart = require("src.battle.TypeChart")
TypeChart.load(Data)
local function boostedText(mutate)
local save = SaveData.newGame()
save.player.id = save.player.id or 1234
save.party = { Pokemon.new(Data, "FIXMON_A", 20) }
mutate(save.party[1], save)
local game = { data = Data, save = save,
stack = { top = function() return nil end, push = function() end } }
local battle = BattleState.newWild(game, "FIXMON_C", 10)
battle.participants = { [save.party[1]] = true }
battle:enemyMonFainted()
for _, item in ipairs(battle.queue) do
if item.text and item.text:find("boosted", 1, true) then return true end
end
return false
end
T.eq(boostedText(function(mon, save) mon.otId = save.player.id end), false,
"a mon whose OTID is the player's own earns no boost")
T.eq(boostedText(function(mon, save) mon.otId = (save.player.id or 0) + 1 end), true,
"a foreign OTID trips BoostExp")
T.eq(boostedText(function(mon, save)
-- traded away and traded back: the stored OTID is the player's again
mon.traded = true
mon.otId = save.player.id
end), false, "and a mon traded back to its original trainer loses it (#1488)")
T.finish("traded exp boost is an OTID comparison (#1488)")
+110
View File
@@ -1306,4 +1306,114 @@ do
eq(lead.moves[4].id, "SURF", "and SURF is still there")
end
-- ---- GetMovePriority's Vital Throw carve-out (#1475) ----------------------
-- engine/battle/core.asm:786
do
local screen = newScreen()
check(runToMenu(screen), "reached the menu")
local battle = screen.battle
local moves = battle.data.moves
moves.VITAL_THROW = { id = "VITAL_THROW", name = "VITALTHROW", power = 70,
type = "NORMAL", accuracy = 100, pp = 10, effect = "EFFECT_ALWAYS_HIT" }
moves.SWIFT = { id = "SWIFT", name = "SWIFT", power = 60, type = "NORMAL",
accuracy = 100, pp = 20, effect = "EFFECT_ALWAYS_HIT" }
moves.QUICK_ATTACK = { id = "QUICK_ATTACK", name = "QUICKATTACK", power = 40,
type = "NORMAL", accuracy = 100, pp = 30, effect = "EFFECT_PRIORITY_HIT" }
eq(battle:movePriority("VITAL_THROW"), -1, "VITAL_THROW goes last")
eq(battle:movePriority("SWIFT"), 0,
"while SWIFT, which shares its effect, keeps BASE_PRIORITY")
eq(battle:movePriority("QUICK_ATTACK"), 1, "and the table still reads")
eq(battle:orderOf("TACKLE", "TACKLE"), "player",
"the faster mon leads on equal priority")
eq(battle:orderOf("VITAL_THROW", "TACKLE"), "enemy",
"but VITAL_THROW loses to a normal move whatever the Speed")
moves.VITAL_THROW, moves.SWIFT, moves.QUICK_ATTACK = nil, nil, nil
end
-- ---- a send-out snapshots HP at send time (#1514) -------------------------
-- SendOutPlayerMon's tail (engine/battle/core.asm:3796-3838)
do
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
local bench = Mon.new(DATA, "TOTODILE", 10, { dvs = perfect })
local screen, battle = newScreen({ player = lead, party = { lead, bench } })
check(runToMenu(screen), "reached the menu")
battle:takeEvents()
check(battle:switch(2), "the bench mon comes in")
local send = battle:takeEvents()[1]
eq(send.kind, "send", "the switch emits a send-out")
eq(send.hp, bench.hp, "carrying a numeric HP snapshot")
bench.hp = bench.hp - 7
check(send.hp ~= bench.hp,
"which the rest of the turn's damage cannot walk back")
screen.shownHp.player = 0
screen:push(send)
screen:advanceQueue()
eq(screen.shownHp.player, send.hp,
"and the HUD opens on the snapshot, not on the post-hit value")
end
-- ---- LearnMove finishes before the queued send-out (#1516) ----------------
-- LearnMove inside GiveExperiencePoints (engine/battle/core.asm:1959-2010)
do
local screen, lead = learnScreen()
screen:push({ kind = "send", side = "enemy", mon = { hp = 1 }, hp = 1,
text = "JOE sent out PIDGEY!" })
check(runToPhase(screen, "ask-forget"), "the pages reach the question")
local tap = tapper(screen)
tap("a") -- read the question
tap("a") -- YES
eq(screen.phase, "choose-forget", "YES opens the picker")
tap("a") -- slot 1
eq(lead.moves[1].id, "EMBER", "the move is learned")
check(screen.message and screen.message:find("forgot", 1, true) ~= nil,
"and its line prints ahead of the send-out that was already queued")
end
-- ---- MoveSelectionScreen's two boxes (#1478) ------------------------------
-- engine/battle/core.asm:5074-5094, MoveInfoBox :5403-5478
do
local Chrome = require("src.ui.gen2.Chrome")
local lead = Mon.new(DATA, "CYNDAQUIL", 10, { dvs = perfect })
lead.moves = { { id = "TACKLE", pp = 30, maxPp = 35 },
{ id = "THUNDER_WAVE", pp = 20, maxPp = 20 } }
local screen = newScreen({ player = lead, party = { lead } })
check(runToMenu(screen), "reached the menu")
screen.phase = "moves"
screen.moveIndex = 1
local boxes, prints = {}, {}
local saved = { box = Chrome.box, print = Chrome.print,
printRight = Chrome.printRight, cursor = Chrome.cursor }
Chrome.box = function(x, y, w, h)
boxes[#boxes + 1] = ("%d,%d,%d,%d"):format(x, y, w, h)
end
Chrome.print = function(text, x, y)
prints[#prints + 1] = ("%s@%d,%d"):format(tostring(text), x, y)
end
Chrome.printRight = function(text, x, y)
prints[#prints + 1] = ("R:%s@%d,%d"):format(tostring(text), x, y)
end
Chrome.cursor = function(x, y)
prints[#prints + 1] = ("cursor@%d,%d"):format(x, y)
end
local ok, err = pcall(function() screen:drawPanel() end)
Chrome.box, Chrome.print = saved.box, saved.print
Chrome.printRight, Chrome.cursor = saved.printRight, saved.cursor
check(ok, "the move menu draws: " .. tostring(err))
local drawn = table.concat(boxes, " ")
check(drawn:find("0,8,11,5", 1, true) ~= nil, "the TYPE/PP box is drawn")
check(drawn:find("4,12,16,6", 1, true) ~= nil, "over the narrow list box")
check(drawn:find("0,12,20,6", 1, true) == nil,
"and the full-width message box is not")
local text = table.concat(prints, " ")
check(text:find("TACKLE@6,13", 1, true) ~= nil, "names sit at column 6")
check(text:find("cursor@5,13", 1, true) ~= nil, "with the cursor at 5")
check(text:find("TYPE/@1,9", 1, true) ~= nil, "TYPE/ at (1,9)")
check(text:find("NORMAL@2,10", 1, true) ~= nil, "the type name at (2,10)")
check(text:find("30/35@5,11", 1, true) ~= nil,
"and only the highlighted move's PP, at (5,11)")
check(text:match("R:%d+/%d+@19,1%d") == nil, "no PP is printed per row")
end
S.finish()
+30
View File
@@ -109,6 +109,36 @@ do
eq(world.daytime, HOST_DAYTIME, "so it is lit by the host clock")
end
-- ---- a pinned palette lights the room, it does not stop the clock (#1557) ---
-- timeofday_pals.asm:114 and :5-11, checktime.asm:2, data/maps/maps.asm:427
do
local save = {}
Clock.setTime(save, 21, 0)
local world = worldWithSave(save)
world.map.def.palette = "PALETTE_DAY"
world.map.def.environment = "INDOOR"
world:applyPalettes()
eq(world.daytime, "DAY", "the pinned room is still lit like day at 21:00")
eq(world.tod, "NITE", "but the world clock knows it is night")
eq(world:timeOfDayId(), 2, "and wTimeOfDay answers NITE_F")
-- Script_checktime: CheckTime's bit for wTimeOfDay ANDed with the mask.
local function checktime(mask)
local scripts = { generation = 2,
["s:t"] = { { op = "checktime", args = { mask } } } }
local vm = Vm.new(scripts, {}, world.events,
{ getTimeOfDay = function() return world:timeOfDayId() end })
vm:start("s:t")
for _ = 1, 100 do
if not vm:running() then break end
vm:update()
end
return vm.scriptVar
end
eq(checktime(4), 1, "checktime NITE is TRUE inside the PALETTE_DAY room")
eq(checktime(2), 0, "and checktime DAY is FALSE there")
end
-- ---- the hour-window respawn is not eaten by a busy frame -------------------
-- UpdateTimePals runs every second; the port rides that poll to redo what a
-- map load would (wObjectMasks). A rollover that lands on a busy frame has to
+47
View File
@@ -89,6 +89,53 @@ for _, row in ipairs(log) do
end
check(gotText, "getmonname filled STRBUF in received text")
-- givepoke's trainer arm (#1569): Script_givepoke (engine/overworld/
-- scripting.asm:1817-1824), GivePoke (engine/pokemon/move_mon.asm:1695-1736)
do
local given, asked = nil, false
local kenyaVm = Vm.new({ generation = 2,
["s:randy"] = {
{ op = "givepoke", species = 21, level = 10, item = 0, trainer = 1,
name = "KENYA", otName = "RANDY" },
{ op = "end" },
},
}, {}, Events.new(), {
givePoke = function(species, level, item, opts)
given = { species = species, level = level, opts = opts }
return { species = "SPEAROW" }
end,
askNickname = function() asked = true end,
})
check(kenyaVm:start("s:randy"), "Randy's script starts")
for _ = 1, 10 do kenyaVm:update() end
check(given ~= nil, "the gift reaches givePoke")
check(given.opts ~= nil, "the trainer arm carries the two names")
eq(given.opts.nickname, "KENYA", "the nickname is the script's own")
eq(given.opts.otName, "RANDY", "and so is the OT name")
check(not asked, "no nickname prompt on the trainer arm")
end
-- Every other givepoke in the game is the flag-FALSE form: no names, and the
-- nickname prompt still runs.
do
local given = nil
local plainVm = Vm.new({ generation = 2,
["s:eevee"] = {
{ op = "givepoke", species = 133, level = 20, item = 0, trainer = 0 },
{ op = "end" },
},
}, {}, Events.new(), {
givePoke = function(species, level, item, opts)
given = { opts = opts }
return nil
end,
})
plainVm:start("s:eevee")
for _ = 1, 10 do plainVm:update() end
check(given ~= nil and given.opts == nil,
"the flag-FALSE form hands givePoke no names")
end
-- Phone + verbosegiveitem (Elm directions / aide potion)
local phone = {}
local bag = {}
+12 -4
View File
@@ -298,15 +298,21 @@ landGame.input:press("a")
landPack:update(0)
check(landPack.message == nil, "a button clears the message")
-- An item World claims nothing for still falls through to the PACK's own
-- onChoose (TM teaching).
-- CoinCaseEffect (engine/items/item_effects.asm:2243) is a MenuTextboxWaitButton
-- over _CoinCaseCountText: the PACK stays open and nothing reaches onChoose.
landGame.save.player.coins = 250
landPack.index = 3
landGame.input:press("a")
landPack:update(0)
landGame.input:press("a")
landPack:update(0)
eq(chosen, "COIN_CASE", "an unhandled item reaches onChoose untouched")
check(landPack.message ~= nil, "the COIN CASE prints inside the PACK")
eq(landPack.message[1], "Coins:", "_CoinCaseCountText's first row")
eq(landPack.message[2], "250", "and the count on the second")
eq(chosen, nil, "the COIN CASE never reaches onChoose")
eq(landGame.stack.cleared, 0, "and does not quit the PACK either")
landGame.input:press("a")
landPack:update(0)
-- Facing water: the roll lands on $2 .FishGotSomething, the PACK quits
-- (PACKSTATE_QUITRUNSCRIPT) and Script_FishCastRod's cast owns the world.
@@ -354,8 +360,10 @@ eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing from inside a battle")
busyWorld.battleActive = nil
busyWorld.vm = { running = function() return true end, update = function() end }
eq(busyWorld:useRod("OLD_ROD"), "nowhere", "no fishing while a script runs")
check(busyWorld:useFieldItem("COIN_CASE") == nil,
check(busyWorld:useFieldItem("POTION") == nil,
"useFieldItem passes an unhandled item back to the PACK")
eq(busyWorld:useFieldItem("COIN_CASE"), "coin_case",
"the COIN CASE is ITEMMENU_CURRENT and World claims it")
-- ---- A2. REPEL / SUPER REPEL / MAX REPEL ----------------------------------
-- UseRepel (engine/items/item_effects.asm): the step count is the only thing
+9
View File
@@ -67,6 +67,15 @@ end
check(iGotJump and iGotJump[2] == iFadeOut,
"EVENT_GOT_TM28 jumps to CeruleanHideRocket fade-out")
-- SaveEndBattleTextPointers before EngageMapTrainer
-- (scripts/CeruleanCity.asm:295) (#1579)
local iBattle = find("start_battle")
local iGiveUp = find("save_end_battle_text",
"_CeruleanCityRocketIGiveUpText")
check(iGiveUp, "the thief arms _CeruleanCityRocketIGiveUpText")
check(iBattle and iGiveUp == iBattle - 1,
"SaveEndBattleTextPointers runs just before the battle")
local ScriptRunner = require("src.script.ScriptRunner")
local problems = ScriptRunner.validate(rows)
eq(#problems, 0, "Rocket script validates: " .. table.concat(problems, "; "))
+7
View File
@@ -142,8 +142,15 @@ do
eq(game.save.inventory.RARE_CANDY, 2, "the candy was consumed")
check(game.stack.states[1] == list,
"the bag list is STILL on the stack under the level text (#796)")
-- .useRareCandy redraws the party menu before it prints
-- (item_effects.asm:1392-1418) #1594
check(isPicker(game.stack.states[2]),
"the party picker is the backdrop for the level text (#1594)")
finishLevelUp(game, box)
for _, s in ipairs(game.stack.states) do
check(not isPicker(s), "the picker comes down when the sequence ends")
end
eq(game.stack:top(), list,
"after the stat window the bag is back on top (StartMenu_Item)")
eq(list.index, row, "the cursor is still on the RARE CANDY row")
+2
View File
@@ -1258,7 +1258,9 @@ do
"_GainedText + _ExpPointsText show the amount")
Game.save.party = { Pokemon.new(Data, "BULBASAUR", 30) }
-- GainExperience: MON_OTID vs wPlayerID (experience.asm:69-88) (#1488)
Game.save.party[1].traded = true
Game.save.party[1].otId = (Game.save.player.id or 0) + 1
local eb2 = BattleState.newWild(Game, "RATTATA", 10)
eb2.participants = { [Game.save.party[1]] = true }
eb2:enemyMonFainted()