fix true color UI Pokemon sprites

The UI drew sprite paths but discarded their trueColor flag, so the SGB palette pass recolored full-color Pokemon.
This commit is contained in:
johnjohto
2026-07-29 13:31:50 -04:00
parent d6e36d457f
commit 4c17e606fd
4 changed files with 172 additions and 29 deletions
+9 -3
View File
@@ -35,14 +35,15 @@ function DexEntryMenu.new(game, speciesOrOpts)
local species, forceOwned = resolveArgs(speciesOrOpts) local species, forceOwned = resolveArgs(speciesOrOpts)
local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu) local self = setmetatable({ game = game, forceOwned = forceOwned }, DexEntryMenu)
self.def = game.data.pokemon[species] self.def = game.data.pokemon[species]
local path = require("src.pokemon.Sprites").path(game.data, species, "front", local path, trueColor = require("src.pokemon.Sprites").path(
{ kind = "dex" }) game.data, species, "front", { kind = "dex" })
-- `path and pcall(...)` truncates to one value, so img was always nil and -- `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 -- every dex page drew without its pic (#307); the guard has to be a
-- statement for pcall's second return to survive. -- statement for pcall's second return to survive.
local ok, img = false, nil local ok, img = false, nil
if path then ok, img = pcall(love.graphics.newImage, path) end if path then ok, img = pcall(love.graphics.newImage, path) end
self.sprite = ok and img or nil self.sprite = ok and img or nil
self.spriteTrueColor = self.sprite and trueColor or false
require("src.core.Sound").playCry(game.data, species) require("src.core.Sound").playCry(game.data, species)
return self return self
end end
@@ -59,7 +60,12 @@ function DexEntryMenu:draw()
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.rectangle("fill", 0, 0, 160, 144)
local def = self.def local def = self.def
if self.sprite then if self.sprite then
love.graphics.draw(self.sprite, 8, math.max(0, 60 - self.sprite:getHeight())) local y = math.max(0, 60 - self.sprite:getHeight())
love.graphics.draw(self.sprite, 8, y)
if self.spriteTrueColor then
require("src.render.PaletteFX").markTrueColor(
8, y, self.sprite:getDimensions())
end
end end
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
Font.draw(def.name, 72, 8) Font.draw(def.name, 72, 8)
+34 -19
View File
@@ -61,7 +61,7 @@ local function tryImage(path)
return ok and img or nil return ok and img or nil
end end
-- Resolve a pic descriptor to (image, flip). -- Resolve a pic descriptor to (image, flip, trueColor).
-- Descriptors: -- Descriptors:
-- "oak" | "rival" | "player" shorthand -- "oak" | "rival" | "player" shorthand
-- { type = "trainer", id = "OPP_PROF_OAK" } -- { type = "trainer", id = "OPP_PROF_OAK" }
@@ -70,7 +70,7 @@ end
-- { type = "image", path = "..." } -- { type = "image", path = "..." }
-- { type = "sprite", id = "SPRITE_RED" } -- { type = "sprite", id = "SPRITE_RED" }
function OakSpeech.resolvePic(game, desc, speech) 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 type(desc) == "string" then
if desc == "oak" then if desc == "oak" then
desc = { type = "trainer", id = "OPP_PROF_OAK" } desc = { type = "trainer", id = "OPP_PROF_OAK" }
@@ -86,35 +86,37 @@ function OakSpeech.resolvePic(game, desc, speech)
local t = desc.type local t = desc.type
if t == "trainer" then if t == "trainer" then
if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then if speech and desc.id == "OPP_PROF_OAK" and speech.oakPic then
return speech.oakPic, false return speech.oakPic, false, false
end end
if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then if speech and desc.id == "OPP_RIVAL1" and speech.rivalPic then
return speech.rivalPic, false return speech.rivalPic, false, false
end end
local trainers = game.data.trainers or {} local trainers = game.data.trainers or {}
local tr = trainers[desc.id] local tr = trainers[desc.id]
return tryImage(tr and tr.pic), false return tryImage(tr and tr.pic), false, false
elseif t == "pokemon" then elseif t == "pokemon" then
if speech and desc.id == speech.demoSpecies and speech.demoPic 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 end
local path = require("src.pokemon.Sprites").path( local path, trueColor = require("src.pokemon.Sprites").path(
game.data, desc.id, "front", { kind = "oak" }) 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 elseif t == "player" then
if speech and speech.playerPic and not desc.path then if speech and speech.playerPic and not desc.path then
return speech.playerPic, false return speech.playerPic, false, speech.playerTrueColor
end end
if desc.path then return tryImage(desc.path), false end if desc.path then return tryImage(desc.path), false, false end
return tryImage(require("src.pokemon.Sprites").playerPath( local path, trueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })), false game.data, "front", { kind = "intro" })
return tryImage(path), false, trueColor
elseif t == "image" then 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 elseif t == "sprite" then
local sp = game.data.sprites and game.data.sprites[desc.id] 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 end
return nil, false return nil, false, false
end end
-- Vanilla step list. Ids are the stable anchors mods insert around. -- 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 -- the show-off mon and the name length cap come from data; the vanilla
-- literals stay as the fallbacks -- literals stay as the fallbacks
self.demoSpecies = oakGfx.demoSpecies or "NIDORINO" 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" }) game.data, self.demoSpecies, "front", { kind = "oak" })
self.demoPic = tryImage(demoPath) self.demoPic = tryImage(demoPath)
self.demoTrueColor = self.demoPic and demoTrueColor or false
local constants = game.data.constants or {} local constants = game.data.constants or {}
self.nameLen = constants.playerNameLength or 7 self.nameLen = constants.playerNameLength or 7
-- RedPicFront (gfx/player/red.png, shared with the trainer card) and -- RedPicFront (gfx/player/red.png, shared with the trainer card) and
-- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png) -- the ShrinkPic1/ShrinkPic2 frames (gfx/player/shrink{1,2}.png)
self.playerPic = tryImage(require("src.pokemon.Sprites").playerPath( local playerPath, playerTrueColor = require("src.pokemon.Sprites").playerPath(
game.data, "front", { kind = "intro" })) game.data, "front", { kind = "intro" })
self.playerPic = tryImage(playerPath)
self.playerTrueColor = self.playerPic and playerTrueColor or false
self.shrinkPic1 = tryImage(oakGfx.shrink1 self.shrinkPic1 = tryImage(oakGfx.shrink1
or "assets/generated/intro/shrink1.png") or "assets/generated/intro/shrink1.png")
self.shrinkPic2 = tryImage(oakGfx.shrink2 self.shrinkPic2 = tryImage(oakGfx.shrink2
@@ -278,14 +283,17 @@ end
function OakSpeech:applyPic(step) function OakSpeech:applyPic(step)
if step.pic == nil then return end 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 if img then
self.pic = img self.pic = img
self.picFlip = flip or false 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 elseif step.pic == "player" or (type(step.pic) == "table" and step.pic.type == "player") then
-- mirror the old fallback: player pic missing → oak -- mirror the old fallback: player pic missing → oak
self.pic = self.playerPic or self.oakPic self.pic = self.playerPic or self.oakPic
self.picFlip = false self.picFlip = false
self.picTrueColor = self.pic == self.playerPic and self.playerTrueColor
or false
end end
end end
@@ -342,6 +350,7 @@ function OakSpeech:runStep(step)
-- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A -- NIDORINO show-off: mirrored front sprite + wipe + cry + text 2A
self.pic = self.demoPic self.pic = self.demoPic
self.picFlip = true self.picFlip = true
self.picTrueColor = self.demoTrueColor
self:revealPic("wipe", function() self:revealPic("wipe", function()
Sound.playCry(self.game.data, self.demoSpecies) Sound.playCry(self.game.data, self.demoSpecies)
self:say(Strings("_OakSpeechText2A"), function() self:advance() end) self:say(Strings("_OakSpeechText2A"), function() self:advance() end)
@@ -536,8 +545,10 @@ function OakSpeech:update(dt)
s.frame = s.frame + 1 s.frame = s.frame + 1
if s.frame == 5 then if s.frame == 5 then
self.pic = self.shrinkPic1 or self.pic self.pic = self.shrinkPic1 or self.pic
self.picTrueColor = false
elseif s.frame == 9 then elseif s.frame == 9 then
self.pic = self.shrinkPic2 or self.pic self.pic = self.shrinkPic2 or self.pic
self.picTrueColor = false
-- wAudioFadeOutControl = 10: the music ramps to silence over ~70 -- wAudioFadeOutControl = 10: the music ramps to silence over ~70
-- frames (7 levels x 10), reaching 0 just as the fade-to-white -- 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, -- 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) Music.fadeOut(10)
elseif s.frame == 29 then elseif s.frame == 29 then
self.pic = nil self.pic = nil
self.picTrueColor = false
self.walkVisible = true self.walkVisible = true
elseif s.frame >= 79 and s.frame <= 102 then elseif s.frame >= 79 and s.frame <= 102 then
self.fadeLevel = math.floor((s.frame - 79) / 8) + 1 self.fadeLevel = math.floor((s.frame - 79) / 8) + 1
@@ -584,6 +596,9 @@ function OakSpeech:draw()
else else
love.graphics.draw(self.pic, x + off, y) love.graphics.draw(self.pic, x + off, y)
end end
if self.picTrueColor then
require("src.render.PaletteFX").markTrueColor(x + off, y, w, h)
end
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
if self.walkVisible and self.walkSheet then if self.walkVisible and self.walkSheet then
+41 -7
View File
@@ -76,6 +76,30 @@ local function imagePath(entry)
return entry return entry
end 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) function TitleState.new(game, opts)
opts = opts or {} opts = opts or {}
local self = setmetatable({}, TitleState) local self = setmetatable({}, TitleState)
@@ -99,7 +123,7 @@ function TitleState.new(game, opts)
self.cycleSpecies = (type(title.cycleSpecies) == "table" self.cycleSpecies = (type(title.cycleSpecies) == "table"
and #title.cycleSpecies > 0) and #title.cycleSpecies > 0)
and title.cycleSpecies or defaultCycle 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.cycleIndex = 1
self.timer = 0 self.timer = 0
self.blink = 0 self.blink = 0
@@ -118,12 +142,13 @@ function TitleState:currentSprite()
local species = self.cycleSpecies[self.cycleIndex] local species = self.cycleSpecies[self.cycleIndex]
local cached = self.sprites[species] local cached = self.sprites[species]
if cached == nil then 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" }) 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 self.sprites[species] = cached
end end
return cached or nil return cached and cached.image or nil, cached and cached.trueColor or false
end end
local function hasSave() local function hasSave()
@@ -276,13 +301,22 @@ function TitleState:draw()
love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64) love.graphics.newQuad(40, 0, 40, 8, iw, ih), 80, 64)
end end
end end
local sprite = self:currentSprite() local sprite, spriteTrueColor = self:currentSprite()
if sprite then if sprite then
local w, h = sprite:getDimensions() local w, h = sprite:getDimensions()
local slide = (self.slideIn or 0) * 8 -- scroll in from the right local slide = (self.slideIn or 0) * 8 -- scroll in from the right
-- bottom-aligned and centered in the (5,10)-(11,16) tile box -- bottom-aligned and centered in the (5,10)-(11,16) tile box
love.graphics.draw(sprite, 40 + math.floor((56 - w) / 2) + slide, local x = 40 + math.floor((56 - w) / 2) + slide
136 - h) local y = 136 - h
love.graphics.draw(sprite, x, y)
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 end
-- Red is OAM in the original: he draws over the mon's box edge -- Red is OAM in the original: he draws over the mon's box edge
if self.player then if self.player then
+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()