mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 03:35:56 +02:00
big ass modding update
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
-- Central image cache plus the mod-visible asset search path. Every
|
||||
-- renderer that used to call love.graphics.newImage(path) straight goes
|
||||
-- through Assets.image, so an enabled mod shadows a generated asset with
|
||||
-- its own file without editing a single record, and one flush() drops
|
||||
-- every downstream cache for dev-mode hot reload.
|
||||
--
|
||||
-- No loader installed means resolve() is the identity, which is what
|
||||
-- keeps a mod-free boot (and every headless test) loading exactly the
|
||||
-- paths it always did.
|
||||
|
||||
local Assets = {}
|
||||
|
||||
-- resolved path -> love Image
|
||||
local cache = {}
|
||||
-- downstream caches that must empty when the search path changes
|
||||
local invalidators = {}
|
||||
|
||||
-- The loader bridge: overrideOrder() yields mods highest-priority-first
|
||||
-- and derivedPath(rel) yields an existing save/mod-derived/<id>/<rel>.
|
||||
-- nil until the loader installs one.
|
||||
Assets.loader = nil
|
||||
|
||||
local GENERATED = "assets/generated/"
|
||||
|
||||
local function exists(path)
|
||||
local fs = love and love.filesystem
|
||||
if not (fs and fs.getInfo) then return false end
|
||||
return fs.getInfo(path) ~= nil
|
||||
end
|
||||
Assets.exists = exists
|
||||
|
||||
-- an override dir shadows the generated cache; a transform's derived
|
||||
-- output is the fallback under it, so hand-authored art beats generated
|
||||
function Assets.resolve(path)
|
||||
local loader = Assets.loader
|
||||
if not loader or type(path) ~= "string" then return path end
|
||||
if path:sub(1, #GENERATED) ~= GENERATED then return path end
|
||||
local rel = path:sub(#GENERATED + 1)
|
||||
for _, mod in ipairs(loader:overrideOrder()) do
|
||||
local candidate = mod.path .. "/overrides/" .. rel
|
||||
if exists(candidate) then return candidate end
|
||||
end
|
||||
return loader:derivedPath(rel) or path
|
||||
end
|
||||
|
||||
function Assets.image(path)
|
||||
local resolved = Assets.resolve(path)
|
||||
local image = cache[resolved]
|
||||
if not image then
|
||||
image = love.graphics.newImage(resolved)
|
||||
cache[resolved] = image
|
||||
end
|
||||
return image
|
||||
end
|
||||
|
||||
-- pixel-level reads (tile-shift variants, the spinner strip blit) resolve
|
||||
-- the same way but stay uncached: the caller keeps the derived product
|
||||
function Assets.imageData(path)
|
||||
return love.image.newImageData(Assets.resolve(path))
|
||||
end
|
||||
|
||||
function Assets.register(invalidate)
|
||||
invalidators[#invalidators + 1] = invalidate
|
||||
end
|
||||
|
||||
-- hot reload's single entry point (20-developer-tooling): drop the central
|
||||
-- cache and fan out to every registered downstream one. A cache whose
|
||||
-- invalidator throws must not strand the ones behind it in the list.
|
||||
function Assets.invalidate()
|
||||
cache = {}
|
||||
for _, fn in ipairs(invalidators) do pcall(fn) end
|
||||
end
|
||||
|
||||
Assets.flush = Assets.invalidate
|
||||
|
||||
-- Loader:load hands over the live mod set once the merge is done. Load
|
||||
-- order is priority ascending, so the search walks it backwards: the mod
|
||||
-- that wins the record merge wins the asset lookup too.
|
||||
function Assets.installLoader(loader)
|
||||
if not loader then
|
||||
Assets.loader = nil
|
||||
Assets.invalidate()
|
||||
return
|
||||
end
|
||||
local bridge = {}
|
||||
function bridge:overrideOrder()
|
||||
local order = {}
|
||||
local loaded = loader.loaded or {}
|
||||
for i = #loaded, 1, -1 do
|
||||
order[#order + 1] = { id = loaded[i].manifest.id, path = loaded[i].path }
|
||||
end
|
||||
return order
|
||||
end
|
||||
function bridge:derivedPath(rel)
|
||||
for _, mod in ipairs(self:overrideOrder()) do
|
||||
local candidate = "save/mod-derived/" .. mod.id .. "/" .. rel
|
||||
if exists(candidate) then return candidate end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
Assets.loader = bridge
|
||||
Assets.invalidate()
|
||||
end
|
||||
|
||||
return Assets
|
||||
@@ -9,6 +9,8 @@
|
||||
-- enemy is stronger (wBattleTransitionSpiralDirection).
|
||||
-- Pushed above the overworld; pops itself and runs onDone at the end.
|
||||
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
|
||||
local BattleTransition = {}
|
||||
BattleTransition.__index = BattleTransition
|
||||
BattleTransition.isOpaque = false -- draws over the frozen overworld
|
||||
@@ -105,21 +107,73 @@ local function sweepOrder(arms)
|
||||
return tiles
|
||||
end
|
||||
|
||||
-- The eight wipes as records: frames is the wipe length, flash marks the
|
||||
-- two circle wipes that call BattleTransition_FlashScreen first. new()
|
||||
-- reads them, and the transitions registry serves the same table.
|
||||
BattleTransition.STYLES = {
|
||||
doublecircle = { kind = "wipe", frames = 40, flash = true },
|
||||
spiralin = { kind = "wipe", frames = 40 },
|
||||
circle = { kind = "wipe", frames = 40, flash = true },
|
||||
spiralout = { kind = "wipe", frames = 40 },
|
||||
hstripes = { kind = "wipe", frames = 24 },
|
||||
shrink = { kind = "wipe", frames = 24 },
|
||||
vstripes = { kind = "wipe", frames = 24 },
|
||||
split = { kind = "wipe", frames = 24 },
|
||||
}
|
||||
|
||||
-- the eight wipes plus Transition's two warp fades: one registrant owns
|
||||
-- the whole transitions namespace, so Builtins wires it once
|
||||
function BattleTransition.registerInto(registry, data, owner)
|
||||
for id, record in pairs(BattleTransition.STYLES) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
require("src.render.Transition").registerInto(registry, data, owner)
|
||||
end
|
||||
|
||||
-- the merged record; the built-in table is the fallback for headless
|
||||
-- callers and for any state built before Data:load
|
||||
local function styleDef(game, style)
|
||||
local data = game and game.data
|
||||
local record = data and data.transitions and data.transitions[style]
|
||||
return record or BattleTransition.STYLES[style]
|
||||
end
|
||||
|
||||
local ORDERS = {} -- cached per style
|
||||
|
||||
local function orderFor(style)
|
||||
if not ORDERS[style] then
|
||||
if style == "spiralout" then
|
||||
ORDERS[style] = outwardSpiralOrder()
|
||||
elseif style == "spiralin" then
|
||||
ORDERS[style] = inwardSpiralOrder()
|
||||
elseif style == "circle" then
|
||||
ORDERS[style] = sweepOrder(1)
|
||||
elseif style == "doublecircle" then
|
||||
ORDERS[style] = sweepOrder(2)
|
||||
local BUILTIN_ORDERS = {
|
||||
spiralout = outwardSpiralOrder,
|
||||
spiralin = inwardSpiralOrder,
|
||||
circle = function() return sweepOrder(1) end,
|
||||
doublecircle = function() return sweepOrder(2) end,
|
||||
}
|
||||
|
||||
-- A registered style may bring its own tile order (a list of {x, y}, or a
|
||||
-- function returning one); the four built-in orders are the defaults for
|
||||
-- the styles that have always had them.
|
||||
local function orderFor(style, def)
|
||||
if ORDERS[style] == nil then
|
||||
local order = def and def.order
|
||||
if type(order) == "function" then
|
||||
local ok, built = pcall(order)
|
||||
order = ok and built or nil
|
||||
end
|
||||
if type(order) ~= "table" then
|
||||
local build = BUILTIN_ORDERS[style]
|
||||
order = build and build() or false
|
||||
end
|
||||
ORDERS[style] = order or false
|
||||
end
|
||||
return ORDERS[style]
|
||||
return ORDERS[style] or nil
|
||||
end
|
||||
|
||||
-- the vanilla 3-bit select (battle_transitions.asm), and the default of
|
||||
-- the transition.style hook a mod wraps to choose its own wipe
|
||||
local BIT_STYLES = { [0] = "doublecircle", "spiralin", "circle", "spiralout",
|
||||
"hstripes", "shrink", "vstripes", "split" }
|
||||
|
||||
local function vanillaStyle(ctx)
|
||||
return BIT_STYLES[(ctx.trainer and 1 or 0) + (ctx.stronger and 2 or 0)
|
||||
+ (ctx.dungeon and 4 or 0)]
|
||||
end
|
||||
|
||||
-- opts: trainer (bool), stronger (bool), dungeon (bool)
|
||||
@@ -129,16 +183,20 @@ function BattleTransition.new(game, onDone, opts)
|
||||
self.onDone = onDone
|
||||
self.t = 0
|
||||
opts = opts or {}
|
||||
local bits = (opts.trainer and 1 or 0) + (opts.stronger and 2 or 0)
|
||||
+ (opts.dungeon and 4 or 0)
|
||||
self.style = ({ [0] = "doublecircle", "spiralin", "circle", "spiralout",
|
||||
"hstripes", "shrink", "vstripes", "split" })[bits]
|
||||
local ctx = { trainer = opts.trainer, stronger = opts.stronger,
|
||||
dungeon = opts.dungeon, game = game }
|
||||
local style = Runtime.call("transition.style", vanillaStyle, ctx)
|
||||
local def = styleDef(game, style)
|
||||
-- a hook that names an unregistered style falls back to the vanilla bits
|
||||
if not def then
|
||||
style = vanillaStyle(ctx)
|
||||
def = styleDef(game, style)
|
||||
end
|
||||
self.style = style
|
||||
self.def = def
|
||||
-- only the circle wipes flash first (battle_transitions.asm:585,628)
|
||||
self.phase = (self.style == "circle" or self.style == "doublecircle")
|
||||
and "flash" or "wipe"
|
||||
self.wipeLen = (self.style == "spiralin" or self.style == "spiralout"
|
||||
or self.style == "circle"
|
||||
or self.style == "doublecircle") and 40 or 24
|
||||
self.phase = def.flash and "flash" or "wipe"
|
||||
self.wipeLen = def.frames
|
||||
return self
|
||||
end
|
||||
|
||||
@@ -174,7 +232,14 @@ function BattleTransition:draw()
|
||||
local prog = math.min(1, self.t / self.wipeLen)
|
||||
local style = self.style
|
||||
|
||||
local order = orderFor(style)
|
||||
-- a registered style may draw itself; the eight built-ins do not
|
||||
if self.def and self.def.draw then
|
||||
self.def.draw(self, prog)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
return
|
||||
end
|
||||
|
||||
local order = orderFor(style, self.def)
|
||||
if order then
|
||||
-- tile-order wipes: spiral / circle sweeps
|
||||
local n = math.floor(#order * prog)
|
||||
|
||||
+110
-34
@@ -1,41 +1,105 @@
|
||||
-- Text renderer using the real extracted font sheets and charmap.
|
||||
-- font.png holds glyph codes $80-$FF, font_extra.png $60-$7F (borders etc).
|
||||
-- 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.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local Font = {}
|
||||
|
||||
local GLYPH = 8
|
||||
|
||||
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
|
||||
|
||||
function Font.load(data)
|
||||
loadedFrom = data
|
||||
local def = data.font
|
||||
local main = love.graphics.newImage(def.image)
|
||||
local extra = love.graphics.newImage(def.imageExtra)
|
||||
state = {
|
||||
def = def,
|
||||
main = main,
|
||||
extra = extra,
|
||||
mainQuads = {},
|
||||
extraQuads = {},
|
||||
byFirstByte = {},
|
||||
}
|
||||
local function buildQuads(img, quads)
|
||||
local iw, ih = img:getDimensions()
|
||||
local perRow = iw / 8
|
||||
for i = 0, perRow * (ih / 8) - 1 do
|
||||
quads[i] = love.graphics.newQuad((i % perRow) * 8,
|
||||
math.floor(i / perRow) * 8, 8, 8, iw, ih)
|
||||
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
|
||||
buildQuads(main, state.mainQuads)
|
||||
buildQuads(extra, state.extraQuads)
|
||||
-- charmap comes sorted longest-first from the extractor; bucket by first
|
||||
-- byte for fast greedy matching
|
||||
for _, entry in ipairs(def.charmap) do
|
||||
-- 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
|
||||
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
|
||||
|
||||
-- Convert a text string into a list of glyph codes. Unknown characters
|
||||
@@ -72,27 +136,39 @@ function Font.encode(text)
|
||||
end
|
||||
|
||||
function Font.drawCode(code, x, y)
|
||||
local def = state.def
|
||||
if code >= def.mainBase then
|
||||
love.graphics.draw(state.main, state.mainQuads[code - def.mainBase], x, y)
|
||||
elseif code >= def.extraBase then
|
||||
love.graphics.draw(state.extra, state.extraQuads[code - def.extraBase], x, y)
|
||||
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
|
||||
|
||||
-- Draw a plain single-line string at pixel (x, y).
|
||||
-- how far the pen moves past a glyph; 8 unless its page says otherwise
|
||||
function Font.advanceOf(code)
|
||||
local page = pageFor(code)
|
||||
return page and page.advance or GLYPH
|
||||
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)
|
||||
for i, code in ipairs(codes) do
|
||||
Font.drawCode(code, x + (i - 1) * 8, y)
|
||||
local pen = x
|
||||
for _, code in ipairs(codes) do
|
||||
Font.drawCode(code, pen, y)
|
||||
pen = pen + Font.advanceOf(code)
|
||||
end
|
||||
return #codes * 8
|
||||
return pen - x
|
||||
end
|
||||
|
||||
-- Border glyph codes (font_extra.png, from charmap.asm $79-$7E)
|
||||
Font.BORDER = {
|
||||
-- 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)
|
||||
|
||||
+33
-5
@@ -3,14 +3,34 @@
|
||||
-- status sheet (font_battle_extra -> $62) and the HUD line tiles
|
||||
-- (battle_hud_1 -> $6D, battle_hud_2+3 -> $73).
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
|
||||
local HudTiles = {}
|
||||
|
||||
-- The four HUD sheets are glyph pages like any other, so they resolve
|
||||
-- through the font registry: mod.content.font:register("battle_hud_1",
|
||||
-- { image = ..., base = 0x6D }) reskins the HP bar. These are the
|
||||
-- vanilla pages the importer's cache carries, in the order the asm
|
||||
-- overlays them ($6D lands on top of font_battle_extra's tail).
|
||||
local PAGES = {
|
||||
{ id = "font_battle_extra",
|
||||
image = "assets/generated/battle/font_battle_extra.png", base = 0x62 },
|
||||
{ id = "battle_hud_1",
|
||||
image = "assets/generated/battle/battle_hud_1.png", base = 0x6D },
|
||||
{ id = "battle_hud_2",
|
||||
image = "assets/generated/battle/battle_hud_2.png", base = 0x73 },
|
||||
{ id = "battle_hud_3",
|
||||
image = "assets/generated/battle/battle_hud_3.png", base = 0x76 },
|
||||
}
|
||||
|
||||
local tiles
|
||||
function HudTiles.tile(code, x, y, tint)
|
||||
if not tiles then
|
||||
tiles = {}
|
||||
local registered = require("src.core.Data").font
|
||||
registered = registered and registered.pages or nil
|
||||
local function add(path, base)
|
||||
local ok, img = pcall(love.graphics.newImage, path)
|
||||
local ok, img = pcall(Assets.image, path)
|
||||
if not ok then return end
|
||||
local iw, ih = img:getDimensions()
|
||||
local per = iw / 8
|
||||
@@ -22,10 +42,11 @@ function HudTiles.tile(code, x, y, tint)
|
||||
}
|
||||
end
|
||||
end
|
||||
add("assets/generated/battle/font_battle_extra.png", 0x62)
|
||||
add("assets/generated/battle/battle_hud_1.png", 0x6D) -- overrides
|
||||
add("assets/generated/battle/battle_hud_2.png", 0x73)
|
||||
add("assets/generated/battle/battle_hud_3.png", 0x76)
|
||||
for _, page in ipairs(PAGES) do
|
||||
local override = registered and registered[page.id]
|
||||
add(override and override.image or page.image,
|
||||
override and override.base or page.base)
|
||||
end
|
||||
end
|
||||
local t = tiles[code]
|
||||
if not t then return end
|
||||
@@ -35,6 +56,13 @@ function HudTiles.tile(code, x, y, tint)
|
||||
love.graphics.setColor(r, g, b, a)
|
||||
end
|
||||
|
||||
-- lazy: the next tile() rebuilds every page from the search path
|
||||
function HudTiles.invalidate()
|
||||
tiles = nil
|
||||
end
|
||||
|
||||
Assets.register(HudTiles.invalidate)
|
||||
|
||||
-- The bar's right-end tile follows wHPBarType (DrawHPBar's "Right"
|
||||
-- branch): only type 1 -- the player's in-battle bar and the status
|
||||
-- screen -- gets the double-bar $6D; the enemy bar (0) and the party
|
||||
|
||||
@@ -69,13 +69,57 @@ function PaletteFX.keyedShader()
|
||||
return keyedShader or nil
|
||||
end
|
||||
|
||||
-- ATTR_BLK inclusive tile rect -> pixel-space zone
|
||||
-- ATTR_BLK inclusive tile rect -> pixel-space zone. colors == false is
|
||||
-- the trueColor opt-out: a real zone whose rect blits with no shader, so
|
||||
-- full-color art survives the pass. nil still means "no zone at all".
|
||||
function PaletteFX.zone(colors, tx1, ty1, tx2, ty2)
|
||||
if not colors then return nil end
|
||||
if colors == nil then return nil end
|
||||
return { colors = colors, x = tx1 * 8, y = ty1 * 8,
|
||||
w = (tx2 - tx1 + 1) * 8, h = (ty2 - ty1 + 1) * 8 }
|
||||
end
|
||||
|
||||
-- the trueColor zone a sprite/tileset record asks for by name
|
||||
function PaletteFX.trueColorZone(tx1, ty1, tx2, ty2)
|
||||
return PaletteFX.zone(false, tx1, ty1, tx2, ty2)
|
||||
end
|
||||
|
||||
-- ------- trueColor zone collection
|
||||
|
||||
-- A sprites/tilesets record carrying trueColor = true must not reach the
|
||||
-- shade-remap shader (14 §trueColor propagation), but the states that
|
||||
-- build the zone list know nothing about which records the frame drew.
|
||||
-- So the renderer that draws one reports its covering rect here, in the
|
||||
-- coordinates of the canvas it is filling, and Renderer:endFrame appends
|
||||
-- the frame's rects to that pass's zone list as colors == false zones --
|
||||
-- the region is then re-blit unshaded on top of the colorized pass.
|
||||
-- No vanilla record sets the flag, so both buckets stay empty every frame
|
||||
-- and the zone lists are exactly the ones the states returned.
|
||||
local trueColorRects = { ui = {}, world = {} }
|
||||
local currentPass = nil
|
||||
|
||||
-- which canvas the renderer is filling. nil for a pass that composites
|
||||
-- with no zone list of its own (tilt's upright billboards carry their own
|
||||
-- per-sprite colorization), which drops its rects on the floor.
|
||||
function PaletteFX.setPass(name)
|
||||
currentPass = trueColorRects[name] and name or nil
|
||||
end
|
||||
|
||||
function PaletteFX.clearTrueColor()
|
||||
for _, rects in pairs(trueColorRects) do
|
||||
for i = #rects, 1, -1 do rects[i] = nil end
|
||||
end
|
||||
end
|
||||
|
||||
function PaletteFX.markTrueColor(x, y, w, h)
|
||||
local rects = currentPass and trueColorRects[currentPass]
|
||||
if not rects or w <= 0 or h <= 0 then return end
|
||||
rects[#rects + 1] = { colors = false, x = x, y = y, w = w, h = h }
|
||||
end
|
||||
|
||||
function PaletteFX.trueColorRects(name)
|
||||
return trueColorRects[name] or {}
|
||||
end
|
||||
|
||||
function PaletteFX.whole(colors)
|
||||
return PaletteFX.zone(colors, 0, 0, 19, 17)
|
||||
end
|
||||
|
||||
+47
-4
@@ -8,6 +8,7 @@
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
@@ -61,6 +62,9 @@ end
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
-- last frame's trueColor rects go before anything draws this one
|
||||
PaletteFX.clearTrueColor()
|
||||
PaletteFX.setPass("ui")
|
||||
love.graphics.setCanvas(self.canvas)
|
||||
if transparent then
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
@@ -77,11 +81,13 @@ function Renderer:beginWorldPass()
|
||||
self.worldCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.worldActive = true
|
||||
PaletteFX.setPass("world")
|
||||
love.graphics.setCanvas(self.worldCanvas)
|
||||
love.graphics.clear(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Renderer:endWorldPass()
|
||||
PaletteFX.setPass("ui")
|
||||
love.graphics.setCanvas(self.canvas)
|
||||
end
|
||||
|
||||
@@ -103,6 +109,7 @@ function Renderer:beginUprightPass()
|
||||
self.uprightCanvas:setFilter("nearest", "nearest")
|
||||
end
|
||||
self.uprightActive = true
|
||||
PaletteFX.setPass(nil)
|
||||
love.graphics.setCanvas(self.uprightCanvas)
|
||||
love.graphics.clear(0, 0, 0, 0)
|
||||
-- shift the whole pass into the padded canvas so billboards keep drawing
|
||||
@@ -115,6 +122,7 @@ end
|
||||
-- return to the ground world canvas (the world pass owns it until draw()
|
||||
-- calls endWorldPass)
|
||||
function Renderer:endUprightPass()
|
||||
PaletteFX.setPass("world")
|
||||
love.graphics.pop()
|
||||
love.graphics.setCanvas(self.worldCanvas)
|
||||
end
|
||||
@@ -181,7 +189,6 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
||||
local shader = self:tiltShader()
|
||||
local mesh = self:tiltMesh()
|
||||
if not (shader and mesh) then return false end
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
local wvh = self.worldCanvas:getHeight()
|
||||
|
||||
@@ -201,8 +208,15 @@ function Renderer:drawTiltedWorld(zoneList, s, wox, woy, target)
|
||||
local zoneShader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
||||
if zoneShader then
|
||||
love.graphics.setShader(zoneShader)
|
||||
-- same trueColor sentinel the flat blit honors (14 §trueColor)
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
PaletteFX.sendColors(zoneShader, z.colors)
|
||||
local plain = z.colors == false
|
||||
if plain ~= bare then
|
||||
bare = plain
|
||||
love.graphics.setShader(not plain and zoneShader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(zoneShader, z.colors) end
|
||||
local x, y = math.max(0, z.x), math.max(0, z.y)
|
||||
local x2, y2 = math.min(wvw, z.x + z.w), math.min(wvh, z.y + z.h)
|
||||
if x2 > x and y2 > y then
|
||||
@@ -240,6 +254,20 @@ local function scissorClamped(x, y, w, h, ox, oy, vpw, vph)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Splice the pass's trueColor rects (reported by the renderers that drew
|
||||
-- a record carrying the flag) onto the end of its zone list, so each one
|
||||
-- re-blits its region with no shader over the colorized pass. An absent
|
||||
-- or empty zone list is left alone: that already draws the whole canvas
|
||||
-- unshaded, which is what the rects were asking for.
|
||||
local function withTrueColor(zoneList, pass)
|
||||
local rects = PaletteFX.trueColorRects(pass)
|
||||
if not (rects[1] and zoneList and zoneList[1]) then return zoneList end
|
||||
local merged = {}
|
||||
for i = 1, #zoneList do merged[i] = zoneList[i] end
|
||||
for i = 1, #rects do merged[#merged + 1] = rects[i] end
|
||||
return merged
|
||||
end
|
||||
|
||||
-- zones: optional list of SGB palette regions (see PaletteFX) in
|
||||
-- 160x144 UI space, applied to the UI pass. worldZones: optional
|
||||
-- regions in world-canvas pixels (overworld survey zoom colors each
|
||||
@@ -255,12 +283,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
local vpw, vph = self.WIDTH * S, self.HEIGHT * S
|
||||
local ox = math.floor((ww - vpw) / 2)
|
||||
local oy = math.floor((wh - vph) / 2)
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
-- Forced mono/Classic modes still need a whole-screen zone when a state
|
||||
-- exposes no SGB packets (raw DMG canvas), so sendColors can remap.
|
||||
zones = PaletteFX.ensureZones(zones)
|
||||
if worldZones then worldZones = PaletteFX.ensureZones(worldZones) end
|
||||
-- the UI rects are in 160x144 canvas space and the world rects in world-
|
||||
-- canvas pixels, matching the zone list each is appended to. A world
|
||||
-- pass with no world zones falls back to the UI list, whose coordinate
|
||||
-- space the world rects are not in, so they are dropped there.
|
||||
zones = withTrueColor(zones, "ui")
|
||||
worldZones = withTrueColor(worldZones, "world")
|
||||
|
||||
local needPresent = GBCFX.active()
|
||||
local present = nil
|
||||
@@ -289,8 +322,17 @@ function Renderer:endFrame(zones, worldZones)
|
||||
return
|
||||
end
|
||||
love.graphics.setShader(shader)
|
||||
-- a colors == false zone is the trueColor opt-out: its rect draws with
|
||||
-- no shader at all. Nothing sets one without a mod, so a vanilla zone
|
||||
-- list never toggles and issues exactly the calls it always did.
|
||||
local bare = false
|
||||
for _, z in ipairs(zoneList) do
|
||||
PaletteFX.sendColors(shader, z.colors)
|
||||
local plain = z.colors == false
|
||||
if plain ~= bare then
|
||||
bare = plain
|
||||
love.graphics.setShader(not plain and shader or nil)
|
||||
end
|
||||
if not plain then PaletteFX.sendColors(shader, z.colors) end
|
||||
if scissorClamped(bx + z.x * zoneScale, by + z.y * zoneScale,
|
||||
z.w * zoneScale, z.h * zoneScale,
|
||||
boxX, boxY, boxW, boxH) then
|
||||
@@ -345,6 +387,7 @@ function Renderer:endFrame(zones, worldZones)
|
||||
end
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
PaletteFX.setPass(nil)
|
||||
end
|
||||
|
||||
return Renderer
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
-- Right-facing frames are horizontal flips of the left frames.
|
||||
-- Sprites draw 4px above their cell, like the GB engine.
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local SpriteRenderer = {}
|
||||
SpriteRenderer.__index = SpriteRenderer
|
||||
|
||||
@@ -10,11 +13,19 @@ local imageCache = {}
|
||||
|
||||
local function getImage(path)
|
||||
if not imageCache[path] then
|
||||
imageCache[path] = love.graphics.newImage(path)
|
||||
imageCache[path] = Assets.image(path)
|
||||
end
|
||||
return imageCache[path]
|
||||
end
|
||||
|
||||
-- hot reload drops the sheets; live instances hold their own image, so
|
||||
-- the world rebuilds them (MapLoader.invalidateAll) rather than this
|
||||
function SpriteRenderer.invalidate()
|
||||
imageCache = {}
|
||||
end
|
||||
|
||||
Assets.register(SpriteRenderer.invalidate)
|
||||
|
||||
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||
|
||||
@@ -35,6 +46,8 @@ end
|
||||
function SpriteRenderer:draw(px, py, camX, camY, facing, walkPhase, stepFlip)
|
||||
local x = math.floor(px - camX)
|
||||
local y = math.floor(py - camY) - 4
|
||||
-- full-color art claims its 16x16 cell out of the shade-remap pass
|
||||
if self.def.trueColor then PaletteFX.markTrueColor(x, y, 16, 16) end
|
||||
-- single-frame sprites (item balls, fossils...) have one fixed pose;
|
||||
-- still 3-frame sprites turn to face (the nurse at her machine,
|
||||
-- facePlayer on STAY NPCs) but never show walk frames
|
||||
|
||||
+49
-25
@@ -7,13 +7,14 @@
|
||||
-- the text is exhausted and A is pressed, then calls onDone.
|
||||
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local TextBox = {}
|
||||
TextBox.__index = TextBox
|
||||
|
||||
-- theme-free fallbacks; geometry resolves against Theme.textBox at
|
||||
-- construction time, so an unthemed boot stays byte-identical
|
||||
local BOX_TX, BOX_TY, BOX_TW, BOX_TH = 0, 12, 20, 6
|
||||
local LINE1_Y, LINE2_Y = (BOX_TY + 2) * 8, (BOX_TY + 4) * 8
|
||||
local TEXT_X = 8
|
||||
local MAX_COLS = 18
|
||||
|
||||
-- opts.choice: when the last page has typed out, a YES/NO ChoiceBox pops
|
||||
@@ -33,8 +34,17 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.choice = opts and opts.choice
|
||||
self.defaultNo = opts and opts.defaultNo
|
||||
self.auto = opts and opts.auto
|
||||
local box = Theme.textBox or {}
|
||||
self.boxTx = box.tx or BOX_TX
|
||||
self.boxTy = box.ty or BOX_TY
|
||||
self.boxTw = box.tw or BOX_TW
|
||||
self.boxTh = box.th or BOX_TH
|
||||
self.maxCols = box.maxCols or MAX_COLS
|
||||
self.textX = (self.boxTx + 1) * 8
|
||||
self.line1Y = (self.boxTy + 2) * 8
|
||||
self.line2Y = (self.boxTy + 4) * 8
|
||||
text = TextBox.substitute(game, text)
|
||||
self.pages = TextBox.paginate(text)
|
||||
self.pages = TextBox.paginate(text, self.maxCols)
|
||||
self.pageIndex = 1
|
||||
self.lineIndex = 1
|
||||
self.charIndex = 0
|
||||
@@ -46,23 +56,35 @@ function TextBox.new(game, text, onDone, opts)
|
||||
return self
|
||||
end
|
||||
|
||||
function TextBox.substitute(game, text)
|
||||
local save = game.save
|
||||
text = text:gsub("{PLAYER}", save.player.name or "RED")
|
||||
text = text:gsub("{RIVAL}", save.player.rival or "BLUE")
|
||||
-- wStringBuffer: give_item copies the item name here, like GiveItem ->
|
||||
-- CopyToStringBuffer (home/give.asm); "received item!" texts read it
|
||||
-- (staying set afterwards mirrors pokered's stale-buffer semantics)
|
||||
if game.stringBuffer then
|
||||
text = text:gsub("{RAM:wStringBuffer}", game.stringBuffer)
|
||||
-- The runtime tokens substitute() knows, as handlers the tokens registry
|
||||
-- serves. Each is fn(game, arg) -> replacement, or nil to drop the token.
|
||||
-- RAM keeps pokered's stale-buffer semantics: give_item copies the item
|
||||
-- name into stringBuffer, like GiveItem -> CopyToStringBuffer
|
||||
-- (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,
|
||||
RAM = function(game, arg)
|
||||
return arg == "wStringBuffer" and game.stringBuffer or nil
|
||||
end,
|
||||
}
|
||||
|
||||
function TextBox.registerInto(registry, _, owner)
|
||||
for id, handler in pairs(TextBox.TOKENS) do
|
||||
registry:register(id, handler, owner)
|
||||
end
|
||||
text = text:gsub("{[%w_:]+}", "") -- other runtime tokens: drop visibly-empty
|
||||
return text
|
||||
end
|
||||
|
||||
function TextBox.substitute(game, text)
|
||||
local Tokens = require("src.script.Tokens")
|
||||
local handlers = game.data and game.data.tokens or TextBox.TOKENS
|
||||
return Tokens.expand(game, text, handlers)
|
||||
end
|
||||
|
||||
-- Split marked-up text into pages of lines. \v-scrolled lines become
|
||||
-- additional lines on the same page (the box scrolls them).
|
||||
function TextBox.paginate(text)
|
||||
function TextBox.paginate(text, maxCols)
|
||||
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
||||
local pages = {}
|
||||
for pageText in (text .. "\f"):gmatch("(.-)\f") do
|
||||
if pageText ~= "" then
|
||||
@@ -70,9 +92,9 @@ function TextBox.paginate(text)
|
||||
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
|
||||
local line = chunk
|
||||
-- wrap long lines defensively (the source rarely needs it)
|
||||
while #line > MAX_COLS do
|
||||
local cut = MAX_COLS
|
||||
for i = MAX_COLS, 1, -1 do
|
||||
while #line > maxCols do
|
||||
local cut = maxCols
|
||||
for i = maxCols, 1, -1 do
|
||||
if line:sub(i, i) == " " then cut = i break end
|
||||
end
|
||||
table.insert(lines, line:sub(1, cut))
|
||||
@@ -194,25 +216,27 @@ function TextBox:update(dt)
|
||||
end
|
||||
|
||||
function TextBox:draw()
|
||||
Font.drawBox(BOX_TX, BOX_TY, BOX_TW, BOX_TH)
|
||||
Font.drawBox(self.boxTx, self.boxTy, self.boxTw, self.boxTh)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
if self.scrollPx and self.scrollPx > 0 then
|
||||
self.scrollPx = self.scrollPx - 2
|
||||
if self.scrollPx <= 0 then self.scrollPx = nil end
|
||||
end
|
||||
local off = self.scrollPx or 0
|
||||
local ys = { LINE1_Y, LINE2_Y }
|
||||
local ys = { self.line1Y, self.line2Y }
|
||||
for i, line in ipairs(self.shown) do
|
||||
local y = (ys[i] or LINE2_Y) + off
|
||||
local y = (ys[i] or self.line2Y) + off
|
||||
for j, code in ipairs(line) do
|
||||
Font.drawCode(code, TEXT_X + (j - 1) * 8, y)
|
||||
Font.drawCode(code, self.textX + (j - 1) * 8, y)
|
||||
end
|
||||
end
|
||||
if (self.waiting or (self.done and not self.choice and not self.auto))
|
||||
and self.blink < 30 then
|
||||
-- page-advance cursor: glyph $EE, the blinking down arrow the original
|
||||
-- prints via `ld a, "▼"` (home/text.asm)
|
||||
Font.drawCode(0xEE, 18 * 8, (BOX_TY + 5) * 8 - 4)
|
||||
-- page-advance cursor: glyph $EE by default, the blinking down arrow
|
||||
-- the original prints via `ld a, "▼"` (home/text.asm)
|
||||
Font.drawCode(Theme.moreArrow or 0xEE,
|
||||
(self.boxTx + self.boxTw - 2) * 8,
|
||||
(self.boxTy + self.boxTh - 1) * 8 - 4)
|
||||
end
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
+223
-119
@@ -2,6 +2,9 @@
|
||||
-- a single static SpriteBatch covering the map plus a border-block ring
|
||||
-- (the ring plays the role of the GB border blocks around small maps).
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local TileRenderer = {}
|
||||
TileRenderer.__index = TileRenderer
|
||||
|
||||
@@ -24,7 +27,7 @@ local imageCache = {}
|
||||
|
||||
local function getImage(path)
|
||||
if not imageCache[path] then
|
||||
imageCache[path] = love.graphics.newImage(path)
|
||||
imageCache[path] = Assets.image(path)
|
||||
end
|
||||
return imageCache[path]
|
||||
end
|
||||
@@ -33,6 +36,9 @@ end
|
||||
-- Tile animation (home/vcopy.asm): tilesets with TILEANIM_WATER[_FLOWER]
|
||||
-- rotate water tile $14 one pixel every 20 frames (4 steps right, 4
|
||||
-- left) and cycle flower tile $03 through 3 frames.
|
||||
-- Those two cycles are the *defaults* a vanilla tileset record derives
|
||||
-- from its `animation` string; a tileset that carries `animatedTiles`
|
||||
-- declares its own set instead and animates with no engine change.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
local WATER_TILE, FLOWER_TILE = 0x14, 0x03
|
||||
@@ -40,6 +46,13 @@ local WATER_TILE, FLOWER_TILE = 0x14, 0x03
|
||||
local WATER_OFFSETS = { 1, 2, 3, 2, 1, 0, 7, 0 }
|
||||
-- flower frame per step (wMovingBGTilesCounter2 & 3: <2 -> 1, 2, 3)
|
||||
local FLOWER_FRAMES = { 1, 2, 3, 1, 1, 2, 3, 1 }
|
||||
local ANIM_PERIOD = 20
|
||||
local FLOWER_IMAGES = {
|
||||
"assets/generated/tilesets/flower1.png",
|
||||
"assets/generated/tilesets/flower2.png",
|
||||
"assets/generated/tilesets/flower3.png",
|
||||
}
|
||||
local SPINNER_STRIP = "assets/generated/tilesets/spinners.png"
|
||||
|
||||
local animFrame = 0
|
||||
function TileRenderer.tick()
|
||||
@@ -87,19 +100,26 @@ function TileRenderer.spinBlurActive()
|
||||
return spinning and (math.floor(animFrame / 8) % 2 == 0)
|
||||
end
|
||||
|
||||
-- the 8 shifted variants of a tileset's water tile (built once per sheet)
|
||||
local waterVariants = {}
|
||||
local function getWaterVariants(tilesetImagePath, perRow)
|
||||
if waterVariants[tilesetImagePath] ~= nil then
|
||||
return waterVariants[tilesetImagePath]
|
||||
end
|
||||
-- ------------------------------------------------------------------
|
||||
-- animatedTiles: the per-kind resource builders. Each returns the
|
||||
-- texture list a step indexes into, or false when the pixels are
|
||||
-- unreachable (headless, or a missing frame file) -- false disables that
|
||||
-- one entry and leaves the static batch showing through, which is what
|
||||
-- the water/flower branches did before they were data.
|
||||
-- ------------------------------------------------------------------
|
||||
|
||||
-- the 8 shifted variants of one tile (built once per sheet + tile id)
|
||||
local shiftVariants = {}
|
||||
local function getShiftVariants(tilesetImagePath, perRow, tile)
|
||||
local key = tilesetImagePath .. "#" .. tile
|
||||
if shiftVariants[key] ~= nil then return shiftVariants[key] end
|
||||
if not (love.image and love.image.newImageData) then
|
||||
waterVariants[tilesetImagePath] = false
|
||||
shiftVariants[key] = false
|
||||
return false
|
||||
end
|
||||
local id = love.image.newImageData(tilesetImagePath)
|
||||
local sx = (WATER_TILE % perRow) * 8
|
||||
local sy = math.floor(WATER_TILE / perRow) * 8
|
||||
local id = Assets.imageData(tilesetImagePath)
|
||||
local sx = (tile % perRow) * 8
|
||||
local sy = math.floor(tile / perRow) * 8
|
||||
local out = {}
|
||||
for o = 0, 7 do
|
||||
local v = love.image.newImageData(8, 8)
|
||||
@@ -111,74 +131,153 @@ local function getWaterVariants(tilesetImagePath, perRow)
|
||||
end
|
||||
out[o + 1] = love.graphics.newImage(v)
|
||||
end
|
||||
waterVariants[tilesetImagePath] = out
|
||||
shiftVariants[key] = out
|
||||
return out
|
||||
end
|
||||
|
||||
local flowerFrames
|
||||
local function getFlowerFrames()
|
||||
if flowerFrames ~= nil then return flowerFrames end
|
||||
flowerFrames = {}
|
||||
for i = 1, 3 do
|
||||
local ok, img = pcall(love.graphics.newImage,
|
||||
("assets/generated/tilesets/flower%d.png"):format(i))
|
||||
if not ok then flowerFrames = false return false end
|
||||
flowerFrames[i] = img
|
||||
local frameImages = {}
|
||||
local function getFrameImages(paths)
|
||||
local key = table.concat(paths, "|")
|
||||
if frameImages[key] ~= nil then return frameImages[key] end
|
||||
local out = {}
|
||||
for i, path in ipairs(paths) do
|
||||
local ok, img = pcall(getImage, path)
|
||||
if not ok then
|
||||
frameImages[key] = false
|
||||
return false
|
||||
end
|
||||
out[i] = img
|
||||
end
|
||||
return flowerFrames
|
||||
frameImages[key] = out
|
||||
return out
|
||||
end
|
||||
|
||||
-- the tileset's own atlas ImageData with the 4 spinner-tile slots blitted
|
||||
-- over with the shared blur strip (assets/generated/tilesets/spinners.png,
|
||||
-- extracted from gfx/overworld/spinners.png); cached per tileset image path
|
||||
local spinnerBlurImages = {}
|
||||
local spinnerStripData
|
||||
local function getSpinnerBlurImage(tilesetId, tilesetImagePath, perRow)
|
||||
if spinnerBlurImages[tilesetImagePath] ~= nil then
|
||||
return spinnerBlurImages[tilesetImagePath]
|
||||
end
|
||||
if not (love.image and love.image.newImageData) then
|
||||
spinnerBlurImages[tilesetImagePath] = false
|
||||
-- the tileset's own atlas ImageData with the patched tile slots blitted
|
||||
-- over with a shared strip (vanilla: assets/generated/tilesets/spinners.png,
|
||||
-- extracted from gfx/overworld/spinners.png); cached per tileset + strip
|
||||
local toggleImages = {}
|
||||
local stripData = {}
|
||||
local function getToggleImage(spec, tilesetImagePath, perRow)
|
||||
local key = tilesetImagePath .. "#" .. tostring(spec.image)
|
||||
if toggleImages[key] ~= nil then return toggleImages[key] end
|
||||
local offsets = spec.stripOffsets
|
||||
if not (love.image and love.image.newImageData) or not offsets then
|
||||
toggleImages[key] = false
|
||||
return false
|
||||
end
|
||||
local destTiles = TileRenderer.SPINNER_ARROW_TILES[tilesetId]
|
||||
local offsets = SPINNER_STRIP_OFFSET[tilesetId]
|
||||
if not (destTiles and offsets) then
|
||||
spinnerBlurImages[tilesetImagePath] = false
|
||||
if stripData[spec.image] == nil then
|
||||
local ok, id = pcall(Assets.imageData, spec.image)
|
||||
stripData[spec.image] = ok and id or false
|
||||
end
|
||||
local strip = stripData[spec.image]
|
||||
if not strip then
|
||||
toggleImages[key] = false
|
||||
return false
|
||||
end
|
||||
if spinnerStripData == nil then
|
||||
local ok, id = pcall(love.image.newImageData,
|
||||
"assets/generated/tilesets/spinners.png")
|
||||
spinnerStripData = ok and id or false
|
||||
end
|
||||
if not spinnerStripData then
|
||||
spinnerBlurImages[tilesetImagePath] = false
|
||||
return false
|
||||
end
|
||||
local atlas = love.image.newImageData(tilesetImagePath)
|
||||
local atlas = Assets.imageData(tilesetImagePath)
|
||||
local clone = love.image.newImageData(atlas:getWidth(), atlas:getHeight())
|
||||
clone:paste(atlas, 0, 0, 0, 0, atlas:getWidth(), atlas:getHeight())
|
||||
for _, id in ipairs(destTiles) do
|
||||
local sx = offsets[id] * 8
|
||||
for id, offset in pairs(offsets) do
|
||||
local sx = offset * 8
|
||||
local dx = (id % perRow) * 8
|
||||
local dy = math.floor(id / perRow) * 8
|
||||
for y = 0, 7 do
|
||||
for x = 0, 7 do
|
||||
local r, g, b, a = spinnerStripData:getPixel(sx + x, y)
|
||||
local r, g, b, a = strip:getPixel(sx + x, y)
|
||||
clone:setPixel(dx + x, dy + y, r, g, b, a)
|
||||
end
|
||||
end
|
||||
end
|
||||
local img = love.graphics.newImage(clone)
|
||||
spinnerBlurImages[tilesetImagePath] = img
|
||||
toggleImages[key] = img
|
||||
return img
|
||||
end
|
||||
|
||||
-- a toggle entry names the predicate that decides whether its patch shows
|
||||
-- this frame; an unknown name (or none) is always on
|
||||
TileRenderer.GATES = {
|
||||
spinning = function() return TileRenderer.spinBlurActive() end,
|
||||
}
|
||||
|
||||
function TileRenderer.registerGate(name, predicate)
|
||||
TileRenderer.GATES[name] = predicate
|
||||
end
|
||||
|
||||
local function gateOpen(name)
|
||||
local predicate = TileRenderer.GATES[name]
|
||||
if not predicate then return true end
|
||||
return predicate() and true or false
|
||||
end
|
||||
|
||||
-- The vanilla animation set as data: what the importer would write onto a
|
||||
-- tileset record derived from its `animation` string and its spinner-tile
|
||||
-- row. Consulted only when the record declares no animatedTiles of its
|
||||
-- own, so the vanilla frame is byte-for-byte what it always was.
|
||||
function TileRenderer.defaultAnimatedTiles(tileset)
|
||||
local out = {}
|
||||
local anim = tileset.animation
|
||||
if anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER" then
|
||||
out[#out + 1] = { tile = WATER_TILE, kind = "hshift",
|
||||
period = ANIM_PERIOD, offsets = WATER_OFFSETS }
|
||||
end
|
||||
if anim == "TILEANIM_WATER_FLOWER" then
|
||||
out[#out + 1] = { tile = FLOWER_TILE, kind = "frames",
|
||||
period = ANIM_PERIOD, images = FLOWER_IMAGES,
|
||||
sequence = FLOWER_FRAMES }
|
||||
end
|
||||
local spinners = TileRenderer.SPINNER_ARROW_TILES[tileset.id]
|
||||
if spinners then
|
||||
out[#out + 1] = { tiles = spinners, kind = "toggle", image = SPINNER_STRIP,
|
||||
stripOffsets = SPINNER_STRIP_OFFSET[tileset.id],
|
||||
gate = "spinning" }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- one entry's runtime form: the tile ids it claims, the textures a step
|
||||
-- picks from, and either a step sequence (hshift/frames) or a gate
|
||||
-- (toggle). nil when the entry's pixels could not be built.
|
||||
local function buildAnim(spec, tilesetImagePath, perRow, quads)
|
||||
local tiles = spec.tiles
|
||||
if not tiles then
|
||||
if spec.tile == nil then return nil end
|
||||
tiles = { spec.tile }
|
||||
end
|
||||
local period = spec.period or ANIM_PERIOD
|
||||
if spec.kind == "hshift" then
|
||||
local offsets = spec.offsets
|
||||
if not offsets or #offsets == 0 then return nil end
|
||||
local textures = getShiftVariants(tilesetImagePath, perRow, tiles[1])
|
||||
if not textures then return nil end
|
||||
local sequence = {}
|
||||
for i, offset in ipairs(offsets) do sequence[i] = offset + 1 end
|
||||
return { tiles = tiles, textures = textures, sequence = sequence,
|
||||
period = period }
|
||||
elseif spec.kind == "frames" then
|
||||
local sequence = spec.sequence
|
||||
if not (spec.images and sequence and #sequence > 0) then return nil end
|
||||
local textures = getFrameImages(spec.images)
|
||||
if not textures then return nil end
|
||||
return { tiles = tiles, textures = textures, sequence = sequence,
|
||||
period = period }
|
||||
elseif spec.kind == "toggle" then
|
||||
local image = getToggleImage(spec, tilesetImagePath, perRow)
|
||||
if not image then return nil end
|
||||
-- the patch texture is a whole-atlas clone, so each cell needs the
|
||||
-- quad of the tile it stands in rather than a single-tile image
|
||||
return { tiles = tiles, textures = { image }, gate = spec.gate,
|
||||
quadFor = function(tile) return quads[tile] end }
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function TileRenderer.new(map)
|
||||
local self = setmetatable({}, TileRenderer)
|
||||
self.map = map
|
||||
self.image = getImage(map.tileset.image)
|
||||
-- a full-color atlas colors everything it paints, ring and border fill
|
||||
-- included, so every draw entry point claims its rect out of the pass
|
||||
self.trueColor = map.tileset.trueColor or nil
|
||||
|
||||
local iw, ih = self.image:getDimensions()
|
||||
self.quads = {}
|
||||
@@ -195,20 +294,22 @@ function TileRenderer.new(map)
|
||||
local total = (wB + 2 * BORDER_BLOCKS) * (hB + 2 * BORDER_BLOCKS) * 16
|
||||
self.ringBatch = love.graphics.newSpriteBatch(self.image, total, "static")
|
||||
self.mapBatch = love.graphics.newSpriteBatch(self.image, wB * hB * 16, "static")
|
||||
-- animated tiles overdraw the static batches each frame
|
||||
local anim = map.tileset.animation
|
||||
local animWater = anim == "TILEANIM_WATER" or anim == "TILEANIM_WATER_FLOWER"
|
||||
local variants = animWater and getWaterVariants(map.tileset.image, perRow)
|
||||
local flowers = anim == "TILEANIM_WATER_FLOWER" and getFlowerFrames()
|
||||
-- Gym/Rocket-Hideout spinner-arrow tiles (see SPINNER_ARROW_TILES above);
|
||||
-- only GYM/FACILITY tilesets carry these dest tile ids
|
||||
local spinnerIds = TileRenderer.SPINNER_ARROW_TILES[map.tileset.id]
|
||||
local spinnerSet
|
||||
if spinnerIds then
|
||||
spinnerSet = {}
|
||||
for _, id in ipairs(spinnerIds) do spinnerSet[id] = true end
|
||||
-- animated tiles overdraw the static batches each frame. Entry order
|
||||
-- decides which one claims a tile listed twice, so the vanilla defaults
|
||||
-- keep the old water-then-flower-then-spinner precedence.
|
||||
local anims, claimedBy = {}, {}
|
||||
local declared = map.tileset.animatedTiles
|
||||
or TileRenderer.defaultAnimatedTiles(map.tileset)
|
||||
for _, spec in ipairs(declared) do
|
||||
local anim = buildAnim(spec, map.tileset.image, perRow, self.quads)
|
||||
if anim then
|
||||
anim.cells = {}
|
||||
anims[#anims + 1] = anim
|
||||
for _, tile in ipairs(anim.tiles) do
|
||||
if claimedBy[tile] == nil then claimedBy[tile] = anim end
|
||||
end
|
||||
end
|
||||
end
|
||||
local water, flower, spinner = {}, {}, {}
|
||||
|
||||
for by = -BORDER_BLOCKS, hB + BORDER_BLOCKS - 1 do
|
||||
for bx = -BORDER_BLOCKS, wB + BORDER_BLOCKS - 1 do
|
||||
@@ -222,12 +323,10 @@ function TileRenderer.new(map)
|
||||
if quad then
|
||||
batch:add(quad, bx * 32 + tx * 8, by * 32 + ty * 8)
|
||||
end
|
||||
if variants and tile == WATER_TILE then
|
||||
table.insert(water, { bx * 32 + tx * 8, by * 32 + ty * 8, inside })
|
||||
elseif flowers and tile == FLOWER_TILE then
|
||||
table.insert(flower, { bx * 32 + tx * 8, by * 32 + ty * 8, inside })
|
||||
elseif spinnerSet and spinnerSet[tile] then
|
||||
table.insert(spinner, { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile })
|
||||
local anim = claimedBy[tile]
|
||||
if anim then
|
||||
local cells = anim.cells
|
||||
cells[#cells + 1] = { bx * 32 + tx * 8, by * 32 + ty * 8, inside, tile }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -237,9 +336,9 @@ function TileRenderer.new(map)
|
||||
-- animated overdraw batches: the full set (ring + body) for the
|
||||
-- current map, and a body-only set for connected-map drawing --
|
||||
-- a neighbor's water ring must never overdraw this map's tiles.
|
||||
-- `quadFor`, when given, looks up a per-entry quad (used by the spinner
|
||||
-- batch, whose texture is a full tileset-atlas clone rather than a
|
||||
-- single-tile image like the water/flower variants).
|
||||
-- `quadFor`, when given, looks up a per-entry quad (used by toggle
|
||||
-- entries, whose texture is a full tileset-atlas clone rather than a
|
||||
-- single-tile image like the hshift/frames variants).
|
||||
local function animBatches(entries, image, quadFor)
|
||||
if #entries == 0 then return nil, nil end
|
||||
local all = love.graphics.newSpriteBatch(image, #entries, "static")
|
||||
@@ -253,23 +352,12 @@ function TileRenderer.new(map)
|
||||
end
|
||||
return all, body
|
||||
end
|
||||
if variants then
|
||||
self.waterBatch, self.waterBodyBatch = animBatches(water, variants[1])
|
||||
self.waterVariants = self.waterBatch and variants or nil
|
||||
end
|
||||
if flowers then
|
||||
self.flowerBatch, self.flowerBodyBatch = animBatches(flower, flowers[1])
|
||||
self.flowerFrames = self.flowerBatch and flowers or nil
|
||||
end
|
||||
if spinnerSet then
|
||||
local blurImage = getSpinnerBlurImage(map.tileset.id, map.tileset.image, perRow)
|
||||
if blurImage then
|
||||
local quads = self.quads
|
||||
self.spinnerBatch, self.spinnerBodyBatch =
|
||||
animBatches(spinner, blurImage, function(tile) return quads[tile] end)
|
||||
self.spinnerBlurImage = self.spinnerBatch and blurImage or nil
|
||||
end
|
||||
for _, anim in ipairs(anims) do
|
||||
anim.batch, anim.bodyBatch =
|
||||
animBatches(anim.cells, anim.textures[1], anim.quadFor)
|
||||
anim.cells = nil
|
||||
end
|
||||
self.anims = anims
|
||||
|
||||
-- a repeating 32x32 image of the border block, tiled behind
|
||||
-- everything the 3-block ring doesn't cover (the survey zoom sees
|
||||
@@ -302,6 +390,7 @@ end
|
||||
-- meshes seamlessly with the ring batch)
|
||||
function TileRenderer:drawBorderFill(camX, camY, vw, vh)
|
||||
if not self.borderFill then return end
|
||||
if self.trueColor then PaletteFX.markTrueColor(0, 0, vw, vh) end
|
||||
local x, y = math.floor(camX), math.floor(camY)
|
||||
local quad = love.graphics.newQuad(x, y, vw, vh, 32, 32)
|
||||
love.graphics.draw(self.borderFill, quad, 0, 0)
|
||||
@@ -349,33 +438,41 @@ function TileRenderer:drawCellBottom(cx, cy, camX, camY)
|
||||
if shader then love.graphics.setShader() end
|
||||
end
|
||||
|
||||
-- water/flower overdraw at the current animation step; bodyOnly skips
|
||||
-- the ring positions (connected maps draw body-only)
|
||||
-- animated overdraw at the current step; bodyOnly skips the ring
|
||||
-- positions (connected maps draw body-only)
|
||||
function TileRenderer:drawAnimated(camX, camY, bodyOnly)
|
||||
local waterBatch = bodyOnly and self.waterBodyBatch or self.waterBatch
|
||||
local flowerBatch = bodyOnly and self.flowerBodyBatch or self.flowerBatch
|
||||
local spinnerBatch = bodyOnly and self.spinnerBodyBatch or self.spinnerBatch
|
||||
if not (waterBatch or flowerBatch or spinnerBatch) then return end
|
||||
local i = (math.floor(animFrame / 20) % 8) + 1
|
||||
local anims = self.anims
|
||||
if not anims then return end
|
||||
local x, y = -math.floor(camX), -math.floor(camY)
|
||||
if waterBatch then
|
||||
waterBatch:setTexture(self.waterVariants[WATER_OFFSETS[i] + 1])
|
||||
love.graphics.draw(waterBatch, x, y)
|
||||
end
|
||||
if flowerBatch then
|
||||
flowerBatch:setTexture(self.flowerFrames[FLOWER_FRAMES[i]])
|
||||
love.graphics.draw(flowerBatch, x, y)
|
||||
end
|
||||
-- spinner arrow tiles (engine/overworld/spinners.asm): only 2 frames
|
||||
-- (blur / restore-to-static), gated on spinBlurActive() rather than the
|
||||
-- free-running water/flower cycle above -- when false, draw nothing so
|
||||
-- the already-static mapBatch/ringBatch tile shows through unchanged
|
||||
if spinnerBatch and TileRenderer.spinBlurActive() then
|
||||
love.graphics.draw(spinnerBatch, x, y)
|
||||
for _, anim in ipairs(anims) do
|
||||
local batch = bodyOnly and anim.bodyBatch or anim.batch
|
||||
if batch then
|
||||
if anim.gate then
|
||||
-- a gated entry has only the two frames the asm has (patch /
|
||||
-- restore-to-static); when the gate is shut draw nothing so the
|
||||
-- already-static mapBatch/ringBatch tile shows through unchanged
|
||||
if gateOpen(anim.gate) then love.graphics.draw(batch, x, y) end
|
||||
else
|
||||
local step = math.floor(animFrame / anim.period) % #anim.sequence + 1
|
||||
batch:setTexture(anim.textures[anim.sequence[step]])
|
||||
love.graphics.draw(batch, x, y)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- the drawn extent of one batch in world-canvas pixels; `blocks` is the
|
||||
-- ring width the batch reaches past the map body on every side
|
||||
function TileRenderer:markTrueColor(camX, camY, blocks)
|
||||
local def = self.map.def
|
||||
PaletteFX.markTrueColor(-math.floor(camX) - blocks * 32,
|
||||
-math.floor(camY) - blocks * 32,
|
||||
(def.width + 2 * blocks) * 32,
|
||||
(def.height + 2 * blocks) * 32)
|
||||
end
|
||||
|
||||
function TileRenderer:draw(camX, camY)
|
||||
if self.trueColor then self:markTrueColor(camX, camY, BORDER_BLOCKS) end
|
||||
love.graphics.draw(self.ringBatch, -math.floor(camX), -math.floor(camY))
|
||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
||||
self:drawAnimated(camX, camY)
|
||||
@@ -383,6 +480,7 @@ end
|
||||
|
||||
-- body only, for connected-map strips
|
||||
function TileRenderer:drawMapOnly(camX, camY)
|
||||
if self.trueColor then self:markTrueColor(camX, camY, 0) end
|
||||
love.graphics.draw(self.mapBatch, -math.floor(camX), -math.floor(camY))
|
||||
self:drawAnimated(camX, camY, true)
|
||||
end
|
||||
@@ -392,16 +490,22 @@ function TileRenderer:rebuild()
|
||||
local fresh = TileRenderer.new(self.map)
|
||||
self.ringBatch = fresh.ringBatch
|
||||
self.mapBatch = fresh.mapBatch
|
||||
self.waterBatch = fresh.waterBatch
|
||||
self.waterBodyBatch = fresh.waterBodyBatch
|
||||
self.waterVariants = fresh.waterVariants
|
||||
self.flowerBatch = fresh.flowerBatch
|
||||
self.flowerBodyBatch = fresh.flowerBodyBatch
|
||||
self.flowerFrames = fresh.flowerFrames
|
||||
self.spinnerBatch = fresh.spinnerBatch
|
||||
self.spinnerBodyBatch = fresh.spinnerBodyBatch
|
||||
self.spinnerBlurImage = fresh.spinnerBlurImage
|
||||
self.anims = fresh.anims
|
||||
self.borderFill = fresh.borderFill
|
||||
end
|
||||
|
||||
-- drop every atlas and every derived animation texture so the next
|
||||
-- TileRenderer.new re-resolves through the asset search path. Live
|
||||
-- instances keep the batches they already built; MapLoader.invalidateAll
|
||||
-- is what drops those (14 §cache-invalidation contract).
|
||||
function TileRenderer.invalidate()
|
||||
imageCache = {}
|
||||
shiftVariants = {}
|
||||
frameImages = {}
|
||||
toggleImages = {}
|
||||
stripData = {}
|
||||
end
|
||||
|
||||
Assets.register(TileRenderer.invalidate)
|
||||
|
||||
return TileRenderer
|
||||
|
||||
@@ -5,6 +5,29 @@ local Transition = {}
|
||||
Transition.__index = Transition
|
||||
|
||||
local FRAMES = 12
|
||||
local FLASH_FRAMES = 7
|
||||
|
||||
-- The two fades as transitions records, so a mod retimes a warp fade the
|
||||
-- same way it retimes a battle wipe. BattleTransition.registerInto pulls
|
||||
-- these in with its eight wipes -- one registrant owns the registry.
|
||||
Transition.STYLES = {
|
||||
warp_fade = { kind = "fade", frames = FRAMES },
|
||||
white_flash = { kind = "fade", frames = FLASH_FRAMES },
|
||||
}
|
||||
|
||||
function Transition.registerInto(registry, _, owner)
|
||||
for id, record in pairs(Transition.STYLES) do
|
||||
registry:register(id, record, owner)
|
||||
end
|
||||
end
|
||||
|
||||
-- the merged record, falling back to the built-in when no data is around
|
||||
-- (headless callers, and any state built before Data:load)
|
||||
local function styleOf(game, id)
|
||||
local data = game and game.data
|
||||
local record = data and data.transitions and data.transitions[id]
|
||||
return record or Transition.STYLES[id]
|
||||
end
|
||||
|
||||
function Transition.new(game, onMidpoint, onDone)
|
||||
local self = setmetatable({}, Transition)
|
||||
@@ -13,12 +36,13 @@ function Transition.new(game, onMidpoint, onDone)
|
||||
self.onDone = onDone
|
||||
self.t = 0
|
||||
self.phase = "out"
|
||||
self.frames = styleOf(game, "warp_fade").frames or FRAMES
|
||||
return self
|
||||
end
|
||||
|
||||
function Transition:update(dt)
|
||||
self.t = self.t + 1
|
||||
if self.t >= FRAMES then
|
||||
if self.t >= self.frames then
|
||||
self.t = 0
|
||||
if self.phase == "out" then
|
||||
self.phase = "in"
|
||||
@@ -31,7 +55,7 @@ function Transition:update(dt)
|
||||
end
|
||||
|
||||
function Transition:draw()
|
||||
local alpha = self.t / FRAMES
|
||||
local alpha = self.t / self.frames
|
||||
if self.phase == "in" then alpha = 1 - alpha end
|
||||
love.graphics.setColor(0, 0, 0, alpha)
|
||||
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
||||
@@ -48,7 +72,9 @@ WhiteFlash.__index = WhiteFlash
|
||||
WhiteFlash.isOpaque = true
|
||||
|
||||
function Transition.whiteFlash(game, frames, onDone)
|
||||
return setmetatable({ game = game, frames = frames or 7,
|
||||
return setmetatable({ game = game,
|
||||
frames = frames or styleOf(game, "white_flash").frames
|
||||
or FLASH_FRAMES,
|
||||
onDone = onDone, t = 0 }, WhiteFlash)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user