mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
The new experience (#201)
* new launcher and save converts and pipeline * fixing bugs
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
-- Rendering pipelines: the engine side of the render_pipelines registry.
|
||||
--
|
||||
-- A pipeline is a display mode a mod owns. It may replace the overworld's
|
||||
-- world pass with geometry of its own (drawWorld) and/or post-process the
|
||||
-- finished composite (present). Everything else about being a display mode
|
||||
-- -- the OFF/1/2/3 ladder, the options row, the hotkey, persistence, the
|
||||
-- free-roam gate, and never letting a mod's error take the frame down -- is
|
||||
-- engine plumbing and lives here, so a renderer mod writes the two draw
|
||||
-- functions and declares the rest.
|
||||
--
|
||||
-- The two halves compose independently and in priority order: the highest
|
||||
-- priority eligible drawWorld renders the world, then every eligible
|
||||
-- present folds over whatever came out (the world pipeline's canvas, or the
|
||||
-- vanilla flat/tilt composite when none ran). A present that is switched
|
||||
-- off returns its input, so a full ladder of them costs nothing at level 0.
|
||||
--
|
||||
-- Nothing here reaches collision, movement, triggers or scripts: like
|
||||
-- survey zoom and tilt, a pipeline is purely presentational, which is why
|
||||
-- its level rides in save.options rather than the save proper.
|
||||
--
|
||||
-- Spec: docs/modding.md (rendering pipelines)
|
||||
|
||||
local Data = require("src.core.Data")
|
||||
local Logger = require("src.core.Logger")
|
||||
local Runtime = require("src.mods.Runtime")
|
||||
local Zoom = require("src.render.Zoom")
|
||||
|
||||
local Pipelines = {}
|
||||
|
||||
-- id -> level. Levels live here rather than on the records because the
|
||||
-- records are merged content: frozen after load, and shared with whatever
|
||||
-- else reads Data.
|
||||
local levels = {}
|
||||
|
||||
-- ids whose callbacks have already thrown, so a pipeline that fails every
|
||||
-- frame reports once instead of filling the log at 60Hz
|
||||
local broken = {}
|
||||
|
||||
Pipelines.DEFAULT_LEVELS = { "OFF", "ON" }
|
||||
|
||||
-- The merged dataset the records live in. The boot singleton is the
|
||||
-- default -- Game.data is that very table -- and install() lets a headless
|
||||
-- caller (the SDK harness, a tool) point this at a dataset of its own.
|
||||
local source = Data
|
||||
|
||||
function Pipelines.install(data)
|
||||
source = data or Data
|
||||
Pipelines.reset()
|
||||
end
|
||||
|
||||
-- ------- catalog
|
||||
|
||||
-- Every registered pipeline as { id = ..., def = ... }, ordered by priority
|
||||
-- (descending, ties by id) so selection, the options rows and the present
|
||||
-- fold all walk the same sequence.
|
||||
--
|
||||
-- Memoized on the namespace table's identity. Content freezes at the merge
|
||||
-- boundary, so the answer cannot change for a given table -- and this is
|
||||
-- read several times per frame by update(), worldPipeline() and the
|
||||
-- endFrame present check, which is no place to allocate and sort. An empty
|
||||
-- list is cached too, so a mod-free boot pays one table for the process.
|
||||
local listCache, listSource = nil, nil
|
||||
|
||||
function Pipelines.list()
|
||||
local defs = source and source.render_pipelines
|
||||
if defs == listSource and listCache then return listCache end
|
||||
local out = {}
|
||||
if type(defs) == "table" then
|
||||
for id, def in pairs(defs) do
|
||||
-- the merge writes provenance under _owners; skip the bookkeeping
|
||||
-- keys rather than treating them as pipelines
|
||||
if type(id) == "string" and id:sub(1, 1) ~= "_" and type(def) == "table" then
|
||||
out[#out + 1] = { id = id, def = def }
|
||||
end
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
local pa, pb = a.def.priority or 0, b.def.priority or 0
|
||||
if pa ~= pb then return pa > pb end
|
||||
return a.id < b.id
|
||||
end)
|
||||
end
|
||||
listCache, listSource = out, defs
|
||||
return out
|
||||
end
|
||||
|
||||
function Pipelines.get(id)
|
||||
local defs = source and source.render_pipelines
|
||||
local def = type(defs) == "table" and defs[id] or nil
|
||||
return type(def) == "table" and def or nil
|
||||
end
|
||||
|
||||
-- the mod that registered a pipeline, so a runtime failure lands in the
|
||||
-- feed the mod manager shows instead of only in the console
|
||||
local function ownerOf(id)
|
||||
local defs = source and source.render_pipelines
|
||||
local owners = type(defs) == "table" and defs._owners or nil
|
||||
return owners and owners[id] or nil
|
||||
end
|
||||
|
||||
-- Run one of a pipeline's callbacks under pcall. A mod that throws mid-
|
||||
-- frame must not take the frame with it: the pipeline is marked broken,
|
||||
-- attributed once, and treated as absent from then on -- which degrades to
|
||||
-- the vanilla 2D path rather than a black screen.
|
||||
local function guard(id, fn, ...)
|
||||
if broken[id] then return nil end
|
||||
local ok, result = pcall(fn, ...)
|
||||
if ok then return result end
|
||||
broken[id] = true
|
||||
Logger.error("render pipeline %s failed: %s -- disabled for this session",
|
||||
id, tostring(result))
|
||||
Runtime.reportError(ownerOf(id), "render pipeline failed: " .. tostring(result))
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Whether a callback's return is a real Canvas we can composite. A mod that
|
||||
-- forgets a return, or hands back a shade string / flag / number, must be
|
||||
-- ignored rather than trusted -- draw() on a non-canvas takes the frame down.
|
||||
-- Real LOVE canvases are userdata answering typeOf("Canvas"); the headless
|
||||
-- test stub (tests/love_stub) fakes them as tables carrying the Canvas method
|
||||
-- shape (love.graphics.newCanvas), so accept either and nothing else.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Dispatch a mod render callback with its GPU state fenced off: push("all")
|
||||
-- before and pop() after, so a callback that returns cleanly but leaves a
|
||||
-- shader bound, the canvas redirected, or blend/colour changed cannot corrupt
|
||||
-- the engine composite that follows. guard() catches a callback that throws;
|
||||
-- this catches one that dirties state. A pipeline already retired skips the
|
||||
-- push/pop entirely, so the stack stays balanced.
|
||||
local function guardRender(id, fn, ...)
|
||||
if broken[id] then return nil end
|
||||
love.graphics.push("all")
|
||||
local out = guard(id, fn, ...)
|
||||
love.graphics.pop()
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- levels
|
||||
|
||||
function Pipelines.levelLabels(id)
|
||||
local def = Pipelines.get(id)
|
||||
local labels = def and def.levels
|
||||
if type(labels) ~= "table" or labels[1] == nil then
|
||||
return Pipelines.DEFAULT_LEVELS
|
||||
end
|
||||
return labels
|
||||
end
|
||||
|
||||
-- highest selectable level: one less than the label count, so a two-label
|
||||
-- ladder is a plain OFF/ON toggle
|
||||
function Pipelines.maxLevel(id)
|
||||
return #Pipelines.levelLabels(id) - 1
|
||||
end
|
||||
|
||||
function Pipelines.level(id)
|
||||
return levels[id] or 0
|
||||
end
|
||||
|
||||
function Pipelines.levelLabel(id, level)
|
||||
local labels = Pipelines.levelLabels(id)
|
||||
return labels[(level or Pipelines.level(id)) + 1] or labels[1] or "OFF"
|
||||
end
|
||||
|
||||
-- A world pipeline and the engine's own tilt mode are two answers to the
|
||||
-- same question, so switching one on switches the other off -- the rule
|
||||
-- tilt and survey zoom already follow between themselves. Present-only
|
||||
-- pipelines (post-processes) compose with tilt and are left alone.
|
||||
local function excludeTilt(id, level)
|
||||
local def = Pipelines.get(id)
|
||||
if not (def and def.drawWorld) or level <= 0 then return end
|
||||
local Tilt = require("src.render.Tilt")
|
||||
if Tilt.level > 0 then Tilt.setLevel(0) end
|
||||
-- one world pipeline at a time, for the same reason
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.id ~= id and entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
||||
levels[entry.id] = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Pipelines.setLevel(id, level)
|
||||
if not Pipelines.get(id) then return 0 end
|
||||
level = math.floor(tonumber(level) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
local max = Pipelines.maxLevel(id)
|
||||
if level > max then level = max end
|
||||
levels[id] = level
|
||||
excludeTilt(id, level)
|
||||
return level
|
||||
end
|
||||
|
||||
-- Advance the ladder and wrap to OFF, the shape every display hotkey walks.
|
||||
function Pipelines.cycle(id, dir)
|
||||
local max = Pipelines.maxLevel(id)
|
||||
if max < 1 then return 0 end
|
||||
local span = max + 1
|
||||
local target = (Pipelines.level(id) + (dir or 1)) % span
|
||||
if target < 0 then target = target + span end
|
||||
return Pipelines.setLevel(id, target)
|
||||
end
|
||||
|
||||
-- Turning a world pipeline on must switch tilt off in the save too, not
|
||||
-- just in the live module, or the next boot restores both. Call sites hand
|
||||
-- over the options table so this stays the one place that rule lives.
|
||||
function Pipelines.syncOptions(opts)
|
||||
if type(opts) ~= "table" then return end
|
||||
local bucket = opts.pipelines
|
||||
if type(bucket) ~= "table" then
|
||||
bucket = {}
|
||||
opts.pipelines = bucket
|
||||
end
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
bucket[entry.id] = Pipelines.level(entry.id)
|
||||
if entry.def.drawWorld and Pipelines.level(entry.id) > 0 then
|
||||
opts.tilt = 0
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Restore levels from a loaded options table. A pipeline whose mod is gone
|
||||
-- keeps its stored level untouched in the bucket (so re-enabling the mod
|
||||
-- restores the mode) but contributes nothing while absent.
|
||||
function Pipelines.applyOptions(opts)
|
||||
local bucket = type(opts) == "table" and opts.pipelines or nil
|
||||
levels = {}
|
||||
broken = {}
|
||||
local world = nil
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
local stored = type(bucket) == "table" and bucket[entry.id] or 0
|
||||
local level = math.floor(tonumber(stored) or 0)
|
||||
if level < 0 then level = 0 end
|
||||
local max = Pipelines.maxLevel(entry.id)
|
||||
if level > max then level = max end
|
||||
-- list() is priority order, so the first world pipeline with a stored
|
||||
-- level is the one that wins; the rest restore to OFF rather than
|
||||
-- sitting on a level that can never render
|
||||
if entry.def.drawWorld and level > 0 then
|
||||
if world then level = 0 else world = entry.id end
|
||||
end
|
||||
levels[entry.id] = level
|
||||
end
|
||||
-- a restored world pipeline and tilt are two answers to the same
|
||||
-- question; the pipeline wins, as it does at every place that sets one
|
||||
if world then require("src.render.Tilt").setLevel(0) end
|
||||
end
|
||||
|
||||
function Pipelines.reset()
|
||||
levels = {}
|
||||
broken = {}
|
||||
end
|
||||
|
||||
-- ------- per-frame
|
||||
|
||||
-- Presentational tweens run on real frame time, like Tilt's. Every
|
||||
-- pipeline ticks, not just the active ones: a mode easing back OUT still
|
||||
-- has an angle to retire.
|
||||
function Pipelines.update(dt)
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.update then
|
||||
guardRender(entry.id, entry.def.update, dt, Pipelines.level(entry.id))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- A pipeline may run this frame when it is switched on, has not thrown, and
|
||||
-- its hardware gate says yes. `available` is consulted every frame rather
|
||||
-- than cached: a driver that loses its depth canvas on a resize has to be
|
||||
-- able to change its mind.
|
||||
--
|
||||
-- Deliberately NOT gated on the state stack. `gate` governs whether the
|
||||
-- player may CHANGE the mode, never whether it draws -- a display mode that
|
||||
-- stopped rendering during a warp, a scripted cutscene or an open menu
|
||||
-- would flash the flat 2D world for those frames every time the player
|
||||
-- walked through a door. Once a mode is on it renders until it is off.
|
||||
function Pipelines.eligible(id)
|
||||
local def = Pipelines.get(id)
|
||||
if not def or broken[id] then return false end
|
||||
if Pipelines.level(id) <= 0 then return false end
|
||||
if def.available and guard(id, def.available) ~= true then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
-- Whether the player may cycle this mode right now: the free-roam gate,
|
||||
-- which keeps a hotkey press from switching modes mid-warp or mid-cutscene.
|
||||
-- Input only -- see eligible() for why the draw path does not consult it.
|
||||
function Pipelines.canToggle(id, top, overworld)
|
||||
local def = Pipelines.get(id)
|
||||
if not def then return false end
|
||||
local gate = def.gate or Zoom.gateOK
|
||||
return guard(id, gate, top, overworld) == true
|
||||
end
|
||||
|
||||
-- The pipeline that owns the world pass right now, or nil for the vanilla
|
||||
-- flat/tilt draw. Highest priority wins; the exclusion rules above mean
|
||||
-- there is normally only one candidate anyway.
|
||||
function Pipelines.worldPipeline()
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.drawWorld and Pipelines.eligible(entry.id) then
|
||||
return entry.id, entry.def
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Render the world through `id`. Returns the canvas to composite, or nil
|
||||
-- when the pipeline declined this frame (nothing to draw, a transient
|
||||
-- failure), which the caller treats as "fall back to the 2D path".
|
||||
function Pipelines.drawWorld(id, ctx)
|
||||
local def = Pipelines.get(id)
|
||||
if not (def and def.drawWorld) then return nil end
|
||||
return guardRender(id, def.drawWorld, ctx)
|
||||
end
|
||||
|
||||
-- Fold every eligible world post-process over a pipeline's world image,
|
||||
-- before the UI composites on top. This is where a depth-of-field or a
|
||||
-- colour grade belongs when it must leave the dialog boxes and menus crisp;
|
||||
-- `present` below is the whole-frame counterpart. Only reachable once some
|
||||
-- pipeline rendered the world, so it is gated on the overworld state the
|
||||
-- same way drawWorld is.
|
||||
function Pipelines.worldPresent(canvas, ctx)
|
||||
if canvas == nil then return nil end
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.worldPresent and Pipelines.eligible(entry.id) then
|
||||
local out = guardRender(entry.id, entry.def.worldPresent, canvas, ctx)
|
||||
-- accept only a real Canvas: a pass that returns a non-canvas (a
|
||||
-- forgotten return, a shade string) is ignored, not folded in
|
||||
if isCanvas(out) then canvas = out end
|
||||
end
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- Fold every eligible post-process over the finished frame. Present
|
||||
-- pipelines are not gated on the overworld state -- a CRT curve or a colour
|
||||
-- grade applies to menus and battles too -- so eligibility here is just
|
||||
-- "switched on and available". A pass that returns a non-canvas is
|
||||
-- ignored rather than trusted, so a mod cannot blank the screen by
|
||||
-- forgetting a return.
|
||||
function Pipelines.present(canvas, ctx)
|
||||
if canvas == nil then return nil end
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.present and Pipelines.eligible(entry.id) then
|
||||
local out = guardRender(entry.id, entry.def.present, canvas, ctx)
|
||||
-- accept only a real Canvas: a pass that returns a non-canvas is
|
||||
-- ignored (docstring above), so a mod cannot blank or crash the frame
|
||||
-- by forgetting a return or handing back a truthy non-canvas
|
||||
if isCanvas(out) then canvas = out end
|
||||
end
|
||||
end
|
||||
return canvas
|
||||
end
|
||||
|
||||
-- true when any present-only pass wants to run, so the composite path can
|
||||
-- skip allocating a target it would not use
|
||||
function Pipelines.wantsPresent()
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.present and Pipelines.eligible(entry.id) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- ------- input and UI
|
||||
|
||||
-- Cycle whichever pipeline claims `key`. Returns the id when one did, so
|
||||
-- the caller knows the key was consumed. Checked after the engine's own
|
||||
-- display hotkeys, so a mod can never shadow one.
|
||||
function Pipelines.hotkey(key, top, overworld)
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.hotkey == key then
|
||||
-- the gate belongs here and nowhere else: it stops the player
|
||||
-- flipping modes mid-warp or mid-cutscene, and has no say over
|
||||
-- whether an already-on mode draws
|
||||
if Pipelines.canToggle(entry.id, top, overworld) then
|
||||
Pipelines.cycle(entry.id)
|
||||
return entry.id
|
||||
end
|
||||
return nil
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Options rows for every registered pipeline, in the same priority order,
|
||||
-- in the descriptor shape src/ui/OptionRows.lua renders.
|
||||
function Pipelines.rows(game)
|
||||
local rows = {}
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
local id = entry.id
|
||||
rows[#rows + 1] = {
|
||||
id = "pipeline:" .. id,
|
||||
label = entry.def.label or id:upper(),
|
||||
value = function() return Pipelines.levelLabel(id) end,
|
||||
step = function(g, dir)
|
||||
Pipelines.cycle(id, dir)
|
||||
local opts = g and g.save and g.save.options
|
||||
if opts then
|
||||
Pipelines.syncOptions(opts)
|
||||
-- the exclusion above may have switched tilt off; keep the live
|
||||
-- module in step with the option it just wrote
|
||||
require("src.render.Tilt").setLevel(opts.tilt or 0)
|
||||
end
|
||||
return true
|
||||
end,
|
||||
}
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
-- Drop every pipeline's GPU objects (window resize, hot reload).
|
||||
function Pipelines.invalidate()
|
||||
for _, entry in ipairs(Pipelines.list()) do
|
||||
if entry.def.invalidate then guardRender(entry.id, entry.def.invalidate) end
|
||||
end
|
||||
end
|
||||
|
||||
return Pipelines
|
||||
+76
-4
@@ -9,12 +9,28 @@
|
||||
local Zoom = require("src.render.Zoom")
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
|
||||
local Renderer = {}
|
||||
|
||||
Renderer.WIDTH = 160
|
||||
Renderer.HEIGHT = 144
|
||||
|
||||
-- Whether a value is a real Canvas we can composite. Real LOVE canvases are
|
||||
-- userdata answering typeOf("Canvas"); the headless test stub fakes them as
|
||||
-- tables carrying the Canvas method shape. A mod pipeline handing back a
|
||||
-- non-canvas must be rejected before it reaches love.graphics.draw, which
|
||||
-- would otherwise take the frame down with it.
|
||||
local function isCanvas(v)
|
||||
if type(v) == "userdata" then
|
||||
return type(v.typeOf) == "function" and v:typeOf("Canvas") == true
|
||||
end
|
||||
if type(v) == "table" then
|
||||
return type(v.getWidth) == "function" and type(v.getHeight) == "function"
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Tilt mode: the upright billboard canvas is grown by this many world
|
||||
-- pixels on every side beyond the ground world view, so a structure or
|
||||
-- sprite standing near a view edge still draws in full instead of being
|
||||
@@ -57,6 +73,22 @@ function Renderer:init()
|
||||
-- the projected ground in endFrame; never touched while tilt is off.
|
||||
self.uprightCanvas = nil
|
||||
self.uprightActive = false
|
||||
-- a render pipeline's finished world image, already at window resolution
|
||||
-- (see src/render/Pipelines.lua). nil is "no pipeline rendered this
|
||||
-- frame", which is every vanilla frame.
|
||||
self.worldOverride = nil
|
||||
end
|
||||
|
||||
-- Hand endFrame a pipeline's world image to composite instead of the world
|
||||
-- canvas. Cleared every frame, so a pipeline that declines one frame falls
|
||||
-- straight back to the 2D path rather than showing a stale image.
|
||||
function Renderer:setWorldOverride(canvas)
|
||||
-- Defensive: a pipeline that hands back a non-canvas (forgotten return, a
|
||||
-- truthy sentinel) must not reach the worldOverride blit in endFrame, where
|
||||
-- love.graphics.draw on it would crash the frame. Reject it and fall back
|
||||
-- to the 2D path rather than trust it.
|
||||
if canvas ~= nil and not isCanvas(canvas) then canvas = nil end
|
||||
self.worldOverride = canvas
|
||||
end
|
||||
|
||||
-- Integer framebuffer pixels per GB pixel that fit the window. Zoom /
|
||||
@@ -91,6 +123,7 @@ end
|
||||
function Renderer:beginFrame(transparent)
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
-- warp-fade overlay from Transition (issue #121); cleared each frame so
|
||||
-- a popped transition cannot leave a sticky black veil
|
||||
self.worldFadeAlpha = nil
|
||||
@@ -380,7 +413,11 @@ function Renderer:endFrame(zones, worldZones)
|
||||
zones = withTrueColor(zones, "ui")
|
||||
worldZones = withTrueColor(worldZones, "world")
|
||||
|
||||
local needPresent = GBCFX.active()
|
||||
-- A post-process pipeline needs the whole composite in a canvas for the
|
||||
-- same reason GBC FX does, so either one alone is enough to take the
|
||||
-- present path; with neither, the frame draws straight to the screen
|
||||
-- exactly as it always did.
|
||||
local needPresent = GBCFX.active() or Pipelines.wantsPresent()
|
||||
local present = nil
|
||||
if needPresent then
|
||||
if not self.presentCanvas or self.presentCanvas:getWidth() ~= ww
|
||||
@@ -441,7 +478,27 @@ function Renderer:endFrame(zones, worldZones)
|
||||
love.graphics.setShader()
|
||||
end
|
||||
|
||||
if self.worldActive then
|
||||
if self.worldOverride then
|
||||
-- A render pipeline already produced the whole world -- terrain,
|
||||
-- characters and its own FX overlay -- as one window-resolution image,
|
||||
-- so it composites with a straight 1:1 blit and the world canvas is
|
||||
-- skipped entirely (nothing drew into it). The UI blit below still
|
||||
-- runs, so dialogs, menus and the HUD sit on top as usual.
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.setScissor(0, 0, ww, wh)
|
||||
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpi, 1 / dpi)
|
||||
love.graphics.setScissor()
|
||||
-- the screen-space overlays the flat path draws over its composite
|
||||
local fade = self.worldFadeAlpha
|
||||
if fade and fade > 0 then
|
||||
love.graphics.setColor(0, 0, 0, fade)
|
||||
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
if self.battleCascadeProg then
|
||||
self:drawBattleCascade(self.battleCascadeProg, ww, wh, ox, oy, vpw, vph, S)
|
||||
end
|
||||
elseif self.worldActive then
|
||||
local sp = Zoom.scale(Sp)
|
||||
local s = sp / dpi
|
||||
local wvw = self.worldCanvas:getWidth()
|
||||
@@ -526,11 +583,26 @@ function Renderer:endFrame(zones, worldZones)
|
||||
|
||||
if present then
|
||||
love.graphics.setCanvas()
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(present, Sp)
|
||||
-- Post-process pipelines run over the finished composite -- world, UI
|
||||
-- and all -- and before GBC FX, so a blur or colour grade is what the
|
||||
-- LCD grid is then drawn over rather than something that smears the
|
||||
-- grid itself. Each pass hands back a canvas; with none registered
|
||||
-- this returns `present` unchanged and the frame is byte-identical.
|
||||
local composed = Pipelines.present(present,
|
||||
{ width = ww, height = wh, scale = Sp, dpi = dpi }) or present
|
||||
if GBCFX.active() then
|
||||
-- shader grid/shadow math is in framebuffer pixels
|
||||
GBCFX.present(composed, Sp)
|
||||
else
|
||||
-- the present canvas only existed for the post-process, so put the
|
||||
-- result on the screen at the same 1:1 unit mapping it was built at
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
love.graphics.draw(composed, 0, 0)
|
||||
end
|
||||
end
|
||||
self.worldActive = false
|
||||
self.uprightActive = false
|
||||
self.worldOverride = nil
|
||||
PaletteFX.setPass(nil)
|
||||
end
|
||||
|
||||
|
||||
@@ -65,8 +65,12 @@ end
|
||||
|
||||
Assets.register(SpriteRenderer.invalidate)
|
||||
|
||||
-- exported: a render pipeline's own sprite geometry picks frames by the
|
||||
-- same tables, so a 3D pose can never drift from the 2D one
|
||||
local STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||
local WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||
SpriteRenderer.STAND = STAND
|
||||
SpriteRenderer.WALK = WALK
|
||||
|
||||
-- seed: any stable per-instance value (e.g. an NPC's `id`) used to resolve
|
||||
-- RED++'s per-instance "random" OBP sentinel (PaletteFX.spriteObp)
|
||||
@@ -83,6 +87,29 @@ function SpriteRenderer.new(spriteDef, seed)
|
||||
return self
|
||||
end
|
||||
|
||||
-- The image this sprite would draw from right now: the plain sheet, or the
|
||||
-- OBP-recolored bake of it. Exposed so a render pipeline can texture its
|
||||
-- own geometry from the very same image -- the geometry carries sheet pixel
|
||||
-- coordinates rather than baked colors, so sharing this one resolver is
|
||||
-- what makes palette modes and sprite-replacing mods apply to 2D and 3D
|
||||
-- alike.
|
||||
--
|
||||
-- Deliberately free of draw's bookkeeping: markTrueColor and
|
||||
-- markSpriteRedraw exist to patch up the screen-space zone shader, and a
|
||||
-- pipeline that renders into its own canvas never runs through it. For the
|
||||
-- same reason the OG-RED bake is returned unconditionally here rather than
|
||||
-- only during a redraw pass -- there is no later pass to restore it.
|
||||
function SpriteRenderer:resolveImage()
|
||||
if self.def.trueColor then return self.image end
|
||||
if PaletteFX.usesGbcPack() then
|
||||
local colors, group = PaletteFX.spriteObp(self.def, self.seed)
|
||||
if colors then return getObpImage(self.def.image, colors, group) end
|
||||
elseif PaletteFX.usesSpriteObp() then
|
||||
return getObpImage(self.def.image, PaletteFX.GBC_OBJ, "gbcobj")
|
||||
end
|
||||
return self.image
|
||||
end
|
||||
|
||||
-- facing: down/up/left/right; walkPhase: 0 stand, 1 walk; flip: alternate
|
||||
-- steps mirror the walk frame for up/down (GB uses OAM flip for this).
|
||||
local function blitFrame(image, quad, x, y, flip, redraw)
|
||||
|
||||
+68
-23
@@ -50,6 +50,7 @@ function TextBox.new(game, text, onDone, opts)
|
||||
self.charIndex = 0
|
||||
self.shown = {} -- visible lines (max 2), each a list of glyph codes
|
||||
self.waiting = false
|
||||
self.contAdvance = false
|
||||
self.done = false
|
||||
self.blink = 0
|
||||
self:beginLine()
|
||||
@@ -65,7 +66,11 @@ 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
|
||||
if arg == "wStringBuffer" then return game.stringBuffer end
|
||||
if arg == "wBoxNumString" then return game.boxNumString end
|
||||
-- SendNewMonToBox / _SentToBoxText reads the deposited nick here
|
||||
if arg == "wBoxMonNicks" then return game.boxMonNicks end
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
|
||||
@@ -83,31 +88,55 @@ end
|
||||
|
||||
-- Split marked-up text into pages of lines. \v-scrolled lines become
|
||||
-- additional lines on the same page (the box scrolls them).
|
||||
-- pages.contBefore[p][i] is true when line i was preceded by \v (cont):
|
||||
-- pokered ContText waits for A/B + ▼ before scrolling that line in.
|
||||
function TextBox.paginate(text, maxCols)
|
||||
maxCols = maxCols or (Theme.textBox and Theme.textBox.maxCols) or MAX_COLS
|
||||
local pages = {}
|
||||
local contBefore = {}
|
||||
local function pushLine(lines, conts, line, wait)
|
||||
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))
|
||||
table.insert(conts, wait)
|
||||
wait = false
|
||||
line = line:sub(cut + 1)
|
||||
end
|
||||
table.insert(lines, line)
|
||||
table.insert(conts, wait)
|
||||
end
|
||||
for pageText in (text .. "\f"):gmatch("(.-)\f") do
|
||||
if pageText ~= "" then
|
||||
local lines = {}
|
||||
for chunk in (pageText .. "\n"):gmatch("(.-)[\n\v]") do
|
||||
local line = chunk
|
||||
-- wrap long lines defensively (the source rarely needs it)
|
||||
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))
|
||||
line = line:sub(cut + 1)
|
||||
local lines, conts = {}, {}
|
||||
local pos, waitNext = 1, false
|
||||
while true do
|
||||
local npos = pageText:find("[\n\v]", pos)
|
||||
if not npos then
|
||||
pushLine(lines, conts, pageText:sub(pos), waitNext)
|
||||
break
|
||||
end
|
||||
table.insert(lines, line)
|
||||
pushLine(lines, conts, pageText:sub(pos, npos - 1), waitNext)
|
||||
waitNext = pageText:sub(npos, npos) == "\v"
|
||||
pos = npos + 1
|
||||
end
|
||||
if lines[#lines] == "" then
|
||||
table.remove(lines)
|
||||
table.remove(conts)
|
||||
end
|
||||
if #lines > 0 then
|
||||
table.insert(pages, lines)
|
||||
table.insert(contBefore, conts)
|
||||
end
|
||||
-- drop trailing empty line from the final gmatch round
|
||||
if lines[#lines] == "" then table.remove(lines) end
|
||||
if #lines > 0 then table.insert(pages, lines) end
|
||||
end
|
||||
end
|
||||
if #pages == 0 then pages = { { "" } } end
|
||||
if #pages == 0 then
|
||||
pages = { { "" } }
|
||||
contBefore = { { false } }
|
||||
end
|
||||
pages.contBefore = contBefore
|
||||
return pages
|
||||
end
|
||||
|
||||
@@ -179,10 +208,17 @@ function TextBox:update(dt)
|
||||
if input:wasPressed("a") or input:wasPressed("b") then
|
||||
require("src.core.Sound").play(self.game.data, "Press_AB")
|
||||
self.waiting = false
|
||||
self.shown = {}
|
||||
self.pageIndex = self.pageIndex + 1
|
||||
self.lineIndex = 1
|
||||
self:beginLine()
|
||||
if self.contAdvance then
|
||||
-- ContText / ManualTextScroll: keep the box, scroll one line
|
||||
self.contAdvance = false
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
self:beginLine()
|
||||
else
|
||||
self.shown = {}
|
||||
self.pageIndex = self.pageIndex + 1
|
||||
self.lineIndex = 1
|
||||
self:beginLine()
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
@@ -203,10 +239,19 @@ function TextBox:update(dt)
|
||||
-- line finished
|
||||
local page = self.pages[self.pageIndex]
|
||||
if self.lineIndex < #page then
|
||||
self.lineIndex = self.lineIndex + 1
|
||||
self:beginLine()
|
||||
local nextIdx = self.lineIndex + 1
|
||||
local conts = self.pages.contBefore and self.pages.contBefore[self.pageIndex]
|
||||
if conts and conts[nextIdx] then
|
||||
-- pokered <CONT>: ▼ + WaitForTextScrollButtonPress before scroll
|
||||
self.waiting = true
|
||||
self.contAdvance = true
|
||||
else
|
||||
self.lineIndex = nextIdx
|
||||
self:beginLine()
|
||||
end
|
||||
elseif self.pageIndex < #self.pages then
|
||||
self.waiting = true
|
||||
self.contAdvance = false
|
||||
else
|
||||
self.done = true
|
||||
end
|
||||
|
||||
@@ -179,6 +179,11 @@ local function recolorSample(r, g, b, a, colors)
|
||||
return col[1] / 255, col[2] / 255, col[3] / 255, a
|
||||
end
|
||||
|
||||
-- exported: a render pipeline bakes a map's palette into its own texture
|
||||
-- atlas the same way, and has to land on the identical colors as the 2D
|
||||
-- tiles it is standing in for
|
||||
TileRenderer.recolorSample = recolorSample
|
||||
|
||||
-- the 8 shifted variants of one tile (built once per sheet + tile id [+
|
||||
-- gbcKey, when `colors` recolors it for RED++ -- see buildAnim])
|
||||
local shiftVariants = {}
|
||||
|
||||
Reference in New Issue
Block a user