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()
+109 -18
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)
local frame = loadFrame()
local circle = tryImage("assets/generated/trainer_card/circle_tile.png")
-- 1. Outer ornate frame border: hlcoord 0, 0 / bc 16, 18 -> (0, 0, 20, 18)
drawFrameBox(frame, 0, 0, 20, 18)
-- 2. Draw Player character sprite: farcall DrawPlayerCharacter
-- Shifted +33 px right from title screen base (82 + 33 = 115, y = 80)
local picPath, picTrueColor = Sprites.playerPath(
game.data, "front", { kind = "diploma" })
local pic = tryImage(picPath)
if pic then
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144) love.graphics.draw(pic, 115, 80)
love.graphics.setColor(0, 0, 0, 1) if picTrueColor then
love.graphics.rectangle("line", 2.5, 2.5, 155, 139) PaletteFX.markTrueColor(115, 80, pic:getDimensions())
Font.draw(Strings("<Diploma>"), 40, 16) -- hlcoord 5,2
Font.draw(Strings("Player"), 24, 32) -- hlcoord 3,4
Font.draw(game.save.player.name or "RED", 80, 32) -- hlcoord 10,4
local congrats = { -- hlcoord 2,6
"Congrats! This", "diploma certifies", "that you have",
"completed your", "POKéDEX.",
}
for i, line in ipairs(congrats) do
Font.draw(Strings(line), 16, 48 + (i - 1) * 10)
end end
Font.draw(Strings("GAME FREAK"), 72, 128) -- hlcoord 9,16 end
-- 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()