CLOSES #1090, CLOSES #1093, CLOSES #1094, CLOSES #1095, CLOSES #1098, CLOSES #1102, CLOSES #1105, CLOSES #1106, CLOSES #1108, CLOSES #1109, CLOSES #1110, CLOSES #1111, CLOSES #1113, CLOSES #1114, CLOSES #1117, CLOSES #1118, CLOSES #1121, CLOSES #1122, CLOSES #1123, CLOSES #1124, CLOSES #1126, CLOSES #1127, CLOSES #1128, CLOSES #1131, CLOSES #1132, CLOSES #1134, CLOSES #1137, CLOSES #1141

This commit is contained in:
bryanthaboi
2026-08-11 21:19:36 -04:00
parent 01aab1d763
commit 0136429d3e
24 changed files with 694 additions and 167 deletions
+33 -3
View File
@@ -1246,14 +1246,14 @@ function Battle:dealDamage(attacker, defender, damage, opts)
return damage return damage
end end
function Battle:heal(mon, amount) function Battle:heal(mon, amount, opts)
local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1 local maxHp = mon.maxHp or (mon.stats and mon.stats.hp) or 1
local before = mon.hp or 0 local before = mon.hp or 0
mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0))) mon.hp = math.min(maxHp, before + math.max(0, math.floor(amount or 0)))
local healed = mon.hp - before local healed = mon.hp - before
if healed > 0 then if healed > 0 then
self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed, self:emit({ kind = "heal", side = self:sideOf(mon), amount = healed,
hp = mon.hp }) hp = mon.hp, anim = opts and opts.anim })
end end
return healed return healed
end end
@@ -2600,6 +2600,35 @@ Battle.MOVE_EFFECTS.EFFECT_FORCE_SWITCH = function(self, attacker, defender,
self.forcedSwitch = true self.forcedSwitch = true
end end
-- BattleCommand_Teleport (engine/battle/move_effects/teleport.asm). Fails
-- outright for BATTLETYPE_FORCESHINY/TRAP, for a trapped user, and in any
-- TRAINER battle; in a WILD battle the level ladder is identical to
-- EFFECT_FORCE_SWITCH's. Without an entry here TELEPORT fell through to
-- the (0-power) damage path and never ended the battle.
Battle.MOVE_EFFECTS.EFFECT_TELEPORT = function(self, attacker, defender)
if self.battleType == Battle.BATTLETYPE_FORCESHINY
or self.battleType == Battle.BATTLETYPE_TRAP
or self:volatile(defender).trapsTarget then
return fail(self)
end
if not self.wild then return fail(self) end
local userLevel = attacker.level or 1
local targetLevel = defender.level or 1
local succeeds = userLevel >= targetLevel
if not succeeds then
local roll = self:rollBelow(math.min(256, userLevel + targetLevel + 1))
succeeds = roll >= math.floor(targetLevel / 4)
end
if not succeeds then return fail(self) end
self.over = true
self.outcome = "fled"
self.forcedSwitch = true
self:emit({ kind = "run", side = self:sideOf(attacker),
text = self:monName(attacker) .. " fled from battle!" })
end
-- -------------------------------------------------------- the move effects -- -------------------------------------------------------- the move effects
-- --
-- The three tables above as records, in the shape src/mods/Schemas.lua's -- The three tables above as records, in the shape src/mods/Schemas.lua's
@@ -4442,7 +4471,8 @@ function Battle:tickHeldItem(mon)
end end
if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then if effect == "HELD_BERRY" and (mon.hp or 0) * 2 <= maxHp then
self:heal(mon, parameter > 0 and parameter or 10) -- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
self:heal(mon, parameter > 0 and parameter or 10, { anim = "RECOVER" })
mon.item = nil mon.item = nil
self:emit({ kind = "message", self:emit({ kind = "message",
text = name .. " ate the " .. (def.name or "BERRY") .. "!" }) text = name .. " ate the " .. (def.name or "BERRY") .. "!" })
+13 -2
View File
@@ -203,7 +203,8 @@ function ChipAudio.playMusic(data, header, allowLoops)
cmdCh:push({ cmd = "play", gen = gen, header = header, cmdCh:push({ cmd = "play", gen = gen, header = header,
allowLoops = allowLoops, audio = slimAudio(data), allowLoops = allowLoops, audio = slimAudio(data),
channelVolumes = ChipSynth.getChannelVolumes(), channelVolumes = ChipSynth.getChannelVolumes(),
channelPitches = ChipSynth.getChannelPitches() }) channelPitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo() })
currentMusic = { source = source, gen = gen, threaded = true, currentMusic = { source = source, gen = gen, threaded = true,
started = false, finished = false } started = false, finished = false }
-- playback starts in update() once the first buffer arrives (~1 frame) -- playback starts in update() once the first buffer arrives (~1 frame)
@@ -214,7 +215,8 @@ local function pushChannelMix()
if workerReady and cmdCh then if workerReady and cmdCh then
cmdCh:push({ cmd = "channelMix", cmdCh:push({ cmd = "channelMix",
volumes = ChipSynth.getChannelVolumes(), volumes = ChipSynth.getChannelVolumes(),
pitches = ChipSynth.getChannelPitches() }) pitches = ChipSynth.getChannelPitches(),
stereo = ChipSynth.getStereo() })
end end
end end
@@ -352,6 +354,15 @@ function ChipAudio.shutdown()
workerReady = false workerReady = false
end end
function ChipAudio.setStereo(enabled)
ChipSynth.setStereo(enabled)
pushChannelMix()
end
function ChipAudio.getStereo()
return ChipSynth.getStereo()
end
-- Runtime mix for one hardware channel (1..4). Takes effect on the next -- Runtime mix for one hardware channel (1..4). Takes effect on the next
-- synthesized buffer (live music) and on any SFX/cry rendered after the call. -- synthesized buffer (live music) and on any SFX/cry rendered after the call.
function ChipAudio.setChannelVolume(hw, scale) function ChipAudio.setChannelVolume(hw, scale)
+19 -4
View File
@@ -29,6 +29,18 @@ ChipSynth.SAMPLE_RATE = SAMPLE_RATE
ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES ChipSynth.MUSIC_BUFFER_SAMPLES = MUSIC_BUFFER_SAMPLES
ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT ChipSynth.MUSIC_BUFFER_COUNT = MUSIC_BUFFER_COUNT
-- Gen 2 SOUND option (MONO/STEREO): gates Music_StereoPanning's per-song
-- panning byte (audio/engine.asm:1987 wOptions STEREO bit).
local stereoEnabled = false
function ChipSynth.setStereo(enabled)
stereoEnabled = not not enabled
end
function ChipSynth.getStereo()
return stereoEnabled
end
-- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise). -- Runtime mix per hardware channel (1 pulse, 2 pulse, 3 wave, 4 noise).
-- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave, -- Volume: 1 = authentic GB, 0 = mute. Pitch: 1 = authentic, 2 = +1 octave,
-- 0.5 = -1 octave. Applied at sample time so a live change reaches the next -- 0.5 = -1 octave. Applied at sample time so a live change reaches the next
@@ -760,11 +772,14 @@ function Channel:nextEventGen2()
-- no-op for the PCM renderer -- no-op for the PCM renderer
elseif command == 0xEE then -- unknownmusic0xee elseif command == 0xEE then -- unknownmusic0xee
self:word() self:word()
elseif command == 0xEF then -- stereo_panning (honor always; options.stereo) elseif command == 0xEF then
-- audio/engine.asm:1987 Music_StereoPanning: apply only when STEREO is on
local packed = self:byte() local packed = self:byte()
local mask = bit.lshift(1, self.hardware - 1) if stereoEnabled then
local default = bit.bor(bit.lshift(mask, 4), mask) local mask = bit.lshift(1, self.hardware - 1)
self.tracks = bit.band(packed, default) local default = bit.bor(bit.lshift(mask, 4), mask)
self.tracks = bit.band(packed, default)
end
elseif command == 0xF0 then -- sfx_toggle_noise elseif command == 0xF0 then -- sfx_toggle_noise
if self.noiseSampling then if self.noiseSampling then
self.noiseSampling = false self.noiseSampling = false
+13 -28
View File
@@ -140,7 +140,7 @@ Game2.anchorNewGameClock = anchorNewGameClock
function Game2.new() function Game2.new()
local self = setmetatable({ local self = setmetatable({
speedOverride = 1, speedOverride = nil,
capturePath = nil, capturePath = nil,
world = nil, world = nil,
status = nil, status = nil,
@@ -975,27 +975,9 @@ function Game2:load()
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
Pipelines.install(self.data) Pipelines.install(self.data)
Pipelines.applyOptions(self.options) Pipelines.applyOptions(self.options)
-- Gold composites the WHOLE-FRAME half of a pipeline (`present`) and not the -- Both halves run on Gold now: `present` folds over the composite in
-- world half: its overworld draws straight to the window rather than into a -- Game2:draw, `drawWorld` owns the world pass in World:drawPipeline. So a
-- canvas the way src/world/OverworldController.lua:4827 hands one to -- restored world level stays switched on, as it does for Gen 1.
-- Pipelines.drawWorld, so there is nothing here for drawWorld to replace yet.
-- A restored level for a world-only pipeline is retired rather than left
-- switched on, because on Gold it would render nothing AND hold TILT off
-- (Pipelines.setLevel's tilt exclusion). The stored level in
-- options.pipelines is left untouched, so the mode comes back the day Gold
-- grows a world canvas; Tilt is re-applied from the option the exclusion just
-- cleared.
local retired = false
for _, entry in ipairs(Pipelines.list()) do
if entry.def.drawWorld and not entry.def.present
and Pipelines.level(entry.id) > 0 then
Pipelines.setLevel(entry.id, 0)
retired = true
end
end
if retired then
require("src.render.Tilt").applyOptions(self.options)
end
-- After the merge, so a font override and a translation mod's catalog -- After the merge, so a font override and a translation mod's catalog
-- (#501) are both in Data before the first screen draws a glyph. Gen 1 -- (#501) are both in Data before the first screen draws a glyph. Gen 1
@@ -1112,19 +1094,20 @@ function Game2:update(dt)
-- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265): -- reason and at the same place Gen 1 ticks them (src/core/Game.lua:265):
-- they are presentational, so fast-forward must not speed them up. -- they are presentational, so fast-forward must not speed them up.
require("src.render.Pipelines").update(dt) require("src.render.Pipelines").update(dt)
if self.phase == "boot" then
FixedStep.maxAccum = 0.25
FixedStep:update(dt)
return
end
if not self.world or not self.world.map then return end
-- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does: -- GAME SPEED scales the logic clock only, exactly as the Gen 1 path does:
-- audio runs off its own real-time accumulator, so music and sfx keep their -- audio runs off its own real-time accumulator, so music and sfx keep their
-- tempo at every multiplier. speedOverride is the driver/CLI hook and wins -- tempo at every multiplier. speedOverride is the driver/CLI hook and wins
-- over the saved option. -- over the saved option.
-- pokegold engine/menus/intro_menu.asm:848 IntroSequence: boot cinema runs on the same clock as the overworld
local speed = math.max(1, local speed = math.max(1,
tonumber(self.speedOverride) or tonumber(self.options and self.options.speed) tonumber(self.speedOverride) or tonumber(self.options and self.options.speed)
or 1) or 1)
if self.phase == "boot" then
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
FixedStep:update(dt * speed)
return
end
if not self.world or not self.world.map then return end
FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05) FixedStep.maxAccum = math.max(0.25, speed / 60 + 0.05)
FixedStep:update(dt * speed) FixedStep:update(dt * speed)
end end
@@ -1921,6 +1904,8 @@ function Game2:applyOptions()
-- the mod pipeline ladder rides options.pipelines and restores with the rest -- the mod pipeline ladder rides options.pipelines and restores with the rest
-- of the display block, as it does in src/core/Game.lua:1041 -- of the display block, as it does in src/core/Game.lua:1041
require("src.render.Pipelines").applyOptions(options) require("src.render.Pipelines").applyOptions(options)
-- src/core/Game.lua:1121 mirrors this call for Gen 1
Input:applyBindings(options.bindings)
-- options.touchControls (the launcher editor's per-orientation layouts) and -- options.touchControls (the launcher editor's per-orientation layouts) and
-- options.haptics, the same two keys Gen 1 hands over here -- options.haptics, the same two keys Gen 1 hands over here
-- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a -- (src/core/Game.lua:1073). One options.lua serves both games, so the pad a
+2
View File
@@ -460,6 +460,8 @@ end
function Music.applyOptions(opts) function Music.applyOptions(opts)
Music.setVolumeLevel(opts and opts.musicVol or 7) Music.setVolumeLevel(opts and opts.musicVol or 7)
Music.setFilterLevel(opts and opts.musicFilter or 0) Music.setFilterLevel(opts and opts.musicFilter or 0)
-- engine/menus/options_menu.asm SOUND row (wOptions STEREO bit)
require("src.core.ChipAudio").setStereo(opts and opts.sound == "STEREO")
end end
local function sourceStopped(src) local function sourceStopped(src)
+4
View File
@@ -53,6 +53,9 @@ local function handle(cmd)
if cmd.channelPitches ~= nil then if cmd.channelPitches ~= nil then
ChipSynth.setChannelPitches(cmd.channelPitches) ChipSynth.setChannelPitches(cmd.channelPitches)
end end
if cmd.stereo ~= nil then
ChipSynth.setStereo(cmd.stereo)
end
local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header, local ok, eng = pcall(ChipSynth.newEngine, data, cmd.header,
{ allowLoops = cmd.allowLoops }) { allowLoops = cmd.allowLoops })
if ok then if ok then
@@ -69,6 +72,7 @@ local function handle(cmd)
elseif cmd.cmd == "channelMix" then elseif cmd.cmd == "channelMix" then
if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end if cmd.volumes ~= nil then ChipSynth.setChannelVolumes(cmd.volumes) end
if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end if cmd.pitches ~= nil then ChipSynth.setChannelPitches(cmd.pitches) end
if cmd.stereo ~= nil then ChipSynth.setStereo(cmd.stereo) end
elseif cmd.cmd == "invalidate" then elseif cmd.cmd == "invalidate" then
ChipSynth.invalidateBanks() ChipSynth.invalidateBanks()
elseif cmd.cmd == "quit" then elseif cmd.cmd == "quit" then
+2 -2
View File
@@ -96,7 +96,7 @@ function Nests.landmark(data, index)
end end
local function landmarkOfMap(data, mapId) local function landmarkOfMap(data, mapId)
local def = data and data.maps and data.maps[mapId] local def = data and data.gen2Maps and data.gen2Maps[mapId]
return def and def.landmark return def and def.landmark
end end
@@ -131,7 +131,7 @@ function Nests.find(data, species, region, save)
out[#out + 1] = landmark out[#out + 1] = landmark
end end
local enc = data and data.encounters local enc = data and data.gen2Encounters
for _, key in ipairs({ "grass", "water" }) do for _, key in ipairs({ "grass", "water" }) do
for mapId, entry in pairs((enc and enc[key]) or {}) do for mapId, entry in pairs((enc and enc[key]) or {}) do
if tableHasSpecies(entry, species) then if tableHasSpecies(entry, species) then
+3
View File
@@ -127,6 +127,9 @@ end
Save.filenames = saveNames Save.filenames = saveNames
local function fs() local function fs()
-- portable.txt: same standard/portable root as Gen 1 (SaveData.persistenceFs).
local ok, SaveData = pcall(require, "src.core.SaveData")
if ok and SaveData.persistenceFs then return SaveData.persistenceFs() end
return love.filesystem return love.filesystem
end end
+4 -1
View File
@@ -241,7 +241,10 @@ function RomExtractorGen2:writeCompressedPic(label, tiles, relative)
while #pixels < byteLength do pixels[#pixels + 1] = 0 end while #pixels < byteLength do pixels[#pixels + 1] = 0 end
while #pixels > byteLength do table.remove(pixels) end while #pixels > byteLength do table.remove(pixels) end
pixels = ImageWriter.columnsToRows(pixels, tiles, tiles) pixels = ImageWriter.columnsToRows(pixels, tiles, tiles)
self:write2bpp(pixels, size, size, relative) -- pokegold engine/battle/core.asm GetTrainerBackpic: no hardware masking,
-- so matte the white backdrop like Gen 1's writeCompressedPic does.
self:save(ImageWriter.matteColor0(
ImageWriter.decode2bpp(pixels, size, size)), relative)
end end
function RomExtractorGen2:extractConstants() function RomExtractorGen2:extractConstants()
+4 -1
View File
@@ -19,6 +19,8 @@ TextBox.isTextBox = true
-- construction time, so an unthemed boot stays byte-identical -- construction time, so an unthemed boot stays byte-identical
local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6 local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6
local MAX_COLS = 18 local MAX_COLS = 18
-- pokegold constants/ram_constants.asm: TEXT_DELAY_FAST/MED/SLOW = 1/3/5
local NAME_DELAYS = { FAST = 1, MID = 3, SLOW = 5 }
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops -- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
-- up over the still-visible text (YesNoChoicePokeCenter and friends); -- up over the still-visible text (YesNoChoicePokeCenter and friends);
@@ -352,7 +354,8 @@ function TextBox:update(dt)
-- typewriter cadence: one character every N frames, N = the OPTION -- typewriter cadence: one character every N frames, N = the OPTION
-- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B -- text speed (TextSpeedOptionData frame delays 1/3/5); holding A/B
-- prints every frame like the original's held-button fast path -- prints every frame like the original's held-button fast path
local delay = (self.game.save.options and self.game.save.options.textSpeed) or 3 local rawSpeed = self.game.save.options and self.game.save.options.textSpeed
local delay = NAME_DELAYS[rawSpeed] or rawSpeed or 3
if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end if delay ~= 1 and delay ~= 3 and delay ~= 5 then delay = 3 end
if input:isDown("a") or input:isDown("b") then delay = 1 end if input:isDown("a") or input:isDown("b") then delay = 1 end
self.charTimer = (self.charTimer or 0) + 1 self.charTimer = (self.charTimer or 0) + 1
+10 -1
View File
@@ -236,6 +236,12 @@ local function runCmd(self, cmd, op)
if self.hidePicFn then self.hidePicFn() end if self.hidePicFn then self.hidePicFn() end
elseif op == "writetext" or op == "farwritetext" then elseif op == "writetext" or op == "farwritetext" then
self:showText(cmd.text) self:showText(cmd.text)
if self.nextOp == "playsound" then
-- pokegold home/joypad.asm PromptButton: the real press this box's
-- own close absorbed plays SFX_READ_TEXT_2; drain it before the
-- script's own playsound or Sound.lua's priority gate drops it.
coroutine.yield({ kind = "waitsfx" })
end
elseif op == "rawtext" then elseif op == "rawtext" then
-- NOT a cart opcode. `writetext`'s operand is a KEY into text.lua, and -- NOT a cart opcode. `writetext`'s operand is a KEY into text.lua, and
-- text.lua only holds strings the extractor reached through a script -- text.lua only holds strings the extractor reached through a script
@@ -384,9 +390,12 @@ local function runCmd(self, cmd, op)
local scene = self.getMapSceneFn and self.getMapSceneFn(group, mapNum) local scene = self.getMapSceneFn and self.getMapSceneFn(group, mapNum)
self.scriptVar = scene or 0xff self.scriptVar = scene or 0xff
elseif op == "turnobject" then elseif op == "turnobject" then
-- engine/events/std_scripts.asm: turnobject LAST_TALKED resolves to the NPC last talked to
local facing = Movement.dir(cmd.facing or 0) local facing = Movement.dir(cmd.facing or 0)
local object = cmd.object or 0
if object == LAST_TALKED then object = self.lastTalked end
if self.turnObjectFn then if self.turnObjectFn then
self.turnObjectFn(cmd.object or 0, facing) self.turnObjectFn(object, facing)
end end
elseif op == "applymovement" or op == "applymovementlasttalked" then elseif op == "applymovement" or op == "applymovementlasttalked" then
local object = cmd.object or 0 local object = cmd.object or 0
+4 -1
View File
@@ -74,7 +74,10 @@ function ChoiceBox:draw()
if r and r.setUIAnchor then if r and r.setUIAnchor then
r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor) r:setUIAnchor(tx * 8, ty * 8, tw * 8, th * 8, self.anchor)
end end
Font.drawBox(tx, ty, tw, th) -- pokegold home/menu.asm YesNoBox: font-page tiles take the screen's own
-- BG palette 0 colour 0, same as TextBox.lua's paper fold.
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) love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8) Font.draw(Strings("YES"), (tx + 2) * 8, (ty + 1) * 8)
Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8) Font.draw(Strings("NO"), (tx + 2) * 8, (ty + 3) * 8)
+153 -34
View File
@@ -43,8 +43,9 @@ local BattleState = {}
BattleState.__index = BattleState BattleState.__index = BattleState
BattleState.isOpaque = true BattleState.isOpaque = true
-- How long a message stays before the next event runs, in logic steps. The -- Armed while a battle line waits for PromptButton (home/text.asm). Any
-- cart waits for A on most lines; holding A skips faster, same as text boxes. -- positive value means "hold until A/B"; the cart never times these out, so
-- the victory jingle can keep looping through the post-win prompts.
local MESSAGE_FRAMES = 48 local MESSAGE_FRAMES = 48
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves. -- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
@@ -573,8 +574,10 @@ function BattleState:drawPic(mon, back)
local px, py local px, py
local boxTiles local boxTiles
if back then if back then
px = BattleState.PLAYER_PIC_TILE_X * 8 -- pokegold engine/battle/core.asm:8569: 6x6 box, bottom-aligned/centred
py = BattleState.PLAYER_PIC_TILE_Y * 8 local box = BattleState.PLAYER_PIC_TILES * 8
px = BattleState.PLAYER_PIC_TILE_X * 8 + math.floor((box - w) / 2)
py = BattleState.PLAYER_PIC_TILE_Y * 8 + (box - h)
boxTiles = BattleState.PLAYER_PIC_TILES boxTiles = BattleState.PLAYER_PIC_TILES
else else
-- Bottom-aligned and horizontally centred inside the 7x7 box. -- Bottom-aligned and horizontally centred inside the 7x7 box.
@@ -881,8 +884,12 @@ function BattleState:startAnim(key, opts)
if not self.anims.scripts[key] then return false end if not self.anims.scripts[key] then return false end
-- BattleAnimRunScript's own gate: `bit BATTLE_SCENE, [wOptions]` skips the -- BattleAnimRunScript's own gate: `bit BATTLE_SCENE, [wOptions]` skips the
-- move animation entirely, which is the OPTION screen's BATTLE SCENE row. -- move animation entirely, which is the OPTION screen's BATTLE SCENE row.
-- The check only applies to a real move id (wFXAnimID+1 == 0); non-move
-- ids (isMove unset here) branch straight to .not_move and always run.
local options = self.game and self.game.options local options = self.game and self.game.options
if options and options.battleScene == false then return false end if options and options.battleScene == false and opts and opts.isMove then
return false
end
opts = opts or {} opts = opts or {}
local data = (self.game and self.game.data) or {} local data = (self.game and self.game.data) or {}
local audio = data.audio or {} local audio = data.audio or {}
@@ -939,11 +946,38 @@ function BattleState:startAnim(key, opts)
return true return true
end end
-- wBattleAfterAnim target for this attacker's turn
-- (effect_commands.asm:1963-1972): player swing -> enemy shake, and reverse.
function BattleState:afterAnimFor(side)
if side == "player" then return "ANIM_ENEMY_DAMAGE" end
return "ANIM_PLAYER_DAMAGE"
end
function BattleState:animForMove(moveId, side) function BattleState:animForMove(moveId, side)
local key = self.anims and self.anims.moves and self.anims.moves[moveId] local key = self.anims and self.anims.moves and self.anims.moves[moveId]
return self:startAnim(key, { local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true, turn = self:turnFor(side), animId = moveId, isMove = true,
}) })
if started then
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
-- restores HUDs it immediately runs wBattleAfterAnim (the hit shake).
-- Queue it so stepAnim chains without waiting on the next event.
self.pendingAfterAnim = { name = self:afterAnimFor(side), side = side }
end
return started
end
-- Kick off a queued after-anim; returns true when one is now running.
function BattleState:startPendingAfterAnim()
local pending = self.pendingAfterAnim
if not pending then return false end
self.pendingAfterAnim = nil
if self:animForId(pending.name, pending.side) then
-- dealDamage's default ANIM_x_DAMAGE is this same shake; skip it there.
self.afterAnimPlayed = true
return true
end
return false
end end
-- True while BattleAnimClearHud has that side's HUD blanked. -- True while BattleAnimClearHud has that side's HUD blanked.
@@ -969,10 +1003,16 @@ function BattleState:stepAnim(input)
-- tilemap is whatever they had got to and nothing is latched -- the -- tilemap is whatever they had got to and nothing is latched -- the
-- explicit latches (a catch) are the only ones that survive a skip. -- explicit latches (a catch) are the only ones that survive a skip.
self.anim = nil self.anim = nil
-- 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()
end end
if not self.anim:step() then if not self.anim:step() then
self.anim = nil -- pokegold data/moves/animations.asm .Click: anim_keepsprites means
-- the OAM outlives the script, so keep the runner for drawing too.
if not self.anim.keepSprites then self.anim = nil end
if self:startPendingAfterAnim() then return end
return self:endSendOutAnim() return self:endSendOutAnim()
end end
end end
@@ -1103,7 +1143,14 @@ function BattleState:advanceQueue()
-- until the next damage or heal event moved it. -- until the next damage or heal event moved it.
local battle = self.battle local battle = self.battle
local mon = battle and battle.party and battle.party[event.index] local mon = battle and battle.party and battle.party[event.index]
-- pokegold engine/battle/core.asm:7057-7069: every mon that leveled
-- gets the stats box, not just the mon currently on the field.
self.pendingStatsMon = mon
-- BattleText_StringBuffer1GrewToLevel ends in text_end (battle.asm:336-343),
-- and the active mon never even prints it (core.asm:7044-7056 jumps to the
-- stats box). Either way there is no PromptButton before the stats box.
if mon and mon == battle.player then if mon and mon == battle.player then
event.text = nil
if self.shownHp then if self.shownHp then
self.shownHp.player = mon.hp or 0 self.shownHp.player = mon.hp or 0
if self.hpAnim and self.hpAnim.side == "player" then if self.hpAnim and self.hpAnim.side == "player" then
@@ -1257,7 +1304,19 @@ function BattleState:advanceQueue()
end end
if event.text then if event.text then
self.message = event.text self.message = event.text
self.messageTimer = MESSAGE_FRAMES -- Lines that must not hold the queue for A/B:
-- move UsedMoveText -> text_end, then moveanim
-- level GrewToLevel is text_end (battle.asm:336-343), then the stats
-- box's WaitPressAorB is the real hold
-- experience keeps the wait: _ExpPointsText ends in `prompt`
-- (common_1.asm:1660-1665). update() runs stepExpAnim before that wait,
-- so the bar crawls under the line and A dismisses it before the battle
-- can end.
if event.kind == "move" or event.kind == "level" then
self.messageTimer = 0
else
self.messageTimer = MESSAGE_FRAMES
end
-- BattleStartMessage's own line: the enemy HUD comes up on the step after -- BattleStartMessage's own line: the enemy HUD comes up on the step after
-- it returns (engine/battle/core.asm:7808-7817), not with it. -- it returns (engine/battle/core.asm:7808-7817), not with it.
if event.intro then self.introTextShown = true end if event.intro then self.introTextShown = true end
@@ -1277,14 +1336,25 @@ function BattleState:advanceQueue()
end end
end end
-- The move's own animation plays over its "used X!" line, which is where -- The move's own animation plays over its "used X!" line, which is where
-- PlayBattleAnim sits in the effect command list. A damage event that -- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
-- follows gets the shared hit animation instead. -- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens -- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a -- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
-- move that missed burns the delay and plays nothing. Battle:markMissed sets -- move that missed burns the delay and plays nothing. Battle:markMissed sets
-- event.missed on every wAttackMissed path. -- event.missed on every wAttackMissed path.
if event.kind == "move" and not event.missed then if event.kind == "move" and not event.missed then
self:animForMove(event.move, event.side) self.afterAnimPlayed = nil
self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side) then
-- BATTLE SCENE off skips the move script but still runs wBattleAfterAnim
-- (anim_commands.asm:55-72 .disabled fallthrough).
local options = self.game and self.game.options
if options and options.battleScene == false then
if self:animForId(self:afterAnimFor(event.side), event.side) then
self.afterAnimPlayed = true
end
end
end
elseif event.kind == "damage" and event.side then elseif event.kind == "damage" and event.side then
-- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972), -- ANIM_x_DAMAGE is the MOVE's after-anim (effect_commands.asm:1963-1972),
-- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203). -- so only a move hit gets it; `animMove` is HandleWrap's (core.asm:1198-1203).
@@ -1293,10 +1363,25 @@ function BattleState:advanceQueue()
if event.animMove then if event.animMove then
self:animForMove(event.animMove, from) self:animForMove(event.animMove, from)
elseif event.anim ~= false then elseif event.anim ~= false then
self:animForId(event.anim local hit = event.anim
or (event.side == "enemy" and "ANIM_ENEMY_DAMAGE" or (event.side == "enemy" and "ANIM_ENEMY_DAMAGE"
or "ANIM_PLAYER_DAMAGE"), from) or "ANIM_PLAYER_DAMAGE")
-- Already played as the move's after-anim; do not shake twice.
if self.afterAnimPlayed
and (hit == "ANIM_ENEMY_DAMAGE" or hit == "ANIM_PLAYER_DAMAGE") then
self.afterAnimPlayed = nil
else
self:animForId(hit, from)
end
end end
else
-- Status moves still chain the after-anim but emit no damage event to
-- consume the latch; drop it before the next unrelated line.
self.afterAnimPlayed = nil
end
if event.kind == "heal" and event.anim and event.side then
-- pokegold engine/battle/core.asm:4074 ItemRecoveryAnim
self:animForMove(event.anim, event.side)
elseif event.kind == "send" and event.side then elseif event.kind == "send" and event.side then
-- Every enemy send-out goes through ShowSetEnemyMonAndSendOutAnimation -- Every enemy send-out goes through ShowSetEnemyMonAndSendOutAnimation
-- (engine/battle/core.asm:3354) -- the faint replacement out of -- (engine/battle/core.asm:3354) -- the faint replacement out of
@@ -1593,7 +1678,9 @@ function BattleState:update(_dt)
-- An animation owns the screen for as long as it runs, exactly the way -- An animation owns the screen for as long as it runs, exactly the way
-- RunBattleAnimScript owns the main loop. -- RunBattleAnimScript owns the main loop.
if self.anim then -- pokegold data/moves/animations.asm .Click: a finished keepsprites run
-- no longer owns the loop, just the OAM the draw path still reads.
if self.anim and not (self.anim:done() and self.anim.keepSprites) then
self:stepAnim(input) self:stepAnim(input)
return return
end end
@@ -1611,32 +1698,45 @@ function BattleState:update(_dt)
if Sound.isPlaying(self.waitSfx) then return end if Sound.isPlaying(self.waitSfx) then return end
self.waitSfx = nil self.waitSfx = nil
end end
-- AnimateExpBar sits right after PrintText Text_MonGainedExpPoint
-- (core.asm:6881-6888), with that line still on screen. Run the crawl
-- before any PromptButton wait so the bar does not sit frozen until A.
if self:stepExpAnim() then return end
if self.messageTimer > 0 then if self.messageTimer > 0 then
if self.tutorial then if self.tutorial then
-- PromptButton really does wait for the button; MESSAGE_FRAMES is this -- PromptButton waits for the button; the tutorial cannot press it, so
-- screen's shortcut for that, and the tutorial cannot take it. The -- DudeAutoInput_A (frame 0x51) answers. Never auto-timeout here: a
-- DUDE's A lands on frame 0x51 (DudeAutoInput_A), so a line that timed -- 48-frame skip would hand his press to the next screen.
-- out at 48 would hand his press to whichever screen came next.
self:dudeInput(CatchTutorial.PROMPT_STREAM, self:dudeInput(CatchTutorial.PROMPT_STREAM,
"prompt:" .. tostring(self.message)) "prompt:" .. tostring(self.message))
else
self.messageTimer = self.messageTimer - 1
end end
-- A is the page-advance, exactly like a text box. -- PromptButton (home/text.asm): A/B pages; no frame countdown.
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
return return
end end
-- AnimateExpBar is called from GiveExperiencePoints AFTER -- pokegold engine/battle/core.asm:7057-7069: the stats box shows once
-- Text_MonGainedExpPoint has been read (engine/battle/core.asm:6884-6888), -- the "grew to level" line has finished, held for A/B.
-- so the crawl runs with that line still standing and the "grew to level" if self.pendingStatsMon then
-- line waits behind it. self.statsBoxMon = self.pendingStatsMon
if self:stepExpAnim() then return end self.pendingStatsMon = nil
self.phase = "stats-box"
return
end
self:advanceQueue() self:advanceQueue()
return return
end end
-- pokegold engine/battle/core.asm:7069 (WaitPressAorB_BlinkCursor).
if self.phase == "stats-box" then
if input:wasPressed("a") or input:wasPressed("b") then
self.statsBoxMon = nil
self.phase = "resolving"
end
return
end
-- The turn CheckPlayerLockedIn skipped the menu for. No input is read: the -- The turn CheckPlayerLockedIn skipped the menu for. No input is read: the
-- cart falls straight into ParsePlayerAction, whose .locked_in arm has no -- cart falls straight into ParsePlayerAction, whose .locked_in arm has no
-- MoveSelectionScreen in front of it. -- MoveSelectionScreen in front of it.
@@ -1653,6 +1753,8 @@ function BattleState:update(_dt)
if self.phase == "menu" then if self.phase == "menu" then
-- 2x2 grid: left/right swap the column, up/down the row. -- 2x2 grid: left/right swap the column, up/down the row.
-- MenuClickSound / PlayClickSFX (home/menu.asm:746-762): SFX_READ_TEXT_2
-- on A/B only, never on D-pad.
if input:wasPressed("left") or input:wasPressed("right") then if input:wasPressed("left") or input:wasPressed("right") then
self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1 self.menuIndex = self.menuIndex % 2 == 1 and self.menuIndex + 1
or self.menuIndex - 1 or self.menuIndex - 1
@@ -1660,6 +1762,7 @@ function BattleState:update(_dt)
self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2 self.menuIndex = self.menuIndex <= 2 and self.menuIndex + 2
or self.menuIndex - 2 or self.menuIndex - 2
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
local choice = MENU[self.menuIndex] local choice = MENU[self.menuIndex]
if choice == "FIGHT" then if choice == "FIGHT" then
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm -- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
@@ -1710,11 +1813,13 @@ function BattleState:update(_dt)
end end
elseif input:wasPressed("b") then elseif input:wasPressed("b") then
-- B leaves the list, and a mark never survives it -- B leaves the list, and a mark never survives it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil self.moveSwapIndex = nil
self.phase = "menu" self.phase = "menu"
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move -- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
-- cancels a pending swap rather than performing it -- cancels a pending swap rather than performing it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil self.moveSwapIndex = nil
local move = moves[self.moveIndex] local move = moves[self.moveIndex]
if not move then return end if not move then return end
@@ -1733,7 +1838,6 @@ function BattleState:update(_dt)
-- AskGiveNicknameText ends on `done`, so the line stands while the box is -- AskGiveNicknameText ends on `done`, so the line stands while the box is
-- up rather than paging away from under it. -- up rather than paging away from under it.
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -1754,7 +1858,6 @@ function BattleState:update(_dt)
-- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310). -- straight through to the enemy's send-out (engine/battle/core.asm:3305-3310).
if self.phase == "ask-shift" then if self.phase == "ask-shift" then
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -1775,7 +1878,6 @@ function BattleState:update(_dt)
if self.phase == "refuse-shift" then if self.phase == "refuse-shift" then
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -1797,7 +1899,6 @@ function BattleState:update(_dt)
-- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry. -- what ForcePickPartyMonInBattle's `jr c, .loop` does with the carry.
if self.phase == "refuse-switch" then if self.phase == "refuse-switch" then
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -1816,7 +1917,6 @@ function BattleState:update(_dt)
if self.phase == "refuse-move" then if self.phase == "refuse-move" then
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -1829,7 +1929,6 @@ function BattleState:update(_dt)
if self.phase == "learn-intro" then if self.phase == "learn-intro" then
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -1843,7 +1942,6 @@ function BattleState:update(_dt)
if self.phase == "ask-forget" or self.phase == "stop-learning" then if self.phase == "ask-forget" or self.phase == "stop-learning" then
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -1864,7 +1962,6 @@ function BattleState:update(_dt)
-- MoveCantForgetHMText holds like any prompt, then `jr .loop` reprints -- MoveCantForgetHMText holds like any prompt, then `jr .loop` reprints
-- MoveAskForgetText over the list (engine/pokemon/learn.asm:193-197). -- MoveAskForgetText over the list (engine/pokemon/learn.asm:193-197).
if self.messageTimer > 0 then if self.messageTimer > 0 then
self.messageTimer = self.messageTimer - 1
if input:wasPressed("a") or input:wasPressed("b") then if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0 self.messageTimer = 0
end end
@@ -3089,9 +3186,31 @@ function BattleState:drawPanel()
Chrome.cursor(left + 1, index == 1 and 8 or 10) Chrome.cursor(left + 1, index == 1 and 8 or 10)
end end
end end
if self.phase == "stats-box" and self.statsBoxMon then
self:drawStatsBox(self.statsBoxMon)
end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
-- pokegold engine/pokemon/mon_stats.asm:118-124 (PrintTempMonStats.StatNames).
local STATS_BOX_ROWS = {
{ "ATTACK", "attack" }, { "DEFENSE", "defense" },
{ "SPCL.ATK", "specialAttack" }, { "SPCL.DEF", "specialDefense" },
{ "SPEED", "speed" },
}
-- pokegold engine/battle/core.asm:7060-7066 (box at hlcoord 9,0, stats at 11,y).
function BattleState:drawStatsBox(mon)
local stats = mon and mon.stats
if not stats then return end
Chrome.textbox(9, 0, 9, 10)
for i, row in ipairs(STATS_BOX_ROWS) do
local ty = 1 + (i - 1) * 2
Chrome.print(Strings(row[1]), 11, ty)
Chrome.printRight(("%d"):format(stats[row[2]] or 0), 19, ty + 1)
end
end
-- The BG layer, plus whatever the animation is doing to it, plus the OBJ -- The BG layer, plus whatever the animation is doing to it, plus the OBJ
-- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn -- layer on top. OBJs are not affected by SCX/SCY, which is why they are drawn
-- after the scanline blit rather than into the canvas with everything else. -- after the scanline blit rather than into the canvas with everything else.
+54 -20
View File
@@ -43,6 +43,7 @@
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local Boxes = require("src.core.gen2.Boxes") local Boxes = require("src.core.gen2.Boxes")
local Chrome = require("src.ui.gen2.Chrome") local Chrome = require("src.ui.gen2.Chrome")
local Font = require("src.render.Font")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Mail = require("src.core.gen2.Mail") local Mail = require("src.core.gen2.Mail")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
@@ -74,6 +75,14 @@ local PARTY_BOX = 0
-- can destroy a mon. -- can destroy a mon.
local MOVE_SUBMENU = { "MOVE", "STATS", "CANCEL" } 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" }
function BoxMenu:submenuRows()
if self.mode == "move" then return MOVE_SUBMENU end
return WITHDRAW_SUBMENU
end
-- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while -- MovePKMNWithoutMail_InsertMon's .Saving_LeaveOn, printed for 20 frames while
-- the mon is written into its new home. It stays up here until a button -- the mon is written into its new home. It stays up here until a button
-- clears it, because it is also the only confirmation the player gets that the -- clears it, because it is also the only confirmation the player gets that the
@@ -164,14 +173,16 @@ end
-- The cart's own prompts (PCString_*): short, because the box they print in -- The cart's own prompts (PCString_*): short, because the box they print in
-- is one row of 18 columns. -- is one row of 18 columns.
function BoxMenu:prompt() function BoxMenu:prompt()
if self.mode == "deposit" then return "Deposit which one?" end -- engine/pokemon/bills_pc.asm:356-369: PrepSubmenu places PCString_WhatsUp.
if self.phase == "submenu" then return "What's up?" end
if self.mode == "move" then if self.mode == "move" then
-- .Init, .PrepSubmenu and .PrepInsertCursor each place their own string. -- .Init and .PrepInsertCursor each place their own string.
if self.phase == "insert" then return "Move to where?" end if self.phase == "insert" then return "Move to where?" end
if self.phase == "submenu" then return "What's up?" end
return "Choose a <PK><MN>." return "Choose a <PK><MN>."
end end
return "Choose a POKéMON." -- PCString_ChooseaPKMN: _DepositPKMN.Init and BillsPC_Withdraw.Init both
-- place this exact string (engine/pokemon/bills_pc.asm:2185).
return "Choose a <PK><MN>."
end end
function BoxMenu:total() function BoxMenu:total()
@@ -208,21 +219,15 @@ function BoxMenu:act()
if self.onClose then self.onClose() end if self.onClose then self.onClose() end
return return
end end
-- .a_button: the move screen never acts on the list itself. It checks that -- engine/pokemon/bills_pc.asm:336-344: withdraw and move both PrepSubmenu.
-- the row really is a mon and steps to $2, .PrepSubmenu. if self.mode == "move" or self.mode == "withdraw" then
if self.mode == "move" then
if not self:selected() then return end if not self:selected() then return end
self.phase = "submenu" self.phase = "submenu"
-- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE. -- `ld a, $1 / ld [wMenuCursorY], a`: the submenu always opens on MOVE.
self.submenuIndex = 1 self.submenuIndex = 1
return return
end end
local ok, result local ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
if self.mode == "deposit" then
ok, result = Boxes.deposit(self.save, self.index, self.boxIndex)
else
ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
end
if not ok then if not ok then
self.message = result self.message = result
return return
@@ -294,12 +299,28 @@ function BoxMenu:openStats()
}) })
end end
-- engine/pokemon/bills_pc.asm:397-411: failed withdraw stays on the submenu.
function BoxMenu:doWithdraw()
local ok, result = Boxes.withdraw(self.save, self.boxIndex, self.index)
if not ok then
self.message = result
return
end
self.message = nil
self.phase = nil
self:clampIndex()
end
function BoxMenu:chooseSubmenu() function BoxMenu:chooseSubmenu()
local row = MOVE_SUBMENU[self.submenuIndex] local row = self:submenuRows()[self.submenuIndex]
if row == "MOVE" then if row == "MOVE" then
self:beginMove() self:beginMove()
elseif row == "WITHDRAW" then
self:doWithdraw()
elseif row == "STATS" then elseif row == "STATS" then
self:openStats() self:openStats()
elseif row == "RELEASE" then
self:askRelease()
else else
-- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`. -- .Cancel: `ld a, $0 / ld [wJumptableIndex], a`.
self.phase = nil self.phase = nil
@@ -417,11 +438,12 @@ function BoxMenu:update(_dt)
-- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry. -- .MoveMonWOMailSubmenu, a VerticalMenu: up/down, A picks, B is its carry.
if self.phase == "submenu" then if self.phase == "submenu" then
local submenu = self:submenuRows()
if input:wasPressed("up") then if input:wasPressed("up") then
self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1 self.submenuIndex = self.submenuIndex > 1 and self.submenuIndex - 1
or #MOVE_SUBMENU or #submenu
elseif input:wasPressed("down") then elseif input:wasPressed("down") then
self.submenuIndex = self.submenuIndex < #MOVE_SUBMENU self.submenuIndex = self.submenuIndex < #submenu
and self.submenuIndex + 1 or 1 and self.submenuIndex + 1 or 1
elseif input:wasPressed("a") then elseif input:wasPressed("a") then
self:chooseSubmenu() self:chooseSubmenu()
@@ -518,6 +540,7 @@ function BoxMenu:askRelease()
return return
end end
self.message = name .. " was released." self.message = name .. " was released."
self.phase = nil
self:clampIndex() self:clampIndex()
end, { defaultNo = true })) end, { defaultNo = true }))
end end
@@ -644,12 +667,12 @@ end
-- The PC does not mark the selected row with a ▶: BillsPC_UpdateSelectionCursor -- 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 -- lays 20 OBJs as a frame *around* the row -- ten tiles wide by two tall, top
-- left at pixel (71, 31), stepping 16 pixels per row. Those cursor tiles are -- left at pixel (71, 25), stepping 16 pixels per row. Those cursor tiles are
-- not extracted, so the frame is drawn as an outline at exactly those pixels, -- not extracted, so the frame is drawn as an outline at exactly those pixels,
-- which is what the sprite frame looks like. -- which is what the sprite frame looks like.
function BoxMenu:drawSelectionFrame(row) function BoxMenu:drawSelectionFrame(row)
local G = love.graphics local G = love.graphics
local x, y = 71, 31 + (row - 1) * 16 local x, y = 71, 25 + (row - 1) * 16
G.setColor(0, 0, 0, 1) G.setColor(0, 0, 0, 1)
G.setLineWidth(1) G.setLineWidth(1)
G.rectangle("line", x + 0.5, y + 0.5, 80 - 1, 16 - 1) G.rectangle("line", x + 0.5, y + 0.5, 80 - 1, 16 - 1)
@@ -679,6 +702,9 @@ function BoxMenu:panelMon()
end end
function BoxMenu:drawPanel() function BoxMenu:drawPanel()
-- BillsPC_InitGFX loads FontsBattleExtra once for the whole screen and
-- never restores the standard font (engine/pokemon/bills_pc.asm:2169).
local wasBattle = Font.useBattleExtra(true)
Chrome.clear() Chrome.clear()
-- Box name header, then the list box hanging off it. BillsPC_BoxName is a -- Box name header, then the list box hanging off it. BillsPC_BoxName is a
@@ -686,6 +712,11 @@ function BoxMenu:drawPanel()
Chrome.box(8, 0, 12, 3) Chrome.box(8, 0, 12, 3)
Chrome.print(self:title(), 10, 1) Chrome.print(self:title(), 10, 1)
Chrome.box(8, 2, 12, 12) Chrome.box(8, 2, 12, 12)
-- BillsPC_RefreshTextboxes overwrites its own top corners with '└'/'┘'
-- (engine/pokemon/bills_pc.asm:1204-1211) so the list reads as hanging
-- off the name box above it.
Font.drawCode(Font.BORDER.bl, 8 * 8, 2 * 8)
Font.drawCode(Font.BORDER.br, 19 * 8, 2 * 8)
local list = self:list() local list = self:list()
local inserting = self.phase == "insert" local inserting = self.phase == "insert"
@@ -724,7 +755,9 @@ function BoxMenu:drawPanel()
self:drawEggPic(mon) self:drawEggPic(mon)
else else
self:drawPic(mon) self:drawPic(mon)
Chrome.print(":L" .. tostring(mon.level or 1), PIC_X, 12) -- PrintLevel always writes the single bold glyph, not ":L"
-- (home/pokemon.asm:178-183).
Chrome.print("<LV>" .. tostring(mon.level or 1), PIC_X, 12)
if mon.gender == "male" then if mon.gender == "male" then
Chrome.print("\xe2\x99\x82", 5, 12) Chrome.print("\xe2\x99\x82", 5, 12)
elseif mon.gender == "female" then elseif mon.gender == "female" then
@@ -756,13 +789,14 @@ function BoxMenu:drawPanel()
-- top spacing puts MOVE at (11,6), one row per two tiles. -- top spacing puts MOVE at (11,6), one row per two tiles.
if self.phase == "submenu" then if self.phase == "submenu" then
Chrome.box(9, 4, 11, 10) Chrome.box(9, 4, 11, 10)
for i, label in ipairs(MOVE_SUBMENU) do for i, label in ipairs(self:submenuRows()) do
local ty = 6 + (i - 1) * 2 local ty = 6 + (i - 1) * 2
if i == self.submenuIndex then Chrome.cursor(10, ty) end if i == self.submenuIndex then Chrome.cursor(10, ty) end
Chrome.print(label, 11, ty) Chrome.print(label, 11, ty)
end end
end end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
Font.useBattleExtra(wasBattle)
end end
function BoxMenu:draw() function BoxMenu:draw()
+1 -1
View File
@@ -896,7 +896,7 @@ local function printPriceOpaque(amount, ty)
end end
function MartMenu:drawBuyList() function MartMenu:drawBuyList()
Chrome.box(LIST_BOX_X, LIST_BOX_Y, LIST_BOX_W, LIST_BOX_H) -- pokegold engine/menus/scrolling_menu.asm _InitScrollingMenu: no border for the buy list
for row = 1, VISIBLE_ROWS do for row = 1, VISIBLE_ROWS do
local i = row + self.scroll local i = row + self.scroll
local ty = LIST_Y + (row - 1) * LIST_SPACING local ty = LIST_Y + (row - 1) * LIST_SPACING
+4
View File
@@ -86,6 +86,10 @@ local ROWS = {
-- --
-- The two volume rows clamp at the ends rather than wrapping, the way -- The two volume rows clamp at the ends rather than wrapping, the way
-- pokered's text-speed cursor does, so holding left reaches OFF and stays. -- pokered's text-speed cursor does, so holding left reaches OFF and stays.
{ id = "controls", label = "CONTROLS", port = true,
activate = function(game)
require("src.ui.Screens").push(game, "BindingsMenu")
end },
{ label = "MUSIC VOL", key = "musicVol", port = true, { label = "MUSIC VOL", key = "musicVol", port = true,
cycle = function(options, delta) cycle = function(options, delta)
options.musicVol = stepVolume(options.musicVol, delta) options.musicVol = stepVolume(options.musicVol, delta)
+21 -3
View File
@@ -188,6 +188,22 @@ function PokedexMenu.new(game, opts)
self.dexPalette = gfx.palette self.dexPalette = gfx.palette
end end
-- pokegold engine/pokegear/pokegear.asm Pokedex_GetArea: the AREA page
-- draws through the Pokegear's own town-map tiles and TownMapPals, not the
-- dex's PokedexLZ sheet.
local mapGfx = (opts.menuGfx or data.gen2MenuGfx or {}).pokegear
self.mapGfx = mapGfx
if mapGfx then
self.mapSheet = TileSheet.new({
path = mapGfx.tiles, wide = mapGfx.tilesWide or 16, firstTile = 0,
paletteFor = function(tile)
if not mapGfx.palettes then return nil end
if tile >= 0x60 then return mapGfx.palettes[1] end
return mapGfx.palettes[(mapGfx.palMap and mapGfx.palMap[tile + 1]) or 1]
end,
})
end
-- Pokedex_LoadUnownFont: 27 tiles at vTiles2 tile FIRST_UNOWN_CHAR, live -- Pokedex_LoadUnownFont: 27 tiles at vTiles2 tile FIRST_UNOWN_CHAR, live
-- only while UNOWN MODE is on screen. It is a sheet rather than a font -- only while UNOWN MODE is on screen. It is a sheet rather than a font
-- page (see PokedexMenu:unownGlyph), and it draws through the dex palette -- page (see PokedexMenu:unownGlyph), and it draws through the dex palette
@@ -793,7 +809,7 @@ end
function PokedexMenu:playerLandmark() function PokedexMenu:playerLandmark()
local save = self.game and self.game.save local save = self.game and self.game.save
local mapId = save and save.position and save.position.map local mapId = save and save.position and save.position.map
local def = mapId and self.data and self.data.maps and self.data.maps[mapId] local def = mapId and self.data and self.data.gen2Maps and self.data.gen2Maps[mapId]
return def and def.landmark return def and def.landmark
end end
@@ -803,11 +819,13 @@ end
-- substitutes them), and borrowing Pokegear's would freeze the map's ink. -- substitutes them), and borrowing Pokegear's would freeze the map's ink.
function PokedexMenu:drawTilemap(cells) function PokedexMenu:drawTilemap(cells)
if type(cells) ~= "table" then return end if type(cells) ~= "table" then return end
local sheet = self.mapSheet
if not sheet then return end
local i = 1 local i = 1
for ty = 0, Chrome.SCREEN_H - 1 do for ty = 0, Chrome.SCREEN_H - 1 do
for tx = 0, Chrome.SCREEN_W - 1 do for tx = 0, Chrome.SCREEN_W - 1 do
local id = cells[i] local id = cells[i]
if id then self:tile(id, tx, ty) end if id then sheet:draw(id, tx, ty) end
i = i + 1 i = i + 1
end end
end end
@@ -837,7 +855,7 @@ function PokedexMenu:drawArea()
-- uses. Without it (a cache imported before the town map was extracted) the -- uses. Without it (a cache imported before the town map was extracted) the
-- page still lists the landmark NAMES, which is the information the screen -- page still lists the landmark NAMES, which is the information the screen
-- exists to convey. -- exists to convey.
local maps = self.gfx and self.gfx.maps local maps = self.mapGfx and self.mapGfx.maps
local cells = maps and maps[region] local cells = maps and maps[region]
if cells then if cells then
self:drawTilemap(cells) self:drawTilemap(cells)
+13 -6
View File
@@ -1537,6 +1537,9 @@ function Pokegear:callContact(id)
text = self:phoneText("GearOutOfService") } text = self:phoneText("GearOutOfService") }
return return
end end
-- pokegold engine/pokegear/pokegear.asm:883-889: SFX_CALL rings before the call connects.
local world = self.game and self.game.world
if world then world:playSfxNamed("Sfx_Call", 106) end
local call = Phone.call(self.save, id, context) local call = Phone.call(self.save, id, context)
local name, className = Phone.contactName(id, self.trainers) local name, className = Phone.contactName(id, self.trainers)
call.name, call.className = name, className call.name, call.className = name, className
@@ -1562,6 +1565,11 @@ end
-- HangUp: the click, the boops, and back to "Whom do you want to call?". -- HangUp: the click, the boops, and back to "Whom do you want to call?".
function Pokegear:hangUp() function Pokegear:hangUp()
-- pokegold engine/phone/phone.asm:517-519: HangUp_Beep plays SFX_HANG_UP.
if self.call and self.call.kind ~= "nosignal" then
local world = self.game and self.game.world
if world then world:playSfxNamed("Sfx_HangUp", 107) end
end
self.call = nil self.call = nil
end end
@@ -1768,14 +1776,13 @@ function Pokegear:loadArrowSheet()
self.arrow = false self.arrow = false
local gfx = self.gfx local gfx = self.gfx
if gfx and gfx.sprites then if gfx and gfx.sprites then
self:loadPlayerIcon()
self.arrow = TileSheet.new({ self.arrow = TileSheet.new({
path = gfx.sprites, wide = gfx.spritesWide or 2, firstTile = 0, path = gfx.sprites, wide = gfx.spritesWide or 2, firstTile = 0,
-- The icon strip's palette (cream / orange / brown / black), not BG -- pokegold data/sprite_anims/oam.asm .OAMData_RedWalk: STILL_CURSOR's
-- palette 0's greys: the arrow is an OBJ and the cart tints it to match -- oamset reuses RED_WALK's OAM data, so this wears PAL_OW_RED.
-- the card icons it points at. The extract carries the gear's BG palette = (self.playerIcon and self.playerIcon.objColors)
-- palettes only, and palMap gives every icon-strip tile this same index, or (gfx.palettes and gfx.palettes[1]),
-- so it is the one that reproduces the cart rather than a guess.
palette = gfx.palettes and (gfx.palettes[4] or gfx.palettes[1]),
}) })
end end
end end
+4
View File
@@ -538,6 +538,10 @@ function TradeAnimView:drawStats(record, offset)
G.push() G.push()
G.translate(offset, WINDOW_Y) G.translate(offset, WINDOW_Y)
Chrome.textbox(PANEL_X, PANEL_Y, PANEL_INNER_W, PANEL_INNER_H) Chrome.textbox(PANEL_X, PANEL_Y, PANEL_INNER_W, PANEL_INNER_H)
-- pokegold engine/movie/trade_animation.asm:883-897,925-929: PlaceString
-- and PrintNum overwrite the border's own tile at cols 4-12, row 0.
G.setColor(1, 1, 1, 1)
G.rectangle("fill", (PANEL_X + 1) * 8, PANEL_Y * 8, 9 * 8, 8)
for _, row in ipairs(TEMPLATE_ROWS) do for _, row in ipairs(TEMPLATE_ROWS) do
Chrome.print(row.text, PANEL_X + 1, row.row) Chrome.print(row.text, PANEL_X + 1, row.row)
end end
+14
View File
@@ -175,6 +175,20 @@ function Permissions.currentDirection(coll)
return CURRENT_DIR[coll % 4] return CURRENT_DIR[coll % 4]
end end
-- DoPlayerMovement .CheckTile, HI_NYBBLE_WARPS arm (.warps): landing on a
-- door/staircase/cave forces a walk DOWN off it (engine/overworld/player_movement.asm).
local DOOR_FORCED = {
[0x71] = true, -- COLL_DOOR
[0x79] = true, -- COLL_DOOR_79 (unused)
[0x7a] = true, -- COLL_STAIRCASE
[0x7b] = true, -- COLL_CAVE
}
function Permissions.doorForcedDirection(coll)
if DOOR_FORCED[coll] then return "down" end
return nil
end
-- CheckCutCollision (engine/overworld/tile_events.asm): the collisions CUT is -- CheckCutCollision (engine/overworld/tile_events.asm): the collisions CUT is
-- allowed to swing at. Both grasses are in it, which is why CUT mows a patch -- allowed to swing at. Both grasses are in it, which is why CUT mows a patch
-- of tall grass down to bare ground and not only trees. -- of tall grass down to bare ground and not only trees.
+6 -3
View File
@@ -140,6 +140,9 @@ function Player:facingCell()
end end
function Player:walkPhase() function Player:walkPhase()
-- pokegold engine/overworld/map_objects.asm StepFunction_Turn: forces the
-- walking leg frame for the whole 4-frame turn-in-place.
if self.turnTimer > 0 then return 1 end
if not self.moving then return 0 end if not self.moving then return 0 end
local p = self.animClock % STEP_FRAMES local p = self.animClock % STEP_FRAMES
return (p >= 4 and p < 12) and 1 or 0 return (p >= 4 and p < 12) and 1 or 0
@@ -167,9 +170,9 @@ function Player:update()
self.px = self.cellX * 16 + dx * adv self.px = self.cellX * 16 + dx * adv
self.py = self.cellY * 16 + dy * adv self.py = self.cellY * 16 + dy * adv
if self.jumping then if self.jumping then
-- The hop arc. Cosmetic: the grid position is the straight-line -- pokegold engine/overworld/map_objects.asm: UpdateJumpPosition's
-- interpolation above, only the drawn pixels rise. -- y_offsets table peaks at -12.
self.py = self.py - math.floor(6 * math.sin(math.pi * self.progress / frames)) self.py = self.py - math.floor(12 * math.sin(math.pi * self.progress / frames))
end end
if self.progress >= frames then if self.progress >= frames then
self.cellX, self.cellY = self.targetX, self.targetY self.cellX, self.cellY = self.targetX, self.targetY
+140 -52
View File
@@ -46,6 +46,7 @@ local Music = require("src.core.Music")
local NPC = require("src.world.gen2.Npc") local NPC = require("src.world.gen2.Npc")
local Party = require("src.pokemon.Party") local Party = require("src.pokemon.Party")
local Permissions = require("src.world.gen2.Permissions") local Permissions = require("src.world.gen2.Permissions")
local Pipelines = require("src.render.Pipelines")
local Player = require("src.world.gen2.Player") local Player = require("src.world.gen2.Player")
local Pokerus = require("src.core.gen2.Pokerus") local Pokerus = require("src.core.gen2.Pokerus")
local Roamers = require("src.core.gen2.Roamers") local Roamers = require("src.core.gen2.Roamers")
@@ -9555,11 +9556,13 @@ function World:stepBody()
-- is a scripted step under World:busy, which returns above this line. -- is a scripted step under World:busy, which returns above this line.
local dir = self.heldDir local dir = self.heldDir
if not p.moving then if not p.moving then
local current = Permissions.currentDirection(self:playerCollision()) local coll = self:playerCollision()
local current = Permissions.currentDirection(coll)
or Permissions.doorForcedDirection(coll)
if current then if current then
dir = current dir = current
elseif self.turningDirection elseif self.turningDirection
and Permissions.isIce(self:playerCollision()) then and Permissions.isIce(coll) then
dir = self.turningDirection dir = self.turningDirection
elseif not dir then elseif not dir then
self.turningDirection = nil self.turningDirection = nil
@@ -9615,7 +9618,17 @@ function World:drawGround(s)
-- clear colour. LoadMetatiles fills it with wMapBorderBlock instead, and -- clear colour. LoadMetatiles fills it with wMapBorderBlock instead, and
-- the connection strips and the map draw straight over the top of it. -- the connection strips and the map draw straight over the top of it.
if self.map then if self.map then
local bw, bh = G.getDimensions() -- Destination size must be the CURRENT canvas (tilt grows it past the
-- window). getDimensions() is always the window, so a grown tilt capture
-- used to tile the void against the wrong view and the fill drifted off
-- the map grid as the camera moved.
local canvas = G.getCanvas()
local bw, bh
if canvas then
bw, bh = canvas:getDimensions()
else
bw, bh = G.getDimensions()
end
BorderFill.draw(self, self:borderImageFor(self.map.id), BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, self.map.id) cam.x, cam.y, bw, bh, s, self.map.id)
end end
@@ -9691,45 +9704,51 @@ function World:drawPeople(s, billboard)
end end
end end
if self.emote and self.emote.image then self:drawEmote(s, billboard)
local e = self.emote self:drawHealAnim(s, billboard)
local ex = math.floor((e.entity.px - cam.x) * s) end
local ey = math.floor((e.entity.py - 16 - cam.y) * s)
-- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the -- Split out of drawPeople so World:drawPipeline composites the one copy the
-- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the -- flat and tilt paths draw, not a second transcription of it.
-- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB function World:drawEmote(s, billboard)
-- 13,13,13 / black). That row is byte-identical in all four daytime if not (self.emote and self.emote.image) then return end
-- blocks, so the bubble is the same at any hour, but it still goes through local G = love.graphics
-- the daytime lookup because that is what LoadMapPals does and it keeps the local cam = self.camera
-- emote on the same path as every other OW sprite. Blitting the extracted local e = self.emote
-- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead local ex = math.floor((e.entity.px - cam.x) * s)
-- of white: the Gen 2 repeat of #505. local ey = math.floor((e.entity.py - 16 - cam.y) * s)
local emoteColors = Palettes.spritePalette(self.palettes, -- SpawnEmote.EmoteObject (engine/overworld/map_objects.asm:2029) spawns the
self.daytime or Palettes.daytimeFor(self.map and self.map.def, -- bubble as an OBJ on PAL_OW_EMOTE, which LoadMapPals resolves to the
self:hour(), self.flashUsed), -- "silver" row of gfx/overworld/npc_sprites.pal (white / white / RGB
{ paletteId = 5 }) -- 13,13,13 / black). That row is byte-identical in all four daytime
local function blit() -- blocks, so the bubble is the same at any hour, but it still goes through
G.setColor(1, 1, 1, 1) -- the daytime lookup because that is what LoadMapPals does and it keeps the
G.draw(e.image, ex, ey, 0, s, s) -- emote on the same path as every other OW sprite. Blitting the extracted
end -- sheet raw left the interior at the DMG ramp's shade 1 (170 grey) instead
local function body() -- of white: the Gen 2 repeat of #505.
-- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still local emoteColors = Palettes.spritePalette(self.palettes,
-- have to collapse the row to their own ramps, and it restores whatever self.daytime or Palettes.daytimeFor(self.map and self.map.def,
-- shader the billboard pass had set rather than assuming none. self:hour(), self.flashUsed),
if emoteColors and GbcPalette.available() then { paletteId = 5 })
GbcPalette.with(emoteColors, blit) local function blit()
else G.setColor(1, 1, 1, 1)
blit() G.draw(e.image, ex, ey, 0, s, s)
end end
end local function body()
if billboard then -- GbcPalette.with, not useRaw: the DMG and CLASSIC colour modes still
billboard(ex + 8 * s, ey + 32 * s, body) -- have to collapse the row to their own ramps, and it restores whatever
-- shader the billboard pass had set rather than assuming none.
if emoteColors and GbcPalette.available() then
GbcPalette.with(emoteColors, blit)
else else
body() blit()
end end
end end
if billboard then
self:drawHealAnim(s, billboard) billboard(ex + 8 * s, ey + 32 * s, body)
else
body()
end
end end
function World:drawWorldBody(s) function World:drawWorldBody(s)
@@ -9737,6 +9756,52 @@ function World:drawWorldBody(s)
self:drawPeople(s) self:drawPeople(s)
end end
-- Gold's half of the world-pipeline seam: same ctx keys, same order and the
-- same nil-falls-back-to-2D rule as src/world/OverworldController.lua:4867.
function World:drawPipeline(id, w, h, s)
local G = love.graphics
local cam = self.camera
local ctx = {
state = self, cam = cam,
vw = self.viewW, vh = self.viewH,
-- No BG-only shake here: World:draw slides the whole frame through
-- camera.y, so the ground row IS the camera row.
bgY = cam.y,
width = w, height = h, scale = s,
level = Pipelines.level(id),
-- imageFor keys its bakes by GbcPalette.mode, so the colour is already in
-- the art: nil, like Gen 1 returns in its true-colour modes.
paletteFor = function() return nil end,
spriteColors = function() return nil end,
-- Gold's only standing effects; it has no dust/cutTree/bird/rod overlay,
-- and Gen 1's `at` skips a nil body, so those keys are simply absent.
fx = {
emote = function() self:drawEmote(1, nil) end,
heal = function() self:drawHealAnim(1, nil) end,
},
}
-- `project(wx, wy)` -> canvas pixels, nil behind the camera. s = 1 lays the
-- closures out in world pixels off the flat foot, the unit Gen 1 uses.
ctx.drawFx = function(project, scale)
scale = scale or s
local function at(fx, fy, body)
local sx, sy = project(fx + cam.x, fy + cam.y)
if not sx then return end -- behind the camera
G.push()
G.scale(scale, scale)
G.translate(sx / scale - fx, sy / scale - fy)
body()
G.pop()
end
self:drawEmote(1, at)
self:drawHealAnim(1, at)
end
local override = Pipelines.drawWorld(id, ctx)
-- world post-processes fold in here, so they never touch the text box on top
if override then override = Pipelines.worldPresent(override, ctx) end
return override
end
-- The perspective quad TILT draws the ground onto. The shader and the -- The perspective quad TILT draws the ground onto. The shader and the
-- 4-vertex mesh are the renderer's -- the projection is the same one the Gen 1 -- 4-vertex mesh are the renderer's -- the projection is the same one the Gen 1
-- world pass uses, so there is no reason for a second copy of either. -- world pass uses, so there is no reason for a second copy of either.
@@ -9748,21 +9813,25 @@ function World:tiltMesh()
return mesh, shader return mesh, shader
end end
function World:drawTilted(w, h, s) -- `gw, gh` are the grown capture size from World:draw (Tilt.viewGrowth),
-- matching Renderer:worldViewSize. Camera is already followed for that view.
function World:drawTilted(w, h, s, gw, gh)
local mesh, shader = self:tiltMesh() local mesh, shader = self:tiltMesh()
if not mesh then if not mesh then
self:drawWorldBody(s) self:drawWorldBody(s)
return return
end end
local G = love.graphics local G = love.graphics
gw = gw or w
gh = gh or h
-- Linear sampling on the tilt canvas softens the shimmer the perspective -- Linear sampling on the tilt canvas softens the shimmer the perspective
-- warp would otherwise put on every pixel edge; the flat path keeps nearest. -- warp would otherwise put on every pixel edge; the flat path keeps nearest.
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= w if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= gw
or self.tiltCanvas:getHeight() ~= h then or self.tiltCanvas:getHeight() ~= gh then
if self.tiltCanvas and self.tiltCanvas.release then if self.tiltCanvas and self.tiltCanvas.release then
self.tiltCanvas:release() self.tiltCanvas:release()
end end
self.tiltCanvas = G.newCanvas(w, h) self.tiltCanvas = G.newCanvas(gw, gh)
self.tiltCanvas:setFilter("linear", "linear") self.tiltCanvas:setFilter("linear", "linear")
end end
@@ -9778,11 +9847,14 @@ function World:drawTilted(w, h, s)
G.setCanvas(previous) G.setCanvas(previous)
mesh:setTexture(self.tiltCanvas) mesh:setTexture(self.tiltCanvas)
mesh:setVertices(Tilt.meshCorners(w, h)) mesh:setVertices(Tilt.meshCorners(gw, gh))
G.push()
G.translate((w - gw) / 2, (h - gh) / 2)
G.setColor(1, 1, 1, 1) G.setColor(1, 1, 1, 1)
G.setShader(shader) G.setShader(shader)
G.draw(mesh) G.draw(mesh)
G.setShader() G.setShader()
G.pop()
-- ...and the standing things over it, each translated from its flat foot -- ...and the standing things over it, each translated from its flat foot
-- onto that foot's projection. Nothing here is sheared or resized: tilt -- onto that foot's projection. Nothing here is sheared or resized: tilt
@@ -9791,10 +9863,10 @@ function World:drawTilted(w, h, s)
-- The ground quad carries the flat canvas and nothing else, so a foot -- The ground quad carries the flat canvas and nothing else, so a foot
-- outside it has no ground under it; drawing it anyway put NPCs from two -- outside it has no ground under it; drawing it anyway put NPCs from two
-- screens away over the border fill, where the map stops being drawn. -- screens away over the border fill, where the map stops being drawn.
if not Tilt.onGround(fx, fy, w, h, 32 * s) then return end if not Tilt.onGround(fx, fy, gw, gh, 32 * s) then return end
local sx, sy = Tilt.groundPoint(fx, fy, w, h) local sx, sy = Tilt.groundPoint(fx, fy, gw, gh)
G.push() G.push()
G.translate(sx - fx, sy - fy) G.translate(sx - fx + (w - gw) / 2, sy - fy + (h - gh) / 2)
body() body()
G.pop() G.pop()
end) end)
@@ -9832,8 +9904,18 @@ function World:draw()
end end
local s = self:zoomScale() local s = self:zoomScale()
local vw = math.ceil(w / s) -- Decided before sizing the view: a world pipeline wins over tilt, and tilt
local vh = math.ceil(h / s) -- grows the capture the way Renderer:worldViewSize does on Gen 1 so the
-- camera, BorderFill and tilt canvas all share one grid.
local pipelineId = Pipelines.worldPipeline()
local tilt = (not pipelineId) and Tilt.active() and self:tiltMesh() ~= nil
local gw, gh = w, h
if tilt then
local g = Tilt.viewGrowth()
gw, gh = math.ceil(w * g), math.ceil(h * g)
end
local vw = math.ceil(gw / s)
local vh = math.ceil(gh / s)
if vw % 2 ~= 0 then vw = vw + 1 end if vw % 2 ~= 0 then vw = vw + 1 end
if vh % 2 ~= 0 then vh = vh + 1 end if vh % 2 ~= 0 then vh = vh + 1 end
if vw ~= self.viewW or vh ~= self.viewH then if vw ~= self.viewW or vh ~= self.viewH then
@@ -9850,12 +9932,18 @@ function World:draw()
self.camera.y = self.camera.y + (self.shake.phase or 0) self.camera.y = self.camera.y + (self.shake.phase or 0)
end end
local override = pipelineId and self:drawPipeline(pipelineId, w, h, s) or nil
-- TILT projects the finished world frame, so with it on the map, people and -- TILT projects the finished world frame, so with it on the map, people and
-- emote go into a canvas first and that canvas is drawn as a perspective -- emote go into a canvas first and that canvas is drawn as a perspective
-- quad. Everything after -- the encounter pic and the survey HUD -- stays -- quad. Everything after -- the encounter pic and the survey HUD -- stays
-- flat, the same split the Gen 1 renderer makes. -- flat, the same split the Gen 1 renderer makes; a pipeline's finished image
if Tilt.active() and self:tiltMesh() then -- lands in exactly the same place.
self:drawTilted(w, h, s) if override then
G.setColor(1, 1, 1, 1)
G.draw(override, 0, 0)
elseif tilt then
self:drawTilted(w, h, s, gw, gh)
else else
self:drawWorldBody(s) self:drawWorldBody(s)
end end
+159
View File
@@ -0,0 +1,159 @@
-- Gold's world-pipeline seam (World:drawPipeline), the Gen 2 peer of the
-- render_pipelines path src/world/OverworldController.lua:4867 gives Gen 1.
--
-- POKEPORT_IDENTITY=gold-dev POKEPORT_GAME=gold \
-- POKEPORT_DRIVER=tests/drivers/gold_pipeline_shots.lua love .
-- POKEPORT_SHOT_DIR=/tmp/gold-pipeline (default)
--
-- Registers a pipeline that paints an unmistakable magenta field, switches it
-- on, and asserts what the seam is supposed to guarantee: drawWorld owns the
-- frame, ctx carries the Gen 1 keys, ctx.drawFx anchors the standing FX,
-- worldPresent folds over the result, tilt is forced off, and a declined
-- frame falls back to the vanilla 2D draw instead of a blank screen.
local U = require("tests.drivers.util")
local Pipelines = require("src.render.Pipelines")
local Tilt = require("src.render.Tilt")
return function(game)
local out = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/gold-pipeline"
local failures = 0
local function ok(label, condition, detail)
if condition then
print("[pipeline] ok " .. label)
else
failures = failures + 1
print("[pipeline] FAIL " .. label .. " " .. tostring(detail))
end
end
U.wait(45)
local world = game.world
assert(world and world.map, "gold world did not boot")
world:setMap("ROUTE_30", 10, 10, "down")
U.wait(10)
U.shot(game, out .. "/00-vanilla.png")
-- ---------------------------------------------------------------- record
local seen, canvas = {}, nil
local decline = false
local pipeline = {
label = "TESTPIPE",
levels = { "OFF", "ON" },
drawWorld = function(ctx)
seen.ctx = ctx
seen.drawWorld = (seen.drawWorld or 0) + 1
if decline then return nil end
local G = love.graphics
local w, h = ctx.width, ctx.height
if not canvas or canvas:getWidth() ~= w or canvas:getHeight() ~= h then
canvas = G.newCanvas(w, h)
end
local previous = G.getCanvas()
G.push("all")
G.origin()
G.setCanvas(canvas)
G.clear(0.8, 0.1, 0.6, 1)
G.setColor(1, 1, 1, 1)
for x = 0, w, 32 do G.rectangle("fill", x, 0, 1, h) end
for y = 0, h, 32 do G.rectangle("fill", 0, y, w, 1) end
-- the standing FX, anchored under this pipeline's own (identity) camera
ctx.drawFx(function(wx, wy)
return (wx - ctx.cam.x) * ctx.scale, (wy - ctx.cam.y) * ctx.scale
end, ctx.scale)
G.setCanvas(previous)
G.pop()
seen.drew = true
return canvas
end,
worldPresent = function(image, ctx)
seen.worldPresent = (seen.worldPresent or 0) + 1
seen.presentCtx = ctx
return image
end,
}
-- A fresh table so Pipelines.list()'s identity-keyed memo re-sorts; the
-- mod merge hands it a new one for the same reason.
game.data.render_pipelines = { testpipe = pipeline }
Pipelines.install(game.data)
ok("registered", Pipelines.get("testpipe") ~= nil, "not in the registry")
-- ------------------------------------------------------------ switched on
Tilt.setLevel(1)
Pipelines.setLevel("testpipe", 1)
ok("tilt forced off", Tilt.level == 0, "tilt still " .. tostring(Tilt.level))
ok("world pipeline claimed", Pipelines.worldPipeline() == "testpipe",
tostring(Pipelines.worldPipeline()))
U.wait(5)
U.shot(game, out .. "/01-pipeline-on.png")
ok("drawWorld ran", (seen.drawWorld or 0) > 0, "never called")
ok("drawWorld drew", seen.drew == true, "declined every frame")
ok("worldPresent ran", (seen.worldPresent or 0) > 0, "never called")
local ctx = seen.ctx
ok("ctx.state is the world", ctx and ctx.state == world, "wrong state")
ok("ctx.cam is the camera", ctx and ctx.cam == world.camera, "wrong camera")
ok("ctx.scale is zoomScale", ctx and ctx.scale == world:zoomScale(),
ctx and tostring(ctx.scale))
ok("ctx.bgY is the camera row", ctx and ctx.bgY == world.camera.y,
ctx and tostring(ctx.bgY))
ok("ctx.vw/vh are the view", ctx and ctx.vw == world.viewW
and ctx.vh == world.viewH, ctx and tostring(ctx.vw))
ok("ctx.level is the ladder", ctx and ctx.level == 1, ctx and tostring(ctx.level))
ok("ctx.width/height are the window",
ctx and ctx.width == love.graphics.getWidth()
and ctx.height == love.graphics.getHeight(), "mismatch")
ok("ctx.paletteFor is nil-valued (art is baked)",
ctx and ctx.paletteFor and ctx.paletteFor(world.map) == nil, "returned colours")
ok("ctx.spriteColors is nil-valued",
ctx and ctx.spriteColors and ctx.spriteColors() == nil, "returned colours")
ok("ctx.fx has Gold's two effects",
ctx and ctx.fx and type(ctx.fx.emote) == "function"
and type(ctx.fx.heal) == "function", "missing fx")
ok("ctx.drawFx is callable", ctx and type(ctx.drawFx) == "function", "missing")
ok("worldPresent got the same ctx", seen.presentCtx == seen.ctx, "different ctx")
-- ------------------------------------------------- the FX composite path
-- An emote over the player exercises ctx.drawFx end to end: it must be the
-- pipeline that composites it, and the vanilla drawPeople must not also.
local sheet
for _, img in pairs(world.emoteImages or {}) do sheet = img break end
if sheet then
world.emote = { image = sheet, entity = world.player, left = 240 }
end
U.wait(4)
ok("emote is up", world.emote ~= nil, "no emote sheet loaded")
local fxOk = pcall(function()
-- the same call the pipeline made, run again outside the guard so a throw
-- surfaces here rather than only retiring the pipeline
seen.ctx.drawFx(function(wx, wy) return wx, wy end, 1)
end)
ok("drawFx composites without throwing", fxOk, "threw")
U.shot(game, out .. "/02-pipeline-emote.png")
-- ----------------------------------------------------- a declined frame
decline = true
U.wait(5)
U.shot(game, out .. "/03-pipeline-declined.png")
ok("declined frames still call drawWorld", (seen.drawWorld or 0) > 1, "stopped")
decline = false
-- ------------------------------------------------------------ switched off
Pipelines.setLevel("testpipe", 0)
local before = seen.drawWorld
U.wait(5)
ok("off means not called", seen.drawWorld == before,
"still drawing at level 0")
U.shot(game, out .. "/04-pipeline-off.png")
if failures == 0 then
print("[pipeline] PASS")
else
print("[pipeline] FAILURES: " .. failures)
end
love.event.quit(failures == 0 and 0 or 1)
end
+14 -5
View File
@@ -649,7 +649,9 @@ for name, registry in pairs(loader.content) do
end end
end end
if patcher and not defined then if patcher and not defined then
row("ORPHAN", name, id, patcher) local gen2Routed = Schemas.targetFor(name, registry.spec, 2)
~= registry.spec.target
row("ORPHAN", name, id, patcher, tostring(gen2Routed))
end end
end end
end end
@@ -712,7 +714,8 @@ def resolve_base(repo, choice):
return "imported" if os.path.isfile(imported) else "fixture" return "imported" if os.path.isfile(imported) else "fixture"
def run_loader(repo, mod_dir, findings, base="fixture", notes=None): def run_loader(repo, mod_dir, findings, base="fixture", notes=None,
manifest=None):
"""Drive the engine loader headlessly with the mod mounted; the base """Drive the engine loader headlessly with the mod mounted; the base
dataset is the ROM-free fixture, or the imported cache with dataset is the ROM-free fixture, or the imported cache with
--base imported (for mods that reference vanilla Red content). --base imported (for mods that reference vanilla Red content).
@@ -775,8 +778,9 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None):
if "unknown permission" in parts[1]: if "unknown permission" in parts[1]:
continue continue
add(Finding(classify_error(parts[1], "MK001"), "error", parts[1])) add(Finding(classify_error(parts[1], "MK001"), "error", parts[1]))
elif kind == "ORPHAN" and len(parts) >= 4: elif kind == "ORPHAN" and len(parts) >= 5:
registry, target, owner = parts[1], parts[2], parts[3] registry, target, owner = parts[1], parts[2], parts[3]
gen2_routed = parts[4] == "true"
# only the imported dataset owns the real vanilla id space. The # only the imported dataset owns the real vanilla id space. The
# fixture stands in for three species, so "not in base data" there # fixture stands in for three species, so "not in base data" there
# is a fact about the fixture, not about the mod -- MK103 has no # is a fact about the fixture, not about the mod -- MK103 has no
@@ -786,6 +790,11 @@ def run_loader(repo, mod_dir, findings, base="fixture", notes=None):
if base != "imported": if base != "imported":
skipped.add("MK103") skipped.add("MK103")
continue continue
if gen2_routed and declares_gen2(repo, manifest):
# tools/build_data.py never writes a Gen 2 cache, so the
# imported dataset has no Gold/Crystal ground truth either.
skipped.add("MK103")
continue
add(Finding( add(Finding(
"MK103", "error", "MK103", "error",
f"{owner}: patch target {target!r} exists in neither " f"{owner}: patch target {target!r} exists in neither "
@@ -834,7 +843,7 @@ def cmd_validate(args, repo):
findings.extend(gh_findings) findings.extend(gh_findings)
notes.extend(gh_notes) notes.extend(gh_notes)
findings.extend(check_permissions(repo, manifest)) findings.extend(check_permissions(repo, manifest))
run_loader(repo, mod_dir, findings, args.base, notes) run_loader(repo, mod_dir, findings, args.base, notes, manifest)
findings.extend(check_requires(repo, mod_dir, manifest)) findings.extend(check_requires(repo, mod_dir, manifest))
findings.extend(lint_dir(repo, mod_dir, manifest)) findings.extend(lint_dir(repo, mod_dir, manifest))
name = manifest.get("id") if manifest else os.path.basename(mod_dir) name = manifest.get("id") if manifest else os.path.basename(mod_dir)
@@ -1130,7 +1139,7 @@ def cmd_pack(args, repo):
return 1 return 1
findings = list(check_permissions(repo, manifest)) findings = list(check_permissions(repo, manifest))
notes = [] notes = []
run_loader(repo, mod_dir, findings, args.base, notes) run_loader(repo, mod_dir, findings, args.base, notes, manifest)
findings.extend(check_requires(repo, mod_dir, manifest)) findings.extend(check_requires(repo, mod_dir, manifest))
findings.extend(lint_dir(repo, mod_dir, manifest)) findings.extend(lint_dir(repo, mod_dir, manifest))
# pack runs validate --strict (20-developer-tooling.md 5), so a warning # pack runs validate --strict (20-developer-tooling.md 5), so a warning