mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-30 23:37:23 -04:00
30edac03a6
drawBox left the color white, which tiles ignore but ttf text does not, so every label after a box drew white on white. typechart only loaded on entering a battle, so the summary screen got raw type ids back instead of display names.
436 lines
16 KiB
Lua
436 lines
16 KiB
Lua
-- Text renderer using the real extracted font sheets and charmap.
|
|
-- Glyphs live on *pages*: font.png holds codes $80-$FF, font_extra.png
|
|
-- $60-$7F (borders etc), and a mod registers more (a kana block at $100,
|
|
-- a replacement sheet for an existing page) through the font registry,
|
|
-- which merges into data.font.pages. A page may set its own `advance`
|
|
-- for variable-width text; the default is the GB's flat 8px.
|
|
-- The charmap is matched greedily (longest sequence first) so multi-byte
|
|
-- UTF-8 chars and ligature glyphs like 'd 'l 's map to single glyphs.
|
|
--
|
|
-- A translation may instead set data.font.ttf and render its text through a
|
|
-- real TTF (the bundled Plain Pixel by default), so a script that would need
|
|
-- hundreds of page tiles works out of the box. Single characters then draw
|
|
-- from the TTF; multi-character charmap sequences (<PK>, the 'd ligatures)
|
|
-- and the sub-0x80 chrome glyphs (borders, arrows) keep their tiles, which
|
|
-- is why the box border never depends on the TTF's coverage.
|
|
|
|
local Assets = require("src.render.Assets")
|
|
|
|
local Font = {}
|
|
|
|
local GLYPH = 8
|
|
|
|
-- 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
|
|
Font.TTF_BASE = TTF_BASE
|
|
|
|
-- The engine's bundled TTF (assets/fonts/plainpixel/README.md: CC-BY 4.0,
|
|
-- Douglas Vautour). data.font.ttf.file overrides for a mod-shipped font.
|
|
-- 15 is the font's design em: its glyphs only rasterize at their true
|
|
-- pixel size (5x11 base, 11x11 double-width) at multiples of 15; at any
|
|
-- other size they downscale unevenly (at 11, M comes out 4px and A 5px).
|
|
Font.PLAINPIXEL = "assets/fonts/plainpixel/PlainPixel-Regular.ttf"
|
|
Font.PLAINPIXEL_SIZE = 15
|
|
|
|
local state
|
|
local loadedFrom
|
|
|
|
-- 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
|
|
-- page replaces just that one
|
|
local function pagesOf(def)
|
|
local pages = {}
|
|
if def.image then
|
|
pages.main = { image = def.image, base = def.mainBase or 0x80,
|
|
glyphsPerRow = def.glyphsPerRow or 16 }
|
|
end
|
|
if def.imageExtra then
|
|
pages.extra = { image = def.imageExtra, base = def.extraBase or 0x60,
|
|
glyphsPerRow = def.glyphsPerRow or 16 }
|
|
end
|
|
for id, page in pairs(def.pages or {}) do
|
|
if type(page) == "table" and page.image then pages[id] = page end
|
|
end
|
|
return pages
|
|
end
|
|
|
|
-- ttf.tiles as a lookup keyed by charmap sequence. Accepts a plain string
|
|
-- ("0123456789"), which is split into UTF-8 characters, or a list of
|
|
-- sequences ({ "0", "1", "<PK>" }) when a multi-character macro is meant.
|
|
local function tileSet(spec)
|
|
local set = {}
|
|
if type(spec) == "table" then
|
|
for _, seq in ipairs(spec) do set[tostring(seq)] = true end
|
|
elseif type(spec) == "string" then
|
|
-- split on UTF-8 lead bytes rather than utf8Decode, which this file
|
|
-- declares further down and would be nil here
|
|
local i, n = 1, #spec
|
|
while i <= n do
|
|
local last = i
|
|
if spec:byte(i) >= 0xC0 then
|
|
local k = i + 1
|
|
while k <= n do
|
|
local b = spec:byte(k)
|
|
if b < 0x80 or b > 0xBF then break end
|
|
last, k = k, k + 1
|
|
end
|
|
end
|
|
set[spec:sub(i, last)] = true
|
|
i = last + 1
|
|
end
|
|
end
|
|
return set
|
|
end
|
|
|
|
function Font.load(data)
|
|
loadedFrom = data
|
|
local def = data.font
|
|
state = { def = def, pages = {}, order = {}, byFirstByte = {} }
|
|
for id, page in pairs(pagesOf(def)) do
|
|
local ok, img = pcall(Assets.image, page.image)
|
|
if ok then
|
|
local iw, ih = img:getDimensions()
|
|
local perRow = page.glyphsPerRow or math.floor(iw / GLYPH)
|
|
local quads = {}
|
|
for i = 0, perRow * math.floor(ih / GLYPH) - 1 do
|
|
quads[i] = love.graphics.newQuad((i % perRow) * GLYPH,
|
|
math.floor(i / perRow) * GLYPH, GLYPH, GLYPH, iw, ih)
|
|
end
|
|
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
|
|
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)
|
|
|
|
-- Bucket the charmap by first byte for fast greedy matching, longest
|
|
-- sequence first *within* each bucket. The sort is ours rather than
|
|
-- the extractor's: a mod's page ships its own entries and nothing has
|
|
-- put them in length order.
|
|
local function bucket(entry)
|
|
if type(entry) ~= "table" or type(entry.seq) ~= "string"
|
|
or entry.seq == "" then return end
|
|
local b = entry.seq:byte(1)
|
|
state.byFirstByte[b] = state.byFirstByte[b] or {}
|
|
table.insert(state.byFirstByte[b], entry)
|
|
end
|
|
for _, entry in ipairs(def.charmap or {}) do bucket(entry) end
|
|
for _, page in pairs(def.pages or {}) do
|
|
for _, entry in ipairs(type(page) == "table" and page.charmap or {}) do
|
|
bucket(entry)
|
|
end
|
|
end
|
|
for _, entries in pairs(state.byFirstByte) do
|
|
table.sort(entries, function(a, b) return #a.seq > #b.seq end)
|
|
end
|
|
|
|
Font.BORDER = {}
|
|
for key, code in pairs(Font.DEFAULT_BORDER) do Font.BORDER[key] = code end
|
|
for key, code in pairs(def.border or {}) do Font.BORDER[key] = code end
|
|
|
|
-- TTF mode. All fields optional: {} means "the bundled Plain Pixel at
|
|
-- its native 11px". A failed load logs and falls back to tiles, so a
|
|
-- typo'd path degrades exactly like a missing page image does above.
|
|
if type(def.ttf) == "table" then
|
|
local file = def.ttf.file or Font.PLAINPIXEL
|
|
local ok, obj = pcall(love.graphics.newFont, file,
|
|
def.ttf.size or Font.PLAINPIXEL_SIZE, "mono")
|
|
if ok and obj then
|
|
-- nearest keeps the pixel font crisp under the integer UI scale
|
|
if obj.setFilter then pcall(obj.setFilter, obj, "nearest", "nearest") end
|
|
state.ttf = {
|
|
font = obj, file = file,
|
|
-- the font's own advances already carry a 1px gap at its design
|
|
-- size; spacing adds to (or, negative, takes from) every advance
|
|
spacing = def.ttf.spacing or 0,
|
|
-- bold double-prints each glyph at a 1px offset, for fonts whose
|
|
-- single-pixel strokes read too light against the tile art
|
|
bold = def.ttf.bold == true,
|
|
-- glyphs are taller than the 8px cell (11px base, and the em box
|
|
-- reserves even more for vertical extension); anchor the font's
|
|
-- baseline to the tile font's, which sits on row 7 of the cell, so
|
|
-- caps line up and descenders hang below as the GB font's own do
|
|
yOffset = def.ttf.yOffset or (obj.getBaseline
|
|
and (GLYPH - 1 - obj:getBaseline()) or (GLYPH - obj:getHeight())),
|
|
-- Single characters that keep their ROM tile instead of coming from
|
|
-- the TTF. A CJK translation sizes the font so a kana fills the 8px
|
|
-- cell, which leaves Latin narrower than the tile font it replaces:
|
|
-- the numbers in a right-aligned column (the party menu's ":L12" over
|
|
-- "34/ 34") then no longer land where the 8px-per-character layout put
|
|
-- them. Naming "0123456789" here keeps digits on the vanilla tiles --
|
|
-- identical to the English build -- while kana still come from the
|
|
-- font. Sequence keys, so "é" or a "<PK>" macro can be listed too.
|
|
tiles = tileSet(def.ttf.tiles),
|
|
widths = {}, chars = {},
|
|
}
|
|
else
|
|
require("src.core.Logger").warn("font: could not load ttf %q (%s)",
|
|
tostring(file), tostring(obj))
|
|
end
|
|
end
|
|
end
|
|
|
|
function Font.ttfActive()
|
|
return state ~= nil and state.ttf ~= nil
|
|
end
|
|
|
|
-- re-run load against the data it last saw, so hot reload picks up an
|
|
-- edited sheet or a newly merged page
|
|
function Font.invalidate()
|
|
if loadedFrom then Font.load(loadedFrom) end
|
|
end
|
|
|
|
Assets.register(Font.invalidate)
|
|
|
|
-- the page a glyph code draws from, or nil when nothing covers it
|
|
local function pageFor(code)
|
|
if not state then return nil end
|
|
for _, page in ipairs(state.order) do
|
|
if code >= page.base then return page end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local SPACE = 0x7F
|
|
|
|
-- Decode one UTF-8 sequence: codepoint and the index of its last byte, or
|
|
-- nil on a malformed lead/continuation (the caller falls back to bytes).
|
|
local function utf8Decode(text, i)
|
|
local b = text:byte(i)
|
|
if not b then return nil, i end
|
|
if b < 0x80 then return b, i end
|
|
local cont, cp
|
|
if b >= 0xF0 then cont, cp = 3, b - 0xF0
|
|
elseif b >= 0xE0 then cont, cp = 2, b - 0xE0
|
|
elseif b >= 0xC0 then cont, cp = 1, b - 0xC0
|
|
else return nil, i end
|
|
for k = i + 1, i + cont do
|
|
local c = text:byte(k)
|
|
if not c or c < 0x80 or c > 0xBF then return nil, i end
|
|
cp = cp * 64 + (c - 0x80)
|
|
end
|
|
return cp, i + cont
|
|
end
|
|
|
|
local function utf8Encode(cp)
|
|
if cp < 0x80 then return string.char(cp) end
|
|
if cp < 0x800 then
|
|
return string.char(0xC0 + math.floor(cp / 64), 0x80 + cp % 64)
|
|
end
|
|
if cp < 0x10000 then
|
|
return string.char(0xE0 + math.floor(cp / 4096),
|
|
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
|
end
|
|
return string.char(0xF0 + math.floor(cp / 262144),
|
|
0x80 + math.floor(cp / 4096) % 64,
|
|
0x80 + math.floor(cp / 64) % 64, 0x80 + cp % 64)
|
|
end
|
|
|
|
-- the character a TTF code draws, cached per code
|
|
local function ttfChar(ttf, code)
|
|
local ch = ttf.chars[code]
|
|
if not ch then
|
|
ch = utf8Encode(code - TTF_BASE)
|
|
ttf.chars[code] = ch
|
|
end
|
|
return ch
|
|
end
|
|
|
|
-- 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
|
|
-- ("<PK>", "'d") are each one glyph.
|
|
--
|
|
-- Every caller that *measures* or *cuts* text walks these instead of bytes.
|
|
-- "POKéMON" is 8 bytes and 7 glyphs: measuring it as 8 wraps lines that fit
|
|
-- (25 vanilla lines did), and cutting at byte 8 splits the é into two
|
|
-- invalid bytes that both draw as spaces. That distinction is the whole
|
|
-- reason a non-English font can be shipped as a mod (#186, #245).
|
|
--
|
|
-- Safe before Font.load: with no charmap it falls back to UTF-8 lead-byte
|
|
-- boundaries, which is all a headless paginate needs.
|
|
function Font.split(text)
|
|
local spans = {}
|
|
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
|
|
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
|
|
end
|
|
end
|
|
span = { from = i, to = last }
|
|
end
|
|
spans[#spans + 1] = span
|
|
i = span.to + 1
|
|
end
|
|
return spans
|
|
end
|
|
|
|
-- How many leading spans fit in `budget` pixels. Advances come from each
|
|
-- glyph's own page, so a variable-width page measures correctly.
|
|
function Font.spansFitting(spans, budget)
|
|
local used, fit = 0, 0
|
|
for _, span in ipairs(spans) do
|
|
used = used + Font.advanceOf(span.code or SPACE)
|
|
if used > budget then break end
|
|
fit = fit + 1
|
|
end
|
|
return fit
|
|
end
|
|
|
|
-- Convert a text string into a list of glyph codes. Unknown characters
|
|
-- render as space (and are reported once).
|
|
local reported = {}
|
|
function Font.encode(text)
|
|
local codes = {}
|
|
for _, span in ipairs(Font.split(text)) do
|
|
local code = span.code
|
|
if not code then
|
|
local ch = text:sub(span.from, span.to)
|
|
if not reported[ch] and text:byte(span.from) >= 32 then
|
|
reported[ch] = true
|
|
require("src.core.Logger").warn("font: no glyph for %q", ch)
|
|
end
|
|
code = SPACE
|
|
end
|
|
codes[#codes + 1] = code
|
|
end
|
|
return codes
|
|
end
|
|
|
|
function Font.drawCode(code, x, y)
|
|
local ttf = state and state.ttf
|
|
if ttf and code >= TTF_BASE then
|
|
local prev = love.graphics.getFont()
|
|
love.graphics.setFont(ttf.font)
|
|
local ch = ttfChar(ttf, code)
|
|
love.graphics.print(ch, x, y + ttf.yOffset)
|
|
if ttf.bold then love.graphics.print(ch, x + 1, y + ttf.yOffset) end
|
|
if prev then love.graphics.setFont(prev) end
|
|
return
|
|
end
|
|
local page = pageFor(code)
|
|
if not page then return end
|
|
local quad = page.quads[code - page.base]
|
|
if quad then love.graphics.draw(page.image, quad, x, y) end
|
|
end
|
|
|
|
-- how far the pen moves past a glyph; 8 unless its page says otherwise.
|
|
-- TTF glyphs answer with the font's own metrics (5px base, 11px for
|
|
-- double-width kana/CJK in Plain Pixel), which is what makes TextBox's
|
|
-- pixel-budget pagination fit more of a narrow script per line.
|
|
function Font.advanceOf(code)
|
|
local ttf = state and state.ttf
|
|
if ttf and code >= TTF_BASE then
|
|
local w = ttf.widths[code]
|
|
if not w then
|
|
w = ttf.font:getWidth(ttfChar(ttf, code)) + ttf.spacing
|
|
+ (ttf.bold and 1 or 0)
|
|
ttf.widths[code] = w
|
|
end
|
|
return w
|
|
end
|
|
local page = pageFor(code)
|
|
return page and page.advance or GLYPH
|
|
end
|
|
|
|
-- Pixel width of a string (glyph advances, not UTF-8 byte length).
|
|
-- Multi-byte charmap entries like "¥" are one glyph; callers that
|
|
-- right-align with `#text * 8` mis-place them.
|
|
function Font.width(text)
|
|
local w = 0
|
|
for _, code in ipairs(Font.encode(text)) do
|
|
w = w + Font.advanceOf(code)
|
|
end
|
|
return w
|
|
end
|
|
|
|
-- Draw a plain single-line string at pixel (x, y). Returns the width
|
|
-- drawn, which is #codes * 8 for every fixed-width page.
|
|
function Font.draw(text, x, y)
|
|
local codes = Font.encode(text)
|
|
local pen = x
|
|
for _, code in ipairs(codes) do
|
|
Font.drawCode(code, pen, y)
|
|
pen = pen + Font.advanceOf(code)
|
|
end
|
|
return pen - x
|
|
end
|
|
|
|
-- Border glyph codes (font_extra.png, from charmap.asm $79-$7E). A font
|
|
-- that draws its boxes from different glyphs sets data.font.border and
|
|
-- Font.load folds it over these; the table itself stays writable so a mod
|
|
-- can retheme one corner without shipping a whole page.
|
|
Font.DEFAULT_BORDER = {
|
|
tl = 0x79, h = 0x7A, tr = 0x7B, v = 0x7C, bl = 0x7D, br = 0x7E,
|
|
}
|
|
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
|
|
-- 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
|
|
-- glyph was a tile. TTF text is not immune -- it draws in the current
|
|
-- color -- so a leaked white left every label printed after a box white on
|
|
-- 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)
|
|
love.graphics.rectangle("fill", tx * 8, ty * 8, tw * 8, th * 8)
|
|
love.graphics.setColor(r, g, b, a)
|
|
local B = Font.BORDER
|
|
Font.drawCode(B.tl, tx * 8, ty * 8)
|
|
Font.drawCode(B.tr, (tx + tw - 1) * 8, ty * 8)
|
|
Font.drawCode(B.bl, tx * 8, (ty + th - 1) * 8)
|
|
Font.drawCode(B.br, (tx + tw - 1) * 8, (ty + th - 1) * 8)
|
|
for i = 1, tw - 2 do
|
|
Font.drawCode(B.h, (tx + i) * 8, ty * 8)
|
|
Font.drawCode(B.h, (tx + i) * 8, (ty + th - 1) * 8)
|
|
end
|
|
for j = 1, th - 2 do
|
|
Font.drawCode(B.v, tx * 8, (ty + j) * 8)
|
|
Font.drawCode(B.v, (tx + tw - 1) * 8, (ty + j) * 8)
|
|
end
|
|
end
|
|
|
|
return Font
|