Merge pull request #416 from johnjohto/fix-true-color-dex-intro

fix true color UI Pokemon sprites

Two conflicts against dev, both structural rather than behavioural:

DexEntryMenu.render became a static function on dev so the printer could
share the entry page; the branch still marked true color from the method
form.  Threaded the flag through as a render() parameter.  The printer
caller leaves it nil on purpose -- it renders to its own PNG canvas, and a
mark left there would bleed into the next real frame.

TitleState moved the cycling mon and Red's OAM into the non-Yellow branch
on dev (title_yellow.asm has neither); the branch still drew them at top
level.  Kept dev's structure and folded the true color marking into it.
This commit is contained in:
bryanthaboi
2026-07-29 15:10:03 -04:00
4 changed files with 184 additions and 32 deletions
+15 -5
View File
@@ -35,14 +35,15 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species]
local path = require("src.pokemon.Sprites").path(game.data, species, "front",
{ kind = "dex" })
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.
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
require("src.core.Sound").playCry(game.data, species)
return self
end
@@ -55,17 +56,26 @@ function DexEntryMenu:update(dt)
end
function DexEntryMenu:draw()
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned)
DexEntryMenu.render(self.game, self.def, self.sprite, self.forceOwned,
self.spriteTrueColor)
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)
function DexEntryMenu.render(game, def, sprite, forceOwned, trueColor)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
if sprite then
love.graphics.draw(sprite, 8, math.max(0, 60 - sprite:getHeight()))
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.
if trueColor then
require("src.render.PaletteFX").markTrueColor(8, y, sprite:getDimensions())
end
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8)
+34 -19
View File
@@ -61,7 +61,7 @@ local function tryImage(path)
return ok and img or nil
end
-- Resolve a pic descriptor to (image, flip).
-- Resolve a pic descriptor to (image, flip, trueColor).
-- Descriptors:
-- "oak" | "rival" | "player" shorthand
-- { type = "trainer", id = "OPP_PROF_OAK" }
@@ -70,7 +70,7 @@ end
-- { type = "image", path = "..." }
-- { type = "sprite", id = "SPRITE_RED" }
function OakSpeech.resolvePic(game, desc, speech)
if desc == nil then return nil, false end
if desc == nil then return nil, false, false end
if type(desc) == "string" then
if desc == "oak" then
desc = { type = "trainer", id = "OPP_PROF_OAK" }
@@ -86,35 +86,37 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type
if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false
return speech.oakPic, false, false
end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false
return speech.rivalPic, false, false
end
local trainers = game.data.trainers or {}
local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false
return tryImage(tr and tr.pic), false, false
elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic then
return speech.demoPic, desc.flip and true or false
return speech.demoPic, desc.flip and true or false, speech.demoTrueColor
end
local path = require("src.pokemon.Sprites").path(
local path, trueColor = require("src.pokemon.Sprites").path(
game.data, desc.id, "front", { kind = "oak" })
return tryImage(path), desc.flip and true or false
return tryImage(path), desc.flip and true or false, trueColor
elseif t == "player" then
if speech and speech.playerPic and not desc.path then
return speech.playerPic, false
return speech.playerPic, false, speech.playerTrueColor
end
if desc.path then return tryImage(desc.path), false end
return tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })), false
if desc.path then return tryImage(desc.path), false, false end
local path, trueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })
return tryImage(path), false, trueColor
elseif t == "image" then
return tryImage(desc.path), desc.flip and true or false
return tryImage(desc.path), desc.flip and true or false, false
elseif t == "sprite" then
local sp = game.data.sprites and game.data.sprites[desc.id]
return tryImage(sp and sp.image), desc.flip and true or false
return tryImage(sp and sp.image), desc.flip and true or false,
sp and sp.trueColor or false
end
return nil, false
return nil, false, false
end
-- Vanilla step list. Ids are the stable anchors mods insert around.
@@ -219,15 +221,18 @@ function OakSpeech.new(game, onDone)
-- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO"
local demoPath = require("src.pokemon.Sprites").path(
local demoPath, demoTrueColor = require("src.pokemon.Sprites").path(
game.data, self.demoSpecies, "front", { kind = "oak" })
self.demoPic = tryImage(demoPath)
self.demoTrueColor = self.demoPic and demoTrueColor or false
local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" }))
local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })
self.playerPic = tryImage(playerPath)
self.playerTrueColor = self.playerPic and playerTrueColor or false
self.shrinkPic1 = tryImage(oakGfx.shrink1
or "assets/generated/intro/shrink1.png")
self.shrinkPic2 = tryImage(oakGfx.shrink2
@@ -278,14 +283,17 @@ end
function OakSpeech:applyPic(step)
if step.pic == nil then return end
local img, flip = OakSpeech.resolvePic(self.game, step.pic, self)
local img, flip, trueColor = OakSpeech.resolvePic(self.game, step.pic, self)
if img then
self.pic = img
self.picFlip = flip or false
self.picTrueColor = trueColor or false
elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
-- mirror the old fallback: player pic missing → oak
self.pic = self.playerPic or self.oakPic
self.picFlip = false
self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor
or false
end
end
@@ -342,6 +350,7 @@ function OakSpeech:runStep(step)
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
self.pic = self.demoPic
self.picFlip = true
self.picTrueColor = self.demoTrueColor
self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies)
self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
@@ -536,8 +545,10 @@ function OakSpeech:update(dt)
s.frame = s.frame + 1
if s.frame == 5 then
self.pic = self.shrinkPic1 or self.pic
self.picTrueColor = false
elseif s.frame == 9 then
self.pic = self.shrinkPic2 or self.pic
self.picTrueColor = false
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70
-- frames (7 levels x 10), reaching 0 just as the fade-to-white
-- begins at frame 79, instead of a hard cut (oak_speech.asm:145-149,
@@ -545,6 +556,7 @@ function OakSpeech:update(dt)
Music.fadeOut(10)
elseif s.frame == 29 then
self.pic = nil
self.picTrueColor = false
self.walkVisible = true
elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
@@ -584,6 +596,9 @@ function OakSpeech:draw()
else
love.graphics.draw(self.pic, x + off, y)
end
if self.picTrueColor then
require("src.render.PaletteFX").markTrueColor(x + off, y, w, h)
end
love.graphics.setColor(1, 1, 1, 1)
end
if self.walkVisible and self.walkSheet then
+47 -8
View File
@@ -98,6 +98,30 @@ local function imagePath(entry)
return entry
end
-- PaletteFX redraws a true-color rectangle after the palette pass. Red's
-- title art is drawn on top of the title mon, so leave its bounds out of the
-- rectangle rather than redrawing that art without its title palette.
local function markVisibleTrueColor(x, y, w, h, cover)
local P = require("src.render.PaletteFX")
if not cover then
P.markTrueColor(x, y, w, h)
return
end
local cx, cy, cw, ch = cover[1], cover[2], cover[3], cover[4]
local right, bottom = x + w, y + h
local cright, cbottom = cx + cw, cy + ch
local ix1, iy1 = math.max(x, cx), math.max(y, cy)
local ix2, iy2 = math.min(right, cright), math.min(bottom, cbottom)
if ix1 >= ix2 or iy1 >= iy2 then
P.markTrueColor(x, y, w, h)
return
end
if y < iy1 then P.markTrueColor(x, y, w, iy1 - y) end
if iy2 < bottom then P.markTrueColor(x, iy2, w, bottom - iy2) end
if x < ix1 then P.markTrueColor(x, iy1, ix1 - x, iy2 - iy1) end
if ix2 < right then P.markTrueColor(ix2, iy1, right - ix2, iy2 - iy1) end
end
function TitleState.new(game, opts)
opts = opts or {}
local self = setmetatable({}, TitleState)
@@ -148,7 +172,7 @@ function TitleState.new(game, opts)
self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0)
and title.cycleSpecies or defaultCycle
self.sprites = {} -- species -> image or false (load failed)
self.sprites = {} -- species -> { image, trueColor } or false (load failed)
self.cycleIndex = 1
self.timer = 0
self.blink = 0
@@ -252,12 +276,13 @@ function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species]
if cached == nil then
local path = require("src.pokemon.Sprites").path(
local path, trueColor = require("src.pokemon.Sprites").path(
self.game.data, species, "front", { kind = "title" })
cached = tryImage(path) or false
local image = tryImage(path)
cached = image and { image = image, trueColor = trueColor } or false
self.sprites[species] = cached
end
return cached or nil
return cached and cached.image or nil, cached and cached.trueColor or false
end
local function hasSave()
@@ -446,13 +471,27 @@ function TitleState:draw()
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end
end
local sprite = self:currentSprite()
local sprite, spriteTrueColor = self:currentSprite()
if sprite then
local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide,
136 - h)
local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box
local x = 40 + math.floor((56 - w) / 2) + slide
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).
if spriteTrueColor then
local cover
if self.player then
local pw, ph = self.player:getDimensions()
cover = { 82, 80, pw, ph }
end
markVisibleTrueColor(x, y, w, h, cover)
end
end
-- Red is OAM in the original: he draws over the mon's box edge
if self.player then
love.graphics.draw(self.player, 82, 80)
end
+88
View File
@@ -0,0 +1,88 @@
-- True-color Pokemon art is drawn on the UI canvas in addition to battle.
-- The Pokedex, title screen, and Oak's intro need to report the exact
-- rectangle they draw so PaletteFX can put the unshaded copy over the SGB
-- palette pass. Run with `luajit tests/parity_true_color_ui.lua`.
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("parity true-color ui")
local check = S.check
local Data = require("src.core.Data")
if not Data.maps then Data:load() end
require("src.render.Font").load(Data)
local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound")
local DexEntryMenu = require("src.ui.DexEntryMenu")
local TitleState = require("src.ui.TitleState")
local OakSpeech = require("src.ui.OakSpeech")
local savedCry = Sound.playCry
Sound.playCry = function() end
local function uiRects(draw)
PaletteFX.clearTrueColor()
PaletteFX.setPass("ui")
draw()
local rects = PaletteFX.trueColorRects("ui")
PaletteFX.setPass(nil)
return rects
end
local def = Data.pokemon.PIKACHU
local savedTrueColor = def.trueColor
def.trueColor = true
local game = {
data = Data,
save = { pokedex = { owned = { PIKACHU = true } } },
stack = { pop = function() end },
}
local dex = DexEntryMenu.new(game, "PIKACHU")
check(dex.spriteTrueColor == true,
"Pokedex keeps a Pokemon sprite's trueColor flag")
local dexRects = uiRects(function() dex:draw() end)
local dx, dy = 8, math.max(0, 60 - dex.sprite:getHeight())
check(#dexRects == 1 and dexRects[1].x == dx and dexRects[1].y == dy
and dexRects[1].w == dex.sprite:getWidth()
and dexRects[1].h == dex.sprite:getHeight(),
"Pokedex reports the true-color sprite rectangle")
local titleGame = {
data = { pokemon = Data.pokemon,
field = { title = { cycleSpecies = { "PIKACHU" } } } },
}
local title = TitleState.new(titleGame, {})
local titleSprite, titleTrueColor = title:currentSprite()
check(titleSprite and titleTrueColor,
"title cache keeps a Pokemon sprite's trueColor flag")
local titleRects = uiRects(function() title:draw() end)
local tx = 40 + math.floor((56 - titleSprite:getWidth()) / 2)
check(#titleRects == 1 and titleRects[1].x == tx
and titleRects[1].y == 136 - titleSprite:getHeight()
and titleRects[1].w == 82 - tx
and titleRects[1].h == titleSprite:getHeight(),
"title reports the visible true-color sprite rectangle")
local oak = OakSpeech.new({
data = { pokemon = Data.pokemon, trainers = {},
field = { oakSpeech = { demoSpecies = "PIKACHU" } } },
}, nil)
oak.pic, oak.picFlip = oak.demoPic, true
oak.picTrueColor = oak.demoTrueColor
check(oak.demoTrueColor == true,
"Oak intro keeps the demo Pokemon's trueColor flag")
local oakRects = uiRects(function() oak:draw() end)
local ow, oh = oak.demoPic:getDimensions()
local ox = 48 + math.floor((8 - ow / 8) / 2) * 8
local oy = 32 + (7 - oh / 8) * 8
check(#oakRects == 1 and oakRects[1].x == ox and oakRects[1].y == oy
and oakRects[1].w == ow and oakRects[1].h == oh,
"Oak intro reports the true-color sprite rectangle")
def.trueColor = savedTrueColor
Sound.playCry = savedCry
PaletteFX.clearTrueColor()
S.finish()