diff --git a/src/ui/DexEntryMenu.lua b/src/ui/DexEntryMenu.lua index 6e8cf5ea..17fa3899 100644 --- a/src/ui/DexEntryMenu.lua +++ b/src/ui/DexEntryMenu.lua @@ -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) diff --git a/src/ui/OakSpeech.lua b/src/ui/OakSpeech.lua index 0cca2dfd..0dc79c88 100644 --- a/src/ui/OakSpeech.lua +++ b/src/ui/OakSpeech.lua @@ -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 diff --git a/src/ui/TitleState.lua b/src/ui/TitleState.lua index 88fd8293..3c393818 100644 --- a/src/ui/TitleState.lua +++ b/src/ui/TitleState.lua @@ -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 diff --git a/tests/parity_true_color_ui.lua b/tests/parity_true_color_ui.lua new file mode 100644 index 00000000..dae04ac8 --- /dev/null +++ b/tests/parity_true_color_ui.lua @@ -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()