mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-06 09:05:48 -04:00
CLOSES #2649, CLOSES #2650, CLOSES #2652, CLOSES #2653, CLOSES #2654, CLOSES #2657, CLOSES #2658, CLOSES #2659, CLOSES #2660, CLOSES #2662, CLOSES #2664, CLOSES #2665, CLOSES #2666, CLOSES #2669, CLOSES #2671, CLOSES #2672, CLOSES #2674, CLOSES #2676, CLOSES #2682, CLOSES #2683, CLOSES #2684, CLOSES #2685, CLOSES #2686, CLOSES #2687, CLOSES #2688, CLOSES #2689
CLOSES #2649, CLOSES #2650, CLOSES #2652, CLOSES #2653, CLOSES #2654, CLOSES #2657, CLOSES #2658, CLOSES #2659, CLOSES #2660, CLOSES #2662, CLOSES #2664, CLOSES #2665, CLOSES #2666, CLOSES #2669, CLOSES #2671, CLOSES #2672, CLOSES #2674, CLOSES #2676, CLOSES #2682, CLOSES #2683, CLOSES #2684, CLOSES #2685, CLOSES #2686, CLOSES #2687, CLOSES #2688, CLOSES #2689
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 241 KiB After Width: | Height: | Size: 435 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 238 KiB After Width: | Height: | Size: 385 KiB |
@@ -1,31 +1,11 @@
|
||||
-- Route 23's badge-gate guards (pokered scripts/Route23.asm). Each
|
||||
-- guard/swimmer stands beside the staircase to Victory Road and, when
|
||||
-- talked to, checks whether the player holds the badge matching that
|
||||
-- step; the automatic walk-into-the-guard blocking script
|
||||
-- (Route23DefaultScript / Route23CheckForBadgeScript) is not ported
|
||||
-- here -- only the talk-triggered text_asm bodies, which share that
|
||||
-- same subroutine (Route23CheckForBadgeScript) and text
|
||||
-- (Route23YouDontHaveTheBadgeYetText / Route23OhThatIsTheBadgeText,
|
||||
-- pokered/text/Route23.asm _Route23YouDontHaveTheBadgeYetText /
|
||||
-- _Route23OhThatIsTheBadgeText). Once shown the badge, pokered sets
|
||||
-- EVENT_PASSED_<BADGE>_CHECK so the walk-through gate (when ported)
|
||||
-- won't re-ask; we mirror that with set_flag for fidelity even though
|
||||
-- no onStep gate currently reads it.
|
||||
|
||||
-- scripts/Route23.asm:153
|
||||
local M = {}
|
||||
|
||||
-- rows: check_item(badge) -> have it? show "Oh! That is the X!" and
|
||||
-- set EVENT_PASSED_X_CHECK : show "You don't have the X yet!"
|
||||
-- scripts/Route23.asm:195
|
||||
local function badgeGuard(badge, passFlag)
|
||||
local subs = { RAM = badge }
|
||||
return {
|
||||
{ "check_item", badge }, -- 1
|
||||
{ "jump_if_true", 5 }, -- 2
|
||||
{ "show_text", "_Route23YouDontHaveTheBadgeYetText", subs }, -- 3
|
||||
{ "jump", 7 }, -- 4 (end)
|
||||
{ "show_text", "_Route23OhThatIsTheBadgeText", subs }, -- 5
|
||||
{ "set_flag", passFlag }, -- 6
|
||||
}
|
||||
return function(game, ow, npc, done)
|
||||
ow:route23BadgeCheck(badge, passFlag, done)
|
||||
end
|
||||
end
|
||||
|
||||
-- Route23SetVictoryRoadBoulders (pokered scripts/Route23.asm:8): every entry
|
||||
|
||||
@@ -34,18 +34,19 @@ local function starterBall(askText, species, choseFlag, ownBall,
|
||||
{ species = species, forceOwned = true } }, -- 5
|
||||
{ "ask", askText }, -- 6
|
||||
{ "jump_if_false", "end" }, -- 7
|
||||
-- scripts/OaksLab.asm:919
|
||||
{ "show_text", "_OaksLabMonEnergeticText" }, -- 8
|
||||
-- scripts/OaksLab.asm:918
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 8
|
||||
-- scripts/OaksLab.asm:921
|
||||
{ "show_text", "_OaksLabMonEnergeticText" }, -- 9
|
||||
-- OaksLab.asm: ReceivedMon (sound_get_key_item) then AddPartyMon; the
|
||||
-- jingle fires once the box has typed and holds it (#668)
|
||||
{ "text_sound", "Get_Key_Item" }, -- 9
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 10
|
||||
{ "give_pokemon", species, 5 }, -- 11
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 12
|
||||
{ "set_flag", choseFlag }, -- 13
|
||||
{ "text_sound", "Get_Key_Item" }, -- 10
|
||||
{ "show_text", "_OaksLabReceivedMonText", { RAM = species } }, -- 11
|
||||
{ "give_pokemon", species, 5 }, -- 12
|
||||
{ "set_flag", "EVENT_GOT_STARTER" }, -- 13
|
||||
-- POKé BALLs come later, at OaksLabOak1Text's .give_poke_balls beat
|
||||
-- once the Route 22 rival is beaten (see TEXT_OAKSLAB_OAK1 below)
|
||||
{ "hide_object", "OAKS_LAB", ownBall }, -- 14
|
||||
{ "set_flag", choseFlag }, -- 14
|
||||
-- the rival walks to the countering ball (around the furniture)
|
||||
{ "move_npc_to", 1, rivalBallX, 4 }, -- 15
|
||||
{ "face_object", 1, "up" }, -- 16
|
||||
|
||||
@@ -1061,7 +1061,8 @@ M.VICTORY_ROAD_3F = {
|
||||
-- pokered forces the fight on map entry: Agatha's victory arms
|
||||
-- SCRIPT_CHAMPIONSROOM_PLAYER_ENTERS (scripts/AgathasRoom.asm), and
|
||||
-- ChampionsRoomPlayerEntersScript then runs RivalEntrance_RLEMovement
|
||||
-- (up 1, right 1, up 3) before ChampionsRoomRivalReadyToBattleScript.
|
||||
-- (played back to front: up 3, right 1, up 1) before
|
||||
-- ChampionsRoomRivalReadyToBattleScript.
|
||||
-- The rival object has no trainer header / sight range, so without that
|
||||
-- entrance script the player can walk past (issue #99). We arm on the
|
||||
-- run flag instead of Agatha's victory bit: same observable effect for
|
||||
@@ -1090,6 +1091,7 @@ local championsRoomRivalScript = {
|
||||
-- playBattle("final") then no-ops on the same song, so the theme stays
|
||||
-- continuous into the fight
|
||||
{ "play_music", "Music_FinalBattle" }, -- 5
|
||||
{ "save_end_battle_text", "_RivalDefeatedText" }, -- scripts/ChampionsRoom.asm:65
|
||||
{ "rival_battle", "OPP_RIVAL3", 1 }, -- 6
|
||||
-- losing halts here; the numeric target this replaced pointed at the
|
||||
-- closing warp, which inducted a player who had just lost the fight (#704)
|
||||
@@ -1098,8 +1100,7 @@ local championsRoomRivalScript = {
|
||||
{ "set_flag", "EVENT_BEAT_CHAMPION_RIVAL" }, -- 9
|
||||
-- ChampionsRoomRivalDefeatedScript re-displays TEXT_CHAMPIONSROOM_RIVAL,
|
||||
-- whose text_asm takes the EVENT_BEAT_CHAMPION_RIVAL branch =
|
||||
-- _ChampionsRoomRivalAfterBattleText (the in-battle _RivalDefeatedText
|
||||
-- is the port's generic "<PLAYER> defeated BLUE!" engine line instead).
|
||||
-- _ChampionsRoomRivalAfterBattleText.
|
||||
{ "show_text", "_ChampionsRoomRivalAfterBattleText" }, -- 10
|
||||
-- ChampionsRoomOakArrivesScript: Music_Cities1AlternateTempo
|
||||
-- (Cities1, kept into HALL_OF_FAME like BIT_NO_MAP_MUSIC after
|
||||
@@ -1153,10 +1154,11 @@ M.CHAMPIONS_ROOM = {
|
||||
end
|
||||
-- RivalEntrance_RLEMovement, then the battle/Oak script (queued
|
||||
-- separately so talk-script jump indices stay 1-based as written).
|
||||
-- home/overworld.asm:1844
|
||||
ow:queueScript({
|
||||
{ "move_player", "up", 1 },
|
||||
{ "move_player", "right", 1 },
|
||||
{ "move_player", "up", 3 },
|
||||
{ "move_player", "right", 1 },
|
||||
{ "move_player", "up", 1 },
|
||||
})
|
||||
ow:queueScript(championsRoomRivalScript, { npc = rival })
|
||||
end,
|
||||
|
||||
+35
-33
@@ -867,20 +867,19 @@ M.SAFFRON_CITY = {
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
local function door(game, ow)
|
||||
ow:replaceBlock(2, 0, game.save.flags[flag] and openBlock or closedBlock)
|
||||
end
|
||||
local seal = function(game, ow)
|
||||
local open = game.save.flags[flag]
|
||||
ow:replaceBlock(2, 0, open and openBlock or closedBlock)
|
||||
door(game, ow)
|
||||
-- the auto walk-in on first (south) entry (LoreleiScriptWalkIntoRoom)
|
||||
if autoFlag and not game.save.flags[autoFlag] and ow.player.cellY >= 10 then
|
||||
game.save.flags[autoFlag] = true
|
||||
ow:scriptMove(ow.player, "up", 6)
|
||||
end
|
||||
end
|
||||
-- onVictory re-runs the seal so the door opens right after the
|
||||
-- battle, like pokered's post-battle map reload
|
||||
return {
|
||||
onEnter = seal,
|
||||
onVictory = seal,
|
||||
-- retreating toward the entrance gets "Don't run away!" and a
|
||||
-- shove back up (the entrance coords rows in each room script)
|
||||
onStep = function(game, ow, x, y)
|
||||
@@ -892,14 +891,14 @@ local function e4ExitSeal(flag, closedBlock, openBlock, dontRunText, autoFlag)
|
||||
end))
|
||||
return true
|
||||
end,
|
||||
}
|
||||
}, door
|
||||
end
|
||||
|
||||
-- Lorelei/Bruno/Agatha EndBattleScript (pokered): EndTrainerBattle then
|
||||
-- DisplayTextID -> TalkToTrainer, which prints the AfterBattle text on a
|
||||
-- win. engageTrainer only shows the won line, so wrap talk like Lance /
|
||||
-- Game Corner Rocket and push header.after immediately after a win.
|
||||
local function e4LeaderTalk(afterLabel)
|
||||
local function e4LeaderTalk(afterLabel, door)
|
||||
return function(game, ow, npc, done)
|
||||
done = done or function() end
|
||||
if ow:trainerDefeated(npc) then
|
||||
@@ -912,26 +911,33 @@ local function e4LeaderTalk(afterLabel)
|
||||
done()
|
||||
return
|
||||
end
|
||||
-- scripts/AgathasRoom.asm:2
|
||||
local function reopen()
|
||||
door(game, ow)
|
||||
done()
|
||||
end
|
||||
local after = text(game)[afterLabel]
|
||||
if after then push(game, after, done) else done() end
|
||||
if after then push(game, after, reopen) else reopen() end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
M.LORELEIS_ROOM = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
local loreleiDoor, brunoDoor, agathaDoor
|
||||
M.LORELEIS_ROOM, loreleiDoor = e4ExitSeal("EVENT_BEAT_LORELEIS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
"_LoreleisRoomLoreleiDontRunAwayText", "EVENT_AUTOWALKED_INTO_LORELEIS_ROOM")
|
||||
M.LORELEIS_ROOM.talk = {
|
||||
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText"),
|
||||
TEXT_LORELEISROOM_LORELEI = e4LeaderTalk("_LoreleisRoomLoreleiAfterBattleText",
|
||||
loreleiDoor),
|
||||
}
|
||||
M.BRUNOS_ROOM = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
M.BRUNOS_ROOM, brunoDoor = e4ExitSeal("EVENT_BEAT_BRUNOS_ROOM_TRAINER_0", 0x24, 0x05,
|
||||
"_BrunosRoomBrunoDontRunAwayText", "EVENT_AUTOWALKED_INTO_BRUNOS_ROOM")
|
||||
M.BRUNOS_ROOM.talk = {
|
||||
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText"),
|
||||
TEXT_BRUNOSROOM_BRUNO = e4LeaderTalk("_BrunoAfterBattleText", brunoDoor),
|
||||
}
|
||||
M.AGATHAS_ROOM = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||
M.AGATHAS_ROOM, agathaDoor = e4ExitSeal("EVENT_BEAT_AGATHAS_ROOM_TRAINER_0", 0x3b, 0x0e,
|
||||
"_AgathasRoomAgathaDontRunAwayText", "EVENT_AUTOWALKED_INTO_AGATHAS_ROOM")
|
||||
M.AGATHAS_ROOM.talk = {
|
||||
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText"),
|
||||
TEXT_AGATHASROOM_AGATHA = e4LeaderTalk("_AgathaAfterBattleText", agathaDoor),
|
||||
}
|
||||
|
||||
-- -------------------------------------------------------------------
|
||||
@@ -953,21 +959,15 @@ M.AGATHAS_ROOM.talk = {
|
||||
-- (5,11)/(6,11) the doorway -> CheckAndSetEvent
|
||||
-- EVENT_LANCES_ROOM_LOCK_DOOR: first crossing seals
|
||||
-- the door behind the player with SFX_GO_INSIDE
|
||||
-- (24,16) the entrance staircase -> WalkToLance: an auto-walk
|
||||
-- landing on (6,11). Vanilla WalkToLance_RLEList is
|
||||
-- up 12 / left 12 / down 7 / left 6 and skips collision
|
||||
-- through void tiles; our camera follows that literally
|
||||
-- and briefly shows the empty upper chamber (reads as
|
||||
-- "Gary's room"). We keep the same landing cell but
|
||||
-- route along the open-door floor corridor instead.
|
||||
-- (24,16) the entrance staircase -> WalkToLance
|
||||
-- -------------------------------------------------------------------
|
||||
|
||||
-- Floor corridor (24,16) -> (6,11) with EVENT_LANCES_ROOM_LOCK_DOOR
|
||||
-- unset (entrance blocks $31/$32). Exposed for parity tests.
|
||||
local LANCE_WALK_IN = {
|
||||
{ "down", 2 }, { "left", 6 }, { "down", 5 }, { "left", 10 },
|
||||
{ "up", 9 }, { "left", 2 }, { "up", 3 },
|
||||
}
|
||||
-- scripts/LancesRoom.asm:111
|
||||
-- home/overworld.asm:1845
|
||||
local function lanceWalkInRoute(yellow)
|
||||
return { { "left", 6 }, { "down", 7 }, { "left", 12 }, { "up", yellow and 13 or 12 } }
|
||||
end
|
||||
local LANCE_WALK_IN = lanceWalkInRoute(false)
|
||||
|
||||
local function lanceEntranceBlocks(game, ow)
|
||||
local locked = game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR
|
||||
@@ -983,15 +983,17 @@ local function lanceLockDoor(game, ow)
|
||||
end
|
||||
|
||||
local function lanceWalkIn(game, ow)
|
||||
local route = lanceWalkInRoute(require("src.core.GameVersion").isYellow())
|
||||
local i = 0
|
||||
local function step()
|
||||
i = i + 1
|
||||
local seg = LANCE_WALK_IN[i]
|
||||
local seg = route[i]
|
||||
if not seg then
|
||||
-- lands on (6,11); vanilla's per-frame coord poll then locks the
|
||||
-- door at once. scriptMove landings do not fire onStep, so lock
|
||||
-- here.
|
||||
lanceLockDoor(game, ow)
|
||||
-- scripts/LancesRoom.asm:82
|
||||
local p = ow.player
|
||||
if (p.cellX == 5 or p.cellX == 6) and p.cellY == 11 then
|
||||
lanceLockDoor(game, ow)
|
||||
end
|
||||
return
|
||||
end
|
||||
ow:scriptMove(ow.player, seg[1], seg[2], step)
|
||||
@@ -1001,6 +1003,7 @@ end
|
||||
|
||||
M.LANCES_ROOM = {
|
||||
walkInRoute = LANCE_WALK_IN,
|
||||
walkInRouteFor = lanceWalkInRoute,
|
||||
onEnter = function(game, ow)
|
||||
lanceEntranceBlocks(game, ow)
|
||||
-- the warp arrival lands ON the staircase trigger, and onStep only
|
||||
@@ -1019,7 +1022,6 @@ M.LANCES_ROOM = {
|
||||
if npc.def and npc.def.name == "LANCESROOM_LANCE" then lance = npc break end
|
||||
end
|
||||
if not lance or ow:trainerDefeated(lance) then return false end
|
||||
lance:facePlayer(ow.player)
|
||||
-- LancesRoomLanceEndBattleScript DisplayTextID -> TalkToTrainer
|
||||
-- after-battle text (rival became champion first). engageTrainer
|
||||
-- only shows the won line, so push the after text on a win.
|
||||
|
||||
+67
-13
@@ -1268,8 +1268,9 @@ end
|
||||
-- Returns true while animating.
|
||||
function BattleState:stepHPDrain()
|
||||
local busy = false
|
||||
local only = self.drainOnly
|
||||
for _, b in ipairs({ self.player, self.enemy }) do
|
||||
if b and b.shownHP then
|
||||
if b and b.shownHP and (not only or b == only) then
|
||||
-- drainFloor is the stop the running row carries (see drainNext)
|
||||
local goal = b.mon.hp
|
||||
if b.drainFloor and b.drainFloor > goal
|
||||
@@ -1393,6 +1394,19 @@ function BattleState:beginMsgLine()
|
||||
self.shown[#self.shown + 1] = {}
|
||||
end
|
||||
|
||||
-- home/text.asm:264
|
||||
function BattleState:arrowOwnsCell()
|
||||
local save = self.game and self.game.save
|
||||
if save and (save.generation == 2 or save.version == "gold") then return false end
|
||||
return not self:wideLayout()
|
||||
end
|
||||
|
||||
-- home/text.asm:270
|
||||
function BattleState:blankArrowCell()
|
||||
local line = self.shown and self.shown[2]
|
||||
if line and line[18] and self:arrowOwnsCell() then line[18] = 0x7F end
|
||||
end
|
||||
|
||||
function BattleState:visibleText()
|
||||
if self.phase ~= "messages" or not (self.current or self.animPlaying) then
|
||||
return nil
|
||||
@@ -1436,7 +1450,7 @@ function BattleState:updateQueue()
|
||||
-- an HP-bar drain holds the queue until the bar catches up
|
||||
if self.draining then
|
||||
if self:stepHPDrain() then return true end
|
||||
self.draining = nil
|
||||
self.draining, self.drainOnly = nil, nil
|
||||
if self.player then self.player.drainFloor = nil end
|
||||
if self.enemy then self.enemy.drainFloor = nil end
|
||||
end
|
||||
@@ -1478,7 +1492,7 @@ function BattleState:updateQueue()
|
||||
return true
|
||||
end
|
||||
if item.drain then
|
||||
self.draining = true
|
||||
self.draining, self.drainOnly = true, item.battler
|
||||
if item.battler then item.battler.drainFloor = item.stopAt end
|
||||
return true
|
||||
end
|
||||
@@ -1618,6 +1632,7 @@ function BattleState:updateQueue()
|
||||
end
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.msgWaiting = nil
|
||||
self:blankArrowCell()
|
||||
self:beginMsgLine()
|
||||
-- then the two ScrollTextUpOneLine calls block for 5 frames each
|
||||
-- (home/text.asm:280-305) before the next line starts typing
|
||||
@@ -1714,6 +1729,7 @@ function BattleState:updateQueue()
|
||||
elseif input:wasPressed("a") or input:wasPressed("b") then
|
||||
-- home/text.asm:218
|
||||
self.msgPrompt = nil
|
||||
self:blankArrowCell()
|
||||
self.msgHold = true
|
||||
self.current = nil
|
||||
end
|
||||
@@ -1941,7 +1957,7 @@ function BattleState:enter()
|
||||
-- battle's DrawEnemyHUDAndHPBar only runs after the text (#317). The
|
||||
-- draw is still gated on the slide having landed, so nothing shows while
|
||||
-- the silhouettes are still coming in.
|
||||
self.introBalls = true
|
||||
self.introBalls = "pending"
|
||||
-- SGB: the player-side battle palette while the back pic is up is
|
||||
-- MonsterPalettes[0] = PAL_MEWMON (wBattleMonSpecies is still 0 when
|
||||
-- the intro's SET_PAL_BATTLE runs -- SetPal_Battle,
|
||||
@@ -1997,7 +2013,17 @@ function BattleState:enter()
|
||||
table.insert(self.queue, { waitSound = function() return self.introSfx end })
|
||||
table.insert(self.queue, { wait = Timing.TRAINER_INTRO_SFX_GAP })
|
||||
end
|
||||
-- engine/battle/common_text.asm:25
|
||||
if not self.ghost and not self.scopeReveal
|
||||
and not (require("src.core.GameVersion").isYellow()
|
||||
and (self.safari or self.demo)) then
|
||||
self:act(function() self.introBalls = true end)
|
||||
end
|
||||
self:say(self.introText)
|
||||
-- engine/battle/common_text.asm:46
|
||||
if self.ghost then
|
||||
self:say(self:romText("_GhostCantBeIDdText", "Darn! The GHOST\ncan't be ID'd!"))
|
||||
end
|
||||
-- the unveil rides on that same box, before _InitBattleCommon clears the
|
||||
-- intro chrome below (#492)
|
||||
if self.scopeReveal then self:queueScopeReveal() end
|
||||
@@ -4553,6 +4579,7 @@ function BattleState:continueTrapping(user, target)
|
||||
-- through the attacker's final hit (endOfTurn nils it)
|
||||
user.trappingTurns = user.trappingTurns - 1
|
||||
self:applyDamage(target, user.trapDamage or 1)
|
||||
self:handleBuildingRage(target)
|
||||
if target.mon.hp <= 0 then self:onFaint(target) end
|
||||
end
|
||||
|
||||
@@ -4573,6 +4600,7 @@ function BattleState:continueBide(user, target)
|
||||
-- here, after UnleashedEnergyText and before the damage (#375)
|
||||
self:animNext("BIDE", user.isPlayer)
|
||||
self:applyDamage(target, dmg)
|
||||
self:handleBuildingRage(target)
|
||||
if target.mon.hp <= 0 then self:onFaint(target) end
|
||||
end
|
||||
|
||||
@@ -4581,7 +4609,7 @@ function BattleState:selfDestruct(user)
|
||||
self:onFaint(user)
|
||||
end
|
||||
|
||||
-- Applies damage honoring Substitute, Bide storage and Rage; returns the
|
||||
-- Applies damage honoring Substitute and Bide storage; returns the
|
||||
-- amount that counts as dealt (for recoil/drain).
|
||||
function BattleState:applyDamage(target, dmg)
|
||||
if target.substituteHP then
|
||||
@@ -4601,13 +4629,28 @@ function BattleState:applyDamage(target, dmg)
|
||||
if target.bideTurns then
|
||||
target.bideDamage = (target.bideDamage or 0) + dealt
|
||||
end
|
||||
if target.rageMove and dealt > 0 then
|
||||
target.stages.attack = math.min(6, (target.stages.attack or 0) + 1)
|
||||
self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target)))
|
||||
end
|
||||
return dealt
|
||||
end
|
||||
|
||||
-- engine/battle/move_effects/recoil.asm:28
|
||||
function BattleState:applyRecoil(user, recoil)
|
||||
local mon = user.mon
|
||||
local before = mon.hp
|
||||
mon.hp = math.max(0, mon.hp - recoil)
|
||||
if mon.hp ~= before then self:drainNext(user, mon.hp) end
|
||||
self:sayNext(self:romText("_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:4913
|
||||
function BattleState:handleBuildingRage(target)
|
||||
if not target.rageMove or target.mon.hp <= 0 then return end
|
||||
if (target.stages.attack or 0) >= 6 then return end
|
||||
self:sayNext(self:romText("_BuildingRageText", "%s's\nRAGE is building!", displayName(target)))
|
||||
for _, m in ipairs(MoveEffects.changeStage(self, target, "attack", 1, false)) do
|
||||
self:sayNext(m)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- fainting / exp / party
|
||||
-- ---------------------------------------------------------------------
|
||||
@@ -5809,14 +5852,20 @@ end
|
||||
-- PlayBattleVictoryMusic (core.asm:959-967) + EndLowHealthAlarm
|
||||
-- (core.asm:864-872): winning stops the low-health alarm and disables
|
||||
-- it for the rest of the battle (wLowHealthAlarmDisabled), then starts
|
||||
-- the victory theme once; gym leaders, Lance and the final rival share
|
||||
-- the victory theme once; gym leaders and the final rival share
|
||||
-- MUSIC_DEFEATED_GYM_LEADER (core.asm:917-926).
|
||||
function BattleState:playVictoryMusic()
|
||||
require("src.core.Sound").stopLoop("Low_Health_Alarm")
|
||||
self.lowHealthAlarmDisabled = true
|
||||
if self.victoryMusicPlayed then return end
|
||||
self.victoryMusicPlayed = true
|
||||
local kind = self.musicKind == "final" and "gym" or (self.musicKind or "wild")
|
||||
local kind = self.musicKind or "wild"
|
||||
-- engine/battle/core.asm:917
|
||||
if kind == "final" or self.isGymLeader then
|
||||
kind = "gym"
|
||||
elseif kind == "gym" then
|
||||
kind = "trainer"
|
||||
end
|
||||
require("src.core.Music").playVictory(self.data, kind)
|
||||
end
|
||||
|
||||
@@ -6885,7 +6934,7 @@ function BattleState:drawHUDs(slide)
|
||||
-- rows coming back when the beaten trainer scrolls in (#282):
|
||||
-- _ScrollTrainerPicAfterBattle redraws tilemap columns and never touches
|
||||
-- OAM, which ClearSprites emptied when the intro text was dismissed.
|
||||
local showIntroBalls = self.introBalls and slide == 0
|
||||
local showIntroBalls = self.introBalls == true and slide == 0
|
||||
if showIntroBalls then
|
||||
if self.enemyParty and (self.kind == "trainer" or self.kind == "link") then
|
||||
-- PlaceEnemyHUDTiles (hlcoord 1,2): $73, then $74 + 8x $76 + $78
|
||||
@@ -6975,10 +7024,15 @@ function BattleState:drawTextArea()
|
||||
-- view instead of drawing off-screen at y=144 (#216).
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { 112, 128 }
|
||||
-- home/text.asm:264
|
||||
local arrowCell = (self.msgWaiting or self.msgPrompt) and off == 0
|
||||
and self:arrowOwnsCell()
|
||||
for li, line in ipairs(self.shown or {}) do
|
||||
local y = (ys[li] or 128) + off
|
||||
for i = 1, #line do
|
||||
drawGlyph(line[i], 8 + (i - 1) * 8, y)
|
||||
if not (arrowCell and li == 2 and i == 18) then
|
||||
drawGlyph(line[i], 8 + (i - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- the blinking down arrow ('▼', glyph $EE) while a \v CONT wait
|
||||
|
||||
@@ -26,6 +26,11 @@ local function missBeat(battle, record)
|
||||
battle:waitNext(Timing.MOVE_STATUS_OR_MISS)
|
||||
end
|
||||
|
||||
-- engine/battle/core.asm:3224
|
||||
local function explodeMissRage(battle, record, target)
|
||||
if record and record.explode then battle:handleBuildingRage(target) end
|
||||
end
|
||||
|
||||
-- pokered's <USER>/<TARGET> text macros print "Enemy " before the enemy
|
||||
-- mon's nickname (home/text.asm PlaceMoveUsersName)
|
||||
local function displayName(b)
|
||||
@@ -131,6 +136,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
-- runs the explode effect ("even if Explosion or Selfdestruct
|
||||
-- missed, its effect still needs to be activated", core.asm:3223),
|
||||
-- so the user faints against a mid-Fly/Dig target too (#528)
|
||||
explodeMissRage(battle, record, target)
|
||||
if record and record.onMiss then record.onMiss(ctx, "invulnerable") end
|
||||
return
|
||||
end
|
||||
@@ -153,6 +159,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(romText(battle.data, "_AttackMissedText", "%s's\nattack missed!", displayName(user)))
|
||||
explodeMissRage(battle, record, target)
|
||||
-- Jump Kick crash, Explode self-destruct
|
||||
if record and record.onMiss then record.onMiss(ctx, "accuracy") end
|
||||
user.trappingTurns = nil
|
||||
@@ -205,6 +212,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(romText(battle.data, "_DoesntAffectMonText", "It doesn't affect\n%s!", displayName(target)))
|
||||
explodeMissRage(battle, record, target)
|
||||
if record and record.onMiss then record.onMiss(ctx, "immune") end
|
||||
return
|
||||
end
|
||||
@@ -213,6 +221,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
if not (record and record.explode) then battle:cancelMoveAnim() end
|
||||
missBeat(battle, record)
|
||||
battle:sayNext(romText(battle.data, "_AttackMissedText", "%s's\nattack missed!", displayName(user)))
|
||||
explodeMissRage(battle, record, target)
|
||||
if record and record.onMiss then record.onMiss(ctx, "floored") end
|
||||
return
|
||||
end
|
||||
@@ -261,6 +270,7 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
local totalDealt = 0
|
||||
local landed, brokeSub = 0, false
|
||||
local critPending, ohkoPending = info.crit, info.ohko
|
||||
local multi = hits > 1
|
||||
for h = 1, hits do
|
||||
if target.mon.hp <= 0 then break end
|
||||
local hitRow
|
||||
@@ -310,6 +320,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
damage = dealt, crit = info.crit, typeMult = info.typeMult,
|
||||
})
|
||||
end
|
||||
-- engine/battle/core.asm:3243
|
||||
if multi then battle:handleBuildingRage(target) end
|
||||
if hadSub and not target.substituteHP then
|
||||
-- AttackSubstitute: breaking the substitute ends a multi-hit move
|
||||
brokeSub = true
|
||||
@@ -317,7 +329,8 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
end
|
||||
end
|
||||
hits = landed > 0 and landed or hits
|
||||
if hits > 1 then
|
||||
-- engine/battle/core.asm:3242
|
||||
if hits > 1 and target.mon.hp > 0 then
|
||||
-- player: _MultiHitText; enemy: _HitXTimesText (always plural)
|
||||
if user.isPlayer then
|
||||
battle:sayNext(romText(battle.data, "_MultiHitText", "Hit the enemy\n%d times!", hits))
|
||||
@@ -330,13 +343,15 @@ function EffectRegistry.runDamaging(battle, ctx, record)
|
||||
ctx.rawDamage, ctx.totalDealt = dmg, totalDealt
|
||||
ctx.brokeSub, ctx.hits = brokeSub, hits
|
||||
ctx.hitSfx = hitSfx
|
||||
local explode = record and record.explode
|
||||
if not multi and explode then battle:handleBuildingRage(target) end
|
||||
if record and record.afterDamage then
|
||||
record.afterDamage(ctx, totalDealt)
|
||||
elseif moveInst.struggle then
|
||||
local recoil = math.max(1, math.floor(totalDealt / 2))
|
||||
battle:sayNext(romText(battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(user)))
|
||||
battle:applyDamage(user, recoil)
|
||||
elseif moveInst.struggle and not brokeSub then
|
||||
battle:applyRecoil(user, math.max(1, math.floor(totalDealt / 2)))
|
||||
end
|
||||
-- engine/battle/core.asm:3243
|
||||
if not multi and not explode then battle:handleBuildingRage(target) end
|
||||
|
||||
-- secondary side effects (blocked by fainting)
|
||||
if record and record.run and record.kind ~= "primary"
|
||||
|
||||
@@ -522,11 +522,14 @@ end
|
||||
-- engine/battle/core.asm ApplyDamageToEnemyPokemon
|
||||
local function drainHalf(label, text)
|
||||
return function(ctx)
|
||||
-- engine/battle/core.asm:4899
|
||||
if ctx.brokeSub then return end
|
||||
local heal = math.max(1, math.floor(ctx.totalDealt / 2))
|
||||
ctx.battle.lastDamage = heal
|
||||
local mon = ctx.user.mon
|
||||
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
|
||||
ctx.drain()
|
||||
-- engine/battle/move_effects/drain_hp.asm:81
|
||||
ctx.battle:drainNext(ctx.user, mon.hp)
|
||||
-- `text` arrives as a source string (Strings.source at the call
|
||||
-- site keeps it in the catalog); the ROM's own line wins when the
|
||||
-- import carries it, and both are resolved here, at use time
|
||||
@@ -606,11 +609,12 @@ MoveEffects.full = {
|
||||
|
||||
RECOIL_EFFECT = {
|
||||
afterDamage = function(ctx)
|
||||
-- engine/battle/core.asm:4899
|
||||
if ctx.brokeSub then return end
|
||||
-- engine/battle/move_effects/recoil.asm
|
||||
local recoil = math.max(1, math.floor(ctx.totalDealt
|
||||
/ (ctx.moveInst.struggle and 2 or 4)))
|
||||
ctx.say(romText(ctx.battle.data, "_HitWithRecoilText", "%s's\nhit with recoil!", displayName(ctx.user)))
|
||||
ctx.battle:applyDamage(ctx.user, recoil)
|
||||
ctx.battle:applyRecoil(ctx.user, recoil)
|
||||
end,
|
||||
},
|
||||
DRAIN_HP_EFFECT = {
|
||||
|
||||
@@ -149,7 +149,7 @@ end
|
||||
-- the party ball rows DrawAllPokeballs puts up with the intro text, moved
|
||||
-- out to the wide screen's own corners
|
||||
local function drawIntroBalls(battle)
|
||||
if not battle.introBalls then return end
|
||||
if battle.introBalls ~= true then return end
|
||||
if battle.enemyParty and
|
||||
(battle.kind == "trainer" or battle.kind == "link") then
|
||||
battle:drawBallRow(battle.enemyParty, 88, 40, -8)
|
||||
|
||||
@@ -362,7 +362,7 @@ function BattleCheckpoint.restore(game, checkpoint, copy)
|
||||
battle.current, battle.afterQueue, battle.nextInsert = nil, nil, nil
|
||||
battle.pendingHit, battle.waitingUI, battle.waitingSound = nil, nil, nil
|
||||
battle.waitFrames, battle.draining, battle.animPlaying = nil, nil, nil
|
||||
battle.hitSfxWait = nil
|
||||
battle.hitSfxWait, battle.drainOnly = nil, nil
|
||||
battle.introText, battle.introBalls, battle.introSlide = nil, nil, nil
|
||||
battle.showPlayerBack, battle.showEnemyTrainer, battle.showEnemyBalls = nil, nil, nil
|
||||
battle.player.shownHP, battle.player.shownStatus =
|
||||
|
||||
+356
-66
@@ -56,17 +56,296 @@ end
|
||||
-- menu) can exclude badges the same way the bag does
|
||||
Bag.isBadge = isBadge
|
||||
|
||||
local MAX_STACK = 99
|
||||
|
||||
local function splits(id, data)
|
||||
if require("src.core.GameVersion").generation() ~= 1 then return false end
|
||||
data = data or require("src.core.Data")
|
||||
local def = data and data.items and data.items[id]
|
||||
return not (def and def.pocket)
|
||||
end
|
||||
|
||||
local function stackList(stacks, id, total)
|
||||
local list = stacks and stacks[id]
|
||||
if type(list) ~= "table" or #list == 0 or type(total) ~= "number" then return nil end
|
||||
local sum = 0
|
||||
for i = 1, #list do
|
||||
local c = list[i]
|
||||
if type(c) ~= "number" or c < 1 or c > MAX_STACK or c % 1 ~= 0 then return nil end
|
||||
sum = sum + c
|
||||
end
|
||||
if sum ~= total then return nil end
|
||||
return list
|
||||
end
|
||||
|
||||
-- engine/items/inventory.asm:59
|
||||
local function slotsFor(id, n, data, stacks)
|
||||
if not n or isBadge(id) then return 0 end
|
||||
if type(n) ~= "number" then return 1 end
|
||||
if n <= 0 then return 0 end
|
||||
if not splits(id, data) then return 1 end
|
||||
local list = stackList(stacks, id, n)
|
||||
if list then return #list end
|
||||
if n <= MAX_STACK then return 1 end
|
||||
return math.ceil(n / MAX_STACK)
|
||||
end
|
||||
Bag.slotsFor = slotsFor
|
||||
|
||||
local function derivedCount(total, k, n)
|
||||
if type(total) ~= "number" or n <= 1 then return total end
|
||||
if k < n then return MAX_STACK end
|
||||
return total - MAX_STACK * (n - 1)
|
||||
end
|
||||
|
||||
local function isDerived(counts)
|
||||
for k = 1, #counts - 1 do
|
||||
if counts[k] ~= MAX_STACK then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function countsOf(store, id, data, stacks)
|
||||
local total = store[id]
|
||||
local n = slotsFor(id, total, data, stacks)
|
||||
local list = stackList(stacks, id, total)
|
||||
local out = {}
|
||||
for k = 1, n do out[k] = (list and list[k]) or derivedCount(total, k, n) end
|
||||
return out
|
||||
end
|
||||
|
||||
local function setCounts(store, id, counts, stacks)
|
||||
local sum = 0
|
||||
for i = 1, #counts do sum = sum + counts[i] end
|
||||
store[id] = sum > 0 and sum or nil
|
||||
if stacks then
|
||||
stacks[id] = (#counts > 1 and not isDerived(counts)) and counts or nil
|
||||
end
|
||||
end
|
||||
|
||||
local function occurrences(order, id)
|
||||
local at = {}
|
||||
for i = 1, #order do
|
||||
if order[i] == id then at[#at + 1] = i end
|
||||
end
|
||||
return at
|
||||
end
|
||||
|
||||
local function byIndex(data)
|
||||
data = data or require("src.core.Data")
|
||||
local items = data and data.items
|
||||
local function key(id)
|
||||
local def = items and items[id]
|
||||
return (def and (def.index or def.itemId)) or math.huge
|
||||
end
|
||||
return function(a, b)
|
||||
local ia, ib = key(a), key(b)
|
||||
if ia ~= ib then
|
||||
if type(ia) == type(ib) then return ia < ib end
|
||||
return tostring(ia) < tostring(ib)
|
||||
end
|
||||
if type(a) == type(b) then return a < b end
|
||||
return tostring(a) < tostring(b)
|
||||
end
|
||||
end
|
||||
|
||||
local function normalize(store, order, data, stacks)
|
||||
if stacks then
|
||||
for id in pairs(stacks) do
|
||||
local list = splits(id, data) and stackList(stacks, id, store[id])
|
||||
if not list or #list < 2 or isDerived(list) then stacks[id] = nil end
|
||||
end
|
||||
end
|
||||
local seen, w = {}, 0
|
||||
for i = 1, #order do
|
||||
local id = order[i]
|
||||
local k = (seen[id] or 0) + 1
|
||||
if k <= slotsFor(id, store[id], data, stacks) then
|
||||
seen[id] = k
|
||||
w = w + 1
|
||||
order[w] = id
|
||||
end
|
||||
end
|
||||
for i = #order, w + 1, -1 do order[i] = nil end
|
||||
local missing = {}
|
||||
for id, n in pairs(store) do
|
||||
for _ = (seen[id] or 0) + 1, slotsFor(id, n, data, stacks) do
|
||||
missing[#missing + 1] = id
|
||||
end
|
||||
end
|
||||
if #missing > 1 then table.sort(missing, byIndex(data)) end
|
||||
for _, id in ipairs(missing) do order[#order + 1] = id end
|
||||
return order
|
||||
end
|
||||
Bag.normalize = normalize
|
||||
|
||||
local function storeSlots(store, data, keep, stacks)
|
||||
local n = 0
|
||||
for id, count in pairs(store or {}) do
|
||||
if not keep or keep(id) then n = n + slotsFor(id, count, data, stacks) end
|
||||
end
|
||||
return n
|
||||
end
|
||||
Bag.storeSlots = storeSlots
|
||||
|
||||
function Bag.stackRows(store, order, data, stacks)
|
||||
local rows, seen = {}, {}
|
||||
for i, id in ipairs(order) do
|
||||
local k = (seen[id] or 0) + 1
|
||||
seen[id] = k
|
||||
local total = store[id]
|
||||
local list = stackList(stacks, id, total)
|
||||
rows[#rows + 1] = { id = id, slot = k, index = i,
|
||||
count = (list and list[k])
|
||||
or derivedCount(total, k, slotsFor(id, total, data, stacks)) }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function Bag.setRows(store, order, rows, data, stacks)
|
||||
local per = {}
|
||||
for i = 1, #rows do
|
||||
local id = rows[i].id
|
||||
order[i] = id
|
||||
if splits(id, data) and type(rows[i].count) == "number" then
|
||||
local counts = per[id] or {}
|
||||
counts[#counts + 1] = rows[i].count
|
||||
per[id] = counts
|
||||
end
|
||||
end
|
||||
for i = #order, #rows + 1, -1 do order[i] = nil end
|
||||
for id, counts in pairs(per) do setCounts(store, id, counts, stacks) end
|
||||
end
|
||||
|
||||
-- engine/items/inventory.asm:7
|
||||
function Bag.addTo(store, order, id, qty, data, used, cap, stacks)
|
||||
qty = qty or 1
|
||||
local old = store[id]
|
||||
if type(old) == "number" and old <= 0 then old = nil end
|
||||
if isBadge(id) then
|
||||
store[id] = (old or 0) + qty
|
||||
return true
|
||||
end
|
||||
if not splits(id, data) then
|
||||
if not old and used >= cap then return false end
|
||||
if (old or 0) + qty > MAX_STACK then return false end
|
||||
store[id] = (old or 0) + qty
|
||||
if not old then order[#order + 1] = id end
|
||||
return true
|
||||
end
|
||||
local free = cap - used
|
||||
local counts = old and countsOf(store, id, data, stacks) or {}
|
||||
local left = qty
|
||||
for k = 1, #counts do
|
||||
local sum = counts[k] + left
|
||||
if sum < MAX_STACK + 1 then
|
||||
counts[k] = sum
|
||||
left = 0
|
||||
break
|
||||
end
|
||||
-- engine/items/inventory.asm:72
|
||||
if free <= 0 then return false end
|
||||
counts[k] = MAX_STACK
|
||||
left = sum - MAX_STACK
|
||||
end
|
||||
while left > 0 do
|
||||
-- engine/items/inventory.asm:44
|
||||
if free <= 0 then return false end
|
||||
counts[#counts + 1] = math.min(left, MAX_STACK)
|
||||
left = left - MAX_STACK
|
||||
end
|
||||
local before = #occurrences(order, id)
|
||||
setCounts(store, id, counts, stacks)
|
||||
for _ = before + 1, #counts do order[#order + 1] = id end
|
||||
return true
|
||||
end
|
||||
|
||||
-- engine/items/inventory.asm:100
|
||||
function Bag.removeFrom(store, order, id, qty, data, slot, stacks)
|
||||
qty = qty or 1
|
||||
local old = store[id]
|
||||
if old ~= nil and type(old) ~= "number" then return end
|
||||
if not old or old <= 0 then
|
||||
store[id] = nil
|
||||
if order then
|
||||
for i = #order, 1, -1 do
|
||||
if order[i] == id then table.remove(order, i) end
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
if isBadge(id) or not splits(id, data) then
|
||||
local left = old - qty
|
||||
store[id] = left > 0 and left or nil
|
||||
if order and not store[id] then
|
||||
for i = #order, 1, -1 do
|
||||
if order[i] == id then table.remove(order, i) end
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
local counts = countsOf(store, id, data, stacks)
|
||||
local k = (slot and counts[slot]) and slot or 1
|
||||
local take = math.min(qty, counts[k])
|
||||
counts[k] = counts[k] - take
|
||||
qty = qty - take
|
||||
for i = 1, #counts do
|
||||
if qty <= 0 then break end
|
||||
take = math.min(qty, counts[i])
|
||||
counts[i] = counts[i] - take
|
||||
qty = qty - take
|
||||
end
|
||||
if order then
|
||||
local at = occurrences(order, id)
|
||||
for i = #at, 1, -1 do
|
||||
if counts[i] ~= nil and counts[i] <= 0 then table.remove(order, at[i]) end
|
||||
end
|
||||
end
|
||||
local kept = {}
|
||||
for i = 1, #counts do
|
||||
if counts[i] > 0 then kept[#kept + 1] = counts[i] end
|
||||
end
|
||||
setCounts(store, id, kept, stacks)
|
||||
end
|
||||
|
||||
-- engine/menus/swap_items.asm:78
|
||||
function Bag.swapSlots(store, order, first, second, data, stacks)
|
||||
if first == second or not order[first] or not order[second] then return false end
|
||||
local rows = Bag.stackRows(store, order, data, stacks)
|
||||
local a, b = rows[first], rows[second]
|
||||
if a.id ~= b.id or type(a.count) ~= "number" or type(b.count) ~= "number"
|
||||
or not splits(a.id, data) then
|
||||
rows[first], rows[second] = b, a
|
||||
else
|
||||
-- engine/menus/swap_items.asm:98
|
||||
local sum = a.count + b.count
|
||||
if sum < MAX_STACK + 1 then
|
||||
b.count = sum
|
||||
table.remove(rows, first)
|
||||
else
|
||||
a.count = sum - MAX_STACK
|
||||
b.count = MAX_STACK
|
||||
end
|
||||
end
|
||||
Bag.setRows(store, order, rows, data, stacks)
|
||||
return true
|
||||
end
|
||||
|
||||
local function stacksOf(save, key)
|
||||
local t = save[key]
|
||||
if type(t) ~= "table" then t = {} end
|
||||
return t
|
||||
end
|
||||
|
||||
local function keepStacks(save, key, t)
|
||||
save[key] = next(t) ~= nil and t or nil
|
||||
end
|
||||
|
||||
-- Occupied slots, of one pocket when named or of the whole inventory when not.
|
||||
function Bag.slots(save, data, pocket)
|
||||
if not save or not save.inventory then return 0 end
|
||||
local n = 0
|
||||
for id in pairs(save.inventory) do
|
||||
if not isBadge(id)
|
||||
and (not pocket or pocketOf(id, data) == pocket) then
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
return storeSlots(save.inventory, data, pocket and function(id)
|
||||
return pocketOf(id, data) == pocket
|
||||
end, save.bagStacks)
|
||||
end
|
||||
|
||||
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
|
||||
@@ -76,40 +355,67 @@ function Bag.order(save, data)
|
||||
save.inventory = save.inventory or {}
|
||||
local order = save.bagOrder
|
||||
if not order then
|
||||
local items = (data or require("src.core.Data")).items
|
||||
order = {}
|
||||
for id in pairs(save.inventory) do
|
||||
if not isBadge(id) then table.insert(order, id) end
|
||||
end
|
||||
table.sort(order, function(a, b)
|
||||
local ia = (items and items[a] and (items[a].index or items[a].itemId)) or math.huge
|
||||
local ib = (items and items[b] and (items[b].index or items[b].itemId)) or math.huge
|
||||
if ia ~= ib then
|
||||
if type(ia) == type(ib) then return ia < ib end
|
||||
return tostring(ia) < tostring(ib)
|
||||
end
|
||||
if type(a) == type(b) then return a < b end
|
||||
return tostring(a) < tostring(b)
|
||||
end)
|
||||
save.bagOrder = order
|
||||
end
|
||||
-- drop stale ids, append unknown ones (defensive against direct
|
||||
-- inventory writes)
|
||||
local seen = {}
|
||||
for i = #order, 1, -1 do
|
||||
local id = order[i]
|
||||
if not save.inventory[id] or seen[id] then
|
||||
table.remove(order, i)
|
||||
else
|
||||
seen[id] = true
|
||||
end
|
||||
end
|
||||
for id in pairs(save.inventory) do
|
||||
if not isBadge(id) and not seen[id] then table.insert(order, id) end
|
||||
end
|
||||
local stacks = stacksOf(save, "bagStacks")
|
||||
normalize(save.inventory, order, data, stacks)
|
||||
keepStacks(save, "bagStacks", stacks)
|
||||
return order
|
||||
end
|
||||
|
||||
function Bag.rows(save, data)
|
||||
if not save then return {} end
|
||||
local order = Bag.order(save, data)
|
||||
return Bag.stackRows(save.inventory, order, data, save.bagStacks)
|
||||
end
|
||||
|
||||
function Bag.swap(save, first, second, data)
|
||||
if not save or not save.inventory then return false end
|
||||
local order = Bag.order(save, data)
|
||||
local stacks = stacksOf(save, "bagStacks")
|
||||
local ok = Bag.swapSlots(save.inventory, order, first, second, data, stacks)
|
||||
keepStacks(save, "bagStacks", stacks)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- ram/wram.asm:1895
|
||||
function Bag.pcOrder(save, data)
|
||||
if not save then return {} end
|
||||
save.pcItems = save.pcItems or {}
|
||||
local order = type(save.pcOrder) == "table" and save.pcOrder or {}
|
||||
save.pcOrder = order
|
||||
local stacks = stacksOf(save, "pcStacks")
|
||||
normalize(save.pcItems, order, data, stacks)
|
||||
keepStacks(save, "pcStacks", stacks)
|
||||
return order
|
||||
end
|
||||
|
||||
function Bag.pcRows(save, data)
|
||||
if not save then return {} end
|
||||
local order = Bag.pcOrder(save, data)
|
||||
return Bag.stackRows(save.pcItems, order, data, save.pcStacks)
|
||||
end
|
||||
|
||||
function Bag.pcAdd(save, id, qty, data, cap)
|
||||
if not save then return false end
|
||||
local order = Bag.pcOrder(save, data)
|
||||
local stacks = stacksOf(save, "pcStacks")
|
||||
local ok = Bag.addTo(save.pcItems, order, id, qty, data,
|
||||
storeSlots(save.pcItems, data, nil, stacks), cap or 50, stacks)
|
||||
keepStacks(save, "pcStacks", stacks)
|
||||
return ok
|
||||
end
|
||||
|
||||
function Bag.pcRemove(save, id, qty, data, slot)
|
||||
if not save or not save.pcItems then return end
|
||||
local stacks = stacksOf(save, "pcStacks")
|
||||
Bag.removeFrom(save.pcItems, save.pcOrder, id, qty, data, slot, stacks)
|
||||
keepStacks(save, "pcStacks", stacks)
|
||||
end
|
||||
|
||||
-- engine/items/switch_items.asm:38 SwitchItemsInBag .below / .above -- the
|
||||
-- rotate the PACK's SELECT performs, over the rows of ONE pocket.
|
||||
function Bag.move(save, id, pocket, toIndex, data)
|
||||
@@ -133,49 +439,33 @@ function Bag.move(save, id, pocket, toIndex, data)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Add qty of an item; returns false (and adds nothing) when a new slot
|
||||
-- is needed and the bag is full, or when the stack would pass 99
|
||||
-- (AddItemToInventory's per-slot quantity cap).
|
||||
-- engine/items/inventory.asm:59
|
||||
function Bag.add(save, id, qty, data)
|
||||
if not save then return false end
|
||||
save.inventory = save.inventory or {}
|
||||
local inv = save.inventory
|
||||
if isBadge(id) then
|
||||
inv[id] = (inv[id] or 0) + (qty or 1)
|
||||
return true
|
||||
end
|
||||
-- Only the item's OWN pocket has to have room -- a full ITEM pocket does not
|
||||
-- keep a KEY_ITEM or an HM out, which is the whole point of pockets.
|
||||
local pocket = pocketOf(id, data)
|
||||
if not inv[id] and not isBadge(id)
|
||||
and Bag.slots(save, data, pocket) >= Bag.capacity(data, pocket) then
|
||||
return false
|
||||
end
|
||||
if not isBadge(id) and (inv[id] or 0) + (qty or 1) > 99 then
|
||||
return false
|
||||
end
|
||||
-- Insert into the order BEFORE the inventory write: Bag.order's defensive
|
||||
-- append reads save.inventory, so writing first made it add the id and the
|
||||
-- table.insert below add it again -- one pickup, two bag rows, until the
|
||||
-- next order() pass deduped it (and a save taken in between kept both).
|
||||
local isNew = not inv[id]
|
||||
if isNew and not isBadge(id) then
|
||||
table.insert(Bag.order(save, data), id)
|
||||
end
|
||||
inv[id] = (inv[id] or 0) + (qty or 1)
|
||||
return true
|
||||
local order = Bag.order(save, data)
|
||||
local stacks = stacksOf(save, "bagStacks")
|
||||
local ok = Bag.addTo(inv, order, id, qty, data,
|
||||
Bag.slots(save, data, pocket), Bag.capacity(data, pocket), stacks)
|
||||
keepStacks(save, "bagStacks", stacks)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Remove qty (default 1); clears the slot and its order entry at zero.
|
||||
function Bag.remove(save, id, qty)
|
||||
-- engine/menus/pc.asm:118
|
||||
function Bag.remove(save, id, qty, data, slot)
|
||||
if not save or not save.inventory then return end
|
||||
local inv = save.inventory
|
||||
inv[id] = (inv[id] or 0) - (qty or 1)
|
||||
if inv[id] <= 0 then
|
||||
inv[id] = nil
|
||||
local order = save.bagOrder
|
||||
if order then
|
||||
for i, oid in ipairs(order) do
|
||||
if oid == id then table.remove(order, i) break end
|
||||
end
|
||||
end
|
||||
end
|
||||
local stacks = stacksOf(save, "bagStacks")
|
||||
Bag.removeFrom(save.inventory, save.bagOrder, id, qty, data, slot, stacks)
|
||||
keepStacks(save, "bagStacks", stacks)
|
||||
end
|
||||
|
||||
return Bag
|
||||
|
||||
@@ -159,9 +159,52 @@ function Evolution.learnEvolutionMoves(game, mon, onDone)
|
||||
nextStep()
|
||||
end
|
||||
|
||||
local function clearScreenLayer()
|
||||
local layer = { isOpaque = true, evoClear = true }
|
||||
layer.update = function() end
|
||||
layer.draw = function()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
end
|
||||
-- engine/movie/evolution.asm:68
|
||||
layer.sgbPalettes = function(_, g)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local c = layer.species and P.monPal(g.data, layer.species)
|
||||
if c then return { P.whole(c) } end
|
||||
return P.wholeNamed(g.data, "MEWMON")
|
||||
end
|
||||
return layer
|
||||
end
|
||||
|
||||
-- engine/pokemon/evos_moves.asm:156
|
||||
function Evolution.clearScreen(game, species)
|
||||
while game.stack:top() and game.stack:top().evoIntro do game.stack:pop() end
|
||||
local top = game.stack:top()
|
||||
if not (top and top.evoClear) then
|
||||
top = clearScreenLayer()
|
||||
game.stack:push(top)
|
||||
end
|
||||
if species then top.species = species end
|
||||
end
|
||||
|
||||
local function dropClearScreen(game)
|
||||
local top = game.stack:top()
|
||||
while top and (top.evoClear or top.evoIntro) do
|
||||
game.stack:pop()
|
||||
top = game.stack:top()
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/pokemon/evos_moves.asm:245
|
||||
local function finishEvolution(game)
|
||||
dropClearScreen(game)
|
||||
-- engine/link/cable_club.asm:290
|
||||
Music.restoreMap(game.data)
|
||||
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)
|
||||
function Evolution.evolve(game, mon, newSpecies, onDone, via, batch)
|
||||
local oldName = mon.nickname or game.data.pokemon[mon.species].name
|
||||
-- IsEvolvingText, DelayFrames 50; ClearScreenArea then wipes rows 0-11
|
||||
-- ONLY, so the box rides through EvolveMon (evos_moves.asm:120-134)
|
||||
@@ -181,8 +224,11 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
-- forward `via` so trade evolutions stay non-cancelable while
|
||||
-- others accept B (evos_moves.asm:72-75) (#213)
|
||||
Screens.push(game, "EvolutionState", mon, newSpecies, function()
|
||||
-- the result/cancel box owns the intro box's pop (#1596)
|
||||
if game.stack:top() == intro then game.stack:pop() end
|
||||
if batch then
|
||||
Evolution.clearScreen(game)
|
||||
else
|
||||
finishEvolution(game)
|
||||
end
|
||||
if onDone then onDone() end
|
||||
end, via)
|
||||
end
|
||||
@@ -190,6 +236,7 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
game.stack:push(hold)
|
||||
end,
|
||||
} })
|
||||
intro.evoIntro = true
|
||||
game.stack:push(intro)
|
||||
return
|
||||
end
|
||||
@@ -201,10 +248,12 @@ function Evolution.evolve(game, mon, newSpecies, onDone, via)
|
||||
.. 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)
|
||||
Evolution.learnEvolutionMoves(game, mon, function()
|
||||
if not batch then finishEvolution(game) end
|
||||
if onDone then onDone() end
|
||||
end)
|
||||
end, TextBox.soundOpts(game, "Get_Item2")))
|
||||
end
|
||||
|
||||
@@ -245,10 +294,11 @@ function Evolution.checkParty(game, onDone, leveledUp)
|
||||
i = i + 1
|
||||
local p = pending[i]
|
||||
if not p then
|
||||
dropClearScreen(game)
|
||||
if onDone then onDone() end
|
||||
return
|
||||
end
|
||||
Evolution.evolve(game, p.mon, p.to, nextOne, p.via)
|
||||
Evolution.evolve(game, p.mon, p.to, nextOne, p.via, true)
|
||||
end
|
||||
nextOne()
|
||||
return #pending
|
||||
|
||||
@@ -251,6 +251,12 @@ function PaletteFX.ogObjNormal()
|
||||
return OG_OBJ_NORMAL, "gbcobjnormal_soft"
|
||||
end
|
||||
|
||||
-- home/fade.asm:68
|
||||
function PaletteFX.ogObjWorld()
|
||||
if darkWorld or fadeObpMap then return PaletteFX.ogObj() end
|
||||
return PaletteFX.ogObjNormal()
|
||||
end
|
||||
|
||||
-- The DMG object ramp every mode except OG RED bakes onto overworld sprites,
|
||||
-- plus its cache group (same two-value contract as ogObj). Entry 1 is never
|
||||
-- read -- SpriteRenderer.getObpImage keys OBJ color 0 to alpha, the hardware's
|
||||
|
||||
@@ -319,10 +319,10 @@ function SpriteRenderer:resolveImage()
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then return getObpImage(self.def.image, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() then
|
||||
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObj
|
||||
-- OG boot-ROM OBJ palette: green on Red, pink on Blue (PaletteFX.ogObjWorld
|
||||
-- returns colors + a version-distinct cache group so the two never
|
||||
-- collide in obpCache) -- see issue #155
|
||||
return getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
return getObpImage(self.def.image, PaletteFX.ogObjWorld())
|
||||
end
|
||||
-- Every other mode (SGB and the mono/inverted novelties) leaves the sprite
|
||||
-- in DMG shades so the zone shader colors it out of the map's own palette,
|
||||
@@ -435,11 +435,11 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
-- OG RED (GBC boot-ROM look): every OBJ wears the one global object
|
||||
-- palette -- green over Red's red background, pink over Blue's blue
|
||||
-- background (PaletteFX.ogObj, #155). The BG zone shader still runs over
|
||||
-- background (PaletteFX.ogObjWorld, #155). The BG zone shader still runs over
|
||||
-- the world canvas, so the baked sprite is queued for a post-zone redraw
|
||||
-- (PaletteFX.markSpriteRedraw) that restores its object-colored pixels on
|
||||
-- top.
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObj())
|
||||
image = getObpImage(self.def.image, PaletteFX.ogObjWorld())
|
||||
redraw = true
|
||||
else
|
||||
-- SGB and the mono/inverted modes (and OG RED's tilt upright pass, which
|
||||
@@ -529,7 +529,7 @@ function SpriteRenderer:drawTile(path, x, y, flip, quad)
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then image = getObpImage(path, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
image, redraw = getObpImage(path, PaletteFX.ogObj()), true
|
||||
image, redraw = getObpImage(path, PaletteFX.ogObjWorld()), true
|
||||
else
|
||||
image = getObpImage(path, PaletteFX.dmgObj())
|
||||
end
|
||||
|
||||
+25
-1
@@ -444,6 +444,22 @@ function TextBox:arrowPos()
|
||||
gold and (self.boxTy + self.boxTh - 1) * 8 or self.line2Y
|
||||
end
|
||||
|
||||
-- home/text.asm:270
|
||||
function TextBox:blankArrowCell()
|
||||
if not self.fixedGen1Rows then return end
|
||||
local line = self.shown[#self.shown]
|
||||
if not line or #self.shown < 2 then return end
|
||||
local ax = self:arrowPos()
|
||||
local pen = self.textX
|
||||
for i, code in ipairs(line) do
|
||||
if pen == ax then
|
||||
line[i] = 0x7F
|
||||
return
|
||||
end
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
|
||||
-- scripts/MtMoonPokecenter.asm:30
|
||||
function TextBox:moneyVisible()
|
||||
if not self.money then return false end
|
||||
@@ -640,6 +656,7 @@ function step(self, dt)
|
||||
if self.contAdvance then
|
||||
-- ContText / ManualTextScroll: keep the box, scroll one line
|
||||
self.contAdvance = false
|
||||
self:blankArrowCell()
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
self:beginLine()
|
||||
-- ScrollTextUpOneLine is 5 blocking frames and, as its own comment
|
||||
@@ -760,13 +777,20 @@ function TextBox:draw()
|
||||
-- nothing in the original is ever drawn between two rows.
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { self.line1Y, self.line2Y }
|
||||
-- home/text.asm:264
|
||||
local coverX, coverY
|
||||
if self.fixedGen1Rows and self:arrowVisible() then
|
||||
coverX, coverY = arrowX, arrowY
|
||||
end
|
||||
for i, line in ipairs(self.shown) do
|
||||
local y = (ys[i] or self.line2Y) + (i == 1 and off or 0)
|
||||
-- the pen advances per glyph, matching the pixel budget paginate
|
||||
-- measured with; every fixed-width page still lands on the 8px grid
|
||||
local pen = self.textX
|
||||
for _, code in ipairs(line) do
|
||||
drawGlyph(code, pen, y)
|
||||
if not (pen == coverX and y == coverY) then
|
||||
drawGlyph(code, pen, y)
|
||||
end
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
end
|
||||
|
||||
+79
-31
@@ -733,48 +733,92 @@ local function getKeyedTile(self, tile)
|
||||
return img or nil
|
||||
end
|
||||
|
||||
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
local ty = cy * 2 + 1
|
||||
for i = 0, 1 do
|
||||
local tx = cx * 2 + i
|
||||
local tile = self.map:tileAt(tx, ty)
|
||||
local keyed = tile and self.gbcCtx and getKeyedTile(self, tile)
|
||||
if keyed then
|
||||
love.graphics.draw(keyed, tx * 8 - camX, ty * 8 - camY)
|
||||
else
|
||||
local quad = self.quads[tile]
|
||||
if quad then
|
||||
love.graphics.draw(self.image, quad, tx * 8 - camX, ty * 8 - camY)
|
||||
end
|
||||
-- data/sprites/facings.asm:51
|
||||
-- engine/gfx/sprite_oam.asm:127
|
||||
function TileRenderer.feetStrip(px, py, sprite)
|
||||
local fw = sprite and sprite.frameWidth or 16
|
||||
local fh = sprite and sprite.frameHeight or 16
|
||||
local ax = sprite and sprite.anchorX or fw / 2
|
||||
local ay = sprite and sprite.anchorY or fh
|
||||
local x0 = math.floor(px + 8 - ax)
|
||||
local top = math.floor(py + 12 - ay)
|
||||
local y0 = top + math.max(0, fh - 8)
|
||||
return x0, y0, x0 + fw, y0 + math.min(8, fh)
|
||||
end
|
||||
|
||||
function TileRenderer.eachStripSpan(x0, y0, x1, y1, fn, a, b, c, d)
|
||||
for ty = math.floor(y0 / 8), math.floor((y1 - 1) / 8) do
|
||||
local top, bottom = math.max(y0, ty * 8), math.min(y1, ty * 8 + 8)
|
||||
for tx = math.floor(x0 / 8), math.floor((x1 - 1) / 8) do
|
||||
local left, right = math.max(x0, tx * 8), math.min(x1, tx * 8 + 8)
|
||||
fn(tx, ty, left, top, left - tx * 8, top - ty * 8,
|
||||
right - left, bottom - top, a, b, c, d)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- redraw a cell's bottom tile row (tall grass hides the lower half of
|
||||
-- sprites standing in it, like the GB sprite-priority trick)
|
||||
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||
local function stripQuad(self, image, tile, ox, oy, w, h)
|
||||
local cache = self.stripQuads
|
||||
if not cache then
|
||||
cache = {}
|
||||
self.stripQuads = cache
|
||||
end
|
||||
local sub = ((ox * 9 + w) * 8 + oy) * 9 + h
|
||||
local key = image == self.image and tile * 6000 + sub or -1 - sub
|
||||
local q = cache[key]
|
||||
if q then return q end
|
||||
local iw, ih = image:getDimensions()
|
||||
local sx, sy = 0, 0
|
||||
if image == self.image then
|
||||
local perRow = self.map.tileset.tilesPerRow
|
||||
sx, sy = (tile % perRow) * 8, math.floor(tile / perRow) * 8
|
||||
end
|
||||
q = love.graphics.newQuad(sx + ox, sy + oy, w, h, iw, ih)
|
||||
cache[key] = q
|
||||
return q
|
||||
end
|
||||
|
||||
local function drawStripTile(tx, ty, x, y, ox, oy, w, h, self, camX, camY)
|
||||
local tile = self.map:tileAt(tx, ty)
|
||||
if not (tile and self.quads[tile]) then return end
|
||||
local img = self.gbcCtx and getKeyedTile(self, tile) or self.image
|
||||
love.graphics.draw(img, stripQuad(self, img, tile, ox, oy, w, h),
|
||||
x - camX, y - camY)
|
||||
end
|
||||
|
||||
local function markStripTile(tx, ty, x, y, ox, oy, w, h, self, camX, camY, colors)
|
||||
local tile = self.map:tileAt(tx, ty)
|
||||
if not (tile and self.quads[tile]) then return end
|
||||
PaletteFX.markSpriteRedraw(self.image,
|
||||
stripQuad(self, self.image, tile, ox, oy, w, h),
|
||||
x - math.floor(camX), y - math.floor(camY),
|
||||
1, colors, true)
|
||||
end
|
||||
|
||||
function TileRenderer:drawStripRaw(x0, y0, x1, y1, camX, camY)
|
||||
TileRenderer.eachStripSpan(x0, y0, x1, y1, drawStripTile, self, camX, camY)
|
||||
end
|
||||
|
||||
function TileRenderer:drawStrip(x0, y0, x1, y1, camX, camY)
|
||||
-- the RED++ path is pre-keyed; the white test would be a no-op there at
|
||||
-- best, and a false hit on some other group's near-white color 0 at worst
|
||||
local shader = not self.gbcCtx and getColor0KeyShader() or nil
|
||||
if shader then love.graphics.setShader(shader) end
|
||||
self:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
self:drawStripRaw(x0, y0, x1, y1, camX, camY)
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
|
||||
-- queue the same bottom tile row for the post-zone sprite-redraw pass
|
||||
-- (GBC mode: OBP-baked sprites replay after the zone shader, so the
|
||||
-- grass patch that hides their feet must replay over them, colorized
|
||||
-- with the map's palette and color-0 keyed)
|
||||
function TileRenderer:markCellBottomRedraw(cx, cy, camX, camY, colors)
|
||||
local ty = cy * 2 + 1
|
||||
for i = 0, 1 do
|
||||
local tx = cx * 2 + i
|
||||
local quad = self.quads[self.map:tileAt(tx, ty)]
|
||||
if quad then
|
||||
PaletteFX.markSpriteRedraw(self.image, quad, tx * 8 - math.floor(camX),
|
||||
ty * 8 - math.floor(camY), 1, colors, true)
|
||||
end
|
||||
end
|
||||
function TileRenderer:markStripRedraw(x0, y0, x1, y1, camX, camY, colors)
|
||||
TileRenderer.eachStripSpan(x0, y0, x1, y1, markStripTile,
|
||||
self, camX, camY, colors)
|
||||
end
|
||||
|
||||
function TileRenderer:drawCellBottomRaw(cx, cy, camX, camY)
|
||||
self:drawStripRaw(cx * 16, cy * 16 + 8, cx * 16 + 16, cy * 16 + 16, camX, camY)
|
||||
end
|
||||
|
||||
function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||
self:drawStrip(cx * 16, cy * 16 + 8, cx * 16 + 16, cy * 16 + 16, camX, camY)
|
||||
end
|
||||
|
||||
|
||||
@@ -1024,6 +1068,10 @@ function TileRenderer:releaseBatches()
|
||||
for _, q in pairs(self.quads) do safeRelease(q) end
|
||||
self.quads = nil
|
||||
end
|
||||
if self.stripQuads then
|
||||
for _, q in pairs(self.stripQuads) do safeRelease(q) end
|
||||
self.stripQuads = nil
|
||||
end
|
||||
if self.anims then
|
||||
for _, a in ipairs(self.anims) do
|
||||
safeRelease(a.batch); a.batch = nil
|
||||
|
||||
@@ -845,43 +845,91 @@ local function decodeItemRows(bytes, off, capacity)
|
||||
return rows
|
||||
end
|
||||
|
||||
local function stackCounts(stacks, id, qty)
|
||||
local list = type(stacks) == "table" and stacks[id]
|
||||
if type(list) ~= "table" or #list < 2 then return nil end
|
||||
local sum = 0
|
||||
for i = 1, #list do
|
||||
local c = list[i]
|
||||
if type(c) ~= "number" or c < 1 or c > 99 or c % 1 ~= 0 then return nil end
|
||||
sum = sum + c
|
||||
end
|
||||
if sum ~= qty then return nil end
|
||||
return list
|
||||
end
|
||||
|
||||
local function foldRows(rows, cw)
|
||||
local inventory, order = {}, {}
|
||||
local inventory, order, counts = {}, {}, {}
|
||||
for _, r in ipairs(rows) do
|
||||
local id = cw.itemsByIndex[r[1]]
|
||||
if id and not BADGE_BY_BIT_SET[id] and r[2] > 0 then
|
||||
if not inventory[id] then order[#order + 1] = id end
|
||||
order[#order + 1] = id
|
||||
inventory[id] = (inventory[id] or 0) + r[2]
|
||||
local list = counts[id] or {}
|
||||
list[#list + 1] = r[2]
|
||||
counts[id] = list
|
||||
end
|
||||
end
|
||||
return inventory, order
|
||||
local stacks = {}
|
||||
for id, list in pairs(counts) do
|
||||
local derived = true
|
||||
for k = 1, #list - 1 do
|
||||
if list[k] ~= 99 then derived = false end
|
||||
end
|
||||
if not derived and stackCounts({ [id] = list }, id, inventory[id]) then stacks[id] = list end
|
||||
end
|
||||
return inventory, order, next(stacks) ~= nil and stacks or nil
|
||||
end
|
||||
|
||||
local function modelRows(inventory, order, capacity, cw)
|
||||
local rows, seen, ids, rest = {}, {}, {}, {}
|
||||
for _, id in ipairs(type(order) == "table" and order or {}) do
|
||||
if not seen[id] then seen[id] = true; ids[#ids + 1] = id end
|
||||
-- engine/items/inventory.asm:64
|
||||
local function modelRows(inventory, order, capacity, cw, stacks)
|
||||
inventory = inventory or {}
|
||||
local need, lists = {}, {}
|
||||
local function slots(id)
|
||||
if need[id] == nil then
|
||||
local qty = tonumber(inventory[id])
|
||||
qty = qty and math.floor(qty) or 0
|
||||
lists[id] = stackCounts(stacks, id, qty)
|
||||
need[id] = (qty > 0 and cw.itemsIndex[id] and not BADGE_BY_BIT_SET[id])
|
||||
and (lists[id] and #lists[id] or math.ceil(qty / 99)) or 0
|
||||
end
|
||||
return need[id]
|
||||
end
|
||||
for id in pairs(inventory or {}) do
|
||||
if not seen[id] then rest[#rest + 1] = id end
|
||||
local kept, have = {}, {}
|
||||
for _, id in ipairs(type(order) == "table" and order or {}) do
|
||||
if (have[id] or 0) < slots(id) then
|
||||
have[id] = (have[id] or 0) + 1
|
||||
kept[#kept + 1] = id
|
||||
end
|
||||
end
|
||||
local ids, at = {}, {}
|
||||
for _, id in ipairs(kept) do
|
||||
at[id] = (at[id] or 0) + 1
|
||||
ids[#ids + 1] = id
|
||||
if at[id] == have[id] then
|
||||
for _ = have[id] + 1, slots(id) do ids[#ids + 1] = id end
|
||||
end
|
||||
end
|
||||
local rest = {}
|
||||
for id in pairs(inventory) do
|
||||
if not have[id] and slots(id) > 0 then rest[#rest + 1] = id end
|
||||
end
|
||||
table.sort(rest, function(a, b)
|
||||
local ia, ib = cw.itemsIndex[a] or 1e9, cw.itemsIndex[b] or 1e9
|
||||
if ia ~= ib then return ia < ib end
|
||||
return tostring(a) < tostring(b)
|
||||
end)
|
||||
for _, id in ipairs(rest) do ids[#ids + 1] = id end
|
||||
for _, id in ipairs(rest) do
|
||||
for _ = 1, slots(id) do ids[#ids + 1] = id end
|
||||
end
|
||||
local rows, k = {}, {}
|
||||
for _, id in ipairs(ids) do
|
||||
local qty = tonumber((inventory or {})[id])
|
||||
local idx = cw.itemsIndex[id]
|
||||
if qty and qty > 0 and idx and not BADGE_BY_BIT_SET[id] then
|
||||
qty = math.floor(qty)
|
||||
while qty > 0 and #rows < capacity do
|
||||
local q = math.min(qty, 99)
|
||||
rows[#rows + 1] = { idx, q }
|
||||
qty = qty - q
|
||||
end
|
||||
end
|
||||
if #rows >= capacity then break end
|
||||
k[id] = (k[id] or 0) + 1
|
||||
local n = slots(id)
|
||||
local q = lists[id] and lists[id][k[id]]
|
||||
or (k[id] < n and 99 or math.floor(tonumber(inventory[id])) - 99 * (n - 1))
|
||||
rows[#rows + 1] = { cw.itemsIndex[id], q }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
@@ -904,14 +952,14 @@ end
|
||||
|
||||
local function decodeItems(bytes, off, capacity, cw)
|
||||
local rows = decodeItemRows(bytes, off, capacity)
|
||||
local inventory, order = foldRows(rows, cw)
|
||||
local inventory, order, stacks = foldRows(rows, cw)
|
||||
local carrier = nil
|
||||
if not sameRows(modelRows(inventory, order, capacity, cw), rows) then carrier = rows end
|
||||
return inventory, order, carrier
|
||||
if not sameRows(modelRows(inventory, order, capacity, cw, stacks), rows) then carrier, stacks = rows, nil end
|
||||
return inventory, order, carrier, stacks
|
||||
end
|
||||
|
||||
local function encodeItems(buf, countOff, off, capacity, inventory, order, carrier, cw)
|
||||
local rows = modelRows(inventory, order, capacity, cw)
|
||||
local function encodeItems(buf, countOff, off, capacity, inventory, order, carrier, cw, stacks)
|
||||
local rows = modelRows(inventory, order, capacity, cw, stacks)
|
||||
carrier = validRows(carrier)
|
||||
if carrier then
|
||||
local inv, ord = foldRows(carrier, cw)
|
||||
@@ -1023,8 +1071,8 @@ function GenSave.decode(bytes, data, opts)
|
||||
if bitGet(bytes, O.pokedexSeen, bitIdx) then save.pokedex.seen[species] = true end
|
||||
end
|
||||
|
||||
save.inventory, save.bagOrder, save.cartBag = decodeItems(bytes, O.bagItems, 20, cw)
|
||||
save.pcItems, save.pcOrder, save.cartPc = decodeItems(bytes, O.pcItems, 50, cw)
|
||||
save.inventory, save.bagOrder, save.cartBag, save.bagStacks = decodeItems(bytes, O.bagItems, 20, cw)
|
||||
save.pcItems, save.pcOrder, save.cartPc, save.pcStacks = decodeItems(bytes, O.pcItems, 50, cw)
|
||||
|
||||
-- badges: truthy save.inventory[id] entries (src/inventory/Badges.lua),
|
||||
-- set AFTER the bag decode since that call replaces save.inventory
|
||||
@@ -1424,8 +1472,8 @@ function GenSave.encode(save, data, template)
|
||||
-- wBagItems (they only ever live in wObtainedBadges, already encoded
|
||||
-- above), so they must be filtered out here or they'd corrupt the bag
|
||||
-- with bogus "badge items".
|
||||
encodeItems(buf, O.numBagItems, O.bagItems, 20, save.inventory or {}, save.bagOrder, save.cartBag, cw)
|
||||
encodeItems(buf, O.numPcItems, O.pcItems, 50, save.pcItems or {}, save.pcOrder, save.cartPc, cw)
|
||||
encodeItems(buf, O.numBagItems, O.bagItems, 20, save.inventory or {}, save.bagOrder, save.cartBag, cw, save.bagStacks)
|
||||
encodeItems(buf, O.numPcItems, O.pcItems, 50, save.pcItems or {}, save.pcOrder, save.cartPc, cw, save.pcStacks)
|
||||
|
||||
local flags = type(save.flags) == "table" and save.flags or {}
|
||||
local events = data.eventFlags
|
||||
|
||||
+45
-21
@@ -19,7 +19,8 @@ local BAG_RETURN_WHITE = 19
|
||||
-- acquisition order like wBagItems (Bag.order), not alphabetical
|
||||
local function buildItems(game)
|
||||
local items = {}
|
||||
for _, id in ipairs(Bag.order(game.save)) do
|
||||
for _, row in ipairs(Bag.rows(game.save, game.data)) do
|
||||
local id = row.id
|
||||
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)
|
||||
@@ -27,7 +28,9 @@ local function buildItems(game)
|
||||
table.insert(items, {
|
||||
value = id,
|
||||
label = def and def.name or id,
|
||||
count = (not unsellable) and game.save.inventory[id] or nil,
|
||||
count = (not unsellable) and row.count or nil,
|
||||
slot = row.slot,
|
||||
stack = row.count,
|
||||
})
|
||||
end
|
||||
-- the $ff terminator's row: CANCEL is selectable and exits like B
|
||||
@@ -37,9 +40,12 @@ local function buildItems(game)
|
||||
end
|
||||
|
||||
local function consume(game, id, list)
|
||||
Bag.remove(game.save, id, 1)
|
||||
local before = #Bag.rows(game.save, game.data)
|
||||
local row = list and list.items and list.items[list.index]
|
||||
local slot = row and row.value == id and row.slot or nil
|
||||
Bag.remove(game.save, id, 1, game.data, slot)
|
||||
-- engine/items/inventory.asm:131-136
|
||||
if not game.save.inventory[id] then
|
||||
if #Bag.rows(game.save, game.data) < before then
|
||||
game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0
|
||||
if list then list.index, list.scroll = 1, 0 end
|
||||
end
|
||||
@@ -362,13 +368,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
local function removeUsed()
|
||||
consume(game, id, list)
|
||||
-- refresh counts in the list
|
||||
for i, it in ipairs(list.items) do
|
||||
if it.value == id then
|
||||
local left = game.save.inventory[id]
|
||||
if left then it.count = left else table.remove(list.items, i) end
|
||||
break
|
||||
end
|
||||
end
|
||||
list.items = buildItems(game)
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
end
|
||||
if not battle and not target and not picker then
|
||||
@@ -516,6 +516,8 @@ local function useOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
|
||||
local function pickTargetAndUse(game, battle, id, list)
|
||||
-- engine/menus/start_sub_menus.asm:416
|
||||
if list then list.optionBox = nil end
|
||||
-- pick a target from the party
|
||||
-- the ETHERs and PP UP open the move menu after picking a mon
|
||||
-- (ItemUsePPRestore / ItemUsePPUp); the ELIXERs hit every move
|
||||
@@ -616,11 +618,13 @@ function BagMenu.new(game, opts)
|
||||
l.swapIndex = l.index
|
||||
return
|
||||
end
|
||||
local order = Bag.order(game.save)
|
||||
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
|
||||
-- engine/menus/swap_items.asm:45
|
||||
if l.swapIndex == l.index then return end
|
||||
Bag.swap(game.save, l.swapIndex, l.index, game.data)
|
||||
l.swapIndex = nil
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
l.items = buildItems(game)
|
||||
l.index = math.max(1, math.min(l.index, #l.items))
|
||||
end,
|
||||
onChoose = function(item)
|
||||
-- A on CANCEL leaves the list exactly like B (home/list_menu.asm:105-110)
|
||||
@@ -632,11 +636,13 @@ function BagMenu.new(game, opts)
|
||||
local id = item.value
|
||||
local def = game.data.items[id]
|
||||
if list.swapIndex then -- A also completes a pending swap
|
||||
local order = Bag.order(game.save)
|
||||
order[list.swapIndex], order[list.index] = order[list.index], order[list.swapIndex]
|
||||
-- engine/menus/swap_items.asm:45
|
||||
if list.swapIndex == list.index then return end
|
||||
Bag.swap(game.save, list.swapIndex, list.index, game.data)
|
||||
list.swapIndex = nil
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
list.items = buildItems(game)
|
||||
list.index = math.max(1, math.min(list.index, #list.items))
|
||||
return
|
||||
end
|
||||
if battle then -- no tossing mid-battle
|
||||
@@ -659,8 +665,12 @@ function BagMenu.new(game, opts)
|
||||
-- (engine/menus/start_sub_menus.asm:330-337; home/window.asm:193-206)
|
||||
list.hollowIndex = list.index
|
||||
local Menu = require("src.ui.Menu")
|
||||
game.stack:push(Menu.new(game, {
|
||||
local optionBox
|
||||
optionBox = Menu.new(game, {
|
||||
{ label = Strings("USE"), onSelect = function()
|
||||
-- engine/menus/start_sub_menus.asm:362
|
||||
optionBox.hollowIndex = optionBox.index
|
||||
list.optionBox = optionBox
|
||||
useItem(game, battle, id, list)
|
||||
end },
|
||||
{ label = Strings("TOSS"), keepOpen = true, onSelect = function()
|
||||
@@ -670,12 +680,13 @@ function BagMenu.new(game, opts)
|
||||
-- engine/menus/start_sub_menus.asm:298-300, 438-439
|
||||
local function itemMenuLoop(pops)
|
||||
for _ = 1, pops do game.stack:pop() end
|
||||
if not game.save.inventory[id] then
|
||||
local fresh = buildItems(game)
|
||||
if #fresh < #list.items then
|
||||
-- engine/items/inventory.asm:131
|
||||
game.bagSavedMenuItem, game.bagListScrollOffset = 0, 0
|
||||
list.index, list.scroll = 1, 0
|
||||
end
|
||||
list.items = buildItems(game)
|
||||
list.items = fresh
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
end
|
||||
-- KeyItemFlags + HMs decide tossability (not price:
|
||||
@@ -689,7 +700,7 @@ function BagMenu.new(game, opts)
|
||||
end
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
game.stack:push(QuantityBox.new(game, {
|
||||
max = game.save.inventory[id] or 1,
|
||||
max = item.stack or game.save.inventory[id] or 1,
|
||||
keepOpen = true,
|
||||
onDone = function(qty)
|
||||
if not qty then itemMenuLoop(2) return end
|
||||
@@ -703,7 +714,7 @@ function BagMenu.new(game, opts)
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
game.stack:pop()
|
||||
if not yes then itemMenuLoop(2) return end
|
||||
Bag.remove(game.save, id, qty)
|
||||
Bag.remove(game.save, id, qty, game.data, item.slot)
|
||||
showMessages(game, {
|
||||
((t._ThrewAwayItemText or Strings("Threw away\n%s.", name))
|
||||
:gsub("{RAM:wNameBuffer}", name)) },
|
||||
@@ -713,7 +724,8 @@ function BagMenu.new(game, opts)
|
||||
end,
|
||||
}))
|
||||
end },
|
||||
}, { tx = 13, ty = 10, tw = 7, th = 5 }))
|
||||
}, { tx = 13, ty = 10, tw = 7, th = 5 })
|
||||
game.stack:push(optionBox)
|
||||
end,
|
||||
})
|
||||
-- reopen on the saved cursor: engine/battle/core.asm:2230-2234 and
|
||||
@@ -727,10 +739,22 @@ function BagMenu.new(game, opts)
|
||||
end
|
||||
local baseUpdate = list.update
|
||||
list.update = function(self, dt)
|
||||
self.optionBox = nil
|
||||
baseUpdate(self, dt)
|
||||
game.bagListScrollOffset = self.scroll
|
||||
game.bagSavedMenuItem = self.index - self.scroll - 1
|
||||
end
|
||||
local baseDraw = list.draw
|
||||
list.draw = function(self)
|
||||
-- engine/menus/start_sub_menus.asm:299
|
||||
if game.stack:top() == self then
|
||||
self.optionBox = nil
|
||||
self.hollowIndex = nil
|
||||
end
|
||||
baseDraw(self)
|
||||
-- engine/menus/start_sub_menus.asm:298
|
||||
if self.optionBox then self.optionBox:draw() end
|
||||
end
|
||||
list.closeStartMenu = opts.onClose
|
||||
-- the item box overlaps the kept-open START menu box, so neither docks to
|
||||
-- a screen edge on its own (start_sub_menus.asm:302-329) #1745
|
||||
|
||||
+78
-27
@@ -6,6 +6,7 @@
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Font = require("src.render.Font")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
@@ -14,6 +15,7 @@ local Strings = require("src.core.Strings")
|
||||
-- engine/events/diploma.asm:65
|
||||
local OBP0_90 = { { 255, 255, 255 }, { 255, 255, 255 },
|
||||
{ 170, 170, 170 }, { 85, 85, 85 } }
|
||||
local PIC_X, PIC_Y = 115, 80
|
||||
|
||||
local Diploma = {}
|
||||
Diploma.__index = Diploma
|
||||
@@ -69,6 +71,57 @@ local function drawFrameBox(frame, tx, ty, tw, th)
|
||||
end
|
||||
end
|
||||
|
||||
local function playerName(game)
|
||||
return (game.save.player and game.save.player.name) or "RED"
|
||||
end
|
||||
|
||||
local function drawLines(game)
|
||||
-- engine/events/diploma.asm:95
|
||||
Font.draw(Strings("Player"), 24, 32)
|
||||
-- engine/events/diploma.asm:39
|
||||
Font.draw(playerName(game), 80, 32)
|
||||
|
||||
-- engine/events/diploma.asm:97
|
||||
local congrats = {
|
||||
{ text = Strings.source("Congrats! This"), y = 48 },
|
||||
{ text = Strings.source("diploma certifies"), y = 64 },
|
||||
{ text = Strings.source("that you have"), y = 80 },
|
||||
{ text = Strings.source("completed your"), y = 96 },
|
||||
{ text = Strings.source("POKéDEX."), y = 112 },
|
||||
}
|
||||
for _, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line.text), 16, line.y)
|
||||
end
|
||||
|
||||
-- engine/events/diploma.asm:98
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128)
|
||||
end
|
||||
|
||||
local overlayCanvas, overlayKey
|
||||
|
||||
local function textOverlay(game)
|
||||
local g = love.graphics
|
||||
if not (g.newCanvas and g.setCanvas and g.push and g.pop) then return nil end
|
||||
local key = playerName(game) .. "\0" .. tostring(Strings.active())
|
||||
if overlayCanvas and overlayKey == key then return overlayCanvas end
|
||||
if not overlayCanvas then
|
||||
local ok, c = pcall(g.newCanvas, 160, 144)
|
||||
if not ok or not c then return nil end
|
||||
overlayCanvas = c
|
||||
end
|
||||
g.push("all")
|
||||
g.setCanvas(overlayCanvas)
|
||||
g.origin()
|
||||
g.setScissor()
|
||||
g.setShader()
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setColor(0, 0, 0, 1)
|
||||
drawLines(game)
|
||||
g.pop()
|
||||
overlayKey = key
|
||||
return overlayCanvas
|
||||
end
|
||||
|
||||
function Diploma.new(game, onDone)
|
||||
local self = setmetatable({
|
||||
game = game,
|
||||
@@ -102,25 +155,32 @@ function Diploma.render(game)
|
||||
titlePlayer or "assets/generated/title/player.png",
|
||||
{ side = "front", kind = "diploma", data = game.data })
|
||||
local pic
|
||||
local ogObp = false
|
||||
if picPath then
|
||||
if picTrueColor then
|
||||
pic = tryImage(picPath)
|
||||
else
|
||||
local ok, faded = pcall(SpriteRenderer.obpImage, picPath, OBP0_90,
|
||||
"diploma")
|
||||
ogObp = PaletteFX.usesSpriteObp() and PaletteFX.pass() == "ui"
|
||||
local ramp, group = OBP0_90, "diploma"
|
||||
if ogObp then
|
||||
local c = PaletteFX.ogObjBase()
|
||||
-- engine/events/diploma.asm:65
|
||||
ramp = { c[1], c[1], c[2], c[3] }
|
||||
group = GameVersion.isBlue() and "diploma_og_blue" or "diploma_og"
|
||||
end
|
||||
local ok, faded = pcall(SpriteRenderer.obpImage, picPath, ramp, group)
|
||||
pic = (ok and faded) or tryImage(picPath)
|
||||
ogObp = ogObp and ok and faded ~= nil
|
||||
end
|
||||
end
|
||||
if pic then
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local sx, sy, sw, sh = love.graphics.getScissor()
|
||||
-- engine/events/diploma.asm:44
|
||||
love.graphics.setScissor(8, 8, 144, 128)
|
||||
love.graphics.draw(pic, 115, 80)
|
||||
if sx then love.graphics.setScissor(sx, sy, sw, sh)
|
||||
else love.graphics.setScissor() end
|
||||
love.graphics.draw(pic, PIC_X, PIC_Y)
|
||||
if picTrueColor then
|
||||
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
|
||||
PaletteFX.markTrueColor(PIC_X, PIC_Y, pic:getDimensions())
|
||||
elseif ogObp then
|
||||
PaletteFX.markUiSpriteRedraw(pic, nil, PIC_X, PIC_Y)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -133,26 +193,17 @@ function Diploma.render(game)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
|
||||
|
||||
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
|
||||
Font.draw(Strings("Player"), 24, 32)
|
||||
local playerName = (game.save.player and game.save.player.name) or "RED"
|
||||
Font.draw(playerName, 80, 32)
|
||||
|
||||
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
|
||||
local congrats = {
|
||||
{ text = Strings.source("Congrats! This"), y = 48 }, -- hlcoord 2, 6
|
||||
{ text = Strings.source("diploma certifies"), y = 64 }, -- hlcoord 2, 8
|
||||
{ text = Strings.source("that you have"), y = 80 }, -- hlcoord 2, 10
|
||||
{ text = Strings.source("completed your"), y = 96 }, -- hlcoord 2, 12
|
||||
{ text = Strings.source("POKéDEX."), y = 112 }, -- hlcoord 2, 14
|
||||
}
|
||||
for _, line in ipairs(congrats) do
|
||||
Font.draw(Strings(line.text), 16, line.y)
|
||||
end
|
||||
|
||||
-- 6. Developer signature: hlcoord 9, 16
|
||||
Font.draw(Strings("GAME FREAK"), 72, 128)
|
||||
drawLines(game)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
if ogObp then
|
||||
local overlay = textOverlay(game)
|
||||
if overlay then
|
||||
-- engine/events/diploma.asm:44
|
||||
PaletteFX.markUiSpriteRedraw(overlay, nil, 0, 0,
|
||||
{ clip = { PIC_X, PIC_Y, pic:getWidth(), pic:getHeight() } })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Diploma:draw()
|
||||
|
||||
@@ -126,26 +126,28 @@ end
|
||||
function EvolutionState:pushDoneText()
|
||||
local game = self.game
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
if self.canceled then
|
||||
game.stack:push(TextBox.new(game,
|
||||
romText(game.data, "_StoppedEvolvingText",
|
||||
"Huh? %s\nstopped evolving!", self.oldName),
|
||||
function()
|
||||
Music.restoreMap(game.data)
|
||||
game.stack:pop()
|
||||
-- engine/pokemon/evos_moves.asm:296
|
||||
Evolution.clearScreen(game, self.mon.species)
|
||||
if self.onDone then self.onDone() end
|
||||
end))
|
||||
return
|
||||
end
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
local newName = game.data.pokemon[self.newSpecies].name
|
||||
-- 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()
|
||||
-- engine/pokemon/evos_moves.asm:156
|
||||
Evolution.clearScreen(game, self.newSpecies)
|
||||
-- engine/pokemon/evos_moves.asm:212 (#12)
|
||||
Evolution.learnEvolutionMoves(game, self.mon, self.onDone)
|
||||
end,
|
||||
|
||||
+17
-2
@@ -150,8 +150,18 @@ function NamingScreen:enter()
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/menus/naming_screen.asm:88
|
||||
local REPROMPT_WHITE_FRAMES = 45
|
||||
|
||||
function NamingScreen:confirm()
|
||||
local name = table.concat(self.glyphs)
|
||||
if name == "" and self.presets and #self.presets > 0 then
|
||||
-- engine/movie/oak_speech/oak_speech2.asm:20
|
||||
self.row, self.col = 1, 1
|
||||
self.anim = 0
|
||||
self.whiteout = REPROMPT_WHITE_FRAMES
|
||||
return
|
||||
end
|
||||
if name == "" then
|
||||
-- An empty confirm (START, or the ED cell with nothing typed) must not
|
||||
-- invent a letter (#833). DisplayNamingScreen seeds wStringBuffer with
|
||||
@@ -163,10 +173,10 @@ function NamingScreen:confirm()
|
||||
-- .playerCancelled and keeps the old nick, which is why an explicit
|
||||
-- opts.default still wins here. Player/rival naming
|
||||
-- (oak_speech2.asm ChoosePlayerName) re-opens on '@' and never accepts an
|
||||
-- empty result; the port keeps its preset fallback for that.
|
||||
-- empty result.
|
||||
-- Contract for callers: "" means NO name -- BattleState:askNicknameUI and
|
||||
-- Commands.give_pokemon both guard on #name > 0 before setting nickname.
|
||||
name = (self.presets and self.presets[1]) or self.default or ""
|
||||
name = self.default or ""
|
||||
end
|
||||
Sound.play(self.game.data, "Press_AB")
|
||||
self.game.stack:pop()
|
||||
@@ -193,6 +203,10 @@ function NamingScreen:jumpToEnd()
|
||||
end
|
||||
|
||||
function NamingScreen:update(dt)
|
||||
if (self.whiteout or 0) > 0 then
|
||||
self.whiteout = self.whiteout - 1
|
||||
return
|
||||
end
|
||||
-- engine/menus/naming_screen.asm:131
|
||||
self.anim = (self.anim or 0) + 1
|
||||
local GRID = self:grid()
|
||||
@@ -252,6 +266,7 @@ function NamingScreen:draw()
|
||||
if self.choosing then return end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
if (self.whiteout or 0) > 0 then return end
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
-- engine/menus/naming_screen.asm:453
|
||||
if self.mon then
|
||||
|
||||
+71
-23
@@ -253,6 +253,8 @@ function OakSpeech.defaultSteps(speech)
|
||||
pic = "player",
|
||||
-- oak_speech.asm:106-108
|
||||
reveal = "fade_white_in",
|
||||
-- data/text/text_2.asm:1753
|
||||
auto = true,
|
||||
},
|
||||
{
|
||||
id = "shrink",
|
||||
@@ -331,6 +333,8 @@ function OakSpeech.new(game, onDone)
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"] or sprites.SPRITE_RED
|
||||
self.walkSheet = tryImage(red and red.image)
|
||||
self.walkDef = red
|
||||
self.walkPath = red and red.image
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -453,7 +457,8 @@ function OakSpeech:runStep(step)
|
||||
self.holdBox = box
|
||||
self.game.stack:push(box)
|
||||
else
|
||||
self:sayText(self:stepText(step), function() self:advance() end)
|
||||
self:sayText(self:stepText(step), function() self:advance() end,
|
||||
step.auto and { auto = { delay = 0 } } or nil)
|
||||
end
|
||||
end)
|
||||
elseif kind == "demo" then
|
||||
@@ -687,14 +692,23 @@ function OakSpeech:finish()
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
-- Shrink timeline (oak_speech.asm .next):
|
||||
-- frames 1-4 RedPicFront still up (ld c, 4 / DelayFrames)
|
||||
-- frames 5-8 ShrinkPic1 (ld c, 4 / DelayFrames)
|
||||
-- frames 9-28 ShrinkPic2, music fades (wAudioFadeOutControl; ld c, 20)
|
||||
-- frames 29-78 pic area cleared, walking sprite at the standard
|
||||
-- player screen spot (ResetPlayerSpriteData /
|
||||
-- ClearScreenArea / wUpdateSpritesEnabled; ld c, 50)
|
||||
-- frames 79-102 GBFadeOutToWhite (3 palettes x 8 frames)
|
||||
-- engine/movie/oak_speech/oak_speech.asm:125
|
||||
local SHRINK_PIC1_AT = 36
|
||||
local SHRINK_PIC2_AT = 76
|
||||
-- engine/movie/oak_speech/oak_speech.asm:154
|
||||
local SHRINK_WALK_AT = 108
|
||||
local SHRINK_FADE_AT = 158
|
||||
local SHRINK_END = SHRINK_FADE_AT + 3 * WHITE_FADE_STEP
|
||||
-- home/fade.asm:71
|
||||
local SHRINK_FADE_BGP = { 0x90, 0x40, 0x00 }
|
||||
local SHRINK_FADE_OBP0 = { 0x80, 0x40, 0x00 }
|
||||
|
||||
OakSpeech.SHRINK_PIC1_AT = SHRINK_PIC1_AT
|
||||
OakSpeech.SHRINK_PIC2_AT = SHRINK_PIC2_AT
|
||||
OakSpeech.SHRINK_WALK_AT = SHRINK_WALK_AT
|
||||
OakSpeech.SHRINK_FADE_AT = SHRINK_FADE_AT
|
||||
OakSpeech.SHRINK_END = SHRINK_END
|
||||
|
||||
function OakSpeech:update(dt)
|
||||
local r = self.picReveal
|
||||
if r then
|
||||
@@ -708,24 +722,24 @@ function OakSpeech:update(dt)
|
||||
if not self.shrink then return end
|
||||
local s = self.shrink
|
||||
s.frame = s.frame + 1
|
||||
if s.frame == 5 then
|
||||
if s.frame == SHRINK_PIC1_AT then
|
||||
self.pic = self.shrinkPic1 or self.pic
|
||||
self.picTrueColor = false
|
||||
elseif s.frame == 9 then
|
||||
elseif s.frame == SHRINK_PIC2_AT then
|
||||
self.pic = self.shrinkPic2 or self.pic
|
||||
self.picTrueColor = false
|
||||
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
|
||||
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
|
||||
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
|
||||
-- frames (7 levels x 10), reaching 0 shortly before the fade-to-white
|
||||
-- begins, instead of a hard cut (oak_speech.asm:145-149,
|
||||
-- home/fade_audio.asm)
|
||||
Music.fadeOut(10)
|
||||
elseif s.frame == 29 then
|
||||
elseif s.frame == SHRINK_WALK_AT then
|
||||
self.pic = nil
|
||||
self.picTrueColor = false
|
||||
self.walkVisible = true
|
||||
elseif s.frame >= 79 and s.frame <= 102 then
|
||||
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
|
||||
elseif s.frame > 102 then
|
||||
elseif s.frame >= SHRINK_FADE_AT and s.frame < SHRINK_END then
|
||||
self.fadeLevel = math.floor((s.frame - SHRINK_FADE_AT) / WHITE_FADE_STEP) + 1
|
||||
elseif s.frame >= SHRINK_END then
|
||||
-- clear before finish(): a finished-listener that pushes a state gets
|
||||
-- ITS state popped in the speech's place, and a live shrink would call
|
||||
-- finish() again next frame, re-firing the event every frame (#308)
|
||||
@@ -734,6 +748,33 @@ function OakSpeech:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/movie/oak_speech/oak_speech.asm:162
|
||||
function OakSpeech:walkImage()
|
||||
if not self.walkPath then return self.walkSheet, false end
|
||||
local PF = require("src.render.PaletteFX")
|
||||
local colors, group
|
||||
if PF.usesGbcPack() then
|
||||
colors, group = PF.spriteObp(self.walkDef)
|
||||
if colors then colors, group = PF.fadeObp(colors, group) end
|
||||
end
|
||||
if not colors then
|
||||
if PF.usesSpriteObp() then
|
||||
if self.fadeLevel then
|
||||
colors, group = PF.ogObjLit()
|
||||
else
|
||||
-- home/fade.asm:68
|
||||
colors, group = PF.ogObjNormal()
|
||||
end
|
||||
else
|
||||
colors, group = PF.dmgObjLit()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(require("src.render.SpriteRenderer").obpImage,
|
||||
self.walkPath, colors, group)
|
||||
if not (ok and img) then return self.walkSheet, false end
|
||||
return img, PF.usesSpriteObp() or PF.usesGbcPack()
|
||||
end
|
||||
|
||||
function OakSpeech:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -743,6 +784,14 @@ function OakSpeech:draw()
|
||||
require("src.render.PaletteFX").setShadeMap(
|
||||
require("src.render.Transition").shadeMapFor(revealBgp))
|
||||
end
|
||||
if self.fadeLevel then
|
||||
-- home/fade.asm:26
|
||||
local PF = require("src.render.PaletteFX")
|
||||
local Transition = require("src.render.Transition")
|
||||
local i = math.min(#SHRINK_FADE_BGP, self.fadeLevel)
|
||||
PF.setShadeMap(Transition.shadeMapFor(SHRINK_FADE_BGP[i]))
|
||||
PF.setFadeObp(Transition.shadeMapFor(SHRINK_FADE_OBP0[i]))
|
||||
end
|
||||
if self.pic then
|
||||
-- IntroDisplayPicCenteredOrUpperRight centered: the 7x7-tile pic
|
||||
-- area sits at hlcoord 6,4 = (48,32); smaller mon pics pad inside
|
||||
@@ -773,7 +822,11 @@ function OakSpeech:draw()
|
||||
-- ResetPlayerSpriteData: Y screen pos $3c, X screen pos $40
|
||||
self.walkQuad = self.walkQuad
|
||||
or love.graphics.newQuad(0, 0, 16, 16, self.walkSheet:getDimensions())
|
||||
love.graphics.draw(self.walkSheet, self.walkQuad, 64, 60)
|
||||
local img, redraw = self:walkImage()
|
||||
love.graphics.draw(img, self.walkQuad, 64, 60)
|
||||
if redraw then
|
||||
require("src.render.PaletteFX").markUiSpriteRedraw(img, self.walkQuad, 64, 60)
|
||||
end
|
||||
end
|
||||
if self.shrinkText then
|
||||
-- This is a REPLICA of the dialogue box that just closed, redrawn at
|
||||
@@ -795,11 +848,6 @@ function OakSpeech:draw()
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.fadeLevel then
|
||||
love.graphics.setColor(1, 1, 1, self.fadeLevel / 3)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
end
|
||||
|
||||
return OakSpeech
|
||||
|
||||
+23
-12
@@ -35,14 +35,11 @@ PartyMenu.isOpaque = true
|
||||
-- MEWMON instead painted every bar with MEWMON's shades, which is why a
|
||||
-- full bar came out black and a low one purple (#274, absorbing #272).
|
||||
--
|
||||
-- Two rects differ from the packet's, both because this port draws pixels
|
||||
-- One rect differs from the packet's, because this port draws pixels
|
||||
-- where the hardware drew OAM over BG:
|
||||
-- * the icon block is rows 0-11, not the packet's 0-12 -- row 12 is the
|
||||
-- message box's top edge, which on hardware was BG under an OBJ-free
|
||||
-- part of the block; here it would take MEWMON instead of the base.
|
||||
-- * the bar blocks sit one tile right of the packet's 05-11 because this
|
||||
-- port's bar starts at tile 5 where party_menu.asm:71-76 starts it at
|
||||
-- 4; the span is the same "left cap + six fill tiles".
|
||||
function PartyMenu:sgbPalettes(game)
|
||||
local P = require("src.render.PaletteFX")
|
||||
local base = P.pal(game.data, "GREENBAR")
|
||||
@@ -65,7 +62,7 @@ function PartyMenu:sgbPalettes(game)
|
||||
if self.heal and self.heal.mon == mon then hp = self.heal.from end
|
||||
local bar = P.pal(game.data, P.barPalName(hp, mon.stats.hp))
|
||||
if bar then
|
||||
zones[#zones + 1] = P.zone(bar, 6, i * 2 - 1, 12, i * 2 - 1)
|
||||
zones[#zones + 1] = P.zone(bar, 5, i * 2 - 1, 11, i * 2 - 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -466,7 +463,10 @@ end
|
||||
|
||||
function PartyMenu:update(dt)
|
||||
-- icon animation counter; 320 = a whole cycle at every HP speed
|
||||
self.blink = ((self.blink or 0) + 1) % 320
|
||||
-- home/pokemon.asm:243
|
||||
if not (self.submenu or self.chosenHollow or self.heal or self.swapAnim) then
|
||||
self.blink = ((self.blink or 0) + 1) % 320
|
||||
end
|
||||
-- The bar fill owns the menu while it runs: UpdateHPBar2 is a blocking
|
||||
-- predef in item_effects.asm, so no button is read until it lands (#252).
|
||||
local heal = self.heal
|
||||
@@ -497,6 +497,7 @@ function PartyMenu:update(dt)
|
||||
end
|
||||
if anim.frames < 10 or (playing and anim.frames < 30) then return end
|
||||
self.swapAnim = nil
|
||||
self.blink = 0 -- home/window.asm:16
|
||||
if self.game.data then
|
||||
require("src.core.Sound").play(self.game.data, "Swap")
|
||||
end
|
||||
@@ -524,6 +525,7 @@ function PartyMenu:update(dt)
|
||||
self.subIndex = self.subIndex < n and self.subIndex + 1 or 1
|
||||
elseif input:wasPressed("b") then
|
||||
self.submenu = nil
|
||||
self.blink = 0 -- home/window.asm:16
|
||||
elseif input:wasPressed("a") then
|
||||
local mon = party[self.index]
|
||||
if followerUnavailable(self.game, mon) then
|
||||
@@ -595,6 +597,7 @@ function PartyMenu:update(dt)
|
||||
return
|
||||
end
|
||||
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
|
||||
self.chosenHollow = true
|
||||
ow:useFlashFieldMove(function() self:close() end)
|
||||
return
|
||||
elseif action == "surf" then
|
||||
@@ -614,6 +617,7 @@ function PartyMenu:update(dt)
|
||||
-- trySurf closes this menu when its text does (#385)
|
||||
local fx, fy = ow.player:facingCell()
|
||||
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
|
||||
self.chosenHollow = true
|
||||
ow:trySurf(fx, fy, function() self:close() end)
|
||||
return
|
||||
end
|
||||
@@ -687,6 +691,7 @@ function PartyMenu:update(dt)
|
||||
return
|
||||
end
|
||||
self.submenu = nil -- engine/menus/start_sub_menus.asm:66
|
||||
self.chosenHollow = true
|
||||
ow:useStrengthFieldMove(mon, function() self:close() end)
|
||||
return
|
||||
elseif ow and ow.useFieldMove then
|
||||
@@ -739,6 +744,7 @@ function PartyMenu:update(dt)
|
||||
return
|
||||
end
|
||||
self.submenu = nil
|
||||
self.blink = 0 -- home/window.asm:16
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -754,12 +760,15 @@ function PartyMenu:update(dt)
|
||||
if grid then
|
||||
self.index = grid
|
||||
self.game.partyMenuSavedIndex = self.index
|
||||
self.blink = 0 -- home/window.asm:16
|
||||
elseif input:wasPressed("up") then
|
||||
self.index = self.index > 1 and self.index - 1 or math.max(1, #party)
|
||||
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
|
||||
self.blink = 0 -- home/window.asm:16
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index < #party and self.index + 1 or 1
|
||||
self.game.partyMenuSavedIndex = self.index -- HandlePartyMenuInput #768
|
||||
self.blink = 0 -- home/window.asm:16
|
||||
elseif input:wasPressed("b") then
|
||||
self.game.stack:pop()
|
||||
if self.onCancel then self.onCancel() end
|
||||
@@ -964,11 +973,11 @@ function PartyMenu:draw()
|
||||
for _, m in ipairs(def.tmhm or {}) do
|
||||
if m == self.tmhm.move then can = true break end
|
||||
end
|
||||
-- right-aligned so the shorter "ABLE" shares "NOT ABLE"'s right edge
|
||||
-- engine/menus/party_menu.asm:93
|
||||
if can then
|
||||
Font.draw(Strings("ABLE"), 120, y + 8)
|
||||
Font.draw(Strings("ABLE"), 96, y + 8)
|
||||
else
|
||||
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||
Font.draw(Strings("NOT ABLE"), 96, y + 8)
|
||||
end
|
||||
elseif self.evoStone then
|
||||
-- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching
|
||||
@@ -979,10 +988,11 @@ function PartyMenu:draw()
|
||||
can = true break
|
||||
end
|
||||
end
|
||||
-- engine/menus/party_menu.asm:160
|
||||
if can then
|
||||
Font.draw(Strings("ABLE"), 120, y + 8)
|
||||
Font.draw(Strings("ABLE"), 96, y + 8)
|
||||
else
|
||||
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||
Font.draw(Strings("NOT ABLE"), 96, y + 8)
|
||||
end
|
||||
else
|
||||
if mon.hp <= 0 then
|
||||
@@ -1006,7 +1016,8 @@ function PartyMenu:draw()
|
||||
shown = { hp = math.floor(self.heal.shown), stats = mon.stats }
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
HudTiles.drawHPBar(self.game.data, 5, (y + 8) / 8, shown, nil, barZoned)
|
||||
-- engine/menus/party_menu.asm:71
|
||||
HudTiles.drawHPBar(self.game.data, 4, (y + 8) / 8, shown, nil, barZoned)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(shown.hp, mon.stats.hp), 104, y + 8)
|
||||
end
|
||||
|
||||
+29
-82
@@ -6,6 +6,7 @@
|
||||
-- move one); toss discards after a YES/NO confirm. Follows the
|
||||
-- BoxMenu/BagMenu list idioms.
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local Menu = require("src.ui.Menu")
|
||||
@@ -23,49 +24,19 @@ local function itemName(game, id)
|
||||
return def and def.name or id
|
||||
end
|
||||
|
||||
local function pcOrder(game)
|
||||
local save, pc = game.save, game.save.pcItems
|
||||
local order = type(save.pcOrder) == "table" and save.pcOrder or {}
|
||||
local kept, seen = {}, {}
|
||||
for _, id in ipairs(order) do
|
||||
if pc[id] and not seen[id] then kept[#kept + 1], seen[id] = id, true end
|
||||
end
|
||||
local added = {}
|
||||
for id in pairs(pc) do if not seen[id] then added[#added + 1] = id end end
|
||||
table.sort(added, function(a, b)
|
||||
local da, db = game.data.items[a], game.data.items[b]
|
||||
local ia = da and (da.index or da.itemId) or math.huge
|
||||
local ib = db and (db.index or db.itemId) or math.huge
|
||||
if ia ~= ib then
|
||||
if type(ia) == type(ib) then return ia < ib end
|
||||
return tostring(ia) < tostring(ib)
|
||||
end
|
||||
if type(a) == type(b) then return a < b end
|
||||
return tostring(a) < tostring(b)
|
||||
end)
|
||||
for _, id in ipairs(added) do kept[#kept + 1] = id end
|
||||
for i = #order, 1, -1 do order[i] = nil end
|
||||
for i, id in ipairs(kept) do order[i] = id end
|
||||
save.pcOrder = order
|
||||
return order
|
||||
end
|
||||
|
||||
local function buildItems(game, store, order)
|
||||
local function buildItems(game, rows)
|
||||
local items = {}
|
||||
local ids = order
|
||||
if not ids then
|
||||
ids = {}
|
||||
for id in pairs(store) do table.insert(ids, id) end
|
||||
table.sort(ids)
|
||||
end
|
||||
for _, id in ipairs(ids) do
|
||||
if store[id] then
|
||||
for _, row in ipairs(rows) do
|
||||
local id = row.id
|
||||
if row.count ~= nil then
|
||||
local def = game.data.items[id]
|
||||
local keyItem = (def and def.keyItem) or id:find("^HM_") ~= nil
|
||||
table.insert(items, {
|
||||
value = id,
|
||||
label = itemName(game, id),
|
||||
count = not keyItem and store[id] or nil,
|
||||
count = not keyItem and row.count or nil,
|
||||
slot = row.slot,
|
||||
stack = row.count,
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -104,19 +75,11 @@ local function askQuantity(game, list, count, id, cb)
|
||||
}))
|
||||
end
|
||||
|
||||
-- refresh the chosen row's count from `store` (or drop the row)
|
||||
local function refreshRow(list, store, id)
|
||||
for i, it in ipairs(list.items) do
|
||||
if it.value == id then
|
||||
if store[id] then
|
||||
it.count = store[id]
|
||||
else
|
||||
table.remove(list.items, i)
|
||||
list.index, list.scroll = 1, 0 -- engine/items/inventory.asm:131
|
||||
end
|
||||
break
|
||||
end
|
||||
local function refreshRows(list, items)
|
||||
if #items < #list.items then
|
||||
list.index, list.scroll = 1, 0 -- engine/items/inventory.asm:131
|
||||
end
|
||||
list.items = items
|
||||
list.index = math.max(1, math.min(list.index, #list.items))
|
||||
end
|
||||
|
||||
@@ -166,7 +129,7 @@ local function withdraw(game)
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(pcList(game, buildItems(game, pc, pcOrder(game)), {
|
||||
game.stack:push(pcList(game, buildItems(game, Bag.pcRows(game.save, game.data)), {
|
||||
kind = "pc_item_withdraw",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:151-152 WhatToWithdrawText, printed before the list
|
||||
@@ -176,39 +139,30 @@ local function withdraw(game)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
list.hollowIndex = list.index -- home/list_menu.asm:91
|
||||
askQuantity(game, list, pc[item.value] or 1, item.value, function(qty)
|
||||
local Bag = require("src.inventory.Bag")
|
||||
askQuantity(game, list, item.stack or pc[item.value] or 1, item.value, function(qty)
|
||||
if not Bag.add(game.save, item.value, qty, game.data) then
|
||||
list.footer = Strings("You can't carry\nany more items.")
|
||||
return
|
||||
end
|
||||
pc[item.value] = pc[item.value] - qty
|
||||
if pc[item.value] <= 0 then
|
||||
pc[item.value] = nil
|
||||
pcOrder(game)
|
||||
end
|
||||
Bag.pcRemove(game.save, item.value, qty, game.data, item.slot)
|
||||
Sound.play(game.data, "Withdraw_Deposit")
|
||||
list:showCompletion(romText(game.data, "_WithdrewItemText",
|
||||
"Withdrew\n%s.{PROMPT}", itemName(game, item.value)),
|
||||
function() refreshRow(list, pc, item.value) end) -- engine/menus/players_pc.asm:191
|
||||
function() refreshRows(list, buildItems(game, Bag.pcRows(game.save, game.data))) end) -- engine/menus/players_pc.asm:191
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
end
|
||||
|
||||
-- wNumBoxItems capacity: 50 stacks (PC_ITEM_CAPACITY)
|
||||
local function pcFull(game, pc, id)
|
||||
if pc[id] then return false end -- growing an existing stack is fine
|
||||
local function storeInPC(game, pc, id, qty)
|
||||
local cap = game.data.field.pcItemCap or 50
|
||||
local stacks = 0
|
||||
for _ in pairs(pc) do stacks = stacks + 1 end
|
||||
return stacks >= cap
|
||||
return Bag.pcAdd(game.save, id, qty, game.data, cap)
|
||||
end
|
||||
|
||||
local function deposit(game)
|
||||
local pc = game.save.pcItems
|
||||
local inv = game.save.inventory
|
||||
local Bag = require("src.inventory.Bag")
|
||||
-- engine/menus/players_pc.asm:99 wListPointer = wNumBagItems, so deposit order == bag order
|
||||
local order = Bag.order(game.save, game.data)
|
||||
-- players_pc.asm:90-95: an empty bag never reaches the list
|
||||
@@ -217,7 +171,7 @@ local function deposit(game)
|
||||
"You have nothing\nto deposit."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(pcList(game, buildItems(game, inv, order), {
|
||||
game.stack:push(pcList(game, buildItems(game, Bag.rows(game.save, game.data)), {
|
||||
kind = "pc_item_deposit",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:97-98 WhatToDepositText, printed before the list
|
||||
@@ -227,21 +181,18 @@ local function deposit(game)
|
||||
onChoose = function(item, list)
|
||||
if leftOnCancel(item, list) then return end
|
||||
list.hollowIndex = list.index -- home/list_menu.asm:91
|
||||
askQuantity(game, list, inv[item.value] or 1, item.value, function(qty)
|
||||
if pcFull(game, pc, item.value) then
|
||||
askQuantity(game, list, item.stack or inv[item.value] or 1, item.value, function(qty)
|
||||
if not storeInPC(game, pc, item.value, qty) then
|
||||
list.footer = Strings("No room left to\nstore items.")
|
||||
return
|
||||
end
|
||||
if not pc[item.value] then
|
||||
local order = pcOrder(game)
|
||||
order[#order + 1] = item.value
|
||||
end
|
||||
require("src.inventory.Bag").remove(game.save, item.value, qty)
|
||||
pc[item.value] = (pc[item.value] or 0) + qty
|
||||
Bag.remove(game.save, item.value, qty, game.data, item.slot)
|
||||
Sound.play(game.data, "Withdraw_Deposit")
|
||||
list:showCompletion(romText(game.data, "_ItemWasStoredText",
|
||||
"%s was\nstored via PC.{PROMPT}", itemName(game, item.value)),
|
||||
function() refreshRow(list, inv, item.value) end) -- engine/menus/players_pc.asm:137
|
||||
function()
|
||||
refreshRows(list, buildItems(game, Bag.rows(game.save, game.data)))
|
||||
end) -- engine/menus/players_pc.asm:137
|
||||
end)
|
||||
end,
|
||||
}))
|
||||
@@ -255,7 +206,7 @@ local function toss(game)
|
||||
"There is nothing\nstored."), nil, { noSound = true }))
|
||||
return
|
||||
end
|
||||
game.stack:push(pcList(game, buildItems(game, pc, pcOrder(game)), {
|
||||
game.stack:push(pcList(game, buildItems(game, Bag.pcRows(game.save, game.data)), {
|
||||
kind = "pc_item_toss",
|
||||
messageBox = true,
|
||||
-- players_pc.asm:205-206 WhatToTossText, printed before the list
|
||||
@@ -272,20 +223,16 @@ local function toss(game)
|
||||
end
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
game.stack:push(QuantityBox.new(game, {
|
||||
max = pc[item.value] or 1,
|
||||
max = item.stack or pc[item.value] or 1,
|
||||
onDone = function(qty)
|
||||
if not qty then return end
|
||||
list.footer = Strings("Toss %s?", itemName(game, item.value))
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if yes then
|
||||
pc[item.value] = pc[item.value] - qty
|
||||
if pc[item.value] <= 0 then
|
||||
pc[item.value] = nil
|
||||
pcOrder(game)
|
||||
end
|
||||
Bag.pcRemove(game.save, item.value, qty, game.data, item.slot)
|
||||
list:showCompletion(romText(game.data, "_ThrewAwayItemText",
|
||||
"Threw away\n%s.{PROMPT}", itemName(game, item.value)),
|
||||
function() refreshRow(list, pc, item.value) end) -- engine/menus/players_pc.asm:240
|
||||
function() refreshRows(list, buildItems(game, Bag.pcRows(game.save, game.data))) end) -- engine/menus/players_pc.asm:240
|
||||
else
|
||||
list.footer = nil
|
||||
end
|
||||
|
||||
+17
-11
@@ -152,13 +152,16 @@ end
|
||||
-- home/list_menu.asm:472-477
|
||||
local function sellItems(game)
|
||||
local items = {}
|
||||
for _, id in ipairs(Bag.order(game.save)) do
|
||||
for _, row in ipairs(Bag.rows(game.save, game.data)) do
|
||||
local id = row.id
|
||||
local def = game.data.items[id]
|
||||
local keyed = (def and def.keyItem) or id:find("^HM_") ~= nil
|
||||
table.insert(items, {
|
||||
value = id,
|
||||
label = def and def.name or id,
|
||||
count = (not keyed) and game.save.inventory[id] or nil,
|
||||
count = (not keyed) and row.count or nil,
|
||||
slot = row.slot,
|
||||
stack = row.count,
|
||||
})
|
||||
end
|
||||
items[#items + 1] = { cancel = true, label = Strings("CANCEL") }
|
||||
@@ -196,11 +199,13 @@ local function sell(game, menu)
|
||||
l.swapIndex = l.index
|
||||
return
|
||||
end
|
||||
local order = Bag.order(game.save)
|
||||
order[l.swapIndex], order[l.index] = order[l.index], order[l.swapIndex]
|
||||
-- engine/menus/swap_items.asm:45
|
||||
if l.swapIndex == l.index then return end
|
||||
Bag.swap(game.save, l.swapIndex, l.index, game.data)
|
||||
l.swapIndex = nil
|
||||
require("src.core.Sound").play(game.data, "Swap")
|
||||
l.items = sellItems(game)
|
||||
l.index = math.max(1, math.min(l.index, #l.items))
|
||||
end,
|
||||
onChoose = function(item)
|
||||
-- home/list_menu.asm:105-110, 523-528
|
||||
@@ -227,7 +232,7 @@ local function sell(game, menu)
|
||||
if game.stack:top() == qtyBox then game.stack:pop() end
|
||||
end
|
||||
qtyBox = QuantityBox.new(game, {
|
||||
max = game.save.inventory[item.value] or 1,
|
||||
max = item.stack or game.save.inventory[item.value] or 1,
|
||||
unitPrice = unit,
|
||||
keepOpen = true,
|
||||
onDone = function(qty)
|
||||
@@ -250,13 +255,14 @@ local function sell(game, menu)
|
||||
game.save.money = game.save.money + unit * qty
|
||||
-- home/inventory.asm:15
|
||||
require("src.core.Sound").play(game.data, "Purchase")
|
||||
Bag.remove(game.save, item.value, qty)
|
||||
local left = game.save.inventory[item.value]
|
||||
if left then
|
||||
item.count = left
|
||||
else
|
||||
list:removeCurrent()
|
||||
Bag.remove(game.save, item.value, qty, game.data, item.slot)
|
||||
local fresh = sellItems(game)
|
||||
if #fresh < #list.items then
|
||||
-- engine/items/inventory.asm:131
|
||||
list.index, list.scroll = 1, 0
|
||||
end
|
||||
list.items = fresh
|
||||
list.index = math.max(1, math.min(list.index, #list.items))
|
||||
-- a sale prints nothing -- engine/events/pokemart.asm:112
|
||||
list.footer = greet
|
||||
end }))
|
||||
|
||||
@@ -516,13 +516,15 @@ function ContinueInfo:draw()
|
||||
math.max(96, 40 + (#Font.split(playerLabel) + 1) * 8), 72)
|
||||
local badges = require("src.inventory.Badges").count(self.game.data, save)
|
||||
Font.draw(Strings("BADGES"), 40, 88)
|
||||
Font.draw(("%2d"):format(badges), 128, 88)
|
||||
-- engine/menus/main_menu.asm:370
|
||||
Font.draw(("%2d"):format(badges), 17 * 8, 11 * 8)
|
||||
local owned = 0
|
||||
for _ in pairs(save.pokedex and save.pokedex.owned or {}) do
|
||||
owned = owned + 1
|
||||
end
|
||||
Font.draw(Strings("POKéDEX"), 40, 104)
|
||||
Font.draw(("%3d"):format(owned), 120, 104)
|
||||
-- engine/menus/main_menu.asm:372
|
||||
Font.draw(("%3d"):format(owned), 16 * 8, 13 * 8)
|
||||
local t = math.floor(save.playTime or 0)
|
||||
Font.draw(Strings("TIME"), 40, 120)
|
||||
Font.draw(("%3d:%02d"):format(math.floor(t / 3600),
|
||||
|
||||
@@ -64,7 +64,9 @@ function Rows.build(ctx, skip)
|
||||
local rows = {}
|
||||
local function add(row) rows[#rows + 1] = row end
|
||||
local function addCart(id, make)
|
||||
if not (skip and skip[id]) then add(make()) end
|
||||
local swap = skip and skip[id]
|
||||
if type(swap) == "table" then add(swap)
|
||||
elseif not swap then add(make()) end
|
||||
end
|
||||
|
||||
addCart("textSpeed", function() return {
|
||||
@@ -498,4 +500,12 @@ function Rows.group(rows, openPage)
|
||||
return view
|
||||
end
|
||||
|
||||
function Rows.withCart(ctx, cartRows, openPage, exclude)
|
||||
local rows = {}
|
||||
for _, r in ipairs(Rows.build(ctx, cartRows)) do
|
||||
if not (exclude and exclude[r.id]) then rows[#rows + 1] = r end
|
||||
end
|
||||
return Rows.group(rows, openPage)
|
||||
end
|
||||
|
||||
return Rows
|
||||
|
||||
@@ -14,11 +14,15 @@ Menu.__index = Menu
|
||||
Menu.TYPE = { HAS_NO_SAVED_GAME = 0, HAS_SAVED_GAME = 1, HAS_MYSTERY_EVENT = 2 }
|
||||
local T = Menu.TYPE
|
||||
local LABEL = { CONTINUE = "gMainMenuString_Continue", NEW_GAME = "gMainMenuString_NewGame",
|
||||
OPTION = "gMainMenuString_Option", MYSTERY_EVENTS = "gMainMenuString_MysteryEvents" }
|
||||
OPTION = "gMainMenuString_Option", MYSTERY_EVENTS = "gMainMenuString_MysteryEvents", EXIT = "SystemText_Exit" }
|
||||
local SCROLL_PX = 32
|
||||
function Menu.items(kind)
|
||||
if kind == T.HAS_MYSTERY_EVENT then return { "CONTINUE", "NEW_GAME", "MYSTERY_EVENTS", "OPTION" } end
|
||||
if kind == T.HAS_SAVED_GAME then return { "CONTINUE", "NEW_GAME", "OPTION" } end
|
||||
return { "NEW_GAME", "OPTION" }
|
||||
local rows
|
||||
if kind == T.HAS_MYSTERY_EVENT then rows = { "CONTINUE", "NEW_GAME", "MYSTERY_EVENTS", "OPTION" }
|
||||
elseif kind == T.HAS_SAVED_GAME then rows = { "CONTINUE", "NEW_GAME", "OPTION" }
|
||||
else rows = { "NEW_GAME", "OPTION" } end
|
||||
rows[#rows + 1] = "EXIT"
|
||||
return rows
|
||||
end
|
||||
function Menu.menuType(hasContinue, info, status)
|
||||
if not hasContinue or status == "invalid" or status == "empty" or status == "no_flash" then return T.HAS_NO_SAVED_GAME end
|
||||
@@ -66,7 +70,7 @@ function Menu.new(opts, ctx)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({ info = opts.continueInfo, game = opts.game, bootState = opts.bootState,
|
||||
textSpeed = tonumber(opts.textSpeed) or 1, saveStatus = opts.saveStatus or "ok", pal = Pal.new(),
|
||||
step = Kit.stepper(), state = "check_save", cursor = 1,
|
||||
step = Kit.stepper(), state = "check_save", cursor = 1, scroll = 0, blink = 0,
|
||||
manifest = assert(Kit.manifest("birch"), "RS menu palette missing"),
|
||||
frameType = opts.continueInfo and opts.continueInfo.frameType or 0 }, Menu)
|
||||
self.menuType = opts.menuType or Menu.menuType(opts.hasContinue, self.info, self.saveStatus)
|
||||
@@ -74,6 +78,12 @@ function Menu.new(opts, ctx)
|
||||
self.pal:beginFade(Pal.ALL, 0, 16, 0, opts.returningFromOptions and Pal.BLACK or Pal.WHITE)
|
||||
return self
|
||||
end
|
||||
function Menu:_fixScroll()
|
||||
local w = Menu.windowFor(self.menuType, self.cursor)
|
||||
local bottom, topPx = (w.top + w.height + 1) * 8, (w.top - 1) * 8
|
||||
while bottom - self.scroll > 160 do self.scroll = self.scroll + SCROLL_PX end
|
||||
while topPx - self.scroll < 0 and self.scroll > 0 do self.scroll = self.scroll - SCROLL_PX end
|
||||
end
|
||||
function Menu:_message(key)
|
||||
self.printer = Kit.printer(key, { speed = 2, canSpeedUp = false, textSpeedOption = self.textSpeed })
|
||||
end
|
||||
@@ -84,6 +94,7 @@ function Menu:_openOptions()
|
||||
end
|
||||
function Menu:frame(inp)
|
||||
local state, result = self.state
|
||||
self.blink = self.blink + 1
|
||||
if state == "check_save" and not self.pal:fadeActive() then
|
||||
local key = self.saveStatus == "invalid" and "gSaveFileDeletedMessage"
|
||||
or self.saveStatus == "error" and "gSaveFileCorruptMessage"
|
||||
@@ -108,13 +119,14 @@ function Menu:frame(inp)
|
||||
Kit.playSe("SE_SELECT"); self.pal:beginFade(Pal.ALL, 0, 0, 16, Pal.BLACK); self.state = "pressed_a"
|
||||
elseif inp.new.b then
|
||||
Kit.playSe("SE_SELECT"); self.pal:beginFade(Pal.ALL, 0, 0, 16, Pal.WHITE); self.state = "pressed_b"
|
||||
elseif inp.new.up and self.cursor > 1 then self.cursor = self.cursor - 1; self.state = "highlight"
|
||||
elseif inp.new.down and self.cursor < #self.items then self.cursor = self.cursor + 1; self.state = "highlight" end
|
||||
elseif inp.new.up and self.cursor > 1 then self.cursor = self.cursor - 1; self:_fixScroll(); self.state = "highlight"
|
||||
elseif inp.new.down and self.cursor < #self.items then self.cursor = self.cursor + 1; self:_fixScroll(); self.state = "highlight" end
|
||||
elseif state == "pressed_a" and not self.pal:fadeActive() then
|
||||
local item = self.items[self.cursor]
|
||||
if item == "CONTINUE" then result = { action = "continue" }
|
||||
elseif item == "NEW_GAME" then result = "newGame"
|
||||
elseif item == "OPTION" then self:_openOptions()
|
||||
elseif item == "EXIT" then result = { action = "exit" }
|
||||
elseif item == "MYSTERY_EVENTS" then
|
||||
require("src.core.game3.audio").fadeOutBgm(0)
|
||||
self.printer = nil
|
||||
@@ -126,7 +138,7 @@ function Menu:frame(inp)
|
||||
local opts = self.game and self.game.options
|
||||
if opts then self.textSpeed = require("src.core.game3.options").block(opts).textSpeed end
|
||||
if self.bootState then self.bootState.textSpeed = self.textSpeed end
|
||||
self.options, self.cursor, self.state, self.saveStatus = nil, 1, "check_save", "ok"
|
||||
self.options, self.cursor, self.scroll, self.state, self.saveStatus = nil, 1, 0, "check_save", "ok"
|
||||
self.pal:beginFade(Pal.ALL, 0, 16, 0, Pal.BLACK)
|
||||
end
|
||||
self.pal:updateFade()
|
||||
@@ -148,17 +160,18 @@ function Menu:colors()
|
||||
info = { bg = normal.bg, fg = Kit.color555(gender), shadow = normal.shadow }, fill = normal.bg,
|
||||
backdrop = Kit.color555(self.manifest.mainMenuPalette[1]) }
|
||||
end
|
||||
function Menu:_drawContinue(c)
|
||||
function Menu:_drawContinue(c, dy)
|
||||
local i = self.info or {}
|
||||
Font.draw(RomText.plain("gMainMenuString_Player"), 16, 24, { colors = c.info })
|
||||
Font.draw(i.name or "", 72, 24, { colors = c.info })
|
||||
Font.draw(RomText.plain("gMainMenuString_Time"), 128, 24, { colors = c.info })
|
||||
local y1, y2 = 24 - dy, 40 - dy
|
||||
Font.draw(RomText.plain("gMainMenuString_Player"), 16, y1, { colors = c.info })
|
||||
Font.draw(i.name or "", 72, y1, { colors = c.info })
|
||||
Font.draw(RomText.plain("gMainMenuString_Time"), 128, y1, { colors = c.info })
|
||||
local time = string.format("%d:%02d", i.hours or 0, i.minutes or 0)
|
||||
Font.draw(time, 224 - Font.measure(time), 24, { colors = c.info })
|
||||
Font.draw(RomText.plain("gMainMenuString_Pokedex"), 16, 40, { colors = c.info })
|
||||
Font.draw(tostring(i.dexCount or 0), 72, 40, { colors = c.info })
|
||||
Font.draw(RomText.plain("gMainMenuString_Badges"), 128, 40, { colors = c.info })
|
||||
Font.draw(tostring(i.badges or 0), 205, 40, { colors = c.info })
|
||||
Font.draw(time, 224 - Font.measure(time), y1, { colors = c.info })
|
||||
Font.draw(RomText.plain("gMainMenuString_Pokedex"), 16, y2, { colors = c.info })
|
||||
Font.draw(tostring(i.dexCount or 0), 72, y2, { colors = c.info })
|
||||
Font.draw(RomText.plain("gMainMenuString_Badges"), 128, y2, { colors = c.info })
|
||||
Font.draw(tostring(i.badges or 0), 205, y2, { colors = c.info })
|
||||
end
|
||||
local canvas
|
||||
local function composite(img, rect)
|
||||
@@ -187,17 +200,22 @@ function Menu:draw()
|
||||
canvas = canvas or love.graphics.newCanvas(240, 160)
|
||||
canvas:setFilter("nearest", "nearest")
|
||||
love.graphics.push("all"); love.graphics.setCanvas(canvas); love.graphics.origin(); love.graphics.clear(0, 0, 0, 0)
|
||||
local dy = self.scroll or 0
|
||||
for index, item in ipairs(self.items) do
|
||||
local w = Menu.windowFor(self.menuType, index)
|
||||
Chrome.stdFrame(w.left, w.top, w.width, w.height)
|
||||
Font.draw(RomText.plain(LABEL[item]), w.left * 8, w.top * 8, { colors = c.headers })
|
||||
if item == "CONTINUE" then self:_drawContinue(c) end
|
||||
Chrome.stdFrame(w.left, w.top - dy / 8, w.width, w.height)
|
||||
Font.draw(RomText.plain(LABEL[item]), w.left * 8, w.top * 8 - dy, { colors = c.headers })
|
||||
if item == "CONTINUE" then self:_drawContinue(c, dy) end
|
||||
end
|
||||
love.graphics.pop()
|
||||
local w = Menu.windowFor(self.menuType, self.cursor)
|
||||
local rect = { 9, (w.top - 1) * 8 + 1, 231, (w.top + w.height + 1) * 8 - 1 }
|
||||
local rect = { 9, (w.top - 1) * 8 + 1 - dy, 231, (w.top + w.height + 1) * 8 - 1 - dy }
|
||||
if self.state == "pressed_b" then rect = { 0, 0, 240, 160 } end
|
||||
composite(canvas, rect)
|
||||
local last = Menu.windowFor(self.menuType, #self.items)
|
||||
local Arrow = require("src.ui.game3.rs.scroll_arrow")
|
||||
if (last.top + last.height + 1) * 8 - dy > 160 then Arrow.draw("down", 0x78, 0x98, self.blink) end
|
||||
if dy > 0 then Arrow.draw("up", 0x78, 8, self.blink) end
|
||||
end
|
||||
if error then
|
||||
Chrome.stdFrame(3, 15, 24, 4)
|
||||
|
||||
@@ -23,28 +23,16 @@ local function page()
|
||||
return Menu._pages and Menu._pages[#Menu._pages]
|
||||
end
|
||||
|
||||
local function portRows()
|
||||
local skip = {}
|
||||
for _, key in ipairs(KEYS) do skip[key] = true end
|
||||
for key in pairs(Menu.EXCLUDE) do skip[key] = true end
|
||||
local rows = {}
|
||||
for _, r in ipairs(Rows.build(ctx(), skip)) do
|
||||
if not skip[r.id] then rows[#rows + 1] = r end
|
||||
end
|
||||
return Rows.group(rows, function(title, members)
|
||||
Menu._pages[#Menu._pages + 1] = {title = title, rows = members, index = 1, scroll = 0}
|
||||
end)
|
||||
end
|
||||
|
||||
local function topRows()
|
||||
local rows = {}
|
||||
for i = 1, NATIVE do rows[i] = {native = i} end
|
||||
for _, r in ipairs(portRows()) do rows[#rows + 1] = {port = r} end
|
||||
return rows
|
||||
local cartRows = {}
|
||||
for i, key in ipairs(KEYS) do cartRows[key] = {id = key, native = i} end
|
||||
return Rows.withCart(ctx(), cartRows, function(title, members)
|
||||
Menu._pages[#Menu._pages + 1] = {title = title, rows = members, index = 1, scroll = 0}
|
||||
end, Menu.EXCLUDE)
|
||||
end
|
||||
|
||||
local function rowCount(p)
|
||||
return p.top and #p.rows or #p.rows + 1
|
||||
return #p.rows + 1
|
||||
end
|
||||
|
||||
local function clampScroll(p)
|
||||
@@ -162,20 +150,14 @@ function Menu.handleInput(input)
|
||||
local total = rowCount(p)
|
||||
local row = p.rows[p.index]
|
||||
if pressed(input, "a") then
|
||||
if p.top and row.native == NATIVE then Menu.back()
|
||||
elseif p.top and row.port then activatePort(row.port)
|
||||
elseif not p.top then
|
||||
if row then activatePort(row) else Menu.back() end
|
||||
end
|
||||
if not row then Menu.back()
|
||||
elseif not row.native then activatePort(row) end
|
||||
elseif pressed(input, "b") then Menu.back()
|
||||
elseif pressed(input, "up") then p.index = (p.index - 2) % total + 1
|
||||
elseif pressed(input, "down") then p.index = p.index % total + 1
|
||||
elseif p.top and row.native and row.native < NATIVE then stepNative(row.native, input)
|
||||
else
|
||||
local port = p.top and row.port or (not p.top and row)
|
||||
if port and port.step and (pressed(input, "left") or pressed(input, "right")) then
|
||||
if port.step(ctx(), pressed(input, "left") and -1 or 1) then persist() end
|
||||
end
|
||||
elseif row and row.native then stepNative(row.native, input)
|
||||
elseif row and row.step and (pressed(input, "left") or pressed(input, "right")) then
|
||||
if row.step(ctx(), pressed(input, "left") and -1 or 1) then persist() end
|
||||
end
|
||||
local cur = page()
|
||||
if cur then clampScroll(cur) end
|
||||
@@ -228,10 +210,8 @@ local function drawContents()
|
||||
if idx > rowCount(p) then break end
|
||||
local y = baseY + (slot - 1) * 16
|
||||
local row = p.rows[idx]
|
||||
if p.top and row.native then
|
||||
drawNative(row.native, -p.scroll * 16)
|
||||
elseif p.top then
|
||||
drawPort(row.port, y)
|
||||
if row and row.native then
|
||||
drawNative(row.native, y - d.rows[row.native].y)
|
||||
elseif row then
|
||||
drawPort(row, y)
|
||||
else
|
||||
|
||||
@@ -46,7 +46,7 @@ end
|
||||
|
||||
Data.window = {left = 23, top = 1, width = 6}
|
||||
Data.textX, Data.textY, Data.rowPitch = 0, 8, 16
|
||||
Data.maxVisible, Data.dexNeedsSeen, Data.exitConfirms = 8, true, false
|
||||
Data.maxVisible, Data.dexNeedsSeen, Data.exitConfirms = 8, true, true
|
||||
function Data.drawCursor(x, y)
|
||||
require("src.ui.game3.rs.menu_cursor").draw(x, y, 48)
|
||||
end
|
||||
|
||||
@@ -64,15 +64,7 @@ end
|
||||
local function portRows()
|
||||
local cartRows = {}
|
||||
for _, r in ipairs(OptionMenu.CART) do cartRows[r.id] = { id = r.id, cart = r } end
|
||||
local rows = {}
|
||||
for _, r in ipairs(Rows.build(st.ctx)) do
|
||||
rows[#rows + 1] = cartRows[r.id] or r
|
||||
cartRows[r.id] = nil
|
||||
end
|
||||
for _, r in ipairs(OptionMenu.CART) do
|
||||
if cartRows[r.id] then rows[#rows + 1] = cartRows[r.id] end
|
||||
end
|
||||
return Rows.group(rows, function(title, members)
|
||||
return Rows.withCart(st.ctx, cartRows, function(title, members)
|
||||
st.pages[#st.pages + 1] = { title = title, rows = members, index = 1, scroll = 0 }
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -149,6 +149,9 @@ local HEAL_FLASH_MAP_GBC = { [0] = 0, [1] = 0, [2] = 1, [3] = 2 }
|
||||
|
||||
-- engine/overworld/healing_machine.asm:13-14
|
||||
local HEAL_OBP1_MAP = { [0] = 0, [1] = 0, [2] = 2, [3] = 3 }
|
||||
|
||||
-- home/fade.asm:67
|
||||
local OBP_IDENTITY = { [0] = 0, [1] = 1, [2] = 2, [3] = 3 }
|
||||
-- engine/overworld/healing_machine.asm:50
|
||||
local HEAL_OBP1_FLASH_MAP = { [0] = 0, [1] = 2, [2] = 0, [3] = 3 }
|
||||
|
||||
@@ -2658,9 +2661,8 @@ function OverworldState:tryBookshelf(fx, fy)
|
||||
Game.stack:push(TextBox.new(Game, t._ElevatorText
|
||||
or Strings("An elevator!")))
|
||||
elseif kind == "statues" then
|
||||
-- IndigoPlateauStatues: the plaque, then one of the two lines
|
||||
-- keyed by the statue's column (XCoord bit 0)
|
||||
local line = (self.player.cellX % 2 == 0) and t._IndigoPlateauStatuesText2
|
||||
-- engine/events/hidden_events/indigo_plateau_statues.asm:5
|
||||
local line = (self.player.cellX % 2 == 1) and t._IndigoPlateauStatuesText2
|
||||
or t._IndigoPlateauStatuesText3
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(t._IndigoPlateauStatuesText1 or romText(Game.data, "_IndigoPlateauStatuesText1", "INDIGO PLATEAU")) .. "\f"
|
||||
@@ -3735,6 +3737,24 @@ function OverworldState:openOaksPC(onDone)
|
||||
end))
|
||||
end
|
||||
|
||||
-- audio/pokedex_rating_sfx.asm:26
|
||||
local DEX_RATING_SFX = {
|
||||
{ 10, "Denied" },
|
||||
{ 40, "Pokedex_Rating" },
|
||||
{ 60, "Get_Item1" },
|
||||
{ 90, "Caught_Mon" },
|
||||
{ 120, "Level_Up" },
|
||||
{ 150, "Get_Key_Item" },
|
||||
{ math.huge, "Get_Item2" },
|
||||
}
|
||||
|
||||
-- audio/pokedex_rating_sfx.asm:1
|
||||
function OverworldState.dexRatingSfx(owned)
|
||||
for _, row in ipairs(DEX_RATING_SFX) do
|
||||
if owned < row[1] then return row[2] end
|
||||
end
|
||||
end
|
||||
|
||||
-- Prof. Oak's dex rating service (engine/events/pokedex_rating.asm):
|
||||
-- the completion line with seen AND owned counts, then the per-decade
|
||||
-- rating text.
|
||||
@@ -3761,7 +3781,14 @@ function OverworldState:dexRating(onDone)
|
||||
-- the box to the plain A/B path once the jingle has sounded.
|
||||
Game.stack:push(TextBox.new(Game, completion .. "\f" .. rating, onDone, {
|
||||
auto = { wait = true, sound = function()
|
||||
return require("src.core.Sound").play(Game.data, "Pokedex_Rating")
|
||||
local Sound = require("src.core.Sound")
|
||||
local name = OverworldState.dexRatingSfx(owned)
|
||||
local src = Sound.play(Game.data, name)
|
||||
-- audio/pokedex_rating_sfx.asm:13
|
||||
if src and not Sound.ducksMusic(Game.data, name) then
|
||||
require("src.core.Music").duckForFanfare(src)
|
||||
end
|
||||
return src
|
||||
end },
|
||||
}))
|
||||
end
|
||||
@@ -4930,13 +4957,12 @@ function OverworldState:checkBadgeGate()
|
||||
end
|
||||
return false
|
||||
end
|
||||
-- Route22GateGuardNoBoulderbadgeText plays SFX_DENIED
|
||||
require("src.core.Sound").play(Game.data, "Denied")
|
||||
-- scripts/Route22Gate.asm:79
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
|
||||
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
|
||||
TextBox.strip(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
|
||||
.. TextBox.PAUSE .. (t._Route22GateGuardICantLetYouPassText or ""), function()
|
||||
self:scriptMove(p, "down", 1, nil, { collide = true })
|
||||
end))
|
||||
end, { pauseSounds = { "Denied" }, pauseSoundWait = true }))
|
||||
return true
|
||||
end
|
||||
end
|
||||
@@ -4947,31 +4973,39 @@ function OverworldState:checkBadgeGate()
|
||||
for _, guard in ipairs(g.guards) do
|
||||
if p.cellY == guard.y and (not guard.maxX or p.cellX <= guard.maxX)
|
||||
and not Game.save.flags[guard.event] then
|
||||
local badgeName = Game.data.items[guard.badge]
|
||||
and Game.data.items[guard.badge].name or guard.badge
|
||||
if Game.save.inventory[guard.badge] then
|
||||
Flags.set(Game.save, guard.event)
|
||||
-- Route23OhThatIsTheBadgeText carries sound_get_item_1
|
||||
local text = (t["_" .. g.passText] or
|
||||
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text,
|
||||
nil, TextBox.soundOpts(Game, "Get_Item1")))
|
||||
return false
|
||||
end
|
||||
-- Route23YouDontHaveTheBadgeYetText plays SFX_DENIED
|
||||
require("src.core.Sound").play(Game.data, "Denied")
|
||||
local text = (t["_" .. g.failText] or
|
||||
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
self:scriptMove(p, "down", 1, nil, { collide = true })
|
||||
end))
|
||||
return true
|
||||
return not self:route23BadgeCheck(guard.badge, guard.event)
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- scripts/Route23.asm:195
|
||||
function OverworldState:route23BadgeCheck(badge, event, onDone)
|
||||
local gates = Game.data.field.badgeGates
|
||||
local g = gates and gates[self.map.id] or {}
|
||||
local t = Game.data.text
|
||||
local badgeName = Game.data.items[badge] and Game.data.items[badge].name or badge
|
||||
if Game.save.inventory[badge] then
|
||||
Flags.set(Game.save, event)
|
||||
-- scripts/Route23.asm:237
|
||||
local text = (t["_" .. (g.passText or "Route23OhThatIsTheBadgeText")] or
|
||||
Strings("Oh! That is the\n{RAM}!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
TextBox.strip(text) .. TextBox.PAUSE .. (t._Route23GoRightAheadText or ""),
|
||||
onDone, { pauseSounds = { "Get_Item1" }, pauseSoundWait = true }))
|
||||
return true
|
||||
end
|
||||
-- scripts/Route23.asm:229
|
||||
local text = (t["_" .. (g.failText or "Route23YouDontHaveTheBadgeYetText")] or
|
||||
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
|
||||
Game.stack:push(TextBox.new(Game, text, function()
|
||||
-- scripts/Route23.asm:123
|
||||
self:scriptMove(self.player, "down", 1, onDone, { collide = true })
|
||||
end, TextBox.soundOpts(Game, "Denied")))
|
||||
return false
|
||||
end
|
||||
|
||||
-- Forced bike/surf tiles (data/maps/force_bike_surf.asm): the Cycling
|
||||
-- Road entrances force you onto the BICYCLE (or turn you back without
|
||||
-- one); the Seafoam current mouths force surfing.
|
||||
@@ -6014,6 +6048,40 @@ local function fieldFxImage(path, image, group)
|
||||
return SpriteRenderer.obpImage(path, colors, name)
|
||||
end
|
||||
|
||||
-- engine/overworld/dust_smoke.asm:21
|
||||
local OBP1_FLASH = { [0] = 0, [1] = 0, [2] = 0, [3] = 2 }
|
||||
local FLASH_GRAYS = PaletteFX.permute(PaletteFX.GRAYS, OBP1_FLASH)
|
||||
local fieldFxFlashColors = setmetatable({}, { __mode = "k" })
|
||||
local fieldFxFlashNames = {}
|
||||
|
||||
local function fieldFxFlashImage(path, group)
|
||||
local colors, key = FLASH_GRAYS, "gray"
|
||||
if PaletteFX.usesGbcPack() then
|
||||
local pack = PaletteFX.worldPack()
|
||||
local obj = pack and pack.spritePalettes and pack.spritePalettes[group]
|
||||
if obj then
|
||||
colors = fieldFxFlashColors[obj]
|
||||
if not colors then
|
||||
colors = PaletteFX.permute(obj, OBP1_FLASH)
|
||||
fieldFxFlashColors[obj] = colors
|
||||
end
|
||||
key = group
|
||||
end
|
||||
end
|
||||
local version = GameVersion.get()
|
||||
local names = fieldFxFlashNames[version]
|
||||
if not names then
|
||||
names = {}
|
||||
fieldFxFlashNames[version] = names
|
||||
end
|
||||
local name = names[key]
|
||||
if not name then
|
||||
name = "fieldfxflash:" .. version .. ":" .. key
|
||||
names[key] = name
|
||||
end
|
||||
return SpriteRenderer.obpImage(path, colors, name)
|
||||
end
|
||||
|
||||
-- the Cut/boulder dust puff: the smoke tile drawn 2x2 over the cell,
|
||||
-- flickering (AnimateBoulderDust XORs the OBJ palette every step)
|
||||
local function fxDust(self, cam)
|
||||
@@ -6027,12 +6095,19 @@ local function fxDust(self, cam)
|
||||
end
|
||||
if self.smokeImg then
|
||||
local da = self.dustAnim
|
||||
local img = fieldFxImage(smoke.path, self.smokeImg, da.boulder and 7 or 6)
|
||||
local group = da.boulder and 7 or 6
|
||||
local flash
|
||||
if da.boulder then
|
||||
flash = da.faded
|
||||
else
|
||||
-- engine/overworld/cut2.asm:68
|
||||
flash = da.frames % 2 == 1
|
||||
end
|
||||
local img = flash and fieldFxFlashImage(smoke.path, group)
|
||||
or fieldFxImage(smoke.path, self.smokeImg, group)
|
||||
local dx = da.x * 16 + (da.ox or 0) - cam.x
|
||||
local dy = da.y * 16 + (da.oy or 0) - cam.y
|
||||
local flicker = math.floor(da.frames / 4) % 2 == 0
|
||||
if da.boulder then flicker = not da.faded end
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
for i = 0, 1 do
|
||||
for j = 0, 1 do
|
||||
love.graphics.draw(img, dx + i * 8, dy + j * 8)
|
||||
@@ -6068,8 +6143,9 @@ local function fxCutTree(self, cam)
|
||||
local off = (ca.total or 8) - ca.frames
|
||||
local dx = ca.x * 16 - cam.x
|
||||
local dy = ca.y * 16 - cam.y
|
||||
local flicker = ca.frames % 2 == 0
|
||||
love.graphics.setColor(1, 1, 1, flicker and 1 or 0.55)
|
||||
-- engine/overworld/cut2.asm:18
|
||||
if ca.frames % 2 == 1 then img = fieldFxFlashImage(tree.path, 6) end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(img, self.cutTreeQuads[1], dx + off, dy)
|
||||
love.graphics.draw(img, self.cutTreeQuads[2], dx - off, dy + 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
@@ -6223,22 +6299,31 @@ local function entityDrawOrder(a, b)
|
||||
return a.pikachuFollower == true and b.pikachuFollower ~= true
|
||||
end
|
||||
|
||||
-- Tall-grass feet overdraw queue for the flat path: each distinct grass
|
||||
-- cell an entity stands on or steps into, in first-queued order. A
|
||||
-- scratch pair of coordinate arrays reused every frame (the queue holds a
|
||||
-- few cells, so the duplicate check is a short linear scan) instead of a
|
||||
-- "cx:cy" string key and a { cx, cy } table per cell per frame.
|
||||
local function queueGrass(self, q, cx, cy)
|
||||
if cx == nil or cy == nil or not self.map:isGrassCell(cx, cy) then
|
||||
return
|
||||
end
|
||||
-- engine/overworld/movement.asm:90
|
||||
local function grassStrip(self, e)
|
||||
local map = self.map
|
||||
local inGrass = (e.cellX ~= nil and e.cellY ~= nil
|
||||
and map:isGrassCell(e.cellX, e.cellY))
|
||||
or (e.targetX ~= nil and e.targetY ~= nil
|
||||
and map:isGrassCell(e.targetX, e.targetY))
|
||||
if not inGrass then return nil end
|
||||
local sprite, px, py = nil, e.px, e.py
|
||||
if e.pose then sprite, px, py = e:pose() end
|
||||
return map.renderer.feetStrip(px or 0, py or 0, sprite)
|
||||
end
|
||||
|
||||
local function queueGrass(self, q, e)
|
||||
local x0, y0, x1, y1 = grassStrip(self, e)
|
||||
if not x0 then return end
|
||||
local n = q.n
|
||||
local xs, ys = q.x, q.y
|
||||
local qx0, qy0, qx1, qy1 = q.x0, q.y0, q.x1, q.y1
|
||||
for i = 1, n do
|
||||
if xs[i] == cx and ys[i] == cy then return end
|
||||
if qx0[i] == x0 and qy0[i] == y0 and qx1[i] == x1 and qy1[i] == y1 then
|
||||
return
|
||||
end
|
||||
end
|
||||
n = n + 1
|
||||
xs[n], ys[n] = cx, cy
|
||||
qx0[n], qy0[n], qx1[n], qy1[n] = x0, y0, x1, y1
|
||||
q.n = n
|
||||
end
|
||||
|
||||
@@ -6289,7 +6374,7 @@ function OverworldState:drawWorld()
|
||||
-- home/fade.asm:52-58
|
||||
-- home/fade.asm:65-73
|
||||
local fadeObp = fade.obp0 and fade:obp0() or fadeBgp
|
||||
PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp))
|
||||
PaletteFX.setFadeObp(Transition.shadeMapFor(fadeObp) or OBP_IDENTITY)
|
||||
fade.paletteStepped = true
|
||||
elseif self.emote and self.emote.bgp and not battleOverWorld then
|
||||
-- engine/pikachu/pikachu_pic_animation.asm:847
|
||||
@@ -6498,10 +6583,10 @@ function OverworldState:drawWorld()
|
||||
local grassColors = PaletteFX.usesSpriteObp()
|
||||
and PaletteFX.pal(Game.data, self:paletteNameFor(self.map)) or nil
|
||||
local boulderDust = self.dustAnim and self.dustAnim.boulder
|
||||
local grass = self.grassScratch
|
||||
local grass = self.grassStripScratch
|
||||
if not grass then
|
||||
grass = { n = 0, x = {}, y = {} }
|
||||
self.grassScratch = grass
|
||||
grass = { n = 0, x0 = {}, y0 = {}, x1 = {}, y1 = {} }
|
||||
self.grassStripScratch = grass
|
||||
end
|
||||
grass.n = 0
|
||||
if boulderDust then fxDust(self, cam) end
|
||||
@@ -6517,18 +6602,18 @@ function OverworldState:drawWorld()
|
||||
if not ((self.flyAnim or self.flyArrive or self.playerHidden)
|
||||
and e == self.player) and not self:oamCulled(e) then
|
||||
e:draw(cam.x, cam.y)
|
||||
queueGrass(self, grass, e.cellX, e.cellY)
|
||||
queueGrass(self, grass, e.targetX, e.targetY)
|
||||
queueGrass(self, grass, e)
|
||||
end
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
local gxs, gys = grass.x, grass.y
|
||||
local shake = bgY - cam.y
|
||||
for i = 1, grass.n do
|
||||
local cx, cy = gxs[i], gys[i]
|
||||
self.map.renderer:drawCellBottom(cx, cy, cam.x, bgY)
|
||||
local x0, y0 = grass.x0[i], grass.y0[i] + shake
|
||||
local x1, y1 = grass.x1[i], grass.y1[i] + shake
|
||||
self.map.renderer:drawStrip(x0, y0, x1, y1, cam.x, bgY)
|
||||
if grassColors then
|
||||
self.map.renderer:markCellBottomRedraw(cx, cy,
|
||||
cam.x, bgY, grassColors)
|
||||
self.map.renderer:markStripRedraw(x0, y0, x1, y1,
|
||||
cam.x, bgY, grassColors)
|
||||
end
|
||||
end
|
||||
fxHeal(self, cam)
|
||||
@@ -6588,18 +6673,15 @@ function OverworldState:drawWorld()
|
||||
function() e:draw(cam.x, cam.y) end)
|
||||
-- tall-grass feet overdraw glued to the sprite: same anchor + depth
|
||||
-- so it keeps hiding the feet, color-0-keyed palette so its white
|
||||
-- gaps still show the sprite through (drawCellBottomRaw lets the
|
||||
-- gaps still show the sprite through (drawStripRaw lets the
|
||||
-- billboard own the shader; bgY keeps the elevator-shake offset).
|
||||
if self.map:isGrassCell(e.cellX, e.cellY) then
|
||||
local x0, y0, x1, y1 = grassStrip(self, e)
|
||||
if x0 then
|
||||
local shake = bgY - cam.y
|
||||
self:billboard(fx, fy, vw, vh, colors, true, function()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.map.renderer:drawCellBottomRaw(e.cellX, e.cellY, cam.x, bgY)
|
||||
end)
|
||||
end
|
||||
if e.targetX and self.map:isGrassCell(e.targetX, e.targetY) then
|
||||
self:billboard(fx, fy, vw, vh, colors, true, function()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
self.map.renderer:drawCellBottomRaw(e.targetX, e.targetY, cam.x, bgY)
|
||||
self.map.renderer:drawStripRaw(x0, y0 + shake, x1, y1 + shake,
|
||||
cam.x, bgY)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
-- engine/menus/start_sub_menus.asm:298
|
||||
-- engine/items/item_effects.asm:2172
|
||||
-- tools/run_driver.sh red <identity> tests/drivers/bag_use_box_bug2657.lua <shot dir>
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
U.log(pass and "PASS" or "FAIL", label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
local function finish()
|
||||
U.log(ok and "PASS bag_use_box_2657" or "FAIL bag_use_box_2657")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
local function onStack(state)
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if s == state then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function boxText(box)
|
||||
local lines = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do lines[#lines + 1] = line end
|
||||
end
|
||||
return table.concat(lines, " / ")
|
||||
end
|
||||
local function waitFor(pred, n)
|
||||
for _ = 1, n or 300 do
|
||||
if pred(game.stack:top()) then return game.stack:top() end
|
||||
U.wait(1)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local function stepTo(state, wants, what, dir)
|
||||
for _ = 1, 30 do
|
||||
if wants(state) then return true end
|
||||
U.tap(game, dir or "down")
|
||||
U.wait(6)
|
||||
end
|
||||
check("found " .. what .. " in the menu", false)
|
||||
return false
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
if not check("new game reached the overworld", game.overworld ~= nil) then finish() end
|
||||
game.save.inventory = {}
|
||||
Bag.add(game.save, "POKE_BALL", 5)
|
||||
Bag.add(game.save, "TM_TOXIC", 1)
|
||||
U.teleport(game, "REDS_HOUSE_1F", 5, 5, "up")
|
||||
U.wait(20)
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(20)
|
||||
local start = game.stack:top()
|
||||
local ITEM = Strings("ITEM")
|
||||
if not check("START opened a menu", start and start.items ~= nil) then finish() end
|
||||
if stepTo(start, function(m) return m.items[m.index].label == ITEM end, "ITEM") then
|
||||
U.tap(game, "a")
|
||||
U.wait(20)
|
||||
end
|
||||
local bag = game.stack:top()
|
||||
if not check("the bag opened", bag and bag.items and bag.items[1] ~= nil) then finish() end
|
||||
|
||||
local function useOn(id, dir)
|
||||
if not stepTo(bag, function(b) return b.items[b.index].value == id end, id, dir) then finish() end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
local sub = game.stack:top()
|
||||
if not check(id .. ": USE/TOSS opened", getmetatable(sub) == Menu and #sub.items == 2) then finish() end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
return sub
|
||||
end
|
||||
|
||||
local sub = useOn("TM_TOXIC")
|
||||
local booted = waitFor(function(t)
|
||||
return getmetatable(t) == TextBox and (t.waiting or t.done)
|
||||
and boxText(t):find("Booted up", 1, true)
|
||||
end)
|
||||
if not check("TM: Booted up text printed", booted ~= nil) then
|
||||
local t = game.stack:top()
|
||||
U.log("top:", tostring(getmetatable(t) == TextBox), t and t.done, t and t.pages and boxText(t))
|
||||
finish()
|
||||
end
|
||||
check("TM: USE/TOSS box left painted on the bag", bag.optionBox == sub and not onStack(sub))
|
||||
check("TM: hollow cursor on USE", sub.hollowIndex == 1 and sub.index == 1)
|
||||
U.wait(10)
|
||||
check("shot 1", U.still(game, DIR .. "/2657_01_booted_up_box_kept.png"))
|
||||
|
||||
local yesno = nil
|
||||
for _ = 1, 40 do
|
||||
yesno = getmetatable(game.stack:top()) == ChoiceBox and game.stack:top() or nil
|
||||
if yesno then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
if not check("TM: Teach YES/NO up", yesno ~= nil) then finish() end
|
||||
check("TM: box still painted under YES/NO", bag.optionBox == sub)
|
||||
U.wait(10)
|
||||
check("shot 2", U.still(game, DIR .. "/2657_02_teach_yes_no_over_box.png"))
|
||||
|
||||
U.tap(game, "b")
|
||||
waitFor(function(t) return t == bag end, 120)
|
||||
U.wait(5)
|
||||
check("TM: NO returns to the bag list", game.stack:top() == bag)
|
||||
check("TM: the list's redraw erased the box", bag.optionBox == nil)
|
||||
check("shot 3", U.still(game, DIR .. "/2657_03_list_redrawn.png"))
|
||||
|
||||
local sub2 = useOn("POKE_BALL", "up")
|
||||
local refusal = waitFor(function(t)
|
||||
return getmetatable(t) == TextBox and (t.waiting or t.done)
|
||||
end)
|
||||
if check("BALL: refusal text printed", refusal ~= nil) then
|
||||
U.log("the box reads:", boxText(refusal))
|
||||
end
|
||||
check("BALL: USE/TOSS box left painted on the bag", bag.optionBox == sub2)
|
||||
U.wait(10)
|
||||
check("shot 4", U.still(game, DIR .. "/2657_04_refusal_box_kept.png"))
|
||||
|
||||
for _ = 1, 20 do
|
||||
if game.stack:top() == bag then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
end
|
||||
U.wait(3)
|
||||
check("BALL: back on the list", game.stack:top() == bag)
|
||||
check("BALL: box erased", bag.optionBox == nil)
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,156 @@
|
||||
-- engine/battle/common_text.asm:10
|
||||
-- engine/battle/common_text.asm:43
|
||||
local U = require("tests.drivers.util")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Version = require("src.core.GameVersion")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
return function(game)
|
||||
local deadline = love.timer.getTime() + 60
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
assert(Version.generation() == 1, "Gen1 required")
|
||||
local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required")
|
||||
|
||||
local function wait(n)
|
||||
for _ = 1, n do
|
||||
assert(love.timer.getTime() < deadline, "#2662 deadline")
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
|
||||
local function snapshot(battle)
|
||||
local rows, strings = 0, {}
|
||||
local realRow, realFont, realDraw = battle.drawBallRow, Font.draw, love.graphics.draw
|
||||
battle.drawBallRow = function() rows = rows + 1 end
|
||||
Font.draw = function(text) strings[#strings + 1] = tostring(text) return 0 end
|
||||
love.graphics.draw = function() end
|
||||
pcall(battle.drawHUDs, battle, 0)
|
||||
battle.drawBallRow, Font.draw, love.graphics.draw = realRow, realFont, realDraw
|
||||
local hud = false
|
||||
for _, s in ipairs(strings) do
|
||||
if s:find(battle.enemy.name, 1, true) then hud = true end
|
||||
end
|
||||
return rows, hud
|
||||
end
|
||||
|
||||
game:startNewGame({ intro = false })
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local function freshParty()
|
||||
game.save.party = { Pokemon.new(game.data, "BULBASAUR", 12),
|
||||
Pokemon.new(game.data, "PIDGEY", 9) }
|
||||
end
|
||||
|
||||
local function introText(b)
|
||||
local cur = b.current and b.current.text
|
||||
if cur == b.introText then return true end
|
||||
return b.current == nil and b.queue[1] ~= nil and b.queue[1].text == b.introText
|
||||
end
|
||||
|
||||
local function watch(label, battle, holdShot)
|
||||
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
|
||||
wait(5)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
local r = { early = false, during = false, hudEarly = false, rowsEarly = false,
|
||||
heldFrames = 0, holdShot = false }
|
||||
local update = battle.update
|
||||
battle.update = function(self, ...)
|
||||
update(self, ...)
|
||||
if (self.introSlide or 0) ~= 0 or game.stack:top() ~= self then return end
|
||||
local atText = introText(self)
|
||||
if self.introBalls == true and not atText then r.early = true end
|
||||
if self.introBalls == true and self.waitingSound then r.during = true end
|
||||
if not atText and not r.reachedText then
|
||||
local rows, hud = snapshot(self)
|
||||
if rows > 0 then r.rowsEarly = true end
|
||||
if hud then r.hudEarly = true end
|
||||
if self.waitingSound or (self.waitFrames or 0) > 0 then
|
||||
r.heldFrames = r.heldFrames + 1
|
||||
end
|
||||
end
|
||||
if atText then r.reachedText = true end
|
||||
end
|
||||
local shotHeld = false
|
||||
for _ = 1, 3000 do
|
||||
wait(1)
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0
|
||||
and not shotHeld and (battle.waitingSound or (battle.waitFrames or 0) > 0)
|
||||
and not introText(battle) then
|
||||
shotHeld = U.still(game, out .. "/" .. holdShot)
|
||||
r.holdShot = shotHeld and battle.introBalls ~= true
|
||||
end
|
||||
if battle.current and battle.current.text == battle.introText then break end
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
local function waitPrompt(b)
|
||||
for _ = 1, 600 do
|
||||
if b.msgPrompt then return true end
|
||||
wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
freshParty()
|
||||
local wild = BattleState.newWild(game, "RATTATA", 3)
|
||||
local r = watch("wild", wild, "2662_01_wild_cry_no_balls.png")
|
||||
check("wild: the cry held the queue after the slide landed", r.heldFrames > 0)
|
||||
check("wild: captured the cry frame with no ball window", r.holdShot)
|
||||
check("wild: no ball window before the intro text", not r.early)
|
||||
check("wild: no ball window while the cry sounds", not r.during)
|
||||
check("wild: no ball row drawn during the cry", not r.rowsEarly)
|
||||
check("wild: no enemy HUD drawn during the cry", not r.hudEarly)
|
||||
check("wild: the ball window is open with the intro text", wild.introBalls == true)
|
||||
waitPrompt(wild)
|
||||
check("wild: balls and text shot", U.still(game, out .. "/2662_02_wild_balls_with_text.png"))
|
||||
|
||||
freshParty()
|
||||
local tr = BattleState.newTrainer(game, "OPP_YOUNGSTER", 1)
|
||||
r = watch("trainer", tr, "2662_03_trainer_sfx_no_balls.png")
|
||||
check("trainer: the sfx and gap held the queue", r.heldFrames > 0)
|
||||
check("trainer: captured the sfx frame with no ball window", r.holdShot)
|
||||
check("trainer: no ball window before the intro text", not r.early)
|
||||
check("trainer: no ball window while the sfx sounds", not r.during)
|
||||
check("trainer: no ball rows drawn during the sfx and gap", not r.rowsEarly)
|
||||
check("trainer: the ball window is open with the intro text", tr.introBalls == true)
|
||||
waitPrompt(tr)
|
||||
check("trainer: balls and text shot", U.still(game, out .. "/2662_04_trainer_balls_with_text.png"))
|
||||
|
||||
freshParty()
|
||||
local ghost = BattleState.newWild(game, "GASTLY", 20)
|
||||
ghost:makeGhost()
|
||||
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
|
||||
wait(5)
|
||||
ghost.onFinish = function() end
|
||||
game.overworld:pushBattle(ghost)
|
||||
local ghostBalls, sawDarn = false, false
|
||||
for _ = 1, 3000 do
|
||||
wait(1)
|
||||
if ghost.introBalls == true then ghostBalls = true end
|
||||
local cur = ghost.current and ghost.current.text
|
||||
if cur and cur:find("ID'd", 1, true) then sawDarn = true end
|
||||
if ghost.msgPrompt then
|
||||
if sawDarn then break end
|
||||
wait(8)
|
||||
U.tap(game, "a")
|
||||
end
|
||||
end
|
||||
check("ghost: GhostCantBeIDdText follows the intro text", sawDarn)
|
||||
check("ghost: DrawAllPokeballs never runs", not ghostBalls)
|
||||
if sawDarn then
|
||||
check("ghost: cant be IDd shot", U.still(game, out .. "/2662_05_ghost_cant_be_idd.png"))
|
||||
end
|
||||
end, debug.traceback)
|
||||
if not ok then print("FAIL #2662 driver error: " .. tostring(err)) fails = fails + 1 end
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -6,7 +6,7 @@
|
||||
-- POKEPORT_DRIVER=tests/drivers/battle_intro_bug317_test.lua POKEPORT_IDENTITY=bug317 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local HudTiles = require("src.render.HudTiles")
|
||||
@@ -19,8 +19,10 @@ return function(game)
|
||||
local TRAINER = "ROUTE3_YOUNGSTER1"
|
||||
local STAND = { x = 13, y = 6, facing = "left" }
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
@@ -103,8 +105,9 @@ return function(game)
|
||||
local wild = BattleState.newWild(game, "RATTATA", 3)
|
||||
wild.onFinish = function() end
|
||||
if ow then ow:pushBattle(wild) end
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == wild and (wild.introSlide or 0) == 0 then break end
|
||||
for _ = 1, 600 do
|
||||
if game.stack:top() == wild and (wild.introSlide or 0) == 0
|
||||
and wild.current and wild.current.text == wild.introText then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the wild battle reached the screen",
|
||||
@@ -226,8 +229,9 @@ return function(game)
|
||||
if ow then ow:pushBattle(battle) end
|
||||
end
|
||||
|
||||
for _ = 1, 400 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
||||
for _ = 1, 600 do
|
||||
if game.stack:top() == battle and (battle.introSlide or 0) == 0
|
||||
and battle.current and battle.current.text == battle.introText then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("a trainer battle is on screen", liveBattle() ~= nil)
|
||||
@@ -249,7 +253,5 @@ return function(game)
|
||||
U.log("own edge before its send-out text prints (#317).")
|
||||
U.log("Screenshots of the wild half: " .. DIR .. "/bug317_*.png")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
-- engine/overworld/dust_smoke.asm:21
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local MAP = "SEAFOAM_ISLANDS_1F"
|
||||
local DIRS = { "right", "left", "down", "up" }
|
||||
local STEP = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
local PUSH = "left"
|
||||
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
print((pass and "PASS " or "FAIL ") .. label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
local realDraw = love.graphics.draw
|
||||
local function finish()
|
||||
love.graphics.draw = realDraw
|
||||
for _, d in ipairs(DIRS) do game.input.state[d] = false end
|
||||
print(ok and "PASS boulder_dust_color_2650" or "FAIL boulder_dust_color_2650")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function upvalue(fn, wanted)
|
||||
for i = 1, 200 do
|
||||
local name, value = debug.getupvalue(fn, i)
|
||||
if not name then return nil end
|
||||
if name == wanted then return value end
|
||||
end
|
||||
end
|
||||
local fxDust = upvalue(OW.drawWorld, "fxDust")
|
||||
local flashImage = fxDust and upvalue(fxDust, "fieldFxFlashImage")
|
||||
if not check("fxDust has an OBP1 flash bake helper", flashImage ~= nil) then finish() end
|
||||
|
||||
U.teleport(game, MAP, 17, 10, "right")
|
||||
U.wait(10)
|
||||
PaletteFX.setMode("ogred")
|
||||
local ow = game.overworld
|
||||
game.data.encounters[MAP] = nil
|
||||
local rock = ow and ow:npcAtCell(18, 10)
|
||||
if not check("boulder present at (18,10) on " .. MAP,
|
||||
rock ~= nil and rock.def.sprite == "SPRITE_BOULDER") then
|
||||
finish()
|
||||
end
|
||||
ow.strengthActive = true
|
||||
|
||||
local function free(x, y)
|
||||
if not (ow.map:inBounds(x, y) and ow.map:isWalkableCell(x, y)) then return false end
|
||||
local n = ow:npcAtCell(x, y)
|
||||
return n == nil or n == rock
|
||||
end
|
||||
local s = STEP[PUSH]
|
||||
local cx, cy
|
||||
for y = 9, 40 do
|
||||
for x = 0, 60 do
|
||||
if not cx and free(x, y) and free(x + s[1], y + s[2]) and free(x + 2 * s[1], y + 2 * s[2]) then
|
||||
cx, cy = x, y
|
||||
end
|
||||
end
|
||||
end
|
||||
if not check("found an open lane for a push", cx ~= nil) then finish() end
|
||||
|
||||
local function setup()
|
||||
for _, d in ipairs(DIRS) do game.input.state[d] = false end
|
||||
ow.dustAnim = nil
|
||||
ow.boulderTried = nil
|
||||
rock.cellX, rock.cellY = cx + s[1], cy + s[2]
|
||||
rock.px, rock.py = rock.cellX * 16, rock.cellY * 16
|
||||
rock.moving, rock.targetX, rock.targetY = false, nil, nil
|
||||
local p = ow.player
|
||||
p.cellX, p.cellY = cx, cy
|
||||
p.px, p.py = cx * 16, cy * 16
|
||||
p.moving, p.targetX, p.targetY = false, nil, nil
|
||||
p.facing = PUSH
|
||||
U.wait(8)
|
||||
end
|
||||
local function pushUntilDust()
|
||||
local first = true
|
||||
for _ = 1, 300 do
|
||||
if ow.dustAnim and ow.dustAnim.boulder then return ow.dustAnim end
|
||||
if first then table.insert(game.input.pressQueue, PUSH); first = false end
|
||||
game.input.state[PUSH] = true
|
||||
U.wait(1)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local rec
|
||||
love.graphics.draw = function(img, ...)
|
||||
if rec and (img == rec.flash or img == ow.smokeImg) then
|
||||
local _, _, _, a = love.graphics.getColor()
|
||||
rec.n = rec.n + 1
|
||||
if (img == rec.flash) ~= rec.faded then rec.wrongImg = rec.wrongImg + 1 end
|
||||
if a ~= 1 then rec.translucent = rec.translucent + 1 end
|
||||
end
|
||||
return realDraw(img, ...)
|
||||
end
|
||||
local function renderFrame(da, flash)
|
||||
local update = rawget(game, "update")
|
||||
game.update = function() end
|
||||
rec = { flash = flash, faded = da.faded, n = 0, wrongImg = 0, translucent = 0 }
|
||||
for _ = 1, 4000 do
|
||||
if rec.n >= 4 then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
game.update = update
|
||||
local r = rec
|
||||
rec = nil
|
||||
return r
|
||||
end
|
||||
|
||||
setup()
|
||||
local da = pushUntilDust()
|
||||
if not check("boulder dust starts after the push", da ~= nil) then finish() end
|
||||
game.input.state[PUSH] = false
|
||||
local flash = flashImage(game.data.field.overworldFx.smoke.path, 7)
|
||||
local frames, flashFrames, wrong, translucent, missing = 0, 0, 0, 0, 0
|
||||
while ow.dustAnim == da and frames < 60 do
|
||||
local r = renderFrame(da, flash)
|
||||
frames = frames + 1
|
||||
if r.faded then flashFrames = flashFrames + 1 end
|
||||
if r.n < 4 then missing = missing + 1 end
|
||||
wrong = wrong + r.wrongImg
|
||||
translucent = translucent + r.translucent
|
||||
U.wait(1)
|
||||
end
|
||||
print(("[driver] dust frames %d flash %d wrong %d translucent %d missing %d")
|
||||
:format(frames, flashFrames, wrong, translucent, missing))
|
||||
check("every dust frame rendered", missing == 0 and frames >= 23)
|
||||
check("OBP1 %10000000 frames use the white/shade-2 flash bake", flashFrames >= 12 and wrong == 0)
|
||||
check("dust is never drawn translucent", translucent == 0)
|
||||
U.wait(30)
|
||||
|
||||
setup()
|
||||
da = pushUntilDust()
|
||||
game.input.state[PUSH] = false
|
||||
if da then
|
||||
while ow.dustAnim == da and not da.faded do U.wait(1) end
|
||||
U.still(game, DIR .. "/2650_01_dust_flash_white.png")
|
||||
while ow.dustAnim == da and da.faded do U.wait(1) end
|
||||
if ow.dustAnim == da then
|
||||
U.still(game, DIR .. "/2650_02_dust_identity_floor_ramp.png")
|
||||
end
|
||||
end
|
||||
U.wait(30)
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
-- scripts/ChampionsRoom.asm:65
|
||||
-- tools/run_driver.sh red <identity> tests/drivers/champion_end_text_bug2660.lua <shotdir>
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
return ok
|
||||
end
|
||||
local function oneline(s)
|
||||
return (tostring(s):gsub("[\n\v\f]", " / "))
|
||||
end
|
||||
local function finish()
|
||||
U.log("done:", failures, "failure(s)")
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local expected = game.data.text and game.data.text._RivalDefeatedText
|
||||
check("cache carries _RivalDefeatedText", type(expected) == "string")
|
||||
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
mon.moves = { { id = "PSYCHIC_M", pp = 10, ppUps = 0 } }
|
||||
if not game.data.moves.PSYCHIC_M then
|
||||
mon.moves = { { id = "PSYCHIC", pp = 10, ppUps = 0 } }
|
||||
end
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "RED"
|
||||
game.save.player.rival = "BLUE"
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
|
||||
game.save.flags.EVENT_BEAT_CHAMPION_RIVAL = nil
|
||||
|
||||
U.teleport(game, "CHAMPIONS_ROOM", 4, 7, "up")
|
||||
|
||||
local battle, armed
|
||||
local sawDefeated, sawLoss, sawMoney
|
||||
local lossText, lossShot, headShot, lastText = nil, false, false, nil
|
||||
for _ = 1, 20000 do
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == BattleState then
|
||||
if not battle then
|
||||
battle = top
|
||||
armed = top.endBattleText
|
||||
U.log("battle pushed; endBattleText:", oneline(armed))
|
||||
for _, m in ipairs(top.enemyParty or {}) do m.hp = 1 end
|
||||
if top.enemy and top.enemy.mon then top.enemy.mon.hp = 1 end
|
||||
end
|
||||
local cur = top.current
|
||||
local text = cur and cur.text
|
||||
if text and text ~= lastText then
|
||||
lastText = text
|
||||
if text:find("defeated", 1, true) then sawDefeated = sawDefeated or U.frame() end
|
||||
if text:find("NO!", 1, true) and text:find("That can't be!", 1, true) then
|
||||
sawLoss = sawLoss or U.frame()
|
||||
lossText = text
|
||||
end
|
||||
if text:find("for winning", 1, true) then sawMoney = sawMoney or U.frame() end
|
||||
end
|
||||
local typingLoss = lossText and text == lossText
|
||||
if typingLoss and not headShot then
|
||||
if top.lineIndex == 2 and top.msgWaiting
|
||||
and (top.frame or 0) % 60 < 30 then
|
||||
headShot = U.still(game, DIR .. "/2660_01_rival_loss_first_page.png")
|
||||
end
|
||||
end
|
||||
if typingLoss and not lossShot
|
||||
and top.charIndex and top.total and top.charIndex >= top.total then
|
||||
lossShot = U.still(game, DIR .. "/2660_02_rival_loss_scrolled.png")
|
||||
end
|
||||
if sawMoney then break end
|
||||
if typingLoss and not headShot then
|
||||
U.wait(1)
|
||||
else
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
elseif battle then
|
||||
break
|
||||
else
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
end
|
||||
|
||||
check("champion battle pushed", battle ~= nil)
|
||||
check("battle armed with _RivalDefeatedText",
|
||||
type(armed) == "string" and armed:find("That can't be!", 1, true) ~= nil)
|
||||
check("defeated line printed", sawDefeated ~= nil)
|
||||
check("rival loss line printed on the battle screen", sawLoss ~= nil)
|
||||
if lossText then
|
||||
U.log("loss line:", oneline(lossText))
|
||||
check("loss line opens with the rival name tag", lossText:sub(1, 6) == "BLUE: ")
|
||||
end
|
||||
check("money line printed", sawMoney ~= nil)
|
||||
check("loss line sits between defeated and money",
|
||||
sawDefeated ~= nil and sawLoss ~= nil and sawMoney ~= nil
|
||||
and sawDefeated < sawLoss and sawLoss < sawMoney)
|
||||
check("loss line first page screenshotted", headShot)
|
||||
check("loss line screenshotted", lossShot)
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,65 @@
|
||||
-- scripts/ChampionsRoom.asm:30
|
||||
local U = require('tests.drivers.util')
|
||||
local Version = require('src.core.GameVersion')
|
||||
local TextBox = require('src.render.TextBox')
|
||||
|
||||
local WANT = '3,7 3,6 3,5 3,4 4,4 4,3'
|
||||
|
||||
return function(game)
|
||||
local deadline = love.timer.getTime() + 90
|
||||
local ok, err = xpcall(function()
|
||||
assert(Version.generation() == 1, 'Gen1 edition required')
|
||||
local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required')
|
||||
love.audio.setVolume(0)
|
||||
game:startNewGame({ intro = false })
|
||||
|
||||
local function run(shootAt)
|
||||
game.save.flags.EVENT_BEAT_CHAMPION_RIVAL_THIS_RUN = nil
|
||||
U.teleport(game, 'CHAMPIONS_ROOM', 3, 7, 'up')
|
||||
local ow = assert(game.overworld)
|
||||
local cells, last = {}, nil
|
||||
local frames, turnFrame = 0, nil
|
||||
for _ = 1, 1200 do
|
||||
assert(love.timer.getTime() < deadline, '#2666 deadline')
|
||||
local p = ow.player
|
||||
local key = p.cellX .. ',' .. p.cellY
|
||||
if key ~= last then
|
||||
cells[#cells + 1] = key
|
||||
last = key
|
||||
if key == '3,4' and not turnFrame then
|
||||
turnFrame = frames
|
||||
if shootAt then
|
||||
assert(U.still(game, out .. '/2666_01_turn_point_left_column.png'), 'capture failed')
|
||||
end
|
||||
end
|
||||
end
|
||||
if getmetatable(game.stack:top()) == TextBox then
|
||||
return table.concat(cells, ' '), turnFrame, p.facing
|
||||
end
|
||||
U.wait(1)
|
||||
frames = frames + 1
|
||||
end
|
||||
error('rival text never opened; walked ' .. table.concat(cells, ' '))
|
||||
end
|
||||
|
||||
local path, turnFrame, facing = run(false)
|
||||
if path == WANT then
|
||||
print('PASS #2666 entrance walk ' .. path)
|
||||
else
|
||||
error('entrance walk ' .. path .. ' want ' .. WANT)
|
||||
end
|
||||
if facing == 'up' then
|
||||
print('PASS #2666 player faces the rival')
|
||||
else
|
||||
error('player faces ' .. tostring(facing))
|
||||
end
|
||||
assert(turnFrame, 'never reached (3,4)')
|
||||
|
||||
run(true)
|
||||
U.wait(40)
|
||||
assert(U.still(game, out .. '/2666_02_rival_hey.png'), 'capture failed')
|
||||
print('PASS #2666 turn point shot at frame ' .. turnFrame)
|
||||
end, debug.traceback)
|
||||
if not ok then print('FAIL #2666 ' .. tostring(err)) end
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,180 @@
|
||||
-- engine/menus/main_menu.asm:357
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Font = require("src.render.Font")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/shots/2684"
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok, detail)
|
||||
print((ok and "PASS " or "FAIL ") .. label .. (detail and (" " .. tostring(detail)) or ""))
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
local function finish()
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local rendered = 0
|
||||
local hostDraw = love.draw
|
||||
love.draw = function(...)
|
||||
local r = hostDraw(...)
|
||||
rendered = rendered + 1
|
||||
return r
|
||||
end
|
||||
local function waitRendered(n)
|
||||
local target = rendered + (n or 2)
|
||||
for _ = 1, 2400 do
|
||||
if rendered >= target then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
local function waitFor(pred, limit)
|
||||
for _ = 1, limit or 1800 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function letterbox(img)
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local x1, y1, x2, y2 = W, H, -1, -1
|
||||
for y = 0, H - 1, 2 do
|
||||
for x = 0, W - 1, 2 do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
if r > 0.02 or g > 0.02 or b > 0.02 then
|
||||
if x < x1 then x1 = x end
|
||||
if x > x2 then x2 = x end
|
||||
if y < y1 then y1 = y end
|
||||
if y > y2 then y2 = y end
|
||||
end
|
||||
end
|
||||
end
|
||||
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
|
||||
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
|
||||
return x1, y1, bw / 160, bh / 144
|
||||
end
|
||||
local s = math.min(W / 160, H / 144)
|
||||
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s, s
|
||||
end
|
||||
local function inkInTile(img, ox, oy, sx, sy, col, row)
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local n = 0
|
||||
for py = 1, 6 do
|
||||
for px = 1, 6 do
|
||||
local x = math.floor(ox + (col * 8 + px + 0.5) * sx)
|
||||
local y = math.floor(oy + (row * 8 + py + 0.5) * sy)
|
||||
if x >= 0 and y >= 0 and x < W and y < H then
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
if (r + g + b) / 3 < 0.35 then n = n + 1 end
|
||||
end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
U.wait(5)
|
||||
local title
|
||||
local reached = false
|
||||
for _ = 1, 120 do
|
||||
title = game.stack:top()
|
||||
if title ~= nil and title.screenId == "TitleState"
|
||||
and type(title.openMenu) == "function" then
|
||||
reached = true
|
||||
break
|
||||
end
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
end
|
||||
if not check("reached the title screen", reached) then
|
||||
print("top state: " .. tostring(title and title.screenId))
|
||||
return finish()
|
||||
end
|
||||
check("title reached its loop", waitFor(function()
|
||||
return title.phase == "loop"
|
||||
end))
|
||||
waitRendered(3)
|
||||
|
||||
local saveName = SaveData.saveFilename(GameVersion.get())
|
||||
if not love.filesystem.getInfo(saveName) then
|
||||
love.filesystem.write(saveName, "return {}")
|
||||
end
|
||||
local owned = {}
|
||||
for i = 1, 151 do owned[i] = true end
|
||||
local inventory = {}
|
||||
for _, id in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
|
||||
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE",
|
||||
"EARTHBADGE" }) do
|
||||
inventory[id] = 1
|
||||
end
|
||||
local savedLoad = SaveData.load
|
||||
SaveData.load = function()
|
||||
return { player = { name = "RED" }, inventory = inventory,
|
||||
pokedex = { owned = owned }, playTime = 47 * 3600 + 27 * 60 }
|
||||
end
|
||||
|
||||
U.tap(game, "start")
|
||||
check("START leaves the title for the main menu", waitFor(function()
|
||||
local top = game.stack:top()
|
||||
return title.menuOpen and top ~= title and top and top.titleUiBox ~= nil
|
||||
end, 1200))
|
||||
waitRendered(3)
|
||||
local menu = game.stack:top()
|
||||
check("main menu opens on CONTINUE", menu and menu.items
|
||||
and menu.items[1] and menu.items[1].label == require("src.core.Strings")("CONTINUE"))
|
||||
U.tap(game, "a")
|
||||
local info
|
||||
check("CONTINUE opens the info window", waitFor(function()
|
||||
info = game.stack:top()
|
||||
return info ~= menu and info and info.titleUiBox
|
||||
and info.titleUiBox[1] == 4 and info.titleUiBox[2] == 7
|
||||
end, 300))
|
||||
SaveData.load = savedLoad
|
||||
if not info or info == menu then return finish() end
|
||||
|
||||
local drawn = {}
|
||||
local realDraw = Font.draw
|
||||
Font.draw = function(text, x, y, ...)
|
||||
drawn[text] = { x, y }
|
||||
return realDraw(text, x, y, ...)
|
||||
end
|
||||
waitRendered(3)
|
||||
Font.draw = realDraw
|
||||
|
||||
local b, d, t = drawn[" 8"], drawn["151"], drawn[" 47:27"]
|
||||
check("badge count drawn at hlcoord 17,11",
|
||||
b and b[1] == 17 * 8 and b[2] == 11 * 8, b and (b[1] .. "," .. b[2]))
|
||||
check("dex count drawn at hlcoord 16,13",
|
||||
d and d[1] == 16 * 8 and d[2] == 13 * 8, d and (d[1] .. "," .. d[2]))
|
||||
check("play time drawn at hlcoord 13,15",
|
||||
t and t[1] == 13 * 8 and t[2] == 15 * 8, t and (t[1] .. "," .. t[2]))
|
||||
|
||||
local shot = SHOT_DIR .. "/2684_01_continue_counts_flush_right.png"
|
||||
if check("info window shot", U.still(game, shot)) then
|
||||
local f = io.open(shot, "rb")
|
||||
local bytes = f and f:read("*a")
|
||||
if f then f:close() end
|
||||
local ok, img = pcall(function()
|
||||
return love.image.newImageData(love.filesystem.newFileData(bytes, "s.png"))
|
||||
end)
|
||||
if check("shot decodes", ok and img) then
|
||||
local ox, oy, sx, sy = letterbox(img)
|
||||
check("badge 8 inks col 18 row 11", inkInTile(img, ox, oy, sx, sy, 18, 11) > 0)
|
||||
check("col 17 row 11 stays blank", inkInTile(img, ox, oy, sx, sy, 17, 11) == 0)
|
||||
check("dex 151 inks col 18 row 13", inkInTile(img, ox, oy, sx, sy, 18, 13) > 0)
|
||||
check("col 15 row 13 stays blank", inkInTile(img, ox, oy, sx, sy, 15, 13) == 0)
|
||||
check("time inks col 18 row 15", inkInTile(img, ox, oy, sx, sy, 18, 15) > 0)
|
||||
end
|
||||
end
|
||||
|
||||
game.stack:pop()
|
||||
waitRendered(2)
|
||||
print(fails == 0 and "PASS continue_info_counts_bug2684" or "FAIL continue_info_counts_bug2684")
|
||||
return finish()
|
||||
end
|
||||
@@ -0,0 +1,98 @@
|
||||
-- engine/events/pokedex_rating.asm:33
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Timing = require("src.core.Timing")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local ok = true
|
||||
local function check(label, pass)
|
||||
print((pass and "PASS " or "FAIL ") .. label)
|
||||
if not pass then ok = false end
|
||||
return pass
|
||||
end
|
||||
local realPlay = Sound.play
|
||||
local plays = {}
|
||||
Sound.play = function(data, name, ...)
|
||||
plays[#plays + 1] = name
|
||||
return realPlay(data, name, ...)
|
||||
end
|
||||
local function finish()
|
||||
Sound.play = realPlay
|
||||
print(ok and "PASS dex_rating_complete_2674" or "FAIL dex_rating_complete_2674")
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
U.teleport(game, "OAKS_LAB", 5, 3, "up")
|
||||
U.wait(10)
|
||||
local ow = game.overworld
|
||||
if not check("overworld is up", ow ~= nil) then finish() end
|
||||
local full = {}
|
||||
for i = 1, 151 do full[i] = true end
|
||||
game.save.pokedex = { seen = full, owned = full }
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
|
||||
local closed = false
|
||||
ow:dexRating(function() closed = true end)
|
||||
local box = game.stack:top()
|
||||
if not check("rating box pushed", getmetatable(box) == TextBox) then finish() end
|
||||
|
||||
local SPACE = 0x7F
|
||||
local target
|
||||
for _ = 1, 6000 do
|
||||
U.wait(1)
|
||||
if box.waiting and box.contAdvance and #box.shown == 2 and #box.shown[2] == 18 then
|
||||
target = true
|
||||
break
|
||||
end
|
||||
if box.waiting and not box.contAdvance and (box.preWait or 0) == 0 then
|
||||
U.tap(game, "a")
|
||||
end
|
||||
if box.done then break end
|
||||
end
|
||||
if not check("reached the cont wait under an 18-tile line", target) then finish() end
|
||||
check("the cont line is the Own150To151 text",
|
||||
(box.pages[box.pageIndex][box.lineIndex] or ""):find("complete", 1, true) ~= nil)
|
||||
check("arrow is up over cell (18,16)", box:arrowVisible() and box.fixedGen1Rows)
|
||||
local early = false
|
||||
for _, name in ipairs(plays) do
|
||||
if name == "Get_Item2" or name == "Pokedex_Rating" then early = true end
|
||||
end
|
||||
check("no rating jingle yet", not early)
|
||||
for _ = 1, 120 do
|
||||
if box.blink % 60 < 30 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.still(game, DIR .. "/2674_01_arrow_replaces_bang.png")
|
||||
|
||||
for _ = 1, Timing.TEXT_PRE_ADVANCE + 2 do U.wait(1) end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
check("scrolled line carries a space in column 18",
|
||||
box.shown[1] and box.shown[1][18] == SPACE)
|
||||
|
||||
for _ = 1, 6000 do
|
||||
if box.done and not box.auto then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("box finished typing and the jingle wait is over", box.done and not box.auto)
|
||||
local rated
|
||||
for _, name in ipairs(plays) do
|
||||
if name == "Get_Item2" then rated = true end
|
||||
if name == "Pokedex_Rating" then rated = false break end
|
||||
end
|
||||
check("151 owned plays SFX_GET_ITEM_2", rated == true)
|
||||
for _ = 1, 120 do
|
||||
if box.blink % 60 < 30 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.still(game, DIR .. "/2674_02_complete_without_bang.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(5)
|
||||
check("A closes the rating", closed)
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,170 @@
|
||||
-- engine/events/diploma.asm:44
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local P = require("src.render.PaletteFX")
|
||||
local Diploma = require("src.ui.Diploma")
|
||||
local SHOT_DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(label, ok, detail)
|
||||
print((ok and "PASS " or "FAIL ") .. label
|
||||
.. (detail ~= nil and (" [" .. tostring(detail) .. "]") or ""))
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local rendered = 0
|
||||
local hostDraw = love.draw
|
||||
love.draw = function(...)
|
||||
local r = hostDraw(...)
|
||||
rendered = rendered + 1
|
||||
return r
|
||||
end
|
||||
local function waitRendered(n)
|
||||
local target = rendered + (n or 2)
|
||||
for _ = 1, 2400 do
|
||||
if rendered >= target then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function near(R, G, B, c)
|
||||
return math.abs(R - c[1]) <= 12 and math.abs(G - c[2]) <= 12
|
||||
and math.abs(B - c[3]) <= 12
|
||||
end
|
||||
|
||||
local function letterbox(img)
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local x1, y1, x2, y2 = W, H, -1, -1
|
||||
for y = 0, H - 1 do
|
||||
for x = 0, W - 1 do
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
if r > 0.02 or g > 0.02 or b > 0.02 then
|
||||
if x < x1 then x1 = x end
|
||||
if x > x2 then x2 = x end
|
||||
if y < y1 then y1 = y end
|
||||
if y > y2 then y2 = y end
|
||||
end
|
||||
end
|
||||
end
|
||||
local bw, bh = x2 - x1 + 1, y2 - y1 + 1
|
||||
if x2 >= x1 and math.abs(bw / 160 - bh / 144) < 0.05 then
|
||||
return x1, y1, bh / 144
|
||||
end
|
||||
local s = math.min(W / 160, H / 144)
|
||||
return math.floor((W - 160 * s) / 2), math.floor((H - 144 * s) / 2), s
|
||||
end
|
||||
|
||||
local function load(path)
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local bytes = f:read("*a")
|
||||
f:close()
|
||||
local ok, img = pcall(function()
|
||||
return love.image.newImageData(
|
||||
love.filesystem.newFileData(bytes, "shot.png"))
|
||||
end)
|
||||
if not ok then return nil end
|
||||
return img
|
||||
end
|
||||
|
||||
local function census(img, objRamp, bgRamp, bx1, by1, bx2, by2)
|
||||
local W, H = img:getWidth(), img:getHeight()
|
||||
local ox, oy, s = letterbox(img)
|
||||
local c = { obj = 0, bg = 0, grey = 0, ink = 0, nonwhite = 0 }
|
||||
for gy = by1, by2 - 1 do
|
||||
for gx = bx1, bx2 - 1 do
|
||||
local x = ox + math.floor((gx + 0.5) * s)
|
||||
local y = oy + math.floor((gy + 0.5) * s)
|
||||
if x >= 0 and x < W and y >= 0 and y < H then
|
||||
local r, g, b = img:getPixel(x, y)
|
||||
local R = math.floor(r * 255 + 0.5)
|
||||
local G = math.floor(g * 255 + 0.5)
|
||||
local B = math.floor(b * 255 + 0.5)
|
||||
if R < 30 and G < 30 and B < 30 then c.ink = c.ink + 1 end
|
||||
if not (R > 230 and G > 230 and B > 230) then
|
||||
c.nonwhite = c.nonwhite + 1
|
||||
end
|
||||
if R == G and G == B and R > 60 and R < 240 then c.grey = c.grey + 1 end
|
||||
for _, k in ipairs(objRamp or {}) do
|
||||
if near(R, G, B, k) then c.obj = c.obj + 1 break end
|
||||
end
|
||||
for _, k in ipairs(bgRamp or {}) do
|
||||
if near(R, G, B, k) then c.bg = c.bg + 1 break end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return c
|
||||
end
|
||||
|
||||
U.wait(5)
|
||||
U.teleport(game, "CELADON_MANSION_3F", 2, 4, "up")
|
||||
game.save.player.name = "RED"
|
||||
local d = Diploma.new(game)
|
||||
game.stack:push(d)
|
||||
|
||||
game.save.options.colors = "ogred"
|
||||
P.applyOptions(game.save.options)
|
||||
check("COLORS ogred uses the sprite OBJ path", P.usesSpriteObp() == true,
|
||||
P.mode)
|
||||
local objRamp = { P.ogObjBase()[2], P.ogObjBase()[3] }
|
||||
local bgRamp = { P.ogBg()[2], P.ogBg()[3] }
|
||||
waitRendered(4)
|
||||
local maxRedraws = 0
|
||||
for _ = 1, 4 do
|
||||
waitRendered(1)
|
||||
local n = #P.uiSpriteRedraws()
|
||||
if n > maxRedraws then maxRedraws = n end
|
||||
end
|
||||
check("ogred records the pic and the text overlay for replay",
|
||||
maxRedraws == 2, maxRedraws)
|
||||
|
||||
local shot1 = SHOT_DIR .. "/2676_01_ogred_player_obj_green.png"
|
||||
if check("shot ogred", U.shot(game, shot1)) then
|
||||
local img = load(shot1)
|
||||
if check("ogred shot decodes", img ~= nil) then
|
||||
local body = census(img, objRamp, bgRamp, 115, 80, 152, 136)
|
||||
check("ogred player wears the OBJ ramp", body.obj > 200, body.obj)
|
||||
check("ogred player has no BG-red pixels", body.bg == 0, body.bg)
|
||||
check("ogred player has no DMG grey", body.grey == 0, body.grey)
|
||||
local fist = census(img, objRamp, nil, 152, 100, 155, 116)
|
||||
check("ogred fist reaches columns 152-154", fist.obj > 0, fist.obj)
|
||||
local r = census(img, objRamp, nil, 120, 96, 128, 104)
|
||||
check("ogred the r of your stays in front of the player", r.ink > 4,
|
||||
r.ink)
|
||||
local gf = census(img, objRamp, nil, 115, 128, 152, 136)
|
||||
check("ogred GAME FREAK stays in front of the player", gf.ink > 20,
|
||||
gf.ink)
|
||||
end
|
||||
end
|
||||
|
||||
P.applyOptions({ colors = "gbc" })
|
||||
waitRendered(4)
|
||||
local gbcRedraws = 0
|
||||
for _ = 1, 4 do
|
||||
waitRendered(1)
|
||||
local n = #P.uiSpriteRedraws()
|
||||
if n > gbcRedraws then gbcRedraws = n end
|
||||
end
|
||||
check("gbc records no OBJ replay", gbcRedraws == 0, gbcRedraws)
|
||||
local shot2 = SHOT_DIR .. "/2676_02_gbc_fist_uncropped.png"
|
||||
if check("shot gbc", U.shot(game, shot2)) then
|
||||
local img = load(shot2)
|
||||
if check("gbc shot decodes", img ~= nil) then
|
||||
local body = census(img, objRamp, nil, 115, 80, 152, 136)
|
||||
check("gbc player never wears the OG OBJ ramp", body.obj == 0, body.obj)
|
||||
local fist = census(img, nil, nil, 152, 100, 155, 116)
|
||||
check("gbc fist reaches columns 152-154", fist.nonwhite > 0,
|
||||
fist.nonwhite)
|
||||
end
|
||||
end
|
||||
|
||||
game.save.options.colors = "ogred"
|
||||
P.applyOptions(game.save.options)
|
||||
waitRendered(2)
|
||||
print(fails == 0 and "PASS diploma_2676_all" or "FAIL diploma_2676_all")
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
-- scripts/AgathasRoom.asm:2
|
||||
-- scripts/AgathasRoom.asm:108
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local CLOSED, OPEN = 0x3b, 0x0e
|
||||
local ok = true
|
||||
|
||||
local function check(label, cond)
|
||||
print((cond and "PASS " or "FAIL ") .. label)
|
||||
if not cond then ok = false end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function finish()
|
||||
U.wait(5)
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
mon.moves = { { id = "PSYCHIC_M", pp = 63 } }
|
||||
game.save.party = { mon }
|
||||
game.save.flags.EVENT_AUTOWALKED_INTO_AGATHAS_ROOM = true
|
||||
game.save.flags.EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 = nil
|
||||
game.save.defeatedTrainers = game.save.defeatedTrainers or {}
|
||||
game.save.defeatedTrainers["AGATHAS_ROOM_obj_1"] = nil
|
||||
|
||||
U.teleport(game, "AGATHAS_ROOM", 5, 3, "up")
|
||||
U.wait(30)
|
||||
local ow = game.overworld
|
||||
if not check("2658 overworld up in AGATHAS_ROOM", ow and ow.map.id == "AGATHAS_ROOM") then
|
||||
return finish()
|
||||
end
|
||||
check("2658 exit shut before the fight", ow.map:blockAt(2, 0) == CLOSED)
|
||||
|
||||
local battle
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if top ~= ow and top and top.enemy then battle = top break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
if not check("2658 talking to Agatha starts the battle", battle ~= nil) then
|
||||
return finish()
|
||||
end
|
||||
|
||||
for _ = 1, 20000 do
|
||||
if game.stack:top() ~= battle then
|
||||
local inStack = false
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if s == battle then inStack = true break end
|
||||
end
|
||||
if not inStack then break end
|
||||
end
|
||||
if battle.enemy and battle.enemy.mon and (battle.enemy.mon.hp or 0) > 1 then
|
||||
battle.enemy.mon.hp = 1
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
local box
|
||||
for _ = 1, 3000 do
|
||||
local top = game.stack:top()
|
||||
if top ~= battle and getmetatable(top) == TextBox then box = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
if not check("2658 after-battle text box shows in the overworld", box ~= nil) then
|
||||
return finish()
|
||||
end
|
||||
U.wait(240)
|
||||
check("2658 Agatha beaten", game.save.flags.EVENT_BEAT_AGATHAS_ROOM_TRAINER_0 and true or false)
|
||||
check("2658 exit still shut while after-battle text is up",
|
||||
ow.map:blockAt(2, 0) == CLOSED)
|
||||
U.still(game, DIR .. "/2658_01_after_text_exit_shut.png")
|
||||
|
||||
for _ = 1, 600 do
|
||||
if game.stack:top() == ow then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
check("2658 after-battle text closed", game.stack:top() == ow)
|
||||
U.wait(10)
|
||||
check("2658 exit opens after the text closes", ow.map:blockAt(2, 0) == OPEN)
|
||||
U.still(game, DIR .. "/2658_02_exit_open_after_text.png")
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
-- engine/pokemon/evos_moves.asm:156
|
||||
-- engine/battle/end_of_battle.asm:42
|
||||
-- POKEPORT_DRIVER=tests/drivers/evolution_double_bug2664.lua POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Growth = require("src.pokemon.Growth")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Music = require("src.core.Music")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/evo2664"
|
||||
|
||||
local failed = false
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 1
|
||||
local pin = function(_, b) return b end
|
||||
local dratini = Pokemon.new(game.data, "DRATINI", 29, pin)
|
||||
local def = game.data.pokemon.DRATINI
|
||||
dratini.exp = Growth.expForLevel(def.growthRate, 30, game.data.growth_rates) - 1
|
||||
dratini.moves = { { id = dratini.moves[1].id, pp = dratini.moves[1].pp } }
|
||||
local tentacool = Pokemon.new(game.data, "TENTACOOL", 30, pin)
|
||||
tentacool.moves = { tentacool.moves[1] }
|
||||
game.save.party = { dratini, tentacool }
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
||||
local ow = game.overworld
|
||||
|
||||
local restores = 0
|
||||
local realRestore = Music.restoreMap
|
||||
Music.restoreMap = function(...)
|
||||
restores = restores + 1
|
||||
return realRestore(...)
|
||||
end
|
||||
|
||||
local battle = BattleState.newWild(game, "RATTATA", 2)
|
||||
battle.rng = function(a, _) return a end
|
||||
battle.enemy.mon.hp = 1
|
||||
battle.enemy.mon.stats.speed = 1
|
||||
battle.leveledUp = { [tentacool] = true }
|
||||
ow:pushBattle(battle)
|
||||
|
||||
for _ = 1, 400 do
|
||||
if battle.phase == "menu" then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
if not check("reached the FIGHT menu", battle.phase == "menu") then return end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, 60 do
|
||||
if battle.phase == "moveSelect" then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.tap(game, "a")
|
||||
|
||||
local function inStack(state)
|
||||
for i, s in ipairs(game.stack.states) do
|
||||
if s == state then return i end
|
||||
end
|
||||
end
|
||||
|
||||
local firstIntro, secondIntro, shot = nil, nil, false
|
||||
local belowSecond, restoresAtSecond, baseAtSecond, battleAtSecond
|
||||
for _ = 1, 20000 do
|
||||
local top = game.stack:top()
|
||||
if top and top.evoIntro then
|
||||
if not firstIntro then
|
||||
firstIntro = top
|
||||
elseif top ~= firstIntro and not secondIntro then
|
||||
secondIntro = top
|
||||
local i = inStack(top)
|
||||
belowSecond = game.stack.states[i - 1]
|
||||
restoresAtSecond = restores
|
||||
baseAtSecond = game.stack:visibleBase()
|
||||
battleAtSecond = inStack(battle)
|
||||
end
|
||||
end
|
||||
if secondIntro and not shot then
|
||||
local i = inStack(secondIntro)
|
||||
if i and i == #game.stack.states - 1 then
|
||||
shot = U.still(game, DIR .. "/2664_01_second_intro_on_blank.png")
|
||||
end
|
||||
end
|
||||
if not inStack(battle) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
check("first mon evolved", dratini.species == "DRAGONAIR")
|
||||
check("second mon evolved", tentacool.species == "TENTACRUEL")
|
||||
if check("second IsEvolvingText box opened", secondIntro ~= nil) then
|
||||
check("second intro sits on an opaque blank screen",
|
||||
belowSecond ~= nil and belowSecond.evoClear == true
|
||||
and belowSecond.isOpaque == true)
|
||||
check("battle screen is not drawn under the second intro",
|
||||
battleAtSecond ~= nil and baseAtSecond > battleAtSecond)
|
||||
check("no map music between the two evolutions", restoresAtSecond == 0)
|
||||
end
|
||||
check("shot of the second intro captured", shot)
|
||||
check("battle closed after the evolutions", inStack(battle) == nil)
|
||||
local stray = false
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if s.evoClear or s.evoIntro then stray = true end
|
||||
end
|
||||
check("no evolution layers left on the stack", not stray)
|
||||
Music.restoreMap = realRestore
|
||||
end)
|
||||
if not ok then
|
||||
print("FAIL driver error: " .. tostring(err))
|
||||
failed = true
|
||||
end
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -161,7 +161,7 @@ return function(game)
|
||||
local top = Option._pages[1]
|
||||
local gi
|
||||
for i, row in ipairs(top.rows) do
|
||||
if row.port and row.port.id == "group.graphics" then gi = i end
|
||||
if row.id == "group.graphics" then gi = i end
|
||||
end
|
||||
if check(gi ~= nil, version .. " RS OPTION has a visible GRAPHICS row") then
|
||||
top.index = gi
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
-- data/sprites/facings.asm:51
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Assets = require("src.render.Assets")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "."
|
||||
|
||||
local fails = 0
|
||||
local function check(cond, label)
|
||||
if cond then print("PASS " .. label) else
|
||||
fails = fails + 1
|
||||
print("FAIL " .. label)
|
||||
end
|
||||
end
|
||||
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
table.insert(game.save.party, Pokemon.new(game.data, "CHARMANDER", 5))
|
||||
game.save.options = game.save.options or {}
|
||||
local savedSpeed = game.speedOverride
|
||||
game.speedOverride = 1
|
||||
|
||||
local function grab()
|
||||
local got
|
||||
love.graphics.captureScreenshot(function(id) got = id end)
|
||||
for _ = 1, 600 do
|
||||
if got then return got end
|
||||
coroutine.yield()
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function frameRows(sprite)
|
||||
local ok, id = pcall(Assets.imageData, sprite.def.image)
|
||||
if not ok or not id then return 0, 15 end
|
||||
local first, last
|
||||
for y = 0, math.min(sprite.frameHeight, id:getHeight()) - 1 do
|
||||
for x = 0, math.min(sprite.frameWidth, id:getWidth()) - 1 do
|
||||
local r = id:getPixel(x, y)
|
||||
if r < 0.83 then
|
||||
first = first or y
|
||||
last = y
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
return first or 0, last or 15
|
||||
end
|
||||
|
||||
local function differs(a, b, x, y)
|
||||
local r1, g1, b1 = a:getPixel(x, y)
|
||||
local r2, g2, b2 = b:getPixel(x, y)
|
||||
return math.abs(r1 - r2) + math.abs(g1 - g2) + math.abs(b1 - b2) > 0.05
|
||||
end
|
||||
|
||||
local function measure(tag)
|
||||
local ow = game.overworld
|
||||
local r = ow.map.renderer
|
||||
U.wait(4)
|
||||
local withGrass = grab()
|
||||
r.drawStrip = function() end
|
||||
r.markStripRedraw = function() end
|
||||
U.wait(2)
|
||||
local noGrass = grab()
|
||||
ow.playerHidden = true
|
||||
U.wait(2)
|
||||
local noPlayer = grab()
|
||||
ow.playerHidden = false
|
||||
r.drawStrip = nil
|
||||
r.markStripRedraw = nil
|
||||
U.wait(2)
|
||||
if not (withGrass and noGrass and noPlayer) then
|
||||
check(false, tag .. " captured three frames")
|
||||
return
|
||||
end
|
||||
local w, h = withGrass:getDimensions()
|
||||
local x0, y0, x1, y1
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
if differs(noGrass, noPlayer, x, y) then
|
||||
x0 = math.min(x0 or x, x); x1 = math.max(x1 or x, x)
|
||||
y0 = math.min(y0 or y, y); y1 = math.max(y1 or y, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
if not y0 then
|
||||
check(false, tag .. " found the player sprite on screen")
|
||||
return
|
||||
end
|
||||
local first, last = frameRows(ow.player.sprite)
|
||||
local scale = (y1 - y0 + 1) / (last - first + 1)
|
||||
local top = y0 - first * scale
|
||||
local rows = {}
|
||||
for y = math.floor(top), math.floor(top + 20 * scale) do
|
||||
if y >= 0 and y < h then
|
||||
for x = x0, x1 do
|
||||
if differs(withGrass, noGrass, x, y) then
|
||||
local row = math.floor((y - top) / scale)
|
||||
rows[row] = (rows[row] or 0) + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
local above, mid, low = 0, 0, 0
|
||||
for row, n in pairs(rows) do
|
||||
if row < 8 then above = above + n
|
||||
elseif row < 12 then mid = mid + n
|
||||
else low = low + n end
|
||||
end
|
||||
U.log(tag, "scale=" .. scale, "rows0-7=" .. above, "rows8-11=" .. mid,
|
||||
"rows12-15=" .. low)
|
||||
check(above == 0, tag .. " grass leaves sprite rows 0-7 alone")
|
||||
check(mid > 0, tag .. " grass covers sprite rows 8-11")
|
||||
check(low > 0, tag .. " grass covers sprite rows 12-15")
|
||||
end
|
||||
|
||||
local modes = {
|
||||
{ "gbc", "sgb" }, { "redpp", "advanced" }, { "ogred", "ogred" }, { "og", "og" },
|
||||
}
|
||||
for _, m in ipairs(modes) do
|
||||
game.save.options.colors = m[1]
|
||||
PaletteFX.setMode(m[1])
|
||||
U.teleport(game, "ROUTE_1", 10, 6, "down")
|
||||
U.wait(20)
|
||||
check(game.overworld.map:isGrassCell(game.overworld.player.cellX,
|
||||
game.overworld.player.cellY),
|
||||
m[2] .. " player stands in tall grass")
|
||||
measure(m[2])
|
||||
end
|
||||
|
||||
for i, m in ipairs(modes) do
|
||||
game.save.options.colors = m[1]
|
||||
PaletteFX.setMode(m[1])
|
||||
U.teleport(game, "ROUTE_1", 10, 6, "down")
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/2688_0" .. i .. "_" .. m[2] .. "_grass_from_shoulders_down.png")
|
||||
end
|
||||
|
||||
game.save.options.colors = "gbc"
|
||||
PaletteFX.setMode("gbc")
|
||||
game.speedOverride = savedSpeed
|
||||
U.wait(2)
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,80 @@
|
||||
local U = require('tests.drivers.util')
|
||||
local Version = require('src.core.GameVersion')
|
||||
local TextBox = require('src.render.TextBox')
|
||||
|
||||
local function flatten(t, out)
|
||||
out = out or {}
|
||||
if type(t) == 'string' then out[#out + 1] = t
|
||||
elseif type(t) == 'table' then for _, v in ipairs(t) do flatten(v, out) end end
|
||||
return out
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local deadline = love.timer.getTime() + 60
|
||||
local ok, err = xpcall(function()
|
||||
assert(Version.generation() == 1, 'Gen1 edition required')
|
||||
local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required')
|
||||
love.audio.setVolume(0)
|
||||
local function wait(n)
|
||||
for _ = 1, n do
|
||||
assert(love.timer.getTime() < deadline, '#2683 deadline')
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
local function untilReady(fn, limit, label)
|
||||
for _ = 1, limit do if fn() then return end; wait(1) end
|
||||
error(label)
|
||||
end
|
||||
local text = game.data.text
|
||||
local want = {
|
||||
odd = assert(text._IndigoPlateauStatuesText2, 'Text2 missing from cache'),
|
||||
even = assert(text._IndigoPlateauStatuesText3, 'Text3 missing from cache'),
|
||||
}
|
||||
game:startNewGame({ intro = false })
|
||||
U.teleport(game, 'INDIGO_PLATEAU', 9, 8, 'down')
|
||||
local ow = assert(game.overworld)
|
||||
local map = ow.map
|
||||
local spots = {}
|
||||
for cy = 0, map.heightCells - 1 do
|
||||
for cx = 0, map.widthCells - 1 do
|
||||
if map:inBounds(cx, cy) and map:inBounds(cx, cy + 1)
|
||||
and map:cellTile(cx, cy) == 0x30 and map:isWalkableCell(cx, cy + 1) then
|
||||
local key = (cx % 2 == 1) and 'odd' or 'even'
|
||||
if not spots[key] then spots[key] = { cx, cy + 1 } end
|
||||
end
|
||||
end
|
||||
end
|
||||
assert(spots.odd and spots.even, 'no statue spots found on INDIGO_PLATEAU')
|
||||
for i, key in ipairs({ 'odd', 'even' }) do
|
||||
local x, y = spots[key][1], spots[key][2]
|
||||
U.teleport(game, 'INDIGO_PLATEAU', x, y, 'up')
|
||||
ow = assert(game.overworld)
|
||||
untilReady(function()
|
||||
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
|
||||
end, 400, 'field did not settle')
|
||||
assert(ow.player.cellX == x and ow.player.facing == 'up', 'player not placed facing the statue')
|
||||
U.tap(game, 'a')
|
||||
untilReady(function() return getmetatable(game.stack:top()) == TextBox end, 120, 'statue text never opened')
|
||||
local box = game.stack:top()
|
||||
local joined = table.concat(flatten(box.pages), ' ')
|
||||
local expect = TextBox.strip(TextBox.substitute(game, want[key])):gsub('[\n\f]', ' ')
|
||||
local firstWord = expect:match('%S+%s+%S+')
|
||||
assert(joined:find(firstWord, 1, true), key .. ' X ' .. x .. ' printed the wrong caption: ' .. joined)
|
||||
untilReady(function()
|
||||
if box.pageIndex == #box.pages and box.waiting then return true end
|
||||
if box.waiting then U.tap(game, 'a') end
|
||||
return false
|
||||
end, 1200, 'caption page never settled')
|
||||
local name = string.format('2683_%02d_%s_x%d_%s.png', i, key, x,
|
||||
key == 'odd' and 'ultimate_goal' or 'highest_authority')
|
||||
assert(U.still(game, out .. '/' .. name), 'capture failed')
|
||||
print('PASS #2683 ' .. Version.get() .. ' ' .. key .. ' X=' .. x .. ' shows ' .. (key == 'odd' and 'Text2' or 'Text3'))
|
||||
for _ = 1, 20 do
|
||||
if game.stack:top() ~= box then break end
|
||||
U.tap(game, 'a'); wait(4)
|
||||
end
|
||||
end
|
||||
end, debug.traceback)
|
||||
if not ok then print('FAIL #2683 ' .. tostring(err)) end
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
-- scripts/LancesRoom.asm:54
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local ok = true
|
||||
|
||||
local function check(label, cond)
|
||||
print((cond and "PASS " or "FAIL ") .. label)
|
||||
if not cond then ok = false end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function finish()
|
||||
U.wait(5)
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function findLance(ow)
|
||||
for _, npc in ipairs(ow.npcs or {}) do
|
||||
if npc.def and npc.def.name == "LANCESROOM_LANCE" then return npc end
|
||||
end
|
||||
end
|
||||
|
||||
local function approach(fromX, fromY, dir, label, shotName)
|
||||
U.teleport(game, "LANCES_ROOM", fromX, fromY, dir)
|
||||
local ow = game.overworld
|
||||
if not check(label .. " overworld up in LANCES_ROOM", ow and ow.map.id == "LANCES_ROOM") then
|
||||
return false
|
||||
end
|
||||
local lance = findLance(ow)
|
||||
if not check(label .. " Lance present", lance ~= nil) then return false end
|
||||
check(label .. " Lance starts facing down", lance.facing == "down")
|
||||
U.hold(game, dir, 4)
|
||||
local opened = false
|
||||
for _ = 1, 600 do
|
||||
if game.stack:top() ~= ow then opened = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check(label .. " pre-battle text opens", opened)
|
||||
U.wait(40)
|
||||
check(label .. " Lance still faces down during his text", lance.facing == "down")
|
||||
if lance.facing ~= "down" then U.log(label, "lance facing", lance.facing) end
|
||||
U.still(game, DIR .. "/" .. shotName)
|
||||
return true
|
||||
end
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "MEWTWO", 100) }
|
||||
game.save.flags.EVENT_BEAT_LANCE = nil
|
||||
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = true
|
||||
|
||||
approach(5, 2, "up", "2669 side (5,1)", "2669_01_side_tile_lance_faces_down.png")
|
||||
approach(6, 3, "up", "2669 front (6,2)", "2669_02_front_tile_lance_faces_down.png")
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,85 @@
|
||||
-- POKEPORT_DRIVER=tests/drivers/lance_victory_music_bug2659.lua POKEPORT_SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Music = require("src.core.Music")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Timing = require("src.core.Timing")
|
||||
os.execute('mkdir -p "' .. DIR .. '" 2>/dev/null')
|
||||
|
||||
local failed = false
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local played = {}
|
||||
local realPlayBattle = Music.playBattle
|
||||
Music.playBattle = function(data, kind, trainerId, song)
|
||||
played[#played + 1] = { call = "battle", kind = kind }
|
||||
return realPlayBattle(data, kind, trainerId, song)
|
||||
end
|
||||
local realPlayVictory = Music.playVictory
|
||||
Music.playVictory = function(data, kind, trainerId)
|
||||
played[#played + 1] = { call = "victory", kind = kind }
|
||||
return realPlayVictory(data, kind, trainerId)
|
||||
end
|
||||
local function lastKind(call)
|
||||
local k
|
||||
for _, p in ipairs(played) do
|
||||
if p.call == call then k = p.kind end
|
||||
end
|
||||
return k
|
||||
end
|
||||
|
||||
U.newGame(game)
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
mon.moves = { { id = "SWIFT", pp = 20, maxPp = 20, ppUps = 0 } }
|
||||
game.save.party = { mon }
|
||||
game.save.player.name = "RED"
|
||||
U.teleport(game, "LANCES_ROOM", 5, 3, "up")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("overworld up in LANCES_ROOM", ow ~= nil)
|
||||
if not ow then love.event.quit(1) return end
|
||||
|
||||
local battle = BattleState.newTrainer(game, "OPP_LANCE", 1)
|
||||
battle.onFinish = function() end
|
||||
for i, m in ipairs(battle.enemyParty) do
|
||||
m.hp = (i == 1) and 1 or 0
|
||||
end
|
||||
battle.enemy.shownHP = 1
|
||||
battle.enemy.shownPx = Timing.hpBarPixels(1, math.max(1, battle.enemy.mon.stats.hp))
|
||||
ow:pushBattle(battle)
|
||||
|
||||
for _ = 1, 900 do
|
||||
if game.stack:top() == battle and battle.musicKind then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("lance battle on screen", game.stack:top() == battle)
|
||||
check("lance battle theme is gym (play_battle_music.asm)",
|
||||
battle.musicKind == "gym" and lastKind("battle") == "gym")
|
||||
check("lance is not a wGymLeaderNo fight", not battle.isGymLeader)
|
||||
|
||||
local won = false
|
||||
for _ = 1, 3000 do
|
||||
if lastKind("victory") then won = true break end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
end
|
||||
check("lance defeated, victory theme requested", won)
|
||||
check("lance victory theme is trainer (TrainerBattleVictory)",
|
||||
lastKind("victory") == "trainer")
|
||||
local b = game.data.audio and game.data.audio.battle
|
||||
check("MUSIC_DEFEATED_TRAINER is playing",
|
||||
b ~= nil and Music.current() == b.trainerWin
|
||||
and b.trainerWin ~= b.gymWin)
|
||||
|
||||
U.wait(40)
|
||||
U.shot(game, DIR .. "/2659_01_lance_defeated_trainer_theme.png")
|
||||
|
||||
Music.playBattle, Music.playVictory = realPlayBattle, realPlayVictory
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
-- scripts/LancesRoom.asm:96
|
||||
-- scripts/LancesRoom.asm:111
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local ok = true
|
||||
|
||||
local function check(label, cond)
|
||||
print((cond and "PASS " or "FAIL ") .. label)
|
||||
if not cond then ok = false end
|
||||
return cond
|
||||
end
|
||||
|
||||
local function finish()
|
||||
U.wait(5)
|
||||
love.event.quit(ok and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local mon = Pokemon.new(game.data, "MEWTWO", 100)
|
||||
game.save.party = { mon }
|
||||
game.save.flags.EVENT_BEAT_LANCE = nil
|
||||
game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR = nil
|
||||
|
||||
U.teleport(game, "LANCES_ROOM", 24, 16, "up")
|
||||
local ow = game.overworld
|
||||
if not check("2665 overworld up in LANCES_ROOM", ow and ow.map.id == "LANCES_ROOM") then
|
||||
return finish()
|
||||
end
|
||||
|
||||
local shots = {
|
||||
["21,16"] = "2665_01_left_off_the_stairs.png",
|
||||
["18,20"] = "2665_02_down_the_right_corridor.png",
|
||||
["12,23"] = "2665_03_left_along_the_bottom.png",
|
||||
["6,16"] = "2665_04_up_the_middle_column.png",
|
||||
}
|
||||
local cells = { "24,16" }
|
||||
local lastKey = "24,16"
|
||||
for _ = 1, 20000 do
|
||||
local p = ow.player
|
||||
local key = p.cellX .. "," .. p.cellY
|
||||
if key ~= lastKey then
|
||||
cells[#cells + 1] = key
|
||||
lastKey = key
|
||||
if shots[key] then
|
||||
U.still(game, DIR .. "/" .. shots[key])
|
||||
shots[key] = nil
|
||||
end
|
||||
end
|
||||
if #(ow.scriptMoves or {}) == 0 and #cells > 1 and not p.moving then break end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
local expected = { "24,16" }
|
||||
local x, y = 24, 16
|
||||
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 } }
|
||||
for _, seg in ipairs({ { "left", 6 }, { "down", 7 }, { "left", 12 }, { "up", 12 } }) do
|
||||
for _ = 1, seg[2] do
|
||||
x, y = x + D[seg[1]][1], y + D[seg[1]][2]
|
||||
expected[#expected + 1] = x .. "," .. y
|
||||
end
|
||||
end
|
||||
check("2665 first step leaves the stairs going left", cells[2] == "23,16")
|
||||
check("2665 walk-in follows the reversed WalkToLance list cell for cell",
|
||||
table.concat(cells, " ") == table.concat(expected, " "))
|
||||
if table.concat(cells, " ") ~= table.concat(expected, " ") then
|
||||
U.log("got", table.concat(cells, " "))
|
||||
end
|
||||
check("2665 lands on (6,11)", ow.player.cellX == 6 and ow.player.cellY == 11)
|
||||
check("2665 door locks on landing", game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR == true)
|
||||
check("2665 door blocks closed", ow.map:blockAt(2, 6) == 0x72 and ow.map:blockAt(3, 6) == 0x73)
|
||||
U.wait(10)
|
||||
U.still(game, DIR .. "/2665_05_landed_door_locked.png")
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,128 @@
|
||||
-- engine/events/pokemart.asm:152
|
||||
-- engine/items/inventory.asm:59
|
||||
-- POKEPORT_DRIVER=tests/drivers/mart_split_stack_bug2671.lua POKEPORT_IDENTITY=bsa1005-red-2671 POKEPORT_VERSION=red love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local Menu = require("src.ui.Menu")
|
||||
local ListMenu = require("src.ui.ListMenu")
|
||||
local QuantityBox = require("src.ui.QuantityBox")
|
||||
local ChoiceBox = require("src.ui.ChoiceBox")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local failures = 0
|
||||
local function check(label, ok)
|
||||
if not ok then failures = failures + 1 end
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
local function top() return game.stack:top() end
|
||||
local function topIs(cls) return getmetatable(top()) == cls end
|
||||
local function waitFor(pred, frames)
|
||||
for _ = 1, frames or 300 do
|
||||
if pred() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return pred()
|
||||
end
|
||||
local function rows()
|
||||
local out = {}
|
||||
for _, r in ipairs(Bag.rows(game.save, game.data)) do
|
||||
out[#out + 1] = r.id .. "x" .. tostring(r.count)
|
||||
end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
local function finish()
|
||||
U.log(failures == 0 and "PASS mart_split_stack_bug2671"
|
||||
or ("FAIL mart_split_stack_bug2671 (" .. failures .. " failures)"))
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
U.wait(600)
|
||||
end
|
||||
|
||||
local MONEY = 104276
|
||||
local ok, err = pcall(function()
|
||||
game.save.money = MONEY
|
||||
game.save.inventory = { X_ACCURACY = 2, POTION = 3 }
|
||||
game.save.bagOrder = { "X_ACCURACY", "POTION" }
|
||||
U.teleport(game, "CELADON_MART_5F", 5, 5, "up")
|
||||
U.wait(10)
|
||||
|
||||
for _ = 1, 60 do
|
||||
if topIs(Menu) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
if not check("the X item clerk's mart menu opens", topIs(Menu)) then return end
|
||||
local menu = top()
|
||||
|
||||
U.tap(game, "a")
|
||||
if not check("BUY opens the list", waitFor(function() return topIs(ListMenu) end, 300)) then return end
|
||||
local list = top()
|
||||
check("the cursor is on X ACCURACY", list.items[list.index].value == "X_ACCURACY")
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
if not check("the quantity box opens", waitFor(function() return topIs(QuantityBox) end, 30)) then return end
|
||||
local qty = top()
|
||||
check("the selector max stays 99", qty.max == 99)
|
||||
U.wait(4)
|
||||
U.tap(game, "down")
|
||||
U.wait(4)
|
||||
check("DOWN wraps to x99", qty.qty == 99)
|
||||
U.still(game, DIR .. "/2671_01_buy_x99.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
if not check("YES/NO opens", waitFor(function()
|
||||
if topIs(TextBox) and top().waiting then U.tap(game, "a") end
|
||||
return topIs(ChoiceBox)
|
||||
end, 600)) then return end
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
local receipt
|
||||
check("the clerk hands the items over", waitFor(function()
|
||||
local t = top()
|
||||
if getmetatable(t) == TextBox and t.pages then
|
||||
local text = table.concat(t.pages[1] or {}, " ")
|
||||
if text:find("Here you are", 1, true) then receipt = t return true end
|
||||
if text:find("carry", 1, true) then return true end
|
||||
end
|
||||
return false
|
||||
end, 600) and receipt ~= nil)
|
||||
check("94050 is paid", game.save.money == MONEY - 94050)
|
||||
check("the bag holds 101 X ACCURACY", game.save.inventory.X_ACCURACY == 101)
|
||||
check("the overflow sits in a new last slot: " .. rows(),
|
||||
rows() == "X_ACCURACYx99,POTIONx3,X_ACCURACYx2")
|
||||
waitFor(function() return receipt.done end, 600)
|
||||
U.still(game, DIR .. "/2671_02_here_you_are.png")
|
||||
|
||||
for _ = 1, 30 do
|
||||
if top() == list then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
end
|
||||
U.tap(game, "b")
|
||||
if not check("B returns to BUY/SELL/QUIT", waitFor(function()
|
||||
if topIs(TextBox) and top().waiting then U.tap(game, "a") end
|
||||
return top() == menu
|
||||
end, 600)) then return end
|
||||
U.wait(4)
|
||||
U.tap(game, "down")
|
||||
U.wait(4)
|
||||
U.tap(game, "a")
|
||||
if not check("SELL opens the bag list", waitFor(function() return topIs(ListMenu) end, 300)) then return end
|
||||
local sellList = top()
|
||||
U.wait(30)
|
||||
local counts = {}
|
||||
for _, it in ipairs(sellList.items) do
|
||||
if not it.cancel then counts[#counts + 1] = it.value .. "x" .. tostring(it.count) end
|
||||
end
|
||||
check("the sell list shows two X ACCURACY rows: " .. table.concat(counts, ","),
|
||||
table.concat(counts, ",") == "X_ACCURACYx99,POTIONx3,X_ACCURACYx2")
|
||||
U.still(game, DIR .. "/2671_03_sell_list_split_rows.png")
|
||||
end)
|
||||
if not ok then
|
||||
failures = failures + 1
|
||||
U.log("FAIL driver error:", tostring(err))
|
||||
end
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
-- engine/movie/oak_speech/oak_speech2.asm:20
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local NamingScreen = require("src.ui.NamingScreen")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/shots"
|
||||
local failed = false
|
||||
|
||||
local function check(label, ok)
|
||||
if ok then print("PASS " .. label) else print("FAIL " .. label) failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
print(failed and "FAIL oak_naming_empty_start_bug2687"
|
||||
or "PASS oak_naming_empty_start_bug2687")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function namingScreen()
|
||||
local states = game.stack.states or {}
|
||||
for i = #states, 1, -1 do
|
||||
if getmetatable(states[i]) == NamingScreen then return states[i] end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function topIsGrid(ns)
|
||||
return ns ~= nil and game.stack:top() == ns and not ns.choosing
|
||||
end
|
||||
|
||||
U.wait(5)
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
local title = game.stack:top()
|
||||
for _ = 1, 60 do
|
||||
U.tap(game, "a")
|
||||
U.wait(5)
|
||||
if game.stack:top() ~= title then break end
|
||||
end
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
local ns
|
||||
for _ = 1, 3000 do
|
||||
ns = namingScreen()
|
||||
if ns and ns.choosing then break end
|
||||
if game.overworld and game.stack:top() == game.overworld then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
if not check("reached_player_name_menu", ns ~= nil and ns.choosing) then finish() end
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/2687_01_name_menu.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(30)
|
||||
check("new_name_opens_empty_grid", topIsGrid(ns) and #ns.glyphs == 0)
|
||||
ns.row, ns.col = 3, 4
|
||||
U.shot(game, DIR .. "/2687_02_empty_grid_before_start.png")
|
||||
|
||||
U.tap(game, "start")
|
||||
check("empty_start_keeps_grid_up", game.stack:top() == ns)
|
||||
check("empty_start_whites_out", (ns.whiteout or 0) > 0)
|
||||
U.still(game, DIR .. "/2687_03_white_reentry.png")
|
||||
|
||||
U.wait(60)
|
||||
check("whiteout_ends", (ns.whiteout or 0) == 0)
|
||||
check("grid_back_with_cursor_on_A", topIsGrid(ns) and ns.row == 1 and ns.col == 1)
|
||||
U.shot(game, DIR .. "/2687_04_grid_back_cursor_on_A.png")
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(60)
|
||||
check("second_empty_start_still_on_grid", topIsGrid(ns) and #ns.glyphs == 0)
|
||||
|
||||
ns.row, ns.col = 1, 1
|
||||
U.tap(game, "a")
|
||||
U.wait(5)
|
||||
check("typed_letter_A", #ns.glyphs == 1 and ns.glyphs[1] == "A")
|
||||
U.shot(game, DIR .. "/2687_05_typed_A.png")
|
||||
U.tap(game, "start")
|
||||
U.wait(30)
|
||||
check("typed_name_confirms", namingScreen() == nil)
|
||||
check("player_named_A", game.save.player.name == "A")
|
||||
U.shot(game, DIR .. "/2687_06_your_name_is_A.png")
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,96 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Oak = require("src.ui.OakSpeech")
|
||||
local TB = require("src.render.TextBox")
|
||||
local PF = require("src.render.PaletteFX")
|
||||
local dir = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local failed = false
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
failed = failed or not ok
|
||||
return ok
|
||||
end
|
||||
local origMode = PF.mode
|
||||
|
||||
while game.stack:top() do game.stack:pop() end
|
||||
game.save.options.textSpeed = 1
|
||||
local speech = Oak.new(game, function() end)
|
||||
game.stack:push(speech)
|
||||
local legend
|
||||
for i, step in ipairs(speech.steps) do if step.id == "legend" then legend = i end end
|
||||
|
||||
for _ = 1, 4000 do
|
||||
if speech.step == legend then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
check("reached legend step", speech.step == legend)
|
||||
|
||||
local arrowOnLast, aPresses = false, 0
|
||||
for _ = 1, 2000 do
|
||||
if speech.shrink then break end
|
||||
local top = game.stack:top()
|
||||
if getmetatable(top) == TB then
|
||||
if top.waiting then
|
||||
U.tap(game, "a")
|
||||
aPresses = aPresses + 1
|
||||
else
|
||||
if top.done and top:arrowVisible() then arrowOnLast = true end
|
||||
U.wait(1)
|
||||
end
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
check("legend last page has no arrow", not arrowOnLast)
|
||||
check("legend closes into the shrink with no A press", speech.shrink ~= nil)
|
||||
|
||||
local firstSeen = {}
|
||||
local function tag()
|
||||
if speech.pic == speech.playerPic then return "red" end
|
||||
if speech.pic == speech.shrinkPic1 then return "p1" end
|
||||
if speech.pic == speech.shrinkPic2 then return "p2" end
|
||||
if speech.walkVisible then return "walk" end
|
||||
return "none"
|
||||
end
|
||||
local shots = {}
|
||||
local replayOg, bakedOg = false, false
|
||||
for _ = 1, 400 do
|
||||
local s = speech.shrink
|
||||
if not s then break end
|
||||
local f = s.frame
|
||||
local t = tag()
|
||||
if f > 0 and not firstSeen[t] then firstSeen[t] = f end
|
||||
if f == Oak.SHRINK_PIC1_AT + 20 and not shots.p1 then
|
||||
shots.p1 = U.still(game, dir .. "/2685_01_shrinkpic1_held.png")
|
||||
elseif f == Oak.SHRINK_PIC2_AT + 16 and not shots.p2 then
|
||||
shots.p2 = U.still(game, dir .. "/2685_02_shrinkpic2_held.png")
|
||||
elseif f == Oak.SHRINK_WALK_AT + 10 and not shots.walk then
|
||||
shots.walk = U.still(game, dir .. "/2685_03_walk_sprite_" .. origMode .. ".png")
|
||||
PF.setMode("ogred")
|
||||
U.wait(1)
|
||||
elseif f == Oak.SHRINK_WALK_AT + 20 and not shots.walkOg then
|
||||
shots.walkOg = U.still(game, dir .. "/2685_04_walk_sprite_ogred_green.png")
|
||||
local r = PF.uiSpriteRedraws()
|
||||
replayOg = r[1] ~= nil
|
||||
bakedOg = r[1] ~= nil and r[1].image ~= speech.walkSheet
|
||||
elseif f == Oak.SHRINK_FADE_AT + 10 and not shots.fade then
|
||||
shots.fade = U.still(game, dir .. "/2685_05_fade_ogred_sprite_lightened.png")
|
||||
else
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
PF.setMode(origMode)
|
||||
|
||||
U.log("first frames red/p1/p2/walk:", tostring(firstSeen.red), tostring(firstSeen.p1),
|
||||
tostring(firstSeen.p2), tostring(firstSeen.walk))
|
||||
check("RedPicFront held >= 30 frames", (firstSeen.p1 or 0) - 1 >= 30)
|
||||
check("ShrinkPic1 held >= 30 frames",
|
||||
firstSeen.p1 and firstSeen.p2 and firstSeen.p2 - firstSeen.p1 >= 30)
|
||||
check("ShrinkPic2 held >= 25 frames",
|
||||
firstSeen.p2 and firstSeen.walk and firstSeen.walk - firstSeen.p2 >= 25)
|
||||
check("OG RED walk sprite is OBJ-baked and replayed", replayOg and bakedOg)
|
||||
check("shrink finished", speech.shrink == nil)
|
||||
check("shots written", shots.p1 and shots.p2 and shots.walk and shots.walkOg and shots.fade)
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -0,0 +1,141 @@
|
||||
-- scripts/OaksLab.asm:918
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR")
|
||||
or "/tmp/shots"
|
||||
|
||||
local MAP = "OAKS_LAB"
|
||||
local OWN = "OAKSLAB_SQUIRTLE_POKE_BALL"
|
||||
local RIVAL = "OAKSLAB_BULBASAUR_POKE_BALL"
|
||||
local LIMIT = 120000
|
||||
local failed = false
|
||||
|
||||
local function check(label, ok)
|
||||
if ok then print("PASS " .. label) else print("FAIL " .. label) failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
print(failed and "FAIL oak_starter_ball_hide_bug2686"
|
||||
or "PASS oak_starter_ball_hide_bug2686")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local function ballShown(name)
|
||||
local ow = game.overworld
|
||||
for _, n in ipairs(ow and ow.npcs or {}) do
|
||||
if n.def and n.def.name == name then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function boxText()
|
||||
local states = game.stack.states or {}
|
||||
for i = #states, 1, -1 do
|
||||
local s = states[i]
|
||||
if getmetatable(s) == TextBox then
|
||||
local out = {}
|
||||
for _, page in ipairs(s.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " "):lower(), s
|
||||
end
|
||||
end
|
||||
return "", nil
|
||||
end
|
||||
|
||||
local function scriptRunning()
|
||||
local ow = game.overworld
|
||||
return ow and ow.runner and ow.runner:isRunning()
|
||||
end
|
||||
|
||||
local save = game.save
|
||||
save.player.name = (save.player.name and save.player.name ~= "")
|
||||
and save.player.name or "RED"
|
||||
save.party = {}
|
||||
save.flags.EVENT_FOLLOWED_OAK_INTO_LAB = true
|
||||
save.flags.EVENT_FOLLOWED_OAK_INTO_LAB_2 = true
|
||||
save.flags.EVENT_GOT_STARTER = nil
|
||||
save.objectToggles = save.objectToggles or {}
|
||||
save.objectToggles[MAP] = save.objectToggles[MAP] or {}
|
||||
local tog = save.objectToggles[MAP]
|
||||
tog.OAKSLAB_OAK1 = true
|
||||
tog.OAKSLAB_OAK2 = false
|
||||
tog.OAKSLAB_RIVAL = true
|
||||
tog.OAKSLAB_CHARMANDER_POKE_BALL = true
|
||||
tog.OAKSLAB_SQUIRTLE_POKE_BALL = true
|
||||
tog.OAKSLAB_BULBASAUR_POKE_BALL = true
|
||||
|
||||
U.teleport(game, MAP, 7, 4, "up")
|
||||
U.wait(20)
|
||||
check("squirtle_ball_on_table_before_pick", ballShown(OWN))
|
||||
check("rival_counter_ball_on_table_before_pick", ballShown(RIVAL))
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
local sawEnergetic = false
|
||||
for _ = 1, LIMIT do
|
||||
local txt = boxText()
|
||||
if txt:find("energetic", 1, true) then sawEnergetic = true break end
|
||||
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
check("energetic_line_reached", sawEnergetic)
|
||||
if not sawEnergetic then finish() end
|
||||
check("ball_gone_when_energetic_line_opens", not ballShown(OWN))
|
||||
check("rival_ball_still_there_on_energetic_line", ballShown(RIVAL))
|
||||
check("no_starter_yet_on_energetic_line", #save.party == 0)
|
||||
U.wait(90)
|
||||
U.shot(game, DIR .. "/2686_01_energetic_line_ball_gone.png")
|
||||
|
||||
local sawReceived = false
|
||||
for _ = 1, LIMIT do
|
||||
local txt = boxText()
|
||||
if txt:find("received", 1, true) then sawReceived = true break end
|
||||
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
check("received_line_reached", sawReceived)
|
||||
check("ball_gone_on_received_line", not ballShown(OWN))
|
||||
U.wait(120)
|
||||
U.shot(game, DIR .. "/2686_02_received_line_ball_gone.png")
|
||||
|
||||
local sawNick, nickBox = false, nil
|
||||
for _ = 1, LIMIT do
|
||||
local txt, box = boxText()
|
||||
if txt:find("nickname", 1, true) then sawNick, nickBox = true, box break end
|
||||
if U.frame() % 6 == 0 then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
check("nickname_prompt_reached", sawNick)
|
||||
if not sawNick then finish() end
|
||||
check("ball_gone_on_nickname_prompt", not ballShown(OWN))
|
||||
check("rival_ball_still_there_on_nickname_prompt", ballShown(RIVAL))
|
||||
for _ = 1, LIMIT do
|
||||
if nickBox.choicePushed then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(20)
|
||||
U.shot(game, DIR .. "/2686_03_nickname_prompt_ball_gone.png")
|
||||
for _ = 1, LIMIT do
|
||||
local _, box = boxText()
|
||||
if box ~= nickBox then break end
|
||||
if U.frame() % 6 == 0 then U.tap(game, "b") else U.wait(1) end
|
||||
end
|
||||
|
||||
for _ = 1, LIMIT do
|
||||
if not scriptRunning() and game.stack:top() == game.overworld then break end
|
||||
local _, box = boxText()
|
||||
if box and box.done and not box.choice then U.tap(game, "a") else U.wait(1) end
|
||||
end
|
||||
U.wait(20)
|
||||
check("script_finished", not scriptRunning())
|
||||
check("player_got_squirtle",
|
||||
save.party[1] ~= nil and save.party[1].species == "SQUIRTLE")
|
||||
check("own_ball_still_hidden_after_pick", not ballShown(OWN))
|
||||
check("rival_took_bulbasaur_ball", not ballShown(RIVAL))
|
||||
check("charmander_ball_left_on_table",
|
||||
ballShown("OAKSLAB_CHARMANDER_POKE_BALL"))
|
||||
U.shot(game, DIR .. "/2686_04_after_rival_pick.png")
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Probe = dofile("tests/drivers/shot_probe.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "."
|
||||
|
||||
local fails = 0
|
||||
local function check(cond, label)
|
||||
if not cond then fails = fails + 1 end
|
||||
print((cond and "PASS " or "FAIL ") .. label)
|
||||
end
|
||||
|
||||
local savedVer = GameVersion.get()
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = "ogred"
|
||||
PaletteFX.setMode("ogred")
|
||||
|
||||
local function visit(version, shotName)
|
||||
GameVersion.set(version)
|
||||
U.teleport(game, "OAKS_LAB", 5, 5, "up")
|
||||
U.wait(60)
|
||||
local base = PaletteFX.ogObjBase()
|
||||
local shot = Probe.grab()
|
||||
if not shot then
|
||||
check(false, version .. " screen probe available")
|
||||
else
|
||||
local counts = Probe.count(shot, {
|
||||
light = base[2], dark = base[3],
|
||||
}, 1)
|
||||
U.log(version, "OBJ light px", counts.light, "OBJ dark px", counts.dark)
|
||||
check(counts.dark == 0, version .. " no dark OBJ shade on overworld sprites")
|
||||
check(counts.light > 0, version .. " light OBJ shade drawn on overworld sprites")
|
||||
end
|
||||
local colors = PaletteFX.ogObjWorld()
|
||||
check(colors[2] == base[1] and colors[3] == base[2] and colors[4] == base[4],
|
||||
version .. " overworld bake is rOBP0 $D0")
|
||||
check(U.shot(game, DIR .. "/" .. shotName), version .. " shot reached disk")
|
||||
end
|
||||
|
||||
local ok, err = pcall(function()
|
||||
visit("red", "2689_01_red_oaks_lab_white_faces.png")
|
||||
visit("blue", "2689_02_blue_oaks_lab_white_faces.png")
|
||||
end)
|
||||
if not ok then check(false, "driver ran: " .. tostring(err)) end
|
||||
GameVersion.set(savedVer)
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
-- engine/menus/party_menu.asm:71
|
||||
-- engine/menus/party_menu.asm:93
|
||||
-- engine/pokemon/status_screen.asm:46
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local failed = false
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
local party = {}
|
||||
for i, sp in ipairs({ "NIDOKING", "BULBASAUR", "MAGIKARP" }) do
|
||||
local mon = Pokemon.new(game.data, sp, 50)
|
||||
mon.stats.hp = 266
|
||||
mon.hp = 266
|
||||
party[i] = mon
|
||||
end
|
||||
game.save.party = party
|
||||
game.save.player.name = game.save.player.name or "RED"
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 12, "down")
|
||||
U.wait(10)
|
||||
|
||||
local function render(drawFn)
|
||||
local c = love.graphics.newCanvas(160, 144)
|
||||
love.graphics.setCanvas(c)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
drawFn()
|
||||
love.graphics.setCanvas()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return c:newImageData()
|
||||
end
|
||||
|
||||
local function ink(id, x0, y0, x1, y1)
|
||||
local n = 0
|
||||
for y = y0, y1 do
|
||||
for x = x0, x1 do
|
||||
local r, g, b = id:getPixel(x, y)
|
||||
if r + g + b < 2.7 then n = n + 1 end
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local function sameInk(a, b, x0, y0, x1, y1)
|
||||
for y = y0, y1 do
|
||||
for x = x0, x1 do
|
||||
local ra, ga, ba = a:getPixel(x, y)
|
||||
local rb, gb, bb = b:getPixel(x, y)
|
||||
if (ra + ga + ba < 2.7) ~= (rb + gb + bb < 2.7) then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
Screens.push(game, "PartyMenu", {})
|
||||
U.wait(12)
|
||||
local pm = game.stack:top()
|
||||
check("party_list_open", getmetatable(pm) == PartyMenu)
|
||||
|
||||
if getmetatable(pm) == PartyMenu then
|
||||
local id = render(function() pm:draw() end)
|
||||
for i = 1, #party do
|
||||
local hy = PartyMenu.entryY(i) + 8
|
||||
check(("row%d_col3_blank_under_name_start"):format(i),
|
||||
ink(id, 24, hy, 31, hy + 7) == 0)
|
||||
check(("row%d_hp_tile_at_col4"):format(i), ink(id, 32, hy, 39, hy + 7) > 0)
|
||||
check(("row%d_cap_at_col12"):format(i), ink(id, 96, hy, 103, hy + 7) > 0)
|
||||
local frac = render(function()
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(("%3d/%3d"):format(party[i].hp, party[i].stats.hp), 104, hy)
|
||||
end)
|
||||
check(("row%d_col13_is_fraction_only"):format(i),
|
||||
sameInk(id, frac, 104, hy, 111, hy + 7))
|
||||
end
|
||||
U.still(game, DIR .. "/2672_01_party_hp_bar_gap_before_fraction.png")
|
||||
end
|
||||
|
||||
game.stack:pop()
|
||||
U.wait(4)
|
||||
Screens.push(game, "PartyMenu", { tmhm = { move = "SWORDS_DANCE", kind = "TM" } })
|
||||
U.wait(12)
|
||||
local tm = game.stack:top()
|
||||
check("tm_list_open", getmetatable(tm) == PartyMenu and tm.tmhm ~= nil)
|
||||
if getmetatable(tm) == PartyMenu then
|
||||
local id = render(function() tm:draw() end)
|
||||
for i = 1, #party do
|
||||
local hy = PartyMenu.entryY(i) + 8
|
||||
check(("tm_row%d_learn_text_starts_col12"):format(i),
|
||||
ink(id, 88, hy, 95, hy + 7) == 0 and ink(id, 96, hy, 103, hy + 7) > 0)
|
||||
end
|
||||
U.still(game, DIR .. "/2672_02_tm_able_not_able_col12.png")
|
||||
end
|
||||
|
||||
print(failed and "FAIL party_hp_columns_bug2672" or "PASS party_hp_columns_bug2672")
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
@@ -6,15 +6,17 @@
|
||||
-- POKEPORT_IDENTITY=bug274 SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local failed = false
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
@@ -159,7 +161,7 @@ return function(game)
|
||||
:format(i, row.hp, row.pal),
|
||||
z ~= nil and z.colors == pal(row.pal))
|
||||
check(("row %d's block covers the bar's cap + six fill tiles"):format(i),
|
||||
z ~= nil and z.x == 48 and z.w == 56 and z.y == (i * 2 - 1) * 8
|
||||
z ~= nil and z.x == 40 and z.w == 56 and z.y == (i * 2 - 1) * 8
|
||||
and z.h == 8)
|
||||
end
|
||||
|
||||
@@ -227,14 +229,12 @@ return function(game)
|
||||
check("the icon column is drawn (not blank)", flesh > 0)
|
||||
|
||||
-- (b) the bars: each row's fill must be ITS OWN bar color and none of the
|
||||
-- other two. Fill tiles run x 56..103 (drawHPBar at tile 5: "HP" 40,
|
||||
-- ":[" 48, six fill tiles 56..103, cap 104).
|
||||
for i, row in ipairs(ROWS) do
|
||||
local y0 = (i * 2 - 1) * 8
|
||||
local own = pal(row.pal)
|
||||
-- the three bar palettes share color 0/1/3; only color 2, the fill
|
||||
-- shade, tells them apart
|
||||
local mine = countColor(id, own and own[3], 56, y0, 103, y0 + 7)
|
||||
local mine = countColor(id, own and own[3], 48, y0, 95, y0 + 7)
|
||||
U.log((" row %d (%d/48 HP) %s fill pixels: %d")
|
||||
:format(i, row.hp, row.pal, mine))
|
||||
if row.fill then
|
||||
@@ -243,7 +243,7 @@ return function(game)
|
||||
for _, other in ipairs({ "GREENBAR", "YELLOWBAR", "REDBAR" }) do
|
||||
if other ~= row.pal then
|
||||
local c = pal(other)
|
||||
local n = countColor(id, c and c[3], 56, y0, 103, y0 + 7)
|
||||
local n = countColor(id, c and c[3], 48, y0, 95, y0 + 7)
|
||||
check(("row %d's bar carries no %s"):format(i, other), n == 0)
|
||||
end
|
||||
end
|
||||
@@ -275,7 +275,5 @@ return function(game)
|
||||
U.log("OPTION -> COLORS cycles modes; OG and CLASSIC are mono by design.")
|
||||
U.log("Captures: bug274_party_advanced.png and bug274_party_sgb.png in " .. DIR)
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local PartyMenu = require("src.ui.PartyMenu")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local fails = 0
|
||||
local function check(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
local function finish()
|
||||
love.event.quit(fails == 0 and 0 or 1)
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
if not U.newGame(game) then
|
||||
check(false, "2682 reached the overworld")
|
||||
finish()
|
||||
end
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.textSpeed = 5
|
||||
game.save.inventory.SOULBADGE = true
|
||||
|
||||
local surfer = Pokemon.new(game.data, "SQUIRTLE", 30)
|
||||
surfer.moves = { { id = "SURF", pp = 15 } }
|
||||
local other = Pokemon.new(game.data, "PIDGEY", 30)
|
||||
game.save.party = { surfer, other }
|
||||
game.partyMenuSavedIndex = 1
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 4, 13, "down")
|
||||
local ow = game.overworld
|
||||
ow.player.surfing = false
|
||||
check(ow:useSurfFieldMove() == "ok", "2682 (4,13) faces surfable water")
|
||||
|
||||
U.tap(game, "start")
|
||||
U.wait(12)
|
||||
local menu = game.stack:top()
|
||||
local row
|
||||
for i, it in ipairs(menu and menu.items or {}) do
|
||||
if tostring(it.label):upper():find("MON", 1, true) then row = i break end
|
||||
end
|
||||
if not check(row ~= nil, "2682 START menu lists POKEMON") then finish() end
|
||||
for _ = 2, row do U.tap(game, "down"); U.wait(2) end
|
||||
U.tap(game, "a")
|
||||
U.wait(12)
|
||||
local pm = game.stack:top()
|
||||
if not check(getmetatable(pm) == PartyMenu, "2682 party menu open") then finish() end
|
||||
|
||||
local b0 = pm.blink
|
||||
U.wait(20)
|
||||
check(pm.blink ~= b0, "2682 cursor icon animates while the list takes input")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
if not check(pm.submenu, "2682 A opens the field-move submenu") then finish() end
|
||||
local frozen = pm.blink
|
||||
U.wait(40)
|
||||
check(pm.blink == frozen, "2682 icon frozen behind the submenu")
|
||||
U.still(game, DIR .. "/2682_01_submenu_icon_frozen.png")
|
||||
U.wait(10)
|
||||
check(pm.blink == frozen, "2682 icon still frozen ten frames later")
|
||||
U.still(game, DIR .. "/2682_02_submenu_icon_frozen_later.png")
|
||||
|
||||
U.tap(game, "b")
|
||||
U.wait(1)
|
||||
check(not pm.submenu and pm.blink <= 1, "2682 B back to the list restarts the icon")
|
||||
U.wait(9)
|
||||
U.tap(game, "down")
|
||||
check(pm.index == 2 and pm.blink <= 1, "2682 cursor move restarts the icon on its rest frame")
|
||||
U.wait(4)
|
||||
U.tap(game, "up")
|
||||
U.wait(4)
|
||||
check(pm.index == 1, "2682 cursor back on the surfer")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(8)
|
||||
local sub
|
||||
for i, it in ipairs(pm.subItems or {}) do
|
||||
if it.action == "surf" then sub = i break end
|
||||
end
|
||||
if not check(sub ~= nil, "2682 SURF listed in the submenu") then finish() end
|
||||
for _ = 2, sub do U.tap(game, "down"); U.wait(2) end
|
||||
U.tap(game, "a")
|
||||
U.wait(6)
|
||||
local top = game.stack:top()
|
||||
check(top ~= pm and top and top.pages ~= nil, "2682 got-on text is up")
|
||||
check(game.stack.states[game.stack:visibleBase()] == pm,
|
||||
"2682 party list is the backdrop of the got-on text")
|
||||
check(not pm.submenu and pm.chosenHollow == true,
|
||||
"2682 party cursor stays hollow under the got-on text")
|
||||
local textBlink = pm.blink
|
||||
local printed = false
|
||||
for _ = 1, 600 do
|
||||
if top.done or top.waiting then printed = true break end
|
||||
U.wait(1)
|
||||
end
|
||||
check(printed, "2682 got-on text finished printing")
|
||||
U.wait(4)
|
||||
check(pm.blink == textBlink, "2682 icon frozen under the got-on text")
|
||||
U.still(game, DIR .. "/2682_03_surf_text_hollow_cursor.png")
|
||||
|
||||
for _ = 1, 400 do
|
||||
local t = game.stack:top()
|
||||
if not (t and t.pages) then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(3)
|
||||
end
|
||||
U.wait(60)
|
||||
check(ow.player.surfing == true, "2682 SURF mounted after the text")
|
||||
|
||||
finish()
|
||||
end
|
||||
@@ -0,0 +1,135 @@
|
||||
-- engine/battle/core.asm:3227
|
||||
-- engine/battle/core.asm:4913
|
||||
local U = require("tests.drivers.util")
|
||||
local Battle = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Version = require("src.core.GameVersion")
|
||||
|
||||
return function(game)
|
||||
local deadline = love.timer.getTime() + 25
|
||||
local old = {}
|
||||
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do old[k] = rawget(game, k) end
|
||||
local keys = { "save", "saveOptions", "saveLiveOptions", "writeSlot", "writeCartSlot" }
|
||||
local writers = {}
|
||||
for _, k in ipairs(keys) do writers[k] = SaveData[k] end
|
||||
local volume = love.audio.getVolume()
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
assert(Version.generation() == 1, "Gen1 required")
|
||||
local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required")
|
||||
local function forbidden() error("#2652 unexpected persistent write") end
|
||||
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = forbidden end
|
||||
for _, k in ipairs(keys) do SaveData[k] = forbidden end
|
||||
love.audio.setVolume(0)
|
||||
|
||||
local function wait(n)
|
||||
for _ = 1, n do
|
||||
assert(love.timer.getTime() < deadline, "#2652 deadline")
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
local function fullyTyped(b)
|
||||
local row = b.shown and b.shown[#b.shown]
|
||||
return b.current and row and b.codes and #row == #b.codes and b.lineIndex == #b.lines
|
||||
and b.total and b.total > 0 and b.charIndex >= b.total and not b.scrollPx and not b.msgWaiting
|
||||
end
|
||||
local function move(id)
|
||||
local def = assert(game.data.moves[id], "required imported move " .. id)
|
||||
return { id = id, pp = def.pp, maxPp = def.pp }
|
||||
end
|
||||
|
||||
game:startNewGame({ intro = false })
|
||||
game.save.options.animations = false
|
||||
game.save.options.textSpeed = 1
|
||||
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
|
||||
local player = Pokemon.new(game.data, "WARTORTLE", 30)
|
||||
player.moves = { move("SURF") }
|
||||
game.save.party = { player }
|
||||
local battle = Battle.newWild(game, "CHARMELEON", 50)
|
||||
battle.enemy.mon.moves = { move("RAGE") }
|
||||
battle.enemy.curMoves = battle.enemy.mon.moves
|
||||
battle.enemy.rageMove = battle.enemy.curMoves[1]
|
||||
battle.rng = function(a) return a or 0 end
|
||||
|
||||
local seen = {}
|
||||
local seenRows = {}
|
||||
local update = battle.update
|
||||
battle.update = function(self, ...)
|
||||
update(self, ...)
|
||||
local row = self.current
|
||||
if row and row.text and not seenRows[row] then
|
||||
seenRows[row] = true
|
||||
seen[#seen + 1] = row.text
|
||||
end
|
||||
end
|
||||
game.stack:push(battle)
|
||||
|
||||
local function advanceUntil(fn, label)
|
||||
for _ = 1, 3000 do
|
||||
if fn() then return true end
|
||||
if battle.current and not battle.current.auto and fullyTyped(battle) then U.tap(game, "a") end
|
||||
wait(1)
|
||||
end
|
||||
error(label)
|
||||
end
|
||||
local function settledOn(fragment)
|
||||
return function()
|
||||
return battle.current and battle.current.text
|
||||
and battle.current.text:find(fragment, 1, true) and fullyTyped(battle)
|
||||
end
|
||||
end
|
||||
local function pickSurf()
|
||||
advanceUntil(function() return battle.phase == "menu" end, "menu did not settle")
|
||||
U.tap(game, "a")
|
||||
advanceUntil(function() return battle.phase == "moveSelect" end, "move list missing")
|
||||
U.tap(game, "a")
|
||||
end
|
||||
local function find(needle, from)
|
||||
for i = from or 1, #seen do
|
||||
if seen[i]:find(needle, 1, true) then return i end
|
||||
end
|
||||
end
|
||||
|
||||
pickSurf()
|
||||
local surfAt = #seen + 1
|
||||
advanceUntil(settledOn("RAGE is building!"), "rage page never settled")
|
||||
assert(U.still(game, out .. "/2654_01_rage_after_super_effective.png"), "capture failed")
|
||||
U.tap(game, "a")
|
||||
advanceUntil(settledOn("ATTACK rose!"), "rose page never settled")
|
||||
assert(U.still(game, out .. "/2654_02_attack_rose.png"), "capture failed")
|
||||
|
||||
local used = find("used SURF", surfAt)
|
||||
local super = find("super", used)
|
||||
local rage = find("RAGE is building!", used)
|
||||
local rose = find("ATTACK rose!", used)
|
||||
check("#2654 Surf, super effective, rage and rose pages all shown", used and super and rage and rose)
|
||||
check("#2654 super effective prints before RAGE is building", super and rage and super < rage)
|
||||
check("#2654 ATTACK rose prints after RAGE is building", rage and rose and rage < rose)
|
||||
check("#2654 rage raised the enemy attack stage", (battle.enemy.stages.attack or 0) >= 1)
|
||||
|
||||
advanceUntil(function() return battle.phase == "menu" end, "turn 1 did not finish")
|
||||
battle.enemy.mon.hp, battle.enemy.shownHP = 1, 1
|
||||
local koFrom = #seen + 1
|
||||
pickSurf()
|
||||
advanceUntil(settledOn("fainted!"), "faint page never settled")
|
||||
assert(U.still(game, out .. "/2652_03_ko_faint_no_rage.png"), "capture failed")
|
||||
local koUsed = find("used SURF", koFrom)
|
||||
check("#2652 the KO turn reached Surf and the faint page", koUsed ~= nil)
|
||||
check("#2652 no RAGE is building page on the fainting hit", koUsed and find("RAGE is building!", koUsed) == nil)
|
||||
check("#2652 no ATTACK rose page on the fainting hit", koUsed and find("rose!", koUsed) == nil)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
|
||||
end, debug.traceback)
|
||||
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = old[k] end
|
||||
for _, k in ipairs(keys) do SaveData[k] = writers[k] end
|
||||
love.audio.setVolume(volume)
|
||||
if not ok then print("FAIL #2652 " .. tostring(err)) end
|
||||
love.event.quit((ok and fails == 0) and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,157 @@
|
||||
-- scripts/Route23.asm:237
|
||||
local U = require('tests.drivers.util')
|
||||
local Version = require('src.core.GameVersion')
|
||||
local TextBox = require('src.render.TextBox')
|
||||
|
||||
local function pageText(box, i)
|
||||
local page = box.pages and box.pages[i]
|
||||
return page and table.concat(page, ' ') or ''
|
||||
end
|
||||
|
||||
return function(game)
|
||||
local deadline = love.timer.getTime() + 90
|
||||
local ok, err = xpcall(function()
|
||||
assert(Version.generation() == 1, 'Gen1 edition required')
|
||||
local out = assert(os.getenv('POKEPORT_SHOT_DIR'), 'shot directory required')
|
||||
love.audio.setVolume(0)
|
||||
local function wait(n)
|
||||
for _ = 1, n do
|
||||
assert(love.timer.getTime() < deadline, '#2649 deadline')
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
local function untilReady(fn, limit, label)
|
||||
for _ = 1, limit do if fn() then return end; wait(1) end
|
||||
error(label)
|
||||
end
|
||||
local function isBox(s) return s and getmetatable(s) == TextBox end
|
||||
assert(game.data.text._Route23GoRightAheadText, '_Route23GoRightAheadText missing from cache')
|
||||
|
||||
game:startNewGame({ intro = false })
|
||||
game.save.inventory.CASCADEBADGE = 1
|
||||
U.teleport(game, 'ROUTE_23', 7, 137, 'up')
|
||||
local ow = assert(game.overworld)
|
||||
untilReady(function()
|
||||
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
|
||||
end, 400, 'field did not settle')
|
||||
|
||||
U.hold(game, 'up', 4)
|
||||
untilReady(function() return isBox(game.stack:top()) end, 200, 'guard text never opened')
|
||||
local box = game.stack:top()
|
||||
assert(#box.pages == 3, 'step path pages: ' .. #box.pages)
|
||||
assert(pageText(box, 2):find('Oh! That is the', 1, true), 'page 2 is not the badge line')
|
||||
assert(pageText(box, 3):find('OK then! Please,', 1, true), 'page 3 is not the go-ahead line')
|
||||
print('PASS #2649 step path shows three pages ending in OK then! Please, go right ahead!')
|
||||
|
||||
local sawFanfare = false
|
||||
untilReady(function()
|
||||
if box.pauseSrc or (box.pauseMark and box.pageIndex == 2) then sawFanfare = true end
|
||||
if box.pageIndex == 2 and box.waiting and not box.pauseSrc then return true end
|
||||
if box.pageIndex == 1 and box.waiting then U.tap(game, 'a') end
|
||||
return false
|
||||
end, 1200, 'badge page never settled')
|
||||
assert(sawFanfare, 'Get_Item1 mark never fired on the badge page')
|
||||
print('PASS #2649 Get_Item1 fires after Oh! That is the CASCADEBADGE!')
|
||||
assert(U.still(game, out .. '/2649_01_badge_page_after_fanfare.png'), 'capture failed')
|
||||
|
||||
untilReady(function()
|
||||
if box.pageIndex < 3 and box.waiting then U.tap(game, 'a') end
|
||||
return box.pageIndex == 3 and (box.done or box.waiting)
|
||||
end, 600, 'go-ahead page never typed: page ' .. tostring(box.pageIndex))
|
||||
assert(U.still(game, out .. '/2649_02_step_go_right_ahead.png'), 'capture failed')
|
||||
print('PASS #2649 step path go-ahead page typed in the same box')
|
||||
|
||||
untilReady(function()
|
||||
if game.stack:top() == box then U.tap(game, 'a') end
|
||||
return game.stack:top() == ow
|
||||
end, 300, 'step box never closed')
|
||||
assert(ow.player.cellY == 136, 'player was turned back: y=' .. ow.player.cellY)
|
||||
assert(game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'pass flag not set')
|
||||
print('PASS #2649 step path lets the player through and sets EVENT_PASSED_CASCADEBADGE_CHECK')
|
||||
|
||||
U.teleport(game, 'ROUTE_23', 7, 136, 'right')
|
||||
ow = assert(game.overworld)
|
||||
untilReady(function()
|
||||
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
|
||||
end, 400, 'field did not settle for talk')
|
||||
U.tap(game, 'a')
|
||||
untilReady(function() return isBox(game.stack:top()) end, 200, 'talk text never opened')
|
||||
local seenOh, goBox = false, nil
|
||||
untilReady(function()
|
||||
local top = game.stack:top()
|
||||
if isBox(top) then
|
||||
for i = 1, #top.pages do
|
||||
if pageText(top, i):find('Oh! That is the', 1, true) then seenOh = true end
|
||||
if seenOh and pageText(top, i):find('OK then! Please,', 1, true) then goBox = top end
|
||||
end
|
||||
if goBox and goBox.pageIndex == #goBox.pages and (goBox.done or goBox.waiting) then
|
||||
return true
|
||||
end
|
||||
if top.waiting or (top.done and not top.pauseSrc) then U.tap(game, 'a') end
|
||||
end
|
||||
return false
|
||||
end, 2400, 'talk path never reached the go-ahead text')
|
||||
assert(U.still(game, out .. '/2649_03_talk_go_right_ahead.png'), 'capture failed')
|
||||
print('PASS #2649 talking to the guard also ends in OK then! Please, go right ahead!')
|
||||
assert(#goBox.pages == 3, 'talk path pages: ' .. #goBox.pages)
|
||||
assert(pageText(goBox, 2):find('Oh! That is the', 1, true), 'talk path: page 2 is not the badge line')
|
||||
assert(goBox.pauseAt, 'talk path: no fanfare mark in the box')
|
||||
print('PASS #2649 talk path is one box: badge line, fanfare mark, go-ahead page')
|
||||
untilReady(function()
|
||||
if game.stack:top() ~= ow then U.tap(game, 'a') end
|
||||
return game.stack:top() == ow
|
||||
end, 300, 'talk box never closed')
|
||||
|
||||
local function failBox(label)
|
||||
local fb = game.stack:top()
|
||||
assert(isBox(fb), label .. ': no box')
|
||||
assert(fb.auto and fb.auto.sound, label .. ': SFX_DENIED not armed after the text')
|
||||
assert(not fb.autoStarted, label .. ': sound fired before the text typed')
|
||||
untilReady(function()
|
||||
if fb.autoStarted then return true end
|
||||
if fb.waiting then U.tap(game, 'a') end
|
||||
return false
|
||||
end, 1200, label .. ': SFX_DENIED never fired after the text')
|
||||
assert(fb.done, label .. ': sound fired before the last page typed')
|
||||
print('PASS #2649 ' .. label .. ' plays SFX_DENIED after the text')
|
||||
untilReady(function()
|
||||
if game.stack:top() == fb then U.tap(game, 'a') end
|
||||
return game.stack:top() == ow
|
||||
end, 300, label .. ': fail box never closed')
|
||||
untilReady(function() return not ow.player.moving and #ow.scriptMoves == 0 end,
|
||||
200, label .. ': walk-down never settled')
|
||||
end
|
||||
|
||||
game.save.inventory.CASCADEBADGE = nil
|
||||
game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK = nil
|
||||
U.teleport(game, 'ROUTE_23', 7, 137, 'up')
|
||||
ow = assert(game.overworld)
|
||||
untilReady(function()
|
||||
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
|
||||
end, 400, 'field did not settle for the step fail')
|
||||
U.hold(game, 'up', 4)
|
||||
untilReady(function() return isBox(game.stack:top()) end, 200, 'step fail text never opened')
|
||||
failBox('step fail')
|
||||
assert(ow.player.cellY == 137, 'step fail: not pushed back down, y=' .. ow.player.cellY)
|
||||
assert(ow.player.facing == 'down', 'step fail: facing ' .. tostring(ow.player.facing))
|
||||
assert(not game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'step fail set the pass flag')
|
||||
print('PASS #2649 step fail walks the player back down')
|
||||
|
||||
U.teleport(game, 'ROUTE_23', 8, 137, 'up')
|
||||
ow = assert(game.overworld)
|
||||
untilReady(function()
|
||||
return game.stack:top() == ow and not ow.transitioning and not ow.player.moving
|
||||
end, 400, 'field did not settle for the talk fail')
|
||||
local canStepDown = ow.map:isWalkableCell(8, 138)
|
||||
U.tap(game, 'a')
|
||||
untilReady(function() return isBox(game.stack:top()) end, 200, 'talk fail text never opened')
|
||||
failBox('talk fail')
|
||||
assert(ow.player.facing == 'down', 'talk fail: facing ' .. tostring(ow.player.facing))
|
||||
assert(ow.player.cellY == (canStepDown and 138 or 137),
|
||||
'talk fail: y=' .. ow.player.cellY .. ' walkable=' .. tostring(canStepDown))
|
||||
assert(not game.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, 'talk fail set the pass flag')
|
||||
print('PASS #2649 talk fail runs Route23MovePlayerDownScript')
|
||||
end, debug.traceback)
|
||||
if not ok then print('FAIL #2649 ' .. tostring(err)) end
|
||||
love.event.quit(ok and 0 or 1)
|
||||
end
|
||||
@@ -0,0 +1,176 @@
|
||||
local U = require("tests.drivers.util")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/rs_menus_parity"
|
||||
|
||||
local failures = 0
|
||||
local function check(ok, label)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then failures = failures + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local function finish()
|
||||
print((failures == 0 and "PASS" or "FAIL") .. " rs_menus_parity failures=" .. failures)
|
||||
love.event.quit(failures == 0 and 0 or 1)
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
local function shot(game, name)
|
||||
U.wait(3)
|
||||
U.shot(game, DIR .. "/" .. name .. ".png")
|
||||
U.wait(2)
|
||||
end
|
||||
|
||||
local function until_(n, fn)
|
||||
for _ = 1, n do
|
||||
if fn() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
return fn()
|
||||
end
|
||||
|
||||
local function demoMenus(game)
|
||||
local Boot = require("src.ui.game3.boot")
|
||||
local Menu = require("src.ui.game3.rs.main_menu")
|
||||
local Pal = require("src.core.game3.pal_fade")
|
||||
local origDraw = Boot.draw
|
||||
local info = { name = "BRENDAN", gender = 0, hours = 12, minutes = 34, badges = 3, dexCount = 27,
|
||||
mysteryEvents = true, frameType = 0 }
|
||||
local cases = {
|
||||
{ "00-main-no-save", { hasContinue = false } },
|
||||
{ "01-main-saved", { hasContinue = true, continueInfo = info, menuType = Menu.TYPE.HAS_SAVED_GAME } },
|
||||
{ "02-main-mystery-events", { hasContinue = true, continueInfo = info, menuType = Menu.TYPE.HAS_MYSTERY_EVENT } },
|
||||
}
|
||||
for _, c in ipairs(cases) do
|
||||
local demo = Menu.new(c[2])
|
||||
demo.pal, demo.state = Pal.new(), "input"
|
||||
Boot.draw = function() demo:draw() end
|
||||
check(demo.items[#demo.items] == "EXIT", c[1] .. " ends with EXIT")
|
||||
for _ = 1, #demo.items - 1 do
|
||||
demo:frame({ new = { down = true } })
|
||||
demo.state = "input"
|
||||
end
|
||||
shot(game, c[1] .. "-exit")
|
||||
local w = Menu.windowFor(demo.menuType, demo.cursor)
|
||||
check((w.top + w.height + 1) * 8 - demo.scroll <= 160, c[1] .. " EXIT window on screen")
|
||||
end
|
||||
Boot.draw = origDraw
|
||||
end
|
||||
|
||||
local function realMainMenuExit(game)
|
||||
local Boot = require("src.ui.game3.boot")
|
||||
local BootModules = require("src.ui.game3.boot_modules")
|
||||
BootModules.startMenu(Boot, game.boot)
|
||||
local menu = game.boot.custom.menu
|
||||
check(until_(300, function() return menu.state == "input" end), "real RS main menu reaches input")
|
||||
for _ = 1, #menu.items do U.tap(game, "down"); U.wait(2) end
|
||||
check(menu.items[menu.cursor] == "EXIT", "cursor on EXIT")
|
||||
shot(game, "03-real-main-menu-exit")
|
||||
local exited = false
|
||||
game.returnToLauncher = function() exited = true end
|
||||
U.tap(game, "a")
|
||||
check(until_(200, function() return exited end), "A on EXIT returns to the launcher")
|
||||
game.returnToLauncher = nil
|
||||
end
|
||||
|
||||
local function enterWorld(game)
|
||||
local MapIds = require("src.core.game3.map_ids")
|
||||
local Map = require("src.core.game3.map")
|
||||
local Player = require("src.core.game3.player")
|
||||
local Runtime = require("src.core.game3.runtime")
|
||||
local Warp = require("src.core.game3.warp")
|
||||
local Message = require("src.ui.game3.message")
|
||||
local function settle()
|
||||
until_(600, function()
|
||||
if Message.isOpen and Message.isOpen() then U.tap(game, "b") end
|
||||
return not (Warp.isBusy() or (Message.isOpen and Message.isOpen()))
|
||||
end)
|
||||
end
|
||||
pcall(function() game:_handleBootAction({ action = "new_game", name = "RUBY", gender = 0 }) end)
|
||||
U.wait(60)
|
||||
settle()
|
||||
pcall(function()
|
||||
Map.load(nil, game, MapIds.forConst("MAP_LITTLEROOT_TOWN"), { x = 10, y = 10, facing = "down" })
|
||||
end)
|
||||
local s = Runtime.getSession()
|
||||
if s then s.x, s.y, s.facing = 10, 10, "down" end
|
||||
Player.cellX, Player.cellY, Player.px, Player.py = 10, 10, 160, 160
|
||||
Player.targetX, Player.targetY, Player.facing = 10, 10, "down"
|
||||
U.wait(60)
|
||||
settle()
|
||||
return s
|
||||
end
|
||||
|
||||
local function moveTo(game, page, target)
|
||||
for _ = 1, 64 do
|
||||
if page.index == target then return true end
|
||||
U.tap(game, page.index < target and "down" or "up")
|
||||
U.wait(1)
|
||||
end
|
||||
return page.index == target
|
||||
end
|
||||
|
||||
local function options(game, session)
|
||||
local Menu = require("src.ui.game3.rs.option_menu")
|
||||
local Option = require("src.ui.game3.screens").get("option", session)
|
||||
check(Option == Menu, "ruby OPTION is the RS menu")
|
||||
Option.show({ session = session, game = game })
|
||||
until_(120, function() return Menu._state == "input" end)
|
||||
local top = Menu._pages[1]
|
||||
check(top.rows[1].id == "group.speed", "top page starts with SPEED like Emerald")
|
||||
shot(game, "10-option-top")
|
||||
moveTo(game, top, #top.rows + 1)
|
||||
shot(game, "11-option-top-cancel")
|
||||
for _, id in ipairs({ "group.battle", "group.graphics", "group.speed", "group.audio" }) do
|
||||
local gi
|
||||
for i, r in ipairs(top.rows) do if r.id == id then gi = i end end
|
||||
if check(gi ~= nil, id .. " on the top page") then
|
||||
moveTo(game, top, gi)
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
local page = Menu._pages[#Menu._pages]
|
||||
local natives = 0
|
||||
for _, r in ipairs(page.rows) do if r.native then natives = natives + 1 end end
|
||||
check(#Menu._pages == 2 and natives > 0, id .. " page carries native cart rows")
|
||||
shot(game, "12-option-" .. id:gsub("^group%.", ""))
|
||||
U.tap(game, "b")
|
||||
U.wait(4)
|
||||
end
|
||||
end
|
||||
U.tap(game, "b")
|
||||
check(until_(120, function() return not Menu.isOpen() end), "B saves and closes OPTION")
|
||||
end
|
||||
|
||||
local function startMenuExit(game, session)
|
||||
local StartMenu = require("src.ui.game3.start_menu")
|
||||
local Boot = require("src.ui.game3.boot")
|
||||
U.wait(10)
|
||||
StartMenu.show({ session = session, game = game })
|
||||
U.wait(4)
|
||||
for i, e in ipairs(StartMenu.ENTRIES) do if e.id == "exit" then StartMenu.cursor = i end end
|
||||
shot(game, "20-start-menu-exit")
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
check(StartMenu._confirmExit == true, "START EXIT asks to return to the main menu")
|
||||
shot(game, "21-start-menu-exit-confirm")
|
||||
U.tap(game, "up")
|
||||
U.wait(2)
|
||||
U.tap(game, "a")
|
||||
check(until_(300, function() return game.phase == "boot" and game.boot and game.boot.phase == Boot.PHASE.TITLE end),
|
||||
"YES returns to the title screen")
|
||||
U.wait(60)
|
||||
shot(game, "22-after-exit-title")
|
||||
end
|
||||
|
||||
return function(game)
|
||||
until_(900, function() return game.phase == "boot" and game.boot end)
|
||||
if not check(game.boot ~= nil, "boot reached") then return finish() end
|
||||
local ok, err = xpcall(function()
|
||||
demoMenus(game)
|
||||
realMainMenuExit(game)
|
||||
local session = enterWorld(game)
|
||||
options(game, session)
|
||||
startMenuExit(game, session)
|
||||
end, debug.traceback)
|
||||
if not ok then check(false, "driver error: " .. tostring(err)) end
|
||||
finish()
|
||||
end
|
||||
@@ -73,7 +73,7 @@ local function phase1(game, ShaderFX, ShaderFXMenu)
|
||||
end
|
||||
shot(game, "01-native-first-page")
|
||||
local top = Menu._pages[1]
|
||||
for i = 1, 7 do check(top.rows[i].native == i, "native row " .. i .. " on page 1") end
|
||||
check(top.rows[1].id == "group.speed", "page 1 opens on Emerald's SPEED group")
|
||||
|
||||
U.tap(game, "select")
|
||||
U.wait(4)
|
||||
@@ -81,12 +81,12 @@ local function phase1(game, ShaderFX, ShaderFXMenu)
|
||||
|
||||
moveTo(game, top, 8)
|
||||
shot(game, "02-scrolled-port-rows")
|
||||
moveTo(game, top, #top.rows)
|
||||
moveTo(game, top, #top.rows + 1)
|
||||
shot(game, "03-port-rows-bottom")
|
||||
|
||||
local groups = {}
|
||||
for i, row in ipairs(top.rows) do
|
||||
if row.port and row.port.group then groups[#groups + 1] = { i = i, id = row.port.id } end
|
||||
if row.group then groups[#groups + 1] = { i = i, id = row.id } end
|
||||
end
|
||||
check(#groups >= 4, "RS OPTION shows " .. #groups .. " port pages")
|
||||
local n = 3
|
||||
|
||||
@@ -4,61 +4,80 @@
|
||||
-- Pallet Town.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Oak = require("src.ui.OakSpeech")
|
||||
local DIR = os.getenv("POKEPORT_SHOT_DIR") or os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local failed = false
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
failed = failed or not ok
|
||||
return ok
|
||||
end
|
||||
|
||||
U.wait(5)
|
||||
U.tap(game, "start") -- skip intro movie
|
||||
U.tap(game, "start")
|
||||
U.wait(10)
|
||||
U.tap(game, "a") -- title -> menu
|
||||
U.wait(5)
|
||||
-- with an existing save the menu is CONTINUE / NEW GAME / OPTION
|
||||
local title = game.stack:top()
|
||||
for _ = 1, 60 do
|
||||
U.tap(game, "a")
|
||||
U.wait(5)
|
||||
if game.stack:top() ~= title then break end
|
||||
end
|
||||
local ok, saved = pcall(function()
|
||||
return require("src.core.SaveData").load() ~= nil
|
||||
end)
|
||||
if ok and saved then U.tap(game, "down") U.wait(3) end
|
||||
U.tap(game, "a") -- NEW GAME
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
-- mash through the speech + both naming screens (preset 1)
|
||||
local function top() return game.stack:top() end
|
||||
local function speechState()
|
||||
for _, s in ipairs(game.stack.states or {}) do
|
||||
if s.shrink ~= nil or s.oakPic ~= nil then return s end
|
||||
if getmetatable(s) == Oak then return s end
|
||||
end
|
||||
end
|
||||
for _ = 1, 400 do
|
||||
local s = speechState()
|
||||
if s and s.step and s.step >= 9 then break end
|
||||
local s
|
||||
for _ = 1, 4000 do
|
||||
s = speechState()
|
||||
if s and s.shrink then break end
|
||||
U.tap(game, "a")
|
||||
U.wait(2)
|
||||
end
|
||||
check("shrink beat entered", s ~= nil and s.shrink ~= nil)
|
||||
if not (s and s.shrink) then love.event.quit(1) return end
|
||||
|
||||
-- the shrink beat: captures aimed at each timeline window, with the
|
||||
-- exact frame logged so the windows can be verified
|
||||
local s = speechState()
|
||||
local function frameNow()
|
||||
return (s and s.shrink and s.shrink.frame) or -1
|
||||
return (s.shrink and s.shrink.frame) or -1
|
||||
end
|
||||
U.log("shrink beat entered:", tostring(s ~= nil and s.shrink ~= nil),
|
||||
"frame:", frameNow())
|
||||
U.shot(game, DIR .. "/shrink_1_redpic.png") -- frames 1-4: RedPicFront
|
||||
U.log("shot1 frame:", frameNow())
|
||||
U.wait(3)
|
||||
U.shot(game, DIR .. "/shrink_2_pic1.png") -- frames 5-8: ShrinkPic1
|
||||
U.log("shot2 frame:", frameNow())
|
||||
U.wait(8)
|
||||
U.shot(game, DIR .. "/shrink_3_pic2.png") -- frames 9-28: ShrinkPic2
|
||||
U.log("shot3 frame:", frameNow())
|
||||
U.wait(25)
|
||||
U.shot(game, DIR .. "/shrink_4_sprite.png") -- frames 29-78: walk sprite
|
||||
U.log("shot4 frame:", frameNow())
|
||||
U.wait(43)
|
||||
U.shot(game, DIR .. "/shrink_5_fade.png") -- frames 79-102: fade
|
||||
U.log("shot5 frame:", frameNow())
|
||||
U.wait(15)
|
||||
U.wait(30)
|
||||
U.shot(game, DIR .. "/shrink_6_overworld.png")
|
||||
local function at(target, name, wantPic, wantWalk)
|
||||
for _ = 1, 400 do
|
||||
if not s.shrink or s.shrink.frame >= target then break end
|
||||
U.wait(1)
|
||||
end
|
||||
local f = frameNow()
|
||||
local shot = U.still(game, DIR .. "/" .. name .. ".png")
|
||||
U.log(name .. " frame:", f)
|
||||
check(name .. " lands at frame " .. target .. " (got " .. f .. ")", f == target)
|
||||
check(name .. " shows the expected beat", s.pic == wantPic and (s.walkVisible or false) == wantWalk)
|
||||
check(name .. " shot reached disk", shot)
|
||||
end
|
||||
|
||||
at(Oak.SHRINK_PIC1_AT - 1, "shrink_1_redpic", s.playerPic, false)
|
||||
at(Oak.SHRINK_PIC1_AT + 2, "shrink_2_pic1", s.shrinkPic1, false)
|
||||
at(Oak.SHRINK_PIC2_AT + 2, "shrink_3_pic2", s.shrinkPic2, false)
|
||||
at(Oak.SHRINK_WALK_AT + 2, "shrink_4_sprite", nil, true)
|
||||
at(Oak.SHRINK_FADE_AT + 2, "shrink_5_fade", nil, true)
|
||||
check("shrink_5_fade is mid fade", s.fadeLevel ~= nil)
|
||||
|
||||
for _ = 1, 400 do
|
||||
if top() == game.overworld and game.overworld then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(10)
|
||||
local shot = U.shot(game, DIR .. "/shrink_6_overworld.png")
|
||||
U.log("after speech: top==overworld:", tostring(top() == game.overworld),
|
||||
"map:", game.overworld and game.overworld.map
|
||||
and game.overworld.map.id or "?")
|
||||
check("speech hands off to the overworld", game.overworld ~= nil and top() == game.overworld)
|
||||
check("shrink_6_overworld shot reached disk", shot)
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
-- engine/battle/move_effects/recoil.asm:65
|
||||
-- engine/battle/core.asm:3226
|
||||
local U = require("tests.drivers.util")
|
||||
local Battle = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Version = require("src.core.GameVersion")
|
||||
|
||||
return function(game)
|
||||
local deadline = love.timer.getTime() + 25
|
||||
local old = {}
|
||||
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do old[k] = rawget(game, k) end
|
||||
local keys = { "save", "saveOptions", "saveLiveOptions", "writeSlot", "writeCartSlot" }
|
||||
local writers = {}
|
||||
for _, k in ipairs(keys) do writers[k] = SaveData[k] end
|
||||
local volume = love.audio.getVolume()
|
||||
local fails = 0
|
||||
local function check(label, ok)
|
||||
print((ok and "PASS " or "FAIL ") .. label)
|
||||
if not ok then fails = fails + 1 end
|
||||
return ok
|
||||
end
|
||||
|
||||
local ok, err = xpcall(function()
|
||||
assert(Version.generation() == 1, "Gen1 required")
|
||||
local out = assert(os.getenv("POKEPORT_SHOT_DIR"), "shot directory required")
|
||||
local function forbidden() error("#2653 unexpected persistent write") end
|
||||
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = forbidden end
|
||||
for _, k in ipairs(keys) do SaveData[k] = forbidden end
|
||||
love.audio.setVolume(0)
|
||||
|
||||
local function wait(n)
|
||||
for _ = 1, n do
|
||||
assert(love.timer.getTime() < deadline, "#2653 deadline")
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
local function fullyTyped(b)
|
||||
local row = b.shown and b.shown[#b.shown]
|
||||
return b.current and row and b.codes and #row == #b.codes and b.lineIndex == #b.lines
|
||||
and b.total and b.total > 0 and b.charIndex >= b.total and not b.scrollPx and not b.msgWaiting
|
||||
end
|
||||
local function move(id)
|
||||
local def = assert(game.data.moves[id], "required imported move " .. id)
|
||||
return { id = id, pp = def.pp, maxPp = def.pp }
|
||||
end
|
||||
|
||||
game:startNewGame({ intro = false })
|
||||
game.save.options.animations = false
|
||||
game.save.options.textSpeed = 1
|
||||
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
|
||||
|
||||
local function runTurn(pauseAt)
|
||||
game.save.party = { Pokemon.new(game.data, "TAUROS", 40) }
|
||||
game.save.party[1].moves = { move("TAKE_DOWN") }
|
||||
local battle = Battle.newWild(game, "SNORLAX", 50)
|
||||
battle.enemy.mon.moves = { move("SPLASH") }
|
||||
battle.enemy.curMoves = battle.enemy.mon.moves
|
||||
battle.rng = function(a) return a or 0 end
|
||||
|
||||
local r = { step = 0 }
|
||||
local update = battle.update
|
||||
battle.update = function(self, ...)
|
||||
if r.paused then return end
|
||||
if r.armed then
|
||||
r.step = r.step + 1
|
||||
local p, e = self.player, self.enemy
|
||||
if e.shownHP ~= r.lastEnemy then
|
||||
r.enemyLastMove = r.step
|
||||
r.lastEnemy = e.shownHP
|
||||
end
|
||||
if not r.playerFirstMove and p.shownHP < r.pStart then r.playerFirstMove = r.step end
|
||||
if not r.eEnd and e.mon.hp < r.eStart then r.eEnd = e.mon.hp end
|
||||
if not r.midStep and r.eEnd and e.shownHP <= math.floor((r.eStart + r.eEnd) / 2) then
|
||||
r.midStep = r.step
|
||||
r.midPlayerHP = p.shownHP
|
||||
end
|
||||
local row = self.current
|
||||
if row and row.text and row.text:find("recoil", 1, true) and not r.recoilStep then
|
||||
r.recoilStep = r.step
|
||||
r.recoilPlayerHP = p.shownHP
|
||||
end
|
||||
if pauseAt and r.midStep == r.step then r.paused = true end
|
||||
end
|
||||
update(self, ...)
|
||||
end
|
||||
game.stack:push(battle)
|
||||
|
||||
local function advanceUntil(fn, label)
|
||||
for _ = 1, 3000 do
|
||||
if fn() then return true end
|
||||
if not r.paused and battle.current and not battle.current.auto and fullyTyped(battle) then
|
||||
U.tap(game, "a")
|
||||
end
|
||||
wait(1)
|
||||
end
|
||||
error(label)
|
||||
end
|
||||
|
||||
advanceUntil(function() return battle.phase == "menu" end, "menu did not settle")
|
||||
r.pStart, r.eStart = battle.player.mon.hp, battle.enemy.mon.hp
|
||||
r.lastEnemy = battle.enemy.shownHP
|
||||
U.tap(game, "a")
|
||||
advanceUntil(function() return battle.phase == "moveSelect" end, "move list missing")
|
||||
r.armed = true
|
||||
U.tap(game, "a")
|
||||
advanceUntil(function() return battle.player.mon.hp < r.pStart end, "recoil never applied")
|
||||
r.eEnd, r.pEnd = r.eEnd or battle.enemy.mon.hp, battle.player.mon.hp
|
||||
if pauseAt then
|
||||
advanceUntil(function() return r.paused end, "never reached the counted mid-drain step")
|
||||
r.shotOk = U.still(game, out .. "/2653_01_target_draining_user_bar_full.png")
|
||||
r.shotPlayerHP, r.shotEnemyHP = battle.player.shownHP, battle.enemy.shownHP
|
||||
r.paused = false
|
||||
end
|
||||
advanceUntil(function()
|
||||
return battle.current and battle.current.text
|
||||
and battle.current.text:find("recoil", 1, true) and fullyTyped(battle)
|
||||
end, "recoil text never settled")
|
||||
if pauseAt then
|
||||
r.textShotOk = U.still(game, out .. "/2653_02_recoil_text_after_user_bar.png")
|
||||
r.textPlayerHP = battle.player.shownHP
|
||||
end
|
||||
advanceUntil(function() return battle.phase == "menu" end, "turn did not finish")
|
||||
game.stack:pop()
|
||||
return r
|
||||
end
|
||||
|
||||
local count = runTurn(nil)
|
||||
check("#2653 the hit and the recoil both landed", count.eEnd < count.eStart and count.pEnd < count.pStart)
|
||||
check("#2653 user bar holds full through the target's drain",
|
||||
count.playerFirstMove and count.enemyLastMove and count.playerFirstMove > count.enemyLastMove)
|
||||
check("#2653 user bar still full at mid target drain", count.midStep ~= nil and count.midPlayerHP == count.pStart)
|
||||
check("#2653 user bar finished before the recoil text",
|
||||
count.recoilStep ~= nil and count.recoilPlayerHP == count.pEnd)
|
||||
|
||||
local shoot = runTurn(count.midStep ~= nil)
|
||||
print(("#2653 mid target drain at logic step %s (count pass) / %s (shot pass)"):format(
|
||||
tostring(count.midStep), tostring(shoot.midStep)))
|
||||
check("#2653 mid-drain shot captured", shoot.shotOk == true)
|
||||
check("#2653 shot frame: target mid-drain, user bar full",
|
||||
shoot.shotPlayerHP == shoot.pStart and shoot.shotEnemyHP and shoot.shotEnemyHP < shoot.eStart
|
||||
and shoot.shotEnemyHP > shoot.eEnd)
|
||||
check("#2653 recoil text shot captured with the user bar down",
|
||||
shoot.textShotOk == true and shoot.textPlayerHP == shoot.pEnd)
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 9, 7, "down")
|
||||
end, debug.traceback)
|
||||
for _, k in ipairs({ "writeSave", "writeOptions", "persistOptions" }) do game[k] = old[k] end
|
||||
for _, k in ipairs(keys) do SaveData[k] = writers[k] end
|
||||
love.audio.setVolume(volume)
|
||||
if not ok then print("FAIL #2653 " .. tostring(err)) end
|
||||
love.event.quit((ok and fails == 0) and 0 or 1)
|
||||
end
|
||||
@@ -12,8 +12,10 @@ return function(game)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local failed = false
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
if not ok then failed = true end
|
||||
return ok
|
||||
end
|
||||
|
||||
@@ -24,7 +26,7 @@ return function(game)
|
||||
U.wait(30)
|
||||
local ow = game.overworld
|
||||
check("overworld is up", ow ~= nil)
|
||||
if not ow then while true do coroutine.yield() end end
|
||||
if not ow then love.event.quit(1) return end
|
||||
|
||||
local npc
|
||||
for _, e in ipairs(ow.entities) do
|
||||
@@ -82,7 +84,7 @@ return function(game)
|
||||
U.shot(game, DIR .. "/bug1948_wipe_ogred.png"))
|
||||
local ok, last = replayRecord()
|
||||
local sp = ow.player.sprite
|
||||
local want = SpriteRenderer.obpImage(sp.def.image, PaletteFX.ogObj())
|
||||
local want = SpriteRenderer.obpImage(sp.def.image, PaletteFX.ogObjWorld())
|
||||
local dmg = SpriteRenderer.obpImage(sp.def.image, PaletteFX.dmgObj())
|
||||
check("the ogred wipe replay queued a redraw", ok and last ~= nil)
|
||||
check("the replayed survivor is the ogObj bake, not the DMG bake",
|
||||
@@ -108,7 +110,5 @@ return function(game)
|
||||
U.log("green, NOT DMG gray.")
|
||||
U.log("bug1948_wipe_black.png: solid black, nobody left.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
love.event.quit(failed and 1 or 0)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
-- engine/items/inventory.asm:59
|
||||
-- engine/events/pokemart.asm:152
|
||||
|
||||
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 function glyphs(text)
|
||||
local n = 0
|
||||
for _ in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
|
||||
n = n + 1
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local realFont = package.loaded["src.render.Font"]
|
||||
package.loaded["src.render.Font"] = {
|
||||
BORDER = { tl = 1, tr = 2, bl = 3, br = 4, h = 5, v = 6 },
|
||||
draw = function() end,
|
||||
drawCode = function() end,
|
||||
drawBox = function() end,
|
||||
width = function(text) return glyphs(text) * 8 end,
|
||||
split = function(text)
|
||||
local out = {}
|
||||
for s in tostring(text):gmatch("[%z\1-\127\194-\244][\128-\191]*") do
|
||||
out[#out + 1] = s
|
||||
end
|
||||
return out
|
||||
end,
|
||||
spansFitting = function(spans, pixels)
|
||||
return math.min(#spans, math.floor(pixels / 8))
|
||||
end,
|
||||
encode = function(text) return { text } end,
|
||||
advanceOf = function() return 8 end,
|
||||
}
|
||||
|
||||
local RELOAD = {
|
||||
"src.ui.QuantityBox", "src.ui.ShopMenu", "src.ui.ListMenu",
|
||||
"src.ui.ChoiceBox", "src.ui.Menu", "src.ui.Theme", "src.render.TextBox",
|
||||
}
|
||||
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local ShopMenu = require("src.ui.ShopMenu")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
|
||||
local BAG_FULL = "You can't carry\nany more items."
|
||||
local BOUGHT = "Here you are!\nThank you!"
|
||||
|
||||
local function boxText(box)
|
||||
local out = {}
|
||||
for _, page in ipairs(box.pages or {}) do
|
||||
for _, line in ipairs(page) do out[#out + 1] = line end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
local function rowsOf(save, data)
|
||||
local out = {}
|
||||
for _, r in ipairs(Bag.rows(save, data)) do
|
||||
out[#out + 1] = r.id .. "x" .. tostring(r.count)
|
||||
end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
local function newGame(money, inventory, order)
|
||||
local stack = {}
|
||||
local game
|
||||
game = {
|
||||
data = {
|
||||
items = {
|
||||
X_ACCURACY = { name = "X ACCURACY", price = 950, index = 46 },
|
||||
POTION = { name = "POTION", price = 300, index = 20 },
|
||||
},
|
||||
text = {
|
||||
_PokemartItemBagFullText = BAG_FULL,
|
||||
_PokemartBoughtItemText = BOUGHT,
|
||||
},
|
||||
constants = {},
|
||||
field = {},
|
||||
},
|
||||
save = { money = money, inventory = inventory, bagOrder = order },
|
||||
input = {
|
||||
wasPressed = function() return false end,
|
||||
isDown = function() return false end,
|
||||
},
|
||||
}
|
||||
game.stack = {
|
||||
push = function(_, s) stack[#stack + 1] = s end,
|
||||
pop = function() return table.remove(stack) end,
|
||||
top = function() return stack[#stack] end,
|
||||
}
|
||||
return game, stack
|
||||
end
|
||||
|
||||
local function drain(game)
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if not (top and top.stay and not top.stayShown) then return end
|
||||
top:update(1 / 60)
|
||||
end
|
||||
end
|
||||
|
||||
local function fill(game, n)
|
||||
for i = 1, n do
|
||||
local id = "FILLER_" .. i
|
||||
game.save.inventory[id] = 1
|
||||
game.save.bagOrder[#game.save.bagOrder + 1] = id
|
||||
end
|
||||
end
|
||||
|
||||
local function buy(game, stack, id, qty)
|
||||
local menu = ShopMenu.new(game, { id }, function() end)
|
||||
menu.items[1].onSelect()
|
||||
drain(game)
|
||||
local list = stack[#stack]
|
||||
list.onChoose({ value = id })
|
||||
local qtyBox = stack[#stack]
|
||||
qtyBox.onDone(qty)
|
||||
local confirm = stack[#stack]
|
||||
game.stack:pop()
|
||||
confirm.choice(true)
|
||||
return stack[#stack]
|
||||
end
|
||||
|
||||
GameVersion.set("red")
|
||||
|
||||
do
|
||||
local game = newGame(0, { X_ACCURACY = 2, POTION = 5 }, { "X_ACCURACY", "POTION" })
|
||||
eq(Bag.add(game.save, "X_ACCURACY", 99, game.data), true,
|
||||
"2 + 99 X ACCURACY fits with a free slot")
|
||||
eq(game.save.inventory.X_ACCURACY, 101, "the total is 101")
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx2",
|
||||
"the first slot maxes at 99 and the overflow lands at the end")
|
||||
eq(Bag.slots(game.save, game.data), 3, "three bag slots")
|
||||
|
||||
Bag.remove(game.save, "X_ACCURACY", 2, game.data, 2)
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5",
|
||||
"tossing the overflow row removes that slot")
|
||||
|
||||
Bag.add(game.save, "X_ACCURACY", 2, game.data)
|
||||
Bag.remove(game.save, "X_ACCURACY", 99, game.data, 1)
|
||||
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx2",
|
||||
"emptying the first slot keeps the later slot where it was")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame(0, { X_ACCURACY = 99 }, { "X_ACCURACY" })
|
||||
fill(game, 19)
|
||||
eq(Bag.add(game.save, "X_ACCURACY", 1, game.data), false,
|
||||
"a 20-slot bag refuses the overflow slot")
|
||||
eq(game.save.inventory.X_ACCURACY, 99, "and changes nothing")
|
||||
eq(#game.save.bagOrder, 20, "no slot is added")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame(0, { X_ACCURACY = 149 }, { "X_ACCURACY", "X_ACCURACY" })
|
||||
fill(game, 18)
|
||||
eq(Bag.add(game.save, "X_ACCURACY", 10, game.data), false,
|
||||
"a full bag refuses once the maxed first slot overflows")
|
||||
eq(game.save.inventory.X_ACCURACY, 149, "and changes nothing")
|
||||
end
|
||||
|
||||
do
|
||||
local game, stack = newGame(104276, { X_ACCURACY = 2 }, { "X_ACCURACY" })
|
||||
local box = buy(game, stack, "X_ACCURACY", 99)
|
||||
if check(getmetatable(box) == TextBox, "mart: a text box follows the YES") then
|
||||
check(boxText(box):find("Here you are!", 1, true) ~= nil,
|
||||
"mart: the clerk hands the items over")
|
||||
check(boxText(box):find("can't carry", 1, true) == nil,
|
||||
"mart: no bag-full refusal")
|
||||
end
|
||||
eq(game.save.money, 104276 - 94050, "mart: 94050 is paid")
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,X_ACCURACYx2",
|
||||
"mart: two X ACCURACY rows")
|
||||
end
|
||||
|
||||
do
|
||||
local game, stack = newGame(3000, { X_ACCURACY = 99 }, { "X_ACCURACY" })
|
||||
fill(game, 19)
|
||||
local box = buy(game, stack, "X_ACCURACY", 1)
|
||||
if check(getmetatable(box) == TextBox, "mart full: a text box follows the YES") then
|
||||
check(boxText(box):find("can't carry", 1, true) ~= nil,
|
||||
"mart full: the bag-full refusal")
|
||||
end
|
||||
eq(game.save.money, 3000, "mart full: no money changes hands")
|
||||
end
|
||||
|
||||
do
|
||||
local game, stack = newGame(0, { X_ACCURACY = 101, POTION = 5 },
|
||||
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
|
||||
local menu = ShopMenu.new(game, {}, function() end)
|
||||
menu.items[2].onSelect()
|
||||
drain(game)
|
||||
local list = stack[#stack]
|
||||
local counts = {}
|
||||
for _, it in ipairs(list.items) do
|
||||
if not it.cancel then counts[#counts + 1] = tostring(it.count) end
|
||||
end
|
||||
eq(table.concat(counts, ","), "99,5,2", "sell list shows each slot's own count")
|
||||
list.index = 3
|
||||
list.onChoose(list.items[3])
|
||||
local qtyBox = stack[#stack]
|
||||
eq(qtyBox.max, 2, "selling from the overflow row caps at that row")
|
||||
qtyBox.onDone(2)
|
||||
local confirm = stack[#stack]
|
||||
game.stack:pop()
|
||||
confirm.choice(true)
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", "the sold row is gone")
|
||||
eq(game.save.money, 950, "paid half price for two")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame(0, { X_ACCURACY = 101, POTION = 5 },
|
||||
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
|
||||
local back = assert(SaveData.decode(SaveData.encode(game.save)))
|
||||
SaveData.validate(back, game.data)
|
||||
eq(rowsOf(back, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx2",
|
||||
"split slots survive a save round trip")
|
||||
end
|
||||
|
||||
do
|
||||
local pc, order = { POTION = 99 }, { "POTION" }
|
||||
local data = newGame(0, {}, {}).data
|
||||
eq(Bag.addTo(pc, order, "POTION", 50, data, Bag.storeSlots(pc, data), 50), true,
|
||||
"PC deposit past 99 splits the box slot")
|
||||
eq(table.concat(order, ","), "POTION,POTION", "two box slots")
|
||||
local full, fullOrder = { POTION = 1 }, { "POTION" }
|
||||
for i = 1, 49 do
|
||||
full["FILLER_" .. i] = 1
|
||||
fullOrder[#fullOrder + 1] = "FILLER_" .. i
|
||||
end
|
||||
full.POTION = 99
|
||||
eq(Bag.addTo(full, fullOrder, "POTION", 1, data, Bag.storeSlots(full, data), 50), false,
|
||||
"a 50-slot box refuses the overflow slot")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame(0, { X_ACCURACY = 101, POTION = 5 },
|
||||
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
|
||||
Bag.remove(game.save, "X_ACCURACY", 5, game.data, 1)
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx94,POTIONx5,X_ACCURACYx2",
|
||||
"tossing from the first slot leaves 94 and 2")
|
||||
eq(Bag.slots(game.save, game.data), 3, "both X ACCURACY slots stay")
|
||||
local back = assert(SaveData.decode(SaveData.encode(game.save)))
|
||||
SaveData.validate(back, game.data)
|
||||
eq(rowsOf(back, game.data), "X_ACCURACYx94,POTIONx5,X_ACCURACYx2",
|
||||
"a 94/2 split survives a save round trip")
|
||||
Bag.remove(game.save, "X_ACCURACY", 1, game.data)
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx93,POTIONx5,X_ACCURACYx2",
|
||||
"a slotless remove takes from the first slot")
|
||||
Bag.add(game.save, "X_ACCURACY", 10, game.data)
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5,X_ACCURACYx6",
|
||||
"adding tops the first slot up and carries the rest to the next")
|
||||
Bag.swap(game.save, 1, 3, game.data)
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx6,POTIONx5,X_ACCURACYx99",
|
||||
"SELECT on two slots of one item moves the 99 to the second pick")
|
||||
Bag.swap(game.save, 1, 2, game.data)
|
||||
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx6,X_ACCURACYx99",
|
||||
"swapping different items keeps each slot's count")
|
||||
Bag.swap(game.save, 3, 2, game.data)
|
||||
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx99,X_ACCURACYx6",
|
||||
"the 99 follows the second pick when the sum exceeds 99")
|
||||
Bag.remove(game.save, "X_ACCURACY", 90, game.data, 2)
|
||||
Bag.swap(game.save, 2, 3, game.data)
|
||||
eq(rowsOf(game.save, game.data), "POTIONx5,X_ACCURACYx15",
|
||||
"SELECT merges two slots that fit in one")
|
||||
eq(game.save.bagStacks, nil, "a merged stack carries no per-slot list")
|
||||
end
|
||||
|
||||
do
|
||||
local game, stack = newGame(0, { X_ACCURACY = 101, POTION = 5 },
|
||||
{ "X_ACCURACY", "POTION", "X_ACCURACY" })
|
||||
local menu = ShopMenu.new(game, {}, function() end)
|
||||
menu.items[2].onSelect()
|
||||
drain(game)
|
||||
local list = stack[#stack]
|
||||
local function sellRow(row, qty)
|
||||
list.index = row
|
||||
list.onChoose(list.items[row])
|
||||
local qtyBox = stack[#stack]
|
||||
qtyBox.onDone(qty)
|
||||
local confirm = stack[#stack]
|
||||
game.stack:pop()
|
||||
confirm.choice(true)
|
||||
end
|
||||
list.scroll = 1
|
||||
sellRow(3, 1)
|
||||
eq(list.scroll, 1, "selling part of a row keeps the list scroll")
|
||||
eq(list.index, 3, "and the cursor")
|
||||
sellRow(3, 1)
|
||||
eq(rowsOf(game.save, game.data), "X_ACCURACYx99,POTIONx5", "the emptied row is gone")
|
||||
eq(list.scroll, 0, "an emptied row resets the sell list scroll")
|
||||
eq(list.index, 1, "and puts the cursor on the first row")
|
||||
end
|
||||
|
||||
do
|
||||
GameVersion.set("gold")
|
||||
local game = newGame(0, { POTION = 99 }, { "POTION" })
|
||||
game.data.items.POTION.pocket = "ITEM"
|
||||
eq(Bag.add(game.save, "POTION", 1, game.data), false,
|
||||
"Gen 2 pockets keep one row per id")
|
||||
eq(#game.save.bagOrder, 1, "no duplicate row in Gen 2")
|
||||
GameVersion.set("red")
|
||||
end
|
||||
|
||||
package.loaded["src.render.Font"] = realFont
|
||||
for _, m in ipairs(RELOAD) do package.loaded[m] = nil end
|
||||
require("src.ui.Screens").invalidate()
|
||||
|
||||
T.finish()
|
||||
@@ -10,7 +10,9 @@ love = love or require("tests.love_stub")
|
||||
package.loaded["src.core.Sound"] = { play = function() end, playCry = function() end }
|
||||
package.loaded["src.core.Music"] = { playMap = function() end }
|
||||
package.loaded["src.render.TextBox"] = {
|
||||
new = function(_, text, done) return { textBox = true, text = text, done = done } end,
|
||||
new = function(_, text, done, opts)
|
||||
return { textBox = true, text = text, done = done, opts = opts }
|
||||
end,
|
||||
soundOpts = function() return nil end,
|
||||
}
|
||||
package.loaded["src.ui.BagMenu"] = nil
|
||||
@@ -114,6 +116,75 @@ do
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/menus/start_sub_menus.asm:361
|
||||
local function useLeavesBox(id, label)
|
||||
local game = freshGame()
|
||||
local list = openOn(game, byId(id))
|
||||
if not check(list ~= nil, label .. ": the bag opened on the row") then return end
|
||||
pressA(game, list)
|
||||
local box = game.stack:top()
|
||||
if not check(getmetatable(box) == Menu, label .. ": the USE/TOSS box is up") then return end
|
||||
eq(box.index, 1, label .. ": the cursor sits on USE")
|
||||
game.input.pressed = "a"
|
||||
box:update(1 / 60)
|
||||
game.input.pressed = nil
|
||||
check(game.stack:top() ~= box, label .. ": USE takes the box off the stack")
|
||||
check(game.stack:top() and game.stack:top().textBox,
|
||||
label .. ": the use flow prints its text")
|
||||
check(list.optionBox == box, label .. ": the box stays painted under the text")
|
||||
eq(box.hollowIndex, 1, label .. ": with the hollow '▷' left on USE")
|
||||
local drawn = false
|
||||
local realDraw = box.draw
|
||||
box.draw = function(self) drawn = true end
|
||||
list.drawItemBox = function() end
|
||||
list:draw()
|
||||
list.drawItemBox = nil
|
||||
box.draw = realDraw
|
||||
check(drawn, label .. ": drawing the list repaints the leftover box")
|
||||
game.stack:pop()
|
||||
drawn = false
|
||||
box.draw = function(self) drawn = true end
|
||||
list.drawItemBox = function() end
|
||||
list:draw()
|
||||
list.drawItemBox = nil
|
||||
box.draw = realDraw
|
||||
check(not drawn, label .. ": the first frame back on the list has no box")
|
||||
check(list.optionBox == nil, label .. ": the redraw erases it before any update")
|
||||
check(list.hollowIndex == nil, label .. ": and the list's '▶' is filled again")
|
||||
list:update(1 / 60)
|
||||
check(list.optionBox == nil, label .. ": the list's next update keeps it erased")
|
||||
end
|
||||
useLeavesBox("FIX_TM", "TM booted-up text")
|
||||
useLeavesBox("FIX_POTION", "potion refusal text")
|
||||
|
||||
-- engine/menus/start_sub_menus.asm:416
|
||||
do
|
||||
local game = freshGame()
|
||||
local list = openOn(game, byId("FIX_TM"))
|
||||
if check(list ~= nil, "TM YES: the bag opened on the TM row") then
|
||||
pressA(game, list)
|
||||
local box = game.stack:top()
|
||||
game.input.pressed = "a"
|
||||
box:update(1 / 60)
|
||||
game.input.pressed = nil
|
||||
local text = game.stack:top()
|
||||
check(list.optionBox == box, "TM YES: the box is painted under the prompt")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local realPush, pushed = Screens.push, nil
|
||||
Screens.push = function(g, id)
|
||||
pushed = id
|
||||
g.stack:push({ partyMenu = true })
|
||||
end
|
||||
local choice = text and text.opts and text.opts.choice
|
||||
if check(choice ~= nil, "TM YES: the prompt carries the YES/NO choice") then
|
||||
choice(true)
|
||||
end
|
||||
Screens.push = realPush
|
||||
eq(pushed, "PartyMenu", "TM YES: the party menu opens")
|
||||
check(list.optionBox == nil, "TM YES: the party menu wipes the leftover box")
|
||||
end
|
||||
end
|
||||
|
||||
-- CANCEL exits like B and never opens the box (home/list_menu.asm:105-110)
|
||||
do
|
||||
local game = freshGame()
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
-- home/text.asm:262
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
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 SPACE = 0x7F
|
||||
local LAST = Font.encode("Z")[1]
|
||||
T.check(LAST ~= SPACE, "fixture charmap encodes the 18th glyph")
|
||||
|
||||
local function newBattle(opts)
|
||||
opts = opts or {}
|
||||
local save = SaveData.newGame()
|
||||
save.player.name = "RED"
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 10, function(_, b) return b end) }
|
||||
if opts.gold then save.version, save.generation = "gold", 2 end
|
||||
if opts.wide then save.options = save.options or {}; save.options.battleLayout = "wide" end
|
||||
local pressA = false
|
||||
local game = {
|
||||
data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end },
|
||||
input = { wasPressed = function(_, k) return pressA and (k == "a") end,
|
||||
isDown = function() return false end },
|
||||
}
|
||||
local battle = BattleState.newWild(game, "FIXMON_C", 10)
|
||||
battle.queue, battle.nextInsert = {}, 0
|
||||
battle.phase = "messages"
|
||||
return battle, function(v) pressA = v end
|
||||
end
|
||||
|
||||
local function step(battle, until_)
|
||||
for _ = 1, 2000 do
|
||||
if until_() then return true end
|
||||
battle:updateQueue()
|
||||
battle:tickTextScroll()
|
||||
end
|
||||
return until_()
|
||||
end
|
||||
|
||||
local function drawnAt(battle, frame)
|
||||
battle.frame = frame
|
||||
local real, realBox = Font.drawCode, Font.drawBox
|
||||
local hits = {}
|
||||
Font.drawCode = function(code, x, y) hits[#hits + 1] = { code = code, x = x, y = y } end
|
||||
Font.drawBox = function() end
|
||||
local ok, err = pcall(function() battle:drawTextArea() end)
|
||||
Font.drawCode, Font.drawBox = real, realBox
|
||||
T.check(ok, "drawTextArea runs (" .. tostring(err) .. ")")
|
||||
local at, row = {}, 0
|
||||
for _, h in ipairs(hits) do
|
||||
if h.y == 128 then row = row + 1 end
|
||||
if h.x == 144 and h.y == 128 then at[#at + 1] = h.code end
|
||||
end
|
||||
return at, row
|
||||
end
|
||||
|
||||
local TEXT = "YOUR DEX IS\nENTIRELY COMPLETEZ\vCONGRATULATIONSZ"
|
||||
|
||||
do
|
||||
local battle, setA = newBattle()
|
||||
battle.queue[1] = { text = TEXT }
|
||||
T.check(step(battle, function() return battle.msgWaiting end),
|
||||
"red reaches the cont wait under an 18-tile line")
|
||||
T.eq(battle.shown[2] and battle.shown[2][18], LAST,
|
||||
"red bottom line's 18th glyph is typed")
|
||||
battle.msgPreWait = 0
|
||||
local lit, row = drawnAt(battle, 0)
|
||||
T.check(row >= 17, "the bottom line is drawn")
|
||||
T.eq(#lit, 0, "red arrow replaces the 18th glyph while lit")
|
||||
local dark, rowDark = drawnAt(battle, 30)
|
||||
T.check(rowDark >= 17, "the bottom line is drawn on blink-off")
|
||||
T.eq(#dark, 0, "red blink-off shows a blank cell, not the glyph")
|
||||
setA(true)
|
||||
battle:updateQueue()
|
||||
setA(false)
|
||||
T.eq(battle.msgWaiting, nil, "red A clears the cont wait")
|
||||
T.eq(battle.shown[1] and battle.shown[1][18], SPACE,
|
||||
"red cont leaves a space in the scrolled line")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, setA = newBattle({ gold = true })
|
||||
battle.queue[1] = { text = TEXT }
|
||||
step(battle, function() return battle.msgWaiting end)
|
||||
battle.msgPreWait = 0
|
||||
setA(true)
|
||||
battle:updateQueue()
|
||||
setA(false)
|
||||
T.eq(battle.shown[1] and battle.shown[1][18], LAST,
|
||||
"gold keeps column 18 (its arrow sits on the border row)")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, setA = newBattle({ wide = true })
|
||||
battle.queue[1] = { text = TEXT }
|
||||
step(battle, function() return battle.msgWaiting end)
|
||||
battle.msgPreWait = 0
|
||||
setA(true)
|
||||
battle:updateQueue()
|
||||
setA(false)
|
||||
T.eq(battle.shown[1] and battle.shown[1][18], LAST,
|
||||
"wide layout keeps column 18 (its arrow sits at column 36)")
|
||||
end
|
||||
|
||||
do
|
||||
local battle, setA = newBattle()
|
||||
battle.queue[1] = { text = "ITS A TEST OF\nTHE ARROW CELLS ZZ" }
|
||||
T.check(step(battle, function() return battle.msgPrompt end),
|
||||
"a typed-out page raises the prompt arrow")
|
||||
T.eq(battle.shown[2][18], LAST, "the prompt page's 18th glyph is typed")
|
||||
local lit, row = drawnAt(battle, 0)
|
||||
T.check(row >= 17, "the prompt page's bottom line is drawn")
|
||||
T.eq(#lit, 0, "prompt arrow replaces the 18th glyph")
|
||||
battle.msgPromptWait = 0
|
||||
setA(true)
|
||||
battle:updateQueue()
|
||||
setA(false)
|
||||
T.eq(battle.msgPrompt, nil, "A dismisses the prompt")
|
||||
T.eq(battle.shown[2][18], SPACE, "PromptText writes a space over the arrow cell")
|
||||
local held = drawnAt(battle, 0)
|
||||
T.eq(held[1], SPACE, "the held page shows the blank, not the glyph")
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.queue[1] = { text = "SHORT LINE\nSHORT TOO\vNEXT" }
|
||||
step(battle, function() return battle.msgWaiting end)
|
||||
T.check(#battle.shown[2] < 18, "a short bottom line has no 18th glyph")
|
||||
battle.msgPreWait = 0
|
||||
T.eq(#drawnAt(battle, 30), 0, "nothing drawn in the arrow cell for a short line")
|
||||
end
|
||||
|
||||
T.finish("battle arrow cell bug 2674")
|
||||
@@ -0,0 +1,160 @@
|
||||
-- engine/battle/common_text.asm:10
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
Font.load(Data)
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Music = require("src.core.Music")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local SOUND_FRAMES = 40
|
||||
local soundLeft = 0
|
||||
local function fakeSource()
|
||||
soundLeft = SOUND_FRAMES
|
||||
return { isPlaying = function() return soundLeft > 0 end,
|
||||
stop = function() soundLeft = 0 end }
|
||||
end
|
||||
|
||||
local real = {}
|
||||
for _, k in ipairs({ "playCry", "play", "playMove", "playMoveCry", "stopLoop" }) do
|
||||
real[k] = Sound[k]
|
||||
end
|
||||
local realMusic = { playBattle = Music.playBattle, play = Music.play }
|
||||
Sound.playCry = function() return fakeSource() end
|
||||
Sound.play = function(_, name)
|
||||
if name == "Trainer_Appeared" then return fakeSource() end
|
||||
end
|
||||
Sound.playMove = function() end
|
||||
Sound.playMoveCry = function() end
|
||||
Sound.stopLoop = function() end
|
||||
Music.playBattle = function() end
|
||||
Music.play = function() end
|
||||
|
||||
local function makeGame()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 20), Pokemon.new(Data, "FIXMON_B", 5) }
|
||||
local stack = { states = {} }
|
||||
function stack:push(s) self.states[#self.states + 1] = s end
|
||||
function stack:pop() return table.remove(self.states) end
|
||||
function stack:top() return self.states[#self.states] end
|
||||
return { data = Data, save = save, stack = stack,
|
||||
input = { wasPressed = function() return false end,
|
||||
isDown = function() return false end } }
|
||||
end
|
||||
|
||||
local function snapshot(battle)
|
||||
local rows, strings = 0, {}
|
||||
local realRow, realDraw = battle.drawBallRow, Font.draw
|
||||
battle.drawBallRow = function() rows = rows + 1 end
|
||||
Font.draw = function(text) strings[#strings + 1] = tostring(text) return 0 end
|
||||
local ok, err = pcall(battle.drawHUDs, battle, 0)
|
||||
battle.drawBallRow, Font.draw = realRow, realDraw
|
||||
local enemyHud = false
|
||||
for _, s in ipairs(strings) do
|
||||
if s:find(battle.enemy.name, 1, true) then enemyHud = true end
|
||||
end
|
||||
return ok, err, rows, enemyHud
|
||||
end
|
||||
|
||||
local function run(label, battle)
|
||||
battle.onFinish = function() end
|
||||
battle:enter()
|
||||
T.check(battle.introBalls ~= true, label .. ": no ball window at enter")
|
||||
local frames, soundOverlap = {}, false
|
||||
local ballsAt, textAt, sawRows
|
||||
for f = 1, 400 do
|
||||
if soundLeft > 0 then soundLeft = soundLeft - 1 end
|
||||
battle:update(1 / 60)
|
||||
local cur = battle.current and battle.current.text
|
||||
local atText = cur == battle.introText
|
||||
if atText and not textAt then textAt = f end
|
||||
if battle.introBalls == true and not ballsAt then
|
||||
ballsAt = f
|
||||
if soundLeft > 0 then soundOverlap = true end
|
||||
end
|
||||
local ok, err, rows, hud = snapshot(battle)
|
||||
T.check(ok, label .. ": drawHUDs runs on frame " .. f .. (ok and "" or (": " .. tostring(err))))
|
||||
frames[f] = { balls = battle.introBalls == true, rows = rows, hud = hud,
|
||||
landed = (battle.introSlide or 0) == 0 }
|
||||
if atText and rows > 0 then sawRows = true end
|
||||
if textAt then break end
|
||||
end
|
||||
local early, earlyRows, earlyHud = false, false, false
|
||||
for f, fr in ipairs(frames) do
|
||||
if textAt and f < textAt - 1 then
|
||||
if fr.balls then early = true end
|
||||
if fr.rows > 0 then earlyRows = true end
|
||||
end
|
||||
if textAt and f < textAt and fr.landed and fr.hud then earlyHud = true end
|
||||
end
|
||||
return { early = early, earlyRows = earlyRows, earlyHud = earlyHud,
|
||||
overlap = soundOverlap, ballsAt = ballsAt, textAt = textAt,
|
||||
sawRows = sawRows }
|
||||
end
|
||||
|
||||
GameVersion.set("red")
|
||||
|
||||
do
|
||||
local r = run("wild", BattleState.newWild(makeGame(), "FIXMON_C", 5))
|
||||
T.check(r.textAt ~= nil, "wild: the intro text comes up")
|
||||
T.check(not r.early, "wild: the ball window stays shut until the intro text")
|
||||
T.check(not r.overlap, "wild: the ball window opens after the cry has finished")
|
||||
T.eq(r.ballsAt, r.textAt and r.textAt - 1, "wild: balls go up right before the intro text")
|
||||
T.check(not r.earlyRows, "wild: no ball row is drawn during the cry")
|
||||
T.check(not r.earlyHud, "wild: no enemy HUD is drawn during the cry")
|
||||
T.check(r.sawRows, "wild: the ball row is drawn with the intro text")
|
||||
end
|
||||
|
||||
do
|
||||
local r = run("trainer", BattleState.newTrainer(makeGame(), "OPP_FIX_YOUNGSTER", 1))
|
||||
T.check(r.textAt ~= nil, "trainer: the intro text comes up")
|
||||
T.check(not r.early, "trainer: the ball window stays shut through the sfx and gap")
|
||||
T.check(not r.overlap, "trainer: the ball window opens after the sfx has finished")
|
||||
T.eq(r.ballsAt, r.textAt and r.textAt - 1, "trainer: balls go up right before the intro text")
|
||||
T.check(not r.earlyRows, "trainer: no ball rows during the sfx and gap")
|
||||
T.check(r.sawRows, "trainer: the ball rows are drawn with the intro text")
|
||||
end
|
||||
|
||||
do
|
||||
local ghost = BattleState.newWild(makeGame(), "FIXMON_C", 5)
|
||||
ghost:makeGhost()
|
||||
local r = run("ghost", ghost)
|
||||
T.check(r.ballsAt == nil and not r.sawRows, "ghost: DrawAllPokeballs never runs")
|
||||
T.check(not r.earlyHud, "ghost: no enemy HUD under the intro text")
|
||||
local nextText = ghost.queue[1] and ghost.queue[1].text
|
||||
T.check(type(nextText) == "string" and nextText:find("ID'd", 1, true) ~= nil,
|
||||
"ghost: GhostCantBeIDdText follows the intro text")
|
||||
end
|
||||
|
||||
do
|
||||
local soul = BattleState.newWild(makeGame(), "FIXMON_C", 5)
|
||||
soul:makeUnveiledGhost()
|
||||
local r = run("unveiled", soul)
|
||||
T.check(r.ballsAt == nil and not r.sawRows, "unveiled ghost: DrawAllPokeballs never runs")
|
||||
end
|
||||
|
||||
do
|
||||
local demo = BattleState.newWild(makeGame(), "FIXMON_C", 5)
|
||||
demo:makeOldManDemo()
|
||||
local r = run("red demo", demo)
|
||||
T.check(r.sawRows, "red old man demo: DrawAllPokeballs runs (no wBattleType check)")
|
||||
end
|
||||
|
||||
GameVersion.set("yellow")
|
||||
do
|
||||
local demo = BattleState.newWild(makeGame(), "FIXMON_C", 5)
|
||||
demo:makeOldManDemo()
|
||||
local r = run("yellow demo", demo)
|
||||
T.check(r.ballsAt == nil and not r.sawRows, "yellow old man demo: wBattleType skips DrawAllPokeballs")
|
||||
end
|
||||
GameVersion.set("red")
|
||||
|
||||
for k, v in pairs(real) do Sound[k] = v end
|
||||
Music.playBattle, Music.play = realMusic.playBattle, realMusic.play
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,179 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness").suite("Boulder dust OBP1 flash #2650")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Assets = require("src.render.Assets")
|
||||
local Game = require("src.core.Game")
|
||||
local OW = require("src.world.OverworldController")
|
||||
local ImageWriter = require("src.import.ImageWriter")
|
||||
|
||||
local bytes = {
|
||||
smoke = {0,24,26,102,4,66,11,129,86,137,26,46,76,18,56,56},
|
||||
redTree = {170,0,65,20,168,106,85,124,162,198,87,103,239,255,93,127,
|
||||
170,40,213,252,162,214,73,227,226,214,85,236,236,126,189,88,
|
||||
172,47,66,23,170,66,86,2,165,12,90,12,166,15,81,7,
|
||||
42,224,85,192,234,64,213,64,106,48,213,56,104,242,149,224},
|
||||
yellowTree = {170,0,65,20,168,106,85,124,130,254,87,127,239,255,93,127,
|
||||
170,40,213,252,162,254,85,255,250,254,85,252,236,126,189,88,
|
||||
172,47,66,23,170,66,86,2,165,12,90,12,166,15,81,7,
|
||||
42,224,85,192,234,64,213,64,106,48,213,56,104,242,149,224},
|
||||
}
|
||||
|
||||
local ID = {}; ID.__index = ID
|
||||
function ID:getDimensions() return self.w, self.h end
|
||||
function ID:getWidth() return self.w end
|
||||
function ID:getHeight() return self.h end
|
||||
function ID:getPixel(x, y) return unpack(self.p[y * self.w + x + 1] or {0,0,0,0}) end
|
||||
function ID:setPixel(x, y, ...) self.p[y * self.w + x + 1] = {...} end
|
||||
function ID:mapPixel(fn)
|
||||
for y = 0, self.h - 1 do
|
||||
for x = 0, self.w - 1 do self:setPixel(x, y, fn(x, y, self:getPixel(x, y))) end
|
||||
end
|
||||
end
|
||||
function ID:setFilter() end
|
||||
local function imageData(w, h) return setmetatable({w = w, h = h, p = {}}, ID) end
|
||||
local paths, draws, allocations, tint = {}, {}, 0, {1,1,1,1}
|
||||
love.image.newImageData = function(a, b)
|
||||
if type(a) == "number" then return imageData(a, b) end
|
||||
local source = assert(paths[a], "missing fixture " .. tostring(a))
|
||||
local out = imageData(source.w, source.h)
|
||||
for y = 0, source.h - 1 do
|
||||
for x = 0, source.w - 1 do out:setPixel(x, y, source:getPixel(x, y)) end
|
||||
end
|
||||
return out
|
||||
end
|
||||
love.graphics.newImage = function(a)
|
||||
allocations = allocations + 1
|
||||
return type(a) == "string" and love.image.newImageData(a) or a
|
||||
end
|
||||
love.graphics.setShader = function() end
|
||||
love.graphics.setColor = function(...) tint = {...} end
|
||||
love.graphics.draw = function(image, ...)
|
||||
draws[#draws + 1] = {image = image, args = {...}, alpha = tint[4]}
|
||||
end
|
||||
|
||||
local function upvalue(fn, wanted)
|
||||
for i = 1, 100 do
|
||||
local name, value = debug.getupvalue(fn, i)
|
||||
if not name then break end
|
||||
if name == wanted then return value end
|
||||
end
|
||||
end
|
||||
local fxDust = assert(upvalue(OW.drawWorld, "fxDust"))
|
||||
local fxCutTree = assert(upvalue(OW.drawWorld, "fxCutTree"))
|
||||
for i = 1, 100 do
|
||||
local name = debug.getupvalue(fxDust, i)
|
||||
if name == "Game" then debug.setupvalue(fxDust, i, Game); break end
|
||||
end
|
||||
|
||||
local definitions = {
|
||||
smoke = {path = "assets/generated/fx/b2650_smoke.png", w = 8, h = 8},
|
||||
cutTree = {path = "assets/generated/fx/b2650_tree.png", w = 16, h = 16},
|
||||
}
|
||||
local function fixtures(version)
|
||||
for key, def in pairs(definitions) do
|
||||
local raw = key == "cutTree" and bytes[version == "yellow" and "yellowTree" or "redTree"] or bytes[key]
|
||||
paths[def.path] = ImageWriter.decode2bpp(raw, def.w, def.h, true)
|
||||
end
|
||||
end
|
||||
local function state()
|
||||
return setmetatable({ map = {id = "VICTORY_ROAD_1F"}, camera = {x = 0, y = 0} }, {__index = OW})
|
||||
end
|
||||
Game.data = {field = {overworldFx = definitions}, palettes = {palettes = {}}, maps = {}}
|
||||
|
||||
local FLASH = { [0] = 0, [1] = 0, [2] = 0, [3] = 2 }
|
||||
local function colorOf(r) return r > .83 and 0 or r > .5 and 1 or r > .17 and 2 or 3 end
|
||||
|
||||
local function checkPixels(rows, key, colors, label)
|
||||
local source = paths[definitions[key].path]
|
||||
local wrong, opaqueZero, translucent, visible = 0, 0, 0, 0
|
||||
for _, row in ipairs(rows) do
|
||||
local q = type(row.args[1]) == "table" and row.args[1]
|
||||
for y = q and q.y or 0, (q and q.y or 0) + (q and q.h or source.h) - 1 do
|
||||
for x = q and q.x or 0, (q and q.x or 0) + (q and q.w or source.w) - 1 do
|
||||
local sr, _, _, sa = source:getPixel(x, y)
|
||||
local c = colorOf(sr)
|
||||
local p = {row.image:getPixel(x, y)}
|
||||
if sa == 0 or c == 0 then
|
||||
if p[4] ~= 0 then opaqueZero = opaqueZero + 1 end
|
||||
else
|
||||
local want = colors[c + 1]
|
||||
for i = 1, 3 do
|
||||
if math.abs(p[i] - want[i] / 255) > .00001 then wrong = wrong + 1; break end
|
||||
end
|
||||
if p[4] ~= 1 then translucent = translucent + 1 end
|
||||
visible = visible + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
T.eq(row.alpha, 1, label .. " drawn opaque, no alpha stand-in")
|
||||
end
|
||||
T.eq(wrong, 0, label .. " OBP1 %10000000 shade remap")
|
||||
T.eq(opaqueZero, 0, label .. " color 0 stays transparent")
|
||||
T.eq(translucent, 0, label .. " nonzero colors fully opaque")
|
||||
T.check(visible > 0, label .. " nonempty")
|
||||
end
|
||||
|
||||
local function flashOf(colors)
|
||||
return { colors[FLASH[0] + 1], colors[FLASH[1] + 1], colors[FLASH[2] + 1], colors[FLASH[3] + 1] }
|
||||
end
|
||||
|
||||
local cases = {
|
||||
{name = "boulder", key = "smoke", group = 7, draw = fxDust, count = 4,
|
||||
flash = function(s) s:startDustAnim(2, 3, nil, "right"); s.dustAnim.faded = true end,
|
||||
lit = function(s) s:startDustAnim(2, 3, nil, "right"); s.dustAnim.faded = false end},
|
||||
{name = "cut grass", key = "smoke", group = 6, draw = fxDust, count = 4,
|
||||
flash = function(s) s:startDustAnim(2, 3); s.dustAnim.frames = 31 end,
|
||||
lit = function(s) s:startDustAnim(2, 3); s.dustAnim.frames = 30 end},
|
||||
{name = "cut tree", key = "cutTree", group = 6, draw = fxCutTree, count = 2,
|
||||
flash = function(s) s:startCutTreeAnim(2, 3); s.cutAnim.frames = 7 end,
|
||||
lit = function(s) s:startCutTreeAnim(2, 3); s.cutAnim.frames = 6 end},
|
||||
}
|
||||
|
||||
local function render(s, case, which)
|
||||
s.dustAnim, s.cutAnim = nil, nil
|
||||
case[which](s); draws = {}; case.draw(s, s.camera)
|
||||
return draws
|
||||
end
|
||||
local function rawOf(s, case) return s[case.key == "smoke" and "smokeImg" or "cutTreeImg"] end
|
||||
|
||||
local s = state()
|
||||
local seen = {}
|
||||
for _, version in ipairs({"red", "blue", "yellow"}) do
|
||||
GameVersion.set(version); fixtures(version)
|
||||
PaletteFX.setShadeMap(nil); PaletteFX.setDarkWorld(false)
|
||||
for _, case in ipairs(cases) do
|
||||
for _, mode in ipairs({"og", "gbc", "ogred"}) do
|
||||
PaletteFX.setMode(mode)
|
||||
local label = version .. " " .. mode .. " " .. case.name
|
||||
local rows = render(s, case, "flash")
|
||||
T.eq(#rows, case.count, label .. " draw count")
|
||||
T.check(rows[1].image ~= rawOf(s, case), label .. " flash frame is not the identity image")
|
||||
checkPixels(rows, case.key, flashOf(PaletteFX.GRAYS), label .. " flash")
|
||||
local img, before = rows[1].image, allocations
|
||||
T.eq(render(s, case, "flash")[1].image, img, label .. " flash bake cached")
|
||||
T.eq(allocations, before, label .. " repeated flash draw allocates no image")
|
||||
rows = render(s, case, "lit")
|
||||
T.eq(rows[1].image, rawOf(s, case), label .. " identity frame is the raw image")
|
||||
T.eq(rows[1].alpha, 1, label .. " identity frame opaque")
|
||||
seen[version .. mode .. case.name] = img
|
||||
end
|
||||
PaletteFX.setMode("redpp")
|
||||
local label = version .. " redpp " .. case.name
|
||||
local obj = PaletteFX.worldPack().spritePalettes[case.group]
|
||||
local rows = render(s, case, "flash")
|
||||
checkPixels(rows, case.key, flashOf(obj), label .. " flash")
|
||||
local img, before = rows[1].image, allocations
|
||||
T.eq(render(s, case, "flash")[1].image, img, label .. " flash bake cached")
|
||||
T.eq(allocations, before, label .. " repeated flash draw allocates no image")
|
||||
rows = render(s, case, "lit")
|
||||
checkPixels(rows, case.key, obj, label .. " identity")
|
||||
T.check(rows[1].image ~= img, label .. " identity and flash bakes differ")
|
||||
end
|
||||
end
|
||||
T.check(seen["yellowogredcut tree"] ~= seen["redogredcut tree"], "yellow tree flash does not alias red bake")
|
||||
|
||||
PaletteFX.setMode("gbc")
|
||||
T.finish()
|
||||
@@ -0,0 +1,54 @@
|
||||
-- scripts/ChampionsRoom.asm:30
|
||||
-- home/map_objects.asm:179
|
||||
-- home/overworld.asm:1844
|
||||
|
||||
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 story = require("data.scripts.story")
|
||||
|
||||
local queued = {}
|
||||
local ow = {
|
||||
player = { cellX = 3, cellY = 7 },
|
||||
npcs = { { def = { name = "CHAMPIONSROOM_RIVAL" } } },
|
||||
queueScript = function(_, script, extra)
|
||||
queued[#queued + 1] = { script = script, extra = extra }
|
||||
end,
|
||||
}
|
||||
story.CHAMPIONS_ROOM.onEnter({ save = { flags = {} } }, ow)
|
||||
eq(#queued, 2, "entrance walk and rival script are queued")
|
||||
|
||||
local walk = queued[1] and queued[1].script or {}
|
||||
local rle = { { "up", 1 }, { "right", 1 }, { "up", 3 } }
|
||||
local expected = {}
|
||||
for i = #rle, 1, -1 do expected[#expected + 1] = rle[i] end
|
||||
eq(#walk, #expected, "entrance walk has one row per RLE entry")
|
||||
for i, e in ipairs(expected) do
|
||||
local row = walk[i] or {}
|
||||
eq(row[1], "move_player", "row " .. i .. " is move_player")
|
||||
eq(row[2], e[1], "row " .. i .. " direction plays the RLE back to front")
|
||||
eq(row[3], e[2], "row " .. i .. " count plays the RLE back to front")
|
||||
end
|
||||
|
||||
local dx = { up = 0, down = 0, left = -1, right = 1 }
|
||||
local dy = { up = -1, down = 1, left = 0, right = 0 }
|
||||
local x, y = 3, 7
|
||||
local cells = { x .. "," .. y }
|
||||
local lastDir
|
||||
for _, row in ipairs(walk) do
|
||||
if row[1] == "move_player" then
|
||||
for _ = 1, row[3] do
|
||||
x, y = x + dx[row[2]], y + dy[row[2]]
|
||||
cells[#cells + 1] = x .. "," .. y
|
||||
end
|
||||
lastDir = row[2]
|
||||
end
|
||||
end
|
||||
eq(table.concat(cells, " "), "3,7 3,6 3,5 3,4 4,4 4,3",
|
||||
"player walks up the left carpet column and sidesteps just below the rival")
|
||||
eq(lastDir, "up", "player ends facing up at the rival")
|
||||
|
||||
T.finish("champion_entrance_walk_bug2666")
|
||||
@@ -0,0 +1,76 @@
|
||||
-- engine/menus/main_menu.asm:357
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local love = _G.love or require("tests.love_stub")
|
||||
_G.love = love
|
||||
|
||||
local S = require("tests.harness").suite("continue info layout")
|
||||
local check, eq = S.check, S.eq
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local SaveData = require("src.core.SaveData")
|
||||
local StateStack = require("src.core.StateStack")
|
||||
local TitleState = require("src.ui.TitleState")
|
||||
|
||||
local savedGetInfo = love.filesystem.getInfo
|
||||
local savedLoad = SaveData.load
|
||||
local savedDraw, savedBox = Font.draw, Font.drawBox
|
||||
|
||||
local owned = {}
|
||||
for i = 1, 151 do owned[i] = true end
|
||||
local inventory = {}
|
||||
for _, id in ipairs({ "BOULDERBADGE", "CASCADEBADGE", "THUNDERBADGE",
|
||||
"RAINBOWBADGE", "SOULBADGE", "MARSHBADGE", "VOLCANOBADGE",
|
||||
"EARTHBADGE" }) do
|
||||
inventory[id] = 1
|
||||
end
|
||||
local fakeSave = {
|
||||
player = { name = "RED" },
|
||||
inventory = inventory,
|
||||
pokedex = { owned = owned },
|
||||
playTime = 47 * 3600 + 27 * 60,
|
||||
}
|
||||
|
||||
love.filesystem.getInfo = function() return { type = "file" } end
|
||||
SaveData.load = function() return fakeSave end
|
||||
|
||||
local stack = setmetatable({}, { __index = StateStack })
|
||||
stack:init()
|
||||
local game = { data = {}, stack = stack }
|
||||
local title = TitleState.new(game, {})
|
||||
title.game = game
|
||||
title:openMenu()
|
||||
local menu = game.stack:top()
|
||||
check(menu and menu.items and menu.items[1], "main menu opened with items")
|
||||
menu.items[1].onSelect()
|
||||
local info = game.stack:top()
|
||||
check(info and info ~= menu and info.draw, "CONTINUE pushed the info window")
|
||||
|
||||
local drawn = {}
|
||||
Font.draw = function(text, x, y) drawn[#drawn + 1] = { text, x, y } end
|
||||
Font.drawBox = function() end
|
||||
local ok, err = pcall(function() info:draw() end)
|
||||
Font.draw, Font.drawBox = savedDraw, savedBox
|
||||
love.filesystem.getInfo = savedGetInfo
|
||||
SaveData.load = savedLoad
|
||||
check(ok, "ContinueInfo:draw runs: " .. tostring(err))
|
||||
|
||||
local function at(text)
|
||||
for _, d in ipairs(drawn) do
|
||||
if d[1] == text then return d[2], d[3] end
|
||||
end
|
||||
end
|
||||
|
||||
local bx, by = at(" 8")
|
||||
eq(bx, 17 * 8, "badge count starts at hlcoord 17 (main_menu.asm:370)")
|
||||
eq(by, 11 * 8, "badge count on row 11")
|
||||
local dx, dy = at("151")
|
||||
eq(dx, 16 * 8, "dex count starts at hlcoord 16 (main_menu.asm:372)")
|
||||
eq(dy, 13 * 8, "dex count on row 13")
|
||||
local tx, ty = at(" 47:27")
|
||||
eq(tx, 13 * 8, "play time starts at hlcoord 13 (main_menu.asm:374)")
|
||||
eq(ty, 15 * 8, "play time on row 15")
|
||||
eq(bx + 2 * 8, tx + 6 * 8, "badge count ends flush with the play time")
|
||||
eq(dx + 3 * 8, tx + 6 * 8, "dex count ends flush with the play time")
|
||||
|
||||
S.finish()
|
||||
@@ -39,6 +39,109 @@ local ok, err = pcall(function()
|
||||
end)
|
||||
check(ok, "Diploma:draw() runs without error: " .. tostring(err))
|
||||
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sprites = require("src.pokemon.Sprites")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local fakePic = setmetatable({ w = 40, h = 56 }, {
|
||||
__index = {
|
||||
getDimensions = function(s) return s.w, s.h end,
|
||||
getWidth = function(s) return s.w end,
|
||||
getHeight = function(s) return s.h end,
|
||||
},
|
||||
})
|
||||
local origPlayerPic, origObp = Sprites.playerPic, SpriteRenderer.obpImage
|
||||
local origScissor, origDraw = love.graphics.setScissor, love.graphics.draw
|
||||
local origMode, origIsBlue = PaletteFX.mode, GameVersion.isBlue
|
||||
local bakes, scissors, picDraws = {}, {}, {}
|
||||
Sprites.playerPic = function() return "fake/player.png", false end
|
||||
SpriteRenderer.obpImage = function(path, ramp, group)
|
||||
bakes[#bakes + 1] = { path = path, ramp = ramp, group = group }
|
||||
return fakePic
|
||||
end
|
||||
love.graphics.setScissor = function(...)
|
||||
scissors[#scissors + 1] = { ... }
|
||||
end
|
||||
love.graphics.draw = function(img, a, b)
|
||||
if img == fakePic then picDraws[#picDraws + 1] = { a, b } end
|
||||
end
|
||||
|
||||
local function renderIn(mode, pass)
|
||||
bakes, scissors, picDraws = {}, {}, {}
|
||||
PaletteFX.mode = mode
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
PaletteFX.setPass(pass)
|
||||
local okR, errR = pcall(Diploma.render, Game)
|
||||
check(okR, "Diploma.render under " .. tostring(mode) .. "/"
|
||||
.. tostring(pass) .. ": " .. tostring(errR))
|
||||
local redraws = {}
|
||||
for _, r in ipairs(PaletteFX.uiSpriteRedraws()) do redraws[#redraws + 1] = r end
|
||||
PaletteFX.setPass(nil)
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
return redraws
|
||||
end
|
||||
|
||||
local function noNarrowScissor(label)
|
||||
for _, s in ipairs(scissors) do
|
||||
local x, w = s[1], s[3]
|
||||
check(x == nil or x + w >= 155,
|
||||
label .. ": scissor never clips the pic short of x 155")
|
||||
end
|
||||
end
|
||||
|
||||
local r = renderIn("sgb", "ui")
|
||||
noNarrowScissor("sgb")
|
||||
eq(#picDraws, 1, "sgb draws the player pic once")
|
||||
eq(picDraws[1] and picDraws[1][1], 115, "pic x 115 (diploma.asm:44 +33)")
|
||||
eq(picDraws[1] and picDraws[1][2], 80, "pic y 80")
|
||||
eq(bakes[1] and bakes[1].group, "diploma", "sgb keeps the OBP0 $90 DMG bake")
|
||||
eq(#r, 0, "sgb records no OBJ-ramp redraw")
|
||||
|
||||
GameVersion.isBlue = function() return false end
|
||||
r = renderIn("ogred", "ui")
|
||||
noNarrowScissor("ogred")
|
||||
eq(bakes[1] and bakes[1].group, "diploma_og", "OG RED bakes the OBJ ramp")
|
||||
local c = PaletteFX.OG_RED_SOFT_OBJ
|
||||
local ramp = bakes[1] and bakes[1].ramp or {}
|
||||
check(ramp[2] == c[1] and ramp[3] == c[2] and ramp[4] == c[3],
|
||||
"OG RED OBP0 $90 maps colors 1-3 to OBJ shades 0, 1, 2")
|
||||
check(r[1] and r[1].image == fakePic and r[1].x == 115 and r[1].y == 80,
|
||||
"OG RED replays the pic at (115, 80) after the zone pass")
|
||||
check(r[2] and r[2].clip and r[2].clip[1] == 115 and r[2].clip[2] == 80
|
||||
and r[2].clip[3] == 40 and r[2].clip[4] == 56,
|
||||
"OG RED replays the BG text over the pic, clipped to the pic")
|
||||
|
||||
local canvasSets = 0
|
||||
local origSetCanvas = love.graphics.setCanvas
|
||||
love.graphics.setCanvas = function(c)
|
||||
if c then canvasSets = canvasSets + 1 end
|
||||
return origSetCanvas(c)
|
||||
end
|
||||
renderIn("ogred", "ui")
|
||||
eq(canvasSets, 0, "OG RED reuses the cached text overlay on a second draw")
|
||||
Game.save.player.name = "RED"
|
||||
renderIn("ogred", "ui")
|
||||
eq(canvasSets, 1, "a changed player name rebuilds the text overlay once")
|
||||
Game.save.player.name = "ASH"
|
||||
love.graphics.setCanvas = origSetCanvas
|
||||
|
||||
GameVersion.isBlue = function() return true end
|
||||
r = renderIn("ogred", "ui")
|
||||
eq(bakes[1] and bakes[1].group, "diploma_og_blue",
|
||||
"OG BLUE bakes under a version-distinct cache group")
|
||||
check(bakes[1] and bakes[1].ramp[3] == PaletteFX.GBC_OBJ_BLUE[2],
|
||||
"OG BLUE uses Blue's OBJ ramp")
|
||||
GameVersion.isBlue = origIsBlue
|
||||
|
||||
r = renderIn("ogred", nil)
|
||||
eq(bakes[1] and bakes[1].group, "diploma", "printer path keeps the DMG bake")
|
||||
eq(#r, 0, "printer path records no redraw")
|
||||
|
||||
Sprites.playerPic, SpriteRenderer.obpImage = origPlayerPic, origObp
|
||||
love.graphics.setScissor, love.graphics.draw = origScissor, origDraw
|
||||
PaletteFX.mode = origMode
|
||||
|
||||
-- Confirm dismissal on A or B press
|
||||
Input.pressed = { a = true }
|
||||
diploma:update()
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
-- engine/pokemon/evos_moves.asm:156
|
||||
|
||||
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")
|
||||
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function() end,
|
||||
playCry = function() end,
|
||||
playPress = function() end,
|
||||
stopLoop = function() end,
|
||||
}
|
||||
|
||||
local Fixtures = require("tests.modkit.fixtures")
|
||||
local Music = require("src.core.Music")
|
||||
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 restores = 0
|
||||
Music.restoreMap = function() restores = restores + 1 end
|
||||
|
||||
local A_KEY, B_KEY = "z", "x"
|
||||
|
||||
local function newGame(count)
|
||||
local game = { data = Data }
|
||||
local party = {}
|
||||
for i = 1, count do party[i] = Pokemon.new(Data, "FIXMON_A", 16) end
|
||||
game.save = {
|
||||
party = party,
|
||||
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()
|
||||
local under = { isOpaque = true, update = function() end, draw = function() end }
|
||||
game.stack:push(under)
|
||||
return game, party, under
|
||||
end
|
||||
|
||||
local function step(game, key)
|
||||
if key then Input:keypressed(key) end
|
||||
game.input:step()
|
||||
game.stack:update(1 / 60)
|
||||
if key then Input:keyreleased(key) end
|
||||
end
|
||||
|
||||
local function indexOf(game, state)
|
||||
for i, s in ipairs(game.stack.states) do
|
||||
if s == state then return i end
|
||||
end
|
||||
end
|
||||
|
||||
local function leftovers(game)
|
||||
local n = 0
|
||||
for _, s in ipairs(game.stack.states) do
|
||||
if s.evoClear or s.evoIntro or getmetatable(s) == EvolutionState then
|
||||
n = n + 1
|
||||
end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
local function runBatch(cancelFirst)
|
||||
local game, party, battle = newGame(2)
|
||||
restores = 0
|
||||
local finished = false
|
||||
local leveled = { [party[1]] = true, [party[2]] = true }
|
||||
Evolution.checkParty(game, function() finished = true end, leveled)
|
||||
local intros, seen = 0, {}
|
||||
local secondIntro
|
||||
local restoresBeforeSecond
|
||||
for frame = 1, 6000 do
|
||||
if finished then break end
|
||||
local top = game.stack:top()
|
||||
if top and top.evoIntro and not seen[top] then
|
||||
seen[top] = true
|
||||
intros = intros + 1
|
||||
if intros == 2 then
|
||||
secondIntro = {
|
||||
below = game.stack.states[indexOf(game, top) - 1],
|
||||
base = game.stack:visibleBase(),
|
||||
battleAt = indexOf(game, battle),
|
||||
}
|
||||
restoresBeforeSecond = restores
|
||||
end
|
||||
end
|
||||
local key = A_KEY
|
||||
if cancelFirst and intros == 1 and getmetatable(top) == EvolutionState
|
||||
and top.t > 100 and not top.done then
|
||||
key = B_KEY
|
||||
elseif frame % 2 == 0 then
|
||||
key = nil
|
||||
end
|
||||
step(game, key)
|
||||
end
|
||||
return {
|
||||
game = game, party = party, battle = battle, finished = finished,
|
||||
intros = intros, second = secondIntro,
|
||||
restoresBeforeSecond = restoresBeforeSecond,
|
||||
}
|
||||
end
|
||||
|
||||
for _, cancelFirst in ipairs({ false, true }) do
|
||||
local label = cancelFirst and "cancelled first mon" or "two evolutions"
|
||||
local r = runBatch(cancelFirst)
|
||||
eq(r.intros, 2, label .. ": both mons get an IsEvolvingText box")
|
||||
if check(r.second ~= nil, label .. ": the second intro box went up") then
|
||||
check(r.second.below and r.second.below.evoClear and r.second.below.isOpaque,
|
||||
label .. ": the second intro draws over an opaque blank screen")
|
||||
check(r.second.base > r.second.battleAt,
|
||||
label .. ": the battle screen is not drawn under the second intro")
|
||||
eq(r.restoresBeforeSecond, 0,
|
||||
label .. ": no map music between the two evolutions")
|
||||
end
|
||||
check(r.finished, label .. ": the batch finished")
|
||||
eq(r.game.stack:top(), r.battle, label .. ": the battle is top again")
|
||||
eq(leftovers(r.game), 0, label .. ": no evolution layers are left")
|
||||
eq(restores, 0, label .. ": the in-battle batch never restores map music")
|
||||
eq(r.party[2].species, "FIXMON_B", label .. ": the second mon evolved")
|
||||
eq(r.party[1].species, cancelFirst and "FIXMON_A" or "FIXMON_B",
|
||||
label .. ": the first mon's species")
|
||||
end
|
||||
|
||||
for _, via in ipairs({ "ITEM", "TRADE" }) do
|
||||
local game, party, menu = newGame(1)
|
||||
restores = 0
|
||||
local finished, topAtDone = false, nil
|
||||
Evolution.evolve(game, party[1], "FIXMON_B", function()
|
||||
finished = true
|
||||
topAtDone = game.stack:top()
|
||||
end, via)
|
||||
local sawClear = false
|
||||
local realClear = Evolution.clearScreen
|
||||
Evolution.clearScreen = function(g, species)
|
||||
realClear(g, species)
|
||||
local top = g.stack:top()
|
||||
if top and top.evoClear and top.isOpaque then sawClear = true end
|
||||
end
|
||||
for frame = 1, 6000 do
|
||||
if finished then break end
|
||||
step(game, frame % 2 == 1 and A_KEY or nil)
|
||||
end
|
||||
check(finished, via .. ": the single evolution finished")
|
||||
eq(topAtDone, menu, via .. ": the caller's screen is top when onDone runs")
|
||||
eq(leftovers(game), 0, via .. ": no evolution layers are left")
|
||||
eq(restores, 1, via .. ": map music comes back once after the result box")
|
||||
Evolution.clearScreen = realClear
|
||||
check(sawClear, via .. ": the screen clears once the result box closes")
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -127,23 +127,34 @@ do
|
||||
Menu.show({ game = game, session = { version = "ruby", engineOptions = game.options } })
|
||||
for _ = 1, 40 do Menu.update() end
|
||||
local top = Menu._pages[1]
|
||||
for i = 1, 7 do eq(top.rows[i].native, i, "RS OPTION keeps native row " .. i .. " in cart order") end
|
||||
local seen = {}
|
||||
for i = 8, #top.rows do seen[top.rows[i].port.id] = i end
|
||||
check(seen["group.graphics"] ~= nil, "RS OPTION lists a visible GRAPHICS row below CANCEL")
|
||||
check(seen["group.speed"] and seen["group.audio"] and seen["controls"] and seen["mods"],
|
||||
"RS OPTION carries the Emerald port pages")
|
||||
check(seen["eventTickets"] == nil and seen["textSpeed"] == nil, "RS port rows skip cart rows and Emerald-only rows")
|
||||
for i, r in ipairs(top.rows) do seen[r.id] = i end
|
||||
local order = {}
|
||||
for _, id in ipairs(Rows.ORDER) do if seen[id] then order[#order + 1] = id end end
|
||||
for i, id in ipairs(order) do eq(top.rows[i].id, id, "RS OPTION top row " .. i .. " follows Emerald's grouped order") end
|
||||
check(seen["group.speed"] and seen["group.graphics"] and seen["group.audio"] and seen["group.battle"]
|
||||
and seen["controls"] and seen["mods"], "RS OPTION carries the Emerald pages")
|
||||
check(seen["textSpeed"] == nil and seen["sound"] == nil and seen["frameType"] == nil,
|
||||
"RS cart rows move into their Emerald groups")
|
||||
eq(top.rows[seen["buttonMode"]].native, 5, "BUTTON MODE stays a native cart row on the top page")
|
||||
check(seen["eventTickets"] == nil, "RS skips Emerald-only rows")
|
||||
Menu.handleInput(input({ "select" }))
|
||||
check(not ShaderFXMenu.isOpen(), "SELECT is not a hidden SHADER FX shortcut")
|
||||
for _ = 1, seen["group.graphics"] - 1 do Menu.handleInput(input({ "down" })) end
|
||||
eq(top.scroll, seen["group.graphics"] - 7, "the list scrolls to reach the port rows")
|
||||
Menu.handleInput(input({ "a" }))
|
||||
local graphics = Menu._pages[#Menu._pages]
|
||||
check(#Menu._pages == 2, "A on GRAPHICS opens its page")
|
||||
local si
|
||||
for i, r in ipairs(graphics.rows) do if r.id == "shaderfx" then si = i end end
|
||||
local si, fi
|
||||
for i, r in ipairs(graphics.rows) do
|
||||
if r.id == "shaderfx" then si = i end
|
||||
if r.id == "frameType" and r.native == 6 then fi = i end
|
||||
end
|
||||
check(fi ~= nil, "RS GRAPHICS carries the native FRAME row")
|
||||
check(si ~= nil, "RS GRAPHICS lists SHADER FX")
|
||||
for _ = 1, (fi or 1) - 1 do Menu.handleInput(input({ "down" })) end
|
||||
Menu.handleInput(input({ "right" }))
|
||||
eq(Menu._pending.frameType, 1, "RIGHT on the grouped FRAME row steps the pending frame")
|
||||
graphics.index = 1
|
||||
for _ = 1, (si or 1) - 1 do Menu.handleInput(input({ "down" })) end
|
||||
Menu.handleInput(input({ "a" }))
|
||||
check(ShaderFXMenu.isOpen(), "A on the visible SHADER FX row opens the picker")
|
||||
|
||||
@@ -256,9 +256,10 @@ for _, transparent in ipairs({false, true}) do
|
||||
render(s, case)
|
||||
if case.key == "cutTree" then s.cutAnim.frames = 5
|
||||
elseif s.dustAnim.boulder then s.dustAnim.faded = true
|
||||
else s.dustAnim.frames = 28 end
|
||||
else s.dustAnim.frames = 31 end
|
||||
draws = {}; case.fn(s, s.camera)
|
||||
T.eq(draws[1].alpha, .55, version .. " " .. case.name .. " flicker tint preserved")
|
||||
T.eq(draws[1].alpha, 1, version .. " " .. case.name .. " OBP1 flash frame drawn opaque")
|
||||
T.check(draws[1].image ~= render(s, case)[1].image, version .. " " .. case.name .. " OBP1 flash frame uses its own bake")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
-- data/sprites/facings.asm:51
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
T.fixtures.fresh()
|
||||
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local OW = require("src.world.OverworldController")
|
||||
|
||||
local function upvalue(fn, name)
|
||||
local i = 1
|
||||
while true do
|
||||
local n, v = debug.getupvalue(fn, i)
|
||||
if not n then return nil end
|
||||
if n == name then return v end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
check(type(TileRenderer.feetStrip) == "function", "TileRenderer exposes the feet strip")
|
||||
|
||||
local x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, nil)
|
||||
eq(x0, 32, "strip starts at the sprite's left edge")
|
||||
eq(y0, 52, "strip starts 4 px into the cell (bottom OAM row, sprite row 8)")
|
||||
eq(x1, 48, "strip is the sprite's 16 px width")
|
||||
eq(y1, 60, "strip ends 4 px above the cell bottom (sprite row 15)")
|
||||
|
||||
local vanilla = { frameWidth = 16, frameHeight = 16, anchorX = 8, anchorY = 16 }
|
||||
x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, vanilla)
|
||||
eq(y0, 52, "vanilla 16x16 frame gets the same strip")
|
||||
eq(y1, 60, "vanilla 16x16 frame strip is 8 rows")
|
||||
|
||||
local tall = { frameWidth = 16, frameHeight = 32, anchorX = 8, anchorY = 32 }
|
||||
x0, y0, x1, y1 = TileRenderer.feetStrip(32, 48, tall)
|
||||
eq(y0, 52, "a 32-tall frame keeps its strip on the frame's bottom 8 rows")
|
||||
eq(y1, 60, "a 32-tall frame strip is still 8 rows")
|
||||
|
||||
local function spans(a, b, c, d)
|
||||
local out = {}
|
||||
TileRenderer.eachStripSpan(a, b, c, d, function(tx, ty, x, y, ox, oy, w, h)
|
||||
out[#out + 1] = { tx = tx, ty = ty, x = x, y = y, ox = ox, oy = oy, w = w, h = h }
|
||||
end)
|
||||
return out
|
||||
end
|
||||
|
||||
local s = spans(32, 52, 48, 60)
|
||||
eq(#s, 4, "a standing strip touches 2x2 tiles")
|
||||
eq(s[1].ty, 6, "first span is the cell's top tile row")
|
||||
eq(s[1].oy, 4, "top tile row is cut from its row 4")
|
||||
eq(s[1].h, 4, "top tile row contributes its lower 4 rows")
|
||||
eq(s[3].ty, 7, "second span is the cell's bottom tile row")
|
||||
eq(s[3].oy, 0, "bottom tile row starts at its row 0")
|
||||
eq(s[3].h, 4, "bottom tile row contributes its upper 4 rows")
|
||||
eq(s[1].w, 8, "standing strip spans whole tile columns")
|
||||
|
||||
s = spans(36, 54, 52, 62)
|
||||
eq(#s, 6, "a mid-step strip straddles three tile columns")
|
||||
eq(s[1].ox, 4, "left column clips to the sprite's left edge")
|
||||
eq(s[1].w, 4, "left column keeps only the covered pixels")
|
||||
eq(s[3].w, 4, "right column clips to the sprite's right edge")
|
||||
eq(s[1].oy + s[1].h, 8, "top span runs to its tile's bottom")
|
||||
eq(s[4].h, 6, "bottom span covers the rest of the strip")
|
||||
|
||||
local image = { getDimensions = function() return 128, 128 end }
|
||||
local fakeMap = {
|
||||
tileset = { tilesPerRow = 16 },
|
||||
tileAt = function() return 0x52 end,
|
||||
}
|
||||
local r = setmetatable({ map = fakeMap, image = image, quads = { [0x52] = {} } },
|
||||
TileRenderer)
|
||||
|
||||
local drawn = {}
|
||||
local realDraw = love.graphics.draw
|
||||
love.graphics.draw = function(_, q, x, y)
|
||||
drawn[#drawn + 1] = { q = q, x = x, y = y }
|
||||
end
|
||||
r:drawStripRaw(32, 52, 48, 60, 0, 0)
|
||||
love.graphics.draw = realDraw
|
||||
|
||||
local covered = {}
|
||||
for _, d in ipairs(drawn) do
|
||||
for row = d.y, d.y + d.q.h - 1 do covered[row] = true end
|
||||
end
|
||||
for row = 48, 51 do
|
||||
check(not covered[row], "sprite row " .. (row - 44) .. " stays above the grass")
|
||||
end
|
||||
for row = 52, 59 do
|
||||
check(covered[row], "sprite row " .. (row - 44) .. " is behind the grass")
|
||||
end
|
||||
for row = 60, 63 do
|
||||
check(not covered[row], "cell row " .. (row - 48) .. " below the sprite is not redrawn")
|
||||
end
|
||||
|
||||
local marks = {}
|
||||
local realMark = PaletteFX.markSpriteRedraw
|
||||
PaletteFX.markSpriteRedraw = function(img, q, x, y)
|
||||
marks[#marks + 1] = { q = q, x = x, y = y }
|
||||
end
|
||||
r:markStripRedraw(32, 52, 48, 60, 0, 0, {})
|
||||
PaletteFX.markSpriteRedraw = realMark
|
||||
local markRows = {}
|
||||
for _, m in ipairs(marks) do
|
||||
for row = m.y, m.y + m.q.h - 1 do markRows[row] = true end
|
||||
end
|
||||
check(markRows[52] and markRows[59] and not markRows[60] and not markRows[51],
|
||||
"OG RED replay redraws the same strip")
|
||||
|
||||
local grassStrip = upvalue(OW.drawWorld, "grassStrip")
|
||||
check(type(grassStrip) == "function", "drawWorld places grass by the feet strip")
|
||||
if grassStrip then
|
||||
local map = {
|
||||
renderer = r,
|
||||
isGrassCell = function(_, cx, cy) return cx == 2 and cy == 3 end,
|
||||
}
|
||||
local ow = { map = map }
|
||||
local standing = { cellX = 2, cellY = 3, px = 32, py = 48 }
|
||||
local a, b, c, d = grassStrip(ow, standing)
|
||||
eq(b, 52, "entity standing in grass gets the strip from row py+4")
|
||||
eq(d, 60, "entity standing in grass gets the strip to row py+12")
|
||||
local entering = { cellX = 2, cellY = 2, targetX = 2, targetY = 3, px = 32, py = 40,
|
||||
pose = function(self) return vanilla, self.px, self.py end }
|
||||
a, b, c, d = grassStrip(ow, entering)
|
||||
eq(b, 44, "stepping into grass follows the sprite mid-step")
|
||||
check(grassStrip(ow, { cellX = 1, cellY = 1, px = 16, py = 16 }) == nil,
|
||||
"entity outside grass gets no strip")
|
||||
end
|
||||
|
||||
T.finish("grass feet strip")
|
||||
@@ -0,0 +1,62 @@
|
||||
-- engine/events/hidden_events/indigo_plateau_statues.asm:5
|
||||
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 textBoxStub = {
|
||||
new = function(_, text, onDone, opts)
|
||||
return { text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
}
|
||||
local fakeGame = {
|
||||
data = { text = {
|
||||
_IndigoPlateauStatuesText1 = "PLAQUE",
|
||||
_IndigoPlateauStatuesText2 = "ULTIMATE GOAL",
|
||||
_IndigoPlateauStatuesText3 = "HIGHEST AUTHORITY",
|
||||
} },
|
||||
stack = { push = function(_, box) pushed = box end },
|
||||
}
|
||||
T.check(setUpvalue(OW.tryBookshelf, "TextBox", textBoxStub),
|
||||
"TextBox upvalue on tryBookshelf")
|
||||
T.check(setUpvalue(OW.tryBookshelf, "Game", fakeGame),
|
||||
"Game upvalue on tryBookshelf")
|
||||
|
||||
local map = {
|
||||
def = { tileset = "PLATEAU" },
|
||||
inBounds = function() return true end,
|
||||
cellTile = function() return 0x30 end,
|
||||
}
|
||||
|
||||
local function read(cellX)
|
||||
pushed = nil
|
||||
local self_ = setmetatable({ player = { facing = "up", cellX = cellX, cellY = 6 },
|
||||
map = map }, { __index = OW })
|
||||
local ok = self_:tryBookshelf(cellX, 5)
|
||||
return ok, pushed and pushed.text
|
||||
end
|
||||
|
||||
for _, x in ipairs({ 9, 11 }) do
|
||||
local ok, text = read(x)
|
||||
T.eq(ok, true, "odd X " .. x .. " statue is consumed")
|
||||
T.eq(text, "PLAQUE\fULTIMATE GOAL", "odd X " .. x .. " prints Text2")
|
||||
end
|
||||
for _, x in ipairs({ 8, 10 }) do
|
||||
local ok, text = read(x)
|
||||
T.eq(ok, true, "even X " .. x .. " statue is consumed")
|
||||
T.eq(text, "PLAQUE\fHIGHEST AUTHORITY", "even X " .. x .. " prints Text3")
|
||||
end
|
||||
|
||||
T.finish("indigo_statues_bug2683")
|
||||
@@ -0,0 +1,62 @@
|
||||
-- scripts/LancesRoom.asm:54
|
||||
-- home/overworld.asm:1212
|
||||
|
||||
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 story4 = require("data.scripts.story4")
|
||||
local hooks = story4.LANCES_ROOM
|
||||
|
||||
local function facingToward(npc, player)
|
||||
local dx, dy = player.cellX - npc.cellX, player.cellY - npc.cellY
|
||||
if math.abs(dx) > math.abs(dy) then
|
||||
return dx < 0 and "left" or "right"
|
||||
end
|
||||
return dy < 0 and "up" or "down"
|
||||
end
|
||||
|
||||
local function trigger(x, y, playerFacing)
|
||||
local lance = {
|
||||
def = { name = "LANCESROOM_LANCE", index = 1,
|
||||
trainerClass = "OPP_LANCE", trainerParty = 1, text = 1 },
|
||||
id = "LANCES_ROOM:1",
|
||||
cellX = 6, cellY = 1,
|
||||
facing = "down",
|
||||
}
|
||||
function lance:facePlayer(player) self.facing = facingToward(self, player) end
|
||||
local engaged = false
|
||||
local facingAtEngage
|
||||
local game = { data = {}, save = { flags = {}, defeatedTrainers = {} } }
|
||||
local ow = {
|
||||
npcs = { lance },
|
||||
player = { cellX = x, cellY = y, facing = playerFacing },
|
||||
}
|
||||
function ow:trainerDefeated(npc) return game.save.defeatedTrainers[npc.id] == true end
|
||||
function ow:engageTrainer(npc)
|
||||
engaged = npc == lance
|
||||
facingAtEngage = npc.facing
|
||||
end
|
||||
local handled = hooks.onStep(game, ow, x, y)
|
||||
return handled, engaged, facingAtEngage, lance
|
||||
end
|
||||
|
||||
do
|
||||
local handled, engaged, atEngage, lance = trigger(5, 1, "right")
|
||||
check(handled == true, "(5,1) trigger handled")
|
||||
check(engaged, "(5,1) trigger engages Lance")
|
||||
eq(atEngage, "down", "(5,1) Lance still faces down when his text starts")
|
||||
eq(lance.facing, "down", "(5,1) Lance keeps his object_event DOWN facing")
|
||||
end
|
||||
|
||||
do
|
||||
local handled, engaged, atEngage, lance = trigger(6, 2, "up")
|
||||
check(handled == true, "(6,2) trigger handled")
|
||||
check(engaged, "(6,2) trigger engages Lance")
|
||||
eq(atEngage, "down", "(6,2) Lance faces down when his text starts")
|
||||
eq(lance.facing, "down", "(6,2) Lance keeps facing down")
|
||||
end
|
||||
|
||||
T.finish("lance_side_trigger_facing_bug2669")
|
||||
@@ -0,0 +1,45 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Music = require("src.core.Music")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
local played
|
||||
local realPlayVictory = Music.playVictory
|
||||
Music.playVictory = function(data, kind) played = kind end
|
||||
|
||||
local function run(kind, id, partyIndex)
|
||||
local b = setmetatable({
|
||||
data = {},
|
||||
kind = kind,
|
||||
oppClass = id,
|
||||
partyIndex = partyIndex,
|
||||
trainer = id and { id = id } or nil,
|
||||
}, { __index = BattleState })
|
||||
b.musicKind = b:computeMusicKind()
|
||||
played = nil
|
||||
b:playVictoryMusic()
|
||||
return b.musicKind, played
|
||||
end
|
||||
|
||||
local cases = {
|
||||
{ "trainer", "OPP_LANCE", 1, "gym", "trainer" },
|
||||
{ "trainer", "OPP_LORELEI", 1, "trainer", "trainer" },
|
||||
{ "trainer", "OPP_AGATHA", 1, "trainer", "trainer" },
|
||||
{ "trainer", "OPP_BROCK", 1, "gym", "gym" },
|
||||
{ "trainer", "OPP_GIOVANNI", 3, "gym", "gym" },
|
||||
{ "trainer", "OPP_GIOVANNI", 2, "trainer", "trainer" },
|
||||
{ "trainer", "OPP_RIVAL3", 1, "final", "gym" },
|
||||
{ "trainer", "OPP_YOUNGSTER", 1, "trainer", "trainer" },
|
||||
{ "wild", nil, nil, "wild", "wild" },
|
||||
}
|
||||
|
||||
for _, c in ipairs(cases) do
|
||||
local label = tostring(c[2] or "wild") .. "#" .. tostring(c[3] or "-")
|
||||
local battleKind, victoryKind = run(c[1], c[2], c[3])
|
||||
T.eq(battleKind, c[4], label .. " battle theme kind")
|
||||
T.eq(victoryKind, c[5], label .. " victory theme kind")
|
||||
end
|
||||
|
||||
Music.playVictory = realPlayVictory
|
||||
T.finish("lance victory music bug2659")
|
||||
@@ -0,0 +1,88 @@
|
||||
-- scripts/LancesRoom.asm:96
|
||||
-- home/map_objects.asm:179
|
||||
-- home/overworld.asm:1845
|
||||
|
||||
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 GameVersion = require("src.core.GameVersion")
|
||||
local savedSound = package.loaded["src.core.Sound"]
|
||||
local played = {}
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, name) played[#played + 1] = name end,
|
||||
}
|
||||
|
||||
local story4 = require("data.scripts.story4")
|
||||
local hooks = story4.LANCES_ROOM
|
||||
|
||||
local function reversed(rle)
|
||||
local out = {}
|
||||
for i = #rle, 1, -1 do out[#out + 1] = rle[i] end
|
||||
return out
|
||||
end
|
||||
|
||||
local function sameRoute(got, want, label)
|
||||
eq(#got, #want, label .. ": segment count")
|
||||
for i, w in ipairs(want) do
|
||||
local g = got[i] or {}
|
||||
eq(g[1], w[1], label .. ": segment " .. i .. " direction")
|
||||
eq(g[2], w[2], label .. ": segment " .. i .. " count")
|
||||
end
|
||||
end
|
||||
|
||||
local redRle = { { "up", 12 }, { "left", 12 }, { "down", 7 }, { "left", 6 } }
|
||||
local yellowRle = { { "up", 13 }, { "left", 12 }, { "down", 7 }, { "left", 6 } }
|
||||
|
||||
sameRoute(hooks.walkInRoute, reversed(redRle), "red walkInRoute")
|
||||
sameRoute(hooks.walkInRouteFor(false), reversed(redRle), "red walkInRouteFor")
|
||||
sameRoute(hooks.walkInRouteFor(true), reversed(yellowRle), "yellow walkInRouteFor")
|
||||
eq(hooks.walkInRoute[1][1], "left", "walk-in leaves the staircase going left first")
|
||||
|
||||
local D = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
local function runWalkIn(version)
|
||||
local prev = GameVersion.current
|
||||
GameVersion.current = version
|
||||
played = {}
|
||||
local blocks = {}
|
||||
local moves = {}
|
||||
local game = { data = {}, save = { flags = {} } }
|
||||
local ow = { player = { cellX = 24, cellY = 16 } }
|
||||
function ow:replaceBlock(bx, by, id) blocks[bx .. "," .. by] = id end
|
||||
function ow:scriptMove(entity, dir, n, onDone)
|
||||
moves[#moves + 1] = { dir, n }
|
||||
local d = D[dir]
|
||||
entity.cellX, entity.cellY = entity.cellX + d[1] * n, entity.cellY + d[2] * n
|
||||
if onDone then onDone() end
|
||||
end
|
||||
hooks.onEnter(game, ow)
|
||||
GameVersion.current = prev
|
||||
return game, ow, moves, blocks
|
||||
end
|
||||
|
||||
do
|
||||
local game, ow, moves, blocks = runWalkIn("red")
|
||||
sameRoute(moves, reversed(redRle), "red onEnter moves")
|
||||
eq(ow.player.cellX, 6, "red walk-in lands at x=6")
|
||||
eq(ow.player.cellY, 11, "red walk-in lands at y=11")
|
||||
eq(game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR, true, "red landing locks the door")
|
||||
eq(played[#played], "Go_Inside", "red landing plays SFX_GO_INSIDE")
|
||||
eq(blocks["2,6"], 0x72, "red door block (2,6) closed")
|
||||
eq(blocks["3,6"], 0x73, "red door block (3,6) closed")
|
||||
end
|
||||
|
||||
do
|
||||
local game, ow, moves, blocks = runWalkIn("yellow")
|
||||
sameRoute(moves, reversed(yellowRle), "yellow onEnter moves")
|
||||
eq(ow.player.cellX, 6, "yellow walk-in lands at x=6")
|
||||
eq(ow.player.cellY, 10, "yellow walk-in lands at y=10")
|
||||
check(not game.save.flags.EVENT_LANCES_ROOM_LOCK_DOOR, "yellow landing leaves the door open")
|
||||
eq(#played, 0, "yellow landing plays no SFX")
|
||||
eq(blocks["2,6"], 0x31, "yellow door block (2,6) open")
|
||||
end
|
||||
|
||||
package.loaded["src.core.Sound"] = savedSound
|
||||
T.finish("lance_walk_in_bug2665")
|
||||
@@ -119,18 +119,38 @@ local function buyOne(game, stack)
|
||||
return menu, list, qty
|
||||
end
|
||||
|
||||
require("src.core.GameVersion").set("red")
|
||||
|
||||
local function fill(game, n)
|
||||
for i = 1, n do
|
||||
local id = "FILLER_" .. i
|
||||
game.save.inventory[id] = 1
|
||||
game.save.bagOrder[#game.save.bagOrder + 1] = id
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame(3000, 99)
|
||||
eq(Bag.add(game.save, "POKE_BALL", 1, game.data), true,
|
||||
"AddItemToInventory puts a 100th POKe BALL in a new slot")
|
||||
eq(game.save.inventory.POKE_BALL, 100, "the total is 100")
|
||||
eq(Bag.slots(game.save, game.data), 2, "split over two slots")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame(3000, 99)
|
||||
fill(game, 19)
|
||||
eq(Bag.add(game.save, "POKE_BALL", 1, game.data), false,
|
||||
"AddItemToInventory refuses a 100th POKe BALL")
|
||||
"a full bag refuses the overflow slot")
|
||||
eq(game.save.inventory.POKE_BALL, 99, "and adds nothing")
|
||||
end
|
||||
|
||||
for _, case in ipairs({
|
||||
{ name = "bag full", money = 3000, count = 99, text = BAG_FULL },
|
||||
{ name = "bag full", money = 3000, count = 99, fill = 19, text = BAG_FULL },
|
||||
{ name = "not enough money", money = 100, count = nil, text = NO_MONEY },
|
||||
}) do
|
||||
local game, events, stack = newGame(case.money, case.count)
|
||||
fill(game, case.fill or 0)
|
||||
local menu, list, qty = buyOne(game, stack)
|
||||
local box = stack[#stack]
|
||||
if check(getmetatable(box) == TextBox, case.name .. ": a text box is pushed") then
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
-- DisplayNameRaterScreen takes .playerCancelled and keeps the old nickname.
|
||||
-- Nothing in the original invents a letter, so NamingScreen:confirm must hand
|
||||
-- the caller "" rather than the literal "A" when nothing was typed -- both via
|
||||
-- START and via the ED cell. The two fallbacks that are load bearing stay:
|
||||
-- presets[1] for player/rival naming (oak_speech2.asm ChoosePlayerName never
|
||||
-- accepts an empty name) and opts.default for the Name Rater cancel.
|
||||
-- START and via the ED cell. Player/rival naming (oak_speech2.asm
|
||||
-- ChoosePlayerName never accepts an empty name) re-opens the grid, and
|
||||
-- opts.default still covers the Name Rater cancel.
|
||||
-- luajit tests/engine/naming_empty_confirm_bug833.lua
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
@@ -107,14 +107,34 @@ press(ns, game, "a")
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "A", "a genuinely typed A still comes back as A")
|
||||
|
||||
-- ---------------------------------------------------------------- presets fallback (player / rival)
|
||||
-- ChoosePlayerName / ChooseRivalName (engine/movie/oak_speech/oak_speech2.asm)
|
||||
-- compare wStringBuffer to '@' and re-open rather than accept an empty name;
|
||||
-- the port answers the same need with its presets fallback, which #833 must
|
||||
-- not disturb.
|
||||
-- ---------------------------------------------------------------- player / rival re-prompt
|
||||
-- engine/movie/oak_speech/oak_speech2.asm:20
|
||||
ns, game, res = newScreen({ title = "YOUR NAME?", maxLen = 7, presets = { "RED", "ASH" } })
|
||||
ns.row, ns.col, ns.lower = 3, 4, true
|
||||
press(ns, game, "start")
|
||||
eq(res.name, "RED", "an empty confirm with presets still yields presets[1]")
|
||||
check(not res.fired, "an empty player/rival name does not confirm")
|
||||
eq(#game.stack.states, 1, "the naming screen stays up on an empty START")
|
||||
eq(ns.row, 1, "re-entry puts the cursor back on row 1")
|
||||
eq(ns.col, 1, "re-entry puts the cursor back on column 1")
|
||||
eq(ns.lower, true, "wAlphabetCase carries over the re-entry")
|
||||
check((ns.whiteout or 0) > 0, "the re-entry whites the screen out")
|
||||
ns.lower = false
|
||||
press(ns, game, "a")
|
||||
eq(#ns.glyphs, 0, "input is ignored while the screen is white")
|
||||
for _ = 1, 60 do ns:update(1 / 60) end
|
||||
eq(ns.whiteout, 0, "the whiteout ends")
|
||||
local edR, edC = edCell(ns)
|
||||
ns.row, ns.col = edR, edC
|
||||
press(ns, game, "a")
|
||||
check(not res.fired, "ED with nothing typed re-prompts too")
|
||||
eq(#game.stack.states, 1, "the naming screen is still up after an empty ED")
|
||||
for _ = 1, 60 do ns:update(1 / 60) end
|
||||
ns.row, ns.col = 1, 1
|
||||
press(ns, game, "a")
|
||||
press(ns, game, "start")
|
||||
check(res.fired, "a typed player name confirms")
|
||||
eq(res.name, "A", "the typed name comes back, never presets[1]")
|
||||
eq(#game.stack.states, 0, "a typed confirm pops the naming screen")
|
||||
|
||||
-- ---------------------------------------------------------------- default fallback (Name Rater)
|
||||
-- DisplayNameRaterScreen jumps to .playerCancelled on '@' and keeps the
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
-- engine/movie/oak_speech/oak_speech.asm:112
|
||||
-- home/fade.asm:24
|
||||
-- data/text/text_2.asm:1742
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local eq, check = T.eq, T.check
|
||||
local OakSpeech = require("src.ui.OakSpeech")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local Transition = require("src.render.Transition")
|
||||
local Music = require("src.core.Music")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Version = require("src.core.GameVersion")
|
||||
|
||||
local origFadeOut, origPlay, origPress = Music.fadeOut, Sound.play, Sound.playPress
|
||||
Music.fadeOut = function() end
|
||||
Sound.play = function() end
|
||||
Sound.playPress = function() end
|
||||
|
||||
local function stub(fields)
|
||||
local s = setmetatable(fields or {}, OakSpeech)
|
||||
return s
|
||||
end
|
||||
|
||||
do
|
||||
local player, p1, p2 = { tag = "player" }, { tag = "p1" }, { tag = "p2" }
|
||||
local speech = stub({ playerPic = player, shrinkPic1 = p1, shrinkPic2 = p2,
|
||||
pic = player })
|
||||
local finishes = 0
|
||||
speech.finish = function() finishes = finishes + 1 end
|
||||
speech.shrink = { frame = 0 }
|
||||
local pics, walk, fade = {}, {}, {}
|
||||
for f = 1, 200 do
|
||||
speech:update(0)
|
||||
pics[f], walk[f], fade[f] = speech.pic, speech.walkVisible, speech.fadeLevel
|
||||
end
|
||||
local function span(want)
|
||||
local first, last
|
||||
for f = 1, 200 do
|
||||
if pics[f] == want then
|
||||
first = first or f
|
||||
last = f
|
||||
end
|
||||
end
|
||||
return first, last
|
||||
end
|
||||
local _, redLast = span(player)
|
||||
local p1First, p1Last = span(p1)
|
||||
local p2First, p2Last = span(p2)
|
||||
check(redLast and redLast >= 30,
|
||||
"RedPicFront holds through ShrinkPic1's decompress (got " .. tostring(redLast) .. ")")
|
||||
eq(p1First, OakSpeech.SHRINK_PIC1_AT, "ShrinkPic1 lands on its frame")
|
||||
check(p1Last and p1First and p1Last - p1First + 1 >= 30,
|
||||
"ShrinkPic1 holds ~40 frames, not 4")
|
||||
eq(p2First, OakSpeech.SHRINK_PIC2_AT, "ShrinkPic2 lands on its frame")
|
||||
check(p2Last and p2First and p2Last - p2First + 1 >= 25,
|
||||
"ShrinkPic2 holds ~32 frames")
|
||||
eq(pics[OakSpeech.SHRINK_WALK_AT], nil, "the pic area clears")
|
||||
check(walk[OakSpeech.SHRINK_WALK_AT] and not walk[OakSpeech.SHRINK_WALK_AT - 1],
|
||||
"and the walking sprite appears on the same frame")
|
||||
eq(OakSpeech.SHRINK_FADE_AT - OakSpeech.SHRINK_WALK_AT, 50,
|
||||
"the walking sprite stands for ld c, 50")
|
||||
eq(fade[OakSpeech.SHRINK_FADE_AT - 1], nil, "no fade before GBFadeOutToWhite")
|
||||
eq(fade[OakSpeech.SHRINK_FADE_AT], 1, "FadePal6 first")
|
||||
eq(fade[OakSpeech.SHRINK_FADE_AT + 8], 2, "FadePal7 eight frames later")
|
||||
eq(fade[OakSpeech.SHRINK_FADE_AT + 16], 3, "FadePal8 eight frames after that")
|
||||
eq(OakSpeech.SHRINK_END - OakSpeech.SHRINK_FADE_AT, 24, "3 palettes x 8 frames")
|
||||
eq(finishes, 1, "finish runs once")
|
||||
end
|
||||
|
||||
do
|
||||
local steps = {}
|
||||
for _, step in ipairs(OakSpeech.defaultSteps({})) do steps[step.id] = step end
|
||||
check(steps.legend and steps.legend.auto,
|
||||
"OakSpeechText3 ends on done, so PrintText returns without a button wait")
|
||||
|
||||
Version.set("red")
|
||||
local pressed = false
|
||||
local game = { data = { text = { _OakSpeechText3 = "A\nB{DONE}" } },
|
||||
save = { player = { name = "RED" }, options = { textSpeed = 1 } },
|
||||
input = { wasPressed = function() return pressed end,
|
||||
isDown = function() return false end } }
|
||||
game.stack = { push = function(s, b) s.box = b end,
|
||||
pop = function(s) s.box = nil end }
|
||||
local advanced = 0
|
||||
local speech = stub({ game = game })
|
||||
speech.advance = function() advanced = advanced + 1 end
|
||||
speech:runStep(steps.legend)
|
||||
for _ = 1, 24 do speech:update(0) end
|
||||
local box = game.stack.box
|
||||
eq(getmetatable(box), TextBox, "the legend page is a real text box")
|
||||
local sawArrow = false
|
||||
for _ = 1, 300 do
|
||||
if not game.stack.box then break end
|
||||
box:update()
|
||||
if game.stack.box and box.done and box:arrowVisible() then sawArrow = true end
|
||||
end
|
||||
check(not sawArrow, "no blinking arrow on the last page")
|
||||
eq(game.stack.box, nil, "the box closes on its own")
|
||||
eq(advanced, 1, "and the shrink starts with no A press")
|
||||
end
|
||||
|
||||
local origObp = SpriteRenderer.obpImage
|
||||
local origDraw = love.graphics.draw
|
||||
|
||||
local function drawWalk(mode, fadeLevel)
|
||||
PaletteFX.mode = mode
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
PaletteFX.setShadeMap(nil)
|
||||
PaletteFX.setFadeObp(nil)
|
||||
PaletteFX.setPass("ui")
|
||||
local baked = { tag = "baked" }
|
||||
local got = {}
|
||||
SpriteRenderer.obpImage = function(path, colors, group)
|
||||
got.path, got.colors, got.group = path, colors, group
|
||||
return baked
|
||||
end
|
||||
local drawn = {}
|
||||
love.graphics.draw = function(img) drawn[#drawn + 1] = img end
|
||||
local sheet = love.graphics.newImage("missing.png")
|
||||
local speech = stub({ game = {}, walkVisible = true, walkSheet = sheet,
|
||||
walkPath = "red.png", walkDef = { image = "red.png" },
|
||||
fadeLevel = fadeLevel })
|
||||
speech:draw()
|
||||
love.graphics.draw = origDraw
|
||||
SpriteRenderer.obpImage = origObp
|
||||
local shadeMap = PaletteFX.shadeMap()
|
||||
local redraws = #PaletteFX.uiSpriteRedraws()
|
||||
PaletteFX.setFadeObp(nil)
|
||||
PaletteFX.setShadeMap(nil)
|
||||
return got, drawn, baked, sheet, redraws, shadeMap
|
||||
end
|
||||
|
||||
do
|
||||
Version.set("red")
|
||||
local got, drawn, baked, sheet, redraws = drawWalk("ogred")
|
||||
eq(got.path, "red.png", "OG RED bakes the RedSprite sheet")
|
||||
local want = PaletteFX.ogObjNormal()
|
||||
eq(got.colors, want, "through the boot-ROM OBJ palette at rOBP0 = $D0")
|
||||
check(drawn[1] == baked and drawn[1] ~= sheet, "and draws the baked sheet")
|
||||
eq(redraws, 1, "replayed past the red BG zone pass")
|
||||
|
||||
got, drawn, baked, sheet, redraws = drawWalk("gbc")
|
||||
eq(got.colors, PaletteFX.OBP0_SHADES,
|
||||
"SGB keeps the sprite in DMG shades through the OBP0 lift")
|
||||
eq(redraws, 0, "and lets the zone palette color it")
|
||||
|
||||
Version.set("blue")
|
||||
got = drawWalk("ogred")
|
||||
eq(got.colors, PaletteFX.ogObjNormal(), "OG BLUE bakes Blue's pink OBJ ramp")
|
||||
Version.set("red")
|
||||
end
|
||||
|
||||
do
|
||||
Version.set("red")
|
||||
local raw = PaletteFX.ogObjBase()
|
||||
local got, drawn, _, _, redraws, shadeMap = drawWalk("ogred", 1)
|
||||
eq(shadeMap, Transition.shadeMapFor(0x90), "FadePal6 rBGP")
|
||||
check(got.colors and got.colors[2] == raw[1] and got.colors[3] == raw[1]
|
||||
and got.colors[4] == raw[3],
|
||||
"FadePal6 rOBP0 $80 lightens the baked sprite")
|
||||
eq(redraws, 1, "and the faded sprite still replays")
|
||||
got, drawn, _, _, redraws, shadeMap = drawWalk("ogred", 3)
|
||||
eq(shadeMap, Transition.shadeMapFor(0x00), "FadePal8 rBGP is solid white")
|
||||
check(got.colors and got.colors[4] == raw[1], "and so is the sprite")
|
||||
end
|
||||
|
||||
PaletteFX.mode = "gbc"
|
||||
Music.fadeOut, Sound.play, Sound.playPress = origFadeOut, origPlay, origPress
|
||||
|
||||
T.finish("oak_shrink_timing_2685")
|
||||
@@ -0,0 +1,61 @@
|
||||
-- scripts/OaksLab.asm:918
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.harness")
|
||||
|
||||
local M = assert(loadfile("data/scripts/oaks_lab.lua"))()
|
||||
|
||||
local cases = {
|
||||
{ "TEXT_OAKSLAB_CHARMANDER_POKE_BALL", "OAKSLAB_CHARMANDER_POKE_BALL",
|
||||
"OAKSLAB_SQUIRTLE_POKE_BALL" },
|
||||
{ "TEXT_OAKSLAB_SQUIRTLE_POKE_BALL", "OAKSLAB_SQUIRTLE_POKE_BALL",
|
||||
"OAKSLAB_BULBASAUR_POKE_BALL" },
|
||||
{ "TEXT_OAKSLAB_BULBASAUR_POKE_BALL", "OAKSLAB_BULBASAUR_POKE_BALL",
|
||||
"OAKSLAB_CHARMANDER_POKE_BALL" },
|
||||
}
|
||||
|
||||
local function find(rows, pred)
|
||||
for i, row in ipairs(rows) do
|
||||
if pred(row) then return i end
|
||||
end
|
||||
end
|
||||
|
||||
for _, c in ipairs(cases) do
|
||||
local key, own, rival = c[1], c[2], c[3]
|
||||
local rows = M.talk[key]
|
||||
T.check(type(rows) == "table", key .. " rows loaded")
|
||||
local jumpNo = find(rows, function(r)
|
||||
return r[1] == "jump_if_false" and r[2] == "end" end)
|
||||
local hideOwn = find(rows, function(r)
|
||||
return r[1] == "hide_object" and r[3] == own end)
|
||||
local energetic = find(rows, function(r)
|
||||
return r[1] == "show_text" and r[2] == "_OaksLabMonEnergeticText" end)
|
||||
local received = find(rows, function(r)
|
||||
return r[1] == "show_text" and r[2] == "_OaksLabReceivedMonText" end)
|
||||
local give = find(rows, function(r) return r[1] == "give_pokemon" end)
|
||||
local illTake = find(rows, function(r)
|
||||
return r[1] == "show_text" and r[2] == "_OaksLabRivalIllTakeThisOneText" end)
|
||||
local hideRival = find(rows, function(r)
|
||||
return r[1] == "hide_object" and r[3] == rival end)
|
||||
local rivalReceived = find(rows, function(r)
|
||||
return r[1] == "show_text" and r[2] == "_OaksLabRivalReceivedMonText" end)
|
||||
|
||||
T.check(jumpNo and hideOwn and energetic and received and give,
|
||||
key .. ": all player-pick rows present")
|
||||
if jumpNo and hideOwn and energetic and received and give then
|
||||
T.eq(hideOwn, jumpNo + 1, key .. ": own ball hides right after YES")
|
||||
T.check(hideOwn < energetic, key .. ": ball gone before the energetic line")
|
||||
T.check(hideOwn < received, key .. ": ball gone before the received line")
|
||||
T.check(hideOwn < give, key .. ": ball gone before the nickname prompt")
|
||||
T.check(energetic < received and received < give,
|
||||
key .. ": energetic, received, then AddPartyMon")
|
||||
end
|
||||
T.check(illTake and hideRival and rivalReceived,
|
||||
key .. ": all rival-pick rows present")
|
||||
if illTake and hideRival and rivalReceived then
|
||||
T.check(illTake < hideRival and hideRival < rivalReceived,
|
||||
key .. ": rival ball hides between ILL_TAKE_THIS_ONE and RECEIVED")
|
||||
end
|
||||
end
|
||||
|
||||
T.finish("oaks_lab_starter_ball_hide_2686")
|
||||
@@ -18,13 +18,16 @@ for i, row in ipairs(rows) do
|
||||
end
|
||||
T.check(askRow ~= nil, "found the ask row")
|
||||
T.eq(rows[askRow + 1][1], "jump_if_false", "the ask is followed by its jump")
|
||||
T.same(rows[askRow + 2], { "show_text", "_OaksLabMonEnergeticText" },
|
||||
"the energetic line is the row right after the NO jump")
|
||||
T.eq(rows[askRow + 3][1], "text_sound",
|
||||
T.same(rows[askRow + 2],
|
||||
{ "hide_object", "OAKS_LAB", "OAKSLAB_CHARMANDER_POKE_BALL" },
|
||||
"the ball hides right after the NO jump")
|
||||
T.same(rows[askRow + 3], { "show_text", "_OaksLabMonEnergeticText" },
|
||||
"the energetic line follows the ball hide")
|
||||
T.eq(rows[askRow + 4][1], "text_sound",
|
||||
"the sound cue stays on the RECEIVED line, not the energetic one")
|
||||
T.eq(rows[askRow + 4][1], "show_text",
|
||||
T.eq(rows[askRow + 5][1], "show_text",
|
||||
"and the received-mon text follows the sound cue")
|
||||
T.eq(rows[askRow + 4][2], "_OaksLabReceivedMonText",
|
||||
T.eq(rows[askRow + 5][2], "_OaksLabReceivedMonText",
|
||||
"specifically the received-mon text")
|
||||
|
||||
-- jump_if_true (row 2): the EVENT_GOT_STARTER short-circuit must land on
|
||||
|
||||
@@ -149,7 +149,7 @@ T.check(tostring(ratingBox.text):find("least 50 species", 1, true) ~= nil,
|
||||
T.eq(#plays, 0, "no jingle while the evaluation is printing")
|
||||
ratingBox.opts.auto.sound()
|
||||
T.eq(#plays, 1, "jingle fires once the rating text is printed")
|
||||
T.eq(plays[1], "Pokedex_Rating", "jingle is the Pokedex_Rating fanfare")
|
||||
T.eq(plays[1], "Get_Item1", "55 owned plays the Get_Item1 tier jingle")
|
||||
T.check(tostring(pushed[6].text):find("Closed link", 1, true) ~= nil,
|
||||
"the closing link prints at the end of the session")
|
||||
|
||||
@@ -166,6 +166,29 @@ T.check(tostring(lastPush().text):find("Closed link", 1, true) ~= nil,
|
||||
"NO skips the rating and closes the PC")
|
||||
T.eq(#plays, 0, "declining plays no jingle")
|
||||
|
||||
-- audio/pokedex_rating_sfx.asm:26
|
||||
local tiers = {
|
||||
{ 0, "Denied" }, { 9, "Denied" },
|
||||
{ 10, "Pokedex_Rating" }, { 39, "Pokedex_Rating" },
|
||||
{ 40, "Get_Item1" }, { 59, "Get_Item1" },
|
||||
{ 60, "Caught_Mon" }, { 89, "Caught_Mon" },
|
||||
{ 90, "Level_Up" }, { 119, "Level_Up" },
|
||||
{ 120, "Get_Key_Item" }, { 149, "Get_Key_Item" },
|
||||
{ 150, "Get_Item2" }, { 151, "Get_Item2" },
|
||||
}
|
||||
for _, row in ipairs(tiers) do
|
||||
T.eq(OW.dexRatingSfx and OW.dexRatingSfx(row[1]), row[2],
|
||||
("%d owned rates with %s"):format(row[1], row[2]))
|
||||
end
|
||||
|
||||
reset()
|
||||
local full = {}
|
||||
for i = 1, 151 do full[i] = true end
|
||||
fakeGame.save.pokedex = { seen = full, owned = full }
|
||||
fakeSelf:dexRating(function() end)
|
||||
lastPush().opts.auto.sound()
|
||||
T.eq(plays[1], "Get_Item2", "a complete dex plays Get_Item2")
|
||||
|
||||
package.loaded["src.ui.Menu"] = realMenu
|
||||
if realSound ~= nil then package.loaded["src.core.Sound"] = realSound end
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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 GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Assets = require("src.render.Assets")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local prevVersion, prevMode, prevDark = GameVersion.get(), PaletteFX.mode, PaletteFX.darkWorld()
|
||||
local realImageData, realImage = Assets.imageData, Assets.image
|
||||
local realNewImage = love.graphics.newImage
|
||||
|
||||
local SHADES = { 1.0, 0.66, 0.33, 0.0 }
|
||||
|
||||
Assets.imageData = function(path)
|
||||
local probe = { path = path }
|
||||
function probe:mapPixel(fn)
|
||||
self.mapped = {}
|
||||
for i, v in ipairs(SHADES) do
|
||||
local r, g, b, a = fn(0, 0, v, v, v, 1)
|
||||
self.mapped[i] = { r, g, b, a }
|
||||
end
|
||||
end
|
||||
return probe
|
||||
end
|
||||
Assets.image = function(path)
|
||||
return realNewImage(path)
|
||||
end
|
||||
love.graphics.newImage = function(src)
|
||||
local img = realNewImage(src)
|
||||
if type(src) == "table" and src.mapped then img.mapped = src.mapped end
|
||||
return img
|
||||
end
|
||||
|
||||
local serial = 0
|
||||
local function freshPath(tag)
|
||||
serial = serial + 1
|
||||
return "bug2689/" .. tag .. "_" .. serial .. ".png"
|
||||
end
|
||||
|
||||
local function rgb(c) return string.format("%d,%d,%d", c[1], c[2], c[3]) end
|
||||
local function mappedRgb(m)
|
||||
return string.format("%d,%d,%d", math.floor(m[1] * 255 + 0.5),
|
||||
math.floor(m[2] * 255 + 0.5), math.floor(m[3] * 255 + 0.5))
|
||||
end
|
||||
|
||||
local function stubSprite(tag)
|
||||
return setmetatable({ def = { image = freshPath(tag) } }, SpriteRenderer)
|
||||
end
|
||||
|
||||
local function expectRamp(img, ramp, label)
|
||||
local m = img and img.mapped
|
||||
check(m ~= nil, label .. ": bake ran through mapPixel")
|
||||
if not m then return end
|
||||
eq(m[1][4], 0, label .. ": OBJ color 0 is transparent")
|
||||
eq(mappedRgb(m[2]), rgb(ramp[1]), label .. ": OBJ color 1 -> rOBP0 shade")
|
||||
eq(mappedRgb(m[3]), rgb(ramp[2]), label .. ": OBJ color 2 -> rOBP0 shade")
|
||||
eq(mappedRgb(m[4]), rgb(ramp[3]), label .. ": OBJ color 3 -> rOBP0 shade")
|
||||
end
|
||||
|
||||
local function lastRedrawImage()
|
||||
local list = PaletteFX.spriteRedraws()
|
||||
local e = list[#list]
|
||||
return e and e.image
|
||||
end
|
||||
|
||||
for _, version in ipairs({ "red", "blue" }) do
|
||||
GameVersion.set(version)
|
||||
PaletteFX.setMode("ogred")
|
||||
PaletteFX.setDarkWorld(false)
|
||||
PaletteFX.setFadeObp(nil)
|
||||
local base = PaletteFX.ogObjBase()
|
||||
local lit = { base[1], base[2], base[4] }
|
||||
|
||||
expectRamp(stubSprite(version):resolveImage(), lit,
|
||||
version .. " lit resolveImage")
|
||||
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
PaletteFX.setPass("world")
|
||||
local ok, err = pcall(SpriteRenderer.drawTile, stubSprite(version),
|
||||
freshPath(version .. "_tile"), 0, 0, false)
|
||||
PaletteFX.setPass(nil)
|
||||
check(ok, version .. " drawTile runs headless" .. (ok and "" or (": " .. tostring(err))))
|
||||
expectRamp(lastRedrawImage(), lit, version .. " lit drawTile redraw")
|
||||
PaletteFX.clearSpriteRedraws()
|
||||
|
||||
PaletteFX.setFadeObp({ [0] = 0, [1] = 1, [2] = 2, [3] = 3 })
|
||||
expectRamp(stubSprite(version):resolveImage(), { base[2], base[3], base[4] },
|
||||
version .. " FadePal3 rOBP0 $E4 step")
|
||||
PaletteFX.setFadeObp({ [0] = 0, [1] = 0, [2] = 1, [3] = 3 })
|
||||
expectRamp(stubSprite(version):resolveImage(), lit,
|
||||
version .. " FadePal4 rOBP0 $D0 step")
|
||||
PaletteFX.setFadeObp(nil)
|
||||
|
||||
PaletteFX.setDarkWorld(true)
|
||||
local _, group = PaletteFX.ogObjWorld()
|
||||
check(tostring(group):match("dark$") ~= nil,
|
||||
version .. " dark map keeps the FadePal2 OBJ shift (" .. tostring(group) .. ")")
|
||||
expectRamp(stubSprite(version):resolveImage(), { base[4], base[4], base[4] },
|
||||
version .. " dark map rOBP0 3,3,3,2")
|
||||
PaletteFX.setDarkWorld(false)
|
||||
|
||||
local _, litGroup = PaletteFX.ogObjWorld()
|
||||
local _, rawGroup = PaletteFX.ogObj()
|
||||
check(litGroup ~= rawGroup,
|
||||
version .. " overworld bake group differs from the $E4 title/battle bake")
|
||||
end
|
||||
|
||||
love.graphics.newImage = realNewImage
|
||||
Assets.imageData, Assets.image = realImageData, realImage
|
||||
PaletteFX.setFadeObp(nil)
|
||||
PaletteFX.setDarkWorld(prevDark)
|
||||
GameVersion.set(prevVersion)
|
||||
PaletteFX.setMode(prevMode)
|
||||
|
||||
T.finish("overworld OG rOBP0 #2689")
|
||||
@@ -0,0 +1,122 @@
|
||||
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 PartyMenu = require("src.ui.PartyMenu")
|
||||
|
||||
local function newGame(moveId, ow)
|
||||
local function mk(species)
|
||||
return { species = species, hp = 50, stats = { hp = 50 },
|
||||
level = 30, moves = { { id = moveId, pp = 15 } } }
|
||||
end
|
||||
local party = { mk("MACHOP"), mk("MACHOP") }
|
||||
ow.map = ow.map or { def = { tileset = "OVERWORLD" }, id = "PALLET_TOWN" }
|
||||
ow.dark = ow.dark or false
|
||||
ow.partyKnows = function() return party[1] end
|
||||
local game = {
|
||||
data = { pokemon = { MACHOP = { name = "MACHOP" } } },
|
||||
save = { party = party, inventory = {}, options = {}, flags = {} },
|
||||
overworld = ow,
|
||||
}
|
||||
game.stack = {
|
||||
states = {},
|
||||
push = function(self, s) table.insert(self.states, s) end,
|
||||
pop = function(self) return table.remove(self.states) end,
|
||||
top = function(self) return self.states[#self.states] end,
|
||||
}
|
||||
game.input = {
|
||||
queue = {},
|
||||
wasPressed = function(self, btn) return self.queue[btn] or false end,
|
||||
isDown = function() return false end,
|
||||
}
|
||||
return game
|
||||
end
|
||||
|
||||
local function press(pm, btn)
|
||||
pm.game.input.queue = { [btn] = true }
|
||||
pm:update(1 / 60)
|
||||
pm.game.input.queue = {}
|
||||
end
|
||||
|
||||
local function idle(pm, n)
|
||||
for _ = 1, n do pm:update(1 / 60) end
|
||||
end
|
||||
|
||||
local function rowOf(pm, action)
|
||||
for i, item in ipairs(pm.subItems or {}) do
|
||||
if item.action == action then return i end
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame("STRENGTH", {})
|
||||
local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
idle(pm, 7)
|
||||
check(pm.blink > 0, "the cursor mon animates while the list takes input")
|
||||
press(pm, "a")
|
||||
check(pm.submenu, "A opens the submenu")
|
||||
local frozen = pm.blink
|
||||
idle(pm, 40)
|
||||
eq(pm.blink, frozen, "the icon counter holds while the submenu is open")
|
||||
press(pm, "b")
|
||||
eq(pm.blink, 0, "closing the submenu restarts the icon on its rest frame")
|
||||
idle(pm, 7)
|
||||
press(pm, "down")
|
||||
eq(pm.index, 2, "down moves the cursor")
|
||||
eq(pm.blink, 0, "a cursor move restarts the icon on its rest frame")
|
||||
idle(pm, 3)
|
||||
press(pm, "up")
|
||||
eq(pm.blink, 0, "up restarts the icon too")
|
||||
end
|
||||
|
||||
do
|
||||
local game = newGame("STRENGTH", {})
|
||||
local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
idle(pm, 5)
|
||||
local before = pm.blink
|
||||
pm:animateTo(game.save.party[1], 10)
|
||||
pm:update(1 / 60)
|
||||
eq(pm.blink, before, "the icon counter holds during the HP bar fill")
|
||||
end
|
||||
|
||||
local function fieldMove(label, moveId, action, ow, captured)
|
||||
local game = newGame(moveId, ow)
|
||||
local pm = PartyMenu.new(game, {})
|
||||
game.stack:push(pm)
|
||||
press(pm, "a")
|
||||
pm.subIndex = rowOf(pm, action) or 1
|
||||
press(pm, "a")
|
||||
check(captured.onClose ~= nil, label .. " reaches the overworld handler")
|
||||
check(not pm.submenu, label .. ": the options box is gone under the text")
|
||||
check(pm.chosenHollow, label .. ": the party cursor stays hollow under the text")
|
||||
end
|
||||
|
||||
do
|
||||
local captured = {}
|
||||
fieldMove("STRENGTH", "STRENGTH", "strength", {
|
||||
useStrengthFieldMove = function(_, _, onClose) captured.onClose = onClose end,
|
||||
}, captured)
|
||||
end
|
||||
|
||||
do
|
||||
local captured = {}
|
||||
fieldMove("SURF", "SURF", "surf", {
|
||||
player = { facingCell = function() return 4, 14 end },
|
||||
useSurfFieldMove = function() return "ok" end,
|
||||
trySurf = function(_, _, _, onClose) captured.onClose = onClose end,
|
||||
}, captured)
|
||||
end
|
||||
|
||||
do
|
||||
local captured = {}
|
||||
fieldMove("FLASH", "FLASH", "flash", {
|
||||
dark = true,
|
||||
useFlashFieldMove = function(_, onClose) captured.onClose = onClose end,
|
||||
}, captured)
|
||||
end
|
||||
|
||||
T.finish()
|
||||
@@ -0,0 +1,179 @@
|
||||
-- engine/battle/core.asm:3227
|
||||
-- engine/battle/core.asm:4913
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
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)
|
||||
|
||||
Data.moves.FIX_LEAFISH = {
|
||||
id = "FIX_LEAFISH", index = 97, name = "FIX LEAF",
|
||||
type = "GRASS", power = 40, accuracy = 100, pp = 25,
|
||||
effect = "NO_ADDITIONAL_EFFECT",
|
||||
}
|
||||
Data.moves.FIX_MULTI = {
|
||||
id = "FIX_MULTI", index = 98, name = "FIX MULTI",
|
||||
type = "NORMAL", power = 15, accuracy = 100, pp = 10,
|
||||
effect = "TWO_TO_FIVE_ATTACKS_EFFECT", multiHit = 3,
|
||||
}
|
||||
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local game = { data = Data, save = save,
|
||||
stack = { top = function() return nil end, push = function() end } }
|
||||
|
||||
local function lowRolls(lo, hi)
|
||||
if lo and lo > 0 then return hi end
|
||||
return 0
|
||||
end
|
||||
|
||||
local function mkseq(vals)
|
||||
local i = 0
|
||||
return function(_, hi)
|
||||
i = i + 1
|
||||
return vals[i] ~= nil and vals[i] or hi
|
||||
end
|
||||
end
|
||||
|
||||
local function rager(rng)
|
||||
local b = BattleState.newWild(game, "FIXMON_C", 40)
|
||||
b.rng = rng or lowRolls
|
||||
b.enemy.rageMove = { id = "RAGE", pp = 20 }
|
||||
b.enemy.stages.attack = 0
|
||||
return b
|
||||
end
|
||||
|
||||
local function texts(b)
|
||||
local out = {}
|
||||
for _, row in ipairs(b.queue) do
|
||||
if row.text then out[#out + 1] = row.text end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function find(list, needle, from)
|
||||
for i = from or 1, #list do
|
||||
if list[i]:find(needle, 1, true) then return i end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function count(list, needle)
|
||||
local n = 0
|
||||
for _, t in ipairs(list) do
|
||||
if t:find(needle, 1, true) then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
do
|
||||
local b = rager()
|
||||
b:performMove(b.player, b.enemy, { id = "FIX_LEAFISH", pp = 25 })
|
||||
local t = texts(b)
|
||||
local crit = find(t, "Critical hit!")
|
||||
local super = find(t, "super")
|
||||
local rage = find(t, "RAGE is building!")
|
||||
local rose = find(t, "rose!")
|
||||
T.check(crit and super and rage and rose, "crit, effectiveness, rage and rose rows all queued")
|
||||
T.check(crit and super and crit < super, "crit text precedes effectiveness")
|
||||
T.check(super and rage and super < rage, "rage text follows effectiveness")
|
||||
T.check(rage and rose and rage < rose, "ATTACK rose! follows the rage text")
|
||||
T.check(rose and t[rose]:find("ATTACK", 1, true) ~= nil, "the rose row names ATTACK")
|
||||
T.eq(b.enemy.stages.attack, 1, "rage raises the attack stage once")
|
||||
end
|
||||
|
||||
do
|
||||
local b = rager()
|
||||
b.enemy.mon.hp = 1
|
||||
b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 })
|
||||
local t = texts(b)
|
||||
T.eq(b.enemy.mon.hp, 0, "the hit knocks the rager out")
|
||||
T.eq(find(t, "RAGE is building!"), nil, "no rage text on the fainting hit")
|
||||
T.eq(find(t, "rose!"), nil, "no stat text on the fainting hit")
|
||||
T.eq(b.enemy.stages.attack, 0, "no stage gained on the fainting hit")
|
||||
end
|
||||
|
||||
do
|
||||
local b = rager()
|
||||
b.enemy.stages.attack = 6
|
||||
b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 })
|
||||
local t = texts(b)
|
||||
T.eq(find(t, "RAGE is building!"), nil, "maxed attack stays silent")
|
||||
T.eq(find(t, "Nothing happened!"), nil, "maxed attack prints no fallback either")
|
||||
T.eq(b.enemy.stages.attack, 6, "stage stays at +6")
|
||||
end
|
||||
|
||||
do
|
||||
local b = rager()
|
||||
b.enemy.substituteHP = 200
|
||||
local hp = b.enemy.mon.hp
|
||||
b:performMove(b.player, b.enemy, { id = "FIX_TACKLE", pp = 35 })
|
||||
local t = texts(b)
|
||||
T.eq(b.enemy.mon.hp, hp, "the substitute takes the hit")
|
||||
T.check(find(t, "RAGE is building!") ~= nil, "rage still builds behind a substitute")
|
||||
T.eq(b.enemy.stages.attack, 1, "substitute hit raises the stage")
|
||||
end
|
||||
|
||||
do
|
||||
local b = rager(mkseq({ 0, 255, 255 }))
|
||||
b.enemy.mon.maxHP, b.enemy.mon.hp = 999, 999
|
||||
b:performMove(b.player, b.enemy, { id = "FIX_MULTI", pp = 10 })
|
||||
local t = texts(b)
|
||||
T.eq(count(t, "RAGE is building!"), 3, "rage builds once per strike")
|
||||
T.eq(count(t, "rose!"), 3, "a rose line per strike")
|
||||
T.eq(b.enemy.stages.attack, 3, "three strikes, three stages")
|
||||
local multi = find(t, "times!")
|
||||
local lastRose
|
||||
for i, s in ipairs(t) do if s:find("rose!", 1, true) then lastRose = i end end
|
||||
T.check(multi and lastRose and lastRose < multi, "hit count text follows the last strike's rage")
|
||||
end
|
||||
|
||||
do
|
||||
local probe = rager(mkseq({ 0, 255, 255 }))
|
||||
probe.enemy.mon.maxHP, probe.enemy.mon.hp = 999, 999
|
||||
probe:performMove(probe.player, probe.enemy, { id = "FIX_MULTI", pp = 10 })
|
||||
local perStrike = math.floor((999 - probe.enemy.mon.hp) / 3)
|
||||
local b = rager(mkseq({ 0, 255, 255 }))
|
||||
b.enemy.mon.hp = perStrike + 1
|
||||
b:performMove(b.player, b.enemy, { id = "FIX_MULTI", pp = 10 })
|
||||
local t = texts(b)
|
||||
T.eq(b.enemy.mon.hp, 0, "the second strike knocks the rager out")
|
||||
T.eq(count(t, "RAGE is building!"), 1, "only the first strike built rage")
|
||||
T.eq(find(t, "times!"), nil, "no hit count text when a strike faints the target")
|
||||
T.check(find(t, "fainted!") ~= nil, "the target faints")
|
||||
end
|
||||
|
||||
Data.moves.FIX_BOOM = {
|
||||
id = "FIX_BOOM", index = 99, name = "FIX BOOM",
|
||||
type = "NORMAL", power = 170, accuracy = 100, pp = 5,
|
||||
effect = "EXPLODE_EFFECT",
|
||||
}
|
||||
|
||||
for _, arm in ipairs({
|
||||
{ label = "accuracy miss", seq = { 255 } },
|
||||
{ label = "invulnerable target", seq = { 0, 255, 255 }, invulnerable = true },
|
||||
}) do
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
local b = rager(mkseq(arm.seq))
|
||||
b.enemy.invulnerable = arm.invulnerable
|
||||
b:performMove(b.player, b.enemy, { id = "FIX_BOOM", pp = 5 })
|
||||
local t = texts(b)
|
||||
T.check(find(t, "attack missed!") ~= nil, arm.label .. ": the Explosion missed")
|
||||
T.eq(b.player.mon.hp, 0, arm.label .. ": the exploder still faints")
|
||||
local rage = find(t, "RAGE is building!")
|
||||
local faint = find(t, "fainted!")
|
||||
T.check(rage ~= nil, arm.label .. ": a missed Explosion still builds rage")
|
||||
T.check(rage and faint and rage < faint, arm.label .. ": rage text precedes the exploder's faint")
|
||||
T.eq(b.enemy.stages.attack, 1, arm.label .. ": stage +1 after the miss")
|
||||
end
|
||||
|
||||
Data.moves.FIX_LEAFISH = nil
|
||||
Data.moves.FIX_MULTI = nil
|
||||
Data.moves.FIX_BOOM = nil
|
||||
T.finish("rage building order")
|
||||
@@ -0,0 +1,155 @@
|
||||
-- engine/battle/move_effects/recoil.asm:28
|
||||
-- engine/battle/move_effects/drain_hp.asm:81
|
||||
-- engine/battle/core.asm:4899
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Data = T.fixtures.fresh()
|
||||
local Font = require("src.render.Font")
|
||||
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)
|
||||
|
||||
Data.moves.FIX_RECOIL = {
|
||||
id = "FIX_RECOIL", index = 98, name = "FIX RECOIL",
|
||||
type = "NORMAL", power = 90, accuracy = 100, pp = 20,
|
||||
effect = "RECOIL_EFFECT",
|
||||
}
|
||||
Data.moves.FIX_ABSORB = {
|
||||
id = "FIX_ABSORB", index = 97, name = "FIX ABSORB",
|
||||
type = "NORMAL", power = 60, accuracy = 100, pp = 20,
|
||||
effect = "DRAIN_HP_EFFECT",
|
||||
}
|
||||
|
||||
local function mkseq(vals)
|
||||
local i = 0
|
||||
return function(_, hi)
|
||||
i = i + 1
|
||||
return vals[i] ~= nil and vals[i] or hi
|
||||
end
|
||||
end
|
||||
|
||||
local function newBattle()
|
||||
local save = SaveData.newGame()
|
||||
save.party = { Pokemon.new(Data, "FIXMON_A", 30) }
|
||||
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 = mkseq({ 0, 255, 255 })
|
||||
return battle
|
||||
end
|
||||
|
||||
local function rowsOf(battle)
|
||||
local out = {}
|
||||
for i, row in ipairs(battle.queue) do
|
||||
local kind
|
||||
if row.drain then
|
||||
kind = row.battler == battle.player and "drain:player"
|
||||
or row.battler == battle.enemy and "drain:enemy" or "drain"
|
||||
elseif row.text then
|
||||
kind = row.text:find("recoil", 1, true) and "text:recoil" or "text"
|
||||
end
|
||||
if kind then out[#out + 1] = { kind = kind, row = row, i = i } end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function find(rows, kind)
|
||||
for _, r in ipairs(rows) do
|
||||
if r.kind == kind then return r end
|
||||
end
|
||||
end
|
||||
|
||||
local function replay(battle, row)
|
||||
battle.queue, battle.current = { row }, nil
|
||||
local frames = 0
|
||||
while battle:updateQueue() and frames < 8000 do frames = frames + 1 end
|
||||
T.check(frames < 8000, "drain row settles")
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.player.mon.hp = battle.player.mon.stats.hp
|
||||
battle.player.shownHP = battle.player.mon.hp
|
||||
local pStart = battle.player.mon.hp
|
||||
local eStart = battle.enemy.mon.hp
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 })
|
||||
local dealt = eStart - battle.enemy.mon.hp
|
||||
local recoil = math.max(1, math.floor(dealt / 4))
|
||||
T.eq(pStart - battle.player.mon.hp, recoil, "recoil is a quarter of the damage")
|
||||
|
||||
local rows = rowsOf(battle)
|
||||
local enemyRow, playerRow = find(rows, "drain:enemy"), find(rows, "drain:player")
|
||||
local recoilText = find(rows, "text:recoil")
|
||||
T.check(enemyRow and playerRow and recoilText, "target drain, user drain and recoil text all queued")
|
||||
if enemyRow and playerRow and recoilText then
|
||||
T.check(enemyRow.i < playerRow.i, "the target's bar drains before the user's")
|
||||
T.check(playerRow.i < recoilText.i, "the user's bar drains before the recoil text")
|
||||
T.eq(playerRow.row.stopAt, battle.player.mon.hp, "the user's row stops at the post-recoil HP")
|
||||
|
||||
replay(battle, enemyRow.row)
|
||||
T.eq(battle.enemy.shownHP, battle.enemy.mon.hp, "the target bar settles on its row")
|
||||
T.eq(battle.player.shownHP, pStart, "the user's bar holds still during the target's row")
|
||||
|
||||
replay(battle, playerRow.row)
|
||||
T.eq(battle.player.shownHP, battle.player.mon.hp, "the user's bar settles on the recoil row")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.player.substituteHP = 10
|
||||
local pStart = battle.player.mon.hp
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 })
|
||||
T.check(battle.player.mon.hp < pStart, "recoil comes off the user's HP")
|
||||
T.eq(battle.player.substituteHP, 10, "the user's substitute is untouched by recoil")
|
||||
for _, row in ipairs(battle.queue) do
|
||||
T.check(not (row.text and row.text:find("SUBSTITUTE", 1, true)),
|
||||
"no substitute text for the user's recoil")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.enemy.substituteHP = 1
|
||||
local pStart = battle.player.mon.hp
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_RECOIL", pp = 20 })
|
||||
T.eq(battle.enemy.substituteHP, nil, "the hit breaks the substitute")
|
||||
T.eq(battle.player.mon.hp, pStart, "no recoil after breaking a substitute")
|
||||
T.check(not find(rowsOf(battle), "text:recoil"), "no recoil text after breaking a substitute")
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.player.mon.hp = 10
|
||||
battle.player.shownHP = 10
|
||||
battle.player.shownPx = nil
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_ABSORB", pp = 20 })
|
||||
T.check(battle.player.mon.hp > 10, "absorb heals the user")
|
||||
local rows = rowsOf(battle)
|
||||
local enemyRow, playerRow = find(rows, "drain:enemy"), find(rows, "drain:player")
|
||||
T.check(enemyRow and playerRow and enemyRow.i < playerRow.i,
|
||||
"the user's heal has its own row after the target's")
|
||||
if enemyRow then
|
||||
replay(battle, enemyRow.row)
|
||||
T.eq(battle.player.shownHP, 10, "the user's bar holds still during the target's row")
|
||||
end
|
||||
if playerRow then
|
||||
replay(battle, playerRow.row)
|
||||
T.eq(battle.player.shownHP, battle.player.mon.hp, "the user's bar settles on its row")
|
||||
end
|
||||
end
|
||||
|
||||
do
|
||||
local battle = newBattle()
|
||||
battle.player.mon.hp = 10
|
||||
battle.enemy.substituteHP = 1
|
||||
battle:performMove(battle.player, battle.enemy, { id = "FIX_ABSORB", pp = 20 })
|
||||
T.eq(battle.player.mon.hp, 10, "no heal after breaking a substitute")
|
||||
end
|
||||
|
||||
Data.moves.FIX_RECOIL, Data.moves.FIX_ABSORB = nil, nil
|
||||
T.finish("recoil hp drain")
|
||||
@@ -0,0 +1,223 @@
|
||||
-- scripts/Route23.asm:195
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
|
||||
local played = {}
|
||||
package.loaded["src.core.Sound"] = {
|
||||
play = function(_, id) played[#played + 1] = id; return nil end,
|
||||
}
|
||||
|
||||
local OW = require("src.world.OverworldController")
|
||||
local RealTextBox = require("src.render.TextBox")
|
||||
|
||||
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 OH = "You can pass here\nonly if you have\vthe {RAM:wNameBuffer}!\fOh! That is the\n{RAM:wNameBuffer}!{DONE}"
|
||||
local GO = "\fOK then! Please,\ngo right ahead!{DONE}"
|
||||
local NO = "You can pass here\nonly if you have\vthe {RAM:wNameBuffer}!\fYou don't have the\n{RAM:wNameBuffer} yet!{DONE}"
|
||||
local NO22 = "Only truly skilled\ntrainers are\vallowed through.\fYou don't have the\nBOULDERBADGE yet!{DONE}"
|
||||
local RULES = "\fThe rules are\nrules. I can't\vlet you pass.{DONE}"
|
||||
|
||||
local pushed
|
||||
local textBoxStub = setmetatable({
|
||||
new = function(_, text, onDone, opts)
|
||||
return { text = text, onDone = onDone, opts = opts }
|
||||
end,
|
||||
}, { __index = RealTextBox })
|
||||
local fakeGame = {
|
||||
data = {
|
||||
text = {
|
||||
_Route23OhThatIsTheBadgeText = OH,
|
||||
_Route23GoRightAheadText = GO,
|
||||
_Route23YouDontHaveTheBadgeYetText = NO,
|
||||
_Route22GateGuardNoBoulderbadgeText = NO22,
|
||||
_Route22GateGuardICantLetYouPassText = RULES,
|
||||
},
|
||||
items = { CASCADEBADGE = { name = "CASCADEBADGE" } },
|
||||
field = { badgeGates = {
|
||||
ROUTE_23 = {
|
||||
passText = "Route23OhThatIsTheBadgeText",
|
||||
failText = "Route23YouDontHaveTheBadgeYetText",
|
||||
guards = { { y = 96, badge = "CASCADEBADGE", event = "EVENT_PASSED_CASCADEBADGE_CHECK" } },
|
||||
},
|
||||
ROUTE_22_GATE = {
|
||||
badge = "BOULDERBADGE",
|
||||
passText = "Route22GateGuardGoRightAheadText",
|
||||
failText = "Route22GateGuardNoBoulderbadgeText",
|
||||
coords = { { x = 4, y = 2 } },
|
||||
},
|
||||
} },
|
||||
},
|
||||
save = { inventory = { CASCADEBADGE = 1 }, flags = {} },
|
||||
stack = { push = function(_, box) pushed = box end },
|
||||
}
|
||||
T.check(setUpvalue(OW.checkBadgeGate, "TextBox", textBoxStub),
|
||||
"TextBox upvalue on checkBadgeGate")
|
||||
T.check(setUpvalue(OW.checkBadgeGate, "Game", fakeGame),
|
||||
"Game upvalue on checkBadgeGate")
|
||||
T.check(type(OW.route23BadgeCheck) == "function", "route23BadgeCheck shared helper exists")
|
||||
if OW.route23BadgeCheck then
|
||||
setUpvalue(OW.route23BadgeCheck, "TextBox", textBoxStub)
|
||||
setUpvalue(OW.route23BadgeCheck, "Game", fakeGame)
|
||||
end
|
||||
|
||||
local moves = {}
|
||||
local function newOw(x, y, mapId)
|
||||
return setmetatable({
|
||||
player = { cellX = x, cellY = y },
|
||||
map = { id = mapId or "ROUTE_23" },
|
||||
scriptMove = function(_, entity, dir, tiles, onDone, opts)
|
||||
moves[#moves + 1] = { entity = entity, dir = dir, tiles = tiles,
|
||||
onDone = onDone, collide = opts and opts.collide }
|
||||
end,
|
||||
}, { __index = OW })
|
||||
end
|
||||
|
||||
local function checkPassBox(label)
|
||||
local text = pushed and pushed.text or ""
|
||||
local ohAt = text:find("Oh! That is the\nCASCADEBADGE!", 1, true)
|
||||
local pauseAt = text:find(RealTextBox.PAUSE, 1, true)
|
||||
local goAt = text:find("OK then! Please,\ngo right ahead!", 1, true)
|
||||
T.check(ohAt ~= nil, label .. ": badge line present")
|
||||
T.check(goAt ~= nil, label .. ": go right ahead text follows the badge text")
|
||||
T.check(ohAt and pauseAt and goAt and ohAt < pauseAt and pauseAt < goAt,
|
||||
label .. ": fanfare mark sits between the badge line and the go-ahead page")
|
||||
T.check(not text:sub(1, (goAt or 1) - 1):find("{DONE}", 1, true),
|
||||
label .. ": badge text DONE stripped before the go-ahead page")
|
||||
local opts = pushed and pushed.opts or {}
|
||||
T.eq(opts.pauseSounds and opts.pauseSounds[1], "Get_Item1",
|
||||
label .. ": Get_Item1 rides the mid-string mark")
|
||||
T.eq(opts.pauseSoundWait, true, label .. ": fanfare waits like TextCommand_SOUND")
|
||||
local pages = RealTextBox.paginate((text:gsub(RealTextBox.PAUSE, "")))
|
||||
T.eq(#pages, 3, label .. ": pass text is three pages")
|
||||
end
|
||||
|
||||
local function checkFailBox(label)
|
||||
local text = pushed and pushed.text or ""
|
||||
T.check(text:find("You don't have the\nCASCADEBADGE yet!", 1, true) ~= nil,
|
||||
label .. ": fail text shown")
|
||||
T.eq(#played, 0, label .. ": no SFX_DENIED before the text")
|
||||
local opts = pushed and pushed.opts or {}
|
||||
T.check(opts.auto and type(opts.auto.sound) == "function",
|
||||
label .. ": SFX_DENIED armed as the trailing text_asm sound")
|
||||
T.eq(opts.auto and opts.auto.wait, true, label .. ": button wait after the sound")
|
||||
if opts.auto and opts.auto.sound then opts.auto.sound() end
|
||||
T.eq(played[1], "Denied", label .. ": the trailing sound is SFX_DENIED")
|
||||
T.check(pushed and type(pushed.onDone) == "function",
|
||||
label .. ": the box closes into Route23MovePlayerDownScript")
|
||||
end
|
||||
|
||||
pushed, played, moves = nil, {}, {}
|
||||
local self_ = newOw(10, 96)
|
||||
T.eq(self_:checkBadgeGate(), false, "badge holder walks on")
|
||||
T.check(pushed ~= nil, "pass text box pushed")
|
||||
checkPassBox("step")
|
||||
T.eq(fakeGame.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK ~= nil, true, "pass flag set")
|
||||
|
||||
pushed, played, moves = nil, {}, {}
|
||||
fakeGame.save = { inventory = {}, flags = {} }
|
||||
self_ = newOw(10, 96)
|
||||
T.eq(self_:checkBadgeGate(), true, "no badge: the step is blocked")
|
||||
T.check(pushed ~= nil, "fail text box pushed")
|
||||
checkFailBox("step")
|
||||
if pushed and pushed.onDone then pushed.onDone() end
|
||||
T.eq(#moves, 1, "step fail: one scripted move after the box")
|
||||
T.eq(moves[1] and moves[1].dir, "down", "step fail: the move is down")
|
||||
T.eq(moves[1] and moves[1].tiles, 1, "step fail: one tile")
|
||||
T.eq(moves[1] and moves[1].collide, true, "step fail: simulated d-pad collides")
|
||||
T.eq(moves[1] and moves[1].entity, self_.player, "step fail: the player walks")
|
||||
T.eq(fakeGame.save.flags.EVENT_PASSED_CASCADEBADGE_CHECK, nil, "no pass flag without the badge")
|
||||
|
||||
local R23 = require("data.scripts.flavor.route_23")
|
||||
local talk = R23.ROUTE_23.talk
|
||||
local expect = {
|
||||
TEXT_ROUTE23_GUARD1 = { "EARTHBADGE", "EVENT_PASSED_EARTHBADGE_CHECK" },
|
||||
TEXT_ROUTE23_GUARD2 = { "VOLCANOBADGE", "EVENT_PASSED_VOLCANOBADGE_CHECK" },
|
||||
TEXT_ROUTE23_GUARD3 = { "RAINBOWBADGE", "EVENT_PASSED_RAINBOWBADGE_CHECK" },
|
||||
TEXT_ROUTE23_GUARD4 = { "THUNDERBADGE", "EVENT_PASSED_THUNDERBADGE_CHECK" },
|
||||
TEXT_ROUTE23_GUARD5 = { "CASCADEBADGE", "EVENT_PASSED_CASCADEBADGE_CHECK" },
|
||||
TEXT_ROUTE23_SWIMMER1 = { "MARSHBADGE", "EVENT_PASSED_MARSHBADGE_CHECK" },
|
||||
TEXT_ROUTE23_SWIMMER2 = { "SOULBADGE", "EVENT_PASSED_SOULBADGE_CHECK" },
|
||||
}
|
||||
for id, want in pairs(expect) do
|
||||
local badge, flag = want[1], want[2]
|
||||
T.eq(type(talk[id]), "function", id .. " is a Lua talk handler")
|
||||
if type(talk[id]) == "function" then
|
||||
fakeGame.data.items[badge] = { name = badge }
|
||||
pushed, played, moves = nil, {}, {}
|
||||
fakeGame.save = { inventory = { [badge] = 1 }, flags = {} }
|
||||
local doneCalls = 0
|
||||
local ow = newOw(7, 136)
|
||||
talk[id](fakeGame, ow, {}, function() doneCalls = doneCalls + 1 end)
|
||||
T.check(pushed ~= nil, id .. " pass: one box pushed")
|
||||
local text = pushed and pushed.text or ""
|
||||
T.check(text:find("Oh! That is the\n" .. badge .. "!", 1, true) ~= nil,
|
||||
id .. " pass: badge line names " .. badge)
|
||||
local pauseAt = text:find(RealTextBox.PAUSE, 1, true)
|
||||
local goAt = text:find("OK then! Please,\ngo right ahead!", 1, true)
|
||||
T.check(pauseAt and goAt and pauseAt < goAt, id .. " prints go-ahead after the badge text")
|
||||
local opts = pushed and pushed.opts or {}
|
||||
T.eq(opts.pauseSounds and opts.pauseSounds[1], "Get_Item1", id .. " pass: Get_Item1 on the mark")
|
||||
T.eq(opts.pauseSoundWait, true, id .. " pass: fanfare waits")
|
||||
T.eq(fakeGame.save.flags[flag] ~= nil, true, id .. " sets " .. flag)
|
||||
T.eq(#played, 0, id .. " pass: nothing played before the box")
|
||||
if pushed and pushed.onDone then pushed.onDone() end
|
||||
T.eq(doneCalls, 1, id .. " pass: done fires when the box closes")
|
||||
T.eq(#moves, 0, id .. " pass: no scripted move")
|
||||
|
||||
pushed, played, moves = nil, {}, {}
|
||||
fakeGame.save = { inventory = {}, flags = {} }
|
||||
doneCalls = 0
|
||||
ow = newOw(7, 136)
|
||||
talk[id](fakeGame, ow, {}, function() doneCalls = doneCalls + 1 end)
|
||||
T.check(pushed ~= nil, id .. " fail: box pushed")
|
||||
text = pushed and pushed.text or ""
|
||||
T.check(text:find("You don't have the\n" .. badge .. " yet!", 1, true) ~= nil,
|
||||
id .. " fail: text names " .. badge)
|
||||
T.eq(#played, 0, id .. " fail: no SFX_DENIED before the text")
|
||||
opts = pushed and pushed.opts or {}
|
||||
T.check(opts.auto and type(opts.auto.sound) == "function",
|
||||
id .. " fail: SFX_DENIED armed after the text")
|
||||
if opts.auto and opts.auto.sound then opts.auto.sound() end
|
||||
T.eq(played[1], "Denied", id .. " fail: trailing sound is SFX_DENIED")
|
||||
T.eq(doneCalls, 0, id .. " fail: done waits for the walk")
|
||||
if pushed and pushed.onDone then pushed.onDone() end
|
||||
T.eq(#moves, 1, id .. " fail: Route23MovePlayerDownScript queued")
|
||||
T.eq(moves[1] and moves[1].dir, "down", id .. " fail: walks down")
|
||||
T.eq(moves[1] and moves[1].collide, true, id .. " fail: simulated d-pad collides")
|
||||
T.check(moves[1] and type(moves[1].onDone) == "function",
|
||||
id .. " fail: move carries an onDone")
|
||||
if moves[1] and moves[1].onDone then moves[1].onDone() end
|
||||
T.eq(doneCalls, 1, id .. " fail: done fires after the walk")
|
||||
T.eq(fakeGame.save.flags[flag], nil, id .. " fail: no pass flag")
|
||||
end
|
||||
end
|
||||
|
||||
pushed, played, moves = nil, {}, {}
|
||||
fakeGame.save = { inventory = {}, flags = {} }
|
||||
self_ = newOw(4, 2, "ROUTE_22_GATE")
|
||||
T.eq(self_:checkBadgeGate(), true, "Route 22 gate: no badge blocks the step")
|
||||
T.check(pushed ~= nil, "Route 22 gate: fail box pushed")
|
||||
local text22 = pushed and pushed.text or ""
|
||||
local noAt = text22:find("BOULDERBADGE yet!", 1, true)
|
||||
local pause22 = text22:find(RealTextBox.PAUSE, 1, true)
|
||||
local rulesAt = text22:find("The rules are", 1, true)
|
||||
T.check(noAt and pause22 and rulesAt and noAt < pause22 and pause22 < rulesAt,
|
||||
"Route 22 gate: Denied mark sits between the two texts")
|
||||
T.eq(#played, 0, "Route 22 gate: no SFX_DENIED before the text")
|
||||
local opts22 = pushed and pushed.opts or {}
|
||||
T.eq(opts22.pauseSounds and opts22.pauseSounds[1], "Denied", "Route 22 gate: mark plays SFX_DENIED")
|
||||
T.eq(opts22.pauseSoundWait, true, "Route 22 gate: sound waits")
|
||||
T.check(not text22:sub(1, (rulesAt or 1) - 1):find("{DONE}", 1, true),
|
||||
"Route 22 gate: first text DONE stripped")
|
||||
|
||||
T.finish("route23_go_right_ahead_bug2649")
|
||||
@@ -0,0 +1,116 @@
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
love = love or require("tests.love_stub")
|
||||
|
||||
local T = require("tests.harness")
|
||||
local Font = require("src.render.Font")
|
||||
Font.load({ font = require("tests.fixture_data.font") })
|
||||
local Theme = require("src.ui.Theme")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Version = require("src.core.GameVersion")
|
||||
local Timing = require("src.core.Timing")
|
||||
package.loaded["src.core.Sound"] = { play = function() end, playPress = function() end }
|
||||
|
||||
local ARROW = Theme.moreArrow or 0xEE
|
||||
local SPACE = 0x7F
|
||||
local LINE = "ENTIRELY COMPLETEZ"
|
||||
local HEAD = "YOUR DEX IS"
|
||||
|
||||
local function make(text, save)
|
||||
local pressed = false
|
||||
local game = {
|
||||
data = {}, save = save or { options = { textSpeed = 1 } },
|
||||
input = { wasPressed = function(_, key) return pressed == key end,
|
||||
isDown = function() return false end },
|
||||
stack = { pop = function() end },
|
||||
}
|
||||
local box = TextBox.new(game, text, function() end)
|
||||
return box, function(key)
|
||||
pressed = key or false
|
||||
box:update(1 / 60)
|
||||
pressed = false
|
||||
end
|
||||
end
|
||||
|
||||
local function prompt(box, tick)
|
||||
for _ = 1, 400 do
|
||||
if box.waiting or box.done then return true end
|
||||
tick()
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function drawn(box, blink)
|
||||
local drawCode, drawBox = Font.drawCode, Font.drawBox
|
||||
local out = {}
|
||||
Font.drawCode = function(code, x, y)
|
||||
out[#out + 1] = { code = code, x = x, y = y }
|
||||
end
|
||||
Font.drawBox = function() end
|
||||
local saved = box.blink
|
||||
box.blink = blink
|
||||
box:draw()
|
||||
box.blink = saved
|
||||
Font.drawCode, Font.drawBox = drawCode, drawBox
|
||||
return out
|
||||
end
|
||||
|
||||
local function at(list, x, y)
|
||||
local codes = {}
|
||||
for _, g in ipairs(list) do
|
||||
if g.x == x and g.y == y then codes[#codes + 1] = g.code end
|
||||
end
|
||||
return codes
|
||||
end
|
||||
|
||||
local bang = Font.encode("Z")[1]
|
||||
|
||||
local function has(codes, code)
|
||||
for _, c in ipairs(codes) do
|
||||
if c == code then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- home/text.asm:262
|
||||
for _, edition in ipairs({ "red", "blue", "yellow" }) do
|
||||
Version.set(edition)
|
||||
local box, tick = make(HEAD .. "\n" .. LINE .. "\vCONGRATULATIONS")
|
||||
T.check(prompt(box, tick) and box.contAdvance, edition .. " reaches the cont wait")
|
||||
local ax, ay = box:arrowPos()
|
||||
T.eq(#box.shown[2], 18, edition .. " bottom line is 18 tiles")
|
||||
local lit = at(drawn(box, 0), ax, ay)
|
||||
T.same(lit, { ARROW }, edition .. " arrow replaces the 18th glyph while lit")
|
||||
local dark = at(drawn(box, 30), ax, ay)
|
||||
T.same(dark, {}, edition .. " the cell is blank while the arrow blinks off")
|
||||
for _ = 1, Timing.TEXT_PRE_ADVANCE do tick() end
|
||||
tick("a")
|
||||
T.eq(box.shown[1][18], SPACE, edition .. " cont leaves a space in the scrolled line")
|
||||
T.eq(box.shown[1][17], Font.encode(LINE)[17], edition .. " the 17th glyph survives")
|
||||
for _ = 1, Timing.TEXT_SCROLL_PAIR do tick() end
|
||||
T.check(prompt(box, tick) and box.done, edition .. " completes")
|
||||
local after = drawn(box, 30)
|
||||
T.check(not has(at(after, ax, box.line1Y), bang),
|
||||
edition .. " scrolled line keeps no 18th glyph")
|
||||
end
|
||||
|
||||
Version.set("red")
|
||||
local plain, plainTick = make("SHORT LINE\nHERE")
|
||||
T.check(prompt(plain, plainTick) and plain.done, "short page completes")
|
||||
local ax, ay = plain:arrowPos()
|
||||
local list = drawn(plain, 0)
|
||||
T.same(at(list, ax, ay), { ARROW }, "short line still draws the arrow")
|
||||
T.eq(#plain.shown[2], 4, "short line untouched")
|
||||
|
||||
Version.set("gold")
|
||||
local gold, goldTick = make(HEAD .. "\n" .. LINE .. "\vNEXT",
|
||||
{ generation = 2, options = { textSpeed = 1 } })
|
||||
T.check(prompt(gold, goldTick) and gold.contAdvance, "gold reaches cont")
|
||||
local gx = gold:arrowPos()
|
||||
local gglyphs = at(drawn(gold, 0), gx, gold.line2Y)
|
||||
T.same(gglyphs, { bang }, "gold keeps the glyph above its border-row arrow")
|
||||
for _ = 1, Timing.TEXT_PRE_ADVANCE do goldTick() end
|
||||
goldTick("a")
|
||||
T.eq(gold.shown[#gold.shown - 1][18], bang, "gold cont keeps the glyph")
|
||||
Version.set("red")
|
||||
|
||||
T.finish("textbox_arrow_cell_bug2674")
|
||||
@@ -0,0 +1,87 @@
|
||||
-- home/fade.asm:67
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
if not _G.love then _G.love = require("tests.love_stub") end
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local check, eq = T.check, T.eq
|
||||
|
||||
T.fixtures.fresh()
|
||||
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Transition = require("src.render.Transition")
|
||||
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 function getUpvalue(fn, name)
|
||||
local i = 1
|
||||
while true do
|
||||
local n, v = debug.getupvalue(fn, i)
|
||||
if not n then return nil end
|
||||
if n == name then return v end
|
||||
i = i + 1
|
||||
end
|
||||
end
|
||||
|
||||
local prevVersion, prevMode, prevDark = GameVersion.get(), PaletteFX.mode, PaletteFX.darkWorld()
|
||||
local prevGame = getUpvalue(OW.warpFade, "Game")
|
||||
local realSetFadeObp = PaletteFX.setFadeObp
|
||||
|
||||
local fade
|
||||
local game = { stack = { top = function() return fade end, pop = function() end },
|
||||
worldBgBattleInStack = function() return false end }
|
||||
check(setUpvalue(OW.warpFade, "Game", game), "warpFade reads the Game upvalue")
|
||||
|
||||
local function armedAt(t)
|
||||
local armed, called = nil, false
|
||||
PaletteFX.setFadeObp = function(map) armed, called = map, true; realSetFadeObp(map) end
|
||||
fade = Transition.new(game, nil, nil, true)
|
||||
fade.t = t
|
||||
local self_ = setmetatable({}, { __index = OW })
|
||||
pcall(OW.drawWorld, self_)
|
||||
PaletteFX.setFadeObp = realSetFadeObp
|
||||
return armed, called, fade
|
||||
end
|
||||
|
||||
for _, version in ipairs({ "red", "blue" }) do
|
||||
GameVersion.set(version)
|
||||
PaletteFX.setMode("ogred")
|
||||
PaletteFX.setDarkWorld(false)
|
||||
local base = PaletteFX.ogObjBase()
|
||||
|
||||
for step = 0, 3 do
|
||||
local map, called, f = armedAt(step * 8)
|
||||
local obp = f:obp0()
|
||||
check(called, ("%s step %d drawWorld arms the fade OBP"):format(version, step))
|
||||
check(map ~= nil, ("%s step %d rOBP0 $%02X arms a fade map"):format(version, step, obp))
|
||||
end
|
||||
|
||||
local map, _, f = armedAt(8)
|
||||
eq(f:obp0(), 0xE4, version .. " FadePal3 is the black-out second step")
|
||||
check(map ~= nil and map[0] == 0 and map[1] == 1 and map[2] == 2 and map[3] == 3,
|
||||
version .. " FadePal3 rOBP0 $E4 arms the identity map")
|
||||
local colors = PaletteFX.ogObjWorld()
|
||||
check(colors[2][1] == base[2][1] and colors[2][2] == base[2][2]
|
||||
and colors[3][1] == base[3][1] and colors[3][2] == base[3][2],
|
||||
version .. " FadePal3 step bakes the raw $E4 ramp, not the $D0 lift")
|
||||
PaletteFX.setFadeObp(nil)
|
||||
end
|
||||
|
||||
PaletteFX.setFadeObp = realSetFadeObp
|
||||
PaletteFX.setFadeObp(nil)
|
||||
setUpvalue(OW.warpFade, "Game", prevGame)
|
||||
PaletteFX.setDarkWorld(prevDark)
|
||||
GameVersion.set(prevVersion)
|
||||
PaletteFX.setMode(prevMode)
|
||||
|
||||
T.finish("warp fade rOBP0 identity #2689")
|
||||
@@ -331,4 +331,33 @@ do
|
||||
Version.set(oldVersion)
|
||||
end
|
||||
|
||||
do
|
||||
local RsMenu = require("src.ui.game3.rs.main_menu")
|
||||
local Pal = require("src.core.game3.pal_fade")
|
||||
local T3 = RsMenu.TYPE
|
||||
eq(table.concat(RsMenu.items(T3.HAS_NO_SAVED_GAME), ","), "NEW_GAME,OPTION,EXIT", "RS main menu lists EXIT under OPTION")
|
||||
eq(table.concat(RsMenu.items(T3.HAS_SAVED_GAME), ","), "CONTINUE,NEW_GAME,OPTION,EXIT", "RS continue menu lists EXIT")
|
||||
eq(table.concat(RsMenu.items(T3.HAS_MYSTERY_EVENT), ","), "CONTINUE,NEW_GAME,MYSTERY_EVENTS,OPTION,EXIT",
|
||||
"RS mystery events menu lists EXIT last")
|
||||
local items = RsMenu.items(T3.HAS_MYSTERY_EVENT)
|
||||
local m = setmetatable({ menuType = T3.HAS_MYSTERY_EVENT, items = items, cursor = 1, scroll = 0, blink = 0,
|
||||
pal = Pal.new(), state = "input" }, RsMenu)
|
||||
for _ = 1, #items - 1 do
|
||||
m:frame({ new = { down = true } })
|
||||
m.state = "input"
|
||||
end
|
||||
eq(m.cursor, #items, "RS cursor reaches EXIT")
|
||||
check(m.scroll > 0, "RS menu scrolls so EXIT stays on screen")
|
||||
local w = RsMenu.windowFor(m.menuType, m.cursor)
|
||||
check((w.top + w.height + 1) * 8 - m.scroll <= 160, "RS EXIT window fits after scroll")
|
||||
m.state = "pressed_a"
|
||||
local r = m:frame({ new = {} })
|
||||
eq(type(r) == "table" and r.action, "exit", "A on RS EXIT returns to the launcher")
|
||||
end
|
||||
|
||||
do
|
||||
local Data = require("src.ui.game3.rs.start_menu_data")
|
||||
check(Data.exitConfirms == true, "RS start menu EXIT offers return to title like Emerald")
|
||||
end
|
||||
|
||||
T.finish("game3_pause_and_main_menu_exit_test")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
-- ROM-free:
|
||||
-- luajit tests/gen2_crystal_extras_test.lua
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
require("src.core.GameVersion").set("crystal")
|
||||
|
||||
-- The same love stub tests/gen2_menus_test.lua installs; nothing here draws.
|
||||
love = love or {}
|
||||
@@ -569,4 +570,5 @@ do
|
||||
check(ids.Gen2BuenaPassword, "Gen2BuenaPassword is one too")
|
||||
end
|
||||
|
||||
require("src.core.GameVersion").set("red")
|
||||
S.finish()
|
||||
|
||||
@@ -978,7 +978,7 @@ do
|
||||
local fgame = { data = {}, stack = newStack(), input = newInput(),
|
||||
save = { player = {} } }
|
||||
local fspeech = OakSpeech.new(fgame, nil)
|
||||
fspeech.shrink = { frame = 103 } -- past the shrink timeline's end
|
||||
fspeech.shrink = { frame = OakSpeech.SHRINK_END } -- past the shrink timeline's end
|
||||
fgame.stack:push(fspeech)
|
||||
fspeech.picReveal = nil -- skip the intro fade so update reaches shrink
|
||||
local finishes = 0
|
||||
|
||||
+3
-3
@@ -138,10 +138,10 @@ local champGame = { save = { flags = {} } }
|
||||
champ.onEnter(champGame, champOw)
|
||||
eq(#champQueued, 2, "CHAMPIONS_ROOM.onEnter queues entrance walk + rival script")
|
||||
local walk = champQueued[1] and champQueued[1].script
|
||||
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 1
|
||||
check(walk and walk[1][1] == "move_player" and walk[1][2] == "up" and walk[1][3] == 3
|
||||
and walk[2][1] == "move_player" and walk[2][2] == "right" and walk[2][3] == 1
|
||||
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 3,
|
||||
"entrance walk is RivalEntrance_RLEMovement (up 1, right 1, up 3)")
|
||||
and walk[3][1] == "move_player" and walk[3][2] == "up" and walk[3][3] == 1,
|
||||
"entrance walk is RivalEntrance_RLEMovement (up 3, right 1, up 1)")
|
||||
check(champQueued[2] and champQueued[2].script == rows,
|
||||
"second queue is TEXT_CHAMPIONSROOM_RIVAL script rows")
|
||||
check(champQueued[2] and champQueued[2].extra
|
||||
|
||||
@@ -94,13 +94,14 @@ local wild = BattleState.newWild(game, "RATTATA", 2)
|
||||
wild.onFinish = function() end
|
||||
wild:enter()
|
||||
|
||||
eq(wild.introBalls, true, "the wild intro opens the DrawAllPokeballs window")
|
||||
-- introBalls is a plain field, not a queue row, so it cannot displace the
|
||||
-- wild cry PrintBeginningBattleText plays before PrintText (#303)
|
||||
check(wild.introBalls ~= true, "the DrawAllPokeballs window is not open at enter")
|
||||
-- engine/battle/common_text.asm:11
|
||||
check(wild.queue[1] and wild.queue[1].fn ~= nil,
|
||||
"the cry act is still the first queue row (#303)")
|
||||
check(wild.queue[2] and wild.queue[2].text == wild.introText,
|
||||
"the intro text is still the second queue row")
|
||||
check(wild.queue[2] and wild.queue[2].fn ~= nil,
|
||||
"DrawAllPokeballs is the second queue row")
|
||||
check(wild.queue[3] and wild.queue[3].text == wild.introText,
|
||||
"the intro text is the third queue row")
|
||||
|
||||
-- let the silhouette slide land so the intro box is genuinely up (the slide
|
||||
-- now runs 80 frames at 2px/frame, matching the original ~2px/frame)
|
||||
@@ -174,8 +175,9 @@ local game2 = makeGame({ Pokemon.new(Data, "BULBASAUR", 50) })
|
||||
local tr = BattleState.newTrainer(game2, "OPP_YOUNGSTER", 1)
|
||||
tr.onFinish = function() end
|
||||
tr:enter()
|
||||
check(tr.introBalls ~= true, "the trainer window is not open at enter")
|
||||
for _ = 1, 140 do tr:update(1 / 60) end
|
||||
eq(tr.introBalls, true, "the trainer intro opens the same window")
|
||||
for _ = 1, 85 do tr:update(1 / 60) end
|
||||
|
||||
local ok3, err3, rows3 = snapshotHUD(tr)
|
||||
check(ok3, "drawHUDs runs during the trainer intro: " .. tostring(err3))
|
||||
|
||||
@@ -20,18 +20,21 @@ local CASES = {
|
||||
after = "_LoreleisRoomLoreleiAfterBattleText",
|
||||
headerMap = "LoreleisRoom", event = "EVENT_BEAT_LORELEIS_ROOM_TRAINER_0",
|
||||
npcName = "LORELEISROOM_LORELEI", class = "OPP_LORELEI",
|
||||
closed = 0x24, open = 0x05,
|
||||
},
|
||||
{
|
||||
map = "BRUNOS_ROOM", text = "TEXT_BRUNOSROOM_BRUNO",
|
||||
after = "_BrunoAfterBattleText",
|
||||
headerMap = "BrunosRoom", event = "EVENT_BEAT_BRUNOS_ROOM_TRAINER_0",
|
||||
npcName = "BRUNOSROOM_BRUNO", class = "OPP_BRUNO",
|
||||
closed = 0x24, open = 0x05,
|
||||
},
|
||||
{
|
||||
map = "AGATHAS_ROOM", text = "TEXT_AGATHASROOM_AGATHA",
|
||||
after = "_AgathaAfterBattleText",
|
||||
headerMap = "AgathasRoom", event = "EVENT_BEAT_AGATHAS_ROOM_TRAINER_0",
|
||||
npcName = "AGATHASROOM_AGATHA", class = "OPP_AGATHA",
|
||||
closed = 0x3b, open = 0x0e,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -64,23 +67,45 @@ for _, c in ipairs(CASES) do
|
||||
facePlayer = function() end,
|
||||
}
|
||||
local engaged = false
|
||||
local blocks = {}
|
||||
local blocksAtPush
|
||||
game.stack.push = function(_, state)
|
||||
pushed[#pushed + 1] = state
|
||||
blocksAtPush = blocksAtPush or #blocks
|
||||
end
|
||||
local ow = {
|
||||
player = { cellY = 5 },
|
||||
trainerDefeated = function(_, n)
|
||||
return game.save.defeatedTrainers[n.id] == true
|
||||
end,
|
||||
engageTrainer = function(_, n, onDone)
|
||||
replaceBlock = function(_, bx, by, id)
|
||||
blocks[#blocks + 1] = { bx, by, id }
|
||||
end,
|
||||
engageTrainer = function(self, n, onDone)
|
||||
engaged = n == npc
|
||||
game.save.defeatedTrainers[n.id] = true
|
||||
game.save.flags[c.event] = true
|
||||
local hooks = mapScripts.get(c.map)
|
||||
if hooks and hooks.onVictory then hooks.onVictory(game, self) end
|
||||
if onDone then onDone() end
|
||||
end,
|
||||
}
|
||||
|
||||
talk(game, ow, npc, function() end)
|
||||
local finished = false
|
||||
talk(game, ow, npc, function() finished = true end)
|
||||
check(engaged, c.map .. " engages on first talk")
|
||||
check(#pushed == 1 and pushed[1].pages ~= nil,
|
||||
c.map .. " win pushes after-battle TextBox")
|
||||
check(#pushed[1].pages > 0, c.map .. " after-battle TextBox has pages")
|
||||
eq(blocksAtPush, 0, c.map .. " exit stays shut while after-battle text is up")
|
||||
check(not finished, c.map .. " talk not done before after-battle text closes")
|
||||
pushed[1].onDone()
|
||||
eq(#blocks, 1, c.map .. " exit block swapped once after the text")
|
||||
local b = blocks[1] or {}
|
||||
check(b[1] == 2 and b[2] == 0 and b[3] == c.open,
|
||||
c.map .. " exit opens at block (2,0) after the text")
|
||||
check(finished, c.map .. " talk done after after-battle text closes")
|
||||
game.stack.push = function(_, state) pushed[#pushed + 1] = state end
|
||||
|
||||
-- loss: no after text
|
||||
pushed, engaged = {}, false
|
||||
@@ -104,6 +129,17 @@ for _, c in ipairs(CASES) do
|
||||
talk(game, ow, npc, function() end)
|
||||
check(#pushed == 1 and pushed[1].pages ~= nil,
|
||||
c.map .. " defeated re-talk shows after text")
|
||||
|
||||
local hooks = mapScripts.get(c.map)
|
||||
check(hooks.onVictory == nil, c.map .. " has no onVictory door swap")
|
||||
blocks = {}
|
||||
game.save.flags = { [c.event] = true }
|
||||
hooks.onEnter(game, ow)
|
||||
check(blocks[1] and blocks[1][3] == c.open, c.map .. " reload after win opens exit")
|
||||
blocks = {}
|
||||
game.save.flags = {}
|
||||
hooks.onEnter(game, ow)
|
||||
check(blocks[1] and blocks[1][3] == c.closed, c.map .. " reload before win keeps exit shut")
|
||||
end
|
||||
|
||||
S.finish()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user