mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
CLOSES #1396, CLOSES #1398, CLOSES #1400, CLOSES #1401, CLOSES #1406, CLOSES #1407, CLOSES #1411, CLOSES #1413, CLOSES #1415, CLOSES #1416, CLOSES #1417, CLOSES #1419, CLOSES #1421, CLOSES #1422, CLOSES #1423, CLOSES #1424, CLOSES #1425, CLOSES #1427, CLOSES #1428, CLOSES #1429, CLOSES #1431, CLOSES #1432, CLOSES #1433, CLOSES #1435, CLOSES #1437, CLOSES #1440, CLOSES #1441, CLOSES #1442, CLOSES #1443, CLOSES #1447, CLOSES #1449, CLOSES #1456, CLOSES #1464, CLOSES #1465, CLOSES #1468, CLOSES #1469, CLOSES #1470
This commit is contained in:
@@ -702,6 +702,23 @@ M.MT_MOON_B2F = {
|
||||
return false
|
||||
end,
|
||||
talk = {
|
||||
-- MtMoonB2FSuperNerdText: once beaten his line turns on the fossils
|
||||
-- (scripts/MtMoonB2F.asm:187), which the header's flat `after` can't hold
|
||||
TEXT_MTMOONB2F_SUPER_NERD = function(game, ow, npc, done)
|
||||
if not superNerdBeaten(ow) then
|
||||
engageSuperNerd(game, ow, done)
|
||||
return
|
||||
end
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local t = game.data.text
|
||||
local flags = game.save.flags
|
||||
local line = (flags.EVENT_GOT_DOME_FOSSIL or flags.EVENT_GOT_HELIX_FOSSIL)
|
||||
and (t._MtMoonB2FSuperNerdTheresAPokemonLabText
|
||||
or "Far away, on\nCINNABAR ISLAND,\nthere's a POKéMON\nLAB.")
|
||||
or (t._MtMoonB2fSuperNerdEachTakeOneText
|
||||
or "We'll each take\none!\nNo being greedy!")
|
||||
game.stack:push(TextBox.new(game, line, done))
|
||||
end,
|
||||
TEXT_MTMOONB2F_DOME_FOSSIL = mtMoonFossil(
|
||||
"DOME_FOSSIL", "MTMOONB2F_HELIX_FOSSIL", "EVENT_GOT_DOME_FOSSIL"),
|
||||
TEXT_MTMOONB2F_HELIX_FOSSIL = mtMoonFossil(
|
||||
|
||||
+30
-27
@@ -118,33 +118,36 @@ M.ROUTE_15_GATE_2F = {
|
||||
|
||||
M.MT_MOON_POKECENTER = {
|
||||
talk = {
|
||||
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = function(game, ow, npc, done)
|
||||
local t = text(game)
|
||||
if game.save.flags.EVENT_BOUGHT_MAGIKARP then
|
||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoRefundsText
|
||||
or "Well, I don't\ngive refunds!", done)
|
||||
return
|
||||
end
|
||||
ask(game, t._MtMoonPokecenterMagikarpSalesmanOfferText
|
||||
or "MAGIKARP! A\nsteal at ¥500!\nWant one?", function(yes)
|
||||
if not yes then
|
||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoText
|
||||
or "No? I'm only\nselling today!", done)
|
||||
return
|
||||
end
|
||||
if game.save.money < 500 then
|
||||
push(game, t._MtMoonPokecenterMagikarpSalesmanNoMoneyText
|
||||
or "You'll need more\nmoney than that!", done)
|
||||
return
|
||||
end
|
||||
game.save.money = game.save.money - 500
|
||||
game.save.flags.EVENT_BOUGHT_MAGIKARP = true
|
||||
local Commands = require("src.script.Commands")
|
||||
Commands.give_pokemon({ save = game.save, game = game, overworld = ow },
|
||||
"MAGIKARP", 5)
|
||||
push(game, t._GotMonText or "{PLAYER} got\n{RAM:wNameBuffer}!", done)
|
||||
end)
|
||||
end,
|
||||
-- command rows, not a Lua handler: give_pokemon needs a runner to AskName (#1407)
|
||||
TEXT_MTMOONPOKECENTER_MAGIKARP_SALESMAN = {
|
||||
{ "check_flag", "EVENT_BOUGHT_MAGIKARP" },
|
||||
{ "jump_if_true", "no_refunds" },
|
||||
-- MONEY_BOX goes up between the offer and YesNoChoice -- MtMoonPokecenter.asm:31
|
||||
{ "text_opts", { money = true } },
|
||||
{ "ask", "_MtMoonPokecenterMagikarpSalesmanIGotADealText" },
|
||||
{ "jump_if_false", "declined" },
|
||||
{ "check_money", 500 },
|
||||
{ "jump_if_false", "no_money" },
|
||||
{ "give_pokemon", "MAGIKARP", 5 },
|
||||
-- MtMoonPokecenter.asm:49 `jr nc, .done`: a refused gift is never charged
|
||||
{ "jump_if_false", "box_full" },
|
||||
{ "take_money", 500 },
|
||||
{ "set_flag", "EVENT_BOUGHT_MAGIKARP" },
|
||||
{ "text_sound", "Get_Item1" },
|
||||
{ "show_text", "_GotMonText", { RAM = "MAGIKARP" } },
|
||||
{ "jump", "end" },
|
||||
{ "label", "box_full" },
|
||||
{ "show_text", "_BoxIsFullText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "declined" },
|
||||
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "no_money" },
|
||||
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoMoneyText" },
|
||||
{ "jump", "end" },
|
||||
{ "label", "no_refunds" },
|
||||
{ "show_text", "_MtMoonPokecenterMagikarpSalesmanNoRefundsText" },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+76
-43
@@ -24,6 +24,7 @@ local Runtime = require("src.mods.Runtime")
|
||||
local BattleSafety = require("src.battle.BattleSafety")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Status = require("src.battle.Status")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Timing = require("src.core.Timing")
|
||||
local TrainerAI = require("src.battle.TrainerAI")
|
||||
local TurnOrder = require("src.battle.TurnOrder")
|
||||
@@ -983,8 +984,8 @@ end
|
||||
|
||||
-- Message that opens YES/NO once typed out, keeping the text visible
|
||||
-- underneath (pokered `done` + TWO_OPTION_MENU / TextBox opts.choice).
|
||||
function BattleState:sayChoice(text, onChoose)
|
||||
table.insert(self.queue, { text = text, choice = onChoose })
|
||||
function BattleState:sayChoice(text, onChoose, opts)
|
||||
table.insert(self.queue, { text = text, choice = onChoose, choiceOpts = opts })
|
||||
end
|
||||
|
||||
function BattleState:act(fn)
|
||||
@@ -1485,7 +1486,7 @@ function BattleState:updateQueue()
|
||||
local fn = item.choice
|
||||
battle.current = nil
|
||||
fn(yes)
|
||||
end))
|
||||
end, item.choiceOpts))
|
||||
return true
|
||||
end
|
||||
if item and item.auto then
|
||||
@@ -1843,14 +1844,7 @@ function BattleState:enter()
|
||||
self:say(self:sendOutText(self.player.name))
|
||||
-- then the POOF plays and the mon appears with its cry
|
||||
-- (SendOutMon: message -> AnimateSendingOutMon -> PlayCry)
|
||||
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
|
||||
self:act(function()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): after the poof the mon grows
|
||||
-- out of the ball (AnimateSendingOutMon at hlcoord 4,11)
|
||||
self:startGrowIn(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:queueSendOutAnim(true)
|
||||
self:markParticipant()
|
||||
end
|
||||
self.phase = "messages"
|
||||
@@ -2680,13 +2674,7 @@ function BattleState:resolveSwitch(newMon)
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
self:act(function()
|
||||
self:executeAction(self.enemy, self.player, self:enemyAction())
|
||||
@@ -3231,6 +3219,50 @@ function BattleState:startGrowIn(battler)
|
||||
table.insert(self.queue, self.nextInsert, { wait = 12 })
|
||||
end
|
||||
|
||||
-- SendOutMon branches on IsThisPartyMonStarterPikachu before the animation:
|
||||
-- the starter gets no ball and no grow-in -- pokeyellow core.asm:1798-1819
|
||||
function BattleState:starterPikachuSendOut()
|
||||
if not require("src.core.GameVersion").isYellow() then return false end
|
||||
local mon = self.player and self.player.mon
|
||||
return require("src.world.PikachuFollower")
|
||||
.isStarterPikachu(self.game.save, mon)
|
||||
end
|
||||
|
||||
-- StarterPikachuBattleEntranceAnimation: the back pic walks in from hlcoord
|
||||
-- 0,5, one column every 2 frames -- engine/battle/pikachu_entrance_anim.asm:1
|
||||
function BattleState:startPikachuEntrance()
|
||||
self:slidePic("playerMon", -56, 0, 8, 2)
|
||||
self.nextInsert = (self.nextInsert or 0) + 1
|
||||
table.insert(self.queue, self.nextInsert, { wait = 16 })
|
||||
self:actNext(function()
|
||||
self:slidePic("playerMon")
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
end
|
||||
|
||||
-- Player send-out tail: POOF_ANIM + AnimateSendingOutMon (core.asm:1757-1762),
|
||||
-- or the starter Pikachu entrance instead (pokeyellow core.asm:1798-1819)
|
||||
function BattleState:queueSendOutAnim(append)
|
||||
local pikachu = self:starterPikachuSendOut()
|
||||
if not pikachu then
|
||||
if append then
|
||||
table.insert(self.queue, { anim = "POOF_ANIM", attackerIsPlayer = false })
|
||||
else
|
||||
self:animNext("POOF_ANIM", false)
|
||||
end
|
||||
end
|
||||
local fn = function()
|
||||
self.sendingOut = false
|
||||
if pikachu then
|
||||
self:startPikachuEntrance()
|
||||
else
|
||||
self:startGrowIn(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end
|
||||
end
|
||||
if append then self:act(fn) else self:actNext(fn) end
|
||||
end
|
||||
|
||||
-- Should the low-health alarm sound this frame? pokered keys it off
|
||||
-- the drawn bar color: DrawPlayerHUDAndHPBar (core.asm:1846-1875) sets
|
||||
-- wLowHealthAlarm bit 7 when GetHealthBarColor says the player bar is
|
||||
@@ -3289,17 +3321,18 @@ end
|
||||
-- _ScrollTrainerPicAfterBattle (engine/battle/scroll_draw_trainer_pic.asm)
|
||||
-- brings the beaten foe back in from the right one column every 4 frames.
|
||||
-- picOff holds the live programs by slot -- "foe" = the enemy trainer pic,
|
||||
-- "back" = the player's back pic -- as a screen-pixel x offset stepped
|
||||
-- toward `to`; updateFx advances them, drawPicsLayer adds them, and the
|
||||
-- queue rows that start them park a { wait } of the matching length. Call
|
||||
-- with no target to clear a slot (#317, #282).
|
||||
function BattleState:slidePic(slot, from, to, step)
|
||||
-- "back" = the player's back pic, "playerMon" = the player's mon back pic --
|
||||
-- as a screen-pixel x offset stepped toward `to`; updateFx advances them,
|
||||
-- drawPicsLayer adds them, and the queue rows that start them park a
|
||||
-- { wait } of the matching length. Call with no target to clear
|
||||
-- a slot (#317, #282).
|
||||
function BattleState:slidePic(slot, from, to, step, hold)
|
||||
self.picOff = self.picOff or {}
|
||||
if to == nil then
|
||||
self.picOff[slot] = nil
|
||||
return
|
||||
end
|
||||
self.picOff[slot] = { x = from or 0, to = to, step = step or 4 }
|
||||
self.picOff[slot] = { x = from or 0, to = to, step = step or 4, hold = hold }
|
||||
end
|
||||
|
||||
-- the live x offset for a pic slot, 0 when nothing is sliding
|
||||
@@ -3317,10 +3350,18 @@ function BattleState:updateFx()
|
||||
-- the battle slot) until its owner clears the slot
|
||||
if self.picOff then
|
||||
for _, p in pairs(self.picOff) do
|
||||
if p.x < p.to then
|
||||
p.x = math.min(p.to, p.x + p.step)
|
||||
elseif p.x > p.to then
|
||||
p.x = math.max(p.to, p.x - p.step)
|
||||
local move = true
|
||||
if p.hold then
|
||||
p.held = (p.held or 0) + 1
|
||||
move = p.held >= p.hold
|
||||
if move then p.held = 0 end
|
||||
end
|
||||
if move then
|
||||
if p.x < p.to then
|
||||
p.x = math.min(p.to, p.x + p.step)
|
||||
elseif p.x > p.to then
|
||||
p.x = math.max(p.to, p.x - p.step)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4231,7 +4272,7 @@ function BattleState:enemyMonFainted()
|
||||
end
|
||||
end,
|
||||
})
|
||||
end)
|
||||
end, { box = Theme.trainerSwitchBox })
|
||||
end
|
||||
self:act(function()
|
||||
local previous = self.enemy
|
||||
@@ -4295,12 +4336,7 @@ function BattleState:enemyMonFainted()
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
self:startGrowIn(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:queueSendOutAnim(false)
|
||||
end)
|
||||
return
|
||||
end
|
||||
@@ -4491,13 +4527,7 @@ function BattleState:openReplacementMenu()
|
||||
sendOutMonCursors(self)
|
||||
self.sendingOut = true
|
||||
self:sayNext(self:sendOutText(self.player.name))
|
||||
self:animNext("POOF_ANIM", false)
|
||||
self:actNext(function()
|
||||
self.sendingOut = false
|
||||
-- SendOutMon (core.asm:1757-1762): poof, then the grow-in
|
||||
self:startGrowIn(self.player)
|
||||
self:waitSfxNext(self:playEntranceCry(self.player))
|
||||
end)
|
||||
self:queueSendOutAnim(false)
|
||||
end,
|
||||
})
|
||||
end)
|
||||
@@ -5749,7 +5779,10 @@ function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
else
|
||||
local dx, dy = BattleState.backPlacement(img:getWidth(),
|
||||
img:getHeight(), pad, padL, s)
|
||||
self:drawBattlerPic(self.player, dx + sx, dy + sy, s)
|
||||
-- picOffset: StarterPikachuBattleEntranceAnimation walking the pic in
|
||||
-- from the left -- engine/battle/pikachu_entrance_anim.asm:1
|
||||
self:drawBattlerPic(self.player, dx + sx + self:picOffset("playerMon"),
|
||||
dy + sy, s)
|
||||
end
|
||||
end
|
||||
if clipped then
|
||||
|
||||
@@ -243,8 +243,9 @@ end
|
||||
|
||||
C.incbgeffect = function(self, row) self.bg:incEffect(row[2]) end
|
||||
|
||||
C.battlergfx_1row = function(self) self:loadBattlerGfx(1) end
|
||||
C.battlergfx_2row = function(self) self:loadBattlerGfx(2) end
|
||||
-- anim_commands.asm:317: $d9 (anim_battlergfx_2row) dispatches to _1Row
|
||||
C.battlergfx_1row = function(self) self:loadBattlerGfx(2) end
|
||||
C.battlergfx_2row = function(self) self:loadBattlerGfx(1) end
|
||||
|
||||
-- GetPokeBallWobble's answer, which the ball's own script then branches on.
|
||||
C.checkpokeball = function(self)
|
||||
|
||||
+24
-16
@@ -3363,25 +3363,33 @@ function Battle:awardExperience(loser)
|
||||
-- has no EXP.ALL (the EXP.SHARE pass below is its replacement), so it is
|
||||
-- accepted and ignored rather than changing what is printed. `recipients`,
|
||||
-- `holders` and `halved` are the Gen 2 additions.
|
||||
if not Runtime.wantsHook("battle.exp_award") then return vanillaAward() end
|
||||
local alive = {}
|
||||
for _, index in ipairs(participants) do
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||
end
|
||||
local function applyShare(mon, split)
|
||||
for index, candidate in ipairs(self.party) do
|
||||
if candidate == mon then
|
||||
return self:giveExperiencePass(loser, def, { index },
|
||||
math.max(1, split or 1), halved)
|
||||
if Runtime.wantsHook("battle.exp_award") then
|
||||
local alive = {}
|
||||
for _, index in ipairs(participants) do
|
||||
local mon = self.party[index]
|
||||
if mon and (mon.hp or 0) > 0 then alive[#alive + 1] = mon end
|
||||
end
|
||||
local function applyShare(mon, split)
|
||||
for index, candidate in ipairs(self.party) do
|
||||
if candidate == mon then
|
||||
return self:giveExperiencePass(loser, def, { index },
|
||||
math.max(1, split or 1), halved)
|
||||
end
|
||||
end
|
||||
end
|
||||
Runtime.call("battle.exp_award", vanillaAward, {
|
||||
battle = self, participants = #participants, alive = alive,
|
||||
applyShare = applyShare, recipients = participants, holders = holders,
|
||||
halved = halved, loser = loser,
|
||||
})
|
||||
else
|
||||
vanillaAward()
|
||||
end
|
||||
Runtime.call("battle.exp_award", vanillaAward, {
|
||||
battle = self, participants = #participants, alive = alive,
|
||||
applyShare = applyShare, recipients = participants, holders = holders,
|
||||
halved = halved, loser = loser,
|
||||
})
|
||||
|
||||
-- GiveExperiencePoints .done falls through ResetBattleParticipants into
|
||||
-- AddBattleParticipant (engine/battle/core.asm:7116 and :3033).
|
||||
self.participants = {}
|
||||
if self.playerIndex then self.participants[self.playerIndex] = true end
|
||||
end
|
||||
|
||||
-- The answer to a `choose-forget`: drop the move in `slot` and put the
|
||||
|
||||
@@ -347,14 +347,13 @@ function Catching.attempt(opts)
|
||||
-- that edits o.catchRate or o.status changes the roll exactly as it does
|
||||
-- on Red, and one that returns `caught, rate` replaces it outright.
|
||||
--
|
||||
-- `mon` and `def` are whatever the catch site supplied. Gold's battle
|
||||
-- screen (src/ui/gen2/BattleState.lua) hands this module a FLAT opts table
|
||||
-- -- hp, maxHp, catchRate, status, species -- rather than the mon and its
|
||||
-- record, so both are nil there until it passes them; nil, not a stand-in
|
||||
-- a mod would read as the real mon. Passing `battle = self.battle` and
|
||||
-- `mon = enemy` with them is what puts the capture tail below on the real
|
||||
-- catch: the Transform reload and the battle.catch_exp hook both hang off
|
||||
-- the battle, and neither can be reached from a flat table.
|
||||
-- `mon` and `def` are whatever the catch site supplied, and nil rather
|
||||
-- than a stand-in a mod would read as the real mon when it supplied
|
||||
-- neither. Gold's battle screen (src/ui/gen2/BattleState.lua) passes them
|
||||
-- with `battle` alongside the flat hp/maxHp/catchRate/status fields, which
|
||||
-- is what puts the capture tail below on the real catch: the Transform
|
||||
-- reload and the battle.catch_exp hook both hang off the battle, and
|
||||
-- neither can be reached from a flat table.
|
||||
caught, rate = Runtime.call("catch.rate", function(_, _, _, o)
|
||||
return Catching.vanillaAttempt(o)
|
||||
end, opts.ball or "POKE_BALL", opts.mon, opts.def, opts)
|
||||
|
||||
@@ -939,6 +939,8 @@ function Game2:load()
|
||||
self.data.gen2Maps = loadGenerated("data/generated/maps.lua")
|
||||
self.data.gen2Tilesets = loadGenerated("data/generated/tilesets.lua")
|
||||
self.data.gen2Roofs = loadGenerated("data/generated/roofs.lua")
|
||||
-- engine/events/magnet_train.asm:165 DrawMagnetTrain
|
||||
self.data.gen2Field = loadGenerated("data/generated/field.lua")
|
||||
self.data.gen2Marts = loadGenerated("data/generated/marts.lua")
|
||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||
@@ -1965,6 +1967,7 @@ function Game2:applyOptions()
|
||||
haptics = options.haptics,
|
||||
})
|
||||
require("src.core.VideoMode").applyOptions(options)
|
||||
require("src.core.FrameCap").applyOptions(options)
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
if GBCFX.applyOptions(options) and self.save then
|
||||
-- applyOptions returns true when it had to clear an unsupported level.
|
||||
|
||||
+17
-5
@@ -6,11 +6,14 @@ local HostShell = {}
|
||||
-- spawn tries to link against the libraries we're shipping instead of the
|
||||
-- system ones. We want to unset the var so that any system tools can find
|
||||
-- their proper libraries. Only needed when running in an AppImage.
|
||||
-- LD_PRELOAD is Steam's overlay (#1470): its 32-bit half cannot load into a
|
||||
-- 64-bit child, so ld.so prints an error into that child's output.
|
||||
function HostShell.envPrefix()
|
||||
if os.getenv("APPIMAGE") then
|
||||
return "env -u LD_LIBRARY_PATH "
|
||||
end
|
||||
return ""
|
||||
local unset = ""
|
||||
if os.getenv("APPIMAGE") then unset = unset .. "-u LD_LIBRARY_PATH " end
|
||||
if os.getenv("LD_PRELOAD") then unset = unset .. "-u LD_PRELOAD " end
|
||||
if unset == "" then return "" end
|
||||
return "env " .. unset
|
||||
end
|
||||
|
||||
-- Windows: every host tool we shell out to (curl for the update and mod-index
|
||||
@@ -222,11 +225,20 @@ end
|
||||
local HTTP_MARK = "\n__gen1recomp_http__"
|
||||
local HTTP_MARK_FMT = "\\n__gen1recomp_http__%{http_code}"
|
||||
|
||||
local function stripLoaderNoise(out)
|
||||
while out:find("^ERROR: ld%.so:") do
|
||||
local nl = out:find("\n", 1, true)
|
||||
if not nl then return "" end
|
||||
out = out:sub(nl + 1)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Split a curl pipe's output into (body, status, noise). `status` is nil
|
||||
-- when curl never got far enough to have one (DNS failure, no route, a
|
||||
-- timeout), in which case `noise` carries curl's own complaint.
|
||||
local function splitCurlOutput(out)
|
||||
out = tostring(out or "")
|
||||
out = stripLoaderNoise(tostring(out or ""))
|
||||
local at = nil
|
||||
local from = 1
|
||||
while true do
|
||||
|
||||
@@ -666,11 +666,10 @@ function SaveData.setModEnabled(options, id, enabled, version)
|
||||
options.modsByVersion = options.modsByVersion or {}
|
||||
local bucket = options.modsByVersion[version] or {}
|
||||
options.modsByVersion[version] = bucket
|
||||
-- no shared flag reads as enabled, the same default the loader applies to a
|
||||
-- missing entry, so a fresh install never fills the overlay with agreement
|
||||
-- with no shared flag the default is the caller's (experimental mods read as
|
||||
-- disabled), so the answer is stored outright rather than judged against one
|
||||
local shared = options.mods and options.mods[id]
|
||||
if type(shared) ~= "boolean" then shared = true end
|
||||
if shared == enabled then
|
||||
if type(shared) == "boolean" and shared == enabled then
|
||||
bucket[id] = nil
|
||||
else
|
||||
bucket[id] = enabled
|
||||
|
||||
@@ -101,6 +101,14 @@ function Boxes.canDeposit(save, partyIndex, boxIndex)
|
||||
return true
|
||||
end
|
||||
|
||||
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon.asm:711-773): every
|
||||
-- slot back to GetMaxPPOfMove, which already carries its own PP Up count.
|
||||
function Boxes.restorePP(mon)
|
||||
for _, move in ipairs((mon and mon.moves) or {}) do
|
||||
if type(move) == "table" then move.pp = move.maxPp or move.pp end
|
||||
end
|
||||
end
|
||||
|
||||
function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
local ok, reason = Boxes.canDeposit(save, partyIndex, boxIndex)
|
||||
if not ok then return false, reason end
|
||||
@@ -112,6 +120,9 @@ function Boxes.deposit(save, partyIndex, boxIndex)
|
||||
Mail.removeSlot(save, partyIndex)
|
||||
local box = Boxes.box(save, boxIndex)
|
||||
box[#box + 1] = mon
|
||||
-- SendGetMonIntoFromBox's PC_DEPOSIT arm ends in RestorePPOfDepositedPokemon
|
||||
-- (engine/pokemon/move_mon.asm:633-635, :696-700).
|
||||
Boxes.restorePP(mon)
|
||||
return true, mon
|
||||
end
|
||||
|
||||
@@ -129,6 +140,15 @@ function Boxes.withdraw(save, boxIndex, slot)
|
||||
local ok, reason = Boxes.canWithdraw(save, boxIndex, slot)
|
||||
if not ok then return false, reason end
|
||||
local mon = table.remove(Boxes.box(save, boxIndex), slot)
|
||||
-- The `get mon into Party` arm alone heals: status cleared and MON_MAXHP
|
||||
-- copied over MON_HP, an egg's staying 0 (move_mon.asm:666-693).
|
||||
mon.status = nil
|
||||
mon.statusTurns = nil
|
||||
if mon.isEgg then
|
||||
mon.hp = 0
|
||||
else
|
||||
mon.hp = mon.maxHp or mon.hp
|
||||
end
|
||||
save.party = save.party or {}
|
||||
save.party[#save.party + 1] = mon
|
||||
return true, mon
|
||||
@@ -142,6 +162,16 @@ function Boxes.release(save, boxIndex, slot)
|
||||
return true, table.remove(box, slot)
|
||||
end
|
||||
|
||||
-- RELEASE off the DEPOSIT screen, whose list is the party: it is
|
||||
-- RemoveMonFromPartyOrBox's REMOVE_PARTY arm (bills_pc.asm:204-207).
|
||||
function Boxes.releaseFromParty(save, slot)
|
||||
local party = (save and save.party) or {}
|
||||
if not party[slot] then return false, "There is no POKéMON there." end
|
||||
local mon = table.remove(party, slot)
|
||||
Mail.removeSlot(save, slot)
|
||||
return true, mon
|
||||
end
|
||||
|
||||
-- Move a boxed mon to another box (MOVE PKMN W/O MAIL's box-to-box case).
|
||||
function Boxes.move(save, fromBox, slot, toBox)
|
||||
if fromBox == toBox then return false, "It's already there." end
|
||||
|
||||
@@ -159,6 +159,13 @@ function NpcTrade.perform(data, save, row, index)
|
||||
-- that slot inheriting it (src/core/gen2/Mail.lua).
|
||||
Mail.removeSlot(save, index)
|
||||
party[#party + 1] = received
|
||||
-- TryAddMonToParty's SetSeenAndCaughtMon (engine/pokemon/move_mon.asm:196),
|
||||
-- which the `predef` at engine/events/npc_trade.asm:168 runs like any other.
|
||||
save.pokedex = save.pokedex or {}
|
||||
save.pokedex.seen = save.pokedex.seen or {}
|
||||
save.pokedex.caught = save.pokedex.caught or {}
|
||||
save.pokedex.seen[received.species] = true
|
||||
save.pokedex.caught[received.species] = true
|
||||
return given, received
|
||||
end
|
||||
|
||||
|
||||
@@ -273,6 +273,7 @@ Save.DEFAULT_OPTIONS = {
|
||||
-- means something different, hence the different name.
|
||||
color = "gbc",
|
||||
videoMode = "windowed",
|
||||
fpsCap = 60,
|
||||
musicVol = 7, -- 0-7, like the GB's NR50 master volume
|
||||
sfxVol = 7, -- 0-7
|
||||
musicFilter = 0, -- low-pass steps, 0 = off
|
||||
|
||||
@@ -638,6 +638,16 @@ local function gen2Rows(opts, hooks)
|
||||
end)
|
||||
end
|
||||
|
||||
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
|
||||
if okCap then
|
||||
add(Strings("MAX FPS"),
|
||||
function() return FrameCap.label(opts.fpsCap) end,
|
||||
function(dir)
|
||||
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
|
||||
return true
|
||||
end)
|
||||
end
|
||||
|
||||
addTouchRows(rows, add, opts, hooks)
|
||||
|
||||
return rows
|
||||
|
||||
@@ -49,6 +49,9 @@ local MAP_GROUP_COUNT = 26 -- constants/map_constants.asm NUM_MAP_GROUPS
|
||||
-- EnvironmentColorsPointers, and the Tilesets row only stores a 16-bit
|
||||
-- pointer, so the bank has to come from here.
|
||||
local PAL_MAP_BANK = 0x02
|
||||
-- LoadBallIconGFX.gfx (engine/battle/trainer_huds.asm:225-232); bank $0b
|
||||
-- carries no manifest symbol to resolve it through.
|
||||
local BALL_ICON_GFX = { 0x0b, 0x41a4 }
|
||||
-- A tileset sheet is 96 tiles (128x48 at 8x8), and its PalMap packs two
|
||||
-- tiles per byte: low nibble first tile, high nibble second (`dn` in the
|
||||
-- tilepal macro). The high bit of each nibble is the VRAM bank, not colour.
|
||||
@@ -4912,6 +4915,14 @@ function RomExtractorGen2:extractMenuGfx()
|
||||
hud.expBarFirstTile = 0x55
|
||||
hud.expBarCells = 9
|
||||
|
||||
-- Four OAM tiles at $31 -- normal, statused, fainted, empty -- and OBJ
|
||||
-- colour 0 is transparent (engine/battle/trainer_huds.asm:47-99, :225-232).
|
||||
local balls = self.symbols["LoadBallIconGFX.gfx"] or BALL_ICON_GFX
|
||||
self:write2bpp(self.rom:bytes(balls[1], balls[2], 4 * 16), 32, 8,
|
||||
"battle/hud/balls.png", true)
|
||||
hud.balls = "assets/generated/battle/hud/balls.png"
|
||||
hud.ballsFirstTile = 0x31
|
||||
|
||||
-- "HP:" and the bar cells, as a plain 2bpp sheet rather than the ink-on-
|
||||
-- transparent font page: the bar's rule is shade 3 (black) while its fill is
|
||||
-- shade 1/2 (the HP colour), so flattening it to one ink loses the very
|
||||
|
||||
@@ -3594,7 +3594,17 @@ function RomImporter:_setAllMods(want, confirmed)
|
||||
local LauncherMods = require("src.mods.LauncherMods")
|
||||
local ids, experimental = {}, false
|
||||
for _, m in ipairs(self.mods or {}) do
|
||||
if m.enabled ~= want then
|
||||
local mismatched = m.enabled ~= want
|
||||
if not self.modScope and type(m.enabledByVersion) == "table" then
|
||||
mismatched = false
|
||||
for _, game in ipairs(GameVersion.ORDER) do
|
||||
if m.enabledByVersion[game] ~= want then
|
||||
mismatched = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if mismatched then
|
||||
ids[#ids + 1] = m.id
|
||||
if want and m.experimental then experimental = true end
|
||||
end
|
||||
|
||||
+31
-2
@@ -68,14 +68,20 @@ end
|
||||
|
||||
-- Acquisition-ordered id list (wBagItems). Rebuilt sorted once for
|
||||
-- saves from before the order existed, then maintained incrementally.
|
||||
function Bag.order(save)
|
||||
function Bag.order(save, data)
|
||||
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)
|
||||
table.sort(order, function(a, b)
|
||||
local ia = (items and items[a] and items[a].index) or math.huge
|
||||
local ib = (items and items[b] and items[b].index) or math.huge
|
||||
if ia ~= ib then return ia < ib end
|
||||
return a < b
|
||||
end)
|
||||
save.bagOrder = order
|
||||
end
|
||||
-- drop stale ids, append unknown ones (defensive against direct
|
||||
@@ -95,6 +101,29 @@ function Bag.order(save)
|
||||
return order
|
||||
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)
|
||||
local order = Bag.order(save, data)
|
||||
local slots, ids = {}, {}
|
||||
for i = 1, #order do
|
||||
if pocketOf(order[i], data) == pocket then
|
||||
slots[#slots + 1] = i
|
||||
ids[#ids + 1] = order[i]
|
||||
end
|
||||
end
|
||||
local from
|
||||
for i = 1, #ids do
|
||||
if ids[i] == id then from = i break end
|
||||
end
|
||||
if not from then return false end
|
||||
local to = math.max(1, math.min(math.floor(tonumber(toIndex) or from), #ids))
|
||||
if to == from then return false end
|
||||
table.insert(ids, to, table.remove(ids, from))
|
||||
for i = 1, #slots do order[slots[i]] = ids[i] end
|
||||
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).
|
||||
|
||||
@@ -26,6 +26,7 @@ local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
|
||||
-- up over the still-visible text (YesNoChoicePokeCenter and friends);
|
||||
-- the box then closes and choice(yes) runs instead of onDone.
|
||||
-- opts.defaultNo starts the cursor on NO.
|
||||
-- opts.choiceLabels / opts.choiceBox: data/yes_no_menu_strings.asm:16
|
||||
-- opts.auto: texts with no `prompt` (a text_asm/text_end tail, like
|
||||
-- _UsedStrengthText) never wait for a button: once the last page has
|
||||
-- typed out, auto.sound() runs (returning an audio source blocks like
|
||||
@@ -51,6 +52,8 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.choice = opts and opts.choice
|
||||
self.defaultNo = opts and opts.defaultNo
|
||||
self.choiceNoSound = opts and opts.noSound
|
||||
self.choiceLabels = opts and opts.choiceLabels
|
||||
self.choiceBox = opts and opts.choiceBox
|
||||
self.money = opts and opts.money
|
||||
self.auto = opts and opts.auto
|
||||
self.stay = opts and opts.stay
|
||||
@@ -340,6 +343,7 @@ function TextBox:update(dt)
|
||||
self.game.stack:pop() -- this text box, under the choice
|
||||
self.choice(yes)
|
||||
end, { defaultNo = self.defaultNo, noSound = self.choiceNoSound,
|
||||
labels = self.choiceLabels, box = self.choiceBox,
|
||||
-- this box is anchored below it; the pair moves together
|
||||
anchor = "bottom" }))
|
||||
end
|
||||
|
||||
@@ -134,6 +134,10 @@ function Commands.show_text(ctx, textId, subs, extraOpts)
|
||||
opts[k] = v
|
||||
end
|
||||
end
|
||||
-- MONEY_BOX (engine/menus/text_box.asm:133) reads the live wallet
|
||||
if opts and opts.money == true then
|
||||
opts.money = function() return ctx.save.money end
|
||||
end
|
||||
ctx.game.stack:push(TextBox.new(ctx.game, text, function()
|
||||
runner:resume()
|
||||
end, opts))
|
||||
@@ -780,6 +784,16 @@ function Commands.give_money(ctx, amount)
|
||||
ctx.save.money = math.max(0, ctx.save.money + amount)
|
||||
end
|
||||
|
||||
-- check_money <amount>: HasEnoughMoney (home/money.asm:1)
|
||||
function Commands.check_money(ctx, amount)
|
||||
ctx.lastCheck = (ctx.save.money or 0) >= (amount or 0)
|
||||
end
|
||||
|
||||
-- take_money <amount>: SubBCDPredef (engine/math/bcd.asm:193)
|
||||
function Commands.take_money(ctx, amount)
|
||||
ctx.save.money = math.max(0, (ctx.save.money or 0) - (amount or 0))
|
||||
end
|
||||
|
||||
-- Point LAST_MAP exits at an outdoor door (pokered wLastMap). Keeps the
|
||||
-- live overworld memory in sync so a scripted home warp from the HoF PC
|
||||
-- does not leave Red's house mats aimed at Indigo Plateau (#103).
|
||||
|
||||
+5
-4
@@ -300,7 +300,7 @@ local function vanillaUseOn(game, battle, id, target, list, moveIndex, picker)
|
||||
end
|
||||
list.index = math.min(list.index, math.max(1, #list.items))
|
||||
if extra and extra.evolveTo then
|
||||
list:close()
|
||||
-- engine/menus/start_sub_menus.asm:408 .useItem_partyMenu
|
||||
local Evolution = require("src.pokemon.Evolution")
|
||||
-- item_effects.asm ItemUseEvoStone sets wForceEvolution before
|
||||
-- TryEvolvingMon, so a stone evolution's B press is read and
|
||||
@@ -428,11 +428,12 @@ local function pickTargetAndUse(game, battle, id, list)
|
||||
-- TM/HM: open the party menu in Gen 1's TM/HM display mode so each mon
|
||||
-- shows ABLE / NOT ABLE from its learnset and the prompt reads "Use TM on
|
||||
-- which POKeMON?" (engine/items/item_effects.asm ItemUseTMHM ->
|
||||
-- party_menu.asm TM/HM type). Stones and other pickOnly items keep the
|
||||
-- plain HP layout (Gen 1 shows no ABLE/NOT ABLE for them), so gate
|
||||
-- strictly on def.machine. #210
|
||||
-- party_menu.asm TM/HM type). #210 Stones get the same ABLE / NOT ABLE
|
||||
-- column: ItemUseEvoStone sets EVO_STONE_PARTY_MENU (party_menu.asm:114).
|
||||
if def and def.machine then
|
||||
opts.tmhm = { move = def.machine.move, kind = def.machine.kind }
|
||||
elseif ItemEffects.isStone(id) then
|
||||
opts.evoStone = id
|
||||
end
|
||||
require("src.ui.Screens").push(game, "PartyMenu", opts)
|
||||
end
|
||||
|
||||
+10
-4
@@ -23,11 +23,15 @@ function ChoiceBox.new(game, onChoose, opts)
|
||||
-- and docking it to the window edge instead tears it off that screen by
|
||||
-- however far the letterbox sits from the edge.
|
||||
self.anchor = opts and opts.anchor or nil
|
||||
local box = Theme.choiceBox
|
||||
local box = (opts and opts.box) or Theme.choiceBox
|
||||
self.tx = (opts and opts.tx) or box.tx
|
||||
self.ty = (opts and opts.ty) or box.ty
|
||||
self.tw = (opts and opts.tw) or box.tw
|
||||
self.th = (opts and opts.th) or box.th
|
||||
-- TwoOptionMenuStrings rows carry their own labels and a "blank line
|
||||
-- before first menu item?" flag (data/yes_no_menu_strings.asm:8-16)
|
||||
self.labels = (opts and opts.labels) or { "YES", "NO" }
|
||||
self.firstItem = (opts and opts.firstItem) or box.firstItem or 1
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -81,10 +85,12 @@ function ChoiceBox:draw()
|
||||
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
|
||||
Font.drawBox(tx, ty, tw, th, paper)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
|
||||
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
|
||||
-- <NEXT> advances 2 * SCREEN_WIDTH -- home/text.asm:64
|
||||
local row = self.firstItem
|
||||
Font.draw(Strings(self.labels[1]), (tx + 2) * 8, (ty + row) * 8)
|
||||
Font.draw(Strings(self.labels[2]), (tx + 2) * 8, (ty + row + 2) * 8)
|
||||
Font.drawCode(Theme.cursor, (tx + 1) * 8,
|
||||
(ty + (self.index == 1 and 1 or 3)) * 8)
|
||||
(ty + row + (self.index == 1 and 0 or 2)) * 8)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
|
||||
+15
-8
@@ -293,23 +293,30 @@ function IntroMovie:update(dt)
|
||||
return
|
||||
end
|
||||
local input = self.game.input
|
||||
if input:wasPressed("a") or input:wasPressed("start") then
|
||||
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
||||
-- on a fresh START or A -- B alone never skips the intro.
|
||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||
-- white hold stands in for that beat before the title is built.
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
-- CheckForUserInterruption (home/overworld.asm:2395) returns carry only
|
||||
-- on a fresh START or A -- B alone never skips the intro.
|
||||
local skip = input:wasPressed("a") or input:wasPressed("start")
|
||||
self.timer = self.timer + 1
|
||||
if self.phase == 1 then
|
||||
-- the copyright card is a bare DelayFrames, deaf to input (intro.asm:311)
|
||||
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
|
||||
elseif self.phase == 2 then
|
||||
if self.timer == STAR_START then
|
||||
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
|
||||
end
|
||||
-- intro.asm:325 `jr c, .next`
|
||||
if skip and self.timer >= STAR_START and self.timer < WAVES_END then
|
||||
self:startPhase(3)
|
||||
return
|
||||
end
|
||||
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
||||
else
|
||||
-- PlayIntro still GBFadeOutToWhite's after an interrupted scene; the
|
||||
-- white hold stands in for that beat before the title is built.
|
||||
if skip then
|
||||
self:exitToTitle()
|
||||
return
|
||||
end
|
||||
-- PlayShootingStar ends `jp Delay3` once Music_IntroBattle is playing
|
||||
-- (intro.asm:337), so PlayIntroScene's first op is not on the music's
|
||||
-- own frame
|
||||
|
||||
@@ -313,6 +313,9 @@ function PartyMenu.new(game, opts)
|
||||
-- TM/HM display (ABLE / NOT ABLE per mon instead of the HP bar, and the
|
||||
-- "Use TM on which POKeMON?" prompt). Set by BagMenu.pickTargetAndUse. #210
|
||||
self.tmhm = opts.tmhm
|
||||
-- Evolution stones: opts.evoStone = item id gives Gen 1's
|
||||
-- EVO_STONE_PARTY_MENU ABLE / NOT ABLE display (party_menu.asm:114). #1411
|
||||
self.evoStone = opts.evoStone
|
||||
self.forceSwitch = opts.forceSwitch
|
||||
self.battle = opts.battle
|
||||
self.party = party -- link/scoped battles pass their local party view
|
||||
@@ -800,6 +803,20 @@ function PartyMenu:draw()
|
||||
else
|
||||
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||
end
|
||||
elseif self.evoStone then
|
||||
-- party_menu.asm:114 .evolutionStoneMenu: an EVOLVE_ITEM row matching
|
||||
-- wEvoStoneItemID, printed in the TM/HM strings' row+1 column+9 slot
|
||||
local can = false
|
||||
for _, evo in ipairs(def.evolutions or {}) do
|
||||
if evo.method == "ITEM" and evo.item == self.evoStone then
|
||||
can = true break
|
||||
end
|
||||
end
|
||||
if can then
|
||||
Font.draw(Strings("ABLE"), 120, y + 8)
|
||||
else
|
||||
Font.draw(Strings("NOT ABLE"), 88, y + 8)
|
||||
end
|
||||
else
|
||||
if mon.hp <= 0 then
|
||||
Font.draw(Strings("FNT"), 136, y)
|
||||
|
||||
@@ -17,6 +17,12 @@ local Theme = {
|
||||
textBox = { tx = 0, ty = 12, tw = 20, th = 6, maxCols = 18 },
|
||||
-- InitYesNoTextBoxParameters / AskName: hlcoord 14, 7 (YES_NO_MENU 4x3)
|
||||
choiceBox = { tx = 14, ty = 7, tw = 6, th = 5 },
|
||||
-- YesNoChoicePokeCenter: hlcoord 11, 6 (HEAL_CANCEL_MENU 7x4, blank line
|
||||
-- before the first item) -- home/yes_no.asm:21, data/yes_no_menu_strings.asm:16
|
||||
healCancelBox = { tx = 11, ty = 6, tw = 9, th = 6, firstItem = 2 },
|
||||
-- EnemySendOutFirstMon inlines its own TWO_OPTION_MENU at hlcoord 0, 7
|
||||
-- instead of the shared right-hand one -- engine/battle/core.asm:1378-1384
|
||||
trainerSwitchBox = { tx = 0, ty = 7, tw = 6, th = 5 },
|
||||
}
|
||||
|
||||
function Theme.load(data)
|
||||
|
||||
@@ -63,6 +63,7 @@ function Editor.load(opts)
|
||||
Editor.hostPoll = opts.hostPoll == true
|
||||
Editor.drag = nil
|
||||
Editor.rects = {}
|
||||
Editor._closed = false
|
||||
Editor._hostMouse = false
|
||||
Editor._hostTouches = nil
|
||||
Editor.fonts = {
|
||||
@@ -137,6 +138,7 @@ function Editor.unload()
|
||||
Editor.drag = nil
|
||||
Editor.onClose = nil
|
||||
Editor.version = nil
|
||||
Editor.rects = {}
|
||||
Editor._hostMouse = false
|
||||
Editor._hostTouches = nil
|
||||
end
|
||||
@@ -159,6 +161,8 @@ local function persist()
|
||||
end
|
||||
|
||||
local function close()
|
||||
if Editor._closed then return end
|
||||
Editor._closed = true
|
||||
persist()
|
||||
local cb = Editor.onClose
|
||||
Editor.unload()
|
||||
@@ -427,6 +431,7 @@ function Editor.pollHostPointers()
|
||||
if not Editor._hostMouse then
|
||||
Editor._hostMouse = true
|
||||
beginDrag("mouse", x, y)
|
||||
if Editor._closed then return end
|
||||
else
|
||||
moveDrag("mouse", x, y)
|
||||
end
|
||||
@@ -446,6 +451,7 @@ function Editor.pollHostPointers()
|
||||
if not Editor._hostTouches[id] then
|
||||
Editor._hostTouches[id] = true
|
||||
beginDrag(id, tx, ty)
|
||||
if Editor._closed then return end
|
||||
else
|
||||
moveDrag(id, tx, ty)
|
||||
end
|
||||
|
||||
+27
-1
@@ -14,7 +14,9 @@
|
||||
-- This is what the party-menu FLY field move opens (#195).
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Sound = require("src.core.Sound")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
|
||||
local TownMap = {}
|
||||
TownMap.__index = TownMap
|
||||
@@ -227,7 +229,21 @@ function TownMap.new(game, opts)
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites[playerSprites.walk or "SPRITE_RED"]
|
||||
or sprites.SPRITE_RED
|
||||
local ok, img = pcall(love.graphics.newImage, red and red.image)
|
||||
-- the marker is the overworld walking sheet, so it wears that sheet's OBJ
|
||||
-- palette and shade-0 keying -- engine/items/town_map.asm:342
|
||||
local colors, group
|
||||
if PaletteFX.usesGbcPack() then
|
||||
colors, group = PaletteFX.spriteObp(red, "player")
|
||||
end
|
||||
if not colors then
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
end
|
||||
local ok, img = pcall(SpriteRenderer.obpImage,
|
||||
red and red.image, colors, group)
|
||||
if ok and img then
|
||||
self.playerSheet = img
|
||||
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
|
||||
@@ -316,6 +332,13 @@ function TownMap:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- OG RED bakes the boot-ROM OBJ palette in, so the marker has to be replayed
|
||||
-- over the screen-wide TOWNMAP zone pass the way every other OBJ is (#301)
|
||||
function TownMap:markPlayerRedraw(x, y)
|
||||
if not PaletteFX.usesSpriteObp() then return end
|
||||
PaletteFX.markUiSpriteRedraw(self.playerSheet, self.playerQuad, x, y)
|
||||
end
|
||||
|
||||
local function drawSquare(loc)
|
||||
if isRoute(loc) then
|
||||
love.graphics.setColor(0.62, 0.62, 0.62, 1) -- routes lighter
|
||||
@@ -364,6 +387,7 @@ function TownMap:draw()
|
||||
local x, y = markerXY(self.playerLoc)
|
||||
if self.playerSheet then
|
||||
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
|
||||
self:markPlayerRedraw(x - 4, y - 3)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
|
||||
@@ -406,6 +430,8 @@ function TownMap:draw()
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(self.playerSheet, self.playerQuad,
|
||||
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
|
||||
self:markPlayerRedraw(self.playerLoc.x * 8 - 4,
|
||||
self.playerLoc.y * 8 - 3)
|
||||
else
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
|
||||
|
||||
@@ -256,10 +256,7 @@ function YellowIntro.new(game, onDone)
|
||||
local ok, canvas = pcall(love.graphics.newCanvas, 256, 256)
|
||||
self.bgCanvas = ok and canvas or nil
|
||||
|
||||
-- Yellow boots exactly like Red up to the attract movie: the copyright
|
||||
-- card and the GAME FREAK shooting-star splash play first. Reuse
|
||||
-- IntroMovie's phases 1-2 and take over where its Gengar fight (phase
|
||||
-- 3) would begin; a skip press during the pre-roll skips everything.
|
||||
-- intro.asm:311 copyright card, splash.asm:29 shooting star
|
||||
local IntroMovie = require("src.ui.IntroMovie")
|
||||
local pre = IntroMovie.new(game, nil)
|
||||
local baseStart = pre.startPhase
|
||||
@@ -644,8 +641,8 @@ end
|
||||
-- Both scene-loop exits (scene 17 running out, and the A/B/START skip) land
|
||||
-- on .go_to_title_screen, which calls YellowIntro_BlankPalettes and then
|
||||
-- spends EXIT_FRAMES DelayFrame calls clearing the tilemap and the OAM
|
||||
-- buffers before the title screen is built (#523). The pre-roll's own skip
|
||||
-- is IntroMovie's path, not this one, and still finishes immediately.
|
||||
-- buffers before the title screen is built (#523). A press during the
|
||||
-- pre-roll splash starts the scenes instead of reaching here.
|
||||
function YellowIntro:exitToTitle()
|
||||
if self.finished or self.exiting then return end
|
||||
self.exiting = true
|
||||
|
||||
+74
-18
@@ -48,6 +48,18 @@ local TILE_PLAYER_BOTTOM_LEFT = 0x6f
|
||||
-- (engine/battle/trainer_huds.asm:143-152).
|
||||
local TILE_CAUGHT = 0x5d
|
||||
|
||||
-- The ball icons StageBallTilesData stages, one per party slot
|
||||
-- (engine/battle/trainer_huds.asm:47-99).
|
||||
local TILE_BALL_NORMAL = 0x31
|
||||
local TILE_BALL_STATUSED = 0x32
|
||||
local TILE_BALL_FAINTED = 0x33
|
||||
local TILE_BALL_EMPTY = 0x34
|
||||
-- DrawPlayerPartyIconHUDBorder's corner (trainer_huds.asm:118-132), which is
|
||||
-- DrawPlayerHUDBorder's with $77 swapped for $5c.
|
||||
local TILE_PARTY_ICON_BOTTOM_RIGHT = 0x5c
|
||||
|
||||
BattleHud.PARTY_LENGTH = 6
|
||||
|
||||
function BattleHud.new(menuGfx, palettes)
|
||||
local self = setmetatable({}, BattleHud)
|
||||
self.gfx = menuGfx and menuGfx.battleHud or nil
|
||||
@@ -184,18 +196,25 @@ local TILE_EXP_FULL = 0x6a -- FontBattleExtra
|
||||
local TILE_EXP_EMPTY = 0x62 -- FontBattleExtra
|
||||
local EXP_PARTIAL_BASE = 0x54 -- $54 + remainder lands in ExpBarGFX
|
||||
|
||||
-- PAL_BATTLE_BG_EXP, which the attrmap lays over (10,11)..(18,11)
|
||||
-- (engine/gfx/cgb_layouts.asm:142-145).
|
||||
function BattleHud:expColors()
|
||||
local pal = self.palettes and self.palettes.expBar
|
||||
if not pal then return nil end
|
||||
return {
|
||||
{ 255, 255, 255 },
|
||||
{ pal[1][1], pal[1][2], pal[1][3] },
|
||||
{ pal[2][1], pal[2][2], pal[2][3] },
|
||||
{ 0, 0, 0 },
|
||||
}
|
||||
end
|
||||
|
||||
function BattleHud:drawExpBar(fraction, tx, ty)
|
||||
if not self:image("hpBar") then return false end
|
||||
fraction = math.max(0, math.min(1, fraction or 0))
|
||||
local pixels = math.floor(fraction * BattleHud.EXP_LENGTH_PX)
|
||||
-- The whole row wears the exp bar's palette, full and empty cells included.
|
||||
local pal = self.palettes and self.palettes.expBar
|
||||
local colors = pal and {
|
||||
{ 255, 255, 255 },
|
||||
{ pal[1][1], pal[1][2], pal[1][3] },
|
||||
{ pal[2][1], pal[2][2], pal[2][3] },
|
||||
{ 0, 0, 0 },
|
||||
} or nil
|
||||
local colors = self:expColors()
|
||||
|
||||
local remaining = pixels
|
||||
for cell = BattleHud.EXP_CELLS - 1, 0, -1 do
|
||||
@@ -269,19 +288,56 @@ end
|
||||
-- DrawPlayerHUDBorder: hlcoord 18, 10 stepping LEFT, tiles $73 / $77 / $6f /
|
||||
-- $76, plus the extra vertical bar DrawPlayerHUD writes at (18,9) so the stub
|
||||
-- is two rows tall.
|
||||
local PLAYER_FRAME_TILES = {
|
||||
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
|
||||
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
|
||||
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
|
||||
-- (engine/gfx/load_font.asm:57-65).
|
||||
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
|
||||
side = TILE_PLAYER_RIGHT,
|
||||
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
|
||||
farCorner = TILE_PLAYER_BOTTOM_LEFT,
|
||||
bottom = TILE_BOTTOM_SIDE,
|
||||
}
|
||||
|
||||
function BattleHud:drawPlayerFrame()
|
||||
self:drawTile("playerBorder", PLAYER_BORDER_FIRST, TILE_PLAYER_RIGHT, 18, 9)
|
||||
self:placeBorder({
|
||||
sideSheet = "playerBorder", sideFirst = PLAYER_BORDER_FIRST,
|
||||
cornerSheet = "playerBorder", cornerFirst = PLAYER_BORDER_FIRST,
|
||||
-- $6f is the LAST tile of EnemyHPBarBorderGFX, not the player sheet
|
||||
-- (engine/gfx/load_font.asm:57-65).
|
||||
farSheet = "enemyBorder", farFirst = ENEMY_BORDER_FIRST,
|
||||
side = TILE_PLAYER_RIGHT,
|
||||
nearCorner = TILE_PLAYER_BOTTOM_RIGHT,
|
||||
farCorner = TILE_PLAYER_BOTTOM_LEFT,
|
||||
bottom = TILE_BOTTOM_SIDE,
|
||||
}, 18, 10, -1)
|
||||
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
|
||||
end
|
||||
|
||||
-- DrawPlayerPartyIconHUDBorder (engine/battle/trainer_huds.asm:118-132): the
|
||||
-- player border with $5c for the bottom right, and no bar at (18,9).
|
||||
function BattleHud:drawPartyIconFrame()
|
||||
self:placeBorder(PLAYER_FRAME_TILES, 18, 10, -1)
|
||||
return self:drawTile("expBar", self.gfx and self.gfx.expBarFirstTile,
|
||||
TILE_PARTY_ICON_BOTTOM_RIGHT, 18, 11, self:expColors())
|
||||
end
|
||||
|
||||
-- StageBallTilesData's .GetHUDTile, and the $34 it stages past the party
|
||||
-- count (engine/battle/trainer_huds.asm:47-100).
|
||||
local function ballTile(mon)
|
||||
if not mon then return TILE_BALL_EMPTY end
|
||||
if (mon.hp or 0) <= 0 then return TILE_BALL_FAINTED end
|
||||
return mon.status and TILE_BALL_STATUSED or TILE_BALL_NORMAL
|
||||
end
|
||||
|
||||
-- PAL_BATTLE_OB_YELLOW (engine/battle/trainer_huds.asm:213-214).
|
||||
function BattleHud:ballColors()
|
||||
local pals = self.palettes and self.palettes.battleObjects
|
||||
return pals and pals.PAL_BATTLE_OB_YELLOW or nil
|
||||
end
|
||||
|
||||
-- LoadTrainerHudOAM (engine/battle/trainer_huds.asm:203-223): six sprites
|
||||
-- from (tx, ty), each one tile further along `step`.
|
||||
function BattleHud:drawBallRow(party, tx, ty, step)
|
||||
if not self:image("balls") then return false end
|
||||
local first = self.gfx.ballsFirstTile or TILE_BALL_NORMAL
|
||||
local colors = self:ballColors()
|
||||
for slot = 1, BattleHud.PARTY_LENGTH do
|
||||
self:drawTile("balls", first, ballTile(party and party[slot]),
|
||||
tx + (slot - 1) * step, ty, colors)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
BattleHud.TILE_HP_LABEL = TILE_HP_LABEL
|
||||
|
||||
+106
-10
@@ -314,6 +314,13 @@ function BattleState.new(game, opts)
|
||||
self.showEnemyHud = false
|
||||
self.showPlayerHud = false
|
||||
|
||||
-- BattleStart_TrainerHuds, farcalled from BattleStartMessage before the
|
||||
-- opening line (engine/battle/trainer_huds.asm:1-9, core.asm:8733).
|
||||
self.ballRows = {
|
||||
player = true,
|
||||
enemy = not (self.battle and self.battle.wild),
|
||||
}
|
||||
|
||||
-- InitEnemyTrainer (engine/battle/core.asm:7848) puts the CLASS's 7x7
|
||||
-- frontpic in the enemy pic box BEFORE the intro slide, and it stays there
|
||||
-- until ResetEnemyBattleVars slides it off; only then is the mon drawn. The
|
||||
@@ -349,6 +356,7 @@ function BattleState.new(game, opts)
|
||||
|
||||
local enemy = self.battle and self.battle.enemy
|
||||
self:noteFirstUnown(enemy)
|
||||
self:markSeen(enemy)
|
||||
if enemy then
|
||||
if self.battle.wild then
|
||||
-- BattleCheckEnemyShininess: a shiny wild mon gets ANIM_SEND_OUT_MON's
|
||||
@@ -399,6 +407,13 @@ function BattleState.new(game, opts)
|
||||
-- "X fainted!" is even displayed. The replacement arrives with its own
|
||||
-- `send` event, which is where the cart's send-out animation sits.
|
||||
self.shownMon = { player = player, enemy = enemy }
|
||||
-- home/battle.asm:150 UpdateBattleHuds
|
||||
self.shownStatus = {
|
||||
player = (player and player.status) or false,
|
||||
enemy = (enemy and enemy.status) or false,
|
||||
}
|
||||
-- engine/battle/trainer_huds.asm:142-151
|
||||
self.caughtMark = self:dexCaught(enemy)
|
||||
-- And the same for the two numbers AnimateExpBar walks: wBattleMonLevel is
|
||||
-- only advanced inside its level loop, right after that level's bar has
|
||||
-- crawled full (engine/battle/core.asm:7267-7274), so neither the level nor
|
||||
@@ -443,6 +458,16 @@ function BattleState:noteFirstUnown(mon)
|
||||
save.firstUnownSeen = Unown.monLetter(mon)
|
||||
end
|
||||
|
||||
-- LoadEnemyMon's "Saw this mon" (engine/battle/core.asm:6203-6209): the seen
|
||||
-- flag is stamped for every battle mode, so a trainer's mon counts too.
|
||||
function BattleState:markSeen(mon)
|
||||
local save = self.save
|
||||
if not (save and mon and mon.species) then return end
|
||||
save.pokedex = save.pokedex or { seen = {}, caught = {} }
|
||||
save.pokedex.seen = save.pokedex.seen or {}
|
||||
save.pokedex.seen[mon.species] = true
|
||||
end
|
||||
|
||||
-- The DUDE answering a prompt. Every re-arm in the ASM sits at the moment the
|
||||
-- cart starts WAITING for a button (`.wait_input` in home/joypad.asm, BattleMenu
|
||||
-- before LoadBattleMenu, TutorialPack before its own loop), so each one goes
|
||||
@@ -936,6 +961,25 @@ function BattleState:dexCaught(mon)
|
||||
return (mon and caught and caught[mon.species]) and true or false
|
||||
end
|
||||
|
||||
-- The status the HUD prints, one drain behind the engine the way shownHp is:
|
||||
-- UpdateBattleHuds runs after the animation and its line (home/battle.asm:150).
|
||||
function BattleState:hudStatus(mon, side)
|
||||
local shown = side and self.shownStatus and self.shownStatus[side]
|
||||
if shown == nil then return mon and mon.status or nil end
|
||||
return shown or nil
|
||||
end
|
||||
|
||||
-- home/battle.asm:150, for the clears no queued event carries (a mon waking,
|
||||
-- a thaw, a bag cure): the tags catch up once the queue is idle.
|
||||
function BattleState:syncShownStatus()
|
||||
local shown, battle = self.shownStatus, self.battle
|
||||
if not (shown and battle) then return end
|
||||
for _, side in ipairs({ "player", "enemy" }) do
|
||||
local mon = battle[side]
|
||||
shown[side] = (mon and mon.status) or false
|
||||
end
|
||||
end
|
||||
|
||||
-- The low-HP alarm is not an SFX id at all. PlayDanger (audio/engine.asm:531)
|
||||
-- runs every frame while DANGER_ON_F is set in wLowHealthAlarm and writes a
|
||||
-- two-tone square straight to channel 1 -- DangerSoundHigh ($750) at counter 0,
|
||||
@@ -1153,7 +1197,7 @@ function BattleState:stepAnim(input)
|
||||
-- Cart still reaches the after-anim arm after a move script ends; a skip
|
||||
-- of the move should not drop the hit shake that follows it.
|
||||
if self:startPendingAfterAnim() then return end
|
||||
return self:endSendOutAnim()
|
||||
return self:endSendOutAnim(true)
|
||||
end
|
||||
if not self.anim:step() then
|
||||
self:latchCaughtPic()
|
||||
@@ -1170,10 +1214,17 @@ end
|
||||
|
||||
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
|
||||
-- HUD update run the moment its animation is done, cut short or not.
|
||||
function BattleState:endSendOutAnim()
|
||||
function BattleState:endSendOutAnim(skipped)
|
||||
local after = self.afterSendOut
|
||||
if not after then return end
|
||||
self.afterSendOut = nil
|
||||
if after.shiny and not skipped then
|
||||
after.shiny = nil
|
||||
if self:animForId("ANIM_SEND_OUT_MON", after.side, 1) then
|
||||
self.afterSendOut = after
|
||||
return
|
||||
end
|
||||
end
|
||||
self:finishSendOut(after)
|
||||
end
|
||||
|
||||
@@ -1202,6 +1253,7 @@ function BattleState:advanceQueue()
|
||||
self.introTextShown = nil
|
||||
end
|
||||
if not event then
|
||||
self:syncShownStatus()
|
||||
-- `jp PlayerSwitch`, which follows the enemy's own send-out and spends no
|
||||
-- turn (engine/battle/core.asm:2955-2963).
|
||||
if self.shiftSwitchIndex then
|
||||
@@ -1315,6 +1367,14 @@ function BattleState:advanceQueue()
|
||||
self.shownHp[event.side] = event.mon.hp or 0
|
||||
if self.hpAnim and self.hpAnim.side == event.side then self.hpAnim = nil end
|
||||
end
|
||||
-- And the same lag for the status tag (home/battle.asm:150); a send snaps it
|
||||
-- to the incoming mon.
|
||||
if self.shownStatus and event.side
|
||||
and (event.kind == "status"
|
||||
or (event.kind == "send" and event.mon)) then
|
||||
self.shownStatus[event.side] =
|
||||
(event.kind == "send" and event.mon.status or event.status) or false
|
||||
end
|
||||
-- AnimateExpBar (engine/battle/core.asm:7191) is called from INSIDE
|
||||
-- GiveExperiencePoints before the exp is committed (the call at :6888 sits
|
||||
-- ahead of the commit at :6889-6901), so the bar crawls from the figures
|
||||
@@ -1339,7 +1399,12 @@ function BattleState:advanceQueue()
|
||||
-- is still on screen for its own line and the replacement arrives here.
|
||||
if self.shownMon then self.shownMon[event.side] = event.mon end
|
||||
-- The second of the cart's two wFirstUnownSeen writes (core.asm:3251).
|
||||
if event.side == "enemy" then self:noteFirstUnown(event.mon) end
|
||||
if event.side == "enemy" then
|
||||
self:noteFirstUnown(event.mon)
|
||||
self:markSeen(event.mon)
|
||||
-- engine/battle/trainer_huds.asm:142-151
|
||||
self.caughtMark = self:dexCaught(event.mon)
|
||||
end
|
||||
if event.side == "player" then
|
||||
-- SendOutPlayerMon zeroes wBattleMenuCursorPosition and wCurMoveNum back
|
||||
-- to back (engine/battle/core.asm:3809), so a switched-in mon opens on
|
||||
@@ -1535,12 +1600,18 @@ end
|
||||
-- SetEnemyTurn / SetPlayerTurn, then ANIM_SEND_OUT_MON. The cry and the HUD
|
||||
-- come after the animation, not with it.
|
||||
function BattleState:startSendOut(side, mon)
|
||||
local after = { side = side, mon = mon }
|
||||
-- BattleCheckPlayerShininess / BattleCheckEnemyShininess replay the anim's
|
||||
-- `.Shiny` arm before the cry (core.asm:3826-3831, :3371-3377).
|
||||
local after = { side = side, mon = mon, shiny = mon and mon.shiny and true }
|
||||
-- ShowSetEnemyMonAndSendOutAnimation and SendOutPlayerMon both draw the pic
|
||||
-- into the box before they play the animation, which is the one thing that
|
||||
-- undoes a cleared box.
|
||||
self.picHidden[side] = false
|
||||
self.faintSlide = nil
|
||||
-- The rows are shadow OAM (engine/battle/trainer_huds.asm:203-223), which
|
||||
-- this animation's own sprites overwrite.
|
||||
self.ballRows.player = false
|
||||
self.ballRows.enemy = false
|
||||
if self:animForId("ANIM_SEND_OUT_MON", side) then
|
||||
self.afterSendOut = after
|
||||
return true
|
||||
@@ -2569,6 +2640,9 @@ function BattleState:pushCaught(enemy, itemId)
|
||||
-- in the box", item_effects.asm:624). Boxes.deposit stays an append: the
|
||||
-- PC's own move is InsertPokemonIntoBox, which inserts at the cursor.
|
||||
table.insert(box, 1, enemy)
|
||||
-- SendMonIntoBox refills the boxed slot's PP before it closes SRAM
|
||||
-- (move_mon.asm:1062-1063).
|
||||
Boxes.restorePP(enemy)
|
||||
-- `.SendToPC` re-reads sBoxCount AFTER the insert and sets
|
||||
-- BATTLERESULT_BOX_FULL when the box has just filled
|
||||
-- (item_effects.asm:612-619); Script_reloadmapafterbattle tests that bit
|
||||
@@ -2638,6 +2712,9 @@ end
|
||||
-- (engine/battle/core.asm:3298-3304, data/text/battle.asm:222-231).
|
||||
function BattleState:offerShiftSwitch(mon)
|
||||
self.shiftIndex = 1
|
||||
-- HandleEnemySwitch farcalls EnemySwitch_TrainerHud before the prompt
|
||||
-- (engine/battle/core.asm:2246, engine/battle/trainer_huds.asm:11-15).
|
||||
self.ballRows.enemy = true
|
||||
local trainer = (self.battle.trainer and self.battle.trainer.name) or "Foe"
|
||||
local player = (self.save and self.save.player and self.save.player.name)
|
||||
or "GOLD"
|
||||
@@ -2921,6 +2998,9 @@ function BattleState:useItem(itemId)
|
||||
end
|
||||
local player = self.battle.player
|
||||
caught, rate = Catching.attempt({
|
||||
battle = self.battle,
|
||||
mon = enemy,
|
||||
def = enemyDef,
|
||||
maxHp = enemy.maxHp or (enemy.stats and enemy.stats.hp),
|
||||
hp = enemy.hp,
|
||||
catchRate = enemyDef and enemyDef.catchRate or 45,
|
||||
@@ -3231,13 +3311,13 @@ function BattleState:drawHud()
|
||||
-- PlaceNonFaintStatus (engine/pokemon/mon_stats.asm): a statused mon's tag
|
||||
-- prints where the level goes, and DrawEnemyHUD's `.skip_level` arm drops
|
||||
-- the level entirely while one is up.
|
||||
Chrome.print(self:statusTag(enemy) or ("<LV>" .. tostring(enemy.level or 1)),
|
||||
6, 1)
|
||||
Chrome.print(self:statusTag(enemy, "enemy")
|
||||
or ("<LV>" .. tostring(enemy.level or 1)), 6, 1)
|
||||
local enemyGender = self:genderSymbol(enemy)
|
||||
if enemyGender then Chrome.print(enemyGender, 9, 1) end
|
||||
-- `ld a, [wBattleMode] / dec a / ret nz`, then CheckCaughtMon puts $5d at
|
||||
-- (1,1) (engine/battle/trainer_huds.asm:140-152).
|
||||
if self.battle and self.battle.wild and self:dexCaught(enemy) then
|
||||
if self.battle and self.battle.wild and self.caughtMark then
|
||||
self.hud:drawCaughtIcon(1, 1, self:hudHp(enemy, "enemy"),
|
||||
enemy.maxHp or (enemy.stats and enemy.stats.hp))
|
||||
end
|
||||
@@ -3249,6 +3329,14 @@ function BattleState:drawHud()
|
||||
self:drawFrame(1, 3, 10, false)
|
||||
end
|
||||
end
|
||||
-- ShowOTTrainerMonsRemaining (engine/battle/trainer_huds.asm:32-45): balls
|
||||
-- walking LEFT from OAM (72, 32), which the -8/-16 offset puts at (8, 2).
|
||||
if showStatus and self.ballRows.enemy then
|
||||
if not self.showEnemyHud and self.hud:available() then
|
||||
self.hud:drawEnemyFrame()
|
||||
end
|
||||
self.hud:drawBallRow(self.battle and self.battle.enemyParty, 8, 2, -1)
|
||||
end
|
||||
|
||||
self:drawPic(enemy, false)
|
||||
|
||||
@@ -3257,6 +3345,14 @@ function BattleState:drawHud()
|
||||
-- the HP bar at (10,9), its numbers below; the vertical bar at (18,9), the
|
||||
-- border from (18,10) going left, and the exp bar at (10,11).
|
||||
self:drawPic(player, true)
|
||||
-- ShowPlayerMonsRemaining (engine/battle/trainer_huds.asm:17-30): balls
|
||||
-- walking RIGHT from OAM (96, 96), i.e. tile (11, 10).
|
||||
if showStatus and self.ballRows.player then
|
||||
if not self.showPlayerHud and self.hud:available() then
|
||||
self.hud:drawPartyIconFrame()
|
||||
end
|
||||
self.hud:drawBallRow(self.battle and self.battle.party, 11, 10, 1)
|
||||
end
|
||||
-- No player HUD in the catching tutorial: DrawPlayerHUD lives in
|
||||
-- SendOutPlayerMon, which BATTLETYPE_TUTORIAL jumps straight over, and
|
||||
-- BattleMenu's own tutorial arm skips UpdateBattleHuds as well. The DUDE's
|
||||
@@ -3270,7 +3366,7 @@ function BattleState:drawHud()
|
||||
Chrome.print(self:name(player), 10, 7)
|
||||
-- PrintPlayerHUD places the same status tag at (14,8) and skips the level
|
||||
-- while it is up.
|
||||
Chrome.print(self:statusTag(player)
|
||||
Chrome.print(self:statusTag(player, "player")
|
||||
or ("<LV>" .. tostring(self.shownLevel or player.level or 1)), 14, 8)
|
||||
local playerGender = self:genderSymbol(player)
|
||||
if playerGender then Chrome.print(playerGender, 17, 8) end
|
||||
@@ -3303,8 +3399,8 @@ local STATUS_TAGS = {
|
||||
paralyze = "PAR", sleep = "SLP",
|
||||
}
|
||||
|
||||
function BattleState:statusTag(mon)
|
||||
return mon and STATUS_TAGS[mon.status] or nil
|
||||
function BattleState:statusTag(mon, side)
|
||||
return mon and STATUS_TAGS[self:hudStatus(mon, side)] or nil
|
||||
end
|
||||
|
||||
-- ♂ / ♀ after the level, or nil for a genderless species (PrintPlayerHUD
|
||||
|
||||
+47
-18
@@ -78,8 +78,12 @@ local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" }
|
||||
-- engine/pokemon/bills_pc.asm:472-478: BillsPC_Withdraw's menu rows.
|
||||
local WITHDRAW_SUBMENU = { "WITHDRAW", "STATS", "RELEASE", "CANCEL" }
|
||||
|
||||
-- BillsPCDepositMenuHeader's .MenuData (engine/pokemon/bills_pc.asm:234-240).
|
||||
local DEPOSIT_SUBMENU = { "DEPOSIT", "STATS", "RELEASE", "CANCEL" }
|
||||
|
||||
function BoxMenu:submenuRows()
|
||||
if self.mode == "move" then return MOVE_SUBMENU end
|
||||
if self.mode == "deposit" then return DEPOSIT_SUBMENU end
|
||||
return WITHDRAW_SUBMENU
|
||||
end
|
||||
|
||||
@@ -219,21 +223,12 @@ function BoxMenu:act()
|
||||
if self.onClose then self.onClose() end
|
||||
return
|
||||
end
|
||||
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
|
||||
if self.mode == "move" or self.mode == "withdraw" then
|
||||
if not self:selected() then return end
|
||||
self.phase = "submenu"
|
||||
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
|
||||
self.submenuIndex = 1
|
||||
return
|
||||
end
|
||||
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
if not ok then
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
self.message = nil
|
||||
self:clampIndex()
|
||||
-- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu,
|
||||
-- and _DepositPKMN's .a_button steps to .WhatsUp the same way (:94-102).
|
||||
if not self:selected() then return end
|
||||
self.phase = "submenu"
|
||||
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on its top row.
|
||||
self.submenuIndex = 1
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ MOVE, step 2
|
||||
@@ -243,8 +238,9 @@ end
|
||||
-- the PARTY, so a boxed mon walks straight past. Returns true, or false and
|
||||
-- the string the cart places.
|
||||
function BoxMenu:checkMailPreventBlackout()
|
||||
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`.
|
||||
if not self:isParty() then return true end
|
||||
-- `ld a, [wBillsPC_LoadedBox] / and a / jr nz, .Okay`; _DepositPKMN zeroes
|
||||
-- that byte too, so its list is the party (bills_pc.asm:17-18).
|
||||
if not (self:isParty() or self.mode == "deposit") then return true end
|
||||
local party = self.save.party or {}
|
||||
-- `cp $3 / jr c, .ItsYourLastPokemon`: a party of one or two may not send
|
||||
-- one away at all, however healthy the rest of it is.
|
||||
@@ -311,10 +307,25 @@ function BoxMenu:doWithdraw()
|
||||
self:clampIndex()
|
||||
end
|
||||
|
||||
-- engine/pokemon/bills_pc.asm:155 BillsPCDepositFuncDeposit
|
||||
function BoxMenu:doDeposit()
|
||||
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
|
||||
if not ok then
|
||||
self.message = result
|
||||
return
|
||||
end
|
||||
self.message = nil
|
||||
self.phase = nil
|
||||
self.index, self.scroll = 1, 0
|
||||
self:clampIndex()
|
||||
end
|
||||
|
||||
function BoxMenu:chooseSubmenu()
|
||||
local row = self:submenuRows()[self.submenuIndex]
|
||||
if row == "MOVE" then
|
||||
self:beginMove()
|
||||
elseif row == "DEPOSIT" then
|
||||
self:doDeposit()
|
||||
elseif row == "WITHDRAW" then
|
||||
self:doWithdraw()
|
||||
elseif row == "STATS" then
|
||||
@@ -393,6 +404,9 @@ function BoxMenu:insertMon()
|
||||
target = target - 1
|
||||
end
|
||||
table.insert(dest, math.max(1, math.min(target, #dest + 1)), mon)
|
||||
-- .CopyToBox is InsertPokemonIntoBox, which tails into
|
||||
-- RestorePPOfDepositedPokemon (engine/pokemon/move_mon_wo_mail.asm:35-37).
|
||||
if not self:isParty(destIndex) then Boxes.restorePP(mon) end
|
||||
self.phase = nil
|
||||
self.moveFrom, self.backup = nil, nil
|
||||
self.index, self.scroll = 1, 0
|
||||
@@ -521,6 +535,16 @@ function BoxMenu:askRelease()
|
||||
if self:isCancel() then return end
|
||||
local mon = self:selected()
|
||||
if not mon then return end
|
||||
-- BillsPCDepositFuncRelease runs the mail/blackout net BEFORE the egg check
|
||||
-- (engine/pokemon/bills_pc.asm:183-187).
|
||||
if self.mode == "deposit" then
|
||||
local allowed, refusal = self:checkMailPreventBlackout()
|
||||
if not allowed then
|
||||
self.phase = nil
|
||||
self.message = refusal
|
||||
return
|
||||
end
|
||||
end
|
||||
-- Both release paths run BillsPC_IsMonAnEgg first, so the question is never
|
||||
-- even asked over an egg (engine/pokemon/bills_pc.asm:186-187 and :427-428).
|
||||
if mon.isEgg then
|
||||
@@ -534,7 +558,12 @@ function BoxMenu:askRelease()
|
||||
local name = mon.nickname or mon.name or mon.species or "?"
|
||||
game.stack:push(ChoiceBox.new(game, function(yes)
|
||||
if not yes then return end
|
||||
local ok, err = Boxes.release(self.save, self.boxIndex, self.index)
|
||||
local ok, err
|
||||
if self.mode == "deposit" then
|
||||
ok, err = Boxes.releaseFromParty(self.save, self.index)
|
||||
else
|
||||
ok, err = Boxes.release(self.save, self.boxIndex, self.index)
|
||||
end
|
||||
if not ok then
|
||||
self.message = err
|
||||
return
|
||||
|
||||
@@ -518,8 +518,9 @@ function ItemPcMenu:drawList()
|
||||
local entry = self.rows[i]
|
||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||
Chrome.print(entry.name, 6, ty)
|
||||
-- PlaceMenuItemQuantity: the xNN is the entry's second line.
|
||||
Chrome.print(TIMES .. tostring(entry.count), 7, ty + 1)
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:24): the xNN is the
|
||||
-- entry's second line, right-aligned in a blank-padded 2-digit field.
|
||||
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
|
||||
elseif i == self:listTotal() then
|
||||
if i == self.listIndex then Chrome.cursor(5, ty) end
|
||||
Chrome.print("CANCEL", 6, ty)
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
-- * The two tilemaps behind DrawMagnetTrain (MagnetTrainBGTiles, a 2x18
|
||||
-- vertical strip repeated across all 32 columns, and MagnetTrainTilemap,
|
||||
-- the 20x4 train laid over rows 6-9) come from the extracted cache at
|
||||
-- data.field.magnetTrain. A cache built before the extractor learned to
|
||||
-- follow them has neither, and then the ride runs with a blank screen
|
||||
-- rather than with invented art.
|
||||
-- data.gen2Field.magnetTrain. A cache built before the extractor
|
||||
-- learned to follow them has neither, and then the ride runs with a
|
||||
-- blank screen rather than with invented art.
|
||||
-- * The background is baked into a 256x144 canvas once, because the only
|
||||
-- thing that changes per frame is the per-band SCX.
|
||||
-- * SetMagnetTrainPals gives the four bush rows and the four bottom rows
|
||||
@@ -57,7 +57,7 @@ function MagnetTrainRide.new(game, opts)
|
||||
self.onDone = opts.onDone
|
||||
self.finished = false
|
||||
|
||||
local field = self.data and self.data.field
|
||||
local field = self.data and self.data.gen2Field
|
||||
local gfx = field and field.magnetTrain
|
||||
self.ride = MagnetTrain.new({
|
||||
toGoldenrod = opts.toGoldenrod,
|
||||
@@ -65,8 +65,8 @@ function MagnetTrainRide.new(game, opts)
|
||||
fgTilemap = gfx and gfx.tilemap,
|
||||
})
|
||||
|
||||
self.tileset = self.data and self.data.tilesets
|
||||
and self.data.tilesets.TILESET_TRAIN_STATION
|
||||
self.tileset = self.data and self.data.gen2Tilesets
|
||||
and self.data.gen2Tilesets.TILESET_TRAIN_STATION
|
||||
self.palettes = self:bgPalettes()
|
||||
self.spriteSheet = self:playerSheet()
|
||||
|
||||
@@ -84,7 +84,7 @@ end
|
||||
-- even though both stations are INDOOR maps.
|
||||
function MagnetTrainRide:bgPalettes()
|
||||
local data = self.data
|
||||
local palettes = data and data.palettes
|
||||
local palettes = data and data.gen2Palettes
|
||||
if not palettes then return nil end
|
||||
return Palettes.bgSet(palettes, { environment = "TOWN" },
|
||||
Palettes.clockDaytime())
|
||||
@@ -129,7 +129,7 @@ end
|
||||
-- are cut per 8x8 sub-tile rather than by the sheet's 16-pixel width, because
|
||||
-- the OAM data addresses the four tiles of a frame individually.
|
||||
function MagnetTrainRide:playerSheet()
|
||||
local sprites = self.data and self.data.sprites
|
||||
local sprites = self.data and self.data.gen2Sprites
|
||||
local def = sprites and (sprites.SPRITE_CHRIS or sprites.SPRITE_KRIS)
|
||||
local path = def and def.image
|
||||
if not path then return nil end
|
||||
@@ -259,8 +259,8 @@ end
|
||||
-- MapObjectPals' PAL_OW_RED, the palette every .OAMData_MagnetTrainRed entry
|
||||
-- names.
|
||||
function MagnetTrainRide:playerPalette()
|
||||
local palettes = self.data and self.data.palettes
|
||||
local sprites = self.data and self.data.sprites
|
||||
local palettes = self.data and self.data.gen2Palettes
|
||||
local sprites = self.data and self.data.gen2Sprites
|
||||
if not palettes then return nil end
|
||||
return Palettes.spritePalette(palettes, Palettes.clockDaytime(),
|
||||
sprites and sprites.SPRITE_CHRIS)
|
||||
|
||||
@@ -211,6 +211,15 @@ local ROWS = {
|
||||
TC.buzz(options.haptics)
|
||||
if game and game.persistOptions then game:persistOptions() end
|
||||
end },
|
||||
{ label = "MAX FPS", key = "fpsCap", port = true,
|
||||
cycle = function(options, delta)
|
||||
local FrameCap = require("src.core.FrameCap")
|
||||
options.fpsCap = FrameCap.cycle(options.fpsCap, delta)
|
||||
FrameCap.apply(options.fpsCap)
|
||||
end,
|
||||
text = function(options)
|
||||
return require("src.core.FrameCap").label(options.fpsCap)
|
||||
end },
|
||||
{ label = "CANCEL", cancel = true },
|
||||
}
|
||||
|
||||
|
||||
+93
-27
@@ -67,6 +67,10 @@ local SUBMENU_LABEL = {
|
||||
-- (data/text/common_2.asm), the three lines TossMenu prints in order.
|
||||
local TOSS_HOW_MANY = { "Throw away how", "many?" }
|
||||
|
||||
-- _AskItemMoveText (data/text/common_2.asm:322), printed while wSwitchItem
|
||||
-- holds a row and the cursor is looking for its new home.
|
||||
local ASK_ITEM_MOVE = { "Where should this", "be moved to?" }
|
||||
|
||||
-- _YouDontHaveAMonText and .AnEggCantHoldAnItemText, GiveItem's two refusals.
|
||||
local NO_POKEMON = { "You don't have a", "#MON!" }
|
||||
local EGG_CANT_HOLD = { "An EGG can't hold", "an item." }
|
||||
@@ -127,6 +131,9 @@ function PackMenu.new(game, opts)
|
||||
self.game = game
|
||||
self.save = opts.save or (game and game.save)
|
||||
self.items = opts.items or (game and game.data and game.data.items)
|
||||
-- Bag.order / Bag.move read `.items` off an injectable data table, and the
|
||||
-- one this screen draws from is not always the Data singleton.
|
||||
self.bagData = { items = self.items }
|
||||
self.world = opts.world or (game and game.world)
|
||||
self.onChoose = opts.onChoose
|
||||
self.onClose = opts.onClose
|
||||
@@ -207,7 +214,13 @@ end
|
||||
function PackMenu:rebuild()
|
||||
local pocket = self:pocket().id
|
||||
local rows = {}
|
||||
for itemId, raw in pairs((self.save and self.save.inventory) or {}) do
|
||||
local save = self.save
|
||||
local inventory = (save and save.inventory) or {}
|
||||
-- Row order IS wBagItems' order, which is what SELECT rewrites.
|
||||
local order = (save and save.inventory)
|
||||
and Bag.order(save, self.bagData) or {}
|
||||
for _, itemId in ipairs(order) do
|
||||
local raw = inventory[itemId]
|
||||
-- A count that is not a number at all (a hand-written save, a mod, an old
|
||||
-- migration) counts as one rather than raising out of the draw.
|
||||
local count = tonumber(raw) or (raw and 1) or 0
|
||||
@@ -219,18 +232,13 @@ function PackMenu:rebuild()
|
||||
name = PackMenu.label(itemId, def),
|
||||
teaches = self:moveLabel(def and def.teaches),
|
||||
tmNumber = def and def.tmNumber,
|
||||
-- KEY_ITEM and TM_HM rows do not show a quantity on the cart.
|
||||
showCount = pocket == "ITEM" or pocket == "BALL",
|
||||
index = def and def.index or math.huge,
|
||||
-- A KEY_ITEM never shows one, and engine/items/tmhm.asm:390 skips the
|
||||
-- count for an HM only -- a TM prints ×NN like any other stack.
|
||||
showCount = pocket == "ITEM" or pocket == "BALL"
|
||||
or (pocket == "TM_HM" and tostring(itemId):sub(1, 3) ~= "HM_"),
|
||||
}
|
||||
end
|
||||
end
|
||||
-- Bag order on the cart is acquisition order; without that recorded, item id
|
||||
-- order is the stable, reproducible choice.
|
||||
table.sort(rows, function(a, b)
|
||||
if a.index ~= b.index then return a.index < b.index end
|
||||
return a.id < b.id
|
||||
end)
|
||||
self.rows = rows
|
||||
self.index = math.min(self.index, #rows + 1)
|
||||
if self.index < 1 then self.index = 1 end
|
||||
@@ -671,6 +679,11 @@ function PackMenu:update(_dt)
|
||||
self:updateQuantity(input)
|
||||
return
|
||||
end
|
||||
-- engine/items/pack.asm:1237 Pack_InterpretJoypad .switching_item
|
||||
if self.switching then
|
||||
self:updateSwitch(input)
|
||||
return
|
||||
end
|
||||
if self.message then
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
self.message = nil
|
||||
@@ -716,11 +729,52 @@ function PackMenu:update(_dt)
|
||||
end
|
||||
return
|
||||
elseif input:wasPressed("select") then
|
||||
self:registerSelected()
|
||||
self:armSwitch()
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
|
||||
function PackMenu:armSwitch()
|
||||
if self:isCancel() then return end
|
||||
if not self.rows[self.index] then return end
|
||||
self.switching = self.index
|
||||
self.message = ASK_ITEM_MOVE
|
||||
end
|
||||
|
||||
-- `.switching_item` (engine/items/pack.asm:1297): A or SELECT places, B backs
|
||||
-- out, and left/right cannot leave the pocket mid-move.
|
||||
function PackMenu:updateSwitch(input)
|
||||
if input:wasPressed("up") then
|
||||
self.index = self.index > 1 and self.index - 1 or self:total()
|
||||
self:ensureVisible()
|
||||
elseif input:wasPressed("down") then
|
||||
self.index = self.index < self:total() and self.index + 1 or 1
|
||||
self:ensureVisible()
|
||||
elseif input:wasPressed("a") or input:wasPressed("select") then
|
||||
self:placeSwitch()
|
||||
elseif input:wasPressed("b") then
|
||||
self:endSwitch()
|
||||
end
|
||||
end
|
||||
|
||||
-- engine/items/pack.asm:1307 .place_insert / .end_switch
|
||||
function PackMenu:placeSwitch()
|
||||
local from = self.switching
|
||||
local row = self.rows[from]
|
||||
if row and not self:isCancel() and self.index ~= from then
|
||||
Bag.move(self.save, row.id, self:pocket().id, self.index, self.bagData)
|
||||
self:rebuild()
|
||||
self:storeCursor()
|
||||
end
|
||||
self:endSwitch()
|
||||
end
|
||||
|
||||
function PackMenu:endSwitch()
|
||||
self.switching = nil
|
||||
self.message = nil
|
||||
end
|
||||
|
||||
-- VerticalMenu over the submenu rows: up/down wrap, A picks, B is the carry
|
||||
-- that ExitMenu answers with (`ret c`), which is QUIT by another name.
|
||||
function PackMenu:updateSubmenu(input)
|
||||
@@ -777,12 +831,10 @@ function PackMenu:updateConfirm(input)
|
||||
end
|
||||
end
|
||||
|
||||
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row. SELECT on the
|
||||
-- highlighted row reaches the same routine: the cart's SELECT is the bag's own
|
||||
-- item shuffle, which this port does not have, so the button is free and a
|
||||
-- player who knows Gen 1's registration shortcut gets it. World:registerItem
|
||||
-- re-runs CheckSelectableItem's gate (TM/HM and anything CANT_SELECT_F
|
||||
-- refuses), so neither door can register what the cart would not.
|
||||
-- RegisterItem (engine/items/pack.asm), the submenu's SEL row and its ONLY
|
||||
-- door -- the cart's SELECT is the bag's own item shuffle (see armSwitch).
|
||||
-- World:registerItem re-runs CheckSelectableItem's gate (TM/HM and anything
|
||||
-- CANT_SELECT_F refuses), so it cannot register what the cart would not.
|
||||
function PackMenu:registerSelected()
|
||||
if self:isCancel() then return end
|
||||
local row = self.rows[self.index]
|
||||
@@ -828,22 +880,38 @@ end
|
||||
|
||||
-- The list, description and cursor, on top of whatever chrome was drawn.
|
||||
--
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm) writes the ×N one row DOWN
|
||||
-- and one column RIGHT of the name -- the quantity is the entry's second line,
|
||||
-- not a right-aligned column, which is why every PACK row is two tiles tall.
|
||||
-- PlaceMenuItemQuantity (engine/menus/menu_2.asm:10) writes the ×N one row
|
||||
-- DOWN and one column RIGHT of the name -- the quantity is the entry's second
|
||||
-- line, not a right-aligned column, which is why every PACK row is two tiles
|
||||
-- tall. Its `lb bc, 1, 2` is a TWO-digit field with the leading digit blanked,
|
||||
-- so the ones digit sits at name + 3 whether the count is 5 or 50.
|
||||
--
|
||||
-- ScrollingMenu_PlaceCursor (engine/menus/scrolling_menu.asm:438) marks the
|
||||
-- row SELECT armed with the hollow ▷ while the solid ▶ goes on looking.
|
||||
function PackMenu:drawList(listX, listY)
|
||||
for row = 1, VISIBLE_ROWS do
|
||||
local i = row + self.scroll
|
||||
local ty = listY + (row - 1) * LIST_SPACING
|
||||
if i <= #self.rows then
|
||||
local entry = self.rows[i]
|
||||
if i == self.index then Chrome.cursor(listX - 1, ty) end
|
||||
if i == self.index then
|
||||
Chrome.cursor(listX - 1, ty)
|
||||
elseif i == self.switching then
|
||||
Chrome.cursor(listX - 1, ty, true)
|
||||
end
|
||||
Chrome.print(entry.name, listX, ty)
|
||||
if entry.teaches then
|
||||
-- The TM pocket puts the move the TM teaches on that second line.
|
||||
-- The TM pocket puts the move the TM teaches on that second line, and
|
||||
-- its count at listX + 9 (engine/items/tmhm.asm:392) -- on the LABEL's
|
||||
-- line here, since the move name owns the one below it.
|
||||
Chrome.print(entry.teaches, listX + 1, ty + 1)
|
||||
if entry.showCount then
|
||||
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
|
||||
listX + 9, ty)
|
||||
end
|
||||
elseif entry.showCount then
|
||||
Chrome.print("\xc3\x97" .. tostring(entry.count), listX + 1, ty + 1)
|
||||
Chrome.print("\xc3\x97" .. Chrome.number(entry.count, 2),
|
||||
listX + 1, ty + 1)
|
||||
end
|
||||
elseif i == self:total() then
|
||||
if i == self.index then Chrome.cursor(listX - 1, ty) end
|
||||
@@ -897,12 +965,10 @@ function PackMenu:drawSubmenu()
|
||||
end
|
||||
end
|
||||
|
||||
-- TossItem_MenuHeader is `menu_coords 15, 9, SCREEN_WIDTH - 1, TEXTBOX_Y - 1`
|
||||
-- with NoPriceToDisplay behind it: a small box in the bottom right holding
|
||||
-- nothing but the count.
|
||||
-- engine/items/buy_sell_toss.asm:133 BuySellToss_UpdateQuantityDisplay
|
||||
function PackMenu:drawQuantity()
|
||||
Chrome.box(15, 9, 5, 3)
|
||||
Chrome.print("\xc3\x97" .. tostring(self.qtyState.qty), 16, 10)
|
||||
Chrome.print("\xc3\x97" .. Chrome.number(self.qtyState.qty, 2, true), 16, 10)
|
||||
end
|
||||
|
||||
-- YesNoBox's own coords, the same box every other Gen 2 screen here draws.
|
||||
|
||||
@@ -20,6 +20,7 @@ local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local HpBar = require("src.battle.gen2.HpBar")
|
||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
@@ -668,17 +669,12 @@ end
|
||||
|
||||
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
|
||||
-- with no HP reads FNT whatever its status byte says.
|
||||
local STATUS_STRING = {
|
||||
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
|
||||
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
|
||||
sleep = "SLP", toxic = "PSN",
|
||||
}
|
||||
|
||||
local function statusString(mon)
|
||||
if (mon.hp or 0) <= 0 then return "FNT" end
|
||||
local status = mon.status
|
||||
if not status then return nil end
|
||||
return STATUS_STRING[tostring(status):lower()]
|
||||
local class = ItemEffects.STATUS_CLASS[tostring(status):lower()]
|
||||
return class and class:upper()
|
||||
end
|
||||
|
||||
-- One list row's strings, exactly what WritePartyMenuTilemap's quality
|
||||
|
||||
+127
-2
@@ -24,6 +24,8 @@ local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Save = require("src.core.gen2.Save")
|
||||
local SaveMenu = require("src.ui.gen2.SaveMenu")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
@@ -36,6 +38,13 @@ local MON_HOLDING_MAIL = {
|
||||
Strings.source("Please remove the\nMAIL."),
|
||||
}
|
||||
|
||||
-- _ChangeBoxSaveText (data/text/common_2.asm:1306) is three lines whose `cont`
|
||||
-- has already scrolled by the time YesNoBox goes up over its last two.
|
||||
local CHANGE_BOX_SAVE = { "#MON BOX, data", "will be saved. OK?" }
|
||||
|
||||
-- YesNoBox's own `lb bc, SCREEN_WIDTH - 6, 7` (home/menu.asm:382-383).
|
||||
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 14, 7, 6, 5
|
||||
|
||||
local PcMenu = {}
|
||||
PcMenu.__index = PcMenu
|
||||
PcMenu.isOpaque = true
|
||||
@@ -90,6 +99,13 @@ function PcMenu.new(game, opts)
|
||||
self.onClose = opts.onClose
|
||||
self.house = opts.house and true or false
|
||||
self.events = opts.events
|
||||
-- The same route the start menu's SAVE row takes (src/core/Game2.lua:435),
|
||||
-- so the save.write veto and the save.writing event fire here too.
|
||||
self.writer = opts.writer
|
||||
or (game and type(game.writeSave) == "function"
|
||||
and function() return game:writeSave() end)
|
||||
or Save.save
|
||||
self.saveExists = opts.saveExists
|
||||
-- The folded MAIL BOX row belongs to the item PC, and the whose-PC menu
|
||||
-- reaches that through <PLAYER>'s PC (src/ui/gen2/ItemPcMenu.lua), so
|
||||
-- BILL's PC shows the cart's own five rows. The bedroom's PC keeps it: the
|
||||
@@ -156,6 +172,91 @@ function PcMenu:close()
|
||||
if self.onClose then self.onClose(self.changedDecorations) end
|
||||
end
|
||||
|
||||
-- engine/pokemon/bills_pc.asm:2403 BillsPC_ChangeBoxSubmenu .Switch
|
||||
-- engine/menus/save.asm:40 ChangeBoxSaveGame
|
||||
function PcMenu:beginChangeBox(index)
|
||||
self.changeBox = index
|
||||
self.savePhase = "confirm"
|
||||
self.saveChoice = 1
|
||||
self.saveTimer = 0
|
||||
self.saved = nil
|
||||
local existed = self.saveExists
|
||||
if existed == nil then existed = Save.exists("gold") end
|
||||
self.existed = existed
|
||||
end
|
||||
|
||||
-- .refused: `pop de / ret`, with wCurBox untouched and the picker still up.
|
||||
function PcMenu:refuseChangeBox()
|
||||
self.savePhase, self.changeBox = nil, nil
|
||||
self.saveTimer = 0
|
||||
end
|
||||
|
||||
function PcMenu:acceptChangeBox()
|
||||
if self.saveChoice == 2 then return self:refuseChangeBox() end
|
||||
if self.savePhase == "confirm" and self.existed then
|
||||
self.savePhase = "overwrite"
|
||||
self.saveChoice = 1
|
||||
return
|
||||
end
|
||||
self.savePhase = "saving"
|
||||
self.saveTimer = 0
|
||||
end
|
||||
|
||||
-- `pop de / ld a, e / ld [wCurBox], a` sits between SaveBox and
|
||||
-- SavingDontTurnOffThePower, so the new index rides the file that is written.
|
||||
function PcMenu:writeChangeBox()
|
||||
Boxes.setCurrent(self.save, self.changeBox)
|
||||
local ok = self.writer(self.save)
|
||||
self.saved = ok and true or false
|
||||
if ok then SaveMenu.playSaveSfx(self.game, SaveMenu.SFX_SAVE) end
|
||||
end
|
||||
|
||||
function PcMenu:savePrompt()
|
||||
if self.savePhase == "overwrite" then return SaveMenu.OVERWRITE_PROMPT end
|
||||
if self.savePhase == "saving" then return SaveMenu.SAVING_PROMPT end
|
||||
if self.savePhase == "done" then
|
||||
if self.saved then
|
||||
local name = (self.save.player and self.save.player.name) or "GOLD"
|
||||
return { name .. " saved", "the game." }
|
||||
end
|
||||
return { "Could not save.", "" }
|
||||
end
|
||||
return CHANGE_BOX_SAVE
|
||||
end
|
||||
|
||||
function PcMenu:updateChangeBox()
|
||||
-- SavingDontTurnOffThePower is DelayFrames, not a prompt: no button does
|
||||
-- anything until the sequence runs out (engine/menus/save.asm:55).
|
||||
if self.savePhase == "saving" then
|
||||
self.saveTimer = self.saveTimer + 1
|
||||
if self.saveTimer >= SaveMenu.SAVING_FRAMES then
|
||||
self:writeChangeBox()
|
||||
self.savePhase = "done"
|
||||
self.saveTimer = 0
|
||||
end
|
||||
return
|
||||
end
|
||||
if self.savePhase == "done" then
|
||||
self.saveTimer = self.saveTimer + 1
|
||||
if self.saveTimer >= SaveMenu.SAVED_FRAMES then
|
||||
self.savePhase, self.changeBox = nil, nil
|
||||
self.picking = false
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
local input = self.game and self.game.input
|
||||
if not input then return end
|
||||
if input:wasPressed("up") or input:wasPressed("down") then
|
||||
self.saveChoice = self.saveChoice == 1 and 2 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
self:acceptChangeBox()
|
||||
elseif input:wasPressed("b") then
|
||||
-- B out of a yes/no is NO (InterpretTwoOptionMenu returns carry).
|
||||
self:refuseChangeBox()
|
||||
end
|
||||
end
|
||||
|
||||
function PcMenu:choose()
|
||||
local entry = self.entries[self.index]
|
||||
if not entry then return end
|
||||
@@ -231,6 +332,11 @@ function PcMenu:update(_dt)
|
||||
return
|
||||
end
|
||||
|
||||
if self.savePhase then
|
||||
self:updateChangeBox()
|
||||
return
|
||||
end
|
||||
|
||||
if self.picking then
|
||||
local total = Boxes.NUM_BOXES
|
||||
if input:wasPressed("up") then
|
||||
@@ -238,8 +344,11 @@ function PcMenu:update(_dt)
|
||||
elseif input:wasPressed("down") then
|
||||
self.pickIndex = self.pickIndex < total and self.pickIndex + 1 or 1
|
||||
elseif input:wasPressed("a") then
|
||||
Boxes.setCurrent(self.save, self.pickIndex)
|
||||
self.picking = false
|
||||
if self.pickIndex == (self.save.currentBox or 1) then
|
||||
self.picking = false
|
||||
else
|
||||
self:beginChangeBox(self.pickIndex)
|
||||
end
|
||||
elseif input:wasPressed("b") then
|
||||
self.picking = false
|
||||
end
|
||||
@@ -287,6 +396,22 @@ function PcMenu:drawPanel()
|
||||
("%d/%d"):format(Boxes.count(self.save, i), Boxes.MONS_PER_BOX),
|
||||
18, ty)
|
||||
end
|
||||
if self.savePhase then
|
||||
-- ChangeBoxSaveGame's MenuTextbox, then YesNoBox over the box list.
|
||||
Chrome.box(0, 12, 20, 6)
|
||||
local lines = self:savePrompt()
|
||||
Chrome.print(lines[1] or "", 1, 14)
|
||||
Chrome.print(lines[2] or "", 1, 16)
|
||||
if self.savePhase == "confirm" or self.savePhase == "overwrite" then
|
||||
Chrome.box(YESNO_X, YESNO_Y, YESNO_W, YESNO_H)
|
||||
Chrome.print("YES", YESNO_X + 2, YESNO_Y + 1)
|
||||
Chrome.print("NO", YESNO_X + 2, YESNO_Y + 3)
|
||||
Chrome.cursor(YESNO_X + 1,
|
||||
YESNO_Y + (self.saveChoice == 1 and 1 or 3))
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
Chrome.box(0, 14, 20, 4)
|
||||
Chrome.print("Which BOX?", 1, 16)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
|
||||
@@ -2096,10 +2096,23 @@ function Pokegear:mapCursorSprite(x, y)
|
||||
Chrome.cursor(math.floor(x / 8), math.floor(y / 8))
|
||||
end
|
||||
|
||||
-- PokegearRadio_Init's tile $08 at `depixel 4, 10, 4, 4`, three rows deep
|
||||
-- (data/sprite_anims/oam.asm:588), x = knob (pokegear.asm:1355).
|
||||
function Pokegear:drawTuningKnob()
|
||||
self:loadArrowSheet()
|
||||
if not (self.arrow and self.arrow:available()) then return end
|
||||
local row = self:currentStation()
|
||||
local tx = (72 + (row and row.knob or 0)) / 8
|
||||
self.arrow:draw(0x08, tx, 1)
|
||||
self.arrow:draw(0x08, tx, 2)
|
||||
self.arrow:draw(0x08, tx, 3)
|
||||
end
|
||||
|
||||
function Pokegear:drawRadio()
|
||||
self:ensureTuned()
|
||||
self:drawTilemap(self.gfx and self.gfx.cards and self.gfx.cards.radio)
|
||||
self:drawStrip()
|
||||
self:drawTuningKnob()
|
||||
local station = self:currentStation()
|
||||
-- UpdateRadioStation prints the tuned channel's name at (2,9). Dead air
|
||||
-- prints nothing: NoRadioStation clears the box and leaves it clear.
|
||||
|
||||
@@ -54,6 +54,11 @@ local TIME_X, TIME_Y = 13, 8
|
||||
|
||||
local YESNO_X, YESNO_Y, YESNO_W, YESNO_H = 0, 7, 6, 5
|
||||
|
||||
-- AlreadyASaveFileText (AskOverwriteSaveFile, engine/menus/save.asm:47) and
|
||||
-- SavingDontTurnOffThePower's own line, shared with the PC's CHANGE BOX save.
|
||||
local OVERWRITE_PROMPT = { "There is already a", "save file. Is it" }
|
||||
local SAVING_PROMPT = { "SAVING… DON'T TURN", "OFF THE POWER." }
|
||||
|
||||
function SaveMenu:wantsFillScale() return true end
|
||||
function SaveMenu:drawsWidescreen() return true end
|
||||
|
||||
@@ -75,14 +80,18 @@ function SaveMenu.new(game, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
function SaveMenu:playSfx(id)
|
||||
local data = self.game and self.game.data
|
||||
function SaveMenu.playSaveSfx(game, id)
|
||||
local data = game and game.data
|
||||
local audio = data and data.audio
|
||||
if not (audio and audio.sfxOrder) then return end
|
||||
local name = audio.sfxOrder[id + 1]
|
||||
if name and audio.sfx and audio.sfx[name] then Sound.play(data, name) end
|
||||
end
|
||||
|
||||
function SaveMenu:playSfx(id)
|
||||
SaveMenu.playSaveSfx(self.game, id)
|
||||
end
|
||||
|
||||
function SaveMenu:finish(saved)
|
||||
if self.onDone then self.onDone(saved) end
|
||||
end
|
||||
@@ -162,10 +171,10 @@ function SaveMenu:prompt()
|
||||
if self.phase == "overwrite" then
|
||||
-- AlreadyASaveFileText when the file is this player's; AnotherSaveFileText
|
||||
-- when the ID differs. Only the first can happen here.
|
||||
return { "There is already a", "save file. Is it" }
|
||||
return OVERWRITE_PROMPT
|
||||
end
|
||||
if self.phase == "saving" then
|
||||
return { "SAVING… DON'T TURN", "OFF THE POWER." }
|
||||
return SAVING_PROMPT
|
||||
end
|
||||
if self.phase == "done" then
|
||||
if self.saved then
|
||||
@@ -226,4 +235,10 @@ function SaveMenu:drawWidescreen(winW, winH)
|
||||
G.pop()
|
||||
end
|
||||
|
||||
SaveMenu.SFX_SAVE = SFX_SAVE
|
||||
SaveMenu.SAVING_FRAMES = SAVING_FRAMES
|
||||
SaveMenu.SAVED_FRAMES = SAVED_FRAMES
|
||||
SaveMenu.OVERWRITE_PROMPT = OVERWRITE_PROMPT
|
||||
SaveMenu.SAVING_PROMPT = SAVING_PROMPT
|
||||
|
||||
return SaveMenu
|
||||
|
||||
@@ -63,6 +63,7 @@ local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
local HpBar = require("src.battle.gen2.HpBar")
|
||||
local ItemEffects = require("src.core.gen2.ItemEffects")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Palettes = require("src.world.gen2.Palettes")
|
||||
local Pokerus = require("src.core.gen2.Pokerus")
|
||||
@@ -109,15 +110,6 @@ local TYPE_NAMES = {
|
||||
CURSE_TYPE = "???",
|
||||
}
|
||||
|
||||
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
|
||||
-- with no HP reads FNT whatever its status byte says. Same table the party
|
||||
-- list uses; both screens call the same routine on the cart.
|
||||
local STATUS_STRING = {
|
||||
slp = "SLP", psn = "PSN", brn = "BRN", frz = "FRZ", par = "PAR",
|
||||
poison = "PSN", burn = "BRN", freeze = "FRZ", paralysis = "PAR",
|
||||
sleep = "SLP", toxic = "PSN",
|
||||
}
|
||||
|
||||
-- Gen 2 pics are 5x5, 6x6 or 7x7 and PadFrontpic centres the small ones in
|
||||
-- the 7x7 block PrepMonFrontpic lays at hlcoord 0, 0. Same table the dex
|
||||
-- uses, for the same reason.
|
||||
@@ -208,11 +200,15 @@ local function levelText(level)
|
||||
return "<LV>" .. tostring(level)
|
||||
end
|
||||
|
||||
-- PlaceStatusString (engine/pokemon/mon_stats.asm): three letters, and a mon
|
||||
-- with no HP reads FNT whatever its status byte says. Same lookup the party
|
||||
-- list makes; both screens call the same routine on the cart.
|
||||
local function statusText(mon)
|
||||
if (mon.hp or 0) <= 0 then return "FNT" end
|
||||
local status = mon.status
|
||||
if not status then return nil end
|
||||
return STATUS_STRING[tostring(status):lower()]
|
||||
local class = ItemEffects.STATUS_CLASS[tostring(status):lower()]
|
||||
return class and class:upper()
|
||||
end
|
||||
|
||||
-- wTempMonPokerusStatus is one byte: the low nibble counts the days left and
|
||||
|
||||
+9
-1
@@ -105,8 +105,16 @@ function NPC:update(map, entities)
|
||||
end
|
||||
end
|
||||
|
||||
-- UpdateNPCSprite branches to NotYetMoving while BIT_FONT_LOADED is set
|
||||
-- -- engine/overworld/movement.asm:139
|
||||
local function textBoxUp()
|
||||
local stack = require("src.core.Game").stack
|
||||
local top = stack and stack.top and stack:top()
|
||||
return top ~= nil and not top.isOverworld
|
||||
end
|
||||
|
||||
function NPC:walkPhase()
|
||||
if not self.moving then return 0 end
|
||||
if not self.moving or textBoxUp() then return 0 end
|
||||
-- engine/overworld/movement.asm:301
|
||||
local p = (self.animClock or 0) % 16
|
||||
return (p >= 4 and p < 12) and 1 or 0
|
||||
|
||||
@@ -20,6 +20,7 @@ local Player = require("src.world.Player")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local ScriptRunner = require("src.script.ScriptRunner")
|
||||
local Theme = require("src.ui.Theme")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local Transition = require("src.render.Transition")
|
||||
@@ -1109,6 +1110,13 @@ function OverworldState:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- EnterMapAnim's .done tail re-enables the companion once the swoop or the
|
||||
-- spin-down has landed (player_animations.asm:40)
|
||||
if self.pikachuWarpHidden and not (self.flyAnim or self.flyArrive
|
||||
or self.teleportOut or self.transitioning or self.player.spinning) then
|
||||
self:showPikachuAfterWarp()
|
||||
end
|
||||
|
||||
-- Dig/Teleport/Escape-Rope departure spin (beginTeleportOut). The sprite
|
||||
-- spins UP out of the map before the fade (player_animations.asm
|
||||
-- _LeaveMapAnim -> PlayerSpinWhileMovingUp + SFX_TELEPORT_EXIT_1), the
|
||||
@@ -1190,7 +1198,9 @@ function OverworldState:update(dt)
|
||||
or self.engaging or self.emote or self.teleportOut
|
||||
or self.flyAnim or self.flyArrive
|
||||
end
|
||||
if not scripted and not self.transitioning then
|
||||
-- a scriptMove's onDone can push a text box on the frame it retires, and
|
||||
-- DisplayTextID owns the loop from there (home/text_script.asm:3)
|
||||
if not scripted and not self.transitioning and Game.stack:top() == self then
|
||||
self:handleInput()
|
||||
end
|
||||
|
||||
@@ -1716,6 +1726,28 @@ function OverworldState:syncSurfingPikachu()
|
||||
p.surfingPikachu = mon ~= nil and mon.species == "PIKACHU" or false
|
||||
end
|
||||
|
||||
-- _LeaveMapAnim drops the companion's sprite before the animation starts and
|
||||
-- EnterMapAnim only puts it back once landed -- home/pikachu.asm:1, :10
|
||||
function OverworldState:hidePikachuForWarp()
|
||||
self.pikachuWarpHidden = true
|
||||
require("src.world.PikachuFollower").setVisible(self, false)
|
||||
end
|
||||
|
||||
function OverworldState:showPikachuAfterWarp()
|
||||
self.pikachuWarpHidden = nil
|
||||
self.pikachuTrail = { x = self.player.cellX, y = self.player.cellY }
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
local npc = Follower.current(self)
|
||||
if npc then
|
||||
npc.cellX, npc.cellY = self.player.cellX, self.player.cellY
|
||||
npc.px, npc.py = npc.cellX * 16, npc.cellY * 16
|
||||
npc.targetX, npc.targetY = nil, nil
|
||||
npc.goalX, npc.goalY = nil, nil
|
||||
npc.moving = false
|
||||
end
|
||||
Follower.setVisible(self, true)
|
||||
end
|
||||
|
||||
-- The rejection loop shared by the Good and Super Rods
|
||||
-- (item_effects.asm ItemUseGoodRod .RandomLoop / ReadSuperRodData): an
|
||||
-- odd random byte is no bite; otherwise a 2-bit pick rerolls until it
|
||||
@@ -1822,6 +1854,7 @@ function OverworldState:flyTo(mapId)
|
||||
Game.save.forcedBike = nil -- HandleFlyWarpOrDungeonWarp res BIT_ALWAYS_ON_BIKE
|
||||
self.player.surfing = false
|
||||
self:syncSurfingPikachu()
|
||||
self:hidePikachuForWarp()
|
||||
-- _LeaveMapAnim .flyAnimation: the bird flaps in place (8 x Delay3),
|
||||
-- then SFX_FLY and the up-right path, a 40-frame beat off screen, and
|
||||
-- the exit over the top-left -- the warp fades only once the bird is
|
||||
@@ -1852,6 +1885,7 @@ function OverworldState:beginTeleportOut(onDone)
|
||||
require("src.core.Sound").play(Game.data, "Teleport_Exit1")
|
||||
self.player.surfing = false
|
||||
self:syncSurfingPikachu()
|
||||
self:hidePikachuForWarp()
|
||||
self.player.inputLocked = true
|
||||
-- rising spin: the mirror of the arrival spin-drop set in startWarpTo, so
|
||||
-- spinRise lifts the sprite (Player:pose) while spinFrames counts down
|
||||
@@ -3045,6 +3079,7 @@ function OverworldState:nurseHeal(onDone, npc)
|
||||
end
|
||||
-- Yellow's companion has its own beat threaded through this sequence
|
||||
local Follower = require("src.world.PikachuFollower")
|
||||
-- YesNoChoicePokeCenter draws HEAL/CANCEL, not YES/NO (home/yes_no.asm:21)
|
||||
Game.stack:push(TextBox.new(Game, hello, nil, { choice = function(yes)
|
||||
if not yes then
|
||||
Game.stack:push(TextBox.new(Game, bye, onDone))
|
||||
@@ -3093,7 +3128,7 @@ function OverworldState:nurseHeal(onDone, npc)
|
||||
end
|
||||
end))
|
||||
end)
|
||||
end }))
|
||||
end, choiceLabels = { "HEAL", "CANCEL" }, choiceBox = Theme.healCancelBox }))
|
||||
end
|
||||
|
||||
-- pokecenter.asm bows the nurse between the two PrintText calls (#995)
|
||||
@@ -4526,6 +4561,11 @@ function OverworldState:startWarpTo(mapId, x, y, facing, onDone, opts)
|
||||
-- down, so the player is never drawable mid-fade nor standing bare on the
|
||||
-- landing frame (#916)
|
||||
self.playerHidden = false
|
||||
-- setMap respawned a follower under the player; _LeaveMapAnim's Func_1510
|
||||
-- suppression runs until EnterMapAnim lands (home/pikachu.asm:1)
|
||||
if self.pikachuWarpHidden then
|
||||
require("src.world.PikachuFollower").setVisible(self, false)
|
||||
end
|
||||
-- The warp we land ON stays inert for the completed-step check until we
|
||||
-- physically step off it, so a warp whose destination cell is itself a
|
||||
-- warp cannot bounce us straight back (elevator cars, stacked stair/door
|
||||
|
||||
@@ -226,7 +226,16 @@ function Player:facingCell()
|
||||
return Collision.target(self.cellX, self.cellY, self.facing)
|
||||
end
|
||||
|
||||
-- UpdatePlayerSprite jumps to .notMoving while BIT_FONT_LOADED is set
|
||||
-- -- engine/overworld/movement.asm:57
|
||||
local function textBoxUp()
|
||||
local stack = require("src.core.Game").stack
|
||||
local top = stack and stack.top and stack:top()
|
||||
return top ~= nil and not top.isOverworld
|
||||
end
|
||||
|
||||
function Player:walkPhase()
|
||||
if textBoxUp() then return 0 end
|
||||
-- moving, the land-frame after a completed step, or an active wall-bonk
|
||||
-- (issue #230) animate; a standing sprite otherwise
|
||||
if not self.moving and not self.stepLanded
|
||||
|
||||
@@ -10,10 +10,10 @@ local PixelCanvas = require("src.render.PixelCanvas")
|
||||
|
||||
local MapPreview = {}
|
||||
|
||||
-- home/map.asm:1739-1748 LoadTilesetGFX
|
||||
local ROOF_TILESETS = {
|
||||
TILESET_JOHTO = true,
|
||||
TILESET_JOHTO_MODERN = true,
|
||||
TILESET_KANTO = true,
|
||||
}
|
||||
|
||||
local function applyRoofOverlay(atlasPath, roofPath, tilesPerRow)
|
||||
|
||||
@@ -117,6 +117,15 @@ local VAR = {
|
||||
SPECIALPHONECALL = 0x14,
|
||||
}
|
||||
|
||||
-- constants/ram_constants.asm:293 wPlayerState. PLAYER_SKATE (2) has no row:
|
||||
-- nothing writes it, and FieldMoves has no string for it either.
|
||||
local PLAYER_STATE_BY_ID = {
|
||||
[0] = FieldMoves.PLAYER_NORMAL,
|
||||
[1] = FieldMoves.PLAYER_BIKE,
|
||||
[4] = FieldMoves.PLAYER_SURF,
|
||||
[8] = FieldMoves.PLAYER_SURF_PIKA,
|
||||
}
|
||||
|
||||
local BATTLETYPE = {
|
||||
CANLOSE = 1,
|
||||
FORCESHINY = 7,
|
||||
@@ -5207,6 +5216,7 @@ function World:dropMapImages(mapId)
|
||||
if key:sub(1, #prefix) == prefix then store[key] = nil end
|
||||
end
|
||||
end
|
||||
if self.connectionMaps then self.connectionMaps[mapId] = nil end
|
||||
end
|
||||
|
||||
-- LoadMapAttributes' refill, for every map the session has edited. Neighbour
|
||||
@@ -7480,13 +7490,10 @@ function World:zoomScale()
|
||||
return Zoom.scale(self:fitScale())
|
||||
end
|
||||
|
||||
-- Outdoor Johto/Kanto town tilesets are the only ones that swap in roof
|
||||
-- tiles $0a-$12 (mapgroup_roofs.asm). Applying roofs to indoor tilesets
|
||||
-- in the same map group (lab, houses) corrupts their GFX.
|
||||
-- home/map.asm:1739 LoadTilesetGFX
|
||||
local ROOF_TILESETS = {
|
||||
TILESET_JOHTO = true,
|
||||
TILESET_JOHTO_MODERN = true,
|
||||
TILESET_KANTO = true,
|
||||
}
|
||||
|
||||
function World:atlasFor(mapDef)
|
||||
@@ -8724,6 +8731,44 @@ function World:spawnFacing()
|
||||
end
|
||||
end
|
||||
|
||||
-- A neighbour map, built once and kept for its collision alone: the seam
|
||||
-- queries below run on the per-step path.
|
||||
function World:connectionMap(mapId)
|
||||
self.connectionMaps = self.connectionMaps or {}
|
||||
local cached = self.connectionMaps[mapId]
|
||||
if cached ~= nil then return cached or nil end
|
||||
local def = self.maps[mapId]
|
||||
local tileset = def and self.tilesets[def.tileset]
|
||||
local map = (def and tileset) and Map.new(def, tileset) or false
|
||||
self.connectionMaps[mapId] = map
|
||||
return map or nil
|
||||
end
|
||||
|
||||
-- home/map.asm:1908 GetMovementPermissions
|
||||
function World:cellCollisionAcross(map, cx, cy)
|
||||
if map:inBounds(cx, cy) then return map:cellCollision(cx, cy) end
|
||||
local dir
|
||||
if cy < 0 then dir = "up"
|
||||
elseif cy >= map.heightCells then dir = "down"
|
||||
elseif cx < 0 then dir = "left"
|
||||
elseif cx >= map.widthCells then dir = "right"
|
||||
end
|
||||
local conn = dir and map:connection(DIR_CONN[dir])
|
||||
local dest = conn and conn.mapId and self.maps[conn.mapId]
|
||||
local destMap = dest and self:connectionMap(conn.mapId)
|
||||
if destMap then
|
||||
local x, y = Map.connectionLanding(dest, conn, dir, cx, cy)
|
||||
local vertical = dir == "up" or dir == "down"
|
||||
local want = (vertical and cx or cy) - (conn.offset or 0) * 2
|
||||
-- connectionLanding clamps into the destination; past the end of the strip
|
||||
-- the buffer still holds this map's own border block
|
||||
if x and (vertical and x or y) == want then
|
||||
return destMap:cellCollision(x, y)
|
||||
end
|
||||
end
|
||||
return map:cellCollision(cx, cy)
|
||||
end
|
||||
|
||||
-- Seamless edge cross: swap map data, park the player one cell before the
|
||||
-- landing (same world pixels the neighbor strip already showed), and keep
|
||||
-- the step running so the seam does not hitch.
|
||||
@@ -8736,7 +8781,13 @@ function World:tryConnection(dir)
|
||||
local x, y = Map.connectionLanding(
|
||||
dest, conn, dir, self.player.cellX, self.player.cellY)
|
||||
if not x then return false end
|
||||
local destMap = Map.new(dest, self.tilesets[dest.tileset])
|
||||
local destMap = self:connectionMap(conn.mapId)
|
||||
if not destMap then return false end
|
||||
-- home/map.asm:1946 GetMovementPermissions side-wall arm
|
||||
if dir == "down"
|
||||
and Permissions.neighborBlocksDown(dir, destMap:cellCollision(x, y)) then
|
||||
return false
|
||||
end
|
||||
-- A surfing crossing lands on water, which isWalkable refuses; the arm the
|
||||
-- step would have taken is what decides, the same as it does inside the map.
|
||||
local landable
|
||||
@@ -8869,7 +8920,8 @@ function World:movePlayer(dir)
|
||||
-- .CheckLandPerms and .CheckSurfPerms read the same wTilePermissions, so the
|
||||
-- veto applies to walking and surfing alike.
|
||||
local permitted = Permissions.stepPermitted(
|
||||
function(x, y) return map:cellCollision(x, y) end, p.cellX, p.cellY, dir)
|
||||
function(x, y) return self:cellCollisionAcross(map, x, y) end,
|
||||
p.cellX, p.cellY, dir)
|
||||
-- `.CheckNPC`'s IsNPCAtCoord answers for a BIG_OBJECT's whole 2x2 blob, and
|
||||
-- Player:tryMove's entity scan only ever compares the one cell an object
|
||||
-- stands on -- so the three cells the Vermilion Snorlax overhangs are vetoed
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
-- Driver: Fly / Teleport must take Pikachu with it (#1400). _LeaveMapAnim
|
||||
-- drops the companion sprite before the bird flaps (home/pikachu.asm:1) and
|
||||
-- EnterMapAnim only puts it back once the swoop or the spin has landed, so
|
||||
-- Pikachu is invisible for the whole sequence and never stands on the landing
|
||||
-- cell ahead of the player.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/fly_pikachu_bug1400_test.lua \
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
--
|
||||
-- Never add POKEPORT_SPEED; the run needs an imported Yellow cache.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PF = require("src.world.PikachuFollower")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow())
|
||||
|
||||
game.save.flags = game.save.flags or {}
|
||||
game.save.flags.EVENT_GOT_STARTER = true
|
||||
game.save.flags.EVENT_BATTLED_RIVAL_IN_OAKS_LAB = true
|
||||
-- DisablePikachuOverworldSpriteDrawing is what keeps it in the ball
|
||||
-- (pokeyellow scripts/OaksLab.asm); out of the ball is what follows (#1009)
|
||||
game.save.pikachuInBall = false
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 20) }
|
||||
game.save.onBike = false
|
||||
|
||||
local function drawn()
|
||||
local ow = game.overworld
|
||||
local npc = ow and PF.current(ow)
|
||||
if not npc then return false end
|
||||
for _, e in ipairs(ow.entities or {}) do
|
||||
if e == npc then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
U.teleport(game, "ROUTE_17", 4, 10, "down")
|
||||
U.wait(30)
|
||||
local ow = game.stack:top()
|
||||
check("Pikachu is out and drawn before the flight", drawn())
|
||||
U.shot(game, DIR .. "/bug1400_0_before.png")
|
||||
|
||||
ow:flyTo("PALLET_TOWN")
|
||||
U.wait(12) -- mid in-place flap
|
||||
check("hidden during the departure flap", not drawn())
|
||||
U.shot(game, DIR .. "/bug1400_1_flap.png")
|
||||
|
||||
U.wait(60) -- the bird's path out
|
||||
check("still hidden while the bird flies off", not drawn())
|
||||
|
||||
local guard = 0
|
||||
while ow.map.id == "ROUTE_17" and guard < 900 do
|
||||
guard = guard + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
guard = 0
|
||||
while not ow.flyArrive and guard < 900 do
|
||||
guard = guard + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(12) -- mid swoop, the frame the old bug showed Pikachu already landed
|
||||
check("hidden through the arrival swoop", not drawn())
|
||||
U.shot(game, DIR .. "/bug1400_2_arrive.png")
|
||||
|
||||
guard = 0
|
||||
while ow.flyArrive and guard < 900 do
|
||||
guard = guard + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(10)
|
||||
check("back out once the bird has landed", drawn())
|
||||
do
|
||||
local npc = PF.current(ow)
|
||||
local p = ow.player
|
||||
check("and it comes back on the player's own cell",
|
||||
npc and p and npc.cellX == p.cellX and npc.cellY == p.cellY)
|
||||
end
|
||||
U.shot(game, DIR .. "/bug1400_3_landed.png")
|
||||
|
||||
-- Dig / Teleport / Escape Rope take the same _LeaveMapAnim path
|
||||
game.save.lastHeal = { map = "VIRIDIAN_CITY", x = 23, y = 26 }
|
||||
ow:beginTeleportOut()
|
||||
U.wait(20)
|
||||
check("hidden during the teleport-out spin", not drawn())
|
||||
U.shot(game, DIR .. "/bug1400_4_spin_out.png")
|
||||
|
||||
guard = 0
|
||||
while (ow.teleportOut or ow.player.spinning or ow.transitioning)
|
||||
and guard < 900 do
|
||||
guard = guard + 1
|
||||
coroutine.yield()
|
||||
end
|
||||
U.wait(10)
|
||||
check("back out once the arrival spin has landed", drawn())
|
||||
U.shot(game, DIR .. "/bug1400_5_spin_in.png")
|
||||
|
||||
U.log("Watch the shots in order: Pikachu stands beside the player before")
|
||||
U.log("the flight, is nowhere on screen for the flap, the fade and the")
|
||||
U.log("swoop, and only reappears under him once the bird sets him down.")
|
||||
U.log("A Pikachu standing on the landing cell during the swoop is the bug.")
|
||||
U.log("The pad is yours -- fly around and watch the departures.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
-- #1401: $d9/$da battlergfx loaded the wrong row count
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1401_test.lua love .
|
||||
|
||||
local U = require("tests.drivers.util")
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
-- data/moves/animations.asm:1840 Growl, :3605 Icy Wind
|
||||
local CASES = {
|
||||
{ move = "GROWL", cmd = "battlergfx_2row", rows = 1, head = 7, feet = 6 },
|
||||
{ move = "ICY_WIND", cmd = "battlergfx_1row", rows = 2, head = 14, feet = 12 },
|
||||
}
|
||||
|
||||
local function sheet(runner, gfx)
|
||||
for _, entry in ipairs(runner.loaded or {}) do
|
||||
if entry.gfx == gfx then return entry end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function liftedStrip(runner)
|
||||
local structs = runner.objects and runner.objects.structs or {}
|
||||
for slot = 1, #structs do
|
||||
local id = structs[slot].objectId
|
||||
if type(id) == "string" and id:match("PLAYERHEAD") then return id end
|
||||
if type(id) == "string" and id:match("ENEMYFEET") then return id end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
game.save.party = { Mon.new(game.data, "CYNDAQUIL", 30) }
|
||||
assert(world:startBattle({ wild = Mon.new(game.data, "PIDGEY", 30) }),
|
||||
"startBattle failed")
|
||||
|
||||
local battle
|
||||
for _ = 1, 600 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then battle = top break end
|
||||
U.wait(1)
|
||||
end
|
||||
assert(battle and battle.battle, "battle screen is not on the stack")
|
||||
assert(battle.anims and battle.anims.scripts,
|
||||
"battle_anims.lua has no scripts -- re-import Gold")
|
||||
|
||||
local failed = false
|
||||
for _, case in ipairs(CASES) do
|
||||
battle.anim = nil
|
||||
if not battle:animForMove(case.move, "player") then
|
||||
U.log("FAIL no animation for", case.move)
|
||||
failed = true
|
||||
else
|
||||
local head, feet
|
||||
for _ = 1, 400 do
|
||||
local runner = battle.anim
|
||||
if not runner then break end
|
||||
head = head or sheet(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
feet = feet or sheet(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
|
||||
U.wait(1)
|
||||
end
|
||||
if not (head and feet) then
|
||||
U.log("FAIL", case.move, "never loaded the battler sheets")
|
||||
failed = true
|
||||
else
|
||||
U.log(("%-9s %-16s rows=%d head=%d feet=%d (want rows=%d head=%d feet=%d)")
|
||||
:format(case.move, case.cmd, head.rows, head.tiles, feet.tiles,
|
||||
case.rows, case.head, case.feet))
|
||||
if head.rows ~= case.rows or head.tiles ~= case.head
|
||||
or feet.tiles ~= case.feet then
|
||||
U.log("FAIL", case.move, "loaded the wrong row count")
|
||||
failed = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
U.log(failed and "FAIL #1401" or "PASS #1401 battlergfx rows follow the jumptable")
|
||||
|
||||
U.log("look: Growl lifts ONE row of the Cyndaquil's own tiles, not two")
|
||||
battle.anim = nil
|
||||
battle:animForMove("GROWL", "player")
|
||||
for _ = 1, 400 do
|
||||
if not battle.anim then break end
|
||||
local strip = liftedStrip(battle.anim)
|
||||
if strip then
|
||||
U.log("parked on", strip)
|
||||
break
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
-- #1421: the Gold battle HUD ran ahead of its own animations.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1421_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1421 (default)
|
||||
--
|
||||
-- Two symptoms, one seam, and neither can be asserted -- both are "what is on
|
||||
-- screen while an animation runs":
|
||||
--
|
||||
-- caught a wild mon that is NOT in the dex is caught with a MASTER BALL.
|
||||
-- The caught mark ($5d at (1,1)) must be absent for every frame of
|
||||
-- the throw and every line after it, because DrawEnemyHUDBorder --
|
||||
-- the only thing that paints it -- is never called again during a
|
||||
-- capture (engine/battle/trainer_huds.asm:134-151). The control
|
||||
-- run right after it re-enters a battle with the same species now
|
||||
-- in the dex, where the mark IS there from the first frame.
|
||||
--
|
||||
-- status THUNDER WAVE on the enemy. PAR may not be on the HUD until the
|
||||
-- animation and the "is paralyzed!" line are done with, which is
|
||||
-- where UpdateBattleHuds runs (home/battle.asm:150).
|
||||
--
|
||||
-- Every frame of both animations is logged as `live` (the engine's byte, a
|
||||
-- whole turn ahead) against `hud` (what the HUD is allowed to print), so the
|
||||
-- lag is a column of text as well as a strip of pictures. The run ends in a
|
||||
-- third wild battle with THUNDER WAVE under the cursor: press A, A, A and
|
||||
-- watch the tag land with its own line.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
|
||||
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1421"
|
||||
local WILD = "PIDGEY"
|
||||
|
||||
local hostVisible = love.visible
|
||||
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
|
||||
|
||||
local function tap(game, button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function openBattle(game, opts)
|
||||
assert(game.world:startBattle(opts), "startBattle failed")
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
local function toMenu(game, screen)
|
||||
for _ = 1, 400 do
|
||||
if screen.phase == "menu" then return true end
|
||||
if screen.battle.over then return false end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
return screen.phase == "menu"
|
||||
end
|
||||
|
||||
local function giveMoves(game, mon, moves)
|
||||
mon.moves = {}
|
||||
for i, id in ipairs(moves) do
|
||||
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
|
||||
mon.moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
-- Shoot and log while the screen is busy: `probe` returns the two values the
|
||||
-- run is about, and the caller gets them for every frame.
|
||||
local function watch(game, screen, prefix, label, probe, limit)
|
||||
local frames = 0
|
||||
while frames < (limit or 240) do
|
||||
if frames % 3 == 0 then
|
||||
U.shot(game, ("%s-%03d.png"):format(prefix, frames))
|
||||
local live, hud = probe()
|
||||
U.log(("[driver] %s f%03d live=%s hud=%s")
|
||||
:format(label, frames, tostring(live), tostring(hud)))
|
||||
end
|
||||
if not screen.anim and screen.phase ~= "resolving" then break end
|
||||
frames = frames + 1
|
||||
U.wait(1)
|
||||
end
|
||||
return frames
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local save = game.save
|
||||
save.pokedex = save.pokedex or { seen = {}, caught = {} }
|
||||
save.pokedex.caught[WILD] = nil
|
||||
save.inventory = { MASTER_BALL = 5, POKE_BALL = 10 }
|
||||
save.boxes = nil
|
||||
save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30),
|
||||
{ "THUNDER_WAVE", "TACKLE" }) }
|
||||
|
||||
------------------------------------------------------------------ caught
|
||||
local screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) })
|
||||
assert(toMenu(game, screen), "never reached the battle menu")
|
||||
U.shot(game, OUT .. "/caught-00-menu.png")
|
||||
U.log(("[driver] caught: dex before = %s, hud latch = %s")
|
||||
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
|
||||
|
||||
screen:useItem("MASTER_BALL")
|
||||
watch(game, screen, OUT .. "/caught-01-throw", "caught",
|
||||
function()
|
||||
return save.pokedex.caught[WILD] and true or false, screen.caughtMark
|
||||
end, 200)
|
||||
|
||||
-- The lines that follow the throw: "Gotcha!", the dex entry, the nickname
|
||||
-- prompt. The mark may not appear on any of them either.
|
||||
for i = 0, 7 do
|
||||
U.shot(game, ("%s/caught-02-after-%d.png"):format(OUT, i))
|
||||
if screen.phase == "ask-nickname" then tap(game, "b", 4)
|
||||
else tap(game, "a", 4) end
|
||||
if screen.phase == "done" or not game.stack:top() then break end
|
||||
end
|
||||
U.log(("[driver] caught: dex after = %s, hud latch = %s")
|
||||
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
|
||||
|
||||
for _ = 1, 300 do
|
||||
if game.world and not (game.stack:top() or {}).battle then break end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
|
||||
------------------------------------------------------------------ control
|
||||
-- Same species, now in the dex: the mark IS on the HUD from the first frame
|
||||
-- the enemy HUD is drawn, because DrawEnemyHUDBorder runs at battle start.
|
||||
save.party = { giveMoves(game, Mon.new(game.data, "CYNDAQUIL", 30),
|
||||
{ "THUNDER_WAVE", "TACKLE" }) }
|
||||
screen = openBattle(game, { wild = Mon.new(game.data, WILD, 5) })
|
||||
assert(toMenu(game, screen), "never reached the battle menu")
|
||||
U.shot(game, OUT .. "/control-00-mark.png")
|
||||
U.log(("[driver] control: dex = %s, hud latch = %s")
|
||||
:format(tostring(save.pokedex.caught[WILD]), tostring(screen.caughtMark)))
|
||||
|
||||
------------------------------------------------------------------ status
|
||||
screen:submit({ kind = "move", move = "THUNDER_WAVE" })
|
||||
watch(game, screen, OUT .. "/status-01-anim", "status",
|
||||
function()
|
||||
local enemy = screen.battle.enemy
|
||||
return enemy and enemy.status, screen:hudStatus(enemy, "enemy")
|
||||
end, 240)
|
||||
U.shot(game, OUT .. "/status-02-line.png")
|
||||
for i = 0, 4 do
|
||||
tap(game, "a", 4)
|
||||
U.shot(game, ("%s/status-03-after-%d.png"):format(OUT, i))
|
||||
end
|
||||
U.log(("[driver] status: live=%s hud=%s"):format(
|
||||
tostring(screen.battle.enemy and screen.battle.enemy.status),
|
||||
tostring(screen:hudStatus(screen.battle.enemy, "enemy"))))
|
||||
|
||||
U.log("[driver] shots in " .. OUT
|
||||
.. " -- the battle is yours: FIGHT, THUNDER WAVE, watch the tag land")
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
-- #1425 (and #1424): the PACK's ×NN column.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1425_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1425 (default)
|
||||
--
|
||||
-- Nothing here can be asserted: the bug IS the column a digit lands in.
|
||||
-- PlaceMenuItemQuantity's `lb bc, 1, 2` (engine/menus/menu_2.asm:24) is a
|
||||
-- two-digit field with the leading digit blanked, so a ×5 and a ×50 must have
|
||||
-- their ones digit in the SAME column; the port printed "×5" hard against the
|
||||
-- cross. Each pocket is seeded with a one-digit and a two-digit count stacked
|
||||
-- next to each other so the two rows can be read off against one another, and
|
||||
-- the TM pocket is here because its rows used to print no count at all.
|
||||
--
|
||||
-- The run ends with the PACK still open on the TM pocket, so a human takes the
|
||||
-- controls exactly where the screenshots stop.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Bag = require("src.inventory.Bag")
|
||||
local PackMenu = require("src.ui.gen2.PackMenu")
|
||||
|
||||
-- One-digit and two-digit counts side by side in every pocket that shows one.
|
||||
local SEED = {
|
||||
{ "POTION", 5 },
|
||||
{ "SUPER_POTION", 50 },
|
||||
{ "ANTIDOTE", 1 },
|
||||
{ "FULL_HEAL", 99 },
|
||||
{ "POKE_BALL", 7 },
|
||||
{ "GREAT_BALL", 12 },
|
||||
{ "TM_DYNAMICPUNCH", 1 },
|
||||
{ "TM_HEADBUTT", 3 },
|
||||
{ "TM_THUNDER", 24 },
|
||||
{ "HM_CUT", 1 },
|
||||
{ "HM_SURF", 1 },
|
||||
}
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1425"
|
||||
|
||||
local function shot(name)
|
||||
U.wait(3)
|
||||
U.shot(game, ("%s/%s.png"):format(out, name))
|
||||
end
|
||||
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local save = game.save
|
||||
save.inventory = {}
|
||||
save.bagOrder = {}
|
||||
for _, entry in ipairs(SEED) do
|
||||
local id, count = entry[1], entry[2]
|
||||
if not (game.data.items and game.data.items[id]) then
|
||||
U.log("[driver] SKIP", id, "-- not in this cache")
|
||||
else
|
||||
save.inventory[id] = count
|
||||
table.insert(Bag.order(save), id)
|
||||
end
|
||||
end
|
||||
|
||||
local pack = PackMenu.new(game, { save = save, world = game.world,
|
||||
onClose = function() end })
|
||||
game.stack:push(pack)
|
||||
|
||||
shot("00-items") -- ×5 over ×50: the ones digits line up
|
||||
U.tap(game, "right")
|
||||
shot("01-balls") -- ×7 over ×12
|
||||
U.tap(game, "right")
|
||||
shot("02-key-items") -- no counts at all here
|
||||
U.tap(game, "right")
|
||||
shot("03-tmhm") -- TMs carry ×NN, the two HMs carry none
|
||||
|
||||
-- SELECT arms the row instead of registering it (#1427): the hollow ▷ marks
|
||||
-- TM_HEADBUTT while "Where should this be moved to?" holds the box.
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "select")
|
||||
shot("04-tmhm-armed")
|
||||
U.tap(game, "up")
|
||||
shot("05-tmhm-destination")
|
||||
U.tap(game, "a")
|
||||
shot("06-tmhm-moved") -- HEADBUTT is now the first TM row
|
||||
|
||||
U.log("[driver] bag order:", table.concat(Bag.order(save), " "))
|
||||
|
||||
-- Back to the ITEM pocket and into a TOSS, whose own box prints the count
|
||||
-- with leading zeros (×01, not × 1).
|
||||
U.tap(game, "left")
|
||||
U.tap(game, "left")
|
||||
U.tap(game, "left")
|
||||
U.tap(game, "a")
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "down")
|
||||
U.tap(game, "a")
|
||||
shot("07-toss-quantity")
|
||||
|
||||
U.log("[driver] shots in " .. out .. " -- the PACK is yours")
|
||||
end
|
||||
@@ -0,0 +1,174 @@
|
||||
-- #1428: the Gold battle HUD never drew the party ball rows.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1428_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1428 (default)
|
||||
--
|
||||
-- BattleStart_TrainerHuds (engine/battle/trainer_huds.asm:1-9) runs from
|
||||
-- inside BattleStartMessage, so the rows are on screen UNDER the opening line
|
||||
-- and nowhere else: the player's six always, the opponent's only outside a
|
||||
-- wild battle. EnemySwitch_TrainerHud (:11-15) brings the opponent's row
|
||||
-- back for the "will you switch?" prompt after one of its mons drops.
|
||||
--
|
||||
-- The party is seeded so all four staged tiles are on screen at once
|
||||
-- (StageBallTilesData, :47-99): healthy, statused, fainted, and the empty
|
||||
-- slots past the party count.
|
||||
--
|
||||
-- trainer the intro line, both rows, then the shift prompt after the first
|
||||
-- enemy mon faints -- the opponent's row again, one ball darkened
|
||||
-- wild the same intro with only the player's row, per the `dec a / ret z`
|
||||
--
|
||||
-- Nothing here is assertable: the fix IS twelve sprites. The run ends on the
|
||||
-- trainer battle's own menu with a human holding the controls.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local Trainers = require("src.world.gen2.Trainers")
|
||||
|
||||
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1428"
|
||||
|
||||
local hostVisible = love.visible
|
||||
love.visible = function(v) if hostVisible then pcall(hostVisible, v) end end
|
||||
|
||||
local function tap(game, button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function openBattle(game, opts)
|
||||
assert(game.world:startBattle(opts), "startBattle failed")
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
-- Four healthy, one poisoned, one fainted, and one slot left empty: every
|
||||
-- tile StageBallTilesData can stage, in one row. The lead is handed a
|
||||
-- damaging move outright, because slot 1 of its level-up set is LEER.
|
||||
local function seedParty(game)
|
||||
local party = {}
|
||||
for i = 1, 5 do
|
||||
party[i] = Mon.new(game.data, "CYNDAQUIL", 20 + i)
|
||||
end
|
||||
party[1].moves = {}
|
||||
for i, id in ipairs({ "EMBER", "TACKLE" }) do
|
||||
local def = assert(game.data.moves[id], id .. " is not in moves.lua")
|
||||
party[1].moves[i] = { id = id, pp = def.pp, maxPp = def.pp }
|
||||
end
|
||||
party[4].status = "poison"
|
||||
party[5].hp = 0
|
||||
return party
|
||||
end
|
||||
|
||||
local function rowState(screen)
|
||||
local rows = screen.ballRows or {}
|
||||
return ("player=%s enemy=%s balls=%s"):format(
|
||||
tostring(rows.player), tostring(rows.enemy),
|
||||
tostring(screen.hud and screen.hud:image("balls") ~= nil))
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
local save = game.save
|
||||
save.inventory = {}
|
||||
save.party = seedParty(game)
|
||||
|
||||
--------------------------------------------------------------- trainer
|
||||
local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1
|
||||
assert(entry, "no BUG_CATCHER member 1 in trainers.lua")
|
||||
entry.party = Trainers.party(game.data, entry)
|
||||
local screen = openBattle(game, { trainer = entry })
|
||||
|
||||
-- The opening line: both rows, both borders, and no names or bars yet.
|
||||
-- Not before the intro slide has settled, or the shots catch the pics
|
||||
-- mid-transform instead of the HUD.
|
||||
U.wait(150)
|
||||
for i = 0, 6 do
|
||||
U.shot(game, ("%s/trainer-00-intro-%d.png"):format(OUT, i))
|
||||
U.log("[driver] intro " .. i .. ": " .. rowState(screen))
|
||||
U.wait(6)
|
||||
end
|
||||
U.log(("[driver] OT party = %d"):format(#(screen.battle.enemyParty or {})))
|
||||
|
||||
-- Page through the pic slide and the send-out: the rows go with the OAM the
|
||||
-- send-out animation takes over.
|
||||
for i = 0, 7 do
|
||||
tap(game, "a", 5)
|
||||
U.shot(game, ("%s/trainer-01-sendout-%d.png"):format(OUT, i))
|
||||
end
|
||||
for _ = 1, 400 do
|
||||
if screen.phase == "menu" then break end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
U.shot(game, OUT .. "/trainer-02-menu.png")
|
||||
U.log("[driver] menu: " .. rowState(screen))
|
||||
|
||||
--------------------------------------------------------------- shift prompt
|
||||
-- Drop the enemy's lead so HandleEnemySwitch offers the switch: its row is
|
||||
-- redrawn for the prompt, with the fainted ball darkened.
|
||||
if #(screen.battle.enemyParty or {}) > 1 then
|
||||
for _ = 1, 400 do
|
||||
if screen.phase == "ask-shift" or screen.phase == "shift-intro" then
|
||||
break
|
||||
end
|
||||
if screen.battle.over then break end
|
||||
if screen.phase == "menu" then
|
||||
local enemy = screen.battle.enemy
|
||||
if enemy then enemy.hp = 1 end
|
||||
screen:chooseMenu("fight")
|
||||
U.wait(2)
|
||||
screen:chooseMove(1)
|
||||
U.wait(4)
|
||||
else
|
||||
tap(game, "a", 6)
|
||||
end
|
||||
end
|
||||
U.log("[driver] shift prompt: phase=" .. tostring(screen.phase))
|
||||
for i = 0, 5 do
|
||||
U.shot(game, ("%s/trainer-03-shift-%d.png"):format(OUT, i))
|
||||
U.log("[driver] shift " .. i .. ": phase=" .. tostring(screen.phase)
|
||||
.. " " .. rowState(screen))
|
||||
U.wait(6)
|
||||
end
|
||||
-- NO: the enemy sends its next mon and the row goes with the animation.
|
||||
tap(game, "down", 4)
|
||||
tap(game, "a", 4)
|
||||
for i = 0, 5 do
|
||||
U.shot(game, ("%s/trainer-04-after-shift-%d.png"):format(OUT, i))
|
||||
tap(game, "a", 5)
|
||||
end
|
||||
else
|
||||
U.log("[driver] this trainer has one mon; no shift prompt to show")
|
||||
end
|
||||
|
||||
--------------------------------------------------------------- wild
|
||||
-- ShowPlayerMonsRemaining runs for a wild battle too; the `dec a / ret z`
|
||||
-- right after it is what keeps the OT row off.
|
||||
for _ = 1, 600 do
|
||||
if not (game.stack:top() or {}).battle then break end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
save.party = seedParty(game)
|
||||
screen = openBattle(game, { wild = Mon.new(game.data, "PIDGEY", 5) })
|
||||
U.wait(150)
|
||||
for i = 0, 6 do
|
||||
U.shot(game, ("%s/wild-00-intro-%d.png"):format(OUT, i))
|
||||
U.log("[driver] wild " .. i .. ": " .. rowState(screen))
|
||||
U.wait(6)
|
||||
end
|
||||
for _ = 1, 400 do
|
||||
if screen.phase == "menu" then break end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
U.shot(game, OUT .. "/wild-01-menu.png")
|
||||
|
||||
U.log("[driver] shots in " .. OUT .. " -- the battle is yours")
|
||||
end
|
||||
@@ -0,0 +1,142 @@
|
||||
-- Driver: the shiny sparkle on a SENT OUT mon (#1431).
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1431_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1431 (default)
|
||||
--
|
||||
-- SendOutPlayerMon calls BattleCheckPlayerShininess and replays
|
||||
-- ANIM_SEND_OUT_MON with wBattleAnimParam 1 -- the script's `.Shiny` arm --
|
||||
-- between the plain animation and the cry (engine/battle/core.asm:3820-3837);
|
||||
-- ShowSetEnemyMonAndSendOutAnimation does the same for every enemy send-out
|
||||
-- (:3364-3383). Only the wild-intro path (BattleStartMessage, :8701-8715) had
|
||||
-- been ported, so a shiny lead-off, a shiny switch-in and a shiny enemy
|
||||
-- replacement all came out silent.
|
||||
--
|
||||
-- Three send-outs to watch, none of them the wild intro: the player's lead-off,
|
||||
-- the player's own switch, and the opponent's replacement after a faint.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local Mon = require("src.battle.gen2.Mon")
|
||||
local GbcPalette = require("src.render.GbcPalette")
|
||||
|
||||
-- constants/battle_constants.asm: the DV pair BATTLETYPE_FORCESHINY forces.
|
||||
local SHINY_DVS = { attack = 14, defense = 10, speed = 10, special = 10 }
|
||||
|
||||
local OUT = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1431"
|
||||
|
||||
local function tap(game, button, frames)
|
||||
game.input.pressQueue[#game.input.pressQueue + 1] = button
|
||||
game.input.state[button] = true
|
||||
U.wait(2)
|
||||
game.input.state[button] = false
|
||||
U.wait(frames or 6)
|
||||
end
|
||||
|
||||
local function openBattle(game, opts)
|
||||
assert(game.world:startBattle(opts), "startBattle failed")
|
||||
for _ = 1, 900 do
|
||||
local top = game.stack:top()
|
||||
if top and top.battle then return top end
|
||||
U.wait(1)
|
||||
end
|
||||
error("battle screen never came up")
|
||||
end
|
||||
|
||||
-- Stop on the frame the `.Shiny` arm is actually running: animId plus the
|
||||
-- wBattleAnimParam the arm branches on (data/moves/animations.asm:414-417).
|
||||
local function shinyArm(screen)
|
||||
local anim = screen.anim
|
||||
return anim ~= nil and anim.animId == "ANIM_SEND_OUT_MON"
|
||||
and anim.param == 1
|
||||
end
|
||||
|
||||
local function watch(game, screen, label, frames)
|
||||
for _ = 1, (frames or 400) do
|
||||
if shinyArm(screen) then
|
||||
U.log("[driver] " .. label .. ": .Shiny arm running")
|
||||
U.shot(game, ("%s/%s.png"):format(OUT, label))
|
||||
return true
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
U.log("[driver] " .. label .. ": NO shiny arm seen")
|
||||
return false
|
||||
end
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
assert(game.world and game.world.map, "gold world did not boot")
|
||||
|
||||
-- A shiny only reads as one in colour, and the sparkle rides the same anim.
|
||||
GbcPalette.setMode("gbc")
|
||||
|
||||
local save = game.save
|
||||
save.inventory = {}
|
||||
local lead = Mon.new(game.data, "CYNDAQUIL", 40, { dvs = SHINY_DVS })
|
||||
local bench = Mon.new(game.data, "TOTODILE", 40, { dvs = SHINY_DVS })
|
||||
assert(lead.shiny and bench.shiny, "the FORCESHINY DVs did not take")
|
||||
local move = assert(game.data.moves.EMBER, "no EMBER in moves.lua")
|
||||
lead.moves = { { id = "EMBER", pp = move.pp, maxPp = move.pp } }
|
||||
save.party = { lead, bench }
|
||||
|
||||
local entry = game.world:trainerParty(36, 1) -- BUG_CATCHER member 1
|
||||
assert(entry, "no BUG_CATCHER member 1 in trainers.lua")
|
||||
local Trainers = require("src.world.gen2.Trainers")
|
||||
entry.party = Trainers.party(game.data, entry)
|
||||
for _, mon in ipairs(entry.party) do mon.shiny = true end
|
||||
local screen = openBattle(game, { trainer = entry })
|
||||
|
||||
------------------------------------------------------------ enemy lead
|
||||
-- ShowSetEnemyMonAndSendOutAnimation, out of the opening sequence.
|
||||
watch(game, screen, "00-enemy-sendout", 900)
|
||||
|
||||
----------------------------------------------------------- player lead
|
||||
-- SendOutPlayerMon behind the "Go!" line: this is the one the report is
|
||||
-- about, and it never sparkled before the fix.
|
||||
local sawPlayer = watch(game, screen, "01-player-sendout", 900)
|
||||
|
||||
for _ = 1, 600 do
|
||||
if screen.phase == "menu" then break end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
|
||||
---------------------------------------------------------- player switch
|
||||
-- The same routine again, this time as a voluntary mid-battle switch.
|
||||
local sawSwitch = false
|
||||
if screen.phase == "menu" and (screen.battle.party or {})[2] then
|
||||
screen:submit({ kind = "switch", index = 2 })
|
||||
sawSwitch = watch(game, screen, "02-player-switch", 900)
|
||||
for _ = 1, 600 do
|
||||
if screen.phase == "menu" or screen.battle.over then break end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
end
|
||||
|
||||
------------------------------------------------------ enemy replacement
|
||||
local sawReplace = false
|
||||
if #(screen.battle.enemyParty or {}) > 1 then
|
||||
for _ = 1, 600 do
|
||||
if screen.battle.over then break end
|
||||
if screen.phase == "menu" then
|
||||
local enemy = screen.battle.enemy
|
||||
if enemy then enemy.hp = 1 end
|
||||
screen:chooseMenu("fight")
|
||||
U.wait(2)
|
||||
screen:chooseMove(1)
|
||||
break
|
||||
end
|
||||
tap(game, "a", 2)
|
||||
end
|
||||
sawReplace = watch(game, screen, "03-enemy-replacement", 900)
|
||||
end
|
||||
|
||||
U.log(("[driver] player lead-off %s, player switch %s, enemy replacement %s")
|
||||
:format(tostring(sawPlayer), tostring(sawSwitch), tostring(sawReplace)))
|
||||
U.log("[driver] shots in " .. OUT)
|
||||
U.log("[driver] every send-out of a shiny must flash, sparkle and chime")
|
||||
U.log("[driver] BEFORE its cry, not only the wild mon at battle start.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
-- #1441: the Magnet Train ride drew a flat white screen.
|
||||
--
|
||||
-- POKEPORT_GAME=gold POKEPORT_TOUCH=0 \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_bug1441_test.lua love .
|
||||
-- POKEPORT_SHOT_DIR=/tmp/gold-bug1441 (default)
|
||||
--
|
||||
-- Nothing here is assertable: the bug IS what the screen shows. The ride
|
||||
-- bakes its background out of data.gen2Field.magnetTrain, TILESET_TRAIN_
|
||||
-- STATION's sheet and the TOWN palettes, and every one of those was read
|
||||
-- under its Gen 1 key, so all four came back nil and GbcPalette's fallback
|
||||
-- filled the panel with DMG_SHADES[1].
|
||||
--
|
||||
-- The run boots into the Goldenrod station, prints whether the four tables
|
||||
-- are actually there, shoots the Saffron-bound ride from JUMPTABLE_INIT
|
||||
-- through to the arrival, then plays the Goldenrod-bound one back at 1x with
|
||||
-- no screenshots in the way -- a human watching the window sees the whole
|
||||
-- animation, which is the only place this bug ever showed.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local STATION = "GOLDENROD_MAGNET_TRAIN_STATION"
|
||||
|
||||
return function(game)
|
||||
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-bug1441"
|
||||
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
world:setMap(STATION, 11, 8, "up")
|
||||
U.wait(10)
|
||||
|
||||
local data = game.data or {}
|
||||
local field = data.gen2Field and data.gen2Field.magnetTrain
|
||||
U.log("gen2Field.magnetTrain:", field and #field.bgTiles or "MISSING")
|
||||
U.log("gen2Tilesets.TILESET_TRAIN_STATION:",
|
||||
data.gen2Tilesets and data.gen2Tilesets.TILESET_TRAIN_STATION
|
||||
and data.gen2Tilesets.TILESET_TRAIN_STATION.image or "MISSING")
|
||||
U.log("gen2Palettes:", data.gen2Palettes and "ok" or "MISSING")
|
||||
U.log("gen2Sprites.SPRITE_CHRIS:",
|
||||
data.gen2Sprites and data.gen2Sprites.SPRITE_CHRIS and "ok" or "MISSING")
|
||||
|
||||
local done = false
|
||||
world:magnetTrain(true, function() done = true end)
|
||||
U.wait(2)
|
||||
|
||||
local ride = game.stack:top()
|
||||
U.log("ride background:", ride and ride.background
|
||||
and (ride:background() and "baked" or "nil canvas") or "no screen")
|
||||
|
||||
-- SFX_TRAIN_ARRIVED lands a good while in; shoot across the whole ride so
|
||||
-- the departure, the three scrolling bands and the stop are all on disk.
|
||||
for index = 0, 11 do
|
||||
U.shot(game, ("%s/%02d-ride.png"):format(out, index))
|
||||
U.wait(20)
|
||||
if done then break end
|
||||
end
|
||||
for _ = 1, 600 do
|
||||
if done then break end
|
||||
U.wait(5)
|
||||
end
|
||||
U.log("ride finished at frame", U.frame())
|
||||
U.log("shots in " .. out)
|
||||
|
||||
-- The return leg, played out in full with nothing else on screen: watch the
|
||||
-- window, not the PNGs. The ride reads no input, so it ends on its own.
|
||||
local back = false
|
||||
world:magnetTrain(false, function() back = true end)
|
||||
for _ = 1, 900 do
|
||||
if back then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(60)
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Eyeball driver (#1442): the radio card's tuning knob. PokegearRadio_Init
|
||||
-- spawns SPRITE_ANIM_OBJ_RADIO_TUNING_KNOB on tile $08 and AnimateTuningKnob
|
||||
-- writes wRadioTuningKnob into its XOFFSET, so a red needle stands in the dial
|
||||
-- box at screen x = 72 + knob and steps with every UP/DOWN. Before the fix the
|
||||
-- card drew the dial art and nothing in it.
|
||||
--
|
||||
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
|
||||
-- POKEPORT_SHOTS=/tmp/radioknob \
|
||||
-- POKEPORT_DRIVER=tests/drivers/gold_radio_knob_bug1442.lua \
|
||||
-- perl -e 'alarm 300; exec @ARGV' \
|
||||
-- python3 -c "import pty; pty.spawn(['love','.'])"
|
||||
--
|
||||
-- The run parks on the radio card with the knob on 08.5, so UP and DOWN move
|
||||
-- the needle by hand.
|
||||
local U = require("tests.drivers.util")
|
||||
|
||||
local SHOTS = os.getenv("POKEPORT_SHOTS") or "/tmp/radioknob"
|
||||
|
||||
return function(game)
|
||||
U.wait(45)
|
||||
local world = game.world
|
||||
assert(world and world.map, "gold world did not boot")
|
||||
|
||||
-- Goldenrod, where the card is handed out, and the two engine flags the
|
||||
-- START menu and the strip read: ENGINE_POKEGEAR and ENGINE_RADIO_CARD.
|
||||
assert(world:setMap("GOLDENROD_CITY", 12, 20, "down"),
|
||||
"setMap failed for GOLDENROD_CITY")
|
||||
world:setEngineFlag(4, true) -- ENGINE_POKEGEAR
|
||||
world:setEngineFlag(0, true) -- ENGINE_RADIO_CARD
|
||||
U.wait(5)
|
||||
|
||||
game:openStartMenuItem("pokegear")
|
||||
U.wait(5)
|
||||
local gear = game.stack:top()
|
||||
assert(gear and gear.cards, "the POKeGEAR did not open")
|
||||
gear.mode = "card"
|
||||
for index, card in ipairs(gear.cards) do
|
||||
if card.id == "radio" then gear.cardIndex = index end
|
||||
end
|
||||
assert(gear:card().id == "radio", "the RADIO card is missing from the strip")
|
||||
U.wait(5)
|
||||
|
||||
-- 04.5, the bottom of the dial: the needle sits at the left of the box.
|
||||
U.shot(game, SHOTS .. "/01-radio-04.5.png")
|
||||
U.log("knob", tostring(gear:currentStation().knob), "at 04.5")
|
||||
|
||||
U.tap(game, "up") U.wait(4)
|
||||
U.tap(game, "up") U.wait(4)
|
||||
U.shot(game, SHOTS .. "/02-radio-08.5.png")
|
||||
U.log("knob", tostring(gear:currentStation().knob), "at 08.5")
|
||||
|
||||
U.log("compare 01 and 02: a red needle stands in the dial box and has",
|
||||
"moved right; UP/DOWN now walk it by hand")
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,89 @@
|
||||
-- Driver: the starter Pikachu's battle entrance (#1429).
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/pikachu_entrance_bug1429_test.lua \
|
||||
-- POKEPORT_VERSION=yellow POKEPORT_IDENTITY=bug1429 POKEPORT_TOUCH=0 \
|
||||
-- SHOT_DIR=/tmp/shots love . (never under POKEPORT_SPEED: audio-timed)
|
||||
--
|
||||
-- SendOutMon branches on IsThisPartyMonStarterPikachu (pokeyellow
|
||||
-- engine/battle/core.asm:1798-1819): the starter never gets POOF_ANIM or
|
||||
-- AnimateSendingOutMon. It walks in instead --
|
||||
-- StarterPikachuBattleEntranceAnimation (engine/battle/pikachu_entrance_anim.asm)
|
||||
-- paints the back pic one column at a time from hlcoord 0,5, eight columns two
|
||||
-- frames apart, and only then does PlayPikachuSoundClip voice it.
|
||||
--
|
||||
-- The run halts in the middle of the walk-in, then again on the finished pic.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local PF = require("src.world.PikachuFollower")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
check("running as Yellow (needs POKEPORT_VERSION=yellow)",
|
||||
GameVersion.isYellow())
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
local pika = Pokemon.new(game.data, "PIKACHU", 20)
|
||||
BattleState.stampOT(game.save, pika)
|
||||
game.save.party = { pika, Pokemon.new(game.data, "CHARMANDER", 20) }
|
||||
check("the lead reads as the starter Pikachu",
|
||||
PF.isStarterPikachu(game.save, pika))
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 20, "down")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("overworld is up to push the battle from", ow ~= nil)
|
||||
|
||||
local battle = BattleState.newWild(game, "PIDGEY", 5)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function tapUntil(cond, taps, gap)
|
||||
for _ = 1, (taps or 90) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, (gap or 4) do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
-- Poll every frame: the slide is 16 frames long and the ball poof, if the
|
||||
-- bug were back, would never set this slot at all.
|
||||
local sliding = tapUntil(function()
|
||||
return battle.picOff ~= nil and battle.picOff.playerMon ~= nil
|
||||
end, 120, 2)
|
||||
check("the send-out started the entrance slide, not the ball poof", sliding)
|
||||
check("no grow-in is running alongside it", battle.growIn == nil)
|
||||
|
||||
if sliding then
|
||||
U.log("slide x =", tostring(battle.picOff.playerMon.x))
|
||||
check("mid-slide screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug1429_pikachu_sliding.png"))
|
||||
U.log("captured", DIR .. "/bug1429_pikachu_sliding.png")
|
||||
for _ = 1, 40 do
|
||||
if battle:picOffset("playerMon") == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
check("the pic landed on its own column", battle:picOffset("playerMon") == 0)
|
||||
check("landed screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug1429_pikachu_landed.png"))
|
||||
U.log("captured", DIR .. "/bug1429_pikachu_landed.png")
|
||||
end
|
||||
|
||||
U.log("Pikachu should have walked in from the LEFT edge of the screen,")
|
||||
U.log("column by column, with no ball, no poof and no grow-out-of-the-ball,")
|
||||
U.log("and voiced its PCM clip only once it was fully drawn (#1429).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
-- Driver: where the "Will PLAYER change POKéMON?" YES/NO box sits (#1398).
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/shift_prompt_box_bug1398_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1398 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
--
|
||||
-- EnemySendOutFirstMon (engine/battle/core.asm:1378-1384) does NOT go through
|
||||
-- InitYesNoTextBoxParameters for this one prompt: it inlines its own
|
||||
-- TWO_OPTION_MENU at hlcoord 0,7, i.e. hard against the LEFT edge, while every
|
||||
-- other YES/NO in the game sits at hlcoord 14,7 on the right. The port only
|
||||
-- had the shared right-hand box, so the SHIFT offer came up on the wrong side.
|
||||
--
|
||||
-- The run stops with the prompt on screen and the controls in a human's hands.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- roster 1 is RATTATA 11 / EKANS 11: two mons, so the KO of the first one
|
||||
-- reaches EnemySendOutFirstMon with a reserve to announce.
|
||||
local OPP, ROSTER = "OPP_YOUNGSTER", 1
|
||||
|
||||
check("trainer class " .. OPP .. " is in the data",
|
||||
game.data.trainers ~= nil and game.data.trainers[OPP] ~= nil)
|
||||
check("Theme carries the left-edge box",
|
||||
Theme.trainerSwitchBox ~= nil and Theme.trainerSwitchBox.tx == 0
|
||||
and Theme.trainerSwitchBox.ty == 7)
|
||||
|
||||
-- SHIFT is what makes the prompt appear at all (the BIT_BATTLE_SHIFT test at
|
||||
-- core.asm:1375-1377 skips it under SET).
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.battleStyle = "shift"
|
||||
game.save.player.name = "bryan"
|
||||
game.save.party = {
|
||||
Pokemon.new(game.data, "MEWTWO", 70),
|
||||
Pokemon.new(game.data, "CHARIZARD", 50),
|
||||
}
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
check("overworld is up to push the battle from", ow ~= nil)
|
||||
|
||||
local ok, battle = pcall(BattleState.newTrainer, game, OPP, ROSTER)
|
||||
check("trainer battle constructed", ok and battle ~= nil)
|
||||
if not ok then
|
||||
U.log("could not start", OPP, "->", tostring(battle))
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
check("the foe has a reserve to send out", #battle.enemyParty >= 2)
|
||||
battle.onFinish = function() end
|
||||
ow:pushBattle(battle)
|
||||
|
||||
local function tapUntil(cond, taps, gap)
|
||||
for _ = 1, (taps or 60) do
|
||||
if cond() then return true end
|
||||
U.tap(game, "a")
|
||||
for _ = 1, (gap or 6) do
|
||||
if cond() then return true end
|
||||
U.wait(1)
|
||||
end
|
||||
end
|
||||
return cond()
|
||||
end
|
||||
|
||||
if not check("reached the FIGHT/PKMN/ITEM/RUN menu",
|
||||
tapUntil(function() return battle.phase == "menu" end, 200)) then
|
||||
U.log("phase is", tostring(battle.phase))
|
||||
end
|
||||
|
||||
-- FIGHT, first move: L70 MEWTWO one-shots the L11 lead.
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
U.tap(game, "a")
|
||||
U.wait(10)
|
||||
|
||||
-- Stop the moment the YES/NO goes up over the still-visible prompt page.
|
||||
local function choiceBox()
|
||||
local top = game.stack:top()
|
||||
return (top and top.tx and top.labels and top.labels[1] == "YES") and top
|
||||
or nil
|
||||
end
|
||||
local reached = tapUntil(function() return choiceBox() ~= nil end, 200, 4)
|
||||
if not check("the SHIFT prompt opened its YES/NO box", reached) then
|
||||
U.log("phase", tostring(battle.phase),
|
||||
"enemy hp", tostring(battle.enemy and battle.enemy.mon.hp),
|
||||
"top", tostring(game.stack:top()))
|
||||
end
|
||||
|
||||
local box = choiceBox()
|
||||
if box then
|
||||
check("the box is at hlcoord 0,7 (core.asm:1378-1384)",
|
||||
box.tx == 0 and box.ty == 7)
|
||||
U.log("box tx=" .. tostring(box.tx) .. " ty=" .. tostring(box.ty))
|
||||
check("prompt screenshot reached disk",
|
||||
U.shot(game, DIR .. "/bug1398_shift_prompt.png"))
|
||||
U.log("captured", DIR .. "/bug1398_shift_prompt.png")
|
||||
end
|
||||
|
||||
U.log("The foe's lead is down and the SHIFT offer is on screen.")
|
||||
U.log("YES/NO must sit against the LEFT edge, over the text box's left half,")
|
||||
U.log("not on the right where every other YES/NO in the game lives (#1398).")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
-- Driver: nobody is caught mid-stride while a text box is up (#1435).
|
||||
-- UpdatePlayerSprite and UpdateNPCSprite both jump to their standing frame
|
||||
-- while BIT_FONT_LOADED is set (engine/overworld/movement.asm:57, :139), so a
|
||||
-- box that opens on a walk frame freezes a standing sprite, never a leg out.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/talk_pose_bug1435_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1435 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- Mom stands at (5,4) in Red's house (pokered
|
||||
-- data/maps/objects/RedsHouse1F.asm); park the player beside her and push
|
||||
-- into her so the walk-in-place cycle is running when the box opens.
|
||||
U.teleport(game, "REDS_HOUSE_1F", 6, 4, "down")
|
||||
U.wait(20)
|
||||
local ow = game.overworld
|
||||
local p = ow.player
|
||||
|
||||
local walking = false
|
||||
for _ = 1, 90 do
|
||||
table.insert(game.input.pressQueue, "left")
|
||||
game.input.state.left = true
|
||||
coroutine.yield()
|
||||
if p:walkPhase() == 1 then walking = true break end
|
||||
end
|
||||
game.input.state.left = false
|
||||
check("bumping into MOM shows the walk frame", walking)
|
||||
U.shot(game, DIR .. "/bug1435_1_midstride.png")
|
||||
|
||||
U.tap(game, "a")
|
||||
U.wait(4)
|
||||
check("talking to MOM opened a box over the overworld",
|
||||
game.stack:top() ~= ow)
|
||||
check("the walk clock is still mid-cycle underneath",
|
||||
(p.bumpFrames or 0) > 0 or p.moving)
|
||||
check("but the player is drawn standing", p:walkPhase() == 0)
|
||||
U.shot(game, DIR .. "/bug1435_2_talking.png")
|
||||
|
||||
-- The same gate for an NPC caught mid-step: Pallet Town's wanderers walk
|
||||
-- on their own, so wait for one and open a box while it is between cells.
|
||||
U.teleport(game, "PALLET_TOWN", 8, 8, "down")
|
||||
U.wait(30)
|
||||
ow = game.overworld
|
||||
local mover
|
||||
for _ = 1, 900 do
|
||||
for _, npc in ipairs(ow.npcs) do
|
||||
if npc.moving and npc:walkPhase() == 1 then mover = npc break end
|
||||
end
|
||||
if mover then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
if check("caught a Pallet Town NPC mid-step", mover ~= nil) then
|
||||
game.stack:push(TextBox.new(game, "TESTING THE POSE."))
|
||||
U.wait(4)
|
||||
check("the NPC is still mid-step underneath", mover.moving)
|
||||
check("but it is drawn standing too", mover:walkPhase() == 0)
|
||||
U.shot(game, DIR .. "/bug1435_3_npc.png")
|
||||
end
|
||||
|
||||
U.log("Look at bug1435_2_talking.png and bug1435_3_npc.png: with the box")
|
||||
U.log("up, both sprites stand square on their feet. A leg out, or the")
|
||||
U.log("split-stride frame held for the whole conversation, is the bug.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -0,0 +1,176 @@
|
||||
-- Manual check for #1453: the SGB zone scissors must stay contiguous in
|
||||
-- framebuffer pixels on a fractional-DPI surface (Android), where LOVE 11
|
||||
-- truncates the scissor to whole units before scaling it. Zones are pokered
|
||||
-- data/sgb/sgb_packets.asm BlkPacket_Titlescreen (rows 0-7 / 8-9 / 10-17).
|
||||
-- POKEPORT_DRIVER=tests/drivers/title_seam_bug1453_test.lua POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
||||
-- PROBE_DPI picks the density to emulate (default 2.625, the reporter's).
|
||||
-- Do not set POKEPORT_SPEED: fast-forward desynchronizes the title music.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Renderer = require("src.render.Renderer")
|
||||
local SHOT_DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
local DPI = tonumber(os.getenv("PROBE_DPI") or "") or 2.625
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
-- past the copyright splash / attract movie (engine/movie/splash.asm)
|
||||
local title
|
||||
for _ = 1, 120 do
|
||||
local top = game.stack:top()
|
||||
if top and top.screenId == "TitleState" and top.sgbPalettes then
|
||||
title = top
|
||||
break
|
||||
end
|
||||
U.tap(game, "start")
|
||||
U.wait(9)
|
||||
end
|
||||
check("the title screen is on top", title ~= nil)
|
||||
if not (title and title.sgbPalettes) then
|
||||
U.log("No title state to look at; nothing below can run.")
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
|
||||
local opts = game.save.options
|
||||
local mode = opts and opts.colors or PaletteFX.mode
|
||||
if PaletteFX.mode ~= "gbc" then
|
||||
U.log("COLORS is", tostring(mode) .. "; switching the view to SGB for the check")
|
||||
PaletteFX.setMode("gbc")
|
||||
U.wait(20)
|
||||
end
|
||||
local zones = PaletteFX.ensureZones(title:sgbPalettes(game))
|
||||
check("the title builds three SGB zones", zones ~= nil and #zones == 3)
|
||||
|
||||
-- A canvas carries its own dpiscale, so the emulated Android surface goes
|
||||
-- through the same LOVE scissor path the phone does, on this desktop.
|
||||
local probe = love.graphics.newCanvas(math.floor(1920 / DPI),
|
||||
math.floor(1080 / DPI),
|
||||
{ dpiscale = DPI })
|
||||
local PW, PH = probe:getPixelWidth(), probe:getPixelHeight()
|
||||
local Sp = math.max(1, math.floor(math.min(PW / 160, PH / 144)))
|
||||
local Ux, Uy = Sp / DPI, Sp / DPI
|
||||
local uox = math.floor((PW - 160 * Sp) / 2) / DPI
|
||||
local uoy = math.floor((PH - 144 * Sp) / 2) / DPI
|
||||
local uvpw, uvph = 160 * Ux, 144 * Uy
|
||||
U.log(("emulating %dx%d px at dpi %.4f, fit scale %d"):format(PW, PH, DPI, Sp))
|
||||
|
||||
-- what the pre-fix Renderer handed LOVE: fractional units with a half
|
||||
-- framebuffer pixel of bias, which LOVE 11 truncates away
|
||||
local function scissorOld(x, y, w, h)
|
||||
local x2, y2 = math.min(x + w, uox + uvpw), math.min(y + h, uoy + uvph)
|
||||
x, y = math.max(x, uox), math.max(y, uoy)
|
||||
if x2 <= x or y2 <= y then return false end
|
||||
local px1, py1 = math.floor(x * DPI), math.floor(y * DPI)
|
||||
local px2, py2 = math.ceil(x2 * DPI), math.ceil(y2 * DPI)
|
||||
love.graphics.setScissor((px1 + 0.5) / DPI, (py1 + 0.5) / DPI,
|
||||
(px2 - px1 + 0.5) / DPI, (py2 - py1 + 0.5) / DPI)
|
||||
return true
|
||||
end
|
||||
|
||||
local function renderProbe(old)
|
||||
love.graphics.setCanvas(probe)
|
||||
love.graphics.clear(0, 0, 0, 1)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if old then
|
||||
local shader = PaletteFX.shader()
|
||||
love.graphics.setShader(shader)
|
||||
for _, z in ipairs(zones) do
|
||||
PaletteFX.sendColors(shader, z.colors)
|
||||
if scissorOld(uox + z.x * Ux, uoy + z.y * Uy, z.w * Ux, z.h * Uy) then
|
||||
love.graphics.draw(Renderer.canvas, uox, uoy, 0, Ux, Uy)
|
||||
end
|
||||
end
|
||||
love.graphics.setScissor()
|
||||
love.graphics.setShader()
|
||||
else
|
||||
Renderer:blitCanvas(Renderer.canvas, Ux, Uy, zones, Ux, Uy,
|
||||
uox, uoy, uox, uoy, uvpw, uvph, DPI, DPI)
|
||||
end
|
||||
love.graphics.setCanvas()
|
||||
return probe:newImageData()
|
||||
end
|
||||
|
||||
local top = math.floor(uoy * DPI)
|
||||
local bottom = math.floor((uoy + uvph) * DPI)
|
||||
local col = math.floor(uox * DPI) + 2
|
||||
local function blackRows(data)
|
||||
local rows = {}
|
||||
for py = top, math.min(bottom - 1, data:getHeight() - 1) do
|
||||
local r, g, b = data:getPixel(col, py)
|
||||
if r < 0.02 and g < 0.02 and b < 0.02 then rows[#rows + 1] = py end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local dataNew = renderProbe(false)
|
||||
local dataOld = renderProbe(true)
|
||||
local rowsNew, rowsOld = blackRows(dataNew), blackRows(dataOld)
|
||||
U.log(("picture rows %d-%d, sampling the background column at x=%d")
|
||||
:format(top, bottom - 1, col))
|
||||
U.log("pre-fix math left", #rowsOld, "black rows:",
|
||||
#rowsOld > 0 and table.concat(rowsOld, ",") or "none")
|
||||
check("no letterbox row shows through the zone boundaries", #rowsNew == 0)
|
||||
check("the emulated surface reproduces the seam without the fix", #rowsOld > 0)
|
||||
|
||||
local ZOOM, CW, CH = 4, 220, 28
|
||||
local cy = math.max(top, math.floor((uoy + 64 * Uy) * DPI) - math.floor(CH / 2))
|
||||
local cx = math.floor(uox * DPI)
|
||||
local function zoomOf(data)
|
||||
local out = love.image.newImageData(CW * ZOOM, CH * ZOOM)
|
||||
out:mapPixel(function(x, y)
|
||||
return data:getPixel(cx + math.floor(x / ZOOM), cy + math.floor(y / ZOOM))
|
||||
end)
|
||||
return out
|
||||
end
|
||||
local function writePng(imageData, path)
|
||||
local dir = path:match("^(.*)[/\\][^/\\]+$")
|
||||
if dir and dir ~= "" then os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') end
|
||||
local f = io.open(path, "wb")
|
||||
if not f then return false end
|
||||
f:write(imageData:encode("png"):getString())
|
||||
f:close()
|
||||
return true
|
||||
end
|
||||
|
||||
local zoomOld, zoomNew = zoomOf(dataOld), zoomOf(dataNew)
|
||||
check("wrote " .. SHOT_DIR .. "/bug1453_before.png",
|
||||
writePng(zoomOld, SHOT_DIR .. "/bug1453_before.png"))
|
||||
check("wrote " .. SHOT_DIR .. "/bug1453_after.png",
|
||||
writePng(zoomNew, SHOT_DIR .. "/bug1453_after.png"))
|
||||
|
||||
local imgOld = love.graphics.newImage(zoomOld)
|
||||
local imgNew = love.graphics.newImage(zoomNew)
|
||||
local baseDraw = love.draw
|
||||
love.draw = function()
|
||||
baseDraw()
|
||||
local w, h = love.graphics.getDimensions()
|
||||
local s = math.min((w - 24) / imgOld:getWidth(),
|
||||
(h * 0.5 - 48) / (imgOld:getHeight() * 2))
|
||||
local bw = imgOld:getWidth() * s + 16
|
||||
local bh = imgOld:getHeight() * 2 * s + 56
|
||||
local bx, by = (w - bw) / 2, h - bh - 8
|
||||
love.graphics.setColor(0, 0, 0, 0.75)
|
||||
love.graphics.rectangle("fill", bx, by, bw, bh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.print("BEFORE (dpi " .. DPI .. ")", bx + 8, by + 4)
|
||||
love.graphics.draw(imgOld, bx + 8, by + 20, 0, s, s)
|
||||
love.graphics.print("AFTER", bx + 8, by + 24 + imgOld:getHeight() * s)
|
||||
love.graphics.draw(imgNew, bx + 8, by + 40 + imgOld:getHeight() * s, 0, s, s)
|
||||
end
|
||||
|
||||
PaletteFX.setMode(mode)
|
||||
U.wait(10)
|
||||
U.shot(game, SHOT_DIR .. "/bug1453_title.png")
|
||||
U.log("captured", SHOT_DIR .. "/bug1453_title.png")
|
||||
U.log("The title is live and the pad is yours; the strip along the bottom is")
|
||||
U.log("the row-64 zone boundary of an emulated " .. DPI .. "x Android surface,")
|
||||
U.log("magnified " .. ZOOM .. "x: BEFORE carries the black hairline across the")
|
||||
U.log("picture, AFTER is unbroken off-white. PROBE_DPI=2.75 tries another one.")
|
||||
|
||||
while true do
|
||||
coroutine.yield()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
-- Driver: the TOWN MAP player marker wears the OBJ palette (#1437).
|
||||
-- LoadPlayerSpriteGraphics hands the town map the same walking sheet the
|
||||
-- overworld draws (engine/items/town_map.asm:342), so the marker has to go
|
||||
-- through the OBJ ramp and the shade-0 keying every other sprite gets --
|
||||
-- green on Red, pink on Blue, never a raw white block on the BG ramp.
|
||||
--
|
||||
-- POKEPORT_DRIVER=tests/drivers/town_map_marker_bug1437_test.lua \
|
||||
-- POKEPORT_IDENTITY=bug1437 POKEPORT_TOUCH=0 POKEPORT_VERSION=red \
|
||||
-- SHOT_DIR=/tmp/shots love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Screens = require("src.ui.Screens")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local SpriteRenderer = require("src.render.SpriteRenderer")
|
||||
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
||||
|
||||
local function check(label, ok)
|
||||
U.log(ok and "PASS" or "FAIL", label)
|
||||
return ok
|
||||
end
|
||||
|
||||
game.save.player.name = "bryan"
|
||||
game.save.visited = {
|
||||
PALLET_TOWN = true, VIRIDIAN_CITY = true, PEWTER_CITY = true,
|
||||
CERULEAN_CITY = true, CELADON_CITY = true,
|
||||
}
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
U.wait(10)
|
||||
|
||||
local function openMap(mode, tag)
|
||||
game.save.options = game.save.options or {}
|
||||
game.save.options.colors = mode
|
||||
PaletteFX.setMode(mode)
|
||||
U.wait(4)
|
||||
Screens.push(game, "TownMap")
|
||||
U.wait(20)
|
||||
local top = game.stack:top()
|
||||
check(tag .. ": the TOWN MAP is up", top and top.playerQuad ~= nil)
|
||||
-- the marker must be a baked OBJ image, not a fresh newImage of the sheet
|
||||
local sprites = game.data.sprites or {}
|
||||
local red = sprites.SPRITE_RED
|
||||
local colors, group
|
||||
if PaletteFX.usesSpriteObp() then
|
||||
colors, group = PaletteFX.ogObj()
|
||||
else
|
||||
colors, group = PaletteFX.dmgObj()
|
||||
end
|
||||
local want = red and SpriteRenderer.obpImage(red.image, colors, group)
|
||||
check(tag .. ": the marker is the OBP-baked sheet",
|
||||
top and want ~= nil and top.playerSheet == want)
|
||||
-- hold the shot on a frame the blink is showing the marker
|
||||
for _ = 1, 40 do
|
||||
if top.blink < 16 then break end
|
||||
coroutine.yield()
|
||||
end
|
||||
U.shot(game, DIR .. "/bug1437_" .. tag .. ".png")
|
||||
U.tap(game, "b")
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
openMap("ogred", "ogred")
|
||||
openMap("gbc", "gbc")
|
||||
openMap("og", "og")
|
||||
|
||||
U.log("Open the shots: in OG RED the marker over PALLET TOWN is the green")
|
||||
U.log("boot-ROM trainer, in the other modes it is coloured by the map zone")
|
||||
U.log("like any overworld sprite. A white box, or a red/black marker on")
|
||||
U.log("the BG ramp, is the bug. The pad is yours -- the TOWN MAP is one")
|
||||
U.log("B away and the ITEM bag reopens it.")
|
||||
|
||||
while true do coroutine.yield() end
|
||||
end
|
||||
@@ -47,12 +47,32 @@ do
|
||||
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "2ROW base")
|
||||
end
|
||||
|
||||
-- engine/battle_anims/anim_commands.asm:317
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
AnimRunner.COMMANDS.battlergfx_1row(runner)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
|
||||
T.eq(head and head.battler, "enemy", "the script command routes the same way")
|
||||
T.eq(head.rows, 2, "$da's macro says 1row, its jumptable slot says _2Row")
|
||||
T.eq(head.tiles, 14, "two enemy rows")
|
||||
T.eq(head.tile, (0x80 - 6 * 2 - 7 * 2) - 49, "at the _2Row base")
|
||||
T.eq(feet.tiles, 12, "two player rows")
|
||||
T.eq(feet.tile, (0x80 - 6 * 2) - 49, "at the _2Row base")
|
||||
end
|
||||
|
||||
do
|
||||
local runner = AnimRunner.new({})
|
||||
runner:start(nil)
|
||||
AnimRunner.COMMANDS.battlergfx_2row(runner)
|
||||
local head = findLoaded(runner, "BATTLE_ANIM_GFX_PLAYERHEAD")
|
||||
local feet = findLoaded(runner, "BATTLE_ANIM_GFX_ENEMYFEET")
|
||||
T.eq(head.rows, 1, "$d9's macro says 2row, its jumptable slot says _1Row")
|
||||
T.eq(head.tiles, 7, "one enemy row")
|
||||
T.eq(head.tile, (0x80 - 6 - 7) - 49, "at the _1Row base")
|
||||
T.eq(feet.tiles, 6, "one player row")
|
||||
T.eq(feet.tile, (0x80 - 6) - 49, "at the _1Row base")
|
||||
end
|
||||
|
||||
T.finish("gen2 battler gfx row attribution bug 1231")
|
||||
|
||||
@@ -151,8 +151,8 @@ do
|
||||
|
||||
local fresh = { mods = {} }
|
||||
SaveData.setModEnabled(fresh, "b", true, "gold")
|
||||
eq(fresh.modsByVersion.gold.b, nil,
|
||||
"no shared flag reads as enabled, so agreeing with it stores nothing")
|
||||
eq(fresh.modsByVersion.gold.b, true,
|
||||
"with no shared flag the answer is stored, not dropped against an assumed default")
|
||||
end
|
||||
|
||||
do
|
||||
|
||||
@@ -357,8 +357,8 @@ local options = OptionsMenu.new(optionsGame, {
|
||||
options = Save.defaultOptions(),
|
||||
})
|
||||
-- The cart's seven rows, then the port's: CONTROLS, audio, speed, display,
|
||||
-- video mode, the mobile-gated touch three (buildRows) and CANCEL.
|
||||
check("twenty-one rows", #OptionsMenu.ROWS, 21)
|
||||
-- video mode, the mobile-gated touch three (buildRows), MAX FPS and CANCEL.
|
||||
check("twenty-two rows", #OptionsMenu.ROWS, 22)
|
||||
check("the cart's rows come first", OptionsMenu.ROWS[7].key, "frame")
|
||||
check("then the rebind screen", OptionsMenu.ROWS[8].id, "controls")
|
||||
check("then the port's audio group", OptionsMenu.ROWS[9].key, "musicVol")
|
||||
|
||||
@@ -42464,7 +42464,7 @@
|
||||
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
|
||||
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||
"won": "_MtMoonB2fSuperNerdEachTakeOneText"
|
||||
"won": "_MtMoonB2FSuperNerdOkIllShareText"
|
||||
},
|
||||
"2": {
|
||||
"after": "_MtMoonB2FRocket1AfterBattleText",
|
||||
|
||||
@@ -42441,7 +42441,7 @@
|
||||
"after": "_MtMoonB2FSuperNerdTheresAPokemonLabText",
|
||||
"battle": "_MtMoonB2FSuperNerdTheyreBothMineText",
|
||||
"event": "EVENT_BEAT_MT_MOON_3_SUPER_NERD",
|
||||
"won": "_MtMoonB2fSuperNerdEachTakeOneText"
|
||||
"won": "_MtMoonB2FSuperNerdOkIllShareText"
|
||||
},
|
||||
"2": {
|
||||
"after": "_MtMoonB2FRocket1AfterBattleText",
|
||||
|
||||
Reference in New Issue
Block a user