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:
bryanthaboi
2026-08-17 10:15:06 -04:00
parent 3cca70608f
commit 45519ad550
61 changed files with 2481 additions and 255 deletions
+76 -43
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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
+7 -8
View File
@@ -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)
+3
View File
@@ -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
View File
@@ -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
+3 -4
View File
@@ -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
+30
View File
@@ -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
+7
View File
@@ -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
+1
View File
@@ -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
+10
View File
@@ -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
+11
View File
@@ -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
+11 -1
View File
@@ -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
View File
@@ -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).
+4
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+17
View File
@@ -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)
+6
View File
@@ -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)
+6
View File
@@ -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
View File
@@ -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,
+3 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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)
+10 -10
View File
@@ -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)
+9
View File
@@ -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
View File
@@ -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.
+3 -7
View File
@@ -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
View File
@@ -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)
+13
View File
@@ -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.
+19 -4
View File
@@ -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
+6 -10
View File
@@ -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
View File
@@ -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
+42 -2
View File
@@ -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
+9
View File
@@ -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
+1 -1
View File
@@ -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)
+58 -6
View File
@@ -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