big ass modding update

This commit is contained in:
bryanthaboi
2026-07-19 16:18:18 -04:00
parent b5a673b252
commit 47923d95b3
258 changed files with 31048 additions and 2310 deletions
+105
View File
@@ -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
+86 -21
View File
@@ -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
View File
@@ -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
View File
@@ -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
+46 -2
View File
@@ -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
View File
@@ -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
+14 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+29 -3
View File
@@ -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