G2 support

This commit is contained in:
bryanthaboi
2026-08-11 11:52:56 -04:00
parent 79ed37699e
commit ae6cac89e1
489 changed files with 226677 additions and 1798 deletions
+162 -38
View File
@@ -20,6 +20,11 @@ local Font = {}
local GLYPH = 8
-- engine/gfx/load_font.asm:29 LoadFrame: TEXTBOX_FRAME_TILES tiles at $79, one
-- row of Frames per wTextboxFrame.
local FRAME_BASE = 0x79
local FRAME_TILES = 6
-- TTF glyph codes are the Unicode codepoint offset far above any page base,
-- so they flow through the same span/encode/drawCode pipeline as tiles.
local TTF_BASE = 0x400000
@@ -35,6 +40,7 @@ Font.PLAINPIXEL_SIZE = 15
local state
local loadedFrom
local currentFrame = 1
-- the two vanilla pages as the legacy def spells them, so a cache that
-- predates the pages table still loads and a mod that registers only one
@@ -49,6 +55,17 @@ local function pagesOf(def)
pages.extra = { image = def.imageExtra, base = def.extraBase or 0x60,
glyphsPerRow = def.glyphsPerRow or 16 }
end
-- Gen 2 keeps two sheets for the same VRAM slot: LoadFontsExtra puts
-- FontExtra at $60 and LoadFontsBattleExtra puts FontBattleExtra there
-- instead. They are genuinely different glyphs -- $6e is "Lv" in one and
-- the bold ":L" in the other -- so the battle sheet is loaded as its own
-- page and Font.useBattleExtra swaps which one $60 resolves to.
if def.imageBattleExtra then
pages.battleExtra = { image = def.imageBattleExtra,
base = def.extraBase or 0x60,
glyphsPerRow = def.glyphsPerRow or 16,
inactive = true }
end
for id, page in pairs(def.pages or {}) do
if type(page) == "table" and page.image then pages[id] = page end
end
@@ -100,9 +117,34 @@ function Font.load(data)
local entry = { id = id, image = img, quads = quads,
base = page.base, advance = page.advance or GLYPH }
state.pages[id] = entry
state.order[#state.order + 1] = entry
-- An inactive page is loaded but not in the resolution order until
-- something swaps it in; see Font.useBattleExtra.
if not page.inactive then
state.order[#state.order + 1] = entry
end
end
end
-- Frames as its own sheet, one row per style (gfx/font.asm:10). Without it
-- the extra page's baked-in row 0 answers $79-$7e, which is frame 1.
state.frameBase = def.frameBase or FRAME_BASE
state.frameTiles = def.frameTiles or FRAME_TILES
if def.imageFrames then
local ok, img = pcall(Assets.image, def.imageFrames)
if ok and img then
local iw, ih = img:getDimensions()
state.framePages = {}
for row = 0, math.floor(ih / GLYPH) - 1 do
local quads = {}
for t = 0, state.frameTiles - 1 do
quads[t] = love.graphics.newQuad(t * GLYPH, row * GLYPH,
GLYPH, GLYPH, iw, ih)
end
state.framePages[row + 1] = { id = "frames", image = img,
quads = quads, base = state.frameBase, advance = GLYPH }
end
end
end
-- highest base first: a code resolves against the last page that starts
-- at or below it, which is exactly what the old main/extra chain did
table.sort(state.order, function(a, b) return a.base > b.base end)
@@ -188,15 +230,61 @@ end
Assets.register(Font.invalidate)
-- How many tiles LoadFontsBattleExtra actually swaps: `lb bc, BANK(...), 25`
-- covers $60-$78 and then `jr LoadFrame` puts the textbox frame back at
-- $79-$7e and the blank at $7f (engine/gfx/load_font.asm). The border glyphs
-- are therefore the SAME tiles on a battle-sheet screen as everywhere else,
-- which is why the swap stops short of them.
local BATTLE_EXTRA_TILES = 25
-- the page a glyph code draws from, or nil when nothing covers it
local function pageFor(code)
if not state then return nil end
-- LoadFrame runs after both extra sheets, so $79-$7e is the selected frame
-- whatever else holds the $60 slot (load_font.asm:20, :27).
local frames = state.framePages
if frames and code >= state.frameBase
and code < state.frameBase + state.frameTiles then
local page = frames[currentFrame]
if page then return page end
end
if state.battleExtra and state.pages.battleExtra then
local swap = state.pages.battleExtra
if code >= swap.base and code < swap.base + BATTLE_EXTRA_TILES then
return swap
end
end
for _, page in ipairs(state.order) do
if code >= page.base then return page end
end
return nil
end
-- LoadFontsBattleExtra / LoadFontsExtra: which sheet the $60-$7f slot holds.
-- The battle screen and the party menu load the battle sheet, everything else
-- the normal one. Returns the previous setting so a caller can restore it.
function Font.useBattleExtra(on)
if not state then return false end
local was = state.battleExtra or false
state.battleExtra = on and true or false
return was
end
function Font.battleExtraActive()
return state ~= nil and state.battleExtra == true
end
-- engine/menus/options_menu.asm:475 UpdateFrame -> LoadFontsExtra -> LoadFrame.
-- Module state, not `state`: applyOptions runs before Font.load on boot
-- (src/core/Game2.lua Game2:load).
function Font.setFrame(index)
currentFrame = math.floor(tonumber(index) or 1)
end
function Font.frameIndex()
return currentFrame
end
local SPACE = 0x7F
-- Decode one UTF-8 sequence: codepoint and the index of its last byte, or
@@ -242,6 +330,15 @@ local function ttfChar(ttf, code)
return ch
end
-- Text commands that place a fixed string rather than one tile. '#' is
-- charmap.asm $54, and home/text.asm's handler for it writes the four
-- characters "POKé" -- which is why the cart's own strings spell POKéMON as
-- `db " #MON"` (data/credits_strings.asm Credits_Staff) and why a
-- hand-ported string in this port may too. One glyph per replacement
-- character comes back out of Font.split, all of them pinned to the byte the
-- command sits on so a cut never lands inside the expansion.
local MACRO_TEXT = { ["#"] = "POK\xc3\xa9" }
-- Segment text into glyph spans: `{ from, to, code }` byte ranges, one per
-- drawn glyph, code nil when the charmap has nothing. A span is a whole
-- charmap sequence, so a multi-byte char ("é", "♂") and an ASCII ligature
@@ -260,46 +357,58 @@ function Font.split(text)
local ttf = state and state.ttf
local i, n = 1, #text
while i <= n do
local span
local candidates = state and state.byFirstByte[text:byte(i)]
if candidates then
for _, entry in ipairs(candidates) do
local len = #entry.seq
if text:sub(i, i + len - 1) == entry.seq then
if ttf and not ttf.tiles[entry.seq] then
-- single characters belong to the TTF; only multi-character
-- sequences (ligatures, <PK> macros) keep their tile mapping,
-- plus anything the mod named in ttf.tiles (see Font.load)
local cp, last = utf8Decode(entry.seq, 1)
if cp and last == len then break end
-- A charmap entry for the same byte wins: a font that ships '#' as a real
-- glyph is describing its own sheet, and the macro is only the fallback
-- the vanilla charmap leaves room for.
local macro = not (state and state.byFirstByte[text:byte(i)])
and MACRO_TEXT[text:sub(i, i)]
if macro then
for _, sub in ipairs(Font.split(macro)) do
spans[#spans + 1] = { from = i, to = i, code = sub.code }
end
i = i + 1
else
local span
local candidates = state and state.byFirstByte[text:byte(i)]
if candidates then
for _, entry in ipairs(candidates) do
local len = #entry.seq
if text:sub(i, i + len - 1) == entry.seq then
if ttf and not ttf.tiles[entry.seq] then
-- single characters belong to the TTF; only multi-character
-- sequences (ligatures, <PK> macros) keep their tile mapping,
-- plus anything the mod named in ttf.tiles (see Font.load)
local cp, last = utf8Decode(entry.seq, 1)
if cp and last == len then break end
end
span = { from = i, to = i + len - 1, code = entry.code }
break
end
span = { from = i, to = i + len - 1, code = entry.code }
break
end
end
end
if not span and ttf then
local cp, last = utf8Decode(text, i)
if cp and cp >= 0x20 then
span = { from = i, to = last, code = TTF_BASE + cp }
end
end
if not span then
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
-- lands mid-character even for a glyph we cannot draw.
local last = i
if text:byte(i) >= 0xC0 then
local k = i + 1
while k <= n do
local b = text:byte(k)
if b < 0x80 or b > 0xBF then break end
last, k = k, k + 1
if not span and ttf then
local cp, last = utf8Decode(text, i)
if cp and cp >= 0x20 then
span = { from = i, to = last, code = TTF_BASE + cp }
end
end
span = { from = i, to = last }
if not span then
-- Nothing matched. Still keep a UTF-8 sequence whole, so a cut never
-- lands mid-character even for a glyph we cannot draw.
local last = i
if text:byte(i) >= 0xC0 then
local k = i + 1
while k <= n do
local b = text:byte(k)
if b < 0x80 or b > 0xBF then break end
last, k = k, k + 1
end
end
span = { from = i, to = last }
end
spans[#spans + 1] = span
i = span.to + 1
end
spans[#spans + 1] = span
i = span.to + 1
end
return spans
end
@@ -406,8 +515,19 @@ Font.BORDER = {}
for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
-- Draw a Game Boy style bordered box in tile coordinates.
function Font.drawBox(tx, ty, tw, th)
-- The white interior is a fill, so it needs the color; everything after it
--
-- `fill` is an optional {r,g,b} in 0..255 for the interior. White is the
-- right answer everywhere in Gen 1 and on nearly every Gold screen, because
-- the box is drawn from font-page tiles ($79-$7e plus the ' ' $7f interior,
-- all >= $60) and those take BG palette 0, whose colour 0 is white there. A
-- host screen whose palette 0 colour 0 is NOT white has to say so: the
-- Pokegear's is `RGB 28, 31, 20` and its tile-attribute map sends everything
-- >= $60 to palette 0 (pokegold engine/pokegear/pokegear.asm TownMapPals,
-- gfx/pokegear/pokegear.pal), so a box pushed over the gear must come out on
-- the gear's cream paper, not as a white band. Default stays white so no
-- existing call site changes.
function Font.drawBox(tx, ty, tw, th, fill)
-- The interior is a fill, so it needs the color; everything after it
-- is a glyph and needs the caller's. Restoring is not cosmetic: the tile
-- pages are black glyphs on transparent, so they come out black whatever
-- the color is, and leaking white here was invisible for as long as every
@@ -416,7 +536,11 @@ function Font.drawBox(tx, ty, tw, th)
-- white. On the summary screen that erased ATTACK/DEFENSE/SPEED/SPECIAL
-- and TYPE1/TYPE2 while the numbers beside them, still tiles, stayed put.
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(1, 1, 1, 1)
if type(fill) == "table" and fill[1] and fill[2] and fill[3] then
love.graphics.setColor(fill[1] / 255, fill[2] / 255, fill[3] / 255, 1)
else
love.graphics.setColor(1, 1, 1, 1)
end
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
love.graphics.setColor(r, g, b, a)
local B = Font.BORDER
+434
View File
@@ -0,0 +1,434 @@
-- Draws a 4-shade GB image through a GBC palette.
--
-- Every 2bpp sheet the importer writes is grayscale: shade 0 is white
-- (or transparent), shade 3 is black (ImageWriter.SHADES). A GBC game picks
-- four real colors per tile instead, so this recovers the shade index from the
-- red channel and substitutes the palette entry -- one shader for map tiles,
-- OW sprites, battle pics and menu chrome alike.
--
-- Shade recovery is exact rather than approximate: the source values are
-- 1, 2/3, 1/3, 0, so rounding (1 - r) * 3 lands on 0..3 with a half-step of
-- headroom either side, which survives texture filtering set to nearest.
--
-- Alpha passes through untouched, so a sheet written with transparent shade 0
-- (OW sprites, the font) keeps its cutout.
-- COLOR (the port's own display option, Gold's answer to the Gen 1 COLORS
-- row). Every colour in the Gen 2 port arrives here, because a CGB game's
-- colour IS its palettes -- so one substitution at this seam recolours the
-- whole game without a single screen knowing about it:
--
-- GBC the cart's own palettes. The default: this is a Game Boy
-- Color game and its colour is the point.
-- DMG the original grey Game Boy. Every palette collapses to the
-- four hardware shades, and the sheets that are drawn straight
-- (chrome, text) are already those shades, so the screen is
-- uniformly monochrome.
-- CLASSIC the DMG pea-soup green. Rendered as DMG and then run through
-- the green ramp as a final full-screen pass, which is what
-- GbcPalette.presentColors is for -- a shade map is exactly what
-- this shader already does, so it needs no second shader.
local GbcPalette = {}
GbcPalette.MODES = { "gbc", "dmg", "classic" }
GbcPalette.MODE_LABELS = { gbc = "GBC", dmg = "DMG", classic = "CLASSIC" }
GbcPalette.mode = "gbc"
-- rBGP's four shades as the hardware shows them.
local DMG_SHADES = {
{ 255, 255, 255 }, { 170, 170, 170 }, { 85, 85, 85 }, { 0, 0, 0 },
}
-- #9BBC0F / #8BAC0F / #306230 / #0F380F, the same ramp Gen 1's CLASSIC uses.
local CLASSIC_SHADES = {
{ 155, 188, 15 }, { 139, 172, 15 }, { 48, 98, 48 }, { 15, 56, 15 },
}
local SHADER_SOURCE = [[
extern vec3 pal0;
extern vec3 pal1;
extern vec3 pal2;
extern vec3 pal3;
vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) {
vec4 px = Texel(tex, uv);
float shade = floor((1.0 - px.r) * 3.0 + 0.5);
vec3 rgb = pal0;
if (shade > 2.5) {
rgb = pal3;
} else if (shade > 1.5) {
rgb = pal2;
} else if (shade > 0.5) {
rgb = pal1;
}
return vec4(rgb, px.a) * tint;
}
]]
-- rBGP, the DMG background palette register, as a remap of an ALREADY DRAWN
-- texture.
--
-- A frame this port has finished drawing holds CGB colours and no shade index
-- any more, so the remap has to run backwards: match the pixel to the palette
-- entry that produced it, then substitute whatever the rBGP byte sends that
-- entry to. With one palette that is exact, because the four entries are the
-- only colours the texture can hold; a composited frame is only as exact as
-- GbcPalette.remapTable's dedupe (see the `ambiguous` count there).
--
-- `remapTol` is a SQUARED distance and it is what keeps this pass off pixels
-- that were never a palette colour -- a letterboxed border, a linear-filtered
-- resample -- rather than snapping them to the nearest entry. Palette colours
-- land on exact 1/255 steps through a nearest-filtered canvas, so the default
-- is a couple of steps of headroom and nothing like the gap between two
-- entries of the same palette.
local REMAP_SOURCE = [[
extern int remapCount;
extern float remapTol;
extern vec3 remapSrc[32];
extern vec3 remapDst[32];
vec4 effect(vec4 tint, Image tex, vec2 uv, vec2 screen) {
vec4 px = Texel(tex, uv);
vec3 mapped = px.rgb;
float best = remapTol;
for (int i = 0; i < 32; i++) {
if (i >= remapCount) { break; }
vec3 d = px.rgb - remapSrc[i];
float dist = dot(d, d);
if (dist < best) {
best = dist;
// Indexed by the LOOP variable and never by a value carried out of the
// loop: GLSL ES 1.00 only allows a constant-index-expression into a
// uniform array, which a loop counter is and `best`'s winner is not.
mapped = remapDst[i];
}
}
return vec4(mapped, px.a) * tint;
}
]]
-- The compiled-in array length above. A map's eight BG palettes are 32
-- colours before dedupe, which is the worst case this has to hold.
GbcPalette.REMAP_MAX = 32
-- Squared RGB distance, in 0..1 units: three 8-bit steps.
GbcPalette.REMAP_TOLERANCE = (3 / 255) ^ 2
local shader = nil
local failed = false
local remapShader = nil
local remapFailed = false
-- nil (and a one-shot warning) if shaders are unavailable, so callers can fall
-- back to the plain grayscale draw instead of crashing a whole boot.
function GbcPalette.shader()
if shader or failed then return shader end
if not (love and love.graphics and love.graphics.newShader) then
failed = true
return nil
end
local ok, result = pcall(love.graphics.newShader, SHADER_SOURCE)
if not ok then
failed = true
return nil
end
shader = result
return shader
end
-- The same contract as GbcPalette.shader for the backwards pass: nil rather
-- than an error, so a caller can fall back to its own approximation.
function GbcPalette.remapShader()
if remapShader or remapFailed then return remapShader end
if not (love and love.graphics and love.graphics.newShader) then
remapFailed = true
return nil
end
local ok, result = pcall(love.graphics.newShader, REMAP_SOURCE)
if not ok then
remapFailed = true
return nil
end
remapShader = result
return remapShader
end
function GbcPalette.available()
return GbcPalette.shader() ~= nil
end
local function channel(colors, index)
local c = colors and colors[index]
if not c then return 0, 0, 0 end
return (c[1] or 0) / 255, (c[2] or 0) / 255, (c[3] or 0) / 255
end
-- What a palette actually draws as under the current COLOR mode. Anything
-- that reads a colour out of a palette directly -- a canvas cleared to BG
-- colour 0, say -- has to go through this too, or the backdrop would keep its
-- cart colour while everything on top of it went grey.
function GbcPalette.resolve(colors)
if GbcPalette.mode == "gbc" then return colors end
return DMG_SHADES
end
--------------------------------------------------------------------------
-- rBGP as data
--------------------------------------------------------------------------
-- %11100100. The `dc` macro emits colour 3 FIRST, so `dc 3, 2, 1, 0` packs to
-- $e4 and reads back as "colour i shows shade i": the identity.
GbcPalette.BGP_IDENTITY = 0xe4
-- The byte's four 2-bit fields, colour 0 in the low bits, returned 1-based so
-- shades[i + 1] is the shade colour i shows.
function GbcPalette.bgpShades(byte)
byte = byte or GbcPalette.BGP_IDENTITY
local shades = {}
for index = 0, 3 do
shades[index + 1] = math.floor(byte / (4 ^ index)) % 4
end
return shades
end
-- CopyPals (home/palettes.asm), which is the whole of what DmgToCgbBGPals does
-- on a CGB: a palette's four entries are REORDERED by the rBGP byte, so a pixel
-- drawn as colour i comes back as colour bgp(i) OF ITS OWN PALETTE.
--
-- This is why a brightness veil can never be right and this table can: $f9
-- (`dc 3, 3, 2, 1`) means "one step darker along this palette's own ramp", and
-- no two palettes have the same ramp. Returns `colors` itself for the identity
-- so the common case allocates nothing.
function GbcPalette.remap(colors, byte)
if not colors then return nil end
if not byte or byte == GbcPalette.BGP_IDENTITY then return colors end
local shades = GbcPalette.bgpShades(byte)
local out = {}
for index = 1, 4 do
out[index] = colors[shades[index] + 1] or colors[4]
end
return out
end
-- The rBGP byte every subsequent GbcPalette.use / .with / .color folds in, or
-- nil for the identity.
--
-- This is the FORWARD half of the register and the exact one: a screen that is
-- still being drawn can take the permutation on its palettes before they ever
-- reach the shader, which is bit for bit DmgToCgbBGPals. Only a frame that is
-- already baked (World's map canvas) needs the backwards pass above.
--
-- GbcPalette.useRaw deliberately does NOT fold it in: the CLASSIC present pass
-- goes through useRaw and is not a BG palette, so a byte left standing there
-- would permute the green ramp itself.
GbcPalette.bgp = nil
-- Set the active byte, returning the previous one so a caller can restore it.
-- The identity is stored as nil, so `setBgp($e4)` is the same as clearing it.
function GbcPalette.setBgp(byte)
local previous = GbcPalette.bgp
if byte == GbcPalette.BGP_IDENTITY then byte = nil end
GbcPalette.bgp = byte
return previous
end
-- One colour out of a palette, mode and active rBGP byte applied. `index` is
-- 1-based.
function GbcPalette.color(colors, index)
local resolved = GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp)
if resolved and resolved[index] then return resolved[index] end
return GbcPalette.remap(DMG_SHADES, GbcPalette.bgp)[index]
end
-- The palette the finished frame is presented through, or nil when the frame
-- is already the right colour. Only CLASSIC needs one: the scene under it has
-- rendered in the four DMG shades, so mapping those to the green ramp is the
-- same shade substitution every other call here makes.
function GbcPalette.presentColors()
if GbcPalette.mode ~= "classic" then return nil end
return CLASSIC_SHADES
end
function GbcPalette.setMode(mode)
for _, name in ipairs(GbcPalette.MODES) do
if name == mode then
GbcPalette.mode = mode
return mode
end
end
GbcPalette.mode = "gbc"
return GbcPalette.mode
end
function GbcPalette.modeLabel(mode)
return GbcPalette.MODE_LABELS[mode or GbcPalette.mode] or "GBC"
end
-- Advance GBC -> DMG -> CLASSIC -> GBC. `delta` may be -1 to step back, so
-- the OPTION screen's left press walks the ladder the other way.
function GbcPalette.cycle(delta)
local at = 1
for index, name in ipairs(GbcPalette.MODES) do
if name == GbcPalette.mode then at = index break end
end
local count = #GbcPalette.MODES
at = (at - 1 + (delta or 1)) % count + 1
GbcPalette.mode = GbcPalette.MODES[at]
return GbcPalette.mode
end
function GbcPalette.applyOptions(opts)
return GbcPalette.setMode(opts and opts.color or "gbc")
end
-- Point the shader at one 4-color palette. Colors are 0-255 triples, matching
-- palettes.lua. Returns false when there is no shader to configure.
--
-- Mode first, then rBGP: on the hardware the register indexes whatever the
-- palette buffer holds, and in DMG/CLASSIC mode that buffer IS the four grey
-- shades, so remapping the resolved palette is what the DMG itself does.
function GbcPalette.use(colors)
return GbcPalette.useRaw(
GbcPalette.remap(GbcPalette.resolve(colors), GbcPalette.bgp))
end
-- The same, ignoring the COLOR mode. The present pass needs it: it IS the
-- mode, so running its own palette back through resolve would flatten the
-- green ramp to grey and the mode would do nothing.
function GbcPalette.useRaw(colors)
local sh = GbcPalette.shader()
if not sh then return false end
for i = 0, 3 do
local r, g, b = channel(colors, i + 1)
sh:send("pal" .. i, { r, g, b })
end
love.graphics.setShader(sh)
return true
end
function GbcPalette.clear()
if love and love.graphics then love.graphics.setShader() end
end
--------------------------------------------------------------------------
-- The backwards pass: remapping a frame that is already drawn
--------------------------------------------------------------------------
local function colorKey(c)
return math.floor(c[1] or 0) .. "," .. math.floor(c[2] or 0)
.. "," .. math.floor(c[3] or 0)
end
-- Source and destination colour lists for REMAP_SOURCE, deduplicated.
--
-- `bgPalettes` is a LIST OF PALETTES the rBGP byte reaches: DmgToCgbBGPals
-- pushes one byte through all eight BG palettes at once, so they all take the
-- same permutation. `objPalettes` is the list it does NOT reach -- OBJ colours
-- go through the separate DmgToCgbObjPals, which the flash never calls -- and
-- they are here mapping to THEMSELVES, so a sprite's colours are recognised and
-- left alone instead of being matched onto a BG entry and swept along.
--
-- Returns src, dst (0..1 triples, both padded to REMAP_MAX) and two counts:
-- `count`, the live length, and `ambiguous`.
--
-- `ambiguous` is the exact limit of this whole pass, and it is worth naming: a
-- colour that is entry 1 of one palette and entry 2 of another has two right
-- answers, and a composited frame threw away which one this pixel was. The
-- first writer wins, which is why BG palettes are walked first and in slot
-- order -- the reading that matches "the whole picture flashes". Nothing here
-- is approximate when `ambiguous` is 0.
--
-- A map's eight BG palettes are 32 entries, which is REMAP_MAX exactly, so a
-- map whose palettes share nothing at all fills the array and the OBJ list is
-- dropped. BG is walked first for that reason too: losing the sprite guard
-- costs a few sprite pixels, losing a BG palette would cost the effect.
function GbcPalette.remapTable(bgPalettes, byte, objPalettes)
local src, dst = {}, {}
local seen = {}
local ambiguous = 0
local function add(colors, mapped)
if not (colors and mapped) then return end
for index = 1, 4 do
local from, to = colors[index], mapped[index]
if from and to and #src < GbcPalette.REMAP_MAX then
local key = colorKey(from)
local at = seen[key]
if at then
-- Same colour, different destination: the pixel cannot say which
-- palette drew it, so the first answer stands and this is counted.
if colorKey(dst[at]) ~= colorKey(to) then ambiguous = ambiguous + 1 end
else
src[#src + 1] = { from[1], from[2], from[3] }
dst[#dst + 1] = { to[1], to[2], to[3] }
seen[key] = #src
end
end
end
end
for _, colors in ipairs(bgPalettes or {}) do
local resolved = GbcPalette.resolve(colors)
add(resolved, GbcPalette.remap(resolved, byte))
end
for _, colors in ipairs(objPalettes or {}) do
local resolved = GbcPalette.resolve(colors)
add(resolved, resolved)
end
local count = #src
-- Shader:send fills the whole declared array, so the tail is padded with a
-- copy of the first entry; `count` keeps the loop off it either way.
for index = count + 1, GbcPalette.REMAP_MAX do
src[index] = src[1] and { src[1][1], src[1][2], src[1][3] } or { 0, 0, 0 }
dst[index] = dst[1] and { dst[1][1], dst[1][2], dst[1][3] } or { 0, 0, 0 }
end
return src, dst, count, ambiguous
end
-- Bind the remap shader for a draw of an already-rendered texture. Returns
-- false when there is no shader or no palette, so a caller can fall back to
-- whatever approximation it had before; on success it also returns the
-- `ambiguous` count, which is 0 when the pass is exact.
function GbcPalette.useRemap(bgPalettes, byte, objPalettes)
local sh = GbcPalette.remapShader()
if not sh then return false end
local src, dst, count, ambiguous =
GbcPalette.remapTable(bgPalettes, byte, objPalettes)
if count == 0 then return false end
local sendSrc, sendDst = {}, {}
for index = 1, GbcPalette.REMAP_MAX do
sendSrc[index] = { src[index][1] / 255, src[index][2] / 255,
src[index][3] / 255 }
sendDst[index] = { dst[index][1] / 255, dst[index][2] / 255,
dst[index][3] / 255 }
end
-- pcall rather than an assert: a driver that will not take a 32-entry vec3
-- array should drop the effect, not take the battle down with it.
local ok = pcall(function()
sh:send("remapCount", count)
sh:send("remapTol", GbcPalette.REMAP_TOLERANCE)
sh:send("remapSrc", unpack(sendSrc))
sh:send("remapDst", unpack(sendDst))
end)
if not ok then
remapFailed = true
remapShader = nil
return false
end
love.graphics.setShader(sh)
return true, ambiguous
end
-- Run `body` with `colors` active, restoring whatever shader was set before.
-- Nested use is safe: the previous shader is captured, not assumed to be nil.
function GbcPalette.with(colors, body)
local previous = love and love.graphics and love.graphics.getShader
and love.graphics.getShader() or nil
local applied = GbcPalette.use(colors)
local ok, err = pcall(body)
if love and love.graphics then love.graphics.setShader(previous) end
if not ok then error(err, 0) end
return applied
end
return GbcPalette
+44 -1
View File
@@ -5,6 +5,7 @@
-- Right-facing frames are horizontal flips of the left frames.
local Assets = require("src.render.Assets")
local GbcPalette = require("src.render.GbcPalette")
local PaletteFX = require("src.render.PaletteFX")
local SpriteRenderer = {}
@@ -204,8 +205,38 @@ end
-- pipeline that renders into its own canvas never runs through it. For the
-- same reason the OG-RED bake is returned unconditionally here rather than
-- only during a redraw pass -- there is no later pass to restore it.
-- Gen 2 hands its OBJ palette over explicitly. Gold is a CGB-native game:
-- every OW sprite already has a real 4-color OBJ palette (PAL_OW_* crossed
-- with the time of day, engine/gfx/color.asm MapObjectPals), so there is
-- nothing for the PaletteFX mode ladder below to infer -- src/world/gen2 just
-- says what the colors are. It rides the same getObpImage bake as RED++,
-- which is also what keys OBJ color 0 to alpha; the sheets carry no real
-- alpha of their own, so a raw blit would put a white box behind every
-- character.
--
-- `group` must be distinct per palette or the bake cache collides -- callers
-- pass something like "gen2:NITE:1".
function SpriteRenderer:setObjPalette(colors, group)
self.objColors = colors
self.objGroup = group or "gen2"
end
-- The Gen 2 OBJ palette with the COLOR option applied. Resolved on the way
-- to the bake rather than where the world hands the colours over: the option
-- can change between two frames of a standing map, and applyPalettes only
-- runs on map entry and once a second. The mode joins the cache group
-- because the bake is per-palette -- without it, DMG would keep serving the
-- colour bake it made first.
function SpriteRenderer:gen2Obp()
return GbcPalette.resolve(self.objColors),
self.objGroup .. "|" .. tostring(GbcPalette.mode)
end
function SpriteRenderer:resolveImage()
if self.def.trueColor then return self.image end
if self.objColors then
return getObpImage(self.def.image, self:gen2Obp())
end
if PaletteFX.usesGbcPack() then
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
if colors then return getObpImage(self.def.image, colors, group) end
@@ -244,7 +275,13 @@ end
-- caller then draws itself through :drawTile (Player:draw, #384). Vanilla
-- frames therefore still draw 8 rows, while taller frames keep their larger
-- body and reserve only the overlay row.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, topHalf)
-- `forceFlip` is the caller asking for the X-flipped copy of the frame it
-- already picked, for the facings whose OAM rows are the mirror of another
-- row's: FacingWeirdTree3 is FacingWeirdTree1's four tiles with the columns
-- swapped and OAM_XFLIP on each (data/sprites/facings.asm:192-197). Optional
-- and trailing, so every existing call site is unchanged.
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip,
topHalf, forceFlip)
local x, y = self:getScreenOrigin(px, py, camX, camY)
local image = self.image
local redraw = false
@@ -252,6 +289,11 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
-- is recorded below once the final frame/height is known.
if self.def.trueColor then
image = self.image
elseif self.objColors then
-- Gen 2: the palette came from the caller (setObjPalette). Like RED++
-- this bakes to a true-color, real-alpha image and there is no BG zone
-- shader over the Gen 2 world to exempt it from.
image = getObpImage(self.def.image, self:gen2Obp())
elseif PaletteFX.usesGbcPack() then
-- RED++: the world canvas is already true-color (TileRenderer bakes
-- terrain, this bakes the sprite) and the world pass runs unshaded
@@ -287,6 +329,7 @@ function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip, to
-- still 3-frame sprites turn to face (the nurse at her machine,
-- facePlayer on STAY NPCs) but never show walk frames.
local frame, flip = pose(self, facing, walkPhase, stepFlip)
if forceFlip then flip = true end
local quad = self.frames[frame]
local drawHeight = self.frameHeight
if topHalf and self.frameCount > 1 then
+54 -2
View File
@@ -51,6 +51,11 @@ function TextBox.new(game, text, onDone, opts)
self.choiceNoSound = opts and opts.noSound
self.auto = opts and opts.auto
self.stay = opts and opts.stay
-- opts.instant: put the LAST page up already typed, with no typewriter and
-- no page waits. A `yesorno` follows a `writetext` that has already been
-- read, so re-typing the line under the YES/NO box would be wrong -- the
-- cart never closed the box in the first place.
self.instant = opts and opts.instant
local box = Theme.textBox or {}
self.boxTx = box.tx or BOX_TX
self.boxTy = box.ty or BOX_TY
@@ -70,6 +75,25 @@ function TextBox.new(game, text, onDone, opts)
self.contAdvance = false
self.done = false
self.blink = 0
if self.instant then
self.pageIndex = #self.pages
-- Both lines of the page at once, which is what the box looks like the
-- moment before the prompt appears.
local page = self.pages[self.pageIndex] or {}
-- The page's LAST two lines, which is what the box is holding. A `cont`
-- inside the page ran _ContTextNoPause (home/text.asm:442): TextScroll
-- twice, then the next line is written at TEXTBOX_INNERY + 2, i.e. the
-- bottom row. Taking the first two would walk the text backwards the
-- instant the prompt appears.
for index = math.max(1, #page - 1), #page do
self.shown[#self.shown + 1] = Font.encode(page[index])
end
self.lineIndex = #page
self.codes = self.shown[#self.shown] or {}
self.charIndex = #self.codes
self.done = true
return self
end
self:beginLine()
return self
end
@@ -81,7 +105,27 @@ end
-- (home/give.asm), and it stays set afterwards.
TextBox.TOKENS = {
PLAYER = function(game) return game.save.player.name or "RED" end,
RIVAL = function(game) return game.save.player.rival or "BLUE" end,
-- A Gen 1 save keeps the rival on player.rival; a Gold save keeps him at
-- save.rival.name, seeded "???" by InitializeNPCNames and written by the
-- NameRival special, whose own InitName fallback (not the seed) is where
-- "SILVER" comes from (pokegold engine/events/specials.asm
-- NameRival .DefaultName). The Gen 1
-- default must not leak into a Gold textbox: the cart's officer never says
-- BLUE. The tail therefore splits by generation rather than ending on the
-- Gen 1 literal. A Gold save with no rival record at all is one that never
-- reached the naming screen, and wRivalName is then still what
-- InitializeNPCNames seeded it with, "???"
-- (pokegold engine/menus/intro_menu.asm .Rival).
RIVAL = function(game)
local gold = game.save.generation == 2 or game.save.version == "gold"
return game.save.player.rival
or (game.save.rival and game.save.rival.name)
or (gold and "???" or "BLUE")
end,
-- Gen 2's TX_RAM points at wStringBuffer2, which getstring / getmonname /
-- getitemname fill. An unset buffer prints nothing, the same as the cart's
-- freshly `@`-filled buffer.
STRBUF = function(game) return game.stringBuffer end,
RAM = function(game, arg)
if arg == "wStringBuffer" then return game.stringBuffer end
if arg == "wBoxNumString" then return game.boxNumString end
@@ -360,7 +404,15 @@ function TextBox:draw()
r:setUIAnchor(self.boxTx * 8, self.boxTy * 8,
self.boxTw * 8, self.boxTh * 8, "bottom")
end
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh)
-- The box's own tiles are all font-page ($79-$7e frame, ' ' $7f interior),
-- so they take whatever BG palette 0 colour 0 the screen UNDER the box is
-- using. On every Gen 1 screen and nearly every Gold one that is white and
-- this is nil; the Pokegear's is a pale cream, and a call's pushed textbox
-- has to sit on the gear's paper rather than paint a white band across it
-- (pokegold engine/pokegear/pokegear.asm TownMapPals sends every tile
-- >= $60 to palette 0). Gen 1's Game has no textboxPaper, so it stays nil.
local paper = self.game and self.game.textboxPaper and self.game:textboxPaper()
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh, paper)
love.graphics.setColor(0, 0, 0, 1)
if self.scrollPx and self.scrollPx > 0 then
self.scrollPx = self.scrollPx - 2
+15
View File
@@ -129,6 +129,21 @@ function Tilt.groundPoint(cx, cy, vw, vh)
return sx, sy, scale
end
-- Is a flat foot point on the ground quad at all?
--
-- The ground is the flat world canvas warped onto the perspective plane, so it
-- STOPS at that canvas. groundPoint has no such edge: it happily projects a
-- point far above the viewport, and perspective pulls it back down toward the
-- horizon, which is how an NPC two screens away ended up standing over the
-- border fill past where the map is drawn at all. Billboards ask this first
-- and skip anything the ground does not reach. `margin` is the sprite's own
-- size, so someone half off the edge still draws.
function Tilt.onGround(fx, fy, vw, vh, margin)
margin = margin or 0
return fx >= -margin and fx <= (vw or 0) + margin
and fy >= -margin and fy <= (vh or 0) + margin
end
function Tilt.viewGrowth()
local a = Tilt.angle
if a <= 0 then return 1 end