mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-21 13:09:54 +02:00
CLOSES #1474, CLOSES #1475, CLOSES #1477, CLOSES #1482, CLOSES #1512, CLOSES #1516, CLOSES #1558, CLOSES #1569, CLOSES #1578, CLOSES #1579
This commit is contained in:
+18
-2
@@ -38,6 +38,22 @@ local function retryTmGive(game, ow, victoryKey, done)
|
||||
return true
|
||||
end
|
||||
|
||||
-- The leader's badge line, armed for the battle screen the way
|
||||
-- scripts/PewterGym.asm:117 / CeruleanGym.asm:111 SaveEndBattleTextPointers do
|
||||
local function badgeEndBattleText(game, victoryKey)
|
||||
local reward = victoryKey and require("data.scripts.victories")[victoryKey]
|
||||
if not (reward and reward.dialogue) then return nil end
|
||||
local text = game.data.text or {}
|
||||
local pages = {}
|
||||
for _, label in ipairs(reward.dialogue) do
|
||||
if text[label] and text[label] ~= "" then
|
||||
pages[#pages + 1] = text[label]
|
||||
end
|
||||
end
|
||||
if #pages == 0 then return nil end
|
||||
return table.concat(pages, "\f")
|
||||
end
|
||||
|
||||
-- scripts/PewterGym.asm PewterGymBrockText (text_asm): CheckEvent
|
||||
-- EVENT_BEAT_BROCK branches his dialogue. Before the badge he prints
|
||||
-- _PewterGymBrockPreBattleText and engages the leader battle
|
||||
@@ -58,7 +74,7 @@ M.PEWTER_GYM.talk = {
|
||||
game.data.text._PewterGymBrockPostBattleAdviceText
|
||||
or "Go to the GYM in\nCERULEAN and test\nyour abilities!", done))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
ow:engageTrainer(npc, done, badgeEndBattleText(game, "OPP_BROCK#1"))
|
||||
end
|
||||
end,
|
||||
}
|
||||
@@ -91,7 +107,7 @@ local function leaderTalk(beatFlag, adviceLabel, fallback, afterAdvice, victoryK
|
||||
game.stack:push(TextBox.new(game,
|
||||
game.data.text[adviceLabel] or fallback, finish))
|
||||
else
|
||||
ow:engageTrainer(npc, done)
|
||||
ow:engageTrainer(npc, done, badgeEndBattleText(game, victoryKey))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+17
-15
@@ -647,27 +647,29 @@ end
|
||||
local rocketRows = {
|
||||
{ "face_player" }, -- 1
|
||||
{ "check_flag", "EVENT_GOT_TM28" }, -- 2
|
||||
{ "jump_if_true", 15 }, -- 3 → CeruleanHideRocket
|
||||
{ "jump_if_true", 16 }, -- 3 → CeruleanHideRocket
|
||||
{ "check_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 4
|
||||
{ "jump_if_true", 9 }, -- 5
|
||||
{ "jump_if_true", 10 }, -- 5
|
||||
{ "show_text", "_CeruleanCityRocketText" }, -- 6
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 7
|
||||
{ "jump_if_false", "end" }, -- 8
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 9
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 10
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 11 (row 13 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 12
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 14
|
||||
{ "fade", "out" }, -- 15 GBFadeOutToBlack
|
||||
-- scripts/CeruleanCity.asm:295 SaveEndBattleTextPointers
|
||||
{ "save_end_battle_text", "_CeruleanCityRocketIGiveUpText" }, -- 7
|
||||
{ "start_battle", "trainer", "OPP_ROCKET", 5 }, -- 8
|
||||
{ "jump_if_false", "end" }, -- 9
|
||||
{ "show_text", "_CeruleanCityRocketIllReturnTheTMText" }, -- 10
|
||||
{ "set_flag", "EVENT_BEAT_CERULEAN_ROCKET_THIEF" }, -- 11
|
||||
{ "give_item", "TM_DIG", 1, false }, -- 12 (row 14 prints)
|
||||
{ "set_flag", "EVENT_GOT_TM28" }, -- 13
|
||||
{ "show_text", "_CeruleanCityRocketReceivedTM28Text" }, -- 14
|
||||
{ "show_text", "_CeruleanCityRocketIBetterGetMovingText" }, -- 15
|
||||
{ "fade", "out" }, -- 16 GBFadeOutToBlack
|
||||
-- CeruleanHideRocket while black: GUARD1 (28,12) appears, GUARD2
|
||||
-- (27,12) and the ROCKET go. GUARD2 blocks the trashed-house south
|
||||
-- door neighbour -- the swap reconnects the city (Bill's ticket does
|
||||
-- the same in story.lua; either route is enough).
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 16
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 18
|
||||
{ "fade", "in" }, -- 19 GBFadeInFromBlack
|
||||
{ "show_object", "CERULEAN_CITY", "CERULEANCITY_GUARD1" }, -- 17
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_GUARD2" }, -- 18
|
||||
{ "hide_object", "CERULEAN_CITY", "CERULEANCITY_ROCKET" }, -- 19
|
||||
{ "fade", "in" }, -- 20 GBFadeInFromBlack
|
||||
}
|
||||
|
||||
M.CERULEAN_CITY = {
|
||||
|
||||
+83
-19
@@ -1364,7 +1364,7 @@ function BattleState:updateQueue()
|
||||
-- subanimation (or just the coarse fx when animations are off).
|
||||
-- item.hit carries the target's blink + damage sound, applied when
|
||||
-- the animation ends (hitRow rows carry a hit with no animation --
|
||||
-- thrash/rage continuation turns that skip the announcement).
|
||||
-- Mimic, whose animation waits on a successful copy).
|
||||
if item.anim or item.hitRow then
|
||||
-- PlayMoveAnimation writes wAnimationID, calls Delay3, and only then
|
||||
-- jumps to MoveAnimation (core.asm:6635-6640), so three frames pass
|
||||
@@ -2708,9 +2708,11 @@ function BattleState:resolveSwitch(newMon)
|
||||
self.afterQueue = "menu"
|
||||
self:act(function()
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon prints over the
|
||||
-- outgoing pic and holds 50 frames before the mon is recalled
|
||||
-- outgoing pic and holds 50 frames, then AnimateRetreatingPlayerMon
|
||||
-- runs before the mon is recalled
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:queueRetreatAnim()
|
||||
self:actNext(function()
|
||||
self:restoreMimicked(self.player) -- the battle copy leaves with it
|
||||
local previous = self.player
|
||||
@@ -3322,6 +3324,23 @@ function BattleState:queueSendOutAnim(append)
|
||||
if append then self:act(fn) else self:actNext(fn) end
|
||||
end
|
||||
|
||||
-- AnimateRetreatingPlayerMon (core.asm:1769-1796); the Yellow starter Pikachu
|
||||
-- slides off instead (pokeyellow core.asm:1862-1866, animations.asm:1259)
|
||||
function BattleState:queueRetreatAnim()
|
||||
if self:starterPikachuSendOut() then
|
||||
self:actNext(function() self:slidePic("playerMon", 0, -64, 8, 3) end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 24 })
|
||||
self:actNext(function() self:slidePic("playerMon") end)
|
||||
else
|
||||
self:actNext(function()
|
||||
self.shrinkOut = { battler = self.player, frame = 0 }
|
||||
end)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 7 })
|
||||
end
|
||||
end
|
||||
|
||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||
@@ -3538,6 +3557,12 @@ function BattleState:updateFx()
|
||||
self.growIn.frame = self.growIn.frame + 1
|
||||
if self.growIn.frame >= 12 then self.growIn = nil end
|
||||
end
|
||||
-- the retreat shrink (AnimateRetreatingPlayerMon): 4+3 frames, then the
|
||||
-- 7x7 area is cleared for good
|
||||
if self.shrinkOut then
|
||||
self.shrinkOut.frame = self.shrinkOut.frame + 1
|
||||
if self.shrinkOut.frame >= 7 then self.shrinkOut = nil end
|
||||
end
|
||||
-- low-HP alarm (audio/low_health_alarm.asm): the two-tone siren
|
||||
-- loops while the player's bar is red; see lowHealthAlarmActive
|
||||
local Sound = require("src.core.Sound")
|
||||
@@ -3802,6 +3827,8 @@ function BattleState:statusInterrupt(user, target, selectedId)
|
||||
{ rng = self.rng, forceCrit = false, typeless = true,
|
||||
screens = target })
|
||||
self:sayNext(self:romText("_HurtItselfText", "It hurt itself in\nits confusion!"))
|
||||
-- HandleSelfConfusionDamage (core.asm:3706-3714, enemy side :5807-5811)
|
||||
self:animNext("POUND", not user.isPlayer)
|
||||
self:clearVolatiles(user, true)
|
||||
self:applyDamage(user, dmg)
|
||||
if user.mon.hp <= 0 then self:onFaint(user) end
|
||||
@@ -3894,25 +3921,36 @@ function BattleState:performMove(user, target, moveInst, isCalled)
|
||||
end
|
||||
|
||||
self.moveAnimRow = nil
|
||||
if not (user.thrashTurns and moveInst == user.thrashMove and user.thrashAnnounced) then
|
||||
local thrashing = user.thrashTurns and moveInst == user.thrashMove
|
||||
and user.thrashAnnounced or false
|
||||
if thrashing then
|
||||
-- .ThrashingAboutCheck (core.asm:3531-3552)
|
||||
self:sayNextAuto(self:romText("_ThrashingAboutText", "%s's\nthrashing about!",
|
||||
displayName(user)))
|
||||
user.thrashTurns = user.thrashTurns - 1
|
||||
if user.thrashTurns <= 0 then
|
||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||
if not user.confusedTurns then user.confusedTurns = self.rng(2, 5) end
|
||||
end
|
||||
else
|
||||
self:sayNextAuto(self:romText("_ItemUseText001", "%s\nused %s!", displayName(user), move.name))
|
||||
-- the move's animation plays right after the announcement; the
|
||||
-- damage path attaches the target's hit blink to this row so the
|
||||
-- blink follows the animation (pokered's order). Mimic is the
|
||||
-- exception (announceAnim = false): PlayCurrentMoveAnimation runs
|
||||
-- only after a successful copy, never on a miss -- applyMimic queues it
|
||||
end
|
||||
-- PlayCurrentMoveAnimation follows the announcement; Mimic (announceAnim
|
||||
-- = false) queues it from applyMimic after a successful copy
|
||||
if not (record and record.announceAnim == false) then
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
self.moveAnimRow = { anim = move.id, attackerIsPlayer = user.isPlayer }
|
||||
table.insert(self.queue, self.nextInsert, self.moveAnimRow)
|
||||
end
|
||||
end
|
||||
Runtime.emit("battle.move_used", {
|
||||
battle = self, user = user, target = target, move = move,
|
||||
isCalled = isCalled or false,
|
||||
})
|
||||
|
||||
local ctx = EffectRegistry.makeCtx(self, user, target, move, moveInst, isCalled)
|
||||
-- .ThrashingAboutCheck jumps past JumpMoveEffect into PlayerCalcMoveDamage
|
||||
-- (core.asm:3540), so SpecialEffectsCont never re-runs on a locked turn
|
||||
ctx.thrashing = thrashing
|
||||
|
||||
-- Metronome / Mirror Move re-entry; a nil pick means the record
|
||||
-- already said its failure text
|
||||
@@ -4185,8 +4223,9 @@ function BattleState:awardExp()
|
||||
end
|
||||
local function applyShare(mon, split, announce)
|
||||
local playerId = self.game.save.player and self.game.save.player.id
|
||||
local traded = mon.traded == true
|
||||
or (mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId)
|
||||
-- GainExperience (engine/battle/experience.asm:69-88) compares the
|
||||
-- stored MON_OTID against wPlayerID every award; no persistent flag
|
||||
local traded = mon.otId ~= nil and playerId ~= nil and mon.otId ~= playerId
|
||||
local levels, gained = Experience.apply(self.data, mon, self.enemy.def,
|
||||
self.enemy.mon.level, self.kind == "trainer",
|
||||
split, traded)
|
||||
@@ -4338,21 +4377,35 @@ function BattleState:enemyMonFainted()
|
||||
-- the battle queue's own \f handling (not TextBox.lua's) does not
|
||||
-- page a sayChoice string the same way -- left as two calls.
|
||||
self:say(Strings("%s is\nabout to use\v%s!", self.trainer.name, nextName))
|
||||
-- EnemySendOutFirstMon .next9/.next8 (core.asm:1390-1409) and
|
||||
-- HasMonFainted's NoWillText (core.asm:1473-1488)
|
||||
self:sayChoice(
|
||||
Strings("Will %s\nchange POKéMON?", self.game.save.player.name),
|
||||
function(yes)
|
||||
if not yes then return end
|
||||
local game = self.game
|
||||
Screens.push(game, "PartyMenu", {
|
||||
local shiftOpts, reopenShift
|
||||
reopenShift = function(text)
|
||||
table.insert(self.queue, 1, { ui = function()
|
||||
return self:buildScreen("PartyMenu", shiftOpts)
|
||||
end })
|
||||
table.insert(self.queue, 1, { text = text })
|
||||
end
|
||||
shiftOpts = {
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
forceSwitch = true,
|
||||
onSwitch = function(mon)
|
||||
if mon ~= self.player.mon and mon.hp > 0 then
|
||||
if mon == self.player.mon then
|
||||
reopenShift(Strings("%s is\nalready out!", self.player.name))
|
||||
elseif mon.hp <= 0 then
|
||||
reopenShift(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||
else
|
||||
shiftSwitchMon = mon
|
||||
end
|
||||
end,
|
||||
})
|
||||
}
|
||||
Screens.push(game, "PartyMenu", shiftOpts)
|
||||
end, { box = Theme.trainerSwitchBox })
|
||||
end
|
||||
self:act(function()
|
||||
@@ -4395,10 +4448,11 @@ function BattleState:enemyMonFainted()
|
||||
local mon = shiftSwitchMon
|
||||
if not mon then return end
|
||||
-- SwitchPlayerMon (core.asm:2419-2423): RetreatMon, the 50-frame
|
||||
-- hold, then the recall and the send-out
|
||||
-- hold, AnimateRetreatingPlayerMon, then the recall and the send-out
|
||||
self.nextInsert = 0
|
||||
self:sayNextAuto(self:withdrawText(self.player.name),
|
||||
Timing.SWITCH_PLAYER_MON)
|
||||
self:queueRetreatAnim()
|
||||
self:actNext(function()
|
||||
local previous = self.player
|
||||
self.player = makeBattler(self.data, mon, true, self.game.save)
|
||||
@@ -5088,10 +5142,13 @@ function BattleState:openParty()
|
||||
battle = self,
|
||||
party = self:playerPartyView(),
|
||||
onSwitch = function(mon)
|
||||
-- PartyMenuOrRockOrRun's SWITCH .partyMonDeselected (core.asm:2396-2408)
|
||||
if mon == self.player.mon then
|
||||
self:say(Strings("%s is\nalready out!", self.player.name))
|
||||
self:act(function() self:openParty() end)
|
||||
elseif mon.hp <= 0 then
|
||||
self:say(self:romText("_NoWillText", "There's no will\nto fight!"))
|
||||
self:act(function() self:openParty() end)
|
||||
else
|
||||
self:resolveSwitch(mon)
|
||||
end
|
||||
@@ -5235,6 +5292,14 @@ function BattleState:growInScale(battler)
|
||||
return f < 3 and 0 or f < 7 and 3 / 7 or 5 / 7
|
||||
end
|
||||
|
||||
-- AnimateRetreatingPlayerMon's CopyDownscaledMonTiles stages
|
||||
-- (core.asm:1769-1796)
|
||||
function BattleState:shrinkOutScale(battler)
|
||||
local shrink = self.shrinkOut
|
||||
if not shrink or shrink.battler ~= battler then return nil end
|
||||
return shrink.frame < 4 and 5 / 7 or 3 / 7
|
||||
end
|
||||
|
||||
-- battler hidden this frame? (damage blink)
|
||||
--
|
||||
-- AnimationBlinkMon hides the pic, waits DelayFrames 5, shows it, waits
|
||||
@@ -5871,11 +5936,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
local s = BattleState.resolveBattleScale(self.data, "back",
|
||||
imagePathOf(self.player.sprite),
|
||||
self.player.mon and self.player.mon.species)
|
||||
local gs = self:growInScale(self.player)
|
||||
local gs = self:growInScale(self.player) or self:shrinkOutScale(self.player)
|
||||
if gs then
|
||||
-- the player-side AnimateSendingOutMon grow (after the poof,
|
||||
-- core.asm:1757-1762): feet pinned at y=96, horizontal centre
|
||||
-- pinned, mod scale composed with the grow stage
|
||||
-- the player-side AnimateSendingOutMon grow (core.asm:1757-1762) and
|
||||
-- the AnimateRetreatingPlayerMon shrink (core.asm:1769-1796)
|
||||
local eff = s * gs
|
||||
if eff > 0 then
|
||||
love.graphics.draw(img,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -582,30 +582,15 @@ MoveEffects.full = {
|
||||
},
|
||||
THRASH_PETAL_DANCE_EFFECT = {
|
||||
-- ThrashPetalDanceEffect (effects.asm:791-808) runs before damage
|
||||
-- (data/battle/special_effects.asm:22) and animates the setup turn
|
||||
-- (data/battle/special_effects.asm:22, core.asm:3531-3552)
|
||||
beforeAccuracy = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||
end
|
||||
end,
|
||||
afterDamage = function(ctx)
|
||||
local user = ctx.user
|
||||
if not user.thrashTurns then
|
||||
if ctx.thrashing or user.thrashTurns then return end
|
||||
user.thrashTurns = ctx.rng(2, 3) -- 3-4 attacks total, then confusion
|
||||
user.thrashMove = ctx.moveInst
|
||||
user.thrashAnnounced = true
|
||||
else
|
||||
user.thrashTurns = user.thrashTurns - 1
|
||||
if user.thrashTurns <= 0 then
|
||||
user.thrashTurns, user.thrashMove, user.thrashAnnounced = nil, nil, nil
|
||||
if not user.confusedTurns then
|
||||
user.confusedTurns = ctx.rng(2, 5)
|
||||
ctx.say(romText(ctx.battle.data, "_BecameConfusedText", "%s\nbecame confused!", displayName(user)))
|
||||
end
|
||||
end
|
||||
end
|
||||
ctx.battle:animBeforeMove(
|
||||
user.isPlayer and "SHRINKING_SQUARE_ANIM" or "ANIM_B1", user.isPlayer)
|
||||
end,
|
||||
},
|
||||
JUMP_KICK_EFFECT = {
|
||||
|
||||
+35
-13
@@ -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", {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -19,6 +19,9 @@ local romText = require("src.core.RomText")
|
||||
|
||||
local Evolution = {}
|
||||
|
||||
-- engine/pokemon/evos_moves.asm:122-123 (ld c, 50 / call DelayFrames)
|
||||
local EVOLVING_TEXT_FRAMES = 50
|
||||
|
||||
Evolution.METHODS = {
|
||||
LEVEL = {
|
||||
check = function(game, mon, evo, trigger)
|
||||
@@ -157,27 +160,32 @@ end
|
||||
-- Play the evolution movie (flashing forms), then apply + text.
|
||||
-- Headless (no real graphics) falls back to the plain text flow.
|
||||
function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
-- IsEvolvingText, DelayFrames 50, then ClearScreenArea before EvolveMon
|
||||
-- (engine/pokemon/evos_moves.asm:120-134)
|
||||
local isEvolving = romText(game.data, "_IsEvolvingText",
|
||||
"What?\n%s is\nevolving!", oldName)
|
||||
if love.image and love.image.newImageData then
|
||||
game.stack:push(TextBox.new(game, isEvolving, function()
|
||||
-- forward `via` so EvolutionState can keep trade evolutions
|
||||
-- non-cancelable (LINK_STATE_TRADING) while others accept B (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, onDone, via)
|
||||
end, { auto = { delay = EVOLVING_TEXT_FRAMES } }))
|
||||
return
|
||||
end
|
||||
Music.play(game.data, Music.special(game.data, "evolution"))
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
Evolution.apply(game, mon, newSpecies, via)
|
||||
-- the congrats page keeps the engine wording: _EvolvedText extracts
|
||||
-- truncated (it stops at a dynamic marker the decoder does not follow)
|
||||
local msg = romText(game.data, "_IsEvolvingText",
|
||||
"What?\n%s is\nevolving!", oldName)
|
||||
.. "\f" .. Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
oldName, game.data.pokemon[newSpecies].name)
|
||||
-- EvolvedText then IntoText in the same box (evos_moves.asm:136-150)
|
||||
local msg = isEvolving .. "\f"
|
||||
.. romText(game.data, "_EvolvedText", "%s evolved", oldName)
|
||||
.. romText(game.data, "_IntoText", "\ninto %s!",
|
||||
game.data.pokemon[newSpecies].name)
|
||||
game.stack:push(TextBox.new(game, msg, function()
|
||||
Music.restoreMap(game.data)
|
||||
-- re-run the evolved species' level-up learn check before onDone
|
||||
-- (evos_moves.asm EvolveMon -> learn_move.asm LearnMoveFromLevelUp, #12)
|
||||
Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
end))
|
||||
end, TextBox.soundOpts(game, "Get_Item2")))
|
||||
end
|
||||
|
||||
-- Entry point for mods whose methods fire outside the vanilla moments
|
||||
|
||||
@@ -543,10 +543,14 @@ local function runCmd(self, cmd, op)
|
||||
local species = cmd.species or arg1(cmd)
|
||||
local level = cmd.level or (cmd.args and cmd.args[2]) or 5
|
||||
local item = cmd.item or (cmd.args and cmd.args[3]) or 0
|
||||
if self.givePokeFn then
|
||||
local mon = self.givePokeFn(species, level, item)
|
||||
-- engine/pokemon/move_mon.asm:1753-1757
|
||||
local trainer = cmd.trainer or (cmd.args and cmd.args[4]) or 0
|
||||
if self.givePokeFn then
|
||||
-- engine/pokemon/move_mon.asm:1695-1736: the trainer arm copies the
|
||||
-- script's own nickname and OT name in instead of asking for one.
|
||||
local named = trainer ~= 0
|
||||
and { nickname = cmd.name, otName = cmd.otName } or nil
|
||||
local mon = self.givePokeFn(species, level, item, named)
|
||||
-- engine/pokemon/move_mon.asm:1753-1757
|
||||
if mon and trainer == 0 then
|
||||
Specials.askNickname(self, mon)
|
||||
end
|
||||
|
||||
+13
-6
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
-- The evolution movie (engine/movie/evolution.asm): the mon's pic
|
||||
-- flashes back and forth with the evolved form, speeding up, then the
|
||||
-- new form appears with its cry and the congratulations text.
|
||||
-- new form appears with its cry and the "evolved into" text
|
||||
-- (engine/pokemon/evos_moves.asm:120-128).
|
||||
-- pokered engine/movie/evolution.asm (Evolution_CheckForCancel) polls the
|
||||
-- joypad during the flash: a fresh B press aborts the evolution -- the mon
|
||||
-- keeps its species and _StoppedEvolvingText ("Huh? MON stopped evolving!")
|
||||
@@ -9,9 +10,7 @@
|
||||
-- and stone evolutions, where the B press is read but thrown away because
|
||||
-- ItemUseEvoStone left wForceEvolution set (#290).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Music = require("src.core.Music")
|
||||
local Strings = require("src.core.Strings")
|
||||
local romText = require("src.core.RomText")
|
||||
|
||||
local EvolutionState = {}
|
||||
@@ -127,11 +126,11 @@ function EvolutionState:update(dt)
|
||||
require("src.core.Sound").playCry(game.data, self.newSpecies)
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
-- _EvolvedText extracts truncated (it stops at a dynamic marker the
|
||||
-- decoder does not follow), so the engine's wording stands here
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("Congratulations!\nYour %s\nevolved into\n%s!",
|
||||
self.oldName, newName),
|
||||
-- EvolvedText then IntoText in the same box (PrintText_NoCreatingTextBox),
|
||||
-- then SFX_GET_ITEM_2 (engine/pokemon/evos_moves.asm:136-153)
|
||||
local msg = romText(game.data, "_EvolvedText", "%s evolved", self.oldName)
|
||||
.. romText(game.data, "_IntoText", "\ninto %s!", newName)
|
||||
game.stack:push(TextBox.new(game, msg,
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop() -- the evolution screen itself
|
||||
@@ -141,7 +140,8 @@ function EvolutionState:update(dt)
|
||||
-- first so the "learned MOVE!" text / forget prompt push onto the
|
||||
-- overworld / battle-return, not this state.
|
||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||
end))
|
||||
end,
|
||||
TextBox.soundOpts(game, "Get_Item2")))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -172,13 +172,6 @@ function EvolutionState:draw()
|
||||
require("src.render.PaletteFX").markTrueColor(x, y, sprite:getDimensions())
|
||||
end
|
||||
end
|
||||
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if not self.done then
|
||||
Font.draw(Strings("What?"), 8, 104)
|
||||
Font.draw(self.oldName .. " is", 8, 114)
|
||||
Font.draw(Strings("evolving!"), 8, 124)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+55
-1
@@ -17,6 +17,14 @@ function ListMenu:sgbPalettes(game)
|
||||
end
|
||||
|
||||
local ROWS = 7
|
||||
-- LIST_MENU_BOX 4,2 - 19,12 (data/text_boxes.asm:13); 4 names from
|
||||
-- hlcoord 6,4 two rows apart (home/list_menu.asm:51-52, 364-365, 471-479)
|
||||
local ITEM_BOX = { tx = 4, ty = 2, tw = 16, th = 11 }
|
||||
local ITEM_ROWS = 4
|
||||
local ITEM_NAME_X, ITEM_TOP_Y = 48, 32
|
||||
local ITEM_CURSOR_X = 40
|
||||
local ITEM_QTY_X, ITEM_QTY_END = 112, 136
|
||||
local ITEM_MORE_X, ITEM_MORE_Y = 144, 88
|
||||
-- frames to wait before key-repeat kicks in, then between repeats
|
||||
local REPEAT_DELAY = 16
|
||||
local REPEAT_RATE = 4
|
||||
@@ -83,7 +91,12 @@ function ListMenu.new(game, title, items, opts)
|
||||
-- for their whole run (engine/menus/pc.asm, engine/menus/players_pc.asm),
|
||||
-- so their lists opt out of the A/B beep the same way Menu's noSound does
|
||||
self.noSound = opts.noSound or false
|
||||
self.rows = opts.rows or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
-- the bag's item list: a partial box the map stays visible around, not a
|
||||
-- screen of its own (home/list_menu.asm:29-31)
|
||||
self.itemBox = opts.itemBox or false
|
||||
if self.itemBox then self.isOpaque = false end
|
||||
self.rows = opts.rows or (self.itemBox and ITEM_ROWS)
|
||||
or ((opts.dialogue or opts.messageBox) and 4 or ROWS)
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -206,7 +219,48 @@ function ListMenu:close()
|
||||
if top == self then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
-- PrintListMenuEntries, minus the price column StartMenu_Item never asks for
|
||||
-- (wPrintItemPrices = 0, engine/menus/start_sub_menus.asm)
|
||||
function ListMenu:drawItemBox()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
Font.drawBox(ITEM_BOX.tx, ITEM_BOX.ty, ITEM_BOX.tw, ITEM_BOX.th)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if #self.items == 0 then
|
||||
Font.draw(Strings("Nothing here."), ITEM_NAME_X, ITEM_TOP_Y)
|
||||
end
|
||||
local shown = 0
|
||||
for row = 1, self.rows do
|
||||
local i = self.scroll + row
|
||||
local item = self.items[i]
|
||||
if not item then break end
|
||||
shown = shown + 1
|
||||
local y = ITEM_TOP_Y + (row - 1) * 16
|
||||
Font.draw(item.label, ITEM_NAME_X, y)
|
||||
if item.right then
|
||||
-- '×' at column 14, PrintNumber's two right-aligned digits after it
|
||||
-- (home/list_menu.asm:479-490)
|
||||
local count = item.right:sub(2)
|
||||
Font.draw(item.right:sub(1, 1), ITEM_QTY_X, y + 8)
|
||||
Font.draw(count, ITEM_QTY_END - Font.width(count), y + 8)
|
||||
end
|
||||
if i == self.index then
|
||||
Font.drawCode(self.hollowIndex == i
|
||||
and Theme.cursorHollow or Theme.cursor, ITEM_CURSOR_X, y)
|
||||
end
|
||||
if self.swapIndex == i and i ~= self.index then
|
||||
Font.drawCode(Theme.cursorHollow, ITEM_CURSOR_X, y)
|
||||
end
|
||||
end
|
||||
-- the terminator prints CANCEL and returns before the '▼'
|
||||
-- (home/list_menu.asm:372, 518-524)
|
||||
if shown == self.rows then
|
||||
Font.drawCode(Theme.moreArrow, ITEM_MORE_X, ITEM_MORE_Y)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function ListMenu:draw()
|
||||
if self.itemBox then return self:drawItemBox() end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
|
||||
+15
-5
@@ -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
|
||||
|
||||
@@ -67,6 +67,7 @@ function NamingScreen.new(game, opts)
|
||||
self.game = game
|
||||
self.title = opts.title or Strings("YOUR NAME?")
|
||||
self.presets = opts.presets
|
||||
self.introBox = opts.introBox
|
||||
self.maxLen = opts.maxLen or 7
|
||||
self.default = opts.default
|
||||
self.onDone = opts.onDone
|
||||
@@ -99,10 +100,19 @@ function NamingScreen:enter()
|
||||
end,
|
||||
})
|
||||
end
|
||||
if self.introBox then
|
||||
-- DisplayIntroNameTextBox (oak_speech2.asm:162): TextBoxBorder at
|
||||
-- hlcoord 0,0 with b=$a c=$9, "NAME" at hlcoord 3,0, list at hlcoord 2,2
|
||||
self.game.stack:push(Menu.new(self.game, items, {
|
||||
tx = 0, ty = 0, tw = 11, th = #items * 2 + 4,
|
||||
itemY = 2, title = Strings("NAME"), cancelable = false,
|
||||
}))
|
||||
else
|
||||
self.game.stack:push(Menu.new(self.game, items, {
|
||||
tx = 4, ty = 0, tw = 12, th = #items * 2 + 2, cancelable = false,
|
||||
}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function NamingScreen:confirm()
|
||||
|
||||
+55
-2
@@ -35,6 +35,23 @@ OakSpeech.letterboxWhite = true
|
||||
local FADE_FRAMES = 24
|
||||
local WIPE_FRAMES = 32
|
||||
|
||||
-- OakSpeechSlidePicRight / OakSpeechSlidePicLeft (oak_speech2.asm:67-89)
|
||||
local SLIDE_TILES = 6
|
||||
local SLIDE_FRAMES = 3
|
||||
|
||||
local PicSlide = {}
|
||||
PicSlide.__index = PicSlide
|
||||
|
||||
function PicSlide:update(dt)
|
||||
self.t = self.t + 1
|
||||
local tiles = math.min(SLIDE_TILES, math.floor(self.t / SLIDE_FRAMES))
|
||||
self.speech.picSlide = (self.dir > 0 and tiles or (SLIDE_TILES - tiles)) * 8
|
||||
if tiles >= SLIDE_TILES then
|
||||
self.game.stack:pop()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
end
|
||||
|
||||
-- naming presets are boot config (field.boot.namePresets), which a total
|
||||
-- conversion replaces; the Red/Blue lists remain the fallback
|
||||
local function namePresets(game, who, fallback)
|
||||
@@ -155,6 +172,10 @@ function OakSpeech.defaultSteps(speech)
|
||||
kind = "say",
|
||||
textKey = "_IntroducePlayerText",
|
||||
pic = "player",
|
||||
-- oak_speech.asm:89-92: MovePicLeft, then IntroducePlayerText ends in
|
||||
-- text_end, so PrintText returns with the box still up under the names
|
||||
reveal = "wipe",
|
||||
stay = true,
|
||||
},
|
||||
{
|
||||
id = "name_player",
|
||||
@@ -177,6 +198,10 @@ function OakSpeech.defaultSteps(speech)
|
||||
kind = "say",
|
||||
textKey = "_IntroduceRivalText",
|
||||
pic = "rival",
|
||||
-- oak_speech.asm:98-101: FadeInIntroPic, then IntroduceRivalText's
|
||||
-- text_end leaves the box up for ChooseRivalName
|
||||
reveal = "fade",
|
||||
stay = true,
|
||||
},
|
||||
{
|
||||
id = "name_rival",
|
||||
@@ -378,7 +403,14 @@ function OakSpeech:runStep(step)
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
self:runCry(step)
|
||||
if step.stay then
|
||||
local box = TextBox.new(self.game, self:stepText(step), nil,
|
||||
{ stay = { onShown = function() self:advance() end } })
|
||||
self.holdBox = box
|
||||
self.game.stack:push(box)
|
||||
else
|
||||
self:sayText(self:stepText(step), function() self:advance() end)
|
||||
end
|
||||
end)
|
||||
elseif kind == "demo" then
|
||||
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
|
||||
@@ -394,9 +426,11 @@ function OakSpeech:runStep(step)
|
||||
local presets = step.presets
|
||||
or namePresets(self.game, step.presetsWho or who,
|
||||
step.presetsFallback or { "RED" })
|
||||
local function openNaming()
|
||||
require("src.ui.Screens").push(self.game, "NamingScreen", {
|
||||
title = step.title or (who == "rival" and "HIS NAME?" or Strings("YOUR NAME?")),
|
||||
presets = presets,
|
||||
introBox = true,
|
||||
maxLen = step.maxLen or self.nameLen,
|
||||
onDone = function(name)
|
||||
if who == "rival" then
|
||||
@@ -405,9 +439,13 @@ function OakSpeech:runStep(step)
|
||||
self.game.save.player.name = name
|
||||
end
|
||||
self:recordAnswer(step, 1, name, name)
|
||||
self:advance()
|
||||
-- YourNameIsText/HisNameIsText print into the box this one held
|
||||
self:closeHoldBox()
|
||||
self:slidePic(-1, function() self:advance() end)
|
||||
end,
|
||||
})
|
||||
end
|
||||
self:slidePic(1, openNaming)
|
||||
elseif kind == "choice" then
|
||||
self:applyPic(step)
|
||||
self:afterReveal(step, function()
|
||||
@@ -518,6 +556,21 @@ function OakSpeech:revealPic(kind, next)
|
||||
}
|
||||
end
|
||||
|
||||
-- ..(engine/movie/oak_speech/oak_speech2.asm ln 67)
|
||||
function OakSpeech:slidePic(dir, onDone)
|
||||
self.picSlide = (dir > 0 and 0 or SLIDE_TILES * 8)
|
||||
self.game.stack:push(setmetatable({
|
||||
game = self.game, speech = self, dir = dir, t = 0, onDone = onDone,
|
||||
}, PicSlide))
|
||||
end
|
||||
|
||||
-- IntroducePlayerText's text_end box (oak_speech.asm:90) is ours to close
|
||||
function OakSpeech:closeHoldBox()
|
||||
local box = self.holdBox
|
||||
self.holdBox = nil
|
||||
if box and self.game.stack:top() == box then self.game.stack:pop() end
|
||||
end
|
||||
|
||||
function OakSpeech:advance()
|
||||
self.step = self.step + 1
|
||||
-- picFlip belongs to the pic, not to the step: OakSpeechText2 prints 2A
|
||||
@@ -615,7 +668,7 @@ function OakSpeech:draw()
|
||||
-- it like the sprite buffer does ((8 - w) >> 1) tiles across,
|
||||
-- bottom-aligned
|
||||
local w, h = self.pic:getDimensions()
|
||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8
|
||||
local x = 48 + math.floor((8 - w / 8) / 2) * 8 + (self.picSlide or 0)
|
||||
local y = 32 + (7 - h / 8) * 8
|
||||
local reveal = self.picReveal
|
||||
local off = 0
|
||||
|
||||
+29
-6
@@ -59,14 +59,37 @@ function StartMenu.new(game)
|
||||
for _ in pairs(game.save.pokedex and game.save.pokedex.owned or {}) do
|
||||
owned = owned + 1
|
||||
end
|
||||
-- PrintSaveScreenText draws its own border at hlcoord 4,0 (b=8, c=$e) and
|
||||
-- leaves it up under the prompt -- engine/menus/main_menu.asm:381-405
|
||||
local panel = {
|
||||
update = function() end,
|
||||
draw = function()
|
||||
local t = math.floor(game.save.playTime or 0)
|
||||
local panel = Strings("PLAYER %s\nBADGES %d\nPOKéDEX %3d\nTIME %6d:%02d",
|
||||
game.save.player.name or "RED", badges, owned,
|
||||
math.floor(t / 3600), math.floor(t / 60) % 60)
|
||||
Font.drawBox(4, 0, 16, 10)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("PLAYER"), 5 * 8, 2 * 8)
|
||||
Font.draw(game.save.player.name or "RED", 12 * 8, 2 * 8)
|
||||
Font.draw(Strings("BADGES"), 5 * 8, 4 * 8)
|
||||
Font.draw(("%2d"):format(badges), 17 * 8, 4 * 8)
|
||||
Font.draw(Strings("POKéDEX"), 5 * 8, 6 * 8)
|
||||
Font.draw(("%3d"):format(owned), 16 * 8, 6 * 8)
|
||||
Font.draw(Strings("TIME"), 5 * 8, 8 * 8)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
|
||||
math.floor(t / 60) % 60), 13 * 8, 8 * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end,
|
||||
}
|
||||
local function closePanel()
|
||||
if game.stack:top() == panel then game.stack:pop() end
|
||||
end
|
||||
game.stack:push(panel)
|
||||
game.stack:push(TextBox.new(game,
|
||||
panel .. Strings("\fWould you like to\nSAVE the game?"), nil, {
|
||||
Strings("Would you like to\nSAVE the game?"), nil, {
|
||||
-- SaveTheGame_YesOrNo pins its TWO_OPTION_MENU at hlcoord 0, 7 rather
|
||||
-- than the shared right-hand one -- engine/menus/save.asm:186-192
|
||||
choiceBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||
choice = function(yes)
|
||||
if not yes then return end
|
||||
if not yes then closePanel() return end
|
||||
-- SaveMenu .save (engine/menus/save.asm:164-181): "Now saving..."
|
||||
-- is a bare PlaceString held by DelayFrames 120, then GameSavedText,
|
||||
-- which ends in `done` and so never reaches TX_PROMPT_BUTTON.
|
||||
@@ -78,7 +101,7 @@ function StartMenu.new(game)
|
||||
game:writeSave()
|
||||
game.stack:push(TextBox.new(game,
|
||||
Strings("%s saved\nthe game!", game.save.player.name or "RED"),
|
||||
nil, { auto = {
|
||||
closePanel, { auto = {
|
||||
sound = function()
|
||||
return require("src.core.Sound").play(game.data, "Save")
|
||||
end,
|
||||
|
||||
+28
-5
@@ -511,7 +511,10 @@ function TitleState:openMenu()
|
||||
table.insert(items, { label = Strings("NEW GAME"), onSelect = function()
|
||||
if self.onNewGame then self.onNewGame() end
|
||||
end })
|
||||
table.insert(items, { label = Strings("OPTION"), onSelect = function()
|
||||
-- DisplayOptionMenu returns to .mainMenuLoop, which redraws the box
|
||||
-- (engine/menus/main_menu.asm ln 87-90)
|
||||
table.insert(items, { label = Strings("OPTION"), keepOpen = true,
|
||||
onSelect = function()
|
||||
require("src.ui.Screens").push(game, "OptionsMenu")
|
||||
end })
|
||||
table.insert(items, { label = Strings("EXIT GAME"), onSelect = function()
|
||||
@@ -530,6 +533,12 @@ function TitleState:openMenu()
|
||||
end
|
||||
local th = #items * 2 + 2
|
||||
local menu = Menu.new(game, items, { tx = 0, ty = 0, tw = 13, th = th })
|
||||
-- .mainMenuLoop's B branch jumps back to DisplayTitleScreen, which opens
|
||||
-- with GBPalWhiteOut (engine/menus/main_menu.asm:69, title.asm:29)
|
||||
menu.onCancel = function()
|
||||
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
|
||||
function() self.menuOpen = false end))
|
||||
end
|
||||
-- full-width title LOGO zones would recolor this box; see sgbPalettes.
|
||||
-- Menu.new may have grown tw for longer (e.g. localized) labels, so the
|
||||
-- recolor zone follows the box's real width instead of the vanilla 13.
|
||||
@@ -537,6 +546,17 @@ function TitleState:openMenu()
|
||||
game.stack:push(menu)
|
||||
end
|
||||
|
||||
-- .finishedWaiting: GBPalWhiteOutWithDelay3 then ClearScreen before MainMenu,
|
||||
-- which clears again itself (engine/movie/title.asm ln 243, main_menu.asm ln 26)
|
||||
function TitleState:toMenu()
|
||||
local game = self.game
|
||||
game.stack:push(require("src.render.Transition").whiteFlash(game, nil,
|
||||
function()
|
||||
self.menuOpen = true
|
||||
self:openMenu()
|
||||
end))
|
||||
end
|
||||
|
||||
-- ..(engine/movie/title.asm ln 271)
|
||||
function TitleState:pickNewMon()
|
||||
if #self.cycleSpecies < 2 then return end
|
||||
@@ -602,7 +622,7 @@ function TitleState:update(dt)
|
||||
if not Sound.playPikaCry(self.game.data, 11) then
|
||||
Sound.playCry(self.game.data, "PIKACHU")
|
||||
end
|
||||
self:openMenu()
|
||||
self:toMenu()
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -616,20 +636,23 @@ function TitleState:update(dt)
|
||||
require("src.core.Sound").playCry(self.game.data,
|
||||
self.yellowLayout and "PIKACHU"
|
||||
or self.cycleSpecies[self.cycleIndex])
|
||||
self:openMenu()
|
||||
self:toMenu()
|
||||
end
|
||||
end
|
||||
|
||||
-- ..(engine/movie/title.asm ln 28)
|
||||
function TitleState:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
-- MainMenu's own ClearScreen wipes the logo, mon and sprites before the
|
||||
-- CONTINUE / NEW GAME border is drawn (engine/menus/main_menu.asm ln 26)
|
||||
if self.menuOpen then return end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local playerImage = self.player
|
||||
if playerImage and PaletteFX.usesSpriteObp() then
|
||||
playerImage = require("src.render.SpriteRenderer").obpImage(
|
||||
self.playerPath, PaletteFX.ogObj())
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
local scrollY = -(self.scy or 0)
|
||||
-- ..(engine/movie/title.asm ln 28)
|
||||
local preRibbon = not self.yellowLayout
|
||||
|
||||
+107
-13
@@ -38,6 +38,9 @@ local Sound = require("src.core.Sound")
|
||||
-- Only for playerPic: the player.sprite raiser both generations share.
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local Strings = require("src.core.Strings")
|
||||
-- Only for TextBox.substitute: the {PLAYER} / {RIVAL} markers a map text
|
||||
-- carries into the battle box (PrintWinLossText, home/trainers.asm:230).
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Unown = require("src.core.gen2.Unown")
|
||||
|
||||
local BattleState = {}
|
||||
@@ -87,6 +90,20 @@ local TRAINER_SLIDE_STEPS = 8
|
||||
local TRAINER_SLIDE_FRAMES_PER_STEP = 2
|
||||
local TRAINER_SLIDE_FRAMES = TRAINER_SLIDE_STEPS * TRAINER_SLIDE_FRAMES_PER_STEP
|
||||
|
||||
-- BattleWinSlideInEnemyTrainerFrontpic (engine/battle/core.asm:6279-6318) and
|
||||
-- WinTrainerBattle's DelayFrames 40 (:2311)
|
||||
local WIN_SLIDE_STEPS = 6
|
||||
local WIN_SLIDE_FRAMES_PER_STEP = 4
|
||||
local WIN_SLIDE_FRAMES = WIN_SLIDE_STEPS * WIN_SLIDE_FRAMES_PER_STEP
|
||||
local WIN_SLIDE_REST_TILES = 2
|
||||
local WIN_SLIDE_DELAY_FRAMES = 40
|
||||
|
||||
local function winSlideTiles(frames)
|
||||
local step = math.min(WIN_SLIDE_STEPS,
|
||||
math.floor(frames / WIN_SLIDE_FRAMES_PER_STEP) + 1)
|
||||
return WIN_SLIDE_STEPS + WIN_SLIDE_REST_TILES - step
|
||||
end
|
||||
|
||||
-- MonFaintedAnimation (engine/battle/core.asm), which PlayerMonFaintedAnimation
|
||||
-- and EnemyMonFaintedAnimation both fall into with the fainted side's pic
|
||||
-- corner: the pic's tilemap rows are copied DOWN one row per step and the row
|
||||
@@ -141,6 +158,10 @@ local MENU_COL_SPACING = 6
|
||||
-- the count PrintNum writes after it (two digits, leading zeros) at (13,16).
|
||||
local CONTEST_MENU_BOX_X = 2
|
||||
local CONTEST_MENU_COL_SPACING = 12
|
||||
|
||||
-- PrintMoveType prints the type table's own names; only these two differ from
|
||||
-- the constant (data/types/names.asm).
|
||||
local TYPE_NAMES = { PSYCHIC_TYPE = "PSYCHIC", CURSE_TYPE = "???" }
|
||||
-- charmap.asm's quantity glyph, spelled the way MartMenu spells it.
|
||||
local CONTEST_BALL_LABEL = "PARKBALL\xc3\x97"
|
||||
|
||||
@@ -508,6 +529,14 @@ function BattleState:pushAll(events)
|
||||
for _, event in ipairs(events or {}) do self:push(event) end
|
||||
end
|
||||
|
||||
-- LearnMove returns before HandleEnemyMonFaint's send-out/prize arms
|
||||
-- (engine/battle/core.asm:1959-2010).
|
||||
function BattleState:pushFront(events)
|
||||
for i = #(events or {}), 1, -1 do
|
||||
table.insert(self.queue, 1, events[i])
|
||||
end
|
||||
end
|
||||
|
||||
function BattleState:pic(mon, back)
|
||||
local def = self.pokemon and mon and self.pokemon[mon.species]
|
||||
local path = def and (back and def.spriteBack or def.spriteFront)
|
||||
@@ -686,6 +715,8 @@ function BattleState:drawPic(mon, back)
|
||||
-- One tile per two frames to the right, SlideBattlePicOut's own step.
|
||||
if enemyTrainer and self.trainerSlide then
|
||||
px = px + math.floor(self.trainerSlide / TRAINER_SLIDE_FRAMES_PER_STEP) * 8
|
||||
elseif enemyTrainer and self.winSlide then
|
||||
px = px + winSlideTiles(self.winSlide) * 8
|
||||
end
|
||||
-- The pic's own scale (battle_sprite_scales, then the species record, then
|
||||
-- 1x) composed with whatever square BattleBGEffect_RunPicResizeScript has
|
||||
@@ -1371,7 +1402,7 @@ function BattleState:advanceQueue()
|
||||
and self.shownHp[event.side] ~= event.hp then
|
||||
self.hpAnim = { side = event.side, to = event.hp }
|
||||
elseif event.kind == "send" and event.side and event.mon and self.shownHp then
|
||||
self.shownHp[event.side] = event.mon.hp or 0
|
||||
self.shownHp[event.side] = event.hp or event.mon.hp or 0
|
||||
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
|
||||
end
|
||||
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
|
||||
@@ -1379,8 +1410,9 @@ function BattleState:advanceQueue()
|
||||
if self.shownStatus and event.side
|
||||
and (event.kind == "status"
|
||||
or (event.kind == "send" and event.mon)) then
|
||||
self.shownStatus[event.side] =
|
||||
(event.kind == "send" and event.mon.status or event.status) or false
|
||||
local shown = event.status
|
||||
if event.kind == "send" and shown == nil then shown = event.mon.status end
|
||||
self.shownStatus[event.side] = shown or false
|
||||
end
|
||||
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
|
||||
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
|
||||
@@ -1435,6 +1467,24 @@ function BattleState:advanceQueue()
|
||||
self.trainerSlide = 0
|
||||
return
|
||||
end
|
||||
-- BattleWinSlideInEnemyTrainerFrontpic and the DelayFrames 40 behind it
|
||||
-- (engine/battle/core.asm:2310-2312)
|
||||
if event.kind == "trainer-return" then
|
||||
if not self.enemyTrainerImage then return self:advanceQueue() end
|
||||
self.showEnemyTrainer = true
|
||||
self.picHidden.enemy = false
|
||||
self.winSlide = 0
|
||||
self.winSliding = true
|
||||
return
|
||||
end
|
||||
-- PrintWinLossText (home/trainers.asm:230): one FarPrintText of the trainer
|
||||
-- struct's own line, paged and held for A/B like any other map text.
|
||||
if event.kind == "win-text" then
|
||||
local text = event.text
|
||||
if self.game then text = TextBox.substitute(self.game, text) end
|
||||
self:showPages(text)
|
||||
return
|
||||
end
|
||||
-- The shiny sparkle: hBattleTurn 1 and wBattleAnimParam 1 pick
|
||||
-- BattleAnim_SendOutMon's `.Shiny` arm on the enemy (core.asm:8708-8715).
|
||||
if event.kind == "shiny-flash" then
|
||||
@@ -1917,6 +1967,17 @@ function BattleState:update(_dt)
|
||||
return
|
||||
end
|
||||
|
||||
-- BattleWinSlideInEnemyTrainerFrontpic plus WinTrainerBattle's DelayFrames
|
||||
-- 40 (engine/battle/core.asm:6279-6318, :2310-2312)
|
||||
if self.winSliding then
|
||||
self.winSlide = self.winSlide + 1
|
||||
if self.winSlide >= WIN_SLIDE_FRAMES + WIN_SLIDE_DELAY_FRAMES then
|
||||
self.winSliding = nil
|
||||
self:advanceQueue()
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
-- SlideBattlePicOut is a plain loop with DelayFrames in it, so it owns the
|
||||
-- screen the same way (engine/battle/core.asm:2882).
|
||||
if self.trainerSlide then
|
||||
@@ -2004,6 +2065,8 @@ function BattleState:update(_dt)
|
||||
self.phase = "stats-box"
|
||||
return
|
||||
end
|
||||
-- PrintWinLossText's line pages like any map text (home/text.asm:403-448)
|
||||
if self:nextPage() then return end
|
||||
self:advanceQueue()
|
||||
return
|
||||
end
|
||||
@@ -2292,7 +2355,7 @@ function BattleState:update(_dt)
|
||||
learn.move, learn.moveName)
|
||||
self.pendingLearn = nil
|
||||
self.phase = "resolving"
|
||||
self:pushAll(self.battle:takeEvents())
|
||||
self:pushFront(self.battle:takeEvents())
|
||||
self:advanceQueue()
|
||||
end
|
||||
return
|
||||
@@ -2872,7 +2935,7 @@ function BattleState:finishDecline()
|
||||
self.pendingLearn = nil
|
||||
self.phase = "resolving"
|
||||
if learn then self.battle:declineForget(learn.index, learn.moveName) end
|
||||
self:pushAll(self.battle:takeEvents())
|
||||
self:pushFront(self.battle:takeEvents())
|
||||
self:advanceQueue()
|
||||
end
|
||||
|
||||
@@ -3475,6 +3538,23 @@ function BattleState:printMessage()
|
||||
end
|
||||
end
|
||||
|
||||
-- MoveInfoBox (engine/battle/core.asm:5403-5478): "TYPE/" at (1,9), the type
|
||||
-- at (2,10), cur/max PP at (5,11), or "Disabled!" at (1,10).
|
||||
function BattleState:drawMoveInfoBox(move)
|
||||
if not move then return end
|
||||
local fighter = self.battle and self.battle.player
|
||||
if fighter and self.battle:moveDisabled(fighter, move.id) then
|
||||
Chrome.print("Disabled!", 1, 10)
|
||||
return
|
||||
end
|
||||
local def = self.game and self.game.data and self.game.data.moves
|
||||
and self.game.data.moves[move.id]
|
||||
Chrome.print("TYPE/", 1, 9)
|
||||
local moveType = def and def.type
|
||||
Chrome.print(moveType and (TYPE_NAMES[moveType] or moveType) or "", 2, 10)
|
||||
Chrome.print(("%2d/%2d"):format(move.pp or 0, move.maxPp or 0), 5, 11)
|
||||
end
|
||||
|
||||
function BattleState:drawPanel()
|
||||
Chrome.clear()
|
||||
-- A tutorial battle legitimately has no player mon, so only the enemy is
|
||||
@@ -3494,7 +3574,15 @@ function BattleState:drawPanel()
|
||||
-- Message box across the bottom, with the menu window over its right half --
|
||||
-- the cart draws the prompt into the full-width box and then opens the menu
|
||||
-- on top, so the tail of a long name is simply covered.
|
||||
-- MoveSelectionScreen type 0 is two boxes: the name-only list
|
||||
-- (engine/battle/core.asm:5074-5084) and MoveInfoBox's (:5407-5410).
|
||||
local moveMenu = self.phase == "moves"
|
||||
if moveMenu then
|
||||
Chrome.box(0, 8, 11, 5)
|
||||
Chrome.box(4, 12, 16, 6)
|
||||
else
|
||||
Chrome.box(0, 12, 20, 6)
|
||||
end
|
||||
if self.phase == "menu" then
|
||||
self:printMessage()
|
||||
local boxX = self.contest and CONTEST_MENU_BOX_X or MENU_BOX_X
|
||||
@@ -3520,24 +3608,30 @@ function BattleState:drawPanel()
|
||||
moves = (mon and mon.moves) or moves
|
||||
end
|
||||
local cursorRow = forgetting and self.forgetIndex or self.moveIndex
|
||||
-- w2DMenuCursorInitX 5 with the names at hlcoord 6 (core.asm:5086-5107).
|
||||
local cursorCol = moveMenu and 5 or 1
|
||||
local nameCol = moveMenu and 6 or 2
|
||||
for i, move in ipairs(moves) do
|
||||
local ty = 13 + (i - 1)
|
||||
-- Cursor in the box's own gutter, not clipped against the border.
|
||||
if i == cursorRow then Chrome.cursor(1, ty) end
|
||||
if i == cursorRow then Chrome.cursor(cursorCol, ty) end
|
||||
-- The held slot's marker. `.battle_player_moves` writes '▷' into the
|
||||
-- row wSwappingMove names (engine/battle/core.asm:5157-5165) so a move
|
||||
-- picked up for a swap is visible while the cursor moves off it. It
|
||||
-- sits a column right of the cursor gutter, where the cart puts it
|
||||
-- (hlcoord 5, 13 against the cursor's own column), and only while the
|
||||
-- move list itself is up -- the forget picker has no swapping.
|
||||
if not forgetting and self.moveSwapIndex == i then
|
||||
Chrome.print("\u{25B7}", 0, ty)
|
||||
-- hlcoord 5, 13 is the cursor's own gutter, so PlaceMenuCursor covers
|
||||
-- the marker on the cursor's row.
|
||||
if not forgetting and self.moveSwapIndex == i and i ~= cursorRow then
|
||||
Chrome.print("\u{25B7}", cursorCol, ty)
|
||||
end
|
||||
local def = self.game and self.game.data and self.game.data.moves
|
||||
and self.game.data.moves[move.id]
|
||||
Chrome.print((def and def.name) or move.id, 2, ty)
|
||||
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0), 19, ty)
|
||||
Chrome.print((def and def.name) or move.id, nameCol, ty)
|
||||
if not moveMenu then
|
||||
Chrome.printRight(("%d/%d"):format(move.pp or 0, move.maxPp or 0),
|
||||
19, ty)
|
||||
end
|
||||
end
|
||||
if moveMenu then self:drawMoveInfoBox(moves[cursorRow]) end
|
||||
else
|
||||
-- Battle messages wrap inside the box rather than running off the frame.
|
||||
self:printMessage()
|
||||
|
||||
@@ -337,10 +337,15 @@ function PackMenu:useSelected()
|
||||
return
|
||||
end
|
||||
local world = self.world
|
||||
local result = world and world.useFieldItem and world:useFieldItem(row.id)
|
||||
local result, extra = nil, nil
|
||||
if world and world.useFieldItem then result, extra = world:useFieldItem(row.id) end
|
||||
if result then
|
||||
if result == "nowhere" then
|
||||
self.message = OAK_THIS_ISNT_THE_TIME
|
||||
elseif result == "coin_case" then
|
||||
-- _CoinCaseCountText (data/text/common_3.asm:336): "Coins:" then the
|
||||
-- count, text_decimal 4 digits with PRINTNUM_LEFTALIGN_F so no padding.
|
||||
self.message = { "Coins:", tostring(extra or 0) }
|
||||
elseif result == "repel_used" then
|
||||
-- ItemUsedText (data/text/common_3.asm): "<PLAYER> used the\n<ITEM>."
|
||||
-- World already wrote the counter and took the item out of the bag, so
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -36,9 +36,8 @@
|
||||
-- $3f the shiny ⁂ icon (stats_tiles tile 14)
|
||||
-- $40 / $41 the left and right HP/exp bar end caps
|
||||
--
|
||||
-- The extractor does not carry that sheet yet, so `pageTile` draws those seven
|
||||
-- shapes directly and takes the sheet the moment menu_gfx grows a `stats`
|
||||
-- entry. Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed),
|
||||
-- The extractor writes that sheet as menu_gfx.stats, which `pageTile` draws.
|
||||
-- Everything that IS a glyph goes through the font: ◀ ($71), ▶ ($ed),
|
||||
-- № ($74), <ID> ($73), <LV> ($6e) and the row-7 rule's $62 (the empty HP/exp
|
||||
-- bar cell, which is FontBattleExtra's -- hence Font.useBattleExtra(true)
|
||||
-- around the whole screen, exactly as the party menu does).
|
||||
@@ -95,6 +94,14 @@ local TILE_BAR_CAP_RIGHT = 0x41
|
||||
-- made of (StatsScreen_PlaceHorizontalDivider).
|
||||
local TILE_HORIZONTAL_DIVIDER = 0x62
|
||||
|
||||
-- gfx/stats/pages.pal, the three palettes _CGB_StatsScreenHPPals copies to
|
||||
-- wBGPals1 slots 3-5 (engine/gfx/cgb_layouts.asm:199-212)
|
||||
local PAGE_PALETTES = {
|
||||
{ { 255, 255, 255 }, { 255, 156, 255 }, { 255, 123, 255 }, { 0, 0, 0 } },
|
||||
{ { 255, 255, 255 }, { 173, 255, 115 }, { 140, 255, 0 }, { 0, 0, 0 } },
|
||||
{ { 255, 255, 255 }, { 140, 255, 255 }, { 140, 255, 255 }, { 0, 0, 0 } },
|
||||
}
|
||||
|
||||
-- PrintTempMonStats' .StatNames, and the wTempMon fields it prints beside
|
||||
-- them. <NEXT> steps two rows, so the five labels are 2 rows apart and the
|
||||
-- values start one row below the first label.
|
||||
@@ -821,12 +828,37 @@ end
|
||||
|
||||
-- ----------------------------------------------------------------- drawing
|
||||
|
||||
-- A tile out of StatsScreenPageTilesGFX. The extractor does not carry that
|
||||
-- sheet, so each of the seven shapes it needs is drawn here; the moment
|
||||
-- menu_gfx grows a `stats` entry this can take the real tiles instead.
|
||||
-- menu_gfx.stats, the 17 tiles LoadStatsScreenPageTilesGFX lands at vTiles2
|
||||
-- tile $31 (engine/gfx/load_font.asm:90-95)
|
||||
function SummaryMenu:statsTiles()
|
||||
if self.statsSheet ~= nil then return self.statsSheet or nil end
|
||||
local gfx = (self.menuGfx or {}).stats
|
||||
local image = gfx and self:picImage(gfx.sheet)
|
||||
if not image then
|
||||
self.statsSheet = false
|
||||
return nil
|
||||
end
|
||||
local w, h = image:getDimensions()
|
||||
local quads = {}
|
||||
for index = 0, (gfx.tiles or 17) - 1 do
|
||||
quads[(gfx.firstTile or 0x31) + index] =
|
||||
love.graphics.newQuad(index * 8, 0, 8, 8, w, h)
|
||||
end
|
||||
self.statsSheet = { image = image, quads = quads }
|
||||
return self.statsSheet
|
||||
end
|
||||
|
||||
-- A tile out of StatsScreenPageTilesGFX. The fallback arm draws each of the
|
||||
-- seven shapes by hand for a cache built before menu_gfx.stats existed.
|
||||
function SummaryMenu:pageTile(id, tx, ty)
|
||||
local G = love.graphics
|
||||
local px, py = tx * 8, ty * 8
|
||||
local sheet = self:statsTiles()
|
||||
if sheet and sheet.quads[id] then
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(sheet.image, sheet.quads[id], px, py)
|
||||
return
|
||||
end
|
||||
G.setColor(0, 0, 0, 1)
|
||||
if id == TILE_VERTICAL_DIVIDER then
|
||||
G.rectangle("fill", px + 3, py, 2, 8)
|
||||
@@ -847,11 +879,29 @@ end
|
||||
-- (17,5), all small ($36) first, then the one for this page redrawn large
|
||||
-- ($3a). The routine writes the four tiles as [hli]/[hld], a row down, then
|
||||
-- [hli]/[hl] -- which is why it is a 2x2 block and not a 2x1 strip.
|
||||
function SummaryMenu:drawPageSquare(tx, ty, large)
|
||||
function SummaryMenu:drawPageSquare(tx, ty, large, colors)
|
||||
local G = love.graphics
|
||||
local px, py = tx * 8, ty * 8
|
||||
-- $3a..$3d for the page that is up, $36..$39 for the other two.
|
||||
local first = large and TILE_SQUARE_LARGE or TILE_SQUARE_SMALL
|
||||
local sheet = self:statsTiles()
|
||||
if sheet and sheet.quads[first] then
|
||||
-- [hli] / [hld], a row down, [hli] / [hl]: the four tiles in that order.
|
||||
local function body()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.draw(sheet.image, sheet.quads[first], px, py)
|
||||
G.draw(sheet.image, sheet.quads[first + 1], px + 8, py)
|
||||
G.draw(sheet.image, sheet.quads[first + 2], px, py + 8)
|
||||
G.draw(sheet.image, sheet.quads[first + 3], px + 8, py + 8)
|
||||
end
|
||||
if colors and GbcPalette.available() then
|
||||
GbcPalette.with(colors, body)
|
||||
else
|
||||
body()
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
local inset = first == TILE_SQUARE_LARGE and 2 or 5
|
||||
local size = 16 - inset * 2
|
||||
G.setColor(0, 0, 0, 1)
|
||||
@@ -862,7 +912,7 @@ end
|
||||
function SummaryMenu:drawPageIndicators()
|
||||
local columns = { 13, 15, 17 }
|
||||
for i, tx in ipairs(columns) do
|
||||
self:drawPageSquare(tx, 5, i == self.page)
|
||||
self:drawPageSquare(tx, 5, i == self.page, PAGE_PALETTES[i])
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2969,6 +2969,25 @@ function OverworldState:openPC(onDone)
|
||||
})
|
||||
end
|
||||
|
||||
-- PKMN LEAGUE sits between PROF.OAK's PC and LOG OFF once wNumHoFTeams
|
||||
-- is nonzero (engine/pokemon/bills_pc.asm:5, :49)
|
||||
if #(Game.save.hallOfFame or {}) > 0 then
|
||||
table.insert(items, {
|
||||
label = Strings("<PK><MN>LEAGUE"),
|
||||
keepOpen = true,
|
||||
onSelect = function()
|
||||
-- pc.asm PKMNLeague plays SFX_ENTER_PC, then PKMNLeaguePC prints
|
||||
-- AccessedHoFPCText (engine/menus/pc.asm:67, league_pc.asm:2)
|
||||
require("src.core.Sound").play(Game.data, "Enter_PC")
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
romText(Game.data, "_AccessedHoFPCText",
|
||||
"Accessed POKéMON\nLEAGUE's site.\fAccessed the HALL\nOF FAME List."),
|
||||
function() Screens.push(Game, "LeaguePC") end))
|
||||
done()
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
local hooked = Runtime.call("ui.pc.items", sameItems, Game, items)
|
||||
if type(hooked) == "table" then
|
||||
items = hooked
|
||||
@@ -3366,7 +3385,8 @@ function OverworldState:engageTrainer(npc, onDone, endBattleText, skipBattleText
|
||||
-- checkVictoryRewards pushes the badge/prize box and starts the map's
|
||||
-- onVictory script UNDER whatever runs next, so the player still sees
|
||||
-- EndBattle (now inside the battle), then the reward, then AfterBattle
|
||||
self:checkVictoryRewards(d.trainerClass, d.trainerParty)
|
||||
self:checkVictoryRewards(d.trainerClass, d.trainerParty,
|
||||
endBattleText ~= nil)
|
||||
self:afterBattle(result, battle)
|
||||
if onDone then onDone() end
|
||||
else
|
||||
@@ -3477,7 +3497,10 @@ end
|
||||
-- SetEvent / SetEventRange do after the leader victory.
|
||||
-- `hide` is { { mapId, objName }, ... } -- HideObject on those toggles
|
||||
-- (e.g. Brock victory clears PEWTERCITY_YOUNGSTER / ROUTE22_RIVAL1).
|
||||
function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
-- `shownOnBattleScreen`: `dialogue` already rode the battle screen as the
|
||||
-- armed end-battle line (scripts/CeruleanGym.asm:113)
|
||||
function OverworldState:checkVictoryRewards(trainerClass, partyIndex,
|
||||
shownOnBattleScreen)
|
||||
local victories = require("data.scripts.victories")
|
||||
local reward = victories[trainerClass .. "#" .. tostring(partyIndex or 1)]
|
||||
if not reward then return self:runVictoryHook() end
|
||||
@@ -3510,7 +3533,9 @@ function OverworldState:checkVictoryRewards(trainerClass, partyIndex)
|
||||
end
|
||||
local chain = rewardChain()
|
||||
if reward.dialogue then
|
||||
if not shownOnBattleScreen then
|
||||
chain.add(reward.dialogue, reward.badgeSound)
|
||||
end
|
||||
if reward.item then
|
||||
chain.add(reward.tmPre)
|
||||
if tmGiven then
|
||||
@@ -4423,7 +4448,8 @@ function OverworldState:restoreBattleContinuation(battle, origin)
|
||||
if result == "win" then
|
||||
game.save.defeatedTrainers[origin.npcId] = true
|
||||
if origin.event then game.save.flags[origin.event] = true end
|
||||
self:checkVictoryRewards(battle.oppClass, battle.partyIndex)
|
||||
self:checkVictoryRewards(battle.oppClass, battle.partyIndex,
|
||||
battle.endBattleText ~= nil)
|
||||
end
|
||||
self:afterBattle(result, battle)
|
||||
self.engaging = false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,105 @@
|
||||
-- The bag's item list is LIST_MENU_BOX (#1521): home/list_menu.asm:29-31,
|
||||
-- :51-52, :364-365, :471-479, :518-521, data/text_boxes.asm:13
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
-- Font wants a real atlas; the geometry is what this suite is about, so it
|
||||
-- records the calls instead. ListMenu and Theme bind Font at require time.
|
||||
local realFont = package.loaded["src.render.Font"]
|
||||
local calls = {}
|
||||
package.loaded["src.render.Font"] = {
|
||||
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
|
||||
draw = function(text, x, y) calls[#calls + 1] = { "draw", text, x, y } end,
|
||||
drawCode = function(code, x, y) calls[#calls + 1] = { "code", code, x, y } end,
|
||||
drawBox = function(tx, ty, tw, th) calls[#calls + 1] = { "box", tx, ty, tw, th } end,
|
||||
width = function(text) return #tostring(text) * 8 end,
|
||||
}
|
||||
package.loaded["src.ui.ListMenu"] = nil
|
||||
package.loaded["src.ui.Theme"] = nil
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local function found(kind, pred)
|
||||
for _, c in ipairs(calls) do
|
||||
if c[1] == kind and pred(c) then return c end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function newList(count)
|
||||
local items = {}
|
||||
for i = 1, count do
|
||||
items[i] = { value = "ITEM_" .. i, label = "ITEM " .. i, right = "x" .. i }
|
||||
end
|
||||
return ListMenu.new({}, "ITEMS", items, { kind = "bag", itemBox = true })
|
||||
end
|
||||
|
||||
do
|
||||
local list = newList(6)
|
||||
eq(list.rows, 4, "PrintListMenuEntries prints 4 names, not 7")
|
||||
eq(list.isOpaque, false,
|
||||
"the box is partial, so the map keeps drawing behind it")
|
||||
|
||||
calls = {}
|
||||
list:draw()
|
||||
|
||||
local box = found("box", function(c) return true end)
|
||||
if check(box ~= nil, "the list draws LIST_MENU_BOX") then
|
||||
eq(box[2], 4, "upper-left X 4")
|
||||
eq(box[3], 2, "upper-left Y 2")
|
||||
eq(box[4], 16, "through lower-right X 19")
|
||||
eq(box[5], 11, "through lower-right Y 12")
|
||||
end
|
||||
|
||||
-- names at hlcoord 6, 4 and every two rows after it
|
||||
for row = 1, 4 do
|
||||
local y = 32 + (row - 1) * 16
|
||||
check(found("draw", function(c)
|
||||
return c[2] == "ITEM " .. row and c[3] == 48 and c[4] == y
|
||||
end) ~= nil, "name " .. row .. " sits at (48, " .. y .. ")")
|
||||
end
|
||||
check(found("draw", function(c) return c[2] == "ITEM 5" end) == nil,
|
||||
"the fifth name is scrolled out, not printed below the box")
|
||||
|
||||
-- the quantity: '×' at column 14, the count right-aligned after it
|
||||
check(found("draw", function(c)
|
||||
return c[2] == "x" and c[3] == 112 and c[4] == 40
|
||||
end) ~= nil, "the first quantity's '×' is a row down at column 14")
|
||||
check(found("draw", function(c)
|
||||
return c[2] == "1" and c[3] == 128 and c[4] == 40
|
||||
end) ~= nil, "with the count right-aligned in the two columns after it")
|
||||
|
||||
check(found("code", function(c)
|
||||
return c[2] == Theme.cursor and c[3] == 40 and c[4] == 32
|
||||
end) ~= nil, "the cursor is in column 5 (wTopMenuItemX)")
|
||||
check(found("code", function(c)
|
||||
return c[2] == Theme.moreArrow and c[3] == 144 and c[4] == 88
|
||||
end) ~= nil, "a full page ends with the '▼'")
|
||||
|
||||
-- nothing else: no title, no money footer (wPrintItemPrices = 0)
|
||||
check(found("draw", function(c) return tostring(c[2]):find("¥") end) == nil,
|
||||
"the everyday bag has no money box (that is the mart's screen)")
|
||||
check(found("draw", function(c) return c[2] == "ITEMS" end) == nil,
|
||||
"and no title row: the box carries no header text")
|
||||
end
|
||||
|
||||
-- a short list stops at its last name, and the terminator's CANCEL row is
|
||||
-- what would follow -- never the '▼'
|
||||
do
|
||||
local list = newList(2)
|
||||
calls = {}
|
||||
list:draw()
|
||||
check(found("code", function(c) return c[2] == Theme.moreArrow end) == nil,
|
||||
"a page that runs out of names prints no '▼' (:372)")
|
||||
end
|
||||
|
||||
package.loaded["src.render.Font"] = realFont
|
||||
package.loaded["src.ui.ListMenu"] = nil
|
||||
package.loaded["src.ui.Theme"] = nil
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,112 @@
|
||||
-- SwitchPlayerMon (core.asm:2419-2423), AnimateRetreatingPlayerMon (:1769-1796)
|
||||
-- (#1563); pokeyellow core.asm:1862-1866 (#1545); core.asm:1471-1488 (#1608)
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 40), Pokemon.new(Data, "FIXMON_A", 40) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||
battle.rng = function(a) if a then return a end return 0 end
|
||||
return battle
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the shrink stages and their frame budget
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:queueRetreatAnim()
|
||||
T.eq(#battle.queue, 2, "the retreat queues its start act plus the hold")
|
||||
T.eq(battle.queue[2].wait, 7, "4 frames at 5x5 then Delay3 at 3x3")
|
||||
T.check(battle:shrinkOutScale(battle.player) == nil, "nothing shrinks yet")
|
||||
battle.queue[1].fn()
|
||||
T.eq(battle.shrinkOut.battler, battle.player, "the outgoing pic is the one drawn")
|
||||
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "wDownscaledMonSize 0 -> 5x5")
|
||||
battle.shrinkOut.frame = 3
|
||||
T.eq(battle:shrinkOutScale(battle.player), 5 / 7, "for four frames")
|
||||
battle.shrinkOut.frame = 4
|
||||
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "wDownscaledMonSize 1 -> 3x3")
|
||||
battle.shrinkOut.frame = 6
|
||||
T.eq(battle:shrinkOutScale(battle.player), 3 / 7, "through Delay3")
|
||||
T.check(battle:shrinkOutScale(battle.enemy) == nil,
|
||||
"AnimateRetreatingPlayerMon is player-side only")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the Yellow starter slides off instead of shrinking
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.starterPikachuSendOut = function() return true end
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle:queueRetreatAnim()
|
||||
T.eq(#battle.queue, 3, "start act, the slide's hold, then the slot clear")
|
||||
battle.queue[1].fn()
|
||||
local slide = battle.picOff and battle.picOff.playerMon
|
||||
T.check(slide ~= nil, "the back pic slot is sliding")
|
||||
T.eq(slide.to, -64, "8 tiles off the left edge")
|
||||
T.eq(slide.hold, 3, "wSlideMonDelay 3 V-blanks per tile")
|
||||
T.eq(battle.queue[2].wait, 24, "8 tiles x 3 frames")
|
||||
T.check(battle:shrinkOutScale(battle.player) == nil, "and no downscale stage")
|
||||
battle.queue[3].fn()
|
||||
T.check((battle.picOff or {}).playerMon == nil, "the slot clears afterwards")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- resolveSwitch runs the retreat between the withdraw text and the swap
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
local outgoing = battle.player.mon
|
||||
battle:resolveSwitch(battle.game.save.party[2])
|
||||
local withdraw = table.remove(battle.queue, 1)
|
||||
battle.nextInsert = 0
|
||||
withdraw.fn()
|
||||
T.check(battle.queue[1] and battle.queue[1].text ~= nil, "RetreatMon text first")
|
||||
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "then the retreat start")
|
||||
T.eq(battle.queue[3] and battle.queue[3].wait, 7, "then its hold")
|
||||
T.eq(battle.player.mon, outgoing, "the party slot has not been swapped yet")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- a fainted pick reopens the party list (#1608)
|
||||
-- ---------------------------------------------------------------------
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.buildScreen = function(_, _, opts) return opts end
|
||||
battle:openParty()
|
||||
local opts = battle.queue[1].ui()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.game.save.party[2].hp = 0
|
||||
opts.onSwitch(battle.game.save.party[2])
|
||||
T.check(battle.queue[1] and battle.queue[1].text
|
||||
and battle.queue[1].text:find("no will", 1, true) ~= nil,
|
||||
"HasMonFainted prints NoWillText")
|
||||
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "and queues the reprompt")
|
||||
battle.queue[2].fn()
|
||||
T.check(battle.queue[3] and battle.queue[3].ui ~= nil,
|
||||
"GoBackToPartyMenu puts the list back up")
|
||||
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
opts.onSwitch(battle.player.mon)
|
||||
T.check(battle.queue[1] and battle.queue[1].text
|
||||
and battle.queue[1].text:find("already out", 1, true) ~= nil,
|
||||
"AlreadyOutText for the mon that is already out")
|
||||
T.check(battle.queue[2] and battle.queue[2].fn ~= nil, "which also reprompts")
|
||||
end
|
||||
|
||||
T.finish("retreat animation and switch reprompt (#1563, #1545, #1608)")
|
||||
@@ -0,0 +1,64 @@
|
||||
-- HandleSelfConfusionDamage (engine/battle/core.asm:3672-3714, enemy side
|
||||
-- :5806-5811) (#1578)
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local TypeChart = require("src.battle.TypeChart")
|
||||
TypeChart.load(Data)
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 40) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 40)
|
||||
-- cp 50 percent + 1: rand < 128 hurts the user
|
||||
battle.rng = function() return 0 end
|
||||
return battle
|
||||
end
|
||||
|
||||
local function rowsOf(battle)
|
||||
local out = {}
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.anim then out[#out + 1] = item end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.player.confusedTurns = 3
|
||||
T.eq(battle:statusInterrupt(battle.player, battle.enemy, nil), true,
|
||||
"the self-hit interrupts the player's action")
|
||||
local anims = rowsOf(battle)
|
||||
T.eq(#anims, 2, "the IsConfusedText onomatopoeia, then the self-hit's own")
|
||||
T.eq(anims[1].anim, "CONF_PLAYER_ANIM", "CONF_PLAYER_ANIM rides IsConfusedText")
|
||||
T.eq(anims[2].anim, "POUND", "wAnimationID 1 is POUND")
|
||||
T.eq(anims[2].attackerIsPlayer, false,
|
||||
"hWhoseTurn is flipped to the opponent, so it plays against the player")
|
||||
T.check(anims[2].hit == nil, "wAnimationType 0 adds no shake or blink layer")
|
||||
local text, anim
|
||||
for i, item in ipairs(battle.queue) do
|
||||
if item.text and item.text:find("confusion", 1, true) then text = text or i end
|
||||
if item.anim == "POUND" then anim = i end
|
||||
end
|
||||
T.check(text and anim and text < anim, "HurtItselfText prints before the animation")
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.enemy.confusedTurns = 3
|
||||
battle:statusInterrupt(battle.enemy, battle.player, nil)
|
||||
local anims = rowsOf(battle)
|
||||
T.eq(anims[#anims].anim, "POUND", "the enemy side animates too")
|
||||
T.eq(anims[#anims].attackerIsPlayer, true, "from the player's side of hWhoseTurn")
|
||||
end
|
||||
|
||||
T.finish("confusion self-hit animation (#1578)")
|
||||
@@ -126,6 +126,17 @@ local function useStone(game)
|
||||
game.input.pressed = "a"
|
||||
picker:update(1 / 60)
|
||||
game.input.pressed = nil
|
||||
-- IsEvolvingText hands off to the movie when it closes
|
||||
-- (engine/pokemon/evos_moves.asm:120-134)
|
||||
local intro = game.stack:top()
|
||||
if not (intro and intro.textBox and intro.done) then
|
||||
return nil, "the \"is evolving!\" box never opened"
|
||||
end
|
||||
if not tostring(intro.text):find("evolving") then
|
||||
return nil, "the box before the movie is not _IsEvolvingText"
|
||||
end
|
||||
game.stack:pop()
|
||||
intro.done()
|
||||
return list
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- The evolution dialogue is the cart's, in the cart's order (#1596):
|
||||
-- engine/pokemon/evos_moves.asm:120-128, :136-150, :151-153
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local played = {}
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, id) played[#played + 1] = id end,
|
||||
playCry = function() end,
|
||||
}
|
||||
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local EvolutionState = require("src.ui.EvolutionState")
|
||||
local Input = require("src.core.Input")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local Data = Fixtures.fresh()
|
||||
require("src.render.Font").load(Data)
|
||||
|
||||
local EVO_LEVEL = 16
|
||||
|
||||
local function newGame()
|
||||
local game = { data = Data }
|
||||
local mon = Pokemon.new(Data, "FIXMON_A", EVO_LEVEL)
|
||||
game.save = {
|
||||
party = { mon },
|
||||
player = { name = "RED", id = 1 },
|
||||
options = { textSpeed = 5 },
|
||||
flags = {},
|
||||
pokedex = { seen = {}, owned = {} },
|
||||
}
|
||||
game.stack = setmetatable({}, { __index = StateStack })
|
||||
game.stack:init()
|
||||
game.input = Input
|
||||
Input:init()
|
||||
return game, mon
|
||||
end
|
||||
|
||||
local function step(game)
|
||||
game.input:step()
|
||||
game.stack:update(1 / 60)
|
||||
end
|
||||
|
||||
local function textOf(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- The box that goes up before the movie, and the frames it holds for.
|
||||
do
|
||||
local game, mon = newGame()
|
||||
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
|
||||
local intro = game.stack:top()
|
||||
if check(getmetatable(intro) == TextBox,
|
||||
"_IsEvolvingText goes up in a real bordered text box first") then
|
||||
check(textOf(intro):find("is evolving"),
|
||||
"and it is the cart's line: " .. textOf(intro))
|
||||
check(intro.auto ~= nil and not intro.auto.wait,
|
||||
"which waits for no button (IsEvolvingText ends in `done`)")
|
||||
eq(intro.auto.delay, 50,
|
||||
"and holds DelayFrames 50 before it clears (evos_moves.asm:122)")
|
||||
end
|
||||
-- it hands off to the movie on its own, with no input at all
|
||||
local top
|
||||
for _ = 1, 900 do
|
||||
top = game.stack:top()
|
||||
if getmetatable(top) == EvolutionState then break end
|
||||
step(game)
|
||||
end
|
||||
check(getmetatable(top) == EvolutionState,
|
||||
"the flash movie opens once that box has cleared itself")
|
||||
eq(mon.species, "FIXMON_A",
|
||||
"and nothing has evolved yet while the box was up")
|
||||
end
|
||||
|
||||
-- What closes the movie: EvolvedText + IntoText, and the jingle.
|
||||
do
|
||||
local game, mon = newGame()
|
||||
Evolution.evolve(game, mon, "FIXMON_B", nil, "LEVEL")
|
||||
local evo
|
||||
for _ = 1, 900 do
|
||||
evo = game.stack:top()
|
||||
if getmetatable(evo) == EvolutionState then break end
|
||||
step(game)
|
||||
end
|
||||
assert(getmetatable(evo) == EvolutionState, "the movie never opened")
|
||||
played = {}
|
||||
for _ = 1, 600 do
|
||||
if evo.done then break end
|
||||
step(game)
|
||||
end
|
||||
eq(mon.species, "FIXMON_B", "the mon evolved")
|
||||
local box = game.stack:top()
|
||||
if check(getmetatable(box) == TextBox, "and the result text is a text box") then
|
||||
local said = textOf(box)
|
||||
check(said:find("evolved") and said:find("into"),
|
||||
"_EvolvedText + _IntoText print together: " .. said)
|
||||
check(not said:find("Congratulations"),
|
||||
"no fabricated \"Congratulations!\" line (it is in no ROM)")
|
||||
check(box.auto ~= nil and box.auto.sound ~= nil,
|
||||
"and the box carries a jingle the way sound_get_item_1 boxes do")
|
||||
-- type it out; auto.sound fires once the last page has landed
|
||||
for _ = 1, 900 do
|
||||
if box.autoStarted then break end
|
||||
step(game)
|
||||
end
|
||||
local heard = false
|
||||
for _, id in ipairs(played) do
|
||||
if id == "Get_Item2" then heard = true end
|
||||
end
|
||||
check(heard, "SFX_GET_ITEM_2 plays on that box (evos_moves.asm:151)")
|
||||
end
|
||||
end
|
||||
|
||||
-- The flash itself carries no text: EvolutionState draws sprites only.
|
||||
do
|
||||
local source = assert(io.open("src/ui/EvolutionState.lua")):read("*a")
|
||||
check(not source:find("Font%.draw"),
|
||||
"EvolutionState draws no bare text over the flash "
|
||||
.. "(ClearScreenArea ran before EvolveMon, evos_moves.asm:128)")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -52,6 +52,14 @@ local function step(game)
|
||||
game.stack:update(1 / 60)
|
||||
end
|
||||
|
||||
local function textOf(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- the post-battle sequence: grew-to-level box, then Evolution.checkParty
|
||||
local function levelUpBox(game, mon)
|
||||
game.stack:push(TextBox.new(game, "FIXMON A grew\nto level 16!",
|
||||
@@ -69,21 +77,27 @@ local function dismissWithB(game, mon)
|
||||
if not box.done then return nil, "the level-up text never finished typing" end
|
||||
Input:keypressed(B_KEY)
|
||||
step(game)
|
||||
local top = game.stack:top()
|
||||
-- IsEvolvingText holds its own box for DelayFrames 50 before EvolveMon
|
||||
-- runs (engine/pokemon/evos_moves.asm:120-134)
|
||||
local intro = game.stack:top()
|
||||
if getmetatable(intro) ~= TextBox then
|
||||
return nil, "the \"is evolving!\" box never opened"
|
||||
end
|
||||
if not textOf(intro):find("is evolving") then
|
||||
return nil, "the box before the movie is not _IsEvolvingText"
|
||||
end
|
||||
local top
|
||||
for _ = 1, 900 do
|
||||
top = game.stack:top()
|
||||
if getmetatable(top) == EvolutionState then break end
|
||||
step(game)
|
||||
end
|
||||
if getmetatable(top) ~= EvolutionState then
|
||||
return nil, "the evolution screen never opened"
|
||||
end
|
||||
return top
|
||||
end
|
||||
|
||||
local function textOf(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- B held out of the text box: the movie must run to the end and evolve.
|
||||
do
|
||||
local game, mon = newGame()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- wLastDexMode (engine/pokedex/pokedex.asm:60, :97) (#1474)
|
||||
-- luajit tests/engine/gen2_dex_mode_persist_bug1474.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check, eq = T.check, T.eq
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local PokedexMenu = require("src.ui.gen2.PokedexMenu")
|
||||
|
||||
local save = { position = { map = "ROUTE_30" } }
|
||||
local game = { data = {}, save = save }
|
||||
|
||||
local first = PokedexMenu.new(game, {})
|
||||
eq(first.modeIndex, 1, "a save with no remembered mode opens in NEW")
|
||||
|
||||
-- what the OPTION screen's .ChangeMode leaves behind
|
||||
first.modeIndex = 3
|
||||
first:close()
|
||||
eq(save.lastDexMode, "A-Z", "closing the dex writes the live mode into the save")
|
||||
|
||||
local second = PokedexMenu.new(game, {})
|
||||
eq(second.modeIndex, 3, "reopening the dex restores the remembered mode")
|
||||
|
||||
second.modeIndex = 2
|
||||
second:close()
|
||||
eq(PokedexMenu.new(game, {}).modeIndex, 2, "OLD survives the same way")
|
||||
|
||||
local fresh = PokedexMenu.new({ data = {}, save = {} }, {})
|
||||
eq(fresh.modeIndex, 1, "a fresh save still starts on NEW")
|
||||
|
||||
local closed = false
|
||||
local menu = PokedexMenu.new(game, { onClose = function() closed = true end })
|
||||
menu:close()
|
||||
check(closed, "close still runs the caller's onClose")
|
||||
|
||||
T.finish("gen2 pokedex mode persistence bug 1474")
|
||||
@@ -0,0 +1,32 @@
|
||||
-- The Fly map draws no Pokegear mode arrow (engine/pokegear/pokegear.asm:1999) (#1477)
|
||||
-- luajit tests/engine/gen2_fly_map_arrow_bug1477.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
local check = T.check
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local Pokegear = require("src.ui.gen2.Pokegear")
|
||||
|
||||
local function drawnArrow(fly)
|
||||
local drew = false
|
||||
local self = setmetatable({
|
||||
fly = fly,
|
||||
styled = function() return true end,
|
||||
groundColor = function() return { 0, 0, 0 } end,
|
||||
card = function() return { id = "map", label = fly and "FLY" or "MAP" } end,
|
||||
drawMap = function() end,
|
||||
drawModeArrow = function() drew = true end,
|
||||
}, { __index = Pokegear })
|
||||
local realRect = love.graphics.rectangle
|
||||
love.graphics.rectangle = function() end
|
||||
self:drawPanel()
|
||||
love.graphics.rectangle = realRect
|
||||
return drew
|
||||
end
|
||||
|
||||
check(drawnArrow(false), "the Pokegear MAP card still animates the arrow")
|
||||
check(not drawnArrow(true), "_FlyMap draws no mode-indicator arrow")
|
||||
|
||||
T.finish("gen2 fly map arrow bug 1477")
|
||||
@@ -0,0 +1,37 @@
|
||||
-- #1479 / #1449: TILESET_KANTO takes no map-group roof (home/map.asm:1738-1749)
|
||||
-- luajit tests/engine/gen2_kanto_no_roof_bug1479.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local MapPreview = require("src.world.gen2.MapPreview")
|
||||
|
||||
local baker = {
|
||||
tilesets = {
|
||||
TILESET_KANTO = { image = "assets/generated/tilesets/kanto.png" },
|
||||
TILESET_JOHTO = { image = "assets/generated/tilesets/johto.png" },
|
||||
},
|
||||
roofs = {
|
||||
mapGroupRoofs = { [19] = "ROOF_SILVER" },
|
||||
roofs = { ROOF_SILVER = {} },
|
||||
},
|
||||
atlasCache = {},
|
||||
mapImages = {},
|
||||
}
|
||||
|
||||
-- data/maps/maps.asm:396: SilverCaveOutside and Route28 are both group 19
|
||||
-- (MapGroup_Silver) and both TILESET_KANTO.
|
||||
MapPreview.atlasFor(baker, { tileset = "TILESET_KANTO", group = 19 })
|
||||
MapPreview.atlasFor(baker, { tileset = "TILESET_JOHTO", group = 19 })
|
||||
|
||||
local keys = {}
|
||||
for key in pairs(baker.atlasCache) do keys[key] = true end
|
||||
|
||||
T.check(keys["TILESET_KANTO"],
|
||||
"the Kanto atlas is cached under the bare tileset, with no roof")
|
||||
T.check(not keys["TILESET_KANTO|ROOF_SILVER"],
|
||||
"and never under a map-group roof")
|
||||
T.check(keys["TILESET_JOHTO|ROOF_SILVER"],
|
||||
"while TILESET_JOHTO still takes its group's roof")
|
||||
|
||||
print("gen2 Kanto roof gate (#1479): ok")
|
||||
@@ -0,0 +1,205 @@
|
||||
-- engine/battle/core.asm:2310-2323 (WinTrainerBattle), :2763-2782 (LostBattle),
|
||||
-- home/trainers.asm:120 and :230 (PrintWinLossText)
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Battle = require("src.battle.gen2.Battle")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local World = require("src.world.gen2.World")
|
||||
|
||||
local LEVEL = 10
|
||||
|
||||
local MOVES = {
|
||||
TACKLE = { id = "TACKLE", name = "TACKLE", power = 35, type = "NORMAL",
|
||||
accuracy = 100, pp = 35, effect = "EFFECT_NORMAL_HIT" },
|
||||
}
|
||||
|
||||
local POKEMON = {
|
||||
growthRates = {
|
||||
GROWTH_MEDIUM_FAST = { numerator = 1, denominator = 1, squared = 0,
|
||||
linear = 0, constant = 0 },
|
||||
},
|
||||
RATTATA = {
|
||||
id = "RATTATA", index = 19, name = "RATTATA",
|
||||
baseStats = { hp = 30, attack = 56, defense = 35, speed = 72,
|
||||
specialAttack = 25, specialDefense = 35 },
|
||||
types = { "NORMAL", "NORMAL" }, catchRate = 255, baseExp = 51,
|
||||
growthRate = "GROWTH_MEDIUM_FAST", genderRatio = 127,
|
||||
levelMoves = { { level = 1, move = "TACKLE" } }, evolutions = {},
|
||||
},
|
||||
}
|
||||
|
||||
local DATA = {
|
||||
pokemon = POKEMON,
|
||||
moves = MOVES,
|
||||
type_chart = { types = { NORMAL = { id = "NORMAL", index = 0,
|
||||
category = "physical" } }, matchups = {} },
|
||||
items = {},
|
||||
}
|
||||
|
||||
local perfect = { attack = 15, defense = 15, speed = 15, special = 15 }
|
||||
perfect.hp = Mon.hpDV(perfect)
|
||||
|
||||
local function mon()
|
||||
local m = Mon.new(DATA, "RATTATA", LEVEL, { dvs = perfect })
|
||||
m.moves = { { id = "TACKLE", pp = 35, maxPp = 35 } }
|
||||
return m
|
||||
end
|
||||
|
||||
local function newSave()
|
||||
return { player = { name = "GOLD", money = 1000, id = 4242 },
|
||||
mom = { savedMoney = 0 }, party = {} }
|
||||
end
|
||||
|
||||
local function newBattle(trainer, battleType)
|
||||
local save = newSave()
|
||||
local battle = Battle.new({ data = DATA, party = { mon() },
|
||||
trainer = trainer, battleType = battleType, save = save,
|
||||
random = function(n) return 99 % math.max(1, n or 1) end })
|
||||
return battle, save
|
||||
end
|
||||
|
||||
local function trainerRecord(extra)
|
||||
local record = { name = "SAGE CHOW", baseMoney = 3, party = { mon() } }
|
||||
for key, value in pairs(extra or {}) do record[key] = value end
|
||||
return record
|
||||
end
|
||||
|
||||
local function kinds(events)
|
||||
local out = {}
|
||||
for i, event in ipairs(events or {}) do out[i] = event.kind end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
local function indexOf(events, kind)
|
||||
for i, event in ipairs(events or {}) do
|
||||
if event.kind == kind then return i, event end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The win arm: defeated line, frontpic slide-in, the trainer's own line, money.
|
||||
do
|
||||
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
|
||||
battle.enemy.hp = 0
|
||||
T.check(battle:resolveFaints(), "the last enemy mon ends the battle")
|
||||
local events = battle:takeEvents()
|
||||
local defeated = indexOf(events, "faint")
|
||||
local ret = indexOf(events, "trainer-return")
|
||||
local win, winEvent = indexOf(events, "win-text")
|
||||
local money = indexOf(events, "money")
|
||||
T.check(ret and win and money, "all three win rows are queued: " ..
|
||||
kinds(events))
|
||||
T.check(defeated < ret, "BattleText_EnemyWasDefeated comes first")
|
||||
T.check(ret < win, "the frontpic slides back in before the line")
|
||||
T.check(win < money, "and PrintWinLossText runs before the payout")
|
||||
T.eq(winEvent.text, "Th-Thank you!", "the struct's win text is printed")
|
||||
end
|
||||
|
||||
-- The pic comes back whether or not there is a line: the DEBUG_BATTLE_F skip
|
||||
-- sits in front of PrintWinLossText alone.
|
||||
do
|
||||
local battle = newBattle(trainerRecord())
|
||||
battle.enemy.hp = 0
|
||||
battle:resolveFaints()
|
||||
local events = battle:takeEvents()
|
||||
T.check(indexOf(events, "trainer-return"), "the slide-in is unconditional")
|
||||
T.eq(indexOf(events, "win-text"), nil, "with no line to print")
|
||||
end
|
||||
|
||||
-- A wild battle has no trainer and no line.
|
||||
do
|
||||
local save = newSave()
|
||||
local wild = mon()
|
||||
local battle = Battle.new({ data = DATA, party = { mon() }, wild = wild,
|
||||
save = save, random = function(n) return 99 % math.max(1, n or 1) end })
|
||||
battle.enemy.hp = 0
|
||||
battle:resolveFaints()
|
||||
local events = battle:takeEvents()
|
||||
T.eq(indexOf(events, "trainer-return"), nil, "no frontpic to slide back in")
|
||||
T.eq(indexOf(events, "win-text"), nil, "and nothing to print")
|
||||
end
|
||||
|
||||
-- The loss arm: only BATTLETYPE_CANLOSE reaches PrintWinLossText.
|
||||
do
|
||||
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!",
|
||||
lossText = "...Too weak..." }), Battle.BATTLETYPE_CANLOSE)
|
||||
battle.player.hp = 0
|
||||
T.check(battle:resolveFaints(), "the whiteout ends the battle")
|
||||
local events = battle:takeEvents()
|
||||
local _, lossEvent = indexOf(events, "win-text")
|
||||
T.check(lossEvent, "the loss line is printed: " .. kinds(events))
|
||||
T.eq(lossEvent.text, "...Too weak...", "wLossTextPointer, not the win one")
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle(trainerRecord({ lossText = "...Too weak..." }))
|
||||
battle.player.hp = 0
|
||||
battle:resolveFaints()
|
||||
local events = battle:takeEvents()
|
||||
T.eq(indexOf(events, "win-text"), nil, "an ordinary loss whites out instead")
|
||||
end
|
||||
|
||||
-- wWinTextPointer / wLossTextPointer: the map object's struct, or whatever
|
||||
-- `winlosstext` overwrote the pair with.
|
||||
do
|
||||
local world = { text = { ["3:4000"] = "Th-Thank you!",
|
||||
["3:4100"] = "...Too weak...", ["3:4200"] = "Scripted win." } }
|
||||
world.vm = { trainerObject = { winText = "3:4000", lossText = "3:4100" } }
|
||||
local win, loss = World.trainerWinLossText(world)
|
||||
T.eq(win, "Th-Thank you!", "the struct's win text is decoded")
|
||||
T.eq(loss, "...Too weak...", "and its loss text with it")
|
||||
|
||||
world.vm.winTextOverride = "3:4200"
|
||||
win = World.trainerWinLossText(world)
|
||||
T.eq(win, "Scripted win.", "`winlosstext` overwrites the pointer")
|
||||
|
||||
world.vm.trainerObject = nil
|
||||
world.vm.winTextOverride = nil
|
||||
win, loss = World.trainerWinLossText(world)
|
||||
T.eq(win, nil, "a battle with no trainer object has no line")
|
||||
T.eq(loss, nil, "on either side")
|
||||
|
||||
world.vm = nil
|
||||
T.eq(World.trainerWinLossText(world), nil, "and neither has one with no VM")
|
||||
end
|
||||
|
||||
-- The screen side: the slide-in owns the frames the way SlideBattlePicOut
|
||||
-- does, and the line pages like the map text it is.
|
||||
local BattleState = require("src.ui.gen2.BattleState")
|
||||
|
||||
local function newScreen(battle, queue, image)
|
||||
return setmetatable({
|
||||
battle = battle, queue = queue, picHidden = {}, evolvable = {},
|
||||
phase = "resolving", messageTimer = 0, enemyTrainerImage = image,
|
||||
}, { __index = BattleState })
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle(trainerRecord({ winText = "Th-Thank you!" }))
|
||||
local screen = newScreen(battle, { { kind = "trainer-return" },
|
||||
{ kind = "win-text", text = "Th-Thank you!" } }, {})
|
||||
screen:advanceQueue()
|
||||
T.eq(screen.winSlide, 0, "the slide starts on the frame the event runs")
|
||||
T.check(screen.winSliding, "and owns the screen while it runs")
|
||||
T.check(screen.showEnemyTrainer, "the beaten trainer is back on the field")
|
||||
T.eq(screen.picHidden.enemy, false, "in the box the fainted mon left empty")
|
||||
T.eq(#screen.queue, 1, "the line is still waiting behind it")
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle(trainerRecord())
|
||||
local screen = newScreen(battle, { { kind = "trainer-return" },
|
||||
{ kind = "win-text", text = "Th-Thank you!\fReally." } }, nil)
|
||||
screen:advanceQueue()
|
||||
T.eq(screen.winSlide, nil, "no cached pic, no slide")
|
||||
T.eq(screen.message, "Th-Thank you!", "the line runs straight away")
|
||||
T.check(screen.messagePages, "with its `para` page held back")
|
||||
T.check(screen:nextPage(), "which the queue waits for")
|
||||
T.eq(screen.message, "Really.", "before the second page shows")
|
||||
end
|
||||
|
||||
T.finish("gen2 win loss text bug 1512")
|
||||
@@ -0,0 +1,139 @@
|
||||
-- A gym leader's badge line prints from TrainerBattleVictory (#1606):
|
||||
-- scripts/CeruleanGym.asm:111, PewterGym.asm:117, home/trainers.asm:341
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local gyms = require("data.scripts.gyms")
|
||||
local victories = require("data.scripts.victories")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
while true do
|
||||
local n = debug.getupvalue(fn, i)
|
||||
if not n then return false end
|
||||
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
-- one page of text per label, so a joined chain is readable in a failure
|
||||
local text = {}
|
||||
for _, key in ipairs({ "OPP_BROCK#1", "OPP_MISTY#1", "OPP_LT_SURGE#1",
|
||||
"OPP_ERIKA#1", "OPP_KOGA#1", "OPP_SABRINA#1",
|
||||
"OPP_BLAINE#1", "OPP_GIOVANNI#3" }) do
|
||||
for _, label in ipairs(victories[key].dialogue or {}) do
|
||||
text[label] = "text:" .. label
|
||||
end
|
||||
for _, label in ipairs(victories[key].tmPre or {}) do
|
||||
text[label] = "text:" .. label
|
||||
end
|
||||
for _, label in ipairs(victories[key].tmDialogue or {}) do
|
||||
text[label] = "text:" .. label
|
||||
end
|
||||
end
|
||||
|
||||
local fakeGame = { data = { text = text }, save = { flags = {} } }
|
||||
|
||||
local armed
|
||||
local fakeOw = {
|
||||
engageTrainer = function(_, _, _, endBattleText) armed = endBattleText end,
|
||||
}
|
||||
|
||||
local function armedFor(mapId, textId, victoryKey)
|
||||
armed = nil
|
||||
gyms[mapId].talk[textId](fakeGame, fakeOw, { id = "npc#1" }, function() end)
|
||||
local labels = victories[victoryKey].dialogue
|
||||
local want = {}
|
||||
for i, label in ipairs(labels) do want[i] = text[label] end
|
||||
return armed, table.concat(want, "\f")
|
||||
end
|
||||
|
||||
-- every leader, in badge order
|
||||
local leaders = {
|
||||
{ "PEWTER_GYM", "TEXT_PEWTERGYM_BROCK", "OPP_BROCK#1" },
|
||||
{ "CERULEAN_GYM", "TEXT_CERULEANGYM_MISTY", "OPP_MISTY#1" },
|
||||
{ "VERMILION_GYM", "TEXT_VERMILIONGYM_LT_SURGE", "OPP_LT_SURGE#1" },
|
||||
{ "CELADON_GYM", "TEXT_CELADONGYM_ERIKA", "OPP_ERIKA#1" },
|
||||
{ "FUCHSIA_GYM", "TEXT_FUCHSIAGYM_KOGA", "OPP_KOGA#1" },
|
||||
{ "SAFFRON_GYM", "TEXT_SAFFRONGYM_SABRINA", "OPP_SABRINA#1" },
|
||||
{ "CINNABAR_GYM", "TEXT_CINNABARGYM_BLAINE", "OPP_BLAINE#1" },
|
||||
{ "VIRIDIAN_GYM", "TEXT_VIRIDIANGYM_GIOVANNI", "OPP_GIOVANNI#3" },
|
||||
}
|
||||
for _, entry in ipairs(leaders) do
|
||||
local got, want = armedFor(entry[1], entry[2], entry[3])
|
||||
T.eq(got, want, entry[3] .. " arms its badge line for the battle screen")
|
||||
end
|
||||
|
||||
-- Brock's armed label is one text chain of two text_far pages
|
||||
-- (PewterGymBrockReceivedBoulderBadgeText), so both ride the battle screen
|
||||
local brock = select(1, armedFor("PEWTER_GYM", "TEXT_PEWTERGYM_BROCK",
|
||||
"OPP_BROCK#1"))
|
||||
T.check(brock:find("\f", 1, true) ~= nil,
|
||||
"Brock's badge line keeps its BoulderBadgeInfo page")
|
||||
|
||||
-- the beaten branch still talks instead of re-engaging
|
||||
armed = nil
|
||||
fakeGame.save.flags.EVENT_BEAT_MISTY = true
|
||||
fakeGame.save.flags.EVENT_GOT_TM11 = true
|
||||
local realStack = { push = function() end }
|
||||
gyms.CERULEAN_GYM.talk.TEXT_CERULEANGYM_MISTY(
|
||||
{ data = { text = text }, save = fakeGame.save, stack = realStack },
|
||||
fakeOw, { id = "npc#1" }, function() end)
|
||||
T.eq(armed, nil, "a beaten leader does not re-arm the badge line")
|
||||
fakeGame.save.flags.EVENT_BEAT_MISTY = nil
|
||||
fakeGame.save.flags.EVENT_GOT_TM11 = nil
|
||||
|
||||
-- checkVictoryRewards must not reprint what the battle screen showed
|
||||
local boxes
|
||||
local textBoxStub = {
|
||||
new = function(_, str, onDone)
|
||||
boxes[#boxes + 1] = str
|
||||
return { onDone = onDone }
|
||||
end,
|
||||
soundOpts = function() return nil end,
|
||||
}
|
||||
local pushed
|
||||
local rewardGame = {
|
||||
data = { text = text, items = { TM_BUBBLEBEAM = { name = "TM11" } } },
|
||||
save = { flags = {}, inventory = {}, player = { name = "RED" } },
|
||||
stack = { push = function(_, box) pushed[#pushed + 1] = box end },
|
||||
}
|
||||
T.check(setUpvalue(OW.checkVictoryRewards, "Game", rewardGame),
|
||||
"Game upvalue on checkVictoryRewards")
|
||||
-- TextBox is only named inside the rewardChain closure; the chunk-level
|
||||
-- upvalue cell is shared, so any closure that names it will do
|
||||
T.check(setUpvalue(OW.engageTrainer, "TextBox", textBoxStub),
|
||||
"TextBox upvalue on the reward chain")
|
||||
|
||||
local fakeSelf = setmetatable({
|
||||
map = { id = "CERULEAN_GYM", def = { label = "CeruleanGym" } },
|
||||
runVictoryHook = function() end,
|
||||
}, { __index = OW })
|
||||
|
||||
local function rewardPages(shownOnBattleScreen)
|
||||
boxes, pushed = {}, {}
|
||||
rewardGame.save.flags = {}
|
||||
rewardGame.save.inventory = {}
|
||||
fakeSelf:checkVictoryRewards("OPP_MISTY", 1, shownOnBattleScreen)
|
||||
-- the chain pushes one box at a time; walk it to the end
|
||||
local i = 1
|
||||
while pushed[i] do
|
||||
local box = pushed[i]
|
||||
i = i + 1
|
||||
if box.onDone then box.onDone() end
|
||||
end
|
||||
return table.concat(boxes, "\f")
|
||||
end
|
||||
|
||||
local badge = text["_CeruleanGymMistyReceivedCascadeBadgeText"]
|
||||
local onMap = rewardPages(false)
|
||||
T.check(onMap:find(badge, 1, true) ~= nil,
|
||||
"without the battle-screen line the reward chain still shows the badge text")
|
||||
local afterBattleScreen = rewardPages(true)
|
||||
T.eq(afterBattleScreen:find(badge, 1, true), nil,
|
||||
"the badge line is not reprinted on the map once the battle screen showed it")
|
||||
T.check(afterBattleScreen:find(text["_CeruleanGymMistyCascadeBadgeInfoText"],
|
||||
1, true) ~= nil,
|
||||
"the TM hand-over still runs on the map")
|
||||
T.check(rewardGame.save.inventory.CASCADEBADGE == 1, "the badge is still given")
|
||||
@@ -0,0 +1,163 @@
|
||||
-- Title -> main menu handoff (#1510) and the Oak-intro naming layout (#1511):
|
||||
-- title.asm .finishedWaiting, oak_speech2.asm ChoosePlayerName
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
|
||||
local function newStack()
|
||||
local stack = { states = {} }
|
||||
function stack:push(state, ...)
|
||||
table.insert(self.states, state)
|
||||
if type(state.enter) == "function" then state:enter(...) end
|
||||
end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return stack
|
||||
end
|
||||
|
||||
local function newGame()
|
||||
return {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = newStack(),
|
||||
input = { wasPressed = function() return false end,
|
||||
isDown = function() return false end },
|
||||
}
|
||||
end
|
||||
|
||||
local function run(state, frames)
|
||||
for _ = 1, frames do
|
||||
if state.game.stack:top() ~= state then return end
|
||||
state:update(1 / 60)
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- #1510: title -> menu
|
||||
|
||||
local game = newGame()
|
||||
local title = TitleState.new(game, {})
|
||||
game.stack:push(title)
|
||||
|
||||
title:toMenu()
|
||||
local flash = game.stack:top()
|
||||
T.check(flash ~= title, "START pushes a state before the menu")
|
||||
T.eq(flash.isOpaque, true, "GBPalWhiteOutWithDelay3 covers the title art")
|
||||
T.check(not title.menuOpen, "the title is still drawing itself mid-blink")
|
||||
|
||||
run(flash, 60)
|
||||
local menu = game.stack:top()
|
||||
T.check(getmetatable(menu) == Menu, "the blink hands off to the main menu")
|
||||
T.eq(title.menuOpen, true, "MainMenu's ClearScreen takes the title art down")
|
||||
T.eq(menu.tx, 0, "the CONTINUE / NEW GAME box is still at hlcoord 0,0")
|
||||
|
||||
-- OPTION returns to .mainMenuLoop, so its row must not close the box
|
||||
local option
|
||||
for _, item in ipairs(menu.items) do
|
||||
if item.label == "OPTION" then option = item end
|
||||
end
|
||||
T.check(option ~= nil and option.keepOpen == true,
|
||||
"OPTION keeps the main menu on the stack")
|
||||
|
||||
-- B: DisplayTitleScreen opens with GBPalWhiteOut
|
||||
T.check(type(menu.onCancel) == "function", "the main menu cancels back out")
|
||||
game.stack:pop()
|
||||
menu.onCancel()
|
||||
local back = game.stack:top()
|
||||
T.eq(back.isOpaque, true, "backing out blinks white too")
|
||||
T.eq(title.menuOpen, true, "the title art stays down until the blink ends")
|
||||
run(back, 60)
|
||||
T.eq(title.menuOpen, false, "and comes back once the blink is over")
|
||||
T.eq(game.stack:top(), title, "leaving the menu lands back on the title")
|
||||
|
||||
-- ------------------------------------------- #1511: the intro NAME box
|
||||
|
||||
local ngame = newGame()
|
||||
local naming = NamingScreen.new(ngame, {
|
||||
presets = { "RED", "ASH", "JACK" }, introBox = true,
|
||||
})
|
||||
ngame.stack:push(naming)
|
||||
local box = ngame.stack:top()
|
||||
T.check(getmetatable(box) == Menu, "the preset list is a bordered menu")
|
||||
-- DisplayIntroNameTextBox: TextBoxBorder at hlcoord 0,0 with b=$a, c=$9
|
||||
T.eq(box.tx, 0, "the name box starts at column 0")
|
||||
T.eq(box.ty, 0, "the name box starts at row 0")
|
||||
T.eq(box.tw, 11, "c=$9 plus both border columns is 11 tiles wide")
|
||||
T.eq(box.th, 12, "b=$a plus both border rows is 12 tiles tall")
|
||||
T.eq(box.title, "NAME", "the NAME label rides the box's top border")
|
||||
T.eq(box.itemY, 2, "wTopMenuItemY 2: the list is anchored down from the top")
|
||||
T.eq(box.cancelable, false, "there is no way out of the naming choice")
|
||||
|
||||
-- every other NamingScreen caller keeps the old preset box
|
||||
local plain = NamingScreen.new(newGame(), { presets = { "RED" } })
|
||||
local pgame = plain.game
|
||||
pgame.stack:push(plain)
|
||||
T.eq(pgame.stack:top().tx, 4, "a non-intro preset list is unchanged")
|
||||
T.eq(pgame.stack:top().title, nil, "and carries no header")
|
||||
|
||||
-- ------------------------------------------ #1511: the pic slide + box
|
||||
|
||||
local steps = OakSpeech.defaultSteps({})
|
||||
local byId = {}
|
||||
for _, step in ipairs(steps) do byId[step.id] = step end
|
||||
-- oak_speech.asm:86-91 MovePicLeft, then a text_end box that stays up
|
||||
T.eq(byId.ask_player_name.reveal, "wipe", "the player pic wipes in")
|
||||
T.eq(byId.ask_player_name.stay, true, "and its question box stays on screen")
|
||||
T.eq(byId.ask_rival_name.reveal, "fade", "the rival pic fades in")
|
||||
T.eq(byId.ask_rival_name.stay, true, "and its box stays on screen too")
|
||||
|
||||
local sgame = newGame()
|
||||
local speech = OakSpeech.new(sgame, function() end)
|
||||
sgame.stack:push(speech)
|
||||
speech.steps = steps
|
||||
speech.step = 0
|
||||
speech:runStep(byId.name_player)
|
||||
local slide = sgame.stack:top()
|
||||
T.check(slide ~= speech, "the name beat slides the pic before the box opens")
|
||||
T.eq(speech.picSlide, 0, "the slide starts where the pic already sat")
|
||||
-- six tiles, one per Delay3
|
||||
run(slide, 6 * 3)
|
||||
T.eq(speech.picSlide, 48, "OakSpeechSlidePicRight ends six tiles across")
|
||||
T.check(sgame.stack:top() ~= slide, "the slide pops itself when it lands")
|
||||
|
||||
-- ------------------------------------- #1511: the question box stays up
|
||||
|
||||
local fgame = newGame()
|
||||
local flow = OakSpeech.new(fgame, function() end)
|
||||
fgame.stack:push(flow)
|
||||
flow.steps = OakSpeech.defaultSteps(flow)
|
||||
for i, step in ipairs(flow.steps) do
|
||||
if step.id == "ask_player_name" then flow.step = i - 1 end
|
||||
end
|
||||
flow:advance()
|
||||
local function pump(frames)
|
||||
for _ = 1, frames do
|
||||
local top = fgame.stack:top()
|
||||
if top.update then top:update(1 / 60) end
|
||||
if getmetatable(fgame.stack:top()) == Menu then return end
|
||||
end
|
||||
end
|
||||
pump(400)
|
||||
T.check(getmetatable(fgame.stack:top()) == Menu, "the preset list opens")
|
||||
T.eq(fgame.stack.states[2], flow.holdBox,
|
||||
"IntroducePlayerText's box is still on the stack under the name list")
|
||||
T.check(flow.holdBox.done and flow.holdBox.stay,
|
||||
"it is a `stay` box: text_end returns from PrintText without a wait")
|
||||
|
||||
local preset = fgame.stack:top()
|
||||
preset.index = 2
|
||||
fgame.input.wasPressed = function(_, b) return b == "a" end
|
||||
preset:update(1 / 60)
|
||||
fgame.input.wasPressed = function() return false end
|
||||
T.eq(fgame.save.player.name, "RED", "picking a preset names the player")
|
||||
T.eq(flow.holdBox, nil, "and takes the question box down with the list")
|
||||
run(fgame.stack:top(), 6 * 3)
|
||||
T.eq(flow.picSlide, 0, "OakSpeechSlidePicLeft puts the pic back")
|
||||
|
||||
T.finish("intro_title_naming_bug1510_1511")
|
||||
@@ -0,0 +1,95 @@
|
||||
-- The PC gains a PKMN LEAGUE row after the Hall of Fame (#1566):
|
||||
-- DisplayPCMainMenu (engine/pokemon/bills_pc.asm:5), PKMNLeague (pc.asm:67)
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local function setUpvalue(fn, name, val)
|
||||
local i = 1
|
||||
while true do
|
||||
local n = debug.getupvalue(fn, i)
|
||||
if not n then return false end
|
||||
if n == name then debug.setupvalue(fn, i, val); return true end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local pushed = {}
|
||||
local fakeGame = {
|
||||
data = { text = {
|
||||
_TurnedOnPC1Text = "RED turned on\nthe PC.",
|
||||
_AccessedHoFPCText = "Accessed POKéMON\nLEAGUE's site.",
|
||||
} },
|
||||
save = {
|
||||
flags = { EVENT_GOT_POKEDEX = true },
|
||||
player = { name = "RED" },
|
||||
},
|
||||
stack = { push = function(_, item) pushed[#pushed + 1] = item end },
|
||||
}
|
||||
|
||||
local sounds = {}
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, name) sounds[#sounds + 1] = name end,
|
||||
}
|
||||
local menuItems
|
||||
package.loaded["src.ui.Menu"] = {
|
||||
new = function(_, items) menuItems = items; return { menu = true } end,
|
||||
}
|
||||
local opened = {}
|
||||
T.check(setUpvalue(OW.openPC, "Game", fakeGame), "Game upvalue on openPC")
|
||||
T.check(setUpvalue(OW.openPC, "Screens", {
|
||||
push = function(_, id) opened[#opened + 1] = id end,
|
||||
}), "Screens upvalue on openPC")
|
||||
T.check(setUpvalue(OW.openPC, "TextBox", {
|
||||
new = function(_, text, onDone) return { text = text, onDone = onDone } end,
|
||||
}), "TextBox upvalue on openPC")
|
||||
|
||||
local fakeSelf = setmetatable({}, { __index = OW })
|
||||
|
||||
local function labels()
|
||||
pushed, menuItems, sounds, opened = {}, nil, {}, {}
|
||||
fakeSelf:openPC(function() end)
|
||||
pushed[1].onDone() -- close TurnedOnPC1Text; the menu goes up behind it
|
||||
local names = {}
|
||||
for i, item in ipairs(menuItems or {}) do names[i] = item.label end
|
||||
return names
|
||||
end
|
||||
|
||||
local function indexOf(list, label)
|
||||
for i, name in ipairs(list) do
|
||||
if name == label then return i end
|
||||
end
|
||||
end
|
||||
|
||||
-- wNumHoFTeams == 0: three rows plus LOG OFF (bills_pc.asm .noLeaguePC)
|
||||
local before = labels()
|
||||
T.eq(indexOf(before, "<PK><MN>LEAGUE"), nil,
|
||||
"no PKMN LEAGUE row before the Hall of Fame")
|
||||
T.eq(#before, 4, "BILL's PC, the player's PC, PROF.OAK's PC and LOG OFF")
|
||||
|
||||
-- one recorded team is enough, and it stays for good
|
||||
fakeGame.save.hallOfFame = { { { species = "PIKACHU", level = 80 } } }
|
||||
local after = labels()
|
||||
local iLeague = indexOf(after, "<PK><MN>LEAGUE")
|
||||
T.check(iLeague, "PKMN LEAGUE appears once a team is in the Hall of Fame")
|
||||
T.eq(after[iLeague - 1], "PROF.OAK's PC", "it follows PROF.OAK's PC")
|
||||
T.eq(after[iLeague + 1], "LOG OFF", "and LOG OFF still closes the menu")
|
||||
T.check(menuItems[iLeague].keepOpen,
|
||||
"B returns to the PC menu (ReloadMainMenu), it does not log off")
|
||||
|
||||
-- selecting it: SFX_ENTER_PC, AccessedHoFPCText, then the roster screen
|
||||
menuItems[iLeague].onSelect()
|
||||
T.eq(sounds[#sounds], "Enter_PC", "PKMNLeague plays SFX_ENTER_PC")
|
||||
local box = pushed[#pushed]
|
||||
T.eq(box.text, fakeGame.data.text._AccessedHoFPCText,
|
||||
"PKMNLeaguePC prints AccessedHoFPCText first")
|
||||
box.onDone()
|
||||
T.same(opened, { "LeaguePC" }, "the Hall of Fame roster screen opens")
|
||||
|
||||
-- without the Pokedex the league row still shows (bills_pc.asm checks
|
||||
-- wNumHoFTeams before EVENT_GOT_POKEDEX)
|
||||
fakeGame.save.flags.EVENT_GOT_POKEDEX = nil
|
||||
local noDex = labels()
|
||||
T.check(indexOf(noDex, "<PK><MN>LEAGUE"),
|
||||
"the league row does not depend on EVENT_GOT_POKEDEX")
|
||||
@@ -136,8 +136,9 @@ do
|
||||
local list, why = useFromBag(game, nil, "RARE_CANDY")
|
||||
if check(list ~= nil, "the bag opened and reached the picker: " .. tostring(why)) then
|
||||
eq(mon.level, 6, "the candy leveled the mon 5 -> 6")
|
||||
check(not inStack(game.stack, isPicker),
|
||||
"the pickOnly picker popped itself before onSwitch")
|
||||
-- .useRareCandy over the party list (item_effects.asm:1392-1418) #1594
|
||||
check(inStack(game.stack, isPicker),
|
||||
"the party picker is still up under the level text (#1594)")
|
||||
check(inStack(game.stack, function(s) return s == list end),
|
||||
"the bag list is STILL on the stack (.useItem_partyMenu re-enters "
|
||||
.. "StartMenu_Item, it does not CloseStartMenu) (#796)")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
-- SAVE confirmation layout (#1522): PrintSaveScreenText's own box and
|
||||
-- SaveTheGame_YesOrNo's TWO_OPTION_MENU at hlcoord 0, 7
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.load()
|
||||
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StartMenu = require("src.ui.StartMenu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
local function newStack()
|
||||
local stack = { states = {} }
|
||||
function stack:push(state) table.insert(self.states, state) end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return stack
|
||||
end
|
||||
|
||||
local game = {
|
||||
data = Data,
|
||||
save = SaveData.newGame(),
|
||||
stack = newStack(),
|
||||
input = { wasPressed = function() return false end,
|
||||
isDown = function() return false end },
|
||||
}
|
||||
game.save.player.name = "RED"
|
||||
game.save.playTime = 3 * 3600 + 7 * 60
|
||||
|
||||
local menu = StartMenu.new(game)
|
||||
local save
|
||||
for _, item in ipairs(menu.items) do
|
||||
if item.label == "SAVE" then save = item end
|
||||
end
|
||||
T.check(save ~= nil, "the start menu lists SAVE")
|
||||
|
||||
save.onSelect()
|
||||
T.eq(#game.stack.states, 2, "the panel and the prompt are two separate states")
|
||||
local panel, prompt = game.stack.states[1], game.stack.states[2]
|
||||
T.check(type(panel.draw) == "function" and not panel.isTextBox,
|
||||
"the info panel is its own drawn state, not a TextBox page")
|
||||
T.check(prompt.isTextBox == true, "the prompt is the dialogue box on top")
|
||||
T.eq(#prompt.pages, 1, "the prompt is one page: no \\f-merged info panel")
|
||||
T.check(prompt.pages[1][1]:find("Would you like to"),
|
||||
"the prompt page is WouldYouLikeToSaveText")
|
||||
|
||||
-- save.asm:187 hlcoord 0, 7
|
||||
T.eq(prompt.choiceBox.tx, 0, "the save Yes/No box sits at column 0 (left)")
|
||||
T.eq(prompt.choiceBox.ty, 7, "the save Yes/No box sits at row 7")
|
||||
local choice = ChoiceBox.new(game, function() end, { box = prompt.choiceBox })
|
||||
T.eq(choice.tx, 0, "ChoiceBox honours the save-specific left placement")
|
||||
|
||||
-- answering NO takes the panel back down with the prompt
|
||||
prompt.done = true
|
||||
prompt:update(1 / 60)
|
||||
local yesno = game.stack:top()
|
||||
T.check(getmetatable(yesno) == ChoiceBox, "the prompt pushes the Yes/No box")
|
||||
T.eq(yesno.tx, 0, "the pushed Yes/No box is the left-hand one")
|
||||
game.stack:pop()
|
||||
game.stack:pop()
|
||||
prompt.choice(false)
|
||||
T.eq(#game.stack.states, 0, "declining closes the info panel too")
|
||||
|
||||
T.finish("save_confirm_layout_bug1522")
|
||||
@@ -49,6 +49,13 @@ local function indexOf(rows, name)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function hasText(battle, needle)
|
||||
for _, item in ipairs(battle.queue) do
|
||||
if item.text and item.text:find(needle, 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- the player's setup turn: the effect animation precedes the move's own
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -73,6 +80,16 @@ do
|
||||
battle:performMove(battle.player, battle.enemy, slot)
|
||||
T.check(indexOf(animRows(battle), "SHRINKING_SQUARE_ANIM") == nil,
|
||||
"a locked-in Thrash queues no setup animation")
|
||||
-- .ThrashingAboutCheck falls into PlayerCalcMoveDamage, the same
|
||||
-- animation pipeline every other move uses (core.asm:3540) (#1577)
|
||||
T.check(indexOf(animRows(battle), "FIX_THRASH") ~= nil,
|
||||
"but the move's own animation still plays on a continuation turn")
|
||||
T.check(hasText(battle, "thrashing about"),
|
||||
"ThrashingAboutText prints in place of the used-move line")
|
||||
T.check(not hasText(battle, "used FIX THRASH"),
|
||||
"and the used-move line does not")
|
||||
T.eq(battle.player.thrashTurns, 1,
|
||||
"the continuation turn runs wPlayerNumAttacksLeft down")
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -104,6 +121,11 @@ do
|
||||
T.check(indexOf(rows, "SHRINKING_SQUARE_ANIM") ~= nil,
|
||||
"the setup animation survives a miss")
|
||||
T.check(indexOf(rows, "FIX_THRASH") == nil, "while the move's own anim is cancelled")
|
||||
-- ThrashPetalDanceEffect commits before MoveHitTest
|
||||
-- (core.asm:3129-3133, effects.asm:791-808) (#1565)
|
||||
T.eq(battle.player.thrashTurns, 2, "the miss still rolls wPlayerNumAttacksLeft")
|
||||
T.check(battle:menuLockedAction(battle.player) ~= nil,
|
||||
"and the user is locked into Thrash next turn")
|
||||
end
|
||||
|
||||
T.finish("thrash setup animation (#1532)")
|
||||
|
||||
@@ -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)")
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, "; "))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user