mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-22 21:46:51 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user