diff --git a/src/import/RomExtractorGen3.lua b/src/import/RomExtractorGen3.lua index 6e558291..f742150c 100644 --- a/src/import/RomExtractorGen3.lua +++ b/src/import/RomExtractorGen3.lua @@ -46049,6 +46049,9 @@ RomExtractorGen3.FRLG_NAMING = { GFX = 0xE980E4, BG_MAP = 0xE982BC, KB_MAPS = { upper = 0xE98398, lower = 0xE98458, symbols = 0xE98518 }, MENU_PAL = 0xE98024, KB_PAL = 0xE97FE4, + -- text.c gKeypadIconTiles is a 128x32 4bpp sheet (64 tiles). Naming's + -- WIN_BANNER loads GetTextWindowPalette(2), the third 16-colour row here. + KEYPAD_GFX = 0x1EA700, TEXT_WINDOW_PALS = 0x471DEC, -- naming_screen.c's private rival sheet is a second exact copy of -- gObjectEventPic_Blue (0x38A428) in the retail ROM. Its dedicated palette -- sits between gNamingScreenKeyboard_Pal and gNamingScreenMenu_Pal in @@ -46119,6 +46122,39 @@ function RomExtractorGen3:extractFireRedNaming() end pcall(layer, "bg", N.BG_MAP, true) for key, at in pairs(N.KB_MAPS) do pcall(layer, "kb_" .. key, at, false) end + + -- PrintControls does not draw button-shaped boxes. The {DPAD_ANY}, + -- {A_BUTTON} and {B_BUTTON} control codes call DrawKeypadIcon, which blits + -- these exact pixels from gKeypadIconTiles with colour 0 keyed out. Pull + -- those three icons so the runtime can compose gText_MoveOkBack with the + -- cartridge's own small-font glyphs and keypad art. + pcall(function() + local raw = rom:bytes(N.KEYPAD_GFX, 64 * 32) + local px = RomGba.tiles4bpp(raw, 16, 4) + local palRaw = rom:bytes(N.TEXT_WINDOW_PALS + 2 * 32, 32) + local bannerPal = {} + for i = 0, 15 do + bannerPal[i] = { RomGba.bgr555(palRaw[i * 2 + 1] + palRaw[i * 2 + 2] * 256) } + end + local function keypad(key, tile, w, h) + local sx, sy = (tile % 16) * 8, math.floor(tile / 16) * 8 + local img = ImageWriter.blank(w, h) + for y = 0, h - 1 do + for x = 0, w - 1 do + local v = px[sy + y + 1][sx + x + 1] + local col = v ~= 0 and bannerPal[v] + if col then + img:setPixel(x, y, col[1] / 255, col[2] / 255, col[3] / 255, 1) + end + end + end + save(key, img) + end + keypad("help_a", 0x00, 8, 12) + keypad("help_b", 0x01, 8, 12) + keypad("help_dpad", 0x22, 8, 12) -- CHAR_DPAD_NONE / {DPAD_ANY} + end) + for key, spec in pairs(N.SPRITES) do pcall(function() local at, w, h, bank = spec[1], spec[2], spec[3], spec[4] @@ -46126,14 +46162,21 @@ function RomExtractorGen3:extractFireRedNaming() local raw = rom:bytes(at, cols * rws * 32) local px = RomGba.tiles4bpp(raw, cols, rws) local img = ImageWriter.blank(w, h) + -- SpriteCB_Cursor changes ONLY OBJ palette entry 1 while the keyboard + -- cursor idles. Keep those pixels in a separate white mask so the + -- runtime can replace that one colour every frame without tinting the + -- rest of the cartridge-ripped cursor art. + local pulse = key == "cursor" and ImageWriter.blank(w, h) or nil for y = 1, h do for x = 1, w do local v = px[y][x] local col = v ~= 0 and pal[bank * 16 + v] if col then img:setPixel(x - 1, y - 1, col[1] / 255, col[2] / 255, col[3] / 255, 1) end + if pulse and v == 1 then pulse:setPixel(x - 1, y - 1, 1, 1, 1, 1) end end end save(key, img) + if pulse then save("cursor_pulse", pulse) end end) end -- RIVAL's naming icon is not the ordinary object-event sprite at runtime: @@ -46177,9 +46220,15 @@ function RomExtractorGen3:extractFireRedNaming() end) local function c(i) local t = pal[i] return { t[1], t[2], t[3] } end local constants = self._constants or {} + local cursorWord = rom:u16(N.MENU_PAL + 5 * 32 + 2) -- palette 5, entry 1 constants.gen3FRLGNaming = { images = images, rivalIcon = rivalIcon, + cursorPulseBase = { + cursorWord % 32, + math.floor(cursorWord / 32) % 32, + math.floor(cursorWord / 1024) % 32, + }, colors = { fill = { upper = c(10 * 16 + 13), lower = c(10 * 16 + 14), symbols = c(10 * 16 + 15) }, key = { c(10 * 16 + 1), c(10 * 16 + 2) }, diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 8296bf5d..151866cc 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -1222,7 +1222,12 @@ end -- the one gate that forces a re-import, and main's change alters what a -- FireRed cache CONTAINS -- so keeping v359 would let a FireRed cache -- built before the tutor fix pass as current. v360 invalidates both. -local CACHE_FORMAT = "rom-cache-v360:" +-- v361: FireRed naming fidelity now extracts the cartridge's keypad icons and +-- the cursor-pulse mask/base colour. An older cache has none of those +-- files/fields, so without a format bump an updated runtime would keep +-- drawing the pre-fix handmade help icons and static selection cursor +-- until the user happened to force a ROM re-import manually. +local CACHE_FORMAT = "rom-cache-v361:" -- The completion marker is written under each version's cache prefix -- (rom-cache.complete for Red, blue/rom-cache.complete for Blue). local MARKER_PATH = "rom-cache.complete" diff --git a/src/render/Font.lua b/src/render/Font.lua index 4145dc77..a27c4f25 100644 --- a/src/render/Font.lua +++ b/src/render/Font.lua @@ -799,11 +799,20 @@ local function frameIndex(def) return want end -local function sheetFrame(tx, ty, tw, th) +local function sheetFrame(tx, ty, tw, th, frameOverride) local def = state and state.def or {} local quads, image = frameQuads(def) if not quads then return false end - local set = quads[frameIndex(def)] + local want = tonumber(frameOverride) + if want then + want = math.floor(want) + local count = (def.frames and def.frames.count) or 1 + if want < 1 then want = 1 end + if want > count then want = count end + else + want = frameIndex(def) + end + local set = quads[want] if not set then return false end local tile = (def.frames.tile) or 8 local x, y = tx * 8, ty * 8 @@ -844,11 +853,11 @@ local function drawnFrame(tx, ty, tw, th, style) end -- Draw a Game Boy style bordered box in tile coordinates. -function Font.drawBox(tx, ty, tw, th) +function Font.drawBox(tx, ty, tw, th, frameOverride) if not FALLBACK.enabled and state and state.def then -- the cartridge's own nine-slice first; the drawn rectangle is what a -- dataset without one falls back to - if state.def.frames and sheetFrame(tx, ty, tw, th) then return end + if state.def.frames and sheetFrame(tx, ty, tw, th, frameOverride) then return end if state.def.frame == "drawn" or state.def.frame == "sheet" then return drawnFrame(tx, ty, tw, th, activeStyle) end diff --git a/src/ui/Gen3OakSpeechFRLG.lua b/src/ui/Gen3OakSpeechFRLG.lua index b67c201a..f32084ce 100644 --- a/src/ui/Gen3OakSpeechFRLG.lua +++ b/src/ui/Gen3OakSpeechFRLG.lua @@ -68,7 +68,11 @@ function Speech.new(game, onDone) self.black = 1 -- whole-screen fade to black self.picAlpha = 0 -- trainer pic + platform self.picTarget = 0 - self.picOffset = 0 -- slide left while the name list is up + -- Visual offset of BG2 trainer art + the platform OBJ sprites. Oak speech's + -- task variable counts DOWN to -60, but BG2 HOFS=-60 displays the bitmap + -- sixty pixels to the RIGHT, while gSpriteCoordOffsetX is explicitly +60. + -- Store the screen-space result here so draw() does not invert it again. + self.picOffset = 0 self.picScale = 1 self.picWhite = 0 self.nidoAlpha, self.nidoScale, self.nidoWhite = 0, 0, 0 @@ -199,6 +203,7 @@ end function Speech:menu(labels, opts) local Menu = require("src.ui.Menu") local choice + opts = opts or {} local items = {} for i, label in ipairs(labels) do items[i] = { label = label, onSelect = function() @@ -207,6 +212,15 @@ function Speech:menu(labels, opts) self.waitFrames = 1 end } end + -- oak_speech.c passes WindowTemplate CONTENT coordinates to these menus. + -- Their frame is one tile outside the template on every side and every row + -- starts at y=1 with a 16-pixel pitch. A fresh FireRed save has Frame Type + -- 1 (optionsWindowFrameType=0), so keep the professor intro on that frame + -- even though this port deliberately persists desktop options across New + -- Game. + opts.gbaWindow = true + opts.th = opts.th or (#labels * 2) + opts.frameIndex = 1 opts.cancelable = opts.onCancelIndex ~= nil if opts.onCancelIndex then opts.onCancel = function() choice = opts.onCancelIndex; self.waitFrames = 1 end @@ -366,7 +380,7 @@ function Speech:run() while true do box = self:ask(question) question = "rivalNameAgain" - if self.picOffset > -60 then self:tween("picOffset", -60, 2) end + if self.picOffset < 60 then self:tween("picOffset", 60, 2) end local labels = { clean(self.text.newName) or Strings("NEW NAME") } for i = 1, 4 do labels[#labels + 1] = rivals[i] end local pick = self:menu(labels, { tx = 2, ty = 2, tw = 12 }) diff --git a/src/ui/Menu.lua b/src/ui/Menu.lua index 36111815..e9fc095c 100644 --- a/src/ui/Menu.lua +++ b/src/ui/Menu.lua @@ -19,6 +19,13 @@ function Menu.new(game, items, opts) self.tx = opts.tx or 10 self.ty = opts.ty or 0 self.tw = opts.tw or 10 + -- Gen 3 WindowTemplate coordinates describe the CONTENT rectangle. Its + -- standard frame is drawn one tile outside on every side. Most of this + -- engine's menus use the older convention where tx/ty/tw/th already name + -- the OUTER framed rectangle, so keep that as the default and opt the few + -- cartridge-window callers into the GBA convention explicitly. + self.gbaWindow = opts.gbaWindow or false + self.frameIndex = opts.frameIndex -- grow the box to the widest label so longer (e.g. localized) labels don't -- overflow the frame; nudge tx left to keep the box on-screen (20 tiles). do @@ -30,7 +37,7 @@ function Menu.new(game, items, opts) end end local needed = widest + 3 - if needed > self.tw then self.tw = needed end + if not self.gbaWindow and needed > self.tw then self.tw = needed end -- ...ON THE SCREEN THAT IS ACTUALLY THERE. Twenty tiles is the Game Boy's -- width and was written in here as a constant; Hoenn's screen is thirty, -- so a box the cartridge places near the right-hand side was shoved left @@ -38,7 +45,7 @@ function Menu.new(game, items, opts) -- for, and it returns the Game Boy's own 160 for Gen 1 and Gen 2, so -- nothing there moves. local cols = math.floor(select(1, Theme.uiSize()) / 8) - if self.tx + self.tw > cols then + if not self.gbaWindow and self.tx + self.tw > cols then self.tx = math.max(0, cols - self.tw) end end @@ -157,11 +164,16 @@ function Menu:draw() -- edge (the START menu asks for "topright"). Only menus that ask for it -- move; every other menu is placed exactly as before. local r = self.anchor and self.game and self.game.renderer + local frameTx, frameTy, frameTw, frameTh = self.tx, self.ty, self.tw, self.th + if self.gbaWindow then + frameTx, frameTy = self.tx - 1, self.ty - 1 + frameTw, frameTh = self.tw + 2, self.th + 2 + end if r and r.setUIAnchor then - r:setUIAnchor(self.tx * 8, self.ty * 8, - self.tw * 8, self.th * 8, self.anchor) + r:setUIAnchor(frameTx * 8, frameTy * 8, + frameTw * 8, frameTh * 8, self.anchor) end - Font.drawBox(self.tx, self.ty, self.tw, self.th) + Font.drawBox(frameTx, frameTy, frameTw, frameTh, self.frameIndex) love.graphics.setColor(0, 0, 0, 1) local visible = (self.maxVisible and math.min(self.maxVisible, #self.items)) or #self.items @@ -186,6 +198,24 @@ function Menu:draw() -- overhang, which is nothing at all for an 8-pixel font: every Game Boy -- menu in this engine draws where it always did, and a taller face lands -- with its last descender flush on the inner edge. + if self.gbaWindow then + -- FireRed Menu_InitCursor + AddTextPrinterParameterized3: the selector is + -- at x=0 in the window, labels at x=8, both start one pixel down, and rows + -- are sixteen pixels apart in the Oak intro menus. + for row = 1, visible do + local item = self.items[self.scroll + row] + if not item then break end + local y = self.ty * 8 + 1 + (row - 1) * self.rowStep * 8 + Font.draw(item.label, self.tx * 8 + 8, y) + end + local cursorRow = self.index - self.scroll + Font.drawCode(Theme.cursor, self.tx * 8, + self.ty * 8 + 1 + (cursorRow - 1) * self.rowStep * 8) + self:drawDescription() + love.graphics.setColor(1, 1, 1, 1) + return + end + local lastY = (self.ty + self.th - 2) * 8 local lift = math.max(0, (lastY + Font.glyphHeight()) - (self.ty + self.th - 1) * 8) diff --git a/src/ui/NamingScreen.lua b/src/ui/NamingScreen.lua index c848b32d..a58113f6 100644 --- a/src/ui/NamingScreen.lua +++ b/src/ui/NamingScreen.lua @@ -767,25 +767,30 @@ function NamingScreen:drawFireRed(rec) local bg = self:frlgImage("bg") if bg then g.draw(bg, 0, 0) end - -- the banner: window (0,0) 30x2, "{DPAD}MOVE {A}OK {B}BACK" right-aligned + -- the banner: PrintControls renders the exact + -- "{DPAD_ANY}MOVE {A_BUTTON}OK {B_BUTTON}BACK" string in FONT_SMALL and + -- right-aligns it four pixels from the edge. The control codes are bitmap + -- keypad icons, not outlined button boxes. g.setColor(0, 123 / 255, 197 / 255, 1) g.rectangle("fill", 0, 0, 240, 16) local white, darkGray = { 1, 1, 1, 1 }, { 98 / 255, 98 / 255, 98 / 255, 1 } - local hints = { { "+", Strings("MOVE") }, { "A", Strings("OK") }, { "B", Strings("BACK") } } + local help = { + { self:frlgImage("help_dpad"), Strings("MOVE") .. " " }, + { self:frlgImage("help_a"), Strings("OK") .. " " }, + { self:frlgImage("help_b"), Strings("BACK") }, + } local faced = Font.hasFace and Font.hasFace("small") and Font.pushFace("small") local width = 0 - for _, h in ipairs(hints) do width = width + 12 + Font.width(h[2]) + 4 end - if faced then Font.popFace() end - local hx = 240 - 4 - width + 4 - for _, h in ipairs(hints) do - g.setColor(white) - g.rectangle("line", hx + 0.5, 2.5, 10, 9, 3, 3) - text(h[1], hx + 2, 0, white, darkGray, true) - text(h[2], hx + 12, 0, white, darkGray, true) - local f2 = Font.hasFace and Font.hasFace("small") and Font.pushFace("small") - hx = hx + 12 + Font.width(h[2]) + 4 - if f2 then Font.popFace() end + for _, h in ipairs(help) do width = width + 8 + Font.width(h[2]) end + local hx = 240 - 4 - width + for _, h in ipairs(help) do + g.setColor(1, 1, 1, 1) + if h[1] then g.draw(h[1], hx, 0) end + hx = hx + 8 + text(h[2], hx, 0, white, darkGray, false) + hx = hx + Font.width(h[2]) end + if faced then Font.popFace() end -- the question and the typed name, on the plate BG3 already draws local entryInk = cc.entry and rgb(cc.entry[2]) or darkGray @@ -799,9 +804,13 @@ function NamingScreen:drawFireRed(rec) if glyph then text(Strings(glyph), x, 48 + 1, entryInk, entryShadow) end if underscore then g.draw(underscore, x + 3 - 4, 60 - 4) end end - local at = math.min(#self.glyphs + 1, self.maxLen) - if arrow and math.floor((self.blink or 0) / 30) % 2 == 0 then - g.draw(arrow, base + (at - 1) * 8 - 4, 56 - 4) + if arrow then + -- CreateTextEntrySprites puts this sprite at centre (base-5, 56), and it + -- stays there regardless of how many letters are already typed. Its + -- callback cycles x2={0,-4,-2,-1} every eight frames; it does not blink. + local wobble = { 0, -4, -2, -1 } + local wi = math.floor((self.blink or 0) / 8) % #wobble + 1 + g.draw(arrow, base - 9 + wobble[wi], 56 - 4) end -- the page: BG1's frame, the window's fill, the keys @@ -856,7 +865,25 @@ function NamingScreen:drawFireRed(rec) -- the cursor, on a key local cursor = self:frlgImage("cursor") if cursor and self.row <= m.keyRows then - g.draw(cursor, 30 + (cols[self.col] or 0), 80 + (self.row - 1) * 16) + local x, y = 30 + (cols[self.col] or 0), 80 + (self.row - 1) * 16 + g.setColor(1, 1, 1, 1) + g.draw(cursor, x, y) + local pulse = self:frlgImage("cursor_pulse") + local base5 = rec.cursorPulseBase + if pulse and type(base5) == "table" then + -- SpriteCB_Cursor advances 2/4/.../16/14/.../0, each value lasting + -- two frames. MultiplyInvertedPaletteRGBComponents moves green/blue + -- toward white by that amount and red by half as much. + local k = math.floor((self.blink or 0) / 2) % 16 + local n = (k < 8) and ((k + 1) * 2) or ((15 - k) * 2) + local function brighten(v, amount) + v = tonumber(v) or 0 + return (v + math.floor((31 - v) * amount / 16)) / 31 + end + g.setColor(brighten(base5[1], math.floor(n / 2)), + brighten(base5[2], n), brighten(base5[3], n), 1) + g.draw(pulse, x, y) + end end g.setColor(1, 1, 1, 1) end