CLOSES #1478, CLOSES #1479, CLOSES #1488, CLOSES #1510, CLOSES #1511, CLOSES #1514, CLOSES #1521, CLOSES #1522, CLOSES #1545, CLOSES #1557, CLOSES #1563, CLOSES #1565, CLOSES #1566, CLOSES #1577, CLOSES #1594, CLOSES #1596, CLOSES #1606, CLOSES #1608

This commit is contained in:
bryanthaboi
2026-08-20 17:30:18 -04:00
parent 0f8f6d0e4f
commit dbecc345e3
53 changed files with 881 additions and 225 deletions
+7 -5
View File
@@ -38,8 +38,8 @@ 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
-- The badge line + its jingle, armed for the battle screen the way
-- SaveEndBattleTextPointers does (PewterGym.asm:117-119) (#1606)
local function badgeEndBattleText(game, victoryKey)
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
if not (reward and reward.dialogue) then return nil end
@@ -51,7 +51,7 @@ local function badgeEndBattleText(game, victoryKey)
end
end
if #pages == 0 then return nil end
return table.concat(pages, "\f")
return table.concat(pages, "\f"), reward.badgeSound
end
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
@@ -74,7 +74,8 @@ 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, badgeEndBattleText(game, "OPP_BROCK#1"))
local text, sound = badgeEndBattleText(game, "OPP_BROCK#1")
ow:engageTrainer(npc, done, text, nil, sound)
end
end,
}
@@ -107,7 +108,8 @@ 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, badgeEndBattleText(game, victoryKey))
local text, sound = badgeEndBattleText(game, victoryKey)
ow:engageTrainer(npc, done, text, nil, sound)
end
end
end
+5 -4
View File
@@ -837,13 +837,14 @@ M.SILPH_CO_11F = {
-- every Silph rocket leaves off-screen (the street rockets are
-- handled by M.SAFFRON_CITY.onEnter in story4.lua). Queued, not
-- run here: the battle's own callbacks are still unwinding, so
-- queueScript starts it on the first idle overworld frame --
-- after the end-battle "Arrgh!!" box victories.lua OPP_GIOVANNI#2
-- pushes (#722).
-- queueScript starts it on the first idle overworld frame (#722).
if game.save.flags.EVENT_BEAT_SILPH_CO_GIOVANNI then
ow:queueScript(silphAftermathRows())
end
end, nil, true)
end,
-- "Arrgh!!" is armed for the battle screen, not the map
-- (scripts/SilphCo11F.asm:264-266 SaveEndBattleTextPointers) #1606
game.data.text._SilphCo10FGiovanniILostAgainText, true)
end)
end))
return true
+4 -1
View File
@@ -216,7 +216,10 @@ local function dojoMasterGate(game, ow, x, y)
if not master or ow:trainerDefeated(master) then return false end
ow.player.facing = "right"
master:facePlayer(ow.player)
ow:engageTrainer(master)
-- scripts/FightingDojo.asm:117-119 SaveEndBattleTextPointers (#1606)
ow:engageTrainer(master, nil,
((game.data or {}).text or {})._FightingDojoKarateMasterDefeatedText,
nil, nil, false)
return true
end
+1 -1
View File
@@ -651,7 +651,7 @@ local rocketRows = {
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
{ "jump_if_true", 10 }, -- 5
{ "show_text", "_CeruleanCityRocketText" }, -- 6
-- scripts/CeruleanCity.asm:295 SaveEndBattleTextPointers
-- scripts/CeruleanCity.asm:297 SaveEndBattleTextPointers
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
{ "jump_if_false", "end" }, -- 9
+1
View File
@@ -8,6 +8,7 @@ local BattleSafety = {}
local BATTLE_BUSY_FIELDS = {
"current", "afterQueue", "nextInsert", "pendingHit", "waitingUI",
"waitingSound", "waitFrames", "draining", "animPlaying", "growIn",
"shrinkOut",
"introSlide", "ghostReveal", "mimicCtx", "mimicMoves", "result",
}
+45 -14
View File
@@ -2495,9 +2495,12 @@ function BattleState:openOldManBag()
-- POKé BALLs; pokeyellow's SimulatedInputBattleItemList, shared by
-- the Viridian tutorial and Oak's catch, has one.
local qty = require("src.core.GameVersion").isYellow() and "x1" or "x50"
-- the tutorial bag rides DisplayBagMenu's LIST_MENU_BOX over the battle
-- screen (engine/battle/core.asm:2210)
list = ListMenu.new(game, "ITEMS", {
{ value = "POKE_BALL", label = Strings("POKé BALL"), right = qty },
}, {
itemBox = true,
script = function(l)
l.scriptTimer = (l.scriptTimer or 0) + 1
if l.scriptTimer == 81 then
@@ -3329,15 +3332,18 @@ end
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)
self:waitNext(24)
self:actNext(function()
-- .clearScreenArea keeps the 7x7 area blank until the swap
-- (pokeyellow core.asm:1867-1871) (#1545)
self.sendingOut = true
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 })
self:waitNext(7)
end
end
@@ -3558,10 +3564,10 @@ function BattleState:updateFx()
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
-- 7x7 area holds cleared (scale 0) until the swap replaces the battler
if self.shrinkOut then
self.shrinkOut.frame = self.shrinkOut.frame + 1
if self.shrinkOut.frame >= 7 then self.shrinkOut = nil end
if self.shrinkOut.battler ~= self.player 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
@@ -3939,7 +3945,9 @@ function BattleState:performMove(user, target, moveInst, isCalled)
-- = 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 }
-- ld a, THRASH / ld [wPlayerMoveNum] (core.asm:3534-3535, :5909-5910) #1577
self.moveAnimRow = { anim = thrashing and "THRASH" or move.id,
attackerIsPlayer = user.isPlayer }
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
end
Runtime.emit("battle.move_used", {
@@ -4225,7 +4233,9 @@ function BattleState:awardExp()
local playerId = self.game.save.player and self.game.save.player.id
-- 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
-- mon.traded covers otId-less mons (repairTradedOtIds, old link peers) #1488
local traded = playerId ~= nil and ((mon.otId ~= nil and mon.otId ~= playerId)
or (mon.otId == nil and mon.traded == true))
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
self.enemy.mon.level, self.kind == "trainer",
split, traded)
@@ -4397,7 +4407,8 @@ function BattleState:enemyMonFainted()
forceSwitch = true,
onSwitch = function(mon)
if mon == self.player.mon then
reopenShift(Strings("%s is\nalready out!", self.player.name))
reopenShift(self:romText("_AlreadyOutText",
"%s is\nalready out!", self.player.name))
elseif mon.hp <= 0 then
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
else
@@ -4524,9 +4535,22 @@ function BattleState:enemyMonFainted()
-- TrainerNamePointers aims those entries at wTrainerName). The tag
-- prints once, so a `para` page carries no second copy (#566).
local tag = self.trainer and self.trainer.name
-- the badge jingle (sound_get_item_1 and friends) rides the armed
-- line's first page, as the script's text command would (#1606)
local sfx = self.endBattleSound
local data = self.data
for page in (self.endBattleText .. "\f"):gmatch("(.-)\f") do
if page ~= "" then
self:sayNext(tag and (tag .. ": " .. page) or page)
local line = tag and (tag .. ": " .. page) or page
if sfx then
local id = sfx
self:sayNextWaitSfx(line, function()
return require("src.core.Sound").play(data, id)
end)
sfx = nil
else
self:sayNext(line)
end
tag = nil
end
end
@@ -5144,7 +5168,8 @@ function BattleState:openParty()
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:say(self:romText("_AlreadyOutText",
"%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!"))
@@ -5297,7 +5322,9 @@ end
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
-- scale 0 past Delay3: the area stays cleared until the swap
-- (core.asm:1790-1796) (#1563)
return shrink.frame < 4 and 5 / 7 or shrink.frame < 7 and 3 / 7 or 0
end
-- battler hidden this frame? (damage blink)
@@ -5942,8 +5969,12 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
local eff = s * gs
if eff > 0 then
-- the retreat stages sit one tile right of the grow-in's
-- (hlcoord 3,7 / 4,9 vs 2,7 / 3,9, core.asm:1770-1788) (#1563)
local shrinkX = self.shrinkOut
and self.shrinkOut.battler == self.player and 8 or 0
love.graphics.draw(img,
8 - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
8 + shrinkX - padL * s + img:getWidth() * s * (1 - gs) / 2 + sx,
96 - (img:getHeight() - pad) * eff + sy, 0, eff, eff)
end
else
+5 -3
View File
@@ -108,13 +108,17 @@ end
-- The damaging pipeline, extracted from the performMove monolith: every
-- stage keeps the original's exact check order and rng consumption
-- (invulnerability -> gate -> hit count -> pre-accuracy -> accuracy ->
-- (pre-accuracy -> invulnerability -> gate -> hit count -> accuracy ->
-- damage choice -> hits -> messages -> after-damage -> secondary run).
function EffectRegistry.runDamaging(battle, ctx, record)
local user, target = ctx.user, ctx.target
local move, moveInst = ctx.move, ctx.moveInst
local neverMiss = record and record.neverMiss
-- SpecialEffectsCont's JumpMoveEffect (core.asm:3129-3133) runs before
-- MoveHitTest's INVULNERABLE test (:3150), mid-Fly/Dig included (#1565)
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
-- Swift ignores semi-invulnerability (MoveHitTest returns hit for
-- SWIFT_EFFECT before the INVULNERABLE check)
if target.invulnerable and not neverMiss then
@@ -143,8 +147,6 @@ function EffectRegistry.runDamaging(battle, ctx, record)
local hits = hitCount(ctx, record)
if record and record.beforeAccuracy then record.beforeAccuracy(ctx) end
if not neverMiss then
if not battle:accuracyRoll(move, user, target) then
-- Explosion/Selfdestruct still animate on a miss (HandleIfPlayerMoveMissed)
+12 -4
View File
@@ -892,10 +892,12 @@ Battle.PRIORITY = {
EFFECT_ENDURE = 3,
EFFECT_COUNTER = -1,
EFFECT_MIRROR_COAT = -1,
EFFECT_FORCE_SWITCH = -1, -- Whirlwind, Roar: priority 0, below BASE
}
function Battle:movePriority(moveId)
-- engine/battle/core.asm:786 GetMovePriority, `cp VITAL_THROW / ld a, 0`.
-- GetMovePriority `cp VITAL_THROW / ld a, 0 / ret z`
-- (engine/battle/core.asm:787-789).
if moveId == "VITAL_THROW" then return -1 end
local def = self:moveDef(moveId)
return (def and Battle.PRIORITY[def.effect]) or 0
@@ -2410,6 +2412,7 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
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,
level = sent.level, experience = sent.experience,
text = "Go! " .. self:monName(sent) .. "!" })
end
@@ -2670,6 +2673,7 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
hp = incoming.hp or 0, status = incoming.status or false,
level = incoming.level, experience = incoming.experience,
text = self:monName(incoming) .. " was dragged out!" })
self:breakTrapsOnSend(incoming)
self:spikesDamage(incoming)
@@ -3117,6 +3121,7 @@ function Battle:resolveFaints()
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
replacement = true,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = (self.trainer and self.trainer.name or "Foe") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
@@ -3196,11 +3201,12 @@ function Battle:printWinLossText(result)
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" })
-- The CANLOSE loss arm runs ClearBox first (:2770-2773).
self:emit({ kind = "trainer-return", cleared = result == "lose" or nil })
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).
-- FarPrintText prints the pointer alone: no trainer-name tag in front of
-- it, unlike Gen 1's TrainerEndBattleText (pokered home/trainers.asm:355).
self:emit({ kind = "win-text", text = text })
end
@@ -3526,6 +3532,7 @@ function Battle:switch(index)
self.stages.player = Battle.newStages()
self:emit({ kind = "send", side = "player", mon = mon,
hp = mon.hp or 0, status = mon.status or false,
level = mon.level, experience = mon.experience,
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.
@@ -3991,6 +3998,7 @@ function Battle:enemyTrySwitchOrItem()
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,
level = self.enemy.level, experience = self.enemy.experience,
text = (self.trainer.name or "TRAINER") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
+4 -3
View File
@@ -609,9 +609,10 @@ function Game:draw()
-- ...and for the same reason the UI's own scale has to know the world is
-- still the backdrop while an opaque menu covers it. Renderer:uiScale
-- steps the UI down with the survey zoom only while a world is behind it,
-- gated on this frame's world pass -- which the party menu and the bag end
-- by being opaque. Without this hold they lose the step-down and blit at
-- full fit scale over a battle drawn at the zoomed-out one.
-- gated on this frame's world pass -- which the party menu ends by being
-- opaque (the bag's item box shows the map around it, #1521). Without
-- this hold it loses the step-down and blits at full fit scale over a
-- battle drawn at the zoomed-out one.
Renderer.uiWorldHold = Renderer.battleDim ~= nil
-- ...and a battle keeps its dialogue box and YES/NO inside its own screen
-- instead of letting them dock to the window edge.
+9
View File
@@ -220,6 +220,9 @@ function Save.newGame(opts)
phoneContacts = {},
tradeFlags = {},
pokedex = { seen = {}, caught = {} },
-- wLastDexMode (engine/pokedex/pokedex.asm:59-61): the sort mode the
-- #DEX reopens in. NEW_MODE is the cart's zero byte.
lastDexMode = "NEW",
-- wUnownDex: the distinct Unown FORMS caught, in catching order. A second
-- record beside the #DEX because the #DEX knows only the species
-- (src/core/gen2/Unown.lua).
@@ -685,6 +688,12 @@ function Save.validate(save)
scrubEvents(save, report)
scrubMapScenes(save, report)
scrubPlayerState(save, report)
-- wLastDexMode: only the three modes the #DEX has (PokedexMenu MODES);
-- a hand-edited value falls back to NEW_MODE, the cart's zero byte
if save.lastDexMode ~= "NEW" and save.lastDexMode ~= "OLD"
and save.lastDexMode ~= "A-Z" then
save.lastDexMode = "NEW"
end
-- The `mailmsg` structs get the same treatment for the same reason: their
-- `type` byte is an item id nothing else in the save vouches for, and a
-- party key outside 1..6 or a MAILBOX past MAILBOX_CAPACITY is a region the
+25 -7
View File
@@ -161,16 +161,34 @@ end
-- 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)
-- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11
-- ONLY, so the box rides through EvolveMon (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 } }))
local intro
intro = TextBox.new(game, isEvolving, nil, { stay = {
onShown = function()
-- DelayFrames 50 with the box and the old screen still up
-- (evos_moves.asm:122-123)
local hold = { t = 0 }
hold.update = function()
hold.t = hold.t + 1
if hold.t < EVOLVING_TEXT_FRAMES then return end
game.stack:pop() -- this hold
-- forward `via` so trade evolutions stay non-cancelable while
-- others accept B (evos_moves.asm:72-75) (#213)
Screens.push(game, "EvolutionState", mon, newSpecies, function()
-- the result/cancel box owns the intro box's pop (#1596)
if game.stack:top() == intro then game.stack:pop() end
if onDone then onDone() end
end, via)
end
hold.draw = function() end
game.stack:push(hold)
end,
} })
game.stack:push(intro)
return
end
Music.play(game.data, Music.special(game.data, "evolution"))
+13 -2
View File
@@ -44,7 +44,8 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- waits for nothing, shows no blinking arrow, and never pops itself --
-- whoever pushed it owns the pop. stay.onShown fires once, on the frame
-- the last page finishes typing, which is where the caller pushes whatever
-- goes on top of it (#591).
-- goes on top of it (#591). stay.prompt waits out one arrowed A/B press
-- first (TextCommand_PROMPT_BUTTON, home/text.asm:434-444) (#1511).
function TextBox.new(game, text, onDone, opts)
local self = setmetatable({}, TextBox)
self.game = game
@@ -296,6 +297,15 @@ function TextBox:update(dt)
-- exactly once (#591)
if self.stay then
if not self.stayShown then
-- stay.prompt: arrowed A/B wait, then the box stays up
-- (TextCommand_PROMPT_BUTTON, home/text.asm:434-444)
if self.stay.prompt
and not (input:wasPressed("a") or input:wasPressed("b")) then
return
end
if self.stay.prompt then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
self.stayShown = true
if self.stay.onShown then self.stay.onShown() end
end
@@ -494,7 +504,8 @@ function TextBox:draw()
Font.draw(money, 152 - Font.width(money), 8)
end
if (self.waiting or (self.done and not self.choice and not self.auto
and not self.stay))
and (not self.stay
or (self.stay.prompt and not self.stayShown))))
and self.blink < 30 then
-- page-advance cursor: glyph $EE by default, the blinking down arrow
-- the original prints via `ld a, "▼"` (home/text.asm)
+2 -1
View File
@@ -1694,7 +1694,8 @@ H.RandomPhoneWildMon = function(vm)
local entry = contact and contact.map and grass and grass[contact.map]
local slots = entry and entry.slots
if not slots then return end
local daytime = (w and w.daytime) or "DAY"
-- wTimeOfDay, not the palette pin (wildmons.asm:861)
local daytime = (w and (w.tod or w.daytime)) or "DAY"
if daytime == "DARK" then daytime = "NITE" end
local slot = (slots[daytime] or slots.DAY or {})[Specials.random(4)]
if slot and slot.species then nameSpecies(vm, slot.species) end
+8 -3
View File
@@ -1118,7 +1118,9 @@ local function runCmd(self, cmd, op)
-- really does run here.
if self.reloadMapFn then self.reloadMapFn(true) end
elseif op == "winlosstext" then
-- Overrides the struct's win/loss text for this battle only.
-- Overrides the struct's win/loss text for this battle only; a 0
-- argument zeroes that pointer (engine/overworld/scripting.asm:651)
self.winLossArmed = true
self.winTextOverride = cmd.winText
self.lossTextOverride = cmd.lossText
elseif op == "trainertext" then
@@ -1126,9 +1128,11 @@ local function runCmd(self, cmd, op)
local obj = self.trainerObject or {}
local key
if which == 1 then
key = self.winTextOverride or obj.winText
key = self.winLossArmed and self.winTextOverride
or (not self.winLossArmed and obj.winText or nil)
elseif which == 2 then
key = self.lossTextOverride or obj.lossText
key = self.winLossArmed and self.lossTextOverride
or (not self.winLossArmed and obj.lossText or nil)
else
key = obj.seenText
end
@@ -2429,6 +2433,7 @@ function Vm:start(scriptKey)
self.battleOutcome = nil
self.winTextOverride = nil
self.lossTextOverride = nil
self.winLossArmed = nil
-- The whiteout abort is per-run too: a script that ended because the player
-- was wiped must not stop the next one before it starts.
self.aborted = false
+4 -2
View File
@@ -339,9 +339,11 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
{ 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)
Evolution.evolve(game, target, evoTo, closePicker,
evo and evo.method)
else
closePicker()
end
return
end
+4 -3
View File
@@ -13,9 +13,10 @@
local Music = require("src.core.Music")
local romText = require("src.core.RomText")
-- Not opaque: ClearScreenArea wipes rows 0-11 only (evos_moves.asm:126-128),
-- so the "is evolving!" box beneath stays visible through the flash (#1596).
local EvolutionState = {}
EvolutionState.__index = EvolutionState
EvolutionState.isOpaque = true
-- SGB: SetPal_PokemonWholeScreen for the mon on display
function EvolutionState:sgbPalettes(game)
@@ -147,7 +148,8 @@ end
function EvolutionState:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
-- rows 0-11 only (hlcoord 0,0 / lb bc, 12, 20, evos_moves.asm:126-128)
love.graphics.rectangle("fill", 0, 0, 160, 96)
-- accelerating flash between the two forms
local sprite, spriteTrueColor
@@ -172,7 +174,6 @@ function EvolutionState:draw()
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
end
end
love.graphics.setColor(1, 1, 1, 1)
end
return EvolutionState
+12 -3
View File
@@ -94,7 +94,15 @@ function ListMenu.new(game, title, items, opts)
-- 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
if self.itemBox then
self.isOpaque = false
-- keep RunDefaultPaletteCommand's last palette: ItemMenuLoop never sets
-- its own (engine/menus/start_sub_menus.asm:300)
self.sgbPalettes = false
-- wMaxMenuItem is 2 for item lists; the fourth printed row is a
-- look-ahead the cursor cannot reach (home/list_menu.asm:46-48)
self.cursorRows = 3
end
self.rows = opts.rows or (self.itemBox and ITEM_ROWS)
or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
return self
@@ -113,8 +121,9 @@ local function moveIndex(self, delta)
end
local function syncScroll(self)
if self.index - self.scroll > self.rows then
self.scroll = self.index - self.rows
local maxRow = self.cursorRows or self.rows
if self.index - self.scroll > maxRow then
self.scroll = self.index - maxRow
end
if self.index - self.scroll < 1 then self.scroll = self.index - 1 end
end
+7 -1
View File
@@ -119,8 +119,14 @@ function Menu:draw()
self.tw * 8, self.th * 8, self.anchor)
end
Font.drawBox(self.tx, self.ty, self.tw, self.th)
-- PlaceString at hlcoord 3,0 writes over the border row it was just
-- drawn on (oak_speech2.asm:162-170)
if self.title then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", (self.tx + 3) * 8, self.ty * 8,
#Font.split(self.title) * 8, 8)
end
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
+5 -3
View File
@@ -96,15 +96,17 @@ function NamingScreen:enter()
onSelect = function()
-- the menu already popped itself; pop the naming screen too
self.game.stack:pop()
if self.onDone then self.onDone(preset) end
if self.onDone then self.onDone(preset, false) end
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
-- TextBoxBorder's b = $a is a fixed 12-row box, whatever the preset
-- list's length (oak_speech2.asm:163-166)
self.game.stack:push(Menu.new(self.game, items, {
tx = 0, ty = 0, tw = 11, th = #items * 2 + 4,
tx = 0, ty = 0, tw = 11, th = 12,
itemY = 2, title = Strings("NAME"), cancelable = false,
}))
else
@@ -135,7 +137,7 @@ function NamingScreen:confirm()
end
Sound.play(self.game.data, "Press_AB")
self.game.stack:pop()
if self.onDone then self.onDone(name) end
if self.onDone then self.onDone(name, true) end
end
function NamingScreen:grid()
+43 -9
View File
@@ -43,6 +43,12 @@ local PicSlide = {}
PicSlide.__index = PicSlide
function PicSlide:update(dt)
-- OakSpeechSlidePicLeft: ClearScreenArea, ld c, 10 / DelayFrames, Delay3
-- before the first slide step (oak_speech2.asm:69-78)
if (self.delay or 0) > 0 then
self.delay = self.delay - 1
return
end
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
@@ -172,8 +178,8 @@ 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
-- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText's
-- `prompt` (text_2.asm:1730) waits for A and leaves the box up
reveal = "wipe",
stay = true,
},
@@ -192,6 +198,9 @@ function OakSpeech.defaultSteps(speech)
id = "confirm_player_name",
kind = "say",
textKey = "_YourNameIsText",
-- _YourNameIsText's `prompt` (text_2.asm:1766), then GBFadeOutToWhite
-- / ClearScreen with the box still up (oak_speech.asm:93-94)
fadeOut = true,
},
{
id = "ask_rival_name",
@@ -199,7 +208,7 @@ function OakSpeech.defaultSteps(speech)
textKey = "_IntroduceRivalText",
pic = "rival",
-- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's
-- text_end leaves the box up for ChooseRivalName
-- `prompt` (text_2.asm:1740) leaves the box up for ChooseRivalName
reveal = "fade",
stay = true,
},
@@ -216,6 +225,9 @@ function OakSpeech.defaultSteps(speech)
id = "confirm_rival_name",
kind = "say",
textKey = "_HisNameIsText",
-- _HisNameIsText's `prompt` (text_2.asm:1772) then the .skipSpeech
-- fade with the box up (oak_speech.asm:103-104)
fadeOut = true,
},
{
id = "legend",
@@ -403,9 +415,20 @@ function OakSpeech:runStep(step)
self:applyPic(step)
self:afterReveal(step, function()
self:runCry(step)
if step.stay then
if step.stay or step.fadeOut then
local box = TextBox.new(self.game, self:stepText(step), nil,
{ stay = { onShown = function() self:advance() end } })
{ stay = { prompt = true, onShown = function()
if step.fadeOut then
-- GBFadeOutToWhite / ClearScreen (oak_speech.asm:93-94)
self.game.stack:push(require("src.render.Transition")
.whiteFlash(self.game, nil, function()
self:closeHoldBox()
self:advance()
end))
else
self:advance()
end
end } })
self.holdBox = box
self.game.stack:push(box)
else
@@ -432,16 +455,26 @@ function OakSpeech:runStep(step)
presets = presets,
introBox = true,
maxLen = step.maxLen or self.nameLen,
onDone = function(name)
onDone = function(name, custom)
if who == "rival" then
self.game.save.player.rival = name
else
self.game.save.player.name = name
end
self:recordAnswer(step, 1, name, name)
-- YourNameIsText/HisNameIsText print into the box this one held
-- YourNameIsText / HisNameIsText print into the box this one
-- held (oak_speech2.asm:26-28, :59-61)
self:closeHoldBox()
self:slidePic(-1, function() self:advance() end)
if custom then
-- .customName: ClearScreen / Delay3 / pic recentered, no
-- slide-back (oak_speech2.asm:21-25)
self.picSlide = 0
self:advance()
else
-- OakSpeechSlidePicLeft's 13-frame pre-slide beat
-- (oak_speech2.asm:69-78)
self:slidePic(-1, function() self:advance() end, 13)
end
end,
})
end
@@ -557,10 +590,11 @@ function OakSpeech:revealPic(kind, next)
end
-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67)
function OakSpeech:slidePic(dir, onDone)
function OakSpeech:slidePic(dir, onDone, delay)
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,
delay = delay,
}, PicSlide))
end
+26 -8
View File
@@ -11,6 +11,7 @@ local Renderer = require("src.render.Renderer")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local StartMenu = {}
@@ -19,6 +20,7 @@ local function sameItems(_, items) return items end
function StartMenu.new(game)
local flags = game.save.flags or {}
local items = {}
local menu
-- vanilla start submenus return here on B (RedisplayStartMenu): the
-- generic Menu pops the start menu when a row is selected, so each
@@ -51,20 +53,31 @@ function StartMenu.new(game)
end })
-- SAVE shows the player/badges/dex/time panel then asks to confirm
-- (PrintSaveScreenText)
table.insert(items, { label = Strings("SAVE"), onSelect = function()
-- (PrintSaveScreenText); StartMenu_SaveReset never clears the START menu
-- box, so it stays on screen beside the panel (start_sub_menus.asm:641-647)
table.insert(items, { label = Strings("SAVE"), keepOpen = true,
onSelect = function()
local TextBox = require("src.render.TextBox")
local badges = require("src.inventory.Badges").count(game.data, game.save)
local owned = 0
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
owned = owned + 1
end
-- the panel is a static snapshot; the cart prints it once
-- (main_menu.asm:390-401)
local t = math.floor(game.save.playTime or 0)
-- 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,
local panel
panel = {
delay = 0,
update = function()
-- ld c, 30 / jp DelayFrames: the bare panel holds before the
-- prompt (main_menu.asm:404-405)
panel.delay = panel.delay + 1
if panel.delay == 30 then panel.openPrompt() end
end,
draw = function()
local t = math.floor(game.save.playTime or 0)
Font.drawBox(4, 0, 16, 10)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8)
@@ -81,13 +94,16 @@ function StartMenu.new(game)
}
local function closePanel()
if game.stack:top() == panel then game.stack:pop() end
-- SaveMenu returns into HoldTextDisplayOpen, not RedisplayStartMenu
-- (start_sub_menus.asm:645-647): the kept-open START menu goes too
if menu and game.stack:top() == menu then game.stack:pop() end
end
game.stack:push(panel)
panel.openPrompt = function()
game.stack:push(TextBox.new(game,
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 },
choiceBox = Theme.saveBox,
choice = function(yes)
if not yes then closePanel() return end
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
@@ -110,6 +126,8 @@ function StartMenu.new(game)
end, { auto = { delay = 120 } }))
end,
}))
end
game.stack:push(panel)
end })
table.insert(items, { label = Strings("OPTION"), onSelect = function()
@@ -165,7 +183,7 @@ function StartMenu.new(game)
-- with Menu's moreArrow showing while there's more below.
local rowStep = 2
local maxVisible = math.floor((Renderer.HEIGHT / 8 - 2) / rowStep)
local menu = Menu.new(game, items,
menu = Menu.new(game, items,
-- the START menu hugs the top-right corner of the SCREEN, not of a
-- centred letterbox: at 9,0 x 11 it is already flush with the top and
-- right of the 20x18 grid, so the anchor keeps it flush when the view
+3
View File
@@ -23,6 +23,9 @@ local Theme = {
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 too --
-- engine/menus/save.asm:186-192
saveBox = { tx = 0, ty = 7, tw = 6, th = 5 },
}
function Theme.load(data)
+56 -11
View File
@@ -392,6 +392,17 @@ function TitleState:updateSequence()
self.phase = "loop"
self.blinkTimer = 0
end
elseif self.phase == "exitCry" then
-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the
-- white-out (engine/movie/title.asm:241-243)
self.timer = self.timer + 1
local playing = self.exitCrySrc and self.exitCrySrc.isPlaying
and self.exitCrySrc:isPlaying()
if self.timer >= 3 and (not playing or self.timer > 180) then
self.exitCrySrc = nil
self.phase = "loop"
self:toMenu()
end
end
end
@@ -460,8 +471,8 @@ function ContinueInfo:update(dt)
self.game.stack:pop()
if self.title.onContinue then self.title.onContinue() end
elseif input:wasPressed("b") then
-- the CONTINUE / NEW GAME menu is still open underneath (main_menu.asm:91-92)
self.game.stack:pop()
self.title:openMenu()
end
end
@@ -497,7 +508,10 @@ function TitleState:openMenu()
local game = self.game
local items = {}
if hasSave() then
table.insert(items, { label = Strings("CONTINUE"), onSelect = function()
-- DisplayContinueGameInfo leaves the menu box up behind the info window
-- (engine/menus/main_menu.asm:36-39, :91-92)
table.insert(items, { label = Strings("CONTINUE"), keepOpen = true,
onSelect = function()
-- peek at the save for the info window; fall through if the
-- file can't be read
local ok, loaded = pcall(require("src.core.SaveData").load)
@@ -513,8 +527,11 @@ function TitleState:openMenu()
end })
-- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box
-- (engine/menus/main_menu.asm ln 87-90)
local menu
table.insert(items, { label = Strings("OPTION"), keepOpen = true,
onSelect = function()
-- .mainMenuLoop re-zeroes wCurrentMenuItem on re-entry (main_menu.asm:56-57)
if menu then menu.index = 1 end
require("src.ui.Screens").push(game, "OptionsMenu")
end })
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
@@ -532,12 +549,13 @@ function TitleState:openMenu()
type(hooked))
end
local th = #items * 2 + 2
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
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)
-- with GBPalWhiteOut and reruns the whole boot cinematic
-- (engine/menus/main_menu.asm:69-70, title.asm:29)
menu.onCancel = function()
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
function() self.menuOpen = false end))
function() self:restartSequence() 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
@@ -546,6 +564,27 @@ function TitleState:openMenu()
game.stack:push(menu)
end
-- .mainMenuLoop's B branch: DisplayTitleScreen from the top
-- (main_menu.asm:70, title.asm:39-222)
function TitleState:restartSequence()
self.menuOpen = false
pcall(Music.stop)
self.scy = 0x40
self.phase = "drop"
self.dropStep, self.dropLeft = 1, nil
self.showBubble = not self.yellowLayout
self.timer = 0
self.blinkTimer = 0
self.blinkAt = nil
self.cycleIndex = 1
self.scrollPhase = "hold"
self.scrollFrame = 1
self.monOffset = 0
self.ballY = BALL_REST
self.ribbonOffset = nil
self.whooshSrc, self.crySrc, self.exitCrySrc = nil, nil, nil
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()
@@ -609,6 +648,11 @@ function TitleState:updateCycle()
end
function TitleState:update(dt)
-- an onSelect that handed control back without a new state (a failed
-- CONTINUE load, a mod row) re-runs DisplayTitleScreen (main_menu.asm:70)
if self.menuOpen and self.game.stack:top() == self then
self:restartSequence()
end
if self.phase ~= "loop" then
self:updateSequence()
return
@@ -619,10 +663,10 @@ function TitleState:update(dt)
if input:wasPressed("start") or input:wasPressed("a") then
-- .go_to_main_menu voices PikachuCry11 on the way out
local Sound = require("src.core.Sound")
if not Sound.playPikaCry(self.game.data, 11) then
Sound.playCry(self.game.data, "PIKACHU")
end
self:toMenu()
self.exitCrySrc = Sound.playPikaCry(self.game.data, 11)
or Sound.playCry(self.game.data, "PIKACHU")
self.phase = "exitCry"
self.timer = 0
end
return
end
@@ -633,10 +677,11 @@ function TitleState:update(dt)
if input:wasPressed("start") or input:wasPressed("a") then
-- the title mon cries when you leave the title (.finishedWaiting);
-- Yellow's fixed Pikachu title always cries Pikachu.
require("src.core.Sound").playCry(self.game.data,
self.exitCrySrc = require("src.core.Sound").playCry(self.game.data,
self.yellowLayout and "PIKACHU"
or self.cycleSpecies[self.cycleIndex])
self:toMenu()
self.phase = "exitCry"
self.timer = 0
end
end
+18 -11
View File
@@ -38,6 +38,7 @@ 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")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
-- 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")
@@ -161,7 +162,7 @@ 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 = "???" }
local TYPE_NAMES = SummaryMenu.TYPE_NAMES
-- charmap.asm's quantity glyph, spelled the way MartMenu spells it.
local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97"
@@ -1451,12 +1452,12 @@ function BattleState:advanceQueue()
-- inside SendOutPlayerMon and nothing on the enemy's path touches them.
self.menuIndex = 1
self.moveIndex = 1
-- The incoming mon's own level and exp bar: SendOutPlayerMon reloads
-- wBattleMon* from the party slot and UpdatePlayerHUD draws them at its
-- tail (:3838), so both snap here the way shownHp does above.
self.shownLevel = event.mon.level or 1
self.shownExp = self:expPixels(event.mon, event.mon.level,
event.mon.experience)
-- SendOutPlayerMon reloads wBattleMon* from the party slot (:3838):
-- snap from the emit-time snapshot, not the live table (#1514).
local level = event.level or event.mon.level or 1
self.shownLevel = level
self.shownExp = self:expPixels(event.mon, level,
event.experience or event.mon.experience)
self.expAnim = nil
end
end
@@ -1470,6 +1471,12 @@ function BattleState:advanceQueue()
-- BattleWinSlideInEnemyTrainerFrontpic and the DelayFrames 40 behind it
-- (engine/battle/core.asm:2310-2312)
if event.kind == "trainer-return" then
-- LostBattle's ClearBox wipes the live foe pic and HUD before the slide
-- (engine/battle/core.asm:2770-2773)
if event.cleared then
self.showEnemyHud = false
self.ballRows.enemy = false
end
if not self.enemyTrainerImage then return self:advanceQueue() end
self.showEnemyTrainer = true
self.picHidden.enemy = false
@@ -3577,11 +3584,11 @@ function BattleState:drawPanel()
-- 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)
if moveMenu then
-- List box first (core.asm:5074-5084), MoveInfoBox on top (:5157).
Chrome.box(4, 12, 16, 6)
Chrome.box(0, 8, 11, 5)
end
if self.phase == "menu" then
self:printMessage()
+2 -1
View File
@@ -1382,7 +1382,8 @@ end
-- wTimeOfDay, as the cart numbers it: MORN 0, DAY 1, NITE 2, DARK 3.
function Pokegear:timeOfDayIndex()
local world = self.game and self.game.world
local daytime = (world and world.daytime)
-- the unpinned clock split, not the palette pin (pokegear.asm:1456, :1957)
local daytime = (world and (world.tod or world.daytime))
or Palettes.clockDaytime(self.clock and self.clock.hour or nil)
return (Palettes.DAYTIME_ID[daytime] or 2) - 1
end
+3 -1
View File
@@ -886,7 +886,8 @@ function SummaryMenu:drawPageSquare(tx, ty, large, colors)
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.
-- [hli] / [hld], a row down, [hli] / [hl]: the four tiles in that
-- order (engine/pokemon/stats_screen.asm:841-853).
local function body()
G.setColor(1, 1, 1, 1)
G.draw(sheet.image, sheet.quads[first], px, py)
@@ -1184,6 +1185,7 @@ end
SummaryMenu.STAT_LABELS = STAT_LABELS
SummaryMenu.STAT_KEYS = STAT_KEYS
SummaryMenu.TYPE_NAMES = TYPE_NAMES
SummaryMenu.PAGE_PALETTES = PAGE_PALETTES
SummaryMenu.levelText = levelText
return SummaryMenu
+13 -6
View File
@@ -2967,10 +2967,8 @@ function OverworldState:openPC(onDone)
self:openOaksPC(done)
end,
})
end
-- PKMN LEAGUE sits between PROF.OAK's PC and LOG OFF once wNumHoFTeams
-- is nonzero (engine/pokemon/bills_pc.asm:5, :49)
-- engine/pokemon/bills_pc.asm:48-60 PKMN LEAGUE row (#1566)
if #(Game.save.hallOfFame or {}) > 0 then
table.insert(items, {
label = Strings("<PK><MN>LEAGUE"),
@@ -2987,6 +2985,7 @@ function OverworldState:openPC(onDone)
end,
})
end
end
local hooked = Runtime.call("ui.pc.items", sameItems, Game, items)
if type(hooked) == "table" then
@@ -3320,7 +3319,8 @@ end
-- the approach walk and then EngageMapTrainer with no further text: the
-- caller already showed the box, so the battle starts without a second
-- one (#869).
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText)
function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText,
endBattleSound, endBattleIsReward)
local d = npc.def
Runtime.emit("world.trainer_engaged", { npc = npc, trainerClass = d.trainerClass,
partyIndex = d.trainerParty })
@@ -3376,6 +3376,13 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
-- cuts (#282). Substituted here because BattleState:say takes finished
-- text, while TextBox expanded the {PLAYER}/{RIVAL} tokens itself.
battle.endBattleText = wonText and TextBox.substitute(Game, wonText) or nil
-- the badge jingle rides the armed line's first page on the battle
-- screen (sound_get_item_1 in _TX_PRE dialogue; see gyms.lua) (#1606)
battle.endBattleSound = endBattleText ~= nil and endBattleSound or nil
-- one truth for both checkVictoryRewards call sites; endBattleIsReward
-- = false marks an armed line that is NOT the victories dialogue (#1606)
battle.rewardDialogueShown = endBattleText ~= nil
and endBattleIsReward ~= false
battle.onFinish = function(result)
if result == "win" then
Game.save.defeatedTrainers[npc.id] = true
@@ -3386,7 +3393,7 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
-- 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,
endBattleText ~= nil)
battle.rewardDialogueShown)
self:afterBattle(result, battle)
if onDone then onDone() end
else
@@ -4449,7 +4456,7 @@ function OverworldState:restoreBattleContinuation(battle, origin)
game.save.defeatedTrainers[origin.npcId] = true
if origin.event then game.save.flags[origin.event] = true end
self:checkVictoryRewards(battle.oppClass, battle.partyIndex,
battle.endBattleText ~= nil)
battle.rewardDialogueShown)
end
self:afterBattle(result, battle)
self.engaging = false
+8 -2
View File
@@ -6017,8 +6017,14 @@ function World:trainerWinLossText()
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
-- `winlosstext` writes BOTH pointers; a 0 argument destroys the struct
-- value rather than falling back to it (engine/overworld/scripting.asm:651)
local winKey, lossKey
if vm.winLossArmed then
winKey, lossKey = vm.winTextOverride, vm.lossTextOverride
else
winKey, lossKey = obj.winText, obj.lossText
end
return winKey and text[winKey] or nil, lossKey and text[lossKey] or nil
end
@@ -23,8 +23,13 @@ return function(game)
game.save.party = { mon }
-- engine/movie/evolution.asm:103
Evolution.evolve(game, mon, "RAICHU", nil, "ITEM")
U.wait(12)
local top = game.stack:top()
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
local top
for _ = 1, 300 do
top = game.stack:top()
if top and top.screenId == "EvolutionState" then break end
U.wait(1)
end
check("the evolution screen opened",
top and top.screenId == "EvolutionState")
U.shot(game, DIR .. "/bug1412_evo_old_pikachu.png")
@@ -39,9 +39,13 @@ return function(game)
local mon = Pokemon.new(game.data, "PIKACHU", 20)
game.save.party = { mon }
Evolution.evolve(game, mon, "RAICHU")
U.wait(12)
local top = game.stack:top()
-- the IsEvolvingText box now holds 50+ frames before the movie (#1596)
local top
for _ = 1, 300 do
top = game.stack:top()
if top and top.screenId == "EvolutionState" then break end
U.wait(1)
end
check("the evolution screen opened", top and top.screenId == "EvolutionState")
U.log("Issue #494: true-color sprites during evolutions and trades")
U.log("Watch this PIKACHU evolve into RAICHU in ADVANCED colors.")
+53 -14
View File
@@ -50,7 +50,8 @@ return function(game)
U.wait(5)
local ow = game.stack:top()
U.shot(game, DIR .. "/" .. shots.prefix .. "_0_gym.png")
ow:checkVictoryRewards(class, party)
-- true: the badge line rode the battle screen on the real path (#1606)
ow:checkVictoryRewards(class, party, true)
U.wait(10)
for _, s in ipairs(shots.pages) do
advancePages(s.want, shots.prefix .. "_" .. s.name)
@@ -63,26 +64,64 @@ return function(game)
game.save.player.name = game.save.player.name or "RED"
runLeader("PEWTER_GYM", 4, 3, "OPP_BROCK", 1, {
prefix = "brock",
pages = {
{ want = "BOULDERBADGE", name = "1_badge" },
{ want = "FLASH", name = "2_flash" },
{ want = "Wait!", name = "3_wait" },
{ want = "TM34", name = "4_tm34" },
{ want = "BIDE", name = "5_bide" },
},
})
-- The REAL gym path (#1606): the badge line and jingle must ride the
-- battle it pushes (scripts/PewterGym.asm:117-119).
while game.stack:top() do game.stack:pop() end
local Pokemon = require("src.pokemon.Pokemon")
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 50) }
game.save.flags = game.save.flags or {}
game.save.inventory = game.save.inventory or {}
game.save.defeatedTrainers = game.save.defeatedTrainers or {}
game.stack:push(OW, "PEWTER_GYM", 4, 3, "up")
U.wait(5)
local realOw = game.stack:top()
local brock
for _, npc in ipairs(realOw.npcs or {}) do
if npc.def and npc.def.trainerClass == "OPP_BROCK" then brock = npc end
end
assert(brock, "Brock stands in PEWTER_GYM")
require("data.scripts.gyms").PEWTER_GYM.talk.TEXT_PEWTERGYM_BROCK(
game, realOw, brock, function() end)
local battle
for _ = 1, 600 do
local top = game.stack:top()
if top and top.oppClass == "OPP_BROCK" and top.onFinish then
battle = top
break
end
U.tap(game, "a")
U.wait(2)
end
assert(battle, "the real gym path reaches Brock's BattleState")
assert(type(battle.endBattleText) == "string" and #battle.endBattleText > 0,
"the battle carries the armed badge line (#1606)")
assert(battle.endBattleSound == "Get_Item1",
"and the badge jingle beside it (sound_level_up, PewterGym.asm)")
U.shot(game, DIR .. "/brock_real_battle.png")
U.log("force-finishing Brock's battle to run the reward chain")
battle.onFinish("win")
if game.stack:top() == battle then game.stack:pop() end
U.wait(10)
-- rewardDialogueShown: the badge line rode the battle screen, so the map
-- chain opens on the TM prelude, not on a reprint of the badge line
for _, s in ipairs({
{ want = "Wait!", name = "1_wait" },
{ want = "TM34", name = "2_tm34" },
{ want = "BIDE", name = "3_bide" },
}) do
advancePages(s.want, "brock_" .. s.name)
end
assert(game.save.flags.EVENT_BEAT_BROCK, "EVENT_BEAT_BROCK")
assert(game.save.inventory.BOULDERBADGE, "BOULDERBADGE")
assert((game.save.inventory.TM_BIDE or 0) >= 1, "TM_BIDE")
-- checkVictoryRewards direct drive, as after a battle whose badge line
-- rode the battle screen (shownOnBattleScreen = true)
runLeader("CERULEAN_GYM", 5, 5, "OPP_MISTY", 1, {
prefix = "misty",
pages = {
{ want = "CASCADEBADGE", name = "1_badge" },
{ want = "CUT", name = "2_cut" },
{ want = "TM11", name = "3_tm11" },
{ want = "CUT", name = "1_cut" },
{ want = "TM11", name = "2_tm11" },
},
})
assert(game.save.flags.EVENT_BEAT_MISTY, "EVENT_BEAT_MISTY")
+16
View File
@@ -87,6 +87,22 @@ do
"and no title row: the box carries no header text")
end
-- the box keeps the palette beneath and caps the cursor at wMaxMenuItem
do
local list = newList(6)
eq(list.sgbPalettes, false,
"no SET_PAL_GENERIC: ItemMenuLoop keeps RunDefaultPaletteCommand's "
.. "palette (start_sub_menus.asm:300)")
eq(list.cursorRows, 3,
"wMaxMenuItem 2: three cursor rows (home/list_menu.asm:46-48)")
list.game = { input = { wasPressed = function(_, b) return b == "down" end,
isDown = function() return false end } }
for _ = 1, 3 do list:update(1 / 60) end
eq(list.index, 4, "three downs reach the fourth item")
eq(list.index - list.scroll, 3,
"scrolling instead of dropping the cursor onto the look-ahead row")
end
-- a short list stops at its last name, and the terminator's CANCEL row is
-- what would follow -- never the '▼'
do
@@ -40,6 +40,11 @@ do
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")
battle.shrinkOut.frame = 7
T.eq(battle:shrinkOutScale(battle.player), 0,
"then the 7x7 area holds cleared: no full-size flash before the swap")
T.check(battle:shrinkOutScale({}) == nil,
"the swapped-in battler draws normally")
T.check(battle:shrinkOutScale(battle.enemy) == nil,
"AnimateRetreatingPlayerMon is player-side only")
end
@@ -62,6 +67,8 @@ do
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")
T.eq(battle.sendingOut, true,
"and stays hidden until the swap's send-out (#1545)")
end
-- ---------------------------------------------------------------------
@@ -109,4 +116,62 @@ do
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "which also reprompts")
end
-- ---------------------------------------------------------------------
-- the SHIFT prompt's picker reprompts on a dead or already-out pick too
-- (HasMonFainted's NoWillText, core.asm:1473-1488) (#1608)
-- ---------------------------------------------------------------------
do
local save = SaveData.newGame()
save.player.name = "RED"
save.party = { Pokemon.new(Data, "FIXMON_A", 30),
Pokemon.new(Data, "FIXMON_B", 30) }
save.options = { battleStyle = "shift" }
local game = { data = Data, save = save,
stack = { top = function() return nil end,
push = function() end, pop = function() end } }
local battle = BattleState.newTrainer(game, "OPP_FIX_YOUNGSTER", 1)
battle.participants = {}
battle.buildScreen = function(_, _, opts) return opts end
-- capture the picker opts the SHIFT branch pushes
local captured
Data.screens = Data.screens or {}
Data.screens.PartyMenu = function(_, opts) captured = opts; return {} end
require("src.ui.Screens").invalidate()
battle.enemyParty[1].hp = 0
battle.enemy.mon = battle.enemyParty[1]
battle:enemyMonFainted()
local choiceRow
for _, row in ipairs(battle.queue) do
if row.choice then choiceRow = row end
end
T.check(choiceRow ~= nil, "SHIFT queues the change-POKeMON choice")
choiceRow.choice(true)
T.check(captured ~= nil and captured.forceSwitch == true,
"YES opens the forced party picker")
battle.queue, battle.nextInsert = {}, 0
save.party[2].hp = 0
captured.onSwitch(save.party[2])
T.check(battle.queue[1] and battle.queue[1].text
and battle.queue[1].text:find("no will", 1, true) ~= nil,
"a fainted SHIFT pick prints NoWillText first")
T.check(battle.queue[2] and battle.queue[2].ui ~= nil,
"then the picker goes straight back up, ahead of the send-out")
T.eq(battle.queue[2].ui(), captured, "with the same forced opts")
battle.queue, battle.nextInsert = {}, 0
captured.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,
"an already-out SHIFT pick prints AlreadyOutText")
T.check(battle.queue[2] and battle.queue[2].ui ~= nil, "and reprompts too")
battle.queue, battle.nextInsert = {}, 0
save.party[2].hp = 10
captured.onSwitch(save.party[2])
T.eq(#battle.queue, 0, "a healthy pick queues no reprompt rows")
Data.screens.PartyMenu = nil
require("src.ui.Screens").invalidate()
end
T.finish("retreat animation and switch reprompt (#1563, #1545, #1608)")
+12 -6
View File
@@ -30,7 +30,9 @@ package.loaded["src.core.Sound"] = {
playCry = function() end,
}
package.loaded["src.render.TextBox"] = {
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
new = function(_, text, done, opts)
return { textBox = true, text = text, done = done, opts = opts }
end,
}
-- BagMenu and PartyMenu bind TextBox at require time, so they load against the
-- stub; Screens caches its factory per id and must be told to forget.
@@ -126,17 +128,21 @@ 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)
-- IsEvolvingText STAYS up; its onShown starts the DelayFrames 50 hold,
-- which then pushes the movie over it (evos_moves.asm:120-134) (#1596)
local intro = game.stack:top()
if not (intro and intro.textBox and intro.done) then
if not (intro and intro.textBox and intro.opts and intro.opts.stay) 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()
intro.opts.stay.onShown()
local hold = game.stack:top()
for _ = 1, 60 do
if pushed then break end
if hold.update then hold.update() end
end
return list
end
@@ -1,5 +1,6 @@
-- The evolution dialogue is the cart's, in the cart's order (#1596):
-- engine/pokemon/evos_moves.asm:120-128, :136-150, :151-153
-- engine/pokemon/evos_moves.asm:120-134 (the clear is rows 0-11 only),
-- :136-150, :151-153
package.path = "./?.lua;./?/init.lua;" .. package.path
@@ -65,10 +66,9 @@ do
"_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)")
check(intro.stay ~= nil and not intro.stay.prompt,
"which waits for no button (IsEvolvingText ends in `done`) "
.. "and stays up under whatever follows")
end
-- it hands off to the movie on its own, with no input at all
local top
@@ -78,7 +78,15 @@ do
step(game)
end
check(getmetatable(top) == EvolutionState,
"the flash movie opens once that box has cleared itself")
"the flash movie opens once the DelayFrames 50 hold has passed")
local underneath = false
for _, s in ipairs(game.stack.states or {}) do
if s == intro then underneath = true end
end
check(underneath, "the 'is evolving!' box is still on the stack under the "
.. "flash (ClearScreenArea wipes rows 0-11 only, evos_moves.asm:126-128)")
check(not EvolutionState.isOpaque,
"and the flash screen is not opaque, so the box beneath draws")
eq(mon.species, "FIXMON_A",
"and nothing has evolved yet while the box was up")
end
@@ -122,12 +130,4 @@ do
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()
@@ -35,4 +35,17 @@ local menu = PokedexMenu.new(game, { onClose = function() closed = true end })
menu:close()
check(closed, "close still runs the caller's onClose")
-- Save.newGame seeds the key and Save.validate clamps a hand-edited value
local Save = require("src.core.gen2.Save")
eq(Save.newGame().lastDexMode, "NEW",
"a brand-new save carries the key from the start (wLastDexMode's zero)")
local edited = Save.newGame()
edited.lastDexMode = "SPICY"
Save.validate(edited)
eq(edited.lastDexMode, "NEW", "validate clamps an out-of-range mode to NEW")
local kept = Save.newGame()
kept.lastDexMode = "A-Z"
Save.validate(kept)
eq(kept.lastDexMode, "A-Z", "and keeps a legal one")
T.finish("gen2 pokedex mode persistence bug 1474")
+25 -1
View File
@@ -34,4 +34,28 @@ T.check(not keys["TILESET_KANTO|ROOF_SILVER"],
T.check(keys["TILESET_JOHTO|ROOF_SILVER"],
"while TILESET_JOHTO still takes its group's roof")
print("gen2 Kanto roof gate (#1479): ok")
-- World:atlasFor is the copy the bug was filed against; it has its own
-- ROOF_TILESETS gate, so pin it separately from MapPreview's
local realAssets = package.loaded["src.render.Assets"]
package.loaded["src.render.Assets"] = setmetatable({
image = function() return { setFilter = function() end } end,
resolve = function(path) return path end,
}, { __index = realAssets or { register = function() end } })
package.loaded["src.world.gen2.World"] = nil
local World = require("src.world.gen2.World")
local world = setmetatable({
tilesets = baker.tilesets,
roofs = baker.roofs,
atlasCache = {},
}, { __index = World })
world:atlasFor({ tileset = "TILESET_KANTO", group = 19 })
world:atlasFor({ tileset = "TILESET_JOHTO", group = 19 })
T.check(world.atlasCache["TILESET_KANTO"] ~= nil
and world.atlasCache["TILESET_KANTO|ROOF_SILVER"] == nil,
"World:atlasFor bakes Kanto with no map-group roof (home/map.asm:1738-1749)")
T.check(world.atlasCache["TILESET_JOHTO|ROOF_SILVER"] ~= nil,
"and still roofs TILESET_JOHTO by group")
package.loaded["src.render.Assets"] = realAssets
package.loaded["src.world.gen2.World"] = nil
T.finish("gen2 Kanto roof gate (#1479)")
+51
View File
@@ -0,0 +1,51 @@
-- #1558: menu_gfx.stats -> $31..$41 quads (engine/gfx/load_font.asm:90-95);
-- PAGE_PALETTES is gfx/stats/pages.pal (cgb_layouts.asm:199-212)
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local SummaryMenu = require("src.ui.gen2.SummaryMenu")
-- gfx/stats/pages.pal, RGB 5-bit rows in ROM order: pink, green, blue
local ROM_PALS = {
{ { 31, 31, 31 }, { 31, 19, 31 }, { 31, 15, 31 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 21, 31, 14 }, { 17, 31, 0 }, { 0, 0, 0 } },
{ { 31, 31, 31 }, { 17, 31, 31 }, { 17, 31, 31 }, { 0, 0, 0 } },
}
local function up(v) return math.floor(v * 255 / 31 + 0.5) end
for p = 1, 3 do
for c = 1, 4 do
for ch = 1, 3 do
T.eq(SummaryMenu.PAGE_PALETTES[p][c][ch], up(ROM_PALS[p][c][ch]),
("pages.pal palette %d color %d channel %d"):format(p, c, ch))
end
end
end
-- the quads: 17 tiles from $31, one 8x8 cell each off the 136x8 sheet
love.graphics = love.graphics or {}
local realNewQuad = love.graphics.newQuad
love.graphics.newQuad = function(x, y, w, h)
return { x = x, y = y, w = w, h = h }
end
local menu = setmetatable({
menuGfx = { stats = { sheet = "stats", tiles = 17, firstTile = 0x31 } },
picImage = function()
return { getDimensions = function() return 136, 8 end }
end,
}, { __index = SummaryMenu })
local sheet = menu:statsTiles()
if T.check(sheet ~= nil, "menu_gfx.stats builds the tile sheet") then
for id = 0x31, 0x41 do
local q = sheet.quads[id]
T.check(q ~= nil and q.x == (id - 0x31) * 8 and q.w == 8 and q.h == 8,
("tile $%02x maps to sheet cell %d"):format(id, id - 0x31))
end
T.check(sheet.quads[0x42] == nil, "and exactly 17 tiles, no more")
end
-- a cache built before menu_gfx.stats existed keeps the hand-drawn fallback
local old = setmetatable({ menuGfx = {} }, { __index = SummaryMenu })
T.check(old:statsTiles() == nil, "no menu_gfx.stats falls back, not crashes")
love.graphics.newQuad = realNewQuad
T.finish("gen2 stats tiles and page palettes (#1558)")
+7 -2
View File
@@ -93,7 +93,7 @@ do
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(defeated < ret, "the last faint event precedes the pic's return")
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")
@@ -153,12 +153,17 @@ do
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.winLossArmed = true
world.vm.winTextOverride = "3:4200"
win = World.trainerWinLossText(world)
win, loss = World.trainerWinLossText(world)
T.eq(win, "Scripted win.", "`winlosstext` overwrites the pointer")
-- winlosstext writes BOTH pointers; its 0 loss argument destroyed the
-- struct's value (engine/overworld/scripting.asm:651)
T.eq(loss, nil, "and a 0 loss argument zeroes the loss pointer with it")
world.vm.trainerObject = nil
world.vm.winTextOverride = nil
world.vm.winLossArmed = 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")
@@ -35,13 +35,15 @@ end
local fakeGame = { data = { text = text }, save = { flags = {} } }
local armed
local armed, armedSound
local fakeOw = {
engageTrainer = function(_, _, _, endBattleText) armed = endBattleText end,
engageTrainer = function(_, _, _, endBattleText, _, endBattleSound)
armed, armedSound = endBattleText, endBattleSound
end,
}
local function armedFor(mapId, textId, victoryKey)
armed = nil
armed, armedSound = nil, nil
gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end)
local labels = victories[victoryKey].dialogue
local want = {}
@@ -63,6 +65,10 @@ local leaders = {
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")
-- the dialogue's sound command rides the armed line onto the battle
-- screen too (sound_get_item_1 / sound_get_key_item) (#1606)
T.eq(armedSound, victories[entry[3]].badgeSound,
entry[3] .. " arms its badge jingle beside the line")
end
-- Brock's armed label is one text chain of two text_far pages
@@ -137,3 +143,5 @@ 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")
T.finish("gym end battle text (#1606)")
@@ -75,6 +75,25 @@ 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")
-- main_menu.asm:70 jumps back to DisplayTitleScreen: the whole cinematic reruns
T.eq(title.phase, "drop", "cancel reruns the boot cinematic from the logo drop")
-- a stranded menuOpen (an onSelect that handed control straight back) may
-- not leave a blank white title behind
title.menuOpen = true
title:update(1 / 60)
T.eq(title.menuOpen, false, "a stranded menuOpen clears on the next update")
-- .finishedWaiting: PlayCry then WaitForSoundToFinish before the white-out
-- (engine/movie/title.asm:241-243)
while title.phase ~= "loop" do title:updateSequence() end
game.input.wasPressed = function(_, b) return b == "start" end
title:update(1 / 60)
game.input.wasPressed = function() return false end
T.eq(title.phase, "exitCry", "START waits out the cry before the white-out")
T.eq(game.stack:top(), title, "no flash is pushed on the cry frame")
run(title, 10)
T.check(game.stack:top() ~= title, "the flash follows once the cry is done")
-- ------------------------------------------- #1511: the intro NAME box
@@ -144,11 +163,20 @@ local function pump(frames)
end
end
pump(400)
T.check(getmetatable(fgame.stack:top()) == Menu, "the preset list opens")
-- _IntroducePlayerText ends in `prompt` (text_2.asm:1730): arrowed A wait
T.check(getmetatable(fgame.stack:top()) ~= Menu,
"the question box waits for A before the name list")
T.check(fgame.stack:top() == flow.holdBox and flow.holdBox.done,
"the typed-out question box is on top, waiting for the press")
fgame.input.wasPressed = function(_, b) return b == "a" end
fgame.stack:top():update(1 / 60)
fgame.input.wasPressed = function() return false end
pump(400)
T.check(getmetatable(fgame.stack:top()) == Menu, "A opens the preset list")
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")
T.check(flow.holdBox.stayShown == true,
"prompt-then-hold: the box stays up after the press (home/text.asm:434)")
local preset = fgame.stack:top()
preset.index = 2
@@ -157,7 +185,9 @@ 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)
-- 13-frame ClearScreenArea / DelayFrames beat, then six tiles of slide
-- (oak_speech2.asm:69-78)
run(fgame.stack:top(), 13 + 6 * 3)
T.eq(flow.picSlide, 0, "OakSpeechSlidePicLeft puts the pic back")
T.finish("intro_title_naming_bug1510_1511")
+7 -4
View File
@@ -87,9 +87,12 @@ T.eq(box.text, fakeGame.data.text._AccessedHoFPCText,
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)
-- without the Pokedex, .noOaksPC2 skips Oak's PC and the league row alike
-- (bills_pc.asm:48-49, :68-72); only the box height ignores it (:5-7)
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")
T.eq(indexOf(noDex, "<PK><MN>LEAGUE"), nil,
"no dex, no PKMN LEAGUE row, HoF teams or not")
T.eq(indexOf(noDex, "PROF.OAK's PC"), nil, "and no PROF.OAK's PC either")
T.finish("pc league row (#1566)")
@@ -159,6 +159,47 @@ do
end
end
-- .useRareCandy: TryEvolvingMon runs over the party list
-- (item_effects.asm:1392-1418) (#1594)
do
local evolveCalls = {}
package.loaded["src.pokemon.Evolution"] = {
pendingFor = function() return "FIXMON_B", { method = "LEVEL" } end,
evolve = function(_, _, to, onDone, via)
evolveCalls[#evolveCalls + 1] = { to = to, onDone = onDone, via = via }
end,
}
package.loaded["src.battle.BattleState"] = {
StatBox = { new = function(_, _, cb) return { statBox = true, cb = cb } end },
}
local game = freshGame(3)
local list = useFromBag(game, nil, "RARE_CANDY")
if check(list ~= nil, "the bag reached the picker (evolution case)") then
local box = game.stack:top()
check(isBox(box), "the level line prints first")
game.stack:pop() -- a real TextBox pops itself before onDone
box.done()
local stat = game.stack:top()
if check(stat and stat.statBox, "then the stat window") then
game.stack:pop() -- as does the stat window before its callback
stat.cb()
eq(#evolveCalls, 1, "the pending evolution starts")
check(inStack(game.stack, isPicker),
"with the party picker STILL up: the evolution prints over it, "
.. "not over the bag list (#1594)")
check(type(evolveCalls[1].onDone) == "function",
"closePicker rides the evolution's completion callback")
evolveCalls[1].onDone()
check(not inStack(game.stack, isPicker),
"and the picker comes down once the evolution flow completes")
check(inStack(game.stack, function(s) return s == list end),
"while the bag list survives (#796)")
end
end
package.loaded["src.pokemon.Evolution"] = nil
package.loaded["src.battle.BattleState"] = nil
end
-- The last candy: the row goes away (RemoveUsedItem empties the slot) and the
-- cursor clamps to a real row -- but the list itself still must not close.
do
+7 -1
View File
@@ -35,6 +35,10 @@ end
T.check(save ~= nil, "the start menu lists SAVE")
save.onSelect()
-- PrintSaveScreenText ends `ld c, 30 / jp DelayFrames`: the bare panel
-- holds 30 frames before the prompt (main_menu.asm:404-405)
T.eq(#game.stack.states, 1, "the panel shows alone first")
for _ = 1, 30 do game.stack:top().update() end
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,
@@ -44,9 +48,11 @@ 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
-- save.asm:188 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")
T.eq(prompt.choiceBox, require("src.ui.Theme").saveBox,
"the geometry routes through Theme so field.theme can restyle it")
local choice = ChoiceBox.new(game, function() end, { box = prompt.choiceBox })
T.eq(choice.tx, 0, "ChoiceBox honours the save-specific left placement")
+21 -4
View File
@@ -80,10 +80,11 @@ 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")
-- .ThrashingAboutCheck (core.asm:3534-3535, enemy mirror :5909-5910) (#1577)
T.check(indexOf(animRows(battle), "THRASH") ~= nil,
"a continuation turn animates THRASH, not the locked move's own id")
T.check(indexOf(animRows(battle), "FIX_THRASH") == nil,
"so the locked move's own animation does not play")
T.check(hasText(battle, "thrashing about"),
"ThrashingAboutText prints in place of the used-move line")
T.check(not hasText(battle, "used FIX THRASH"),
@@ -128,4 +129,20 @@ do
"and the user is locked into Thrash next turn")
end
-- ---------------------------------------------------------------------
-- JumpMoveEffect (core.asm:3129-3133) before MoveHitTest INVULNERABLE (:3150) (#1565)
do
local battle = newBattle()
battle.queue, battle.nextInsert = {}, 0
battle.enemy.invulnerable = true
battle:performMove(battle.player, battle.enemy, { id = "FIX_THRASH", pp = 20 })
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") ~= nil,
"the setup animation plays against a mid-Fly/Dig target")
T.eq(battle.player.thrashTurns, 2,
"the 2-3 roll commits against a mid-Fly/Dig target")
T.check(battle:menuLockedAction(battle.player) ~= nil,
"and the user is locked into Thrash next turn")
T.check(hasText(battle, "attack missed"), "while the attack itself misses")
end
T.finish("thrash setup animation (#1532)")
+5
View File
@@ -36,5 +36,10 @@ T.eq(boostedText(function(mon, save)
mon.traded = true
mon.otId = save.player.id
end), false, "and a mon traded back to its original trainer loses it (#1488)")
T.eq(boostedText(function(mon)
-- repairTradedOtIds leaves traded mons with otId nil (#1265, #1461)
mon.traded = true
mon.otId = nil
end), true, "a traded mon with no stored OTID keeps the boost (#1488)")
T.finish("traded exp boost is an OTID comparison (#1488)")
+5 -3
View File
@@ -149,9 +149,11 @@ do
local events = battle:takeTurn({ kind = "move", move = "ROAR" })
check(saidSomethingLike(events, "fled in fear!"),
"FledInFearText: the wild mon is blown away")
check(not saidSomethingLike(events, "used TACKLE!"),
"and the mon that left never takes its half of the turn")
eq(player.hp, hpBefore, "so nothing came back the other way")
-- EFFECT_FORCE_SWITCH is priority 0, below BASE_PRIORITY
-- (data/moves/effects_priorities.asm:5): Roar goes last (#1475)
check(saidSomethingLike(events, "used TACKLE!"),
"so the wild mon takes its half of the turn first, Speed regardless")
check(player.hp < hpBefore, "and its hit landed before the blow-away")
eq(battle.over, true, "the battle is over")
eq(battle.outcome, "fled", "as the cart's DRAW")
end
+42 -3
View File
@@ -1307,7 +1307,7 @@ do
end
-- ---- GetMovePriority's Vital Throw carve-out (#1475) ----------------------
-- engine/battle/core.asm:786
-- engine/battle/core.asm:787-789
do
local screen = newScreen()
check(runToMenu(screen), "reached the menu")
@@ -1323,11 +1323,20 @@ do
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")
moves.ROAR_FIX = { id = "ROAR_FIX", name = "ROAR", power = 0,
type = "NORMAL", accuracy = 100, pp = 20, effect = "EFFECT_FORCE_SWITCH" }
-- MoveEffectPriorities: EFFECT_FORCE_SWITCH is 0, below BASE_PRIORITY
-- (data/moves/effects_priorities.asm:5)
eq(battle:movePriority("ROAR_FIX"), -1,
"Whirlwind and Roar sit below BASE_PRIORITY")
eq(battle:orderOf("VITAL_THROW", "ROAR_FIX"), "player",
"VITAL_THROW ties a force-switch move (0 vs 0), so Speed decides")
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
moves.ROAR_FIX = nil
end
-- ---- a send-out snapshots HP at send time (#1514) -------------------------
@@ -1352,6 +1361,29 @@ do
"and the HUD opens on the snapshot, not on the post-hit value")
end
-- ---- the send-out snapshots level and exp the same way (#1514) ------------
-- SendOutPlayerMon reloads wBattleMon* from the party slot (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.level, bench.level, "the send carries a level snapshot")
eq(send.experience, bench.experience, "and an experience snapshot")
-- awardExperience mutates the live table before the UI dequeues the send
bench.level = bench.level + 3
bench.experience = (bench.experience or 0) + 5000
screen:push(send)
screen:advanceQueue()
eq(screen.shownLevel, send.level,
"the HUD opens on the send-time level, not the post-award one")
eq(screen.shownExp, screen:expPixels(bench, send.level, send.experience),
"and the exp bar fills from the send-time experience")
end
-- ---- LearnMove finishes before the queued send-out (#1516) ----------------
-- LearnMove inside GiveExperiencePoints (engine/battle/core.asm:1959-2010)
do
@@ -1404,8 +1436,15 @@ do
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")
-- SafeLoadTempTilemapToTilemap keeps the full battle textbox under the
-- move list (core.asm:4689); Textbox then MoveInfoBox over it (:5084, :5157).
check(drawn:find("0,12,20,6", 1, true) ~= nil,
"over the restored full-width message box")
local base = drawn:find("0,12,20,6", 1, true)
local list = drawn:find("4,12,16,6", 1, true)
local info = drawn:find("0,8,11,5", 1, true)
check(base < list and list < info,
"painted base box, then list box, then info box")
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")
+21
View File
@@ -137,6 +137,27 @@ do
end
eq(checktime(4), 1, "checktime NITE is TRUE inside the PALETTE_DAY room")
eq(checktime(2), 0, "and checktime DAY is FALSE there")
-- the two out-of-World consumers read wTimeOfDay too (#1557)
local Pokegear = require("src.ui.gen2.Pokegear")
eq(Pokegear.timeOfDayIndex({ game = { world = world } }), 2,
"the radio's program pick answers NITE, not the pin (pokegear.asm:1456)")
local Specials = require("src.script.gen2.Specials")
local buffer
local pvm = { curPhoneCaller = 1,
setStringBuffer = function(_, name) buffer = name end,
specials = { world = {
tod = world.tod, daytime = world.daytime,
encounters = { grass = { PLAYERS_HOUSE_1F = { slots = {
NITE = { { species = "NITEMON" }, { species = "NITEMON" },
{ species = "NITEMON" }, { species = "NITEMON" } },
DAY = { { species = "DAYMON" }, { species = "DAYMON" },
{ species = "DAYMON" }, { species = "DAYMON" } },
} } } },
} } }
Specials.ALL.RandomPhoneWildMon(pvm)
eq(buffer, "NITEMON",
"RandomPhoneWildMon reads the NITE column (wildmons.asm:861)")
end
-- ---- the hour-window respawn is not eaten by a busy frame -------------------
+10 -3
View File
@@ -116,9 +116,9 @@ do
end
-- Every other givepoke in the game is the flag-FALSE form: no names, and the
-- nickname prompt still runs.
-- nickname prompt still runs (engine/pokemon/move_mon.asm:1753-1757).
do
local given = nil
local given, asked = nil, false
local plainVm = Vm.new({ generation = 2,
["s:eevee"] = {
{ op = "givepoke", species = 133, level = 20, item = 0, trainer = 0 },
@@ -127,13 +127,20 @@ do
}, {}, Events.new(), {
givePoke = function(species, level, item, opts)
given = { opts = opts }
return nil
return { species = "EEVEE" }
end,
showText = function(_, onDone) onDone() end,
-- GiveANickname_YesNo (move_mon.asm:1753-1757): the prompt's yes/no
yesorno = function(onChoose)
asked = true
onChoose(false)
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")
check(asked, "and the nickname prompt still runs on it")
end
-- Phone + verbosegiveitem (Elm directions / aide potion)
+11 -4
View File
@@ -1709,14 +1709,21 @@ check(hw:mapSceneOf(3, 4) == nil,
"a map with NO scene_var row answers nil, which the VM turns into $ff")
check(hw:mapSceneOf(9, 9) == nil, "and an unresolvable pair is nil too")
-- hw.tod is the production read (the unpinned wTimeOfDay split, #1557);
-- hw.daytime is the palette pin it must NOT follow
hw.tod = "DAY"
eq(hw:timeOfDayId(), 1, "DAY is wTimeOfDay 1")
hw.daytime = "MORN"
hw.tod = "MORN"
eq(hw:timeOfDayId(), 0, "MORN is 0")
hw.daytime = "NITE"
hw.tod = "NITE"
eq(hw:timeOfDayId(), 2, "NITE is 2")
hw.tod = "NITE"
hw.daytime = "DARK"
eq(hw:timeOfDayId(), 3, "DARKNESS is 3")
hw.daytime = "DAY"
eq(hw:timeOfDayId(), 2, "a PALETTE_DARK pin does not leak into wTimeOfDay")
hw.tod = nil
hw.daytime = "DARK"
eq(hw:timeOfDayId(), 3, "DARKNESS is 3 only on the tod-less fallback arm")
hw.tod, hw.daytime = nil, "DAY"
eq(hw:gsVersion(), 0, "checkver: a Gold save is 0")
eq(hookWorld({ version = "silver" }):gsVersion(), 1, "and a Silver save is 1")
+9 -6
View File
@@ -978,14 +978,17 @@ do
game.stack:pop()
battle.onFinish("win")
for _ = 1, 12 do
-- the "is evolving!" box types out and holds DelayFrames 50 before the
-- movie (evos_moves.asm:120-134) (#1596), so drive frames to reach it
game.input.wasPressed = function() return false end
local evoTop
for _ = 1, 900 do
local t = game.stack:top()
if not t or t.screenId == "EvolutionState" then break end
game.stack:pop()
if t.onDone then t.onDone() end
if not t then break end
if t.screenId == "EvolutionState" then evoTop = t break end
if t.update then t:update(1 / 60) else break end
end
check(game.stack:top() and game.stack:top().screenId == "EvolutionState",
"the win reaches the evolution screen")
check(evoTop ~= nil, "the win reaches the evolution screen")
end
do
+3
View File
@@ -735,6 +735,9 @@ REQUIRED_SYMBOLS = {
# nine fill cells. "HP:" and the ten HP-bar cells come from
# FontBattleExtra, which is already extracted.
"EnemyHPBarBorderGFX", "HPExpBarBorderGFX", "ExpBarGFX",
# gfx/stats/stats_tiles.png + gfx/stats/pages.pal, StatsScreen_LoadFont
# and _CGB_StatsScreenHPPals (#1558)
"StatsScreenPageTilesGFX", "StatsScreenPagePals",
# The player's own battle back-pic (gfx/player/chris_back.2bpp.lz). It is
# what stands in the player's pic box for the whole battle intro, before
# SendOutPlayerMon swaps in the mon's backpic.