diff --git a/data/scripts/gyms.lua b/data/scripts/gyms.lua index ddf126c8..dd45aeda 100644 --- a/data/scripts/gyms.lua +++ b/data/scripts/gyms.lua @@ -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 diff --git a/data/scripts/story5.lua b/data/scripts/story5.lua index 6f42068c..cf01c0a7 100644 --- a/data/scripts/story5.lua +++ b/data/scripts/story5.lua @@ -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 = { diff --git a/src/battle/BattleState.lua b/src/battle/BattleState.lua index 68644961..7cc4cec9 100644 --- a/src/battle/BattleState.lua +++ b/src/battle/BattleState.lua @@ -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,18 +3921,26 @@ function BattleState:performMove(user, target, moveInst, isCalled) end self.moveAnimRow = nil - if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then - 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 - 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) + 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)) + 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 Runtime.emit("battle.move_used", { battle = self, user = user, target = target, move = move, @@ -3913,6 +3948,9 @@ function BattleState:performMove(user, target, moveInst, isCalled) }) 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, diff --git a/src/battle/EffectRegistry.lua b/src/battle/EffectRegistry.lua index 09699e0b..db01c7ed 100644 --- a/src/battle/EffectRegistry.lua +++ b/src/battle/EffectRegistry.lua @@ -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 diff --git a/src/battle/MoveEffects.lua b/src/battle/MoveEffects.lua index b07ae63c..1912967e 100644 --- a/src/battle/MoveEffects.lua +++ b/src/battle/MoveEffects.lua @@ -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 - 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 + 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 + ctx.battle:animBeforeMove( + user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer) end, }, JUMP_KICK_EFFECT = { diff --git a/src/battle/gen2/Battle.lua b/src/battle/gen2/Battle.lua index 16ed674e..6f95b06e 100644 --- a/src/battle/gen2/Battle.lua +++ b/src/battle/gen2/Battle.lua @@ -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", { diff --git a/src/import/RomExtractorGen2.lua b/src/import/RomExtractorGen2.lua index 737f0e71..d3736b8e 100644 --- a/src/import/RomExtractorGen2.lua +++ b/src/import/RomExtractorGen2.lua @@ -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") diff --git a/src/inventory/ItemEffects.lua b/src/inventory/ItemEffects.lua index 0ef0c6ef..2915b4eb 100644 --- a/src/inventory/ItemEffects.lua +++ b/src/inventory/ItemEffects.lua @@ -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" diff --git a/src/pokemon/Evolution.lua b/src/pokemon/Evolution.lua index 279daa50..dbd47f77 100644 --- a/src/pokemon/Evolution.lua +++ b/src/pokemon/Evolution.lua @@ -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 - -- 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) + 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 diff --git a/src/script/gen2/Vm.lua b/src/script/gen2/Vm.lua index 8b60c13b..86a79c51 100644 --- a/src/script/gen2/Vm.lua +++ b/src/script/gen2/Vm.lua @@ -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 + local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0 if self.givePokeFn then - local mon = self.givePokeFn(species, level, item) + -- 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 - local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0 if mon and trainer == 0 then Specials.askNickname(self, mon) end diff --git a/src/ui/BagMenu.lua b/src/ui/BagMenu.lua index b4a5dffb..4aaa36cd 100644 --- a/src/ui/BagMenu.lua +++ b/src/ui/BagMenu.lua @@ -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) diff --git a/src/ui/EvolutionState.lua b/src/ui/EvolutionState.lua index 972ec407..bb8f0433 100644 --- a/src/ui/EvolutionState.lua +++ b/src/ui/EvolutionState.lua @@ -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 diff --git a/src/ui/ListMenu.lua b/src/ui/ListMenu.lua index b3856a0c..aa110d41 100644 --- a/src/ui/ListMenu.lua +++ b/src/ui/ListMenu.lua @@ -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) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 5a97908a..de2d9387 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -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 diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index 9c82cfb7..ff44b5dd 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -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,9 +100,18 @@ function NamingScreen:enter() end, }) end - self.game.stack:push(Menu.new(self.game, items, { - tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false, - })) + 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 diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 5da99051..4c2bdfdc 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -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) - self:sayText(self:stepText(step), function() self:advance() end) + 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,20 +426,26 @@ function OakSpeech:runStep(step) local presets = step.presets or namePresets(self.game, step.presetsWho or who, step.presetsFallback or { "RED" }) - require("src.ui.Screens").push(self.game, "NamingScreen", { - title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")), - presets = presets, - maxLen = step.maxLen or self.nameLen, - onDone = function(name) - if who == "rival" then - self.game.save.player.rival = name - else - self.game.save.player.name = name - end - self:recordAnswer(step, 1, name, name) - self:advance() - end, - }) + 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 + 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 + 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 diff --git a/src/ui/StartMenu.lua b/src/ui/StartMenu.lua index 6d6394be..16a9506a 100644 --- a/src/ui/StartMenu.lua +++ b/src/ui/StartMenu.lua @@ -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 - 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) + -- 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) + 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, diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 4956c75d..57c0dcad 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -511,9 +511,12 @@ 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() - require("src.ui.Screens").push(game, "OptionsMenu") - end }) + -- 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() if self.onExit then self.onExit() @@ -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 diff --git a/src/ui/gen2/BattleState.lua b/src/ui/gen2/BattleState.lua index 2274a09d..385347e0 100644 --- a/src/ui/gen2/BattleState.lua +++ b/src/ui/gen2/BattleState.lua @@ -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. - Chrome.box(0, 12, 20, 6) + -- 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() diff --git a/src/ui/gen2/PackMenu.lua b/src/ui/gen2/PackMenu.lua index d1e3c2d7..72aa6132 100644 --- a/src/ui/gen2/PackMenu.lua +++ b/src/ui/gen2/PackMenu.lua @@ -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): " used the\n." -- World already wrote the counter and took the item out of the bag, so diff --git a/src/ui/gen2/PokedexMenu.lua b/src/ui/gen2/PokedexMenu.lua index 480bd9ef..fbd14aef 100644 --- a/src/ui/gen2/PokedexMenu.lua +++ b/src/ui/gen2/PokedexMenu.lua @@ -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 diff --git a/src/ui/gen2/Pokegear.lua b/src/ui/gen2/Pokegear.lua index a7cb56d0..13c3fa91 100644 --- a/src/ui/gen2/Pokegear.lua +++ b/src/ui/gen2/Pokegear.lua @@ -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 diff --git a/src/ui/gen2/SummaryMenu.lua b/src/ui/gen2/SummaryMenu.lua index 7f4dc4ee..a39ad8dc 100644 --- a/src/ui/gen2/SummaryMenu.lua +++ b/src/ui/gen2/SummaryMenu.lua @@ -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), ($73), ($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. 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 diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 088fbe84..2ca6071b 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -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("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 - chain.add(reward.dialogue, reward.badgeSound) + 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 diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index 527d7d66..ce00a6c2 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -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 diff --git a/tests/drivers/gold_bug1479_probe.lua b/tests/drivers/gold_bug1479_probe.lua new file mode 100644 index 00000000..2b178d01 --- /dev/null +++ b/tests/drivers/gold_bug1479_probe.lua @@ -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 diff --git a/tests/drivers/gold_bug1569_test.lua b/tests/drivers/gold_bug1569_test.lua new file mode 100644 index 00000000..e3625988 --- /dev/null +++ b/tests/drivers/gold_bug1569_test.lua @@ -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 diff --git a/tests/engine/bag_item_box_bug1521.lua b/tests/engine/bag_item_box_bug1521.lua new file mode 100644 index 00000000..7b1d4945 --- /dev/null +++ b/tests/engine/bag_item_box_bug1521.lua @@ -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() diff --git a/tests/engine/battle_retreat_switch_bug1563.lua b/tests/engine/battle_retreat_switch_bug1563.lua new file mode 100644 index 00000000..b9dde062 --- /dev/null +++ b/tests/engine/battle_retreat_switch_bug1563.lua @@ -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)") diff --git a/tests/engine/confusion_selfhit_anim_bug1578.lua b/tests/engine/confusion_selfhit_anim_bug1578.lua new file mode 100644 index 00000000..5a3e1c21 --- /dev/null +++ b/tests/engine/confusion_selfhit_anim_bug1578.lua @@ -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)") diff --git a/tests/engine/evo_stone_cancel_bug883_test.lua b/tests/engine/evo_stone_cancel_bug883_test.lua index 6a04f155..a76dd34f 100644 --- a/tests/engine/evo_stone_cancel_bug883_test.lua +++ b/tests/engine/evo_stone_cancel_bug883_test.lua @@ -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 diff --git a/tests/engine/evolution_dialogue_bug1596_test.lua b/tests/engine/evolution_dialogue_bug1596_test.lua new file mode 100644 index 00000000..7b20ca93 --- /dev/null +++ b/tests/engine/evolution_dialogue_bug1596_test.lua @@ -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() diff --git a/tests/engine/evolution_hold_b_bug968_test.lua b/tests/engine/evolution_hold_b_bug968_test.lua index 78174bce..fc4c5502 100644 --- a/tests/engine/evolution_hold_b_bug968_test.lua +++ b/tests/engine/evolution_hold_b_bug968_test.lua @@ -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() diff --git a/tests/engine/gen2_dex_mode_persist_bug1474.lua b/tests/engine/gen2_dex_mode_persist_bug1474.lua new file mode 100644 index 00000000..501fb90c --- /dev/null +++ b/tests/engine/gen2_dex_mode_persist_bug1474.lua @@ -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") diff --git a/tests/engine/gen2_fly_map_arrow_bug1477.lua b/tests/engine/gen2_fly_map_arrow_bug1477.lua new file mode 100644 index 00000000..ceae1666 --- /dev/null +++ b/tests/engine/gen2_fly_map_arrow_bug1477.lua @@ -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") diff --git a/tests/engine/gen2_kanto_no_roof_bug1479.lua b/tests/engine/gen2_kanto_no_roof_bug1479.lua new file mode 100644 index 00000000..eb37547f --- /dev/null +++ b/tests/engine/gen2_kanto_no_roof_bug1479.lua @@ -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") diff --git a/tests/engine/gen2_win_loss_text_bug1512.lua b/tests/engine/gen2_win_loss_text_bug1512.lua new file mode 100644 index 00000000..e309623b --- /dev/null +++ b/tests/engine/gen2_win_loss_text_bug1512.lua @@ -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") diff --git a/tests/engine/gym_leader_end_battle_text_bug1606.lua b/tests/engine/gym_leader_end_battle_text_bug1606.lua new file mode 100644 index 00000000..511aaf95 --- /dev/null +++ b/tests/engine/gym_leader_end_battle_text_bug1606.lua @@ -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") diff --git a/tests/engine/intro_title_naming_bug1510_1511.lua b/tests/engine/intro_title_naming_bug1510_1511.lua new file mode 100644 index 00000000..135dfa65 --- /dev/null +++ b/tests/engine/intro_title_naming_bug1510_1511.lua @@ -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") diff --git a/tests/engine/pc_league_row_bug1566.lua b/tests/engine/pc_league_row_bug1566.lua new file mode 100644 index 00000000..20e4682e --- /dev/null +++ b/tests/engine/pc_league_row_bug1566.lua @@ -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, "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, "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, "LEAGUE"), + "the league row does not depend on EVENT_GOT_POKEDEX") diff --git a/tests/engine/rare_candy_bag_open_bug796.lua b/tests/engine/rare_candy_bag_open_bug796.lua index 6aee10cf..8a022dc6 100644 --- a/tests/engine/rare_candy_bag_open_bug796.lua +++ b/tests/engine/rare_candy_bag_open_bug796.lua @@ -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)") diff --git a/tests/engine/save_confirm_layout_bug1522.lua b/tests/engine/save_confirm_layout_bug1522.lua new file mode 100644 index 00000000..c04ead5b --- /dev/null +++ b/tests/engine/save_confirm_layout_bug1522.lua @@ -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") diff --git a/tests/engine/thrash_setup_anim_bug1532.lua b/tests/engine/thrash_setup_anim_bug1532.lua index 8e5ad7aa..45f6cb88 100644 --- a/tests/engine/thrash_setup_anim_bug1532.lua +++ b/tests/engine/thrash_setup_anim_bug1532.lua @@ -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)") diff --git a/tests/engine/traded_exp_otid_bug1488.lua b/tests/engine/traded_exp_otid_bug1488.lua new file mode 100644 index 00000000..7eaf283d --- /dev/null +++ b/tests/engine/traded_exp_otid_bug1488.lua @@ -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)") diff --git a/tests/gen2_battle_ui_test.lua b/tests/gen2_battle_ui_test.lua index 11486dc0..34189392 100644 --- a/tests/gen2_battle_ui_test.lua +++ b/tests/gen2_battle_ui_test.lua @@ -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() diff --git a/tests/gen2_time_routing_test.lua b/tests/gen2_time_routing_test.lua index 0b5dded9..ff79da4c 100644 --- a/tests/gen2_time_routing_test.lua +++ b/tests/gen2_time_routing_test.lua @@ -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 diff --git a/tests/gen2_vm_test.lua b/tests/gen2_vm_test.lua index 643e57cd..b5df02e2 100644 --- a/tests/gen2_vm_test.lua +++ b/tests/gen2_vm_test.lua @@ -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 = {} diff --git a/tests/gen2_world_test.lua b/tests/gen2_world_test.lua index 79649797..c89eec3e 100644 --- a/tests/gen2_world_test.lua +++ b/tests/gen2_world_test.lua @@ -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 diff --git a/tests/parity_cerulean_rocket.lua b/tests/parity_cerulean_rocket.lua index 9aaa076a..6866d28b 100644 --- a/tests/parity_cerulean_rocket.lua +++ b/tests/parity_cerulean_rocket.lua @@ -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, "; ")) diff --git a/tests/parity_rare_candy_menu.lua b/tests/parity_rare_candy_menu.lua index ed03d62b..e6c936d6 100644 --- a/tests/parity_rare_candy_menu.lua +++ b/tests/parity_rare_candy_menu.lua @@ -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") diff --git a/tests/run_tests.lua b/tests/run_tests.lua index 84900f8a..4ba9ccff 100644 --- a/tests/run_tests.lua +++ b/tests/run_tests.lua @@ -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()