mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 19:54:21 +02:00
Merge branch 'bryanthaboi:dev' into experiment/fixed-extended-world-alignment
This commit is contained in:
+124
-38
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user