Fix Gen 1/2 battle, menu, PC and audio parity gaps; wire luacheck into CI

CLOSES #1484, CLOSES #1486, CLOSES #1513, CLOSES #1517, CLOSES #1556, CLOSES #1564, CLOSES #1567
This commit is contained in:
bryanthaboi
2026-08-21 19:22:34 -04:00
parent 087a275189
commit c0f4315cd8
45 changed files with 2487 additions and 168 deletions
+1
View File
@@ -538,6 +538,7 @@ end
local function makeBattler(data, mon, isPlayer, save)
local def = data.pokemon[mon.species]
require("src.pokemon.Stats").ensure(def, mon)
local badgeBoosts = data.constants and data.constants.badgeBoosts
local badges = nil
if isPlayer and save then
+3 -1
View File
@@ -645,7 +645,9 @@ MoveEffects.full = {
user.bideTurns = ctx.rng(2, 3)
user.bideDamage = 0
ctx.battle:cancelMoveAnim()
ctx.anim(user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM")
ctx.battle:animBeforeMove(
user.isPlayer and "XSTATITEM_ANIM" or "XSTATITEM_DUPLICATE_ANIM",
user.isPlayer)
ctx.say(Strings("%s\nis storing energy!", displayName(user)))
end,
},
+18 -2
View File
@@ -2417,6 +2417,8 @@ Battle.MOVE_EFFECTS.EFFECT_BATON_PASS = function(self, attacker)
self.enemyIndex = target
self.enemy = party[target]
self.enemy.volatile = carried
-- engine/battle/move_effects/baton_pass.asm:59
self:resetParticipants()
end
local sent = side == "player" and self.player or self.enemy
self:emit({ kind = "send", side = side, mon = sent,
@@ -2679,6 +2681,8 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
self.enemyIndex = pick
self.enemy = incoming
self.stages.enemy = Battle.newStages()
-- ForceEnemySwitch (engine/battle/core.asm:2937)
self:resetParticipants()
end
self:emit({ kind = "send", side = self:sideOf(incoming), mon = incoming,
hp = incoming.hp or 0, status = incoming.status or false,
@@ -3341,7 +3345,9 @@ function Battle:giveExperiencePass(loser, def, recipients, count, halved)
local moveDef = self:moveDef(moveId)
local moveName = (moveDef and moveDef.name) or moveId
if ok then
-- data/text/common_3.asm:119
self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned " .. moveName .. "!" })
elseif reason == "full" then
-- LearnMove's full-moveset arm calls ForgetMove, which asks with
@@ -3450,8 +3456,7 @@ function Battle:awardExperience(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
self:resetParticipants()
end
-- The answer to a `choose-forget`: drop the move in `slot` and put the
@@ -3472,7 +3477,9 @@ function Battle:resolveForget(index, slot, entry, moveName)
end
self:emit({ kind = "message",
text = "1, 2 and… " .. self:monName(mon) .. " forgot " .. oldName .. "!" })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:emit({ kind = "message",
sfx = "Sfx_DexFanfare5079", waitSfx = true,
text = self:monName(mon) .. " learned "
.. (moveName or (entry and entry.id) or "?") .. "!" })
-- The forget path writes the slot itself rather than going through
@@ -3511,6 +3518,13 @@ function Battle:switchLocked()
return self:volatile(self.enemy).trapsTarget == true
end
-- ResetBattleParticipants falls through into AddBattleParticipant
-- (engine/battle/core.asm:3033 and :3037).
function Battle:resetParticipants()
self.participants = {}
if self.playerIndex then self.participants[self.playerIndex] = true end
end
-- EnemySwitch's shift arm zeroes both participant bitfields before PlayerSwitch
-- (engine/battle/core.asm:2959-2961).
function Battle:shiftSwitch(index)
@@ -3999,6 +4013,8 @@ function Battle:enemyTrySwitchOrItem()
.. self:monName(outgoing) .. "!" })
self.enemyIndex = target
self.enemy = self.enemyParty[target]
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears
+14 -6
View File
@@ -483,8 +483,10 @@ function Game2:learnMoveOn(mon, moveId, onDone)
if onDone then onDone(learned) end
end
if ok then
-- data/text/common_3.asm:119
return self:say(("%s learned\n%s!"):format(name, moveName),
function() finish(true) end)
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
if reason ~= "full" then return finish(false) end
local askForget, pickMove, askStop
@@ -539,9 +541,11 @@ function Game2:learnMoveOn(mon, moveId, onDone)
-- The slot is written here rather than through Mon.learnMove, so
-- pokemon.move_learned is raised here too.
ModRuntime.emit("pokemon.move_learned", { mon = mon, moveId = moveId })
-- engine/pokemon/learn.asm:115, data/text/common_3.asm:119
self:say(("1, 2 and… Poof!\f%s forgot\n%s.\fAnd…\f%s learned\n%s!")
:format(name, oldName, name, moveName),
function() finish(true) end)
function() finish(true) end,
TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end,
})
end
@@ -706,7 +710,9 @@ function Game2:usePartyItem(itemId)
})
elseif action == "candy" then
self:consumeItem(itemId)
self:say(result.text, function() self:afterRareCandy(mon, result) end)
-- data/text/common_1.asm:86
self:say(result.text, function() self:afterRareCandy(mon, result) end,
result.sfx and TextBox.soundOpts(self, result.sfx) or nil)
else
self:consumeItem(itemId)
self:say(result.text)
@@ -765,8 +771,10 @@ function Game2:useSelectItem()
local name = (items[itemId] and items[itemId].name) or itemId
self:say(Strings("{PLAYER} used the\n%s.", name))
elseif outcome == "trophy_sent" then
-- data/text/common_3.asm:372
self:say(Strings(
"There was a trophy\ninside!\fThe trophy was\nsent home."))
"There was a trophy\ninside!\fThe trophy was\nsent home."),
nil, TextBox.soundOpts(self, "Sfx_DexFanfare5079"))
end
-- Anything else (a fishing bite, the ITEMFINDER's queued script) already
-- drives its own presentation off World:step -- nothing left to print here.
@@ -778,9 +786,9 @@ end
-- onDone that popped again ate the state UNDER the box: dismissing a message
-- over the PACK closed the PACK with it, and over an empty overworld stack it
-- was a silent extra pop.
function Game2:say(text, onDone)
function Game2:say(text, onDone, opts)
local TextBox = require("src.render.TextBox")
self.stack:push(TextBox.new(self, text, onDone))
self.stack:push(TextBox.new(self, text, onDone, opts))
end
-- The landmark the player is standing in, for the Pokegear map's marker.
+2
View File
@@ -240,6 +240,8 @@ local function rareCandy(mon, data)
used = true,
level = newLevel,
learned = learned,
-- data/text/common_1.asm:86
sfx = "Sfx_DexFanfare5079",
text = ("%s grew to\nlevel %d!"):format(monName(mon), newLevel),
}
end
+20
View File
@@ -5151,6 +5151,7 @@ function RomExtractorGen2:extractMenuGfx()
}
out.pack = pack
out.pokedex = self:pokedexGfx()
out.billsPc = self:billsPcGfx()
out.pokegear = self:pokegearGfx()
out.trainerCard = self:trainerCardGfx()
out.unownPuzzle = self:unownPuzzleGfx()
@@ -5697,6 +5698,25 @@ function RomExtractorGen2:pokedexGfx()
return dex
end
-- BillsPC_InitGFX's four tiles at vTiles2 $5c
-- (engine/pokemon/bills_pc.asm:2170-2173)
function RomExtractorGen2:billsPcGfx()
if not self.symbols["PCMailGFX"] then return nil end
local pc = {}
local gfx = self:symbol("PCMailGFX")
self:write2bpp(self.rom:bytes(gfx.bank, gfx.address, 4 * 16),
32, 8, "pc/mail_item.png")
pc.icons = "assets/generated/pc/mail_item.png"
pc.firstTile = 0x5c
pc.palette = self:predefPal(PREDEFPAL_POKEDEX)
-- gfx/pc/orange.pal
if self.symbols["BillsPCOrangePalette"] then
local orange = self:symbol("BillsPCOrangePalette")
pc.orangePalette = self:colors(orange.bank, orange.address, 4)
end
return pc
end
-- Reads a `tile, count` RLE tilemap (Pokegear_LoadTilemapRLE). The routine's
-- own comment says "repeat count, tile ID" and has it backwards: it loads b
-- from the first byte, c from the second, and writes `b` c times. $ff ends
+3
View File
@@ -146,6 +146,9 @@ local VERSION_REQUIRED_FILES_OVERRIDE = {
-- complete and SlotMachine crashed on its labelled-cell fallback.
"assets/generated/slots/gold_slots_1.png",
"assets/generated/card_flip/card_flip_1.png",
-- PCMailGFX (engine/pokemon/bills_pc.asm:2170-2173): Bill's PC has no
-- held-item or mail icon in a cache built before the symbol was listed.
"assets/generated/pc/mail_item.png",
},
}
-- Same Gen 2 extract, so a Silver cache is complete when the same files exist.
+34 -4
View File
@@ -808,6 +808,31 @@ local function buildOverworld()
local Movement = rawRequire("src.script.gen2.Movement")
local HiddenItems = rawRequire("src.world.gen2.HiddenItems")
local Bike = rawRequire("src.world.gen2.Bike")
local FieldMoves = rawRequire("src.world.gen2.FieldMoves")
-- home/map.asm:1869
local function stepAllowed(world, entity, dir)
local d = Map2.DELTA[dir]
if not (world.map and d and entity and entity.cellX) then return true end
if not Permissions.stepPermitted(
function(cx, cy) return world:cellCollisionAcross(world.map, cx, cy) end,
entity.cellX, entity.cellY, dir) then
return false
end
local map = world.map
if entity == world.player and FieldMoves.isSurfing(world.playerState) then
map = world:surfMap(world.map)
end
local tx, ty = entity.cellX + d[1], entity.cellY + d[2]
if not map:inBounds(tx, ty) or not map:isWalkable(tx, ty) then return false end
for _, e in ipairs(world.entities or {}) do
if e ~= entity and not e.passable then
if e.cellX == tx and e.cellY == ty then return false end
if e.moving and e.targetX == tx and e.targetY == ty then return false end
end
end
return true
end
local api = nil
-- one WorldAPI instance, so queueScript reuses the five-verb allow list
@@ -1011,20 +1036,25 @@ local function buildOverworld()
-- Gold has ONE movement slot; a second concurrent call is refused with a
-- reason rather than dropped (src/world/gen2/WorldAPI.lua:171's recipe).
function ow.scriptMove(entity, dir, tiles, onDone)
function ow.scriptMove(entity, dir, tiles, onDone, opts)
local world = w("scriptMove")
if not world then return nil, "no overworld" end
if world.moveState then return nil, "a movement is already running" end
local step = Movement.stepByte(dir)
if not step then return nil, "unknown direction: " .. tostring(dir) end
local bytes = {}
for _ = 1, math.max(0, tiles or 1) do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
local objectId = objectIdOf(world, entity)
if not objectId then
return nil, "no Gen 2 objectId for that entity: only the player and a "
.. "mapped object (def.index) can be moved"
end
local n = math.max(0, tiles or 1)
if opts and opts.collide and n > 0 and not stepAllowed(world, entity, dir) then
entity.facing = dir
n = 0
end
local bytes = {}
for _ = 1, n do bytes[#bytes + 1] = step end
bytes[#bytes + 1] = Movement.STEP_END
world:beginMovement(objectId, bytes, onDone)
return true
end
+12
View File
@@ -119,6 +119,17 @@ local GEN1_ONLY_MODULES = {
["src.ui.OptionsMenu"] = true,
}
local function crossGenerationDenial(name, generation)
if type(name) ~= "string" or generation ~= 1 then return nil end
if not (name:find("^src%.[%w_]+%.gen2%.") or name == "src.core.Game2") then
return nil
end
return ("%s is a Gen 2 engine module and this is a Gen 1 game; the structs "
.. "it reads and writes are not this game's, so anything it stores lands "
.. "on the save in the wrong shape. Take the game from mod.game and the "
.. "world from mod.world, which resolve per generation"):format(name)
end
-- the src.* modules the mod surface points authors at: another mod's
-- exports carry a version string that wants range-checking before use, and
-- ChipAsm is the authoring path for chip music and sfx
@@ -214,6 +225,7 @@ function Loader:_installDevShim()
if owner or callerIsMod(3) then
local id = type(owner) == "string" and owner or nil
local denial = Sandbox.moduleDenial(name, devShim.permissions[id])
or (id and crossGenerationDenial(name, devShim.generation))
if denial then error(("[%s] %s"):format(id or "mod", denial), 0) end
end
if devShim.dev or devShim.generation ~= 1 then scanRequire(name) end
+11 -4
View File
@@ -51,11 +51,18 @@ end
-- (engine/pokemon/status_screen.asm:66-76, "mon is in a box or daycare" ->
-- CalcStats) and when one is moved back into the party
-- (engine/pokemon/add_mon.asm _MoveMon tail). The stored current HP is
-- kept (box_struct does hold it) but clamped to the recalculated maximum so
-- a tampered save cannot overfill the bar. A mon that already has stats is
-- returned untouched, so a vanilla save round-trips. #233, #304
-- kept (box_struct does hold it) but clamped to the recalculated maximum.
-- CalcStats writes all NUM_STATS stats or none (home/move_mon.asm:33-48).
local function statsComplete(stats)
for _, key in ipairs(ORDER) do
if type(stats[key]) ~= "number" then return false end
end
return true
end
function Stats.ensure(speciesDef, mon)
if type(mon) ~= "table" or type(mon.stats) == "table" then return mon end
if type(mon) ~= "table" then return mon end
if type(mon.stats) == "table" and statsComplete(mon.stats) then return mon end
if type(speciesDef) ~= "table" or type(speciesDef.baseStats) ~= "table" then
return mon
end
+2
View File
@@ -2707,7 +2707,9 @@ function BattleState:pushCaught(enemy, itemId)
-- so the contest branch is BELOW them (item_effects.asm:528-546), and
-- CheckReceivedDex gates the pair (:532-533).
if not knew and self:hasPokedex() then
-- data/text/common_3.asm:285
self:push({ kind = "message",
sfx = "Sfx_SlotMachineStart", waitSfx = true,
text = self:name(enemy) .. "'s data was newly added to the #DEX." })
self:push({ kind = "dex-entry", species = enemy.species })
end
+86 -12
View File
@@ -47,6 +47,7 @@ local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette")
local Mail = require("src.core.gen2.Mail")
local Palettes = require("src.world.gen2.Palettes")
local PartyMenu = require("src.ui.gen2.PartyMenu")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
local Unown = require("src.core.gen2.Unown")
@@ -65,6 +66,15 @@ local PIC_X, PIC_Y = 1, 4
-- 7-size blank tiles per column (engine/gfx/load_pics.asm:342-386).
local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } }
-- gfx/pc/orange.pal, for a cache that predates menu_gfx.billsPc.
local BILLS_PC_ORANGE = {
{ 255, 123, 0 }, { 189, 99, 0 }, { 123, 58, 0 }, { 0, 0, 0 },
}
-- PCMonInfo prints the held-item icon at hlcoord 7, 12
-- (engine/pokemon/bills_pc.asm:1093).
local ICON_X, ICON_Y = 7, 12
-- wBillsPC_LoadedBox: 0 is the PARTY, 1..NUM_BOXES are the boxes. Only the
-- MOVE screen ever loads box 0; the withdraw and deposit lists are one list
-- each (BillsPC_BoxName reads the same byte for all three).
@@ -272,6 +282,8 @@ function BoxMenu:beginMove()
-- to the submenu; here the message holds until a button clears it and the
-- list comes back, which is the same place the player ends up.
self.phase = nil
-- engine/pokemon/bills_pc.asm:1607
self:playSfx("Sfx_Wrong")
self.message = reason
return
end
@@ -299,9 +311,13 @@ end
function BoxMenu:doWithdraw()
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
if not ok then
-- engine/pokemon/bills_pc.asm:1845
self:playSfx("Sfx_Wrong")
self.message = result
return
end
-- engine/pokemon/bills_pc.asm:1817
self:playMonCry(result)
self.message = nil
self.phase = nil
self:clampIndex()
@@ -311,9 +327,13 @@ end
function BoxMenu:doDeposit()
local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if not ok then
-- engine/pokemon/bills_pc.asm:1790
self:playSfx("Sfx_Wrong")
self.message = result
return
end
-- engine/pokemon/bills_pc.asm:1762
self:playMonCry(result)
self.message = nil
self.phase = nil
self.index, self.scroll = 1, 0
@@ -486,6 +506,8 @@ function BoxMenu:update(_dt)
if not ok then
-- .no_space: `dec [hl]` puts the jumptable back on .PrepInsertCursor,
-- so the refusal leaves the cursor exactly where it was.
-- engine/pokemon/bills_pc.asm:1567
self:playSfx("Sfx_Wrong")
self.message = reason
else
self:insertMon()
@@ -528,6 +550,14 @@ function BoxMenu:playSfx(name)
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
end
-- PlayMonCry: `call GetCryIndex / jr c, .done` (home/pokemon.asm:101)
function BoxMenu:playMonCry(mon)
local data = self.game and self.game.data
if not (data and mon and mon.species) or mon.isEgg then return end
local cries = data.audio and data.audio.cries
if cries and cries[mon.species] then Sound.playCry(data, mon.species) end
end
-- BillsPC's RELEASE, which the model has always supported and nothing on
-- screen reached. The cart asks first and starts the prompt on NO, the way
-- every irreversible choice in the game does.
@@ -541,6 +571,8 @@ function BoxMenu:askRelease()
local allowed, refusal = self:checkMailPreventBlackout()
if not allowed then
self.phase = nil
-- engine/pokemon/bills_pc.asm:1607
self:playSfx("Sfx_Wrong")
self.message = refusal
return
end
@@ -568,6 +600,8 @@ function BoxMenu:askRelease()
self.message = err
return
end
-- engine/pokemon/bills_pc.asm:1866
self:playMonCry(mon)
self.message = name .. " was released."
self.phase = nil
self:clampIndex()
@@ -627,14 +661,31 @@ function BoxMenu:picFor(mon)
return self:image(path)
end
-- engine/gfx/cgb_layouts.asm:284-300, engine/pokemon/bills_pc.asm:356-369
function BoxMenu:panelColors(speciesId, shiny)
if self.phase == "submenu" or self.phase == "insert" then
return self.palettes
and Palettes.monColors(self.palettes, speciesId, shiny)
end
local gfx = (self.menuGfx or {}).billsPc
return (gfx and gfx.orangePalette) or BILLS_PC_ORANGE
end
-- ClearBox runs before `cp -1 / ret z` (engine/pokemon/bills_pc.asm:1009-1021)
function BoxMenu:fillPicBlock(colors)
local G = love.graphics
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
G.setColor(1, 1, 1, 1)
end
-- PCMonInfo lays the padded pic as one 7x7 block at hlcoord 1, 4
-- (engine/pokemon/bills_pc.asm:1023-1042), the pad tiles at the palette's 0.
function BoxMenu:drawPicBlock(image, colors)
if not image then return end
local G = love.graphics
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
self:fillPicBlock(colors)
local pad = PIC_PAD[math.floor(image:getWidth() / 8)] or PIC_PAD[7]
G.setColor(1, 1, 1, 1)
@@ -650,12 +701,12 @@ function BoxMenu:drawPicBlock(image, colors)
end
function BoxMenu:drawPic(mon)
local image = self:picFor(mon)
if not image then return end
-- _CGB_BillsPC hands wTempMonDVs to GetPlayerOrMonPalettePointer, so the box
-- pic takes the shiny row (engine/gfx/cgb_layouts.asm:292-293).
local colors = self.palettes
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
local colors = self:panelColors(mon.species, mon.shiny)
local image = self:picFor(mon)
-- engine/pokemon/bills_pc.asm:1009-1011
if not image then return self:fillPicBlock(colors) end
self:drawPicBlock(image, colors)
end
@@ -664,17 +715,14 @@ end
-- with the party list's ICON_EGG standing in for a cache built before that.
function BoxMenu:drawEggPic(mon)
local G = love.graphics
local colors = self.palettes
and Palettes.monColors(self.palettes, "EGG", mon and mon.shiny)
local colors = self:panelColors("EGG", mon and mon.shiny)
local gfx = (self.menuGfx or {}).eggHatch
local image = self:image(gfx and gfx.egg)
if image then return self:drawPicBlock(image, colors) end
self:fillPicBlock(colors)
local entry = self.icons and self.icons.icons and self.icons.icons.ICON_EGG
image = self:image(entry and entry.image)
if not image then return end
local blank = colors and GbcPalette.color(colors, 1) or { 255, 255, 255 }
G.setColor(blank[1] / 255, blank[2] / 255, blank[3] / 255, 1)
G.rectangle("fill", PIC_X * 8, PIC_Y * 8, 7 * 8, 7 * 8)
-- The ICON_EGG sheet stacks its frames; the first is the egg at rest.
local w = entry.width or 16
local h = math.min(entry.height or 16, image:getHeight())
@@ -694,6 +742,29 @@ function BoxMenu:drawEggPic(mon)
G.setColor(1, 1, 1, 1)
end
-- ItemIsMail picks $5c over $5d at hlcoord 7, 12
-- (engine/pokemon/bills_pc.asm:1079-1094)
function BoxMenu:drawHeldIcon(mon)
local row = PartyMenu.heldMarkerRow(mon)
if not row then return end
local gfx = (self.menuGfx or {}).billsPc
local image = self:image(gfx and gfx.icons)
if not image then return end
local ok, quad = pcall(love.graphics.newQuad, row * 8, 0, 8, 8,
image:getDimensions())
if not ok then return end
local G = love.graphics
G.setColor(1, 1, 1, 1)
local function body() G.draw(image, quad, ICON_X * 8, ICON_Y * 8) end
local colors = gfx and gfx.palette
if colors and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
G.setColor(1, 1, 1, 1)
end
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor
-- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
-- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
@@ -793,7 +864,10 @@ function BoxMenu:drawPanel()
Chrome.print("\xe2\x99\x80", 5, 12)
end
Chrome.print(mon.name or mon.species or "?", PIC_X, 14)
self:drawHeldIcon(mon)
end
else
self:fillPicBlock(self:panelColors())
end
-- BillsPC_PlaceString: Textbox at (0,15) with a one-row interior, string at
+11 -5
View File
@@ -320,12 +320,15 @@ function ItemPcMenu:leaveDeposit()
end
function ItemPcMenu:offerToDeposit(id, count)
-- .TryDepositItem's `.no_toss` arm is a bare ret: a KEY ITEM or HM stays in
-- the bag with no message at all.
if self:cantToss(id) then return end
if (count or 0) < 1 then return end
local def = self:def(id)
local name = (def and def.name) or id
-- .DepositItem (engine/events/pokecenter_pc.asm:504): an item with no
-- quantity is always x1 and never reaches .AskQuantity.
if self:cantToss(id) then
self:deposit(id, name, 1)
return
end
self:askQuantity(count,
{ "How many do you", "want to deposit?" },
function(qty) self:deposit(id, name, qty) end)
@@ -519,8 +522,11 @@ function ItemPcMenu:drawList()
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print(entry.name, 6, ty)
-- 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)
-- entry's second line, right-aligned in a blank-padded 2-digit field,
-- and an item with no quantity draws none at all (menu_2.asm:18).
if not self:cantToss(entry.id) then
Chrome.print(TIMES .. Chrome.number(entry.count, 2), 7, ty + 1)
end
elseif i == self:listTotal() then
if i == self.listIndex then Chrome.cursor(5, ty) end
Chrome.print("CANCEL", 6, ty)
+25
View File
@@ -192,6 +192,14 @@ function PackMenu:pocketOf(itemId)
return (def and def.pocket) or "ITEM"
end
-- engine/items/tmhm.asm:341
function PackMenu:tmhmKey(itemId)
local def = self.items and self.items[itemId]
local n = def and tonumber(def.tmNumber)
if n then return n end
return 1000 + ((def and tonumber(def.index)) or 0)
end
-- The name on the row. An inventory key with no ItemAttributes row behind it
-- (an older cache, a mod's own item, a driver seeding an id that is not in
-- items.lua) still has to draw something a person can read, so the id stands
@@ -239,6 +247,15 @@ function PackMenu:rebuild()
}
end
end
-- engine/items/tmhm.asm:341 -- wTMsHMs is walked 1..57, so the TM/HM pocket
-- has no acquisition order to preserve.
if pocket == "TM_HM" then
table.sort(rows, function(a, b)
local ka, kb = self:tmhmKey(a.id), self:tmhmKey(b.id)
if ka ~= kb then return ka < kb end
return a.id < b.id
end)
end
self.rows = rows
self.index = math.min(self.index, #rows + 1)
if self.index < 1 then self.index = 1 end
@@ -359,6 +376,9 @@ function PackMenu:useSelected()
-- with sound_dex_fanfare_50_79 between them; the PACK's box here holds
-- all four rows at once, the way OAK_THIS_ISNT_THE_TIME's three fit.
-- World has already set the decoration's flag and taken the box.
if world.playSfxNamed then
world:playSfxNamed("Sfx_DexFanfare5079")
end
self.message = { "There was a trophy", "inside!",
"{PLAYER} sent the", "trophy home." }
self:rebuild()
@@ -641,6 +661,9 @@ function PackMenu:openTeachParty(row)
if id == moveId then allowed = true end
end
if not allowed then
-- engine/items/tmhm.asm:131
local world = game.world
if world and world.playSfxNamed then world:playSfxNamed("Sfx_Wrong") end
if game.say then
game:say(("%s can't learn %s!"):format(
require("src.battle.gen2.Mon").displayName(mon), moveName))
@@ -740,7 +763,9 @@ function PackMenu:update(_dt)
end
-- engine/items/pack.asm:1290 Pack_InterpretJoypad .select
-- engine/items/tmhm.asm:207 -- the TM/HM pocket's joypad filter drops SELECT.
function PackMenu:armSwitch()
if self:pocket().id == "TM_HM" then return end
if self:isCancel() then return end
if not self.rows[self.index] then return end
self.switching = self.index
+37 -5
View File
@@ -150,6 +150,21 @@ local function pooledNPC(pool, data, mapId, obj)
end
OverworldState.pooledNPC = pooledNPC -- exposed for tests
local function nearestWalkableCell(map, x, y)
for r = 1, 8 do
for dy = -r, r do
for dx = -r, r do
if math.abs(dx) == r or math.abs(dy) == r then
local nx, ny = x + dx, y + dy
if map:inBounds(nx, ny) and map:isWalkableCell(nx, ny) then
return nx, ny
end
end
end
end
end
end
-- connection hops rendered around the current map: two, so
-- corner-adjacent maps (connections of connections) don't pop in and
-- out of the survey zoom at the seams (constants.world.neighborHops)
@@ -414,6 +429,15 @@ function OverworldState:setMap(mapId, x, y, facing, opts)
table.insert(self.npcs, npc)
end
end
if opts and opts.via == "boot" and self.map:inBounds(x, y)
and not self.map:isWalkableCell(x, y) and not self.map:isWaterCell(x, y) then
local rx, ry = nearestWalkableCell(self.map, x, y)
if rx then
Logger.warn("saved position %s (%d,%d) is not walkable; moved to (%d,%d)",
mapId, x, y, rx, ry)
x, y = rx, ry
end
end
if self.player then
self.player.cellX, self.player.cellY = x, y
self.player.px, self.player.py = x * 16, y * 16
@@ -4113,7 +4137,7 @@ function OverworldState:checkBadgeGate()
Game.stack:push(TextBox.new(Game,
(t["_" .. g.failText] or Strings("You don't have the\nBOULDERBADGE yet!"))
.. (t._Route22GateGuardICantLetYouPassText or ""), function()
self:scriptMove(p, "down", 1)
self:scriptMove(p, "down", 1, nil, { collide = true })
end))
return true
end
@@ -4141,7 +4165,7 @@ function OverworldState:checkBadgeGate()
local text = (t["_" .. g.failText] or
Strings("You don't have the\n{RAM} yet!")):gsub("{RAM:wNameBuffer}", badgeName)
Game.stack:push(TextBox.new(Game, text, function()
self:scriptMove(p, "down", 1)
self:scriptMove(p, "down", 1, nil, { collide = true })
end))
return true
end
@@ -4181,7 +4205,7 @@ function OverworldState:checkForcedMovement()
function()
local back = ({ up = "down", down = "up",
left = "right", right = "left" })[p.facing]
self:scriptMove(p, back, 1)
self:scriptMove(p, back, 1, nil, { collide = true })
end))
return true
end
@@ -4222,6 +4246,7 @@ function OverworldState:checkSeafoamCurrent()
-- push so the B3F stair warps underfoot cannot bounce you back.
self.forcedWarp = false
require("src.core.Sound").play(Game.data, "Collision")
-- home/overworld.asm:1891
self:scriptMove(p, "up", c.y == 17 and 2 or 1)
return true
end
@@ -4780,9 +4805,10 @@ end
-- scripted movement
-- -------------------------------------------------------------------------
function OverworldState:scriptMove(entity, dir, tiles, onDone)
function OverworldState:scriptMove(entity, dir, tiles, onDone, opts)
table.insert(self.scriptMoves, {
entity = entity, dir = dir, remaining = tiles, onDone = onDone,
collide = opts and opts.collide or nil,
})
end
@@ -4822,14 +4848,20 @@ function OverworldState:updateScriptMoves()
e.moving = true
e.marching = true
e.progress = 0
mv.remaining = mv.remaining - 1
elseif mv.collide
and not Collision.canMove(self.map, self.entities, e, mv.dir) then
-- home/overworld.asm:1224
e.facing = mv.dir
mv.remaining = 0
else
e.facing = mv.dir
local tx, ty = Collision.target(e.cellX, e.cellY, mv.dir)
e.targetX, e.targetY = tx, ty
e.moving = true
e.progress = 0
mv.remaining = mv.remaining - 1
end
mv.remaining = mv.remaining - 1
end
end
-- march_in_place toggles: re-arm the in-place cycle each time it ends.
+13 -1
View File
@@ -83,6 +83,7 @@ local SFX = {
WARP_TO = 19,
EXIT_BUILDING = 35,
JUMP_OVER_LEDGE = 0x16,
BUMP = 0x24,
}
local EMOTE_SHOCK = 0
@@ -2183,6 +2184,13 @@ function World:playSfxNamed(want, fallbackId)
self:playSfx(self:sfxIdNamed(want, fallbackId))
end
-- .BumpSound (engine/overworld/player_movement.asm:771): `call CheckSFX /
-- ret c` is the whole rate limit (home/audio.asm:477), not a frame counter.
function World:bumpSound()
if Sound.sfxBusy() then return end
self:playSfxNamed("Sfx_Bump", SFX.BUMP)
end
-- Script_specialsound (engine/overworld/scripting.asm:476) is not a fixed cue:
-- it farcalls CheckItemPocket (engine/items/items.asm:512), which writes
-- wCurItem's pocket into wItemAttributeValue, and rings SFX.GET_TM for the
@@ -9031,6 +9039,9 @@ function World:movePlayer(dir)
elseif result == "blocked" or result == "edge" then
self.turningDirection = nil
end
-- .NotMoving and .Ice's own arm (player_movement.asm:102 and :87), which
-- .TryJump's carry (:376) returns above.
if result == "blocked" then self:bumpSound() end
-- NormalStep, in order (engine/overworld/movement.asm:657-674): InitStep has
-- already moved OBJECT_TILE_COLLISION onto the destination.
if result == "moved" then self:playerStepGrass() end
@@ -9738,7 +9749,8 @@ function World:stepBody()
local result = self:movePlayer(dir)
if result == "edge" then
self:tryConnection(dir)
-- A border block is a wall: engine/overworld/player_movement.asm:264
if not self:tryConnection(dir) then self:bumpSound() end
elseif result == "blocked" and p.facing == dir then
-- .CheckNPC came back 2: something movable is in the way. The step is
-- lost either way, and the boulder is what moves.