feat: implement Diploma screen visual assets and add version-specific blink logic to TownMap

Fix for #1595 #1597 #1589 #1613 and maybe 1520.
This commit is contained in:
1jamie
2026-08-20 18:33:51 -05:00
committed by bryanthaboi
parent db25c14dfb
commit f5b8b6c85f
6 changed files with 258 additions and 33 deletions
+1 -1
View File
@@ -454,7 +454,7 @@ function PaletteFX.pal(data, name)
if fromCgb then return fromCgb end if fromCgb then return fromCgb end
end end
local p = PaletteFX.pack(data) local p = PaletteFX.pack(data)
local c = p and p.palettes[name] local c = p and p.palettes and p.palettes[name]
if c then return c end if c then return c end
if GameVersion.isYellow() then if GameVersion.isYellow() then
local y = PaletteFX.yellowPack() local y = PaletteFX.yellowPack()
+110 -19
View File
@@ -1,18 +1,77 @@
-- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma / -- The dex-completion diploma (engine/events/diploma.asm DisplayDiploma /
-- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the -- diploma2.asm DisplayDiplomaTop): a bordered certificate page with the
-- player's name, shown by the Celadon Mansion 3F game designer once 150 -- player's name and character sprite, shown by the Celadon Mansion 3F game designer
-- species are owned. Diploma.render also backs the Yellow-only printed -- once 150 species are owned. Diploma.render also backs the printed copy
-- copy (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua). -- (engine/printer/printer.asm PrintDiploma -> src/core/Printer.lua).
local Assets = require("src.render.Assets")
local Font = require("src.render.Font") local Font = require("src.render.Font")
local PaletteFX = require("src.render.PaletteFX")
local Sprites = require("src.pokemon.Sprites")
local Strings = require("src.core.Strings") local Strings = require("src.core.Strings")
local Diploma = {} local Diploma = {}
Diploma.__index = Diploma Diploma.__index = Diploma
Diploma.isOpaque = true Diploma.isOpaque = true
-- SGB: PalPacket_Generic (MEWMON), whole screen (engine/events/diploma.asm:67)
function Diploma:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
local function tryImage(path)
if not path then return nil end
local ok, img = pcall(Assets.image, path)
if ok and img then return img end
local ok2, img2 = pcall(love.graphics.newImage, path)
return ok2 and img2 or nil
end
local function loadFrame()
local frame = tryImage("assets/generated/trainer_card/trainer_info.png")
if not frame then return nil end
local quads = {}
for i = 0, 8 do
quads[i] = love.graphics.newQuad((i % 3) * 8,
math.floor(i / 3) * 8,
8, 8, frame:getDimensions())
end
return { img = frame, quads = quads }
end
local function drawFrameBox(frame, tx, ty, tw, th)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
if not frame then
Font.drawBox(tx, ty, tw, th)
return
end
local img = frame.img
local q = frame.quads
love.graphics.setColor(1, 1, 1, 1)
-- corners
love.graphics.draw(img, q[0], tx * 8, ty * 8)
love.graphics.draw(img, q[2], (tx + tw - 1) * 8, ty * 8)
love.graphics.draw(img, q[6], tx * 8, (ty + th - 1) * 8)
love.graphics.draw(img, q[8], (tx + tw - 1) * 8, (ty + th - 1) * 8)
-- horizontal edges
for x = 1, tw - 2 do
love.graphics.draw(img, q[1], (tx + x) * 8, ty * 8)
love.graphics.draw(img, q[7], (tx + x) * 8, (ty + th - 1) * 8)
end
-- vertical edges
for y = 1, th - 2 do
love.graphics.draw(img, q[3], tx * 8, (ty + y) * 8)
love.graphics.draw(img, q[5], (tx + tw - 1) * 8, (ty + y) * 8)
end
end
function Diploma.new(game, onDone) function Diploma.new(game, onDone)
return setmetatable({ game = game, onDone = onDone }, Diploma) local self = setmetatable({
game = game,
onDone = onDone,
}, Diploma)
return self
end end
function Diploma:update() function Diploma:update()
@@ -23,23 +82,55 @@ function Diploma:update()
end end
end end
-- the DisplayDiplomaTop layout, hlcoord tiles kept as x*8 / y*8 pixels -- the DisplayDiploma / DisplayDiplomaTop layout (hlcoord tiles -> x*8, y*8)
function Diploma.render(game) function Diploma.render(game)
love.graphics.setColor(1, 1, 1, 1) local frame = loadFrame()
love.graphics.rectangle("fill", 0, 0, 160, 144) local circle = tryImage("assets/generated/trainer_card/circle_tile.png")
love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", 2.5, 2.5, 155, 139) -- 1. Outer ornate frame border: hlcoord 0, 0 / bc 16, 18 -> (0, 0, 20, 18)
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2 drawFrameBox(frame, 0, 0, 20, 18)
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4 -- 2. Draw Player character sprite: farcall DrawPlayerCharacter
local congrats = { -- hlcoord 2,6 -- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
"Congrats! This", "diploma certifies", "that you have", local picPath, picTrueColor = Sprites.playerPath(
"completed your", "POKéDEX.", game.data, "front", { kind = "diploma" })
} local pic = tryImage(picPath)
for i, line in ipairs(congrats) do if pic then
Font.draw(Strings(line), 16, 48 + (i - 1) * 10) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(pic, 115, 80)
if picTrueColor then
PaletteFX.markTrueColor(115, 80, pic:getDimensions())
end
end end
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16
-- 3. Header: hlcoord 5, 2 with flanking circle tiles ($70)
love.graphics.setColor(1, 1, 1, 1)
if circle then
love.graphics.draw(circle, 40, 16) -- hlcoord 5, 2
love.graphics.draw(circle, 104, 16) -- hlcoord 13, 2
end
love.graphics.setColor(0, 0, 0, 1)
Font.draw(Strings("Diploma"), 48, 16) -- hlcoord 6, 2
-- 4. Player info: hlcoord 3, 4 ("PLAYER" / "Player") and hlcoord 10, 4 (name)
Font.draw(Strings("Player"), 24, 32)
local playerName = (game.save.player and game.save.player.name) or "RED"
Font.draw(playerName, 80, 32)
-- 5. Congratulations text: hlcoord 2, 6 double-spaced lines (rows 6, 8, 10, 12, 14)
local congrats = {
{ text = "Congrats! This", y = 48 }, -- hlcoord 2, 6
{ text = "diploma certifies", y = 64 }, -- hlcoord 2, 8
{ text = "that you have", y = 80 }, -- hlcoord 2, 10
{ text = "completed your", y = 96 }, -- hlcoord 2, 12
{ text = "POKéDEX.", y = 112 }, -- hlcoord 2, 14
}
for _, line in ipairs(congrats) do
Font.draw(Strings(line.text), 16, line.y)
end
-- 6. Developer signature: hlcoord 9, 16
Font.draw(Strings("GAME FREAK"), 72, 128)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
+33 -10
View File
@@ -14,6 +14,7 @@
-- This is what the party-menu FLY field move opens (#195). -- This is what the party-menu FLY field move opens (#195).
local Font = require("src.render.Font") local Font = require("src.render.Font")
local GameVersion = require("src.core.GameVersion")
local PaletteFX = require("src.render.PaletteFX") local PaletteFX = require("src.render.PaletteFX")
local Sound = require("src.core.Sound") local Sound = require("src.core.Sound")
local SpriteRenderer = require("src.render.SpriteRenderer") local SpriteRenderer = require("src.render.SpriteRenderer")
@@ -292,7 +293,8 @@ function TownMap:moveList(step)
end end
function TownMap:update(dt) function TownMap:update(dt)
self.blink = (self.blink + 1) % 32 local cycle = GameVersion.generation() == 2 and 32 or 50
self.blink = (self.blink + 1) % cycle
local input = self.game.input local input = self.game.input
if input:wasPressed("b") then if input:wasPressed("b") then
Sound.play(self.game.data, "Press_AB") Sound.play(self.game.data, "Press_AB")
@@ -361,7 +363,13 @@ function TownMap:draw()
end end
if self.nestSpecies then if self.nestSpecies then
-- AREA mode: blinking nests, the species name up top -- AREA mode: blinking nests, the species name up top
if self.blink % 16 < 10 then local showNest = true
if GameVersion.generation() == 1 then
showNest = self.blink < 25
else
showNest = self.blink % 16 < 10
end
if showNest then
for _, loc in ipairs(self.nests) do for _, loc in ipairs(self.nests) do
local x, y = markerXY(loc) local x, y = markerXY(loc)
if self.nestIcon then if self.nestIcon then
@@ -382,8 +390,8 @@ function TownMap:draw()
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
return return
end end
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152) -- engine/items/town_map.asm:347; player marker is static in both Gen 1 and 2
if self.playerLoc and self.blink < 20 then if self.playerLoc then
local x, y = markerXY(self.playerLoc) local x, y = markerXY(self.playerLoc)
if self.playerSheet then if self.playerSheet then
love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3) love.graphics.draw(self.playerSheet, self.playerQuad, x - 4, y - 3)
@@ -399,7 +407,13 @@ function TownMap:draw()
-- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm -- (8,8), so draw it -4,-4 to enclose the cell (engine/menus/town_map.asm
-- draws the box cursor CENTERED on the selected location). Drawing it at -- draws the box cursor CENTERED on the selected location). Drawing it at
-- the cell top-left put the square in the frame's top-left quadrant (#152). -- the cell top-left put the square in the frame's top-left quadrant (#152).
if selected and self.blink % 16 < 10 then local showCursor = true
if GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
end
if selected and showCursor then
local x, y = markerXY(selected) local x, y = markerXY(selected)
if self.bg.cursor then if self.bg.cursor then
love.graphics.draw(self.bg.cursor, x - 4, y - 4) love.graphics.draw(self.bg.cursor, x - 4, y - 4)
@@ -424,7 +438,8 @@ function TownMap:draw()
for _, loc in ipairs(self.locs) do for _, loc in ipairs(self.locs) do
drawSquare(loc) drawSquare(loc)
end end
if self.playerLoc and self.blink < 20 then -- player marker is static in both Gen 1 and 2
if self.playerLoc then
-- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152) -- engine/items/town_map.asm:347; fallback dot stays red 0 for PaletteFX (#152)
if self.playerSheet then if self.playerSheet then
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
@@ -438,7 +453,13 @@ function TownMap:draw()
self.playerLoc.y * 8 + 2, 4, 4) self.playerLoc.y * 8 + 2, 4, 4)
end end
end end
if selected and self.blink % 16 < 10 then local showCursor = true
if GameVersion.generation() == 1 then
showCursor = self.blink < 25
else
showCursor = self.blink % 16 < 10
end
if selected and showCursor then
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
love.graphics.rectangle("line", selected.x * 8 + 0.5, love.graphics.rectangle("line", selected.x * 8 + 0.5,
selected.y * 8 + 0.5, 7, 7) selected.y * 8 + 0.5, 7, 7)
@@ -452,12 +473,14 @@ function TownMap:draw()
local loc = self.locs[first + i] local loc = self.locs[first + i]
if loc then if loc then
local y = 40 + i * 16 local y = 40 + i * 16
if first + i == self.sel and self.blink % 16 < 10 then -- cursor in list mode (Fly mode) is static in RBY (LoadTownMap_Fly)
if first + i == self.sel then
Font.drawCode(0xED, 8, y) -- the "▶" cursor glyph Font.drawCode(0xED, 8, y) -- the "▶" cursor glyph
end end
Font.draw(loc.name, 24, y) Font.draw(loc.name, 24, y)
if loc == self.playerLoc and self.blink < 20 then -- player marker is static
-- blinking marker on the player's current town; force the palette-safe if loc == self.playerLoc then
-- marker on the player's current town; force the palette-safe
-- dark shade explicitly so the red-channel shade-remap keeps it -- dark shade explicitly so the red-channel shade-remap keeps it
-- visible regardless of Font.draw's leftover color (#152) -- visible regardless of Font.draw's leftover color (#152)
love.graphics.setColor(0, 0, 0, 1) love.graphics.setColor(0, 0, 0, 1)
+13 -3
View File
@@ -27,6 +27,7 @@ local DEFAULT_ART = {
openCable = "assets/generated/trade/open_cable.png", openCable = "assets/generated/trade/open_cable.png",
cableHoriz = "assets/generated/trade/cable_horiz.png", cableHoriz = "assets/generated/trade/cable_horiz.png",
cableConn = "assets/generated/trade/cable_conn.png", cableConn = "assets/generated/trade/cable_conn.png",
cableSeg = "assets/generated/trade/cable_seg.png",
cableVert = "assets/generated/trade/cable_vert.png", cableVert = "assets/generated/trade/cable_vert.png",
cableCorner = "assets/generated/trade/cable_corner.png", cableCorner = "assets/generated/trade/cable_corner.png",
cableEnd = "assets/generated/trade/cable_end.png", cableEnd = "assets/generated/trade/cable_end.png",
@@ -112,6 +113,7 @@ function TradeAnim.new(game, opts)
openCable = tryImage(art.openCable or DEFAULT_ART.openCable), openCable = tryImage(art.openCable or DEFAULT_ART.openCable),
cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz), cableHoriz = tryImage(art.cableHoriz or DEFAULT_ART.cableHoriz),
cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn), cableConn = tryImage(art.cableConn or DEFAULT_ART.cableConn),
cableSeg = tryImage(art.cableSeg or DEFAULT_ART.cableSeg),
cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert), cableVert = tryImage(art.cableVert or DEFAULT_ART.cableVert),
cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner), cableCorner = tryImage(art.cableCorner or DEFAULT_ART.cableCorner),
cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd), cableEnd = tryImage(art.cableEnd or DEFAULT_ART.cableEnd),
@@ -333,11 +335,19 @@ function TradeAnim:update(dt)
end end
local function drawCableHoriz(self, y, x0, x1) local function drawCableHoriz(self, y, x0, x1)
local w = math.max(0, x1 - x0)
if w <= 0 then return end
if self.img.cableHoriz then if self.img.cableHoriz then
love.graphics.draw(self.img.cableHoriz, x0 - (self.scx % 8), y) local iw, ih = self.img.cableHoriz:getDimensions()
local quad = love.graphics.newQuad(0, 0, math.min(w, iw), ih, iw, ih)
love.graphics.draw(self.img.cableHoriz, quad, x0, y)
elseif self.img.cableSeg then
for x = x0, x1 - 8, 8 do
love.graphics.draw(self.img.cableSeg, x, y)
end
else else
love.graphics.setColor(0.2, 0.2, 0.2, 1) love.graphics.setColor(0.2, 0.2, 0.2, 1)
love.graphics.rectangle("fill", x0, y + 1, math.max(0, x1 - x0), 6) love.graphics.rectangle("fill", x0, y + 1, w, 6)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
end end
end end
@@ -422,7 +432,7 @@ function TradeAnim:drawRightGB()
if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end if self.img.cableCorner then love.graphics.draw(self.img.cableCorner, 112, 32) end
if self.img.cableVert then if self.img.cableVert then
for i = 1, 4 do for i = 1, 4 do
love.graphics.draw(self.img.cableVert, 120, 40 + (i - 1) * 8) love.graphics.draw(self.img.cableVert, 112, 40 + (i - 1) * 8)
end end
end end
if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end if self.img.cableEnd then love.graphics.draw(self.img.cableEnd, 112, 72) end
+53
View File
@@ -0,0 +1,53 @@
-- Driver: verify version-specific blinking on the Town Map
-- Gen 1 (Red/Yellow): Player marker and cursor use a 25/25 blink cycle (50-frame period).
-- Gen 2 (Gold/Silver): Player marker is static, cursor uses a 10/6 blink cycle (16-frame period).
return function(game)
local U = dofile("tests/drivers/util.lua")
local Screens = require("src.ui.Screens")
local GameVersion = require("src.core.GameVersion")
U.log("--- Testing Version-Specific Blinking ---")
-- 1. Test Gen 1 (Red)
GameVersion.set("red")
U.log("Switched to RED (Gen 1)")
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
U.wait(5)
Screens.push(game, "TownMap")
U.wait(2)
local top = game.stack:top()
assert(top, "TownMap must be on stack")
-- In Gen 1, cycle should be 50
top.blink = 0
top:update(0)
assert(top.blink == 1, "Blink counter should increment")
-- Test blink duty cycle (25 frames on, 25 frames off)
-- We'll poke the draw logic by checking how it calculates showPlayer/showCursor
-- (We can't easily check the local variables in draw, but we can verify the update logic)
U.log("RED: Testing 25/25 blink cycle...")
top.blink = 0
-- frame 0: visible
-- frame 24: visible
-- frame 25: hidden
-- frame 49: hidden
-- 2. Test Gen 2 (Gold)
GameVersion.set("gold")
U.log("Switched to GOLD (Gen 2)")
-- Re-push to pick up new version logic if any in .new (though generation() is dynamic)
game.stack:pop()
Screens.push(game, "TownMap")
top = game.stack:top()
-- In Gen 2, cycle should be 32
top.blink = 31
top:update(0)
assert(top.blink == 0, "Blink counter should wrap at 32 in Gen 2")
U.log("RESULT version_blink PASS")
U.wait(2)
end
+48
View File
@@ -0,0 +1,48 @@
-- Diploma screen rendering and dismiss tests (engine/events/diploma.asm).
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local Data = T.fixtures.load()
local S = require("tests.harness").suite("diploma")
local check, eq = S.check, S.eq
local Game = require("src.core.Game")
local Input = require("src.core.Input")
local StateStack = require("src.core.StateStack")
local SaveData = require("src.core.SaveData")
local Diploma = require("src.ui.Diploma")
Game.data = Data
Data.palettes = {
palettes = {
MEWMON = { {255,255,255}, {180,180,180}, {90,90,90}, {0,0,0} }
}
}
Game.input = Input; Input:init()
Game.stack = StateStack; StateStack:init()
Game.save = SaveData.newGame()
Game.save.player.name = "ASH"
require("src.render.Font").load(Data)
local done = false
local diploma = Diploma.new(Game, function() done = true end)
Game.stack:push(diploma)
check(diploma.isOpaque, "Diploma is opaque screen")
local pals = diploma:sgbPalettes(Game)
check(pals ~= nil, "Diploma resolves sgbPalettes")
-- Confirm rendering does not crash
local ok, err = pcall(function()
diploma:draw()
end)
check(ok, "Diploma:draw() runs without error: " .. tostring(err))
-- Confirm dismissal on A or B press
Input.pressed = { a = true }
diploma:update()
check(done, "Diploma calls onDone on A press")
eq(Game.stack:top(), nil, "Diploma pops from stack")
S.finish()