Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment

This commit is contained in:
James Hall
2026-08-16 19:30:19 -05:00
committed by GitHub
233 changed files with 8377 additions and 45512 deletions
+124 -38
View File
@@ -14,6 +14,7 @@
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local Theme = require("src.ui.Theme")
local DexEntryMenu = {}
DexEntryMenu.__index = DexEntryMenu
@@ -36,6 +37,62 @@ local function resolveArgs(speciesOrOpts)
return speciesOrOpts, false
end
local function ownedFor(game, def, forceOwned)
return forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id]) or false
end
-- home/text.asm:245 (<PAGE>), home/text.asm:204 (<DEXEND>)
local function descPages(game, def, forceOwned)
local e = def.dexEntry or {}
local owned = ownedFor(game, def, forceOwned)
local text = owned and e.text and game.data.text[e.text] or nil
if not text then return nil end
local pages = {}
for chunk in (text .. "\f"):gmatch("(.-)\f") do
local lines = {}
for line in (chunk:gsub("\v", "\n") .. "\n"):gmatch("(.-)\n") do
lines[#lines + 1] = line
end
while #lines > 0 and lines[#lines] == "" do table.remove(lines) end
if #lines > 0 then pages[#pages + 1] = lines end
end
if #pages == 0 then return nil end
local last = pages[#pages]
last[#last] = last[#last] .. "."
return pages
end
-- engine/gfx/load_pokedex_tiles.asm: gfx/pokedex/pokedex.png, codes $60..$71
local frameCache = {}
local function frameSheet(game)
local fx = game.data.field and game.data.field.overworldFx
local def = fx and fx.pokedexFrame
local path = def and def.path
if not path then return nil end
local hit = frameCache[path]
if hit ~= nil then return hit or nil end
local ok, img = pcall(love.graphics.newImage, path)
if not ok or not img then
frameCache[path] = false
return nil
end
local iw, ih = img:getDimensions()
local quads = {}
for i = 0, 17 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8, 8, 8, iw, ih)
end
frameCache[path] = { img = img, quads = quads }
return frameCache[path]
end
-- engine/menus/pokedex.asm:601
local DIVIDER = {
0x68, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x6B,
0x6B, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6B, 0x69, 0x6A,
}
function DexEntryMenu.new(game, speciesOrOpts, onDone)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned,
@@ -43,13 +100,14 @@ function DexEntryMenu.new(game, speciesOrOpts, onDone)
self.def = game.data.pokemon[species]
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and
-- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive.
-- pcall's second return has to survive the guard (#307)
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
self.page = 1
local pages = descPages(game, self.def, forceOwned)
self.pageCount = pages and #pages or 1
require("src.core.Sound").playCry(game.data, species)
return self
end
@@ -57,6 +115,11 @@ end
function DexEntryMenu:update(dt)
local input = self.game.input
if input:wasPressed("a") or input:wasPressed("b") then
-- home/text.asm:245
if self.page < (self.pageCount or 1) then
self.page = self.page + 1
return
end
self.game.stack:pop()
if self.onDone then self.onDone() end
end
@@ -64,64 +127,87 @@ end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor)
self.spriteTrueColor, self.page)
end
-- Static entry-page renderer, shared with the printer stand-in
-- (src/core/Printer.lua renders the same page into a PNG the way
-- PrintPokedexEntry rendered it to the Game Boy Printer).
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
-- engine/menus/pokedex.asm:399
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor, page)
page = page or 1
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local frame = frameSheet(game)
if frame then
local function tile(code, tx, ty)
love.graphics.draw(frame.img, frame.quads[code - 0x60], tx * 8, ty * 8)
end
-- engine/menus/pokedex.asm:418
for tx = 1, 18 do
tile(0x64, tx, 0)
tile(0x6f, tx, 17)
end
for ty = 1, 16 do
tile(0x66, 0, ty)
tile(0x67, 19, ty)
end
tile(0x63, 0, 0)
tile(0x65, 19, 0)
tile(0x6c, 0, 17)
tile(0x6e, 19, 17)
-- engine/menus/pokedex.asm:445
for tx = 0, 19 do
tile(DIVIDER[tx + 1], tx, 9)
end
end
if sprite then
local y = math.max(0, 60 - sprite:getHeight())
love.graphics.draw(sprite, 8, y)
-- a full-color pic has to sit out the SGB recolor, so mark its bounds
-- for the unshaded pass (#350). The printer path leaves trueColor nil:
-- it renders to its own PNG canvas, and a mark left behind there would
-- bleed into the next real frame.
-- engine/menus/pokedex.asm:503, home/pokemon.asm:96 (flipped)
local w, h = sprite:getDimensions()
local x = 8 + math.floor((8 - w / 8) / 2) * 8
local y = 8 + (7 - h / 8) * 8
love.graphics.draw(sprite, x + w, y, 0, -1, 1)
-- the unshaded pass needs the pic bounds (#350)
if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
-- engine/menus/pokedex.asm:454
Font.draw(def.name, 72, 16)
local e = def.dexEntry or {}
-- English R/B prints only the kind string (hlcoord 9,4 PlaceString).
-- PokeText ("#"/POKéMON) is an unreferenced JPN leftover in pokedex.asm;
-- appending " POKéMON" here clipped longer kinds ("LIZARD POKé").
Font.draw(e.kind or "?", 72, 20)
-- engine/menus/pokedex.asm:468, kind string only (PokeText is unreferenced)
Font.draw(e.kind or "?", 72, 32)
-- same number width as the list (constants.dexDigits), so a dex past 999
-- prints the extra digit everywhere at once
local digits = (game.data.constants or {}).dexDigits or 3
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0), 72, 32)
local owned = forceOwned
or (game.save.pokedex and game.save.pokedex.owned[def.id])
-- height/weight print only once owned, like the description
-- (pokedex.asm: "if the pokemon has not been owned, don't print the
-- height, weight, or description")
-- engine/menus/pokedex.asm:478
Font.draw(Strings("No.") .. ("%0" .. digits .. "d"):format(def.dex or 0),
16, 64)
local owned = ownedFor(game, def, forceOwned)
-- engine/menus/pokedex.asm:449, numbers only once owned
if owned and e.heightFt then
-- feet/inches use the dex screen's /″ glyphs ("HT ???″" in
-- pokedex.asm; the tiles come from gfx/pokedex/pokedex.png via
-- engine/gfx/load_pokedex_tiles.asm)
if e.heightM then
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 44)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 54)
Font.draw((Strings("GR. %.1fm", e.heightM):gsub("(%d)%.(%d)", "%1,%2")), 72, 48)
Font.draw((Strings("GEW. %.1fkg", e.weightKg or 0):gsub("(%d)%.(%d)", "%1,%2")), 72, 64)
else
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 44)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 54)
Font.draw(Strings("HT %d%02d″", e.heightFt, e.heightIn or 0), 72, 48)
Font.draw(Strings("WT %.1flb", (e.weight or 0) / 10), 72, 64)
end
end
local text = owned and e.text and game.data.text[e.text] or nil
local y = 72
if text then
for line in (text:gsub("\v", "\n"):gsub("\f", "\n") .. "\n"):gmatch("(.-)\n") do
if y > 132 then break end
Font.draw(line, 8, y)
y = y + 10
local pages = descPages(game, def, forceOwned)
if pages then
-- engine/menus/pokedex.asm:568
local lines = pages[page] or pages[#pages]
for i, line in ipairs(lines) do
Font.draw(line, 8, 72 + i * 16)
end
-- home/text.asm:245
if page < #pages then
Font.drawCode(Theme.moreArrow, 144, 128)
end
else
Font.draw(Strings("Data unknown."), 8, y)
Font.draw(Strings("Data unknown."), 8, 88)
end
love.graphics.setColor(1, 1, 1, 1)
end
+112
View File
@@ -0,0 +1,112 @@
-- PKMN LEAGUE hall-of-fame viewer (engine/menus/league_pc.asm:1)
local Font = require("src.render.Font")
local Strings = require("src.core.Strings")
local HallOfFame = require("src.ui.HallOfFame")
local LeaguePC = {}
LeaguePC.__index = LeaguePC
LeaguePC.isOpaque = true
-- constants/pokemon_data_constants.asm:65
local CAPACITY = 50
-- SGB: SET_PAL_POKEMON_WHOLE_SCREEN per mon (engine/menus/league_pc.asm:95)
function LeaguePC:sgbPalettes(game)
local P = require("src.render.PaletteFX")
local mon = self:currentMon()
local c = mon and P.monPal(game.data, mon.species)
if c then return { P.whole(c) } end
return P.wholeNamed(game.data, "MEWMON")
end
function LeaguePC.new(game, onDone)
local self = setmetatable({}, LeaguePC)
self.game = game
self.onDone = onDone
self.teams = (game.save and game.save.hallOfFame) or {}
self.teamIndex = math.max(1, #self.teams - CAPACITY + 1)
self.monIndex = 1
self.sprites = {}
self.spriteTrueColor = {}
self:loadMon()
return self
end
function LeaguePC:currentMon()
local team = self.teams[self.teamIndex]
return team and team[self.monIndex] or nil
end
function LeaguePC:loadMon()
local mon = self:currentMon()
if not mon then return end
local species = mon.species
if self.sprites[species] == nil then
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "hof" })
local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprites[species] = ok and img or false
self.spriteTrueColor[species] = (ok and img and trueColor) or false
end
require("src.core.Sound").playCry(self.game.data, species)
end
function LeaguePC:close()
self.game.stack:pop()
if self.onDone then self.onDone() end
end
function LeaguePC:update(dt)
local input = self.game.input
if input:wasPressed("b") then
self:close()
return
end
if input:wasPressed("a") then
if not self:currentMon() then
self:close()
return
end
local team = self.teams[self.teamIndex]
if self.monIndex < #team then
self.monIndex = self.monIndex + 1
elseif self.teamIndex < #self.teams then
self.teamIndex = self.teamIndex + 1
self.monIndex = 1
else
self:close()
return
end
self:loadMon()
end
end
function LeaguePC:draw()
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
local mon = self:currentMon()
if not mon then return end
local img = self.sprites[mon.species]
if img then
-- engine/menus/league_pc.asm:98 (hlcoord 12, 5)
local w, h = img:getDimensions()
local x = 96 + math.floor((8 - w / 8) / 2) * 8
local y = 40 + (7 - h / 8) * 8
love.graphics.draw(img, x, y)
if self.spriteTrueColor[mon.species] then
require("src.render.PaletteFX").markTrueColor(x, y, w, h)
end
end
-- engine/movie/hall_of_fame.asm:159
HallOfFame.drawMonInfo(self, mon)
-- engine/menus/league_pc.asm:102
Font.drawBox(0, 13, 20, 4)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("HALL OF FAME No"), 1 * 8, 15 * 8)
Font.draw(("%3d"):format(self.teamIndex), 16 * 8, 15 * 8)
love.graphics.setColor(1, 1, 1, 1)
end
return LeaguePC
+11 -1
View File
@@ -79,7 +79,16 @@ end
function NamingScreen:enter()
if self.presets and #self.presets > 0 then
local Menu = require("src.ui.Menu")
local items = { { label = Strings("NEW NAME") } }
-- engine/movie/oak_speech/oak_speech2.asm:1
self.choosing = true
self.isOpaque = false
local items = { {
label = Strings("NEW NAME"),
onSelect = function()
self.choosing = nil
self.isOpaque = nil
end,
} }
for _, preset in ipairs(self.presets) do
table.insert(items, {
label = preset,
@@ -193,6 +202,7 @@ function NamingScreen:update(dt)
end
function NamingScreen:draw()
if self.choosing then return end
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
+2 -16
View File
@@ -628,22 +628,8 @@ function PartyMenu:update(dt)
end
if self.softboiledFrom then
local user = party[self.softboiledFrom]
local heal = math.floor(user.stats.hp / 5)
if mon == user or mon.hp <= 0 or mon.hp >= mon.stats.hp
or user.hp <= heal then
self.softboiledFrom = nil
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game, Strings("It won't have\nany effect.")))
else
user.hp = user.hp - heal
mon.hp = math.min(mon.stats.hp, mon.hp + heal)
self.softboiledFrom = nil
require("src.core.Sound").play(self.game.data, "Heal_HP")
local def = self.game.data.pokemon[mon.species]
local TextBox = require("src.render.TextBox")
self.game.stack:push(TextBox.new(self.game,
Strings("%s's HP\nwas restored!", mon.nickname or def.name)))
end
self.softboiledFrom = nil
self.game.overworld:useSoftboiledFieldMove(user, mon)
elseif self.swapFrom then
if self.swapFrom ~= self.index then
party[self.swapFrom], party[self.index] = party[self.index], party[self.swapFrom]
+7 -5
View File
@@ -134,13 +134,15 @@ function SummaryMenu:draw()
Font.draw(("%03d"):format(def.dex or 0), 24, 56)
if self.page == 1 then
-- HP bar (11,3) + numbers row 4, STATUS/ (9,6), the DrawLineBox
-- bracket around the name/HP block, and PrintLevel at (14,2). The
-- level belongs to page 1 ONLY: StatusScreen2 opens with ClearScreenArea
-- over (9,2) 5x10 (status_screen.asm:303-305), which wipes it. #280
-- level is page 1 only: StatusScreen2 opens with ClearScreenArea over
-- (9,2) 5x10 (status_screen.asm:303-305). #280
printLevel(14, 2, mon.level)
drawLineBox(19, 1, 6, 10)
HudTiles.drawHPBar(data, 11, 3, mon, 1) -- wHPBarType 1
-- engine/pokemon/status_screen.asm:120-125
local PaletteFX = require("src.render.PaletteFX")
local barZoned = PaletteFX.shader() ~= nil
and PaletteFX.pal(data, "GREENBAR") ~= nil
HudTiles.drawHPBar(data, 11, 3, mon, 1, barZoned) -- wHPBarType 1
Font.draw(("%3d/%3d"):format(mon.hp, mon.stats.hp), 96, 32)
Font.draw(Strings("STATUS/"), 72, 48)
Font.draw(mon.status or "OK", 128, 48)
+27 -4
View File
@@ -171,6 +171,27 @@ local function markVisibleTrueColor(x, y, w, h, cover)
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
end
-- ..(engine/movie/title.asm ln 321)
local function replayObjSprite(game, image, quad, x, y)
local P = require("src.render.PaletteFX")
if not P.usesSpriteObp() then return end
local top = game.stack and game.stack:top()
local box = top and top.titleUiBox
if box then
local w, h
if quad then
w, h = select(3, quad:getViewport())
else
w, h = image:getDimensions()
end
if x < (box[3] + 1) * 8 and x + w > box[1] * 8
and y < (box[4] + 1) * 8 and y + h > box[2] * 8 then
return
end
end
P.markUiSpriteRedraw(image, quad, x, y)
end
function TitleState.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TitleState)
@@ -662,12 +683,11 @@ function TitleState:draw()
local x = 40 + math.floor((56 - w) / 2) + self.monOffset
local y = 136 - h
love.graphics.draw(sprite, x, y)
-- a full-color mon keeps its own palette through the SGB pass, minus
-- the strip Red's OAM covers (#350). Yellow never reaches here: its
-- layout has no cycling mon and no Red art (title_yellow.asm).
-- SGB: the mon keeps its palette minus the strip Red's OAM covers
-- (#350); Yellow never reaches here (title_yellow.asm)
if spriteTrueColor then
local cover
if playerImage then
if playerImage and not require("src.render.PaletteFX").usesSpriteObp() then
local pw, ph = playerImage:getDimensions()
cover = { 82, 80, pw, ph }
end
@@ -678,10 +698,13 @@ function TitleState:draw()
if self.playerQuads then
for _, part in ipairs(self.playerQuads) do
love.graphics.draw(playerImage, part[1], 82 + part[2], 80 + part[3])
replayObjSprite(self.game, playerImage, part[1], 82 + part[2], 80 + part[3])
end
love.graphics.draw(playerImage, self.ballQuad, 82, self.ballY)
replayObjSprite(self.game, playerImage, self.ballQuad, 82, self.ballY)
elseif playerImage then
love.graphics.draw(playerImage, 82, 80)
replayObjSprite(self.game, playerImage, nil, 82, 80)
end
end
self:drawCopyright(136 + (preRibbon and 0 or scrollY))
+32 -15
View File
@@ -220,6 +220,20 @@ function TownMap.new(game, opts)
-- the player's current location (guard: overworld may not be running)
local mapId = game.overworld and game.overworld.map and game.overworld.map.id
self.playerLoc = mapId and self.byMap[mapId] or nil
-- engine/items/town_map.asm:347
do
local playerSprites = (game.data.field and game.data.field.playerSprites)
or {}
local sprites = game.data.sprites or {}
local red = sprites[playerSprites.walk or "SPRITE_RED"]
or sprites.SPRITE_RED
local ok, img = pcall(love.graphics.newImage, red and red.image)
if ok and img then
self.playerSheet = img
self.playerQuad = love.graphics.newQuad(0, 0, 16, 16,
img:getDimensions())
end
end
self.sel = 1
-- LoadTownMap_Fly always opens with hl on wFlyLocationsList[0], the FIRST
-- fly destination (PALLET_TOWN), never the player's current town (#795).
@@ -345,18 +359,16 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1)
return
end
-- the player's current location blinks (slow phase). Paint it with a
-- palette-safe DARK shade (red 0), not red: this screen composites through
-- the TOWNMAP SGB shade-remap shader (PaletteFX.shader), which keys ONLY on
-- the red channel, and a red-0.75 dot lands in the c1 bucket = TOWNMAP
-- {165,214,255}, the exact light-blue used for the water and the town-square
-- fill, so the marker was drawn but recolored invisible (#152). Red 0 -> c3
-- {25,16,16} = a solid dark "you are here" dot, visible on land and water.
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerLoc and self.blink < 20 then
local x, y = markerXY(self.playerLoc)
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", x + 2, y + 2, 4, 4)
love.graphics.setColor(1, 1, 1, 1)
end
end
-- blinking cursor on the selected location. markerXY is the 8x8 cell's
-- top-left; the cursor asset is a 16x16 hollow frame centered on its own
@@ -389,11 +401,16 @@ function TownMap:draw()
drawSquare(loc)
end
if self.playerLoc and self.blink < 20 then
-- palette-safe dark, same red-channel shade-remap reason as the primary
-- grid path above (#152); stale-asset builds hit this fallback square
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerSheet then
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(self.playerSheet, self.playerQuad,
self.playerLoc.x * 8 - 4, self.playerLoc.y * 8 - 3)
else
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("fill", self.playerLoc.x * 8 + 2,
self.playerLoc.y * 8 + 2, 4, 4)
end
end
if selected and self.blink % 16 < 10 then
love.graphics.setColor(0, 0, 0, 1)
+54 -16
View File
@@ -150,6 +150,10 @@ end
-- animation (most of them) skips the canvas entirely.
local function needsCanvas(runner)
local bg = runner.bg
-- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays
-- out of the BG until the next pic redraw, so those frames stay baked too.
local lifted = bg.liftedRows
if lifted and (lifted.player or lifted.enemy) then return true end
if bg.scx ~= 0 or bg.scy ~= 0 then return true end
if not bg.lcdc then return false end
if bg.lyEnd <= bg.lyStart then return false end
@@ -229,13 +233,12 @@ end
-- times, and the grouping is by VALUE so a table that happens to repeat costs
-- nothing extra.
local function bgpBands(bg)
local base = bg.bgp or GbcPalette.BGP_IDENTITY
local order, bands = {}, {}
for row = 0, SCREEN_H - 1 do
local inWindow = row >= bg.lyStart and row < bg.lyEnd
-- Outside the window the register still reads whatever wBGP holds, which
-- for every effect that aims hLCDCPointer at rBGP is the identity.
local byte = inWindow and (bg.lyBackup[row] or GbcPalette.BGP_IDENTITY)
or GbcPalette.BGP_IDENTITY
-- Outside the window the register still reads whatever wBGP holds.
local byte = inWindow and (bg.lyBackup[row] or base) or base
local band = bands[byte]
if not band then
band = { byte = byte, rows = {} }
@@ -244,24 +247,56 @@ local function bgpBands(bg)
end
band.rows[#band.rows + 1] = row
end
-- Identity first so the fillBackground below it happens before any blit and
-- the common band is the one drawn from the first bake.
-- The base band first so the fillBackground below it happens before any blit
-- and the common band is the one drawn from the first bake.
table.sort(order, function(a, b)
if a.byte == b.byte then return false end
if a.byte == GbcPalette.BGP_IDENTITY then return true end
if b.byte == GbcPalette.BGP_IDENTITY then return false end
if a.byte == base then return true end
if b.byte == base then return false end
return a.rows[1] < b.rows[1]
end)
return order
end
-- Runs `drawBg` (the battle panel) and then puts it on screen through the
-- animation's BG registers. Returns without a canvas when nothing is
-- displacing anything, which is the common case and costs nothing.
function BattleAnimView:present(runner, drawBg)
-- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals
function BattleAnimView:panelPalettes(battle)
local list = {}
local shades = {}
for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end
list[#list + 1] = shades
local function bracket(pair)
if not (pair and pair[1] and pair[2]) then return end
list[#list + 1] = {
{ 255, 255, 255 },
{ pair[1][1], pair[1][2], pair[1][3] },
{ pair[2][1], pair[2][2], pair[2][3] },
{ 0, 0, 0 },
}
end
for _, side in ipairs({ "player", "enemy" }) do
local mon = battle and battle[side]
local colors = mon
and Palettes.monColors(self.palettes, mon.species, mon.shiny)
if colors then list[#list + 1] = colors end
end
local hpBar = self.palettes and self.palettes.hpBar
if hpBar then
bracket(hpBar.green)
bracket(hpBar.yellow)
bracket(hpBar.red)
end
bracket(self.palettes and self.palettes.expBar)
return list
end
-- Runs `drawBg` (the battle panel) and puts it on screen through the
-- animation's BG registers; skips the canvas when nothing needs one.
function BattleAnimView:present(runner, drawBg, battle)
if not (love and love.graphics) then return end
local bg = runner.bg
if not needsCanvas(runner) then
local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY
and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil
if not invert and not needsCanvas(runner) then
drawBg()
return
end
@@ -292,9 +327,10 @@ function BattleAnimView:present(runner, drawBg)
self:bake(drawBg, nil)
-- A shifted scanline exposes whatever the BG map holds beside the pic, which
-- outside the two pic boxes is the blank tile. Without this the exposed
-- strip is the canvas's own transparency and every shake shows a seam.
local remapped = invert
and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp)
-- A shifted scanline exposes the blank tile beside the pic boxes; without
-- this the exposed strip is the canvas's own transparency.
self:fillBackground()
G.setColor(1, 1, 1, 1)
-- hSCX / hSCY move the whole background; the per-scanline overrides only
@@ -314,6 +350,7 @@ function BattleAnimView:present(runner, drawBg)
self:blitRow(row, dx, dy)
end
end
if remapped then GbcPalette.clear() end
-- Shaderless boot: the panel is raw grayscale, so there are no palettes to
-- permute and the entry's BRIGHTNESS is the only thing left to reproduce.
if bg.lcdc == "BGP" then
@@ -417,5 +454,6 @@ end
BattleAnimView.SCREEN_W = SCREEN_W
BattleAnimView.SCREEN_H = SCREEN_H
BattleAnimView.needsCanvas = needsCanvas
return BattleAnimView
+244 -112
View File
@@ -31,6 +31,7 @@ local ItemEffects = require("src.core.gen2.ItemEffects")
local Mon = require("src.battle.gen2.Mon")
local Palettes = require("src.world.gen2.Palettes")
local Pokerus = require("src.core.gen2.Pokerus")
local Prize = require("src.battle.gen2.Prize")
local Runtime = require("src.mods.Runtime")
local Screens = require("src.ui.Screens")
local Sound = require("src.core.Sound")
@@ -48,6 +49,9 @@ BattleState.isOpaque = true
-- the victory jingle can keep looping through the post-win prompts.
local MESSAGE_FRAMES = 48
-- engine/battle/effect_commands.asm:6661
local MOVE_DELAY_FRAMES = 40
-- home/hm_moves.asm:17-25 IsHMMove's .HMMoves.
local HM_MOVES = {
CUT = true, FLY = true, SURF = true, STRENGTH = true, FLASH = true,
@@ -121,6 +125,8 @@ local TEXT_ASK_FORGET_MOVE = Strings.source(
-- Gen 1 uses. The second label is the two-glyph <PK><MN> ligature (charmap
-- $e1/$e2), which is what makes it fit a six-tile column.
local MENU = { "FIGHT", "<PK><MN>", "PACK", "RUN" }
local MENU_ACTION = { FIGHT = "fight", ["<PK><MN>"] = "party",
PACK = "item", RUN = "run" }
local MENU_BOX_X = 8
local MENU_COL_SPACING = 6
@@ -236,15 +242,8 @@ function BattleState.new(game, opts)
self.menuIndex = 1
self.moveIndex = 1
self.picCache = {}
-- Which side's pic box the tilemap has been left EMPTY in. BattleBGEffect_
-- ReturnMon's last row (what swallows a mon into a thrown ball) and
-- MonFaintedAnimation both clear the box and neither puts anything back: it
-- stays blank until something DRAWS a pic into it, which on the cart is only
-- ever a send-out (ShowSetEnemyMonAndSendOutAnimation / SendOutPlayerMon).
-- Without this latch the pic came back the instant the animation let go of
-- the screen, so a caught mon stood there through "Gotcha!" and a fainted one
-- popped back up for its own faint line. See stepAnim for why it is latched
-- at those two moments rather than off the runner's own last frame.
-- Which side's pic box stays EMPTY until a send-out redraws it: a catch
-- latches from stepAnim (data/moves/animations.asm:379), a faint from the slide.
self.picHidden = { player = false, enemy = false }
-- engine/battle/sliding_intro.asm: 72 frames of the two halves sliding in
-- from opposite sides before the first message.
@@ -621,6 +620,19 @@ function BattleState:drawPic(mon, back)
and not (self.vanishAnim and self.vanishAnim == self.anim) then
return
end
-- GetSubstitutePic (engine/battle_anims/anim_commands.asm:905-960): the
-- doll sits in the mon's own pic box and takes its palette.
local doll, dollQuad
if not (trainerBack or enemyTrainer) then
local over = anim and anim.pic
local up
if over ~= nil then
up = over == "substitute"
else
up = mon and mon.volatile and (mon.volatile.substitute or 0) > 0
end
if up then doll, dollQuad = self:substituteDoll(back) end
end
local G = love.graphics
local w, h = image:getDimensions()
local px, py
@@ -649,7 +661,7 @@ function BattleState:drawPic(mon, back)
-- the mon drawn at this frame.
local scale = self:picScale(path, mon, back)
if anim then
px = px + (anim.slide or 0)
if not self.liftedPass then px = px + (anim.slide or 0) end
local resized = anim.size and PIC_RESIZE_TILES[anim.size]
if resized then scale = scale * (resized / boxTiles) end
end
@@ -660,6 +672,10 @@ function BattleState:drawPic(mon, back)
px = px + math.floor(w * (1 - scale) / 2)
py = py + math.floor(h * (1 - scale))
end
if doll then
px = (back and 32 or 112) + ((anim and anim.slide) or 0)
py = back and 80 or 40
end
G.setColor(1, 1, 1, 1)
-- No mon on this side at all in the catching tutorial, where the box holds
-- the DUDE's back-pic and nothing else for the whole battle.
@@ -682,6 +698,10 @@ function BattleState:drawPic(mon, back)
-- what is still inside the box rather than drawn over the HUD below it.
local sunk = self:faintSink(side)
local function body()
if doll then
G.draw(doll, dollQuad, px, py)
return
end
if sunk > 0 then
local visible = h - math.floor(sunk / scale)
if visible <= 0 then return end
@@ -694,11 +714,75 @@ function BattleState:drawPic(mon, back)
-- A mod-supplied pic that says it is already coloured is drawn as it is:
-- pokemon.sprite's ctx.trueColor, the same flag Gen 1's Sprites.path hands
-- back to its own draw site.
if colors and not trueColor and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
local function paint()
if colors and not trueColor and GbcPalette.available() then
GbcPalette.with(colors, body)
else
body()
end
end
local lifted = anim and anim.lifted
if not lifted then
paint()
return
end
-- engine/battle_anims/bg_effects.asm:448-465: the ClearBoxed band is off the BG.
local bandY = (back and BattleState.PLAYER_PIC_TILE_Y
or BattleState.ENEMY_PIC_TILE_Y) * 8 + lifted[1] * 8
local bandH = lifted[2] * 8
local psx, psy, psw, psh
if G.getScissor then psx, psy, psw, psh = G.getScissor() end
if self.liftedPass then
G.setScissor(0, bandY, 160, bandH)
paint()
else
if bandY > 0 then
G.setScissor(0, 0, 160, bandY)
paint()
end
local below = 144 - bandY - bandH
if below > 0 then
G.setScissor(0, bandY + bandH, 160, below)
paint()
end
end
if psx then G.setScissor(psx, psy, psw, psh) else G.setScissor() end
end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
-- enemy's frontpic, facing-UP for the player's backpic.
function BattleState:substituteDoll(back)
if self.subDoll == nil then
local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png")
if ok and image then
local w, h = image:getDimensions()
self.subDoll = { image = image,
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
else
self.subDoll = false
end
end
if not self.subDoll then return nil end
return self.subDoll.image, back and self.subDoll.up or self.subDoll.down
end
-- MonsterSpriteGFX (gfx/sprites.asm:82): the facing-DOWN 16x16 frame for the
-- enemy's frontpic, facing-UP for the player's backpic.
function BattleState:substituteDoll(back)
if self.subDoll == nil then
local ok, image = pcall(Assets.image, "assets/generated/sprites/monster.png")
if ok and image then
local w, h = image:getDimensions()
self.subDoll = { image = image,
down = love.graphics.newQuad(0, 0, 16, 16, w, h),
up = love.graphics.newQuad(0, 16, 16, 16, w, h) }
else
self.subDoll = false
end
end
if not self.subDoll then return nil end
return self.subDoll.image, back and self.subDoll.up or self.subDoll.down
end
-- The top `visible` rows of a pic, for the faint slide. One quad, re-aimed,
@@ -1008,10 +1092,10 @@ function BattleState:afterAnimFor(side)
return "ANIM_PLAYER_DAMAGE"
end
function BattleState:animForMove(moveId, side)
function BattleState:animForMove(moveId, side, param)
local key = self.anims and self.anims.moves and self.anims.moves[moveId]
local started = self:startAnim(key, {
turn = self:turnFor(side), animId = moveId, isMove = true,
turn = self:turnFor(side), animId = moveId, isMove = true, param = param,
})
if started then
-- BattleAnimRunScript (anim_commands.asm:55-72): after the move script
@@ -1049,14 +1133,22 @@ function BattleState:animForId(idName, side, param)
})
end
-- data/moves/animations.asm:379
function BattleState:latchCaughtPic()
local anim = self.anim
if anim and anim.animId == "ANIM_THROW_POKE_BALL"
and self.ballThrow and self.ballThrow.caught then
self.picHidden.enemy = true
end
end
-- One logic frame of a running animation. B cuts it short, the way holding B
-- pages a text box.
function BattleState:stepAnim(input)
if not self.anim then return end
if input and (input:wasPressed("b") or input:wasPressed("start")) then
-- Cut short: the BG effects never reached their own last step, so 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.
-- Cut short: only the explicit latches (a caught mon) survive a skip.
self:latchCaughtPic()
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.
@@ -1064,6 +1156,7 @@ function BattleState:stepAnim(input)
return self:endSendOutAnim()
end
if not self.anim:step() then
self:latchCaughtPic()
-- 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
@@ -1072,16 +1165,8 @@ function BattleState:stepAnim(input)
end
end
-- NOTE on picHidden and the animation runtime. An animation that ENDS with a
-- pic box cleared could latch it here, and the tilemap argument says it should:
-- BattleAnimRestoreHuds redraws the two HUDs and nothing else. It deliberately
-- does not, because BATTLE_BG_EFFECT_REMOVE_MON and _RETURN_MON are also used
-- by moves whose user is still standing there afterwards -- SUBSTITUTE (the
-- doll takes the box over, and nothing in this port draws one yet), SKY_ATTACK,
-- BEAT_UP, BATON_PASS -- and a blanket latch would make those mons invisible
-- for the rest of the fight. The two moments the cart really does leave the
-- box empty for good are latched explicitly instead: a catch (pushCaught) and
-- a faint (MonFaintedAnimation, in update).
-- REMOVE_MON / RETURN_MON also serve SUBSTITUTE, SKY_ATTACK, BEAT_UP and
-- BATON_PASS, so picHidden is only latched by a catch and a faint.
-- Whatever Call_PlayBattleAnim was standing in front of: a send-out's cry and
-- HUD update run the moment its animation is done, cut short or not.
@@ -1098,9 +1183,11 @@ function BattleState:animPicState(side)
local bg = self.anim.bg
return {
hidden = bg.hidden[side],
lifted = bg.liftedRows and bg.liftedRows[side] or nil,
size = bg.picSize[side],
slide = bg.slide[side] or 0,
shade = bg.monShade[side],
pic = self.anim.picOverride[side],
}
end
@@ -1190,22 +1277,14 @@ function BattleState:advanceQueue()
if event.kind == "level" and event.index then
self.evolvable[event.index] = true
-- GiveExperiencePoints' `.skip_active_mon_update` guard
-- (engine/battle/core.asm:6999-7003): only the mon that is OUT copies its
-- recalculated HP, max HP and level into the battle struct, and only then
-- does `callfar UpdatePlayerHUD` (:7034) redraw the bar. That is a
-- REDRAW, not AnimateHPBar, so the shown HP snaps instead of chasing --
-- without it the bar kept the pre-level-up HP against the new maximum
-- until the next damage or heal event moved it.
-- (engine/battle/core.asm:6999-7003): the OUT mon's shown HP snaps.
local battle = self.battle
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
-- engine/battle/core.asm:7044
-- engine/battle/core.asm:7284
if mon and mon == battle.player then
event.text = nil
event.sfx = nil
event.waitSfx = nil
if self.shownHp then
self.shownHp.player = mon.hp or 0
if self.hpAnim and self.hpAnim.side == "player" then
@@ -1213,9 +1292,6 @@ function BattleState:advanceQueue()
end
end
-- `ld [wBattleMonLevel], a` in the same guarded block (:7018-7020).
-- AnimateExpBar has already walked the number up one level at a time by
-- the time this runs, so this only catches a level gained with no exp
-- crawl behind it.
self.shownLevel = mon.level or self.shownLevel
end
end
@@ -1367,16 +1443,14 @@ function BattleState:advanceQueue()
end
if event.text then
self.message = event.text
-- 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.
-- move/level lines do not hold for A/B (battle.asm:336-343); experience
-- keeps the wait (common_1.asm:1660-1665).
if event.kind == "move" or event.kind == "level" then
self.messageTimer = 0
-- engine/battle/effect_commands.asm:1958-1961
if event.kind == "move" and event.missed then
self.messageDelay = MOVE_DELAY_FRAMES
end
else
self.messageTimer = MESSAGE_FRAMES
end
@@ -1398,17 +1472,12 @@ function BattleState:advanceQueue()
if event.waitSfx then self.waitSfx = event.sfx end
end
end
-- The move's own animation plays over its "used X!" line, which is where
-- PlayBattleAnim sits in the effect command list. Its after-anim (the hit
-- shake) is chained by animForMove / stepAnim, matching BattleAnimRunScript.
-- BattleCommand_MoveAnimNoSub (engine/battle/effect_commands.asm:1958) opens
-- with `ld a, [wAttackMissed] / and a / jp nz, BattleCommand_MoveDelay`: a
-- move that missed burns the delay and plays nothing. Battle:markMissed sets
-- event.missed on every wAttackMissed path.
-- engine/battle/effect_commands.asm:1958: a missed move burns the delay
-- and plays nothing; the after-anim chain is animForMove / stepAnim's.
if event.kind == "move" and not event.missed then
self.afterAnimPlayed = nil
self.pendingAfterAnim = nil
if not self:animForMove(event.move, event.side) then
if not self:animForMove(event.move, event.side, event.animParam) 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
@@ -1660,6 +1729,64 @@ function BattleState:playerMoves()
return (self.battle and self.battle.player and self.battle.player.moves) or {}
end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if choice == "fight" then
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
self:submit({ kind = "move", move = Battle.STRUGGLE })
else
self.phase = "moves"
-- MoveSelectionScreen reopens on the last used move, clamped if the
-- moveset shrank since then.
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
end
elseif choice == "run" then
self:submit({ kind = "run" })
elseif choice == "item" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
elseif choice == "party" then
self:openParty()
else
return nil, "unknown battle menu choice"
end
return true
end
function BattleState:chooseMove(index)
if self.phase ~= "moves" then return nil, "move menu is not active" end
local move = self:playerMoves()[index]
if not move then return nil, "invalid move slot" end
self.moveIndex = index
self.moveSwapIndex = nil
if (move.pp or 0) <= 0 then
self:refuseMove(TEXT_NO_PP_LEFT)
elseif self.battle:moveDisabled(self.battle.player, move.id) then
self:refuseMove(TEXT_MOVE_DISABLED)
else
self:submit({ kind = "move", move = move.id })
end
return true
end
function BattleState:cancelMove()
if self.phase ~= "moves" then return nil, "move menu is not active" end
self.moveSwapIndex = nil
self.phase = "menu"
return true
end
-- MoveSelectionScreen's `.pressed_select` (engine/battle/core.asm:5320-5374).
-- SELECT marks a slot, SELECT again swaps the marked slot with the one under
-- the cursor, and A or B clears the mark without swapping (the A arm opens
@@ -1772,6 +1899,11 @@ function BattleState:update(_dt)
-- (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
-- engine/battle/effect_commands.asm:6661
if (self.messageDelay or 0) > 0 then
self.messageDelay = self.messageDelay - 1
return
end
if self.messageTimer > 0 then
if self.tutorial then
-- PromptButton waits for the button; the tutorial cannot press it, so
@@ -1833,37 +1965,7 @@ function BattleState:update(_dt)
or self.menuIndex - 2
elseif input:wasPressed("a") then
self:playSfx("Sfx_ReadText2")
local choice = MENU[self.menuIndex]
if choice == "FIGHT" then
-- `call .CheckPlayerHasUsableMoves / ret z` (engine/battle/core.asm
-- :5058-5059): a mon with nothing to spend never sees the list.
local fighter = self.battle and self.battle.player
if fighter and #self:playerMoves() > 0
and not self.battle:hasUsableMoves(fighter) then
return self:submit({ kind = "move", move = Battle.STRUGGLE })
end
self.phase = "moves"
-- MoveSelectionScreen seeds wMenuCursorY from wCurMoveNum + 1
-- (engine/battle/core.asm:5111) and the A-press writes the picked row
-- back, so the list reopens on the move used last turn; only
-- SendOutPlayerMon and CleanUpBattleRAM zero it. Clamp rather than
-- reset, for a moveset that shrank (Mimic, a forgotten slot).
local moves = self:playerMoves()
self.moveIndex = math.max(1,
math.min(self.moveIndex or 1, math.max(1, #moves)))
elseif choice == "RUN" then
self:submit({ kind = "run" })
elseif choice == "PACK" then
if self.tutorial then
self:openTutorialPack()
elseif self.contest then
self:throwParkBall()
else
self:openPack()
end
else
self:openParty()
end
self:chooseMenu(MENU_ACTION[MENU[self.menuIndex]])
end
return
end
@@ -1884,22 +1986,12 @@ function BattleState:update(_dt)
elseif input:wasPressed("b") then
-- B leaves the list, and a mark never survives it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
self.phase = "menu"
self:cancelMove()
elseif input:wasPressed("a") then
-- `xor a / ld [wSwappingMove], a` opens the A arm: choosing a move
-- cancels a pending swap rather than performing it
self:playSfx("Sfx_ReadText2")
self.moveSwapIndex = nil
local move = moves[self.moveIndex]
if not move then return end
-- `.no_pp_left` and `.move_disabled` both end on `jp MoveSelectionScreen`
-- (engine/battle/core.asm:5213-5246): neither spends the turn.
if (move.pp or 0) <= 0 then return self:refuseMove(TEXT_NO_PP_LEFT) end
if self.battle:moveDisabled(self.battle.player, move.id) then
return self:refuseMove(TEXT_MOVE_DISABLED)
end
self:submit({ kind = "move", move = move.id })
self:chooseMove(self.moveIndex)
end
return
end
@@ -2421,14 +2513,6 @@ function BattleState:pushCaught(enemy, itemId)
local save = self.save
self.battle.over = true
self.battle.outcome = "caught"
-- The mon is INSIDE the ball from here on. BattleAnim_ThrowPokeBall's caught
-- arm ends on the return-mon BG effect, which leaves the enemy pic box
-- cleared, and PokeBallEffect never draws a frontpic again -- there is no
-- send-out left in a battle that is already over. Latched here as well as
-- from the animation's own last step so that a throw the player skipped with
-- B (BattleAnimRunScript has no such skip; this port does) cannot put the
-- caught mon back on the field for the "Gotcha!" line.
self.picHidden.enemy = true
-- PokeBallEffect's FRIEND_BALL arm: the caught mon's happiness is set to
-- FRIEND_BALL_HAPPINESS (200) instead of the base 70. That is the ball's
-- whole effect; its catch rate is a plain ball's. It applies on the box
@@ -2464,7 +2548,10 @@ function BattleState:pushCaught(enemy, itemId)
self:push({ kind = "dex-entry", species = enemy.species })
end
if self.contest then
return self:contestCatch(enemy)
-- A contest catch still exits through the `.run` arm with result WIN
-- (engine/battle/core.asm:4780-4783), so CheckPayDay runs for it too.
self:contestCatch(enemy)
return self:pushPayDay()
end
save.party = save.party or {}
local toPc = #save.party >= Boxes.PARTY_SIZE
@@ -2520,6 +2607,19 @@ function BattleState:pushCaught(enemy, itemId)
self:push({ kind = "message",
text = self:name(enemy) .. " was sent to BILL's PC." })
end
self:pushPayDay()
end
-- CheckPayDay runs on a capture too: `and $f` keeps the win arm
-- (engine/battle/core.asm:7971-7976, :8014-8042).
function BattleState:pushPayDay()
local save = self.save
local coins = Prize.payDay(save, self.battle.payDay, self.battle.amuletCoin)
self.battle.payDay = nil
if coins then
self:push({ kind = "message",
text = Prize.payDayMessage(coins, save.player and save.player.name) })
end
end
-- CheckWhetherToAskSwitch: a started battle, more than one mon, no link, the
@@ -2874,6 +2974,7 @@ function BattleState:useItem(itemId)
-- wThrownBallWobbleCount 0, then `predef PlayBattleAnim`. Everything
-- pushed above is drained only once the ball has finished wobbling.
self:startBallAnim(self:ballAnimParam(itemId), itemId)
if caught and not self.anim then self.picHidden.enemy = true end
self.message = nil
self.messageTimer = 0
self.phase = "resolving"
@@ -3029,7 +3130,7 @@ function BattleState:applyPartyItem(itemId, action, mon, slot)
local before = (mon and mon.hp) or 0
local result
if action == "pp" then
result = ItemEffects.usePpItem(itemId, mon, slot)
result = ItemEffects.usePpItem(itemId, mon, slot, data)
else
result = ItemEffects.useOnMon(itemId, mon, data)
end
@@ -3382,6 +3483,36 @@ function BattleState:drawScene()
end
end
-- data/battle_anims/objects.asm:390-397: the lifted band rides at ABSOLUTE_X,
-- outside the scanline blit, so the attacker's SCX never moves it.
function BattleState:drawLiftedRows()
local battle = self.battle
if not battle then return end
local enemy = self:animPicState("enemy")
local player = self:animPicState("player")
local enemyLift = enemy and enemy.lifted
local playerLift = player and player.lifted
if not (enemyLift or playerLift) then return end
local G = love.graphics
if not self.liftCanvas then
self.liftCanvas = G.newCanvas(160, 144)
self.liftCanvas:setFilter("nearest", "nearest")
end
local previous = G.getCanvas()
G.setCanvas(self.liftCanvas)
G.clear(0, 0, 0, 0)
G.push()
G.origin()
self.liftedPass = true
if enemyLift then self:drawPic(battle.enemy, false) end
if playerLift then self:drawPic(battle.player, true) end
self.liftedPass = nil
G.pop()
G.setCanvas(previous)
G.setColor(1, 1, 1, 1)
G.draw(self.liftCanvas, 0, 0)
end
function BattleState:drawSceneBody()
local panel = function() self:drawPanel() end
if self.animView and self.slideFrame < BattleAnimView.SLIDE_FRAMES then
@@ -3402,7 +3533,8 @@ function BattleState:drawSceneBody()
return
end
if self.anim and self.animView then
self.animView:present(self.anim, panel)
self.animView:present(self.anim, panel, self.battle)
self:drawLiftedRows()
self.animView:drawObjects(self.anim, self.battle)
return
end
+2 -1
View File
@@ -28,6 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end
function BattleTransition:draw()
local w, h = love.graphics.getDimensions()
local w, h = GameViewport.dimensions()
self:drawWidescreen(w, h)
end
+11
View File
@@ -172,6 +172,17 @@ local ROWS = {
text = function(options)
return require("src.render.GBCFX").levelLabel(options.gbcfx or 0)
end },
{ label = "VIDEO MODE", key = "videoMode", port = true,
cycle = function(options, delta)
local VideoMode = require("src.core.VideoMode")
options.videoMode = VideoMode.cycle(options.videoMode, delta)
VideoMode.apply(options.videoMode)
end,
text = function(options)
local VideoMode = require("src.core.VideoMode")
return VideoMode.normalize(options.videoMode) == "borderless"
and "FULL" or "WINDOWED"
end },
{ id = "touchControls", label = "TOUCH PAD", port = true,
text = function(options)
local tc = options.touchControls
+8
View File
@@ -283,6 +283,14 @@ function PartyMenu:finishSwitch()
if self.save and self.save.party == party then
Mail.swapSlots(self.save, from, to)
end
-- engine/pokemon/switchpartymons.asm:38
local data = self.game and self.game.data
local ok, Sound = pcall(require, "src.core.Sound")
if not (ok and data and Sound and Sound.play) then return end
local sfx = data.audio and data.audio.sfx
if sfx and sfx[Sound.resolve(data, "Sfx_SwitchPokemon")] then
pcall(Sound.play, data, "Sfx_SwitchPokemon")
end
end
-- The reopened list: InitPartyMenuNoCancel caps the cursor at the last mon,
+4 -18
View File
@@ -144,12 +144,7 @@ function PokedexMenu.new(game, opts)
self.game = game
self.save = opts.save or (game and game.save)
local data = game and game.data or {}
-- Held, not just read into the fields below. CRY resolves its sample
-- through data.audio.cries and AREA resolves nests and landmark names
-- through data.maps / data.landmarks, and every one of those reads
-- `self.data` -- which nothing assigned, so `cries` folded to nil, playCry
-- returned before it reached Sound, and the button did nothing at all.
-- Taken by reference so a mod's merged cry or landmark is the one used.
-- engine/pokedex/pokedex.asm:447
self.data = data
self.dex = opts.pokedex or data.gen2Pokedex
self.pokemon = opts.pokemon or data.pokemon
@@ -866,15 +861,6 @@ function PokedexMenu:drawArea()
self:text(region == "kanto" and "KANTO" or "JOHTO", 1, 1)
local G = love.graphics
local table_ = self.data and self.data.landmarks
local byIndex = self.landmarkByIndex
if not byIndex then
byIndex = {}
for _, entry in pairs((table_ and table_.landmarks) or {}) do
if entry and entry.index then byIndex[entry.index] = entry end
end
self.landmarkByIndex = byIndex
end
if #nests == 0 then
-- A species with no grass, water or roamer entry in this region. The cart
@@ -883,11 +869,11 @@ function PokedexMenu:drawArea()
return
end
-- Blinking markers, the way the cart flashes its OBJs.
-- engine/pokegear/pokegear.asm:2427
local on = ((self.areaBlink or 0) % 32) < 20
if cells and on then
for _, index in ipairs(nests) do
local mark = byIndex[index]
local mark = Nests.landmark(self.data, index)
if mark and mark.x and mark.y then
G.setColor(0, 0, 0, 1)
G.rectangle("fill", mark.x - 2, mark.y - 2, 5, 5)
@@ -900,7 +886,7 @@ function PokedexMenu:drawArea()
-- Name the first one in words as well as on the map: the flashing dot is
-- unreadable at this size on a modern display, and the landmark name is what
-- a player actually wants off this screen.
local first = byIndex[nests[1]]
local first = Nests.landmark(self.data, nests[1])
if first and first.name then
local name = tostring(first.name):gsub("\n", " ")
self:text(name, 1, 16)
+2 -1
View File
@@ -17,6 +17,7 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local SafeArea = require("src.core.SafeArea")
local GameViewport = require("src.render.GameViewport")
local Layout = {}
@@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW)
local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then
W, H = love.graphics.getDimensions()
W, H = GameViewport.dimensions()
end
local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh)