mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
1084 lines
49 KiB
Lua
1084 lines
49 KiB
Lua
-- Two-pass renderer. The UI pass is the classic 160x144 Game Boy canvas
|
|
-- drawn at the integer window fit scale S, letterboxed in the window.
|
|
-- The world pass (overworld survey zoom) is a variable-size canvas that
|
|
-- fills the *entire* window at the effective integer scale s', so black
|
|
-- letterbox voids become more map, not empty bars. Both use nearest-
|
|
-- neighbor filtering.
|
|
-- Spec: docs/new-features.md (survey zoom)
|
|
|
|
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 PixelCanvas = require("src.render.PixelCanvas")
|
|
local Runtime = require("src.mods.Runtime")
|
|
-- leaf module (no renderer dependency), so requiring it here cannot cycle
|
|
local FaithfulRes = require("src.core.FaithfulRes")
|
|
|
|
local Renderer = {}
|
|
|
|
-- The Game Boy surface. WIDTH/HEIGHT are the classic dimensions every
|
|
-- screen is laid out in; uiWidth/uiHeight are the surface actually
|
|
-- allocated this frame, which a state may widen through setUISize (the
|
|
-- widescreen battle layout asks for 304x144). Anything drawing a normal
|
|
-- 160x144 screen can keep reading WIDTH/HEIGHT.
|
|
Renderer.WIDTH = 160
|
|
Renderer.HEIGHT = 144
|
|
Renderer.MAX_UI_WIDTH = 640
|
|
Renderer.MAX_UI_HEIGHT = 576
|
|
|
|
-- 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.getWidth) == "function" and type(v.getHeight) == "function"
|
|
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
|
|
-- clipped where the ground canvas ends (a receding tree wall at the top of
|
|
-- the view rises above row 0; a fence at the bottom-left drops below/left).
|
|
-- endFrame composites the padded canvas back with a matching offset.
|
|
Renderer.UPRIGHT_MARGIN = 160
|
|
|
|
-- LOVE units + framebuffer pixels + per-axis unit→pixel ratios.
|
|
-- Android's DisplayMetrics.density is often non-integer (1.5, 2.75, …).
|
|
-- Integer scaling in units then maps each GB pixel to a fractional number of
|
|
-- framebuffer pixels → shimmer, uneven / non-square "pixels", and movement
|
|
-- judder (issue #87). Always derive the crisp integer scale from the
|
|
-- *drawable* pixel size (the window framebuffer we present into -- not a
|
|
-- combined multi-display metric), then draw with (pixels / axisDpi) so the
|
|
-- GPU lands on whole framebuffer pixels. Desktop dpi=1 is unchanged.
|
|
--
|
|
-- LOVE's projection is anisotropic: 1 unit in X covers pw/ww framebuffer
|
|
-- pixels and 1 unit in Y covers ph/wh. Those ratios match on a normal
|
|
-- highdpi surface, but diverge when unit sizes are truncated independently
|
|
-- (`(int)(pixels/density)`) or when a dual-screen / forced-rotation device
|
|
-- reports mismatched unit vs drawable aspects (AYN Thor, issue #208).
|
|
-- love.graphics.getDPIScale() is only ph/wh, so using it (or pw/ww alone)
|
|
-- for both axes makes the other axis land on a fractional, stretched count.
|
|
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
|
|
-- on BOTH axes (square).
|
|
local function displayMetrics()
|
|
local ww, wh = love.graphics.getDimensions()
|
|
local pw, ph = ww, wh
|
|
if love.graphics.getPixelDimensions then
|
|
pw, ph = love.graphics.getPixelDimensions()
|
|
end
|
|
local dpiX, dpiY = 1, 1
|
|
if ww > 0 and pw > 0 then dpiX = pw / ww end
|
|
if wh > 0 and ph > 0 then dpiY = ph / wh end
|
|
-- No pixel API (headless / old stub): fall back to getDPIScale, else 1.
|
|
if (dpiX == 1 and dpiY == 1) and not love.graphics.getPixelDimensions
|
|
and love.graphics.getDPIScale then
|
|
local d = love.graphics.getDPIScale()
|
|
if d and d > 1e-6 then dpiX, dpiY = d, d end
|
|
end
|
|
if dpiX < 1e-6 then dpiX = 1 end
|
|
if dpiY < 1e-6 then dpiY = 1 end
|
|
return ww, wh, pw, ph, dpiX, dpiY
|
|
end
|
|
|
|
function Renderer:init()
|
|
-- 160x144 real pixels, never DPI-scaled: see src/render/PixelCanvas.lua
|
|
-- (#208). Every canvas below is sized in framebuffer pixels for the same
|
|
-- reason -- worldViewSize() already works in drawable pixels.
|
|
self.uiWidth, self.uiHeight = self.WIDTH, self.HEIGHT
|
|
self.canvas = PixelCanvas.new(self.uiWidth, self.uiHeight, "nearest")
|
|
self.worldCanvas = nil
|
|
self.worldActive = false
|
|
-- tilt mode only: a transparent overlay canvas the size of the world
|
|
-- canvas that receives the upright billboard pass (sprites + standing
|
|
-- FX, drawn at their projected ground anchors). It composites flat over
|
|
-- 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 /
|
|
-- GBCFX / callers treat this as the crisp scale; endFrame converts to LOVE
|
|
-- units via / dpiX and / dpiY when drawing.
|
|
function Renderer:fitScale()
|
|
local _, _, pw, ph = displayMetrics()
|
|
local w, h = self:uiSize()
|
|
local s = math.max(1, math.floor(math.min(pw / w, ph / h)))
|
|
-- FAITHFUL RATIO on mobile locks the scale here rather than by resizing the
|
|
-- window, which a phone does not have (see src/core/FaithfulRes.lua). The
|
|
-- cap is the largest WHOLE multiple the display holds, so the picture is as
|
|
-- big as exact pixels allow and the remainder is bars. Computed per frame
|
|
-- off the live drawable size, so a rotate re-derives it with nothing to
|
|
-- re-apply.
|
|
local cap = FaithfulRes.scaleCap()
|
|
if cap and cap < s then s = cap end
|
|
return s
|
|
end
|
|
|
|
-- Integer framebuffer pixels per GB pixel for the UI pass.
|
|
--
|
|
-- Survey zoom only ever scaled the world: the UI kept blitting at fitScale,
|
|
-- so zooming out left a full-size dialogue box over a shrunken world, which
|
|
-- reads as the UI growing. Stepping the UI down with the zoom keeps the two
|
|
-- in proportion. Whole integers only, so a UI pixel stays a whole number of
|
|
-- screen pixels and the font does not resample; and never below half of
|
|
-- fitScale, because past that the text stops being readable.
|
|
--
|
|
-- Zooming IN does not scale the UI up -- a dialogue box larger than the
|
|
-- classic one has no reference to be faithful to, and the letterbox it sits
|
|
-- in does not grow either.
|
|
function Renderer:uiScale()
|
|
local S = self:fitScale()
|
|
local off = Zoom.offset or 0
|
|
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw): the UI is
|
|
-- a fixed letterbox at the fit scale and does not follow the survey zoom at
|
|
-- all, which is the whole point of the setting -- the screen furniture
|
|
-- stays put instead of resizing under the player. DYNAMIC keeps the
|
|
-- step-down below. BATTLE SIZE is unaffected either way: uiFill overrides
|
|
-- the scale in endFrame, after this.
|
|
if self.uiCentered then return S end
|
|
-- Only follow the zoom when a world is actually behind the UI. Survey zoom
|
|
-- is an OVERWORLD control; the title screen, the intro and the credits show
|
|
-- no world at all, and shrinking them to match a zoom level the player set
|
|
-- for the map is meaningless. worldActive is this frame's answer --
|
|
-- beginFrame clears it and beginWorldPass sets it -- so a state that draws
|
|
-- no world keeps the full fit scale.
|
|
--
|
|
-- uiWorldHold (Game:draw) is the case worldActive cannot answer: a BATTLE
|
|
-- BG "world" battle stays the scene's backdrop while an OPAQUE state -- the
|
|
-- party menu, the bag -- covers it, whether or not a world pass ran under
|
|
-- that state. Reading worldActive alone drops the step-down there and
|
|
-- blits those menus a whole scale larger than the battle they cover.
|
|
-- Game.drawBaseInStack keeps the map drawing in the common case, so the two
|
|
-- normally agree; this holds the scale even when it cannot (a battle with
|
|
-- something other than the overworld beneath it).
|
|
if not (self.worldActive or self.uiWorldHold) then return S end
|
|
if off >= 0 then return S end
|
|
local floorS = math.ceil(S / 2) -- at most a 50% reduction
|
|
local s = S + off -- one integer step per zoom-out step
|
|
if s < floorS then s = floorS end
|
|
return math.max(1, s)
|
|
end
|
|
|
|
-- the native-pixel UI surface in use right now
|
|
function Renderer:uiSize()
|
|
return self.uiWidth or self.WIDTH, self.uiHeight or self.HEIGHT
|
|
end
|
|
|
|
-- Ask for a UI surface of w x h native pixels; the canvas is reallocated
|
|
-- only when the size actually changes, so the classic path never rebuilds
|
|
-- it. Sizes are resolved before any state draws (Game:draw) and bounded on
|
|
-- both ends -- never smaller than the Game Boy screen every layout assumes,
|
|
-- never large enough for a bad request to allocate an unbounded canvas.
|
|
function Renderer:setUISize(w, h)
|
|
if type(w) ~= "number" or type(h) ~= "number"
|
|
or w < self.WIDTH or h < self.HEIGHT
|
|
or w > self.MAX_UI_WIDTH or h > self.MAX_UI_HEIGHT then
|
|
w, h = self.WIDTH, self.HEIGHT
|
|
end
|
|
w, h = math.floor(w), math.floor(h)
|
|
if w == self.uiWidth and h == self.uiHeight and self.canvas then return end
|
|
if self.canvas and self.canvas.release then self.canvas:release() end
|
|
self.uiWidth, self.uiHeight = w, h
|
|
self.canvas = PixelCanvas.new(w, h, "nearest")
|
|
end
|
|
|
|
-- LOVE-unit draw scales endFrame uses for the UI blit: integer framebuffer
|
|
-- scale (fitScale) divided by each axis's unit→pixel factor, so a GB pixel
|
|
-- lands on fitScale() whole PHYSICAL pixels on both axes once LOVE applies
|
|
-- its projection (fitScale() == drawScaleX() * dpiX == drawScaleY() * dpiY).
|
|
-- Exposed for #208's regression (square pixels when dpiX ≠ dpiY).
|
|
function Renderer:drawScaleX()
|
|
local _, _, _, _, dpiX = displayMetrics()
|
|
return self:fitScale() / dpiX
|
|
end
|
|
|
|
function Renderer:drawScaleY()
|
|
local _, _, _, _, _, dpiY = displayMetrics()
|
|
return self:fitScale() / dpiY
|
|
end
|
|
|
|
-- Back-compat alias: uniform surfaces have drawScaleX == drawScaleY.
|
|
function Renderer:drawScale()
|
|
return self:drawScaleX()
|
|
end
|
|
|
|
-- world-pass canvas size in world pixels: enough to fill the drawable at s'.
|
|
-- Sized from framebuffer pixels (not unit dims) so anisotropic dpiX/dpiY
|
|
-- cannot over/under-cover the window. In tilt mode the canvas grows (both
|
|
-- dimensions, by Tilt.viewGrowth) so the projected ground plane still covers
|
|
-- the whole window with no background peeking at the receded top/bottom
|
|
-- corners; flat mode returns exactly today's size (growth factor is 1 when
|
|
-- tilt is inactive).
|
|
function Renderer:worldViewSize()
|
|
local _, _, pw, ph = displayMetrics()
|
|
-- FAITHFUL RATIO on mobile. The world pass deliberately expands to cover the
|
|
-- WHOLE display, so letterbox voids become more map instead of black bars.
|
|
-- That is why the lock appeared to do nothing in the overworld: it shrank
|
|
-- the UI blit while the map kept filling the screen -- and showed MORE of
|
|
-- the map, because a smaller scale fits more world pixels in.
|
|
--
|
|
-- Size the view against the LOCKED VIEWPORT rather than the display. A
|
|
-- desktop lock gets this for free by making the window exactly 160N x 144N;
|
|
-- this is the same sum with the viewport standing in for the window, so
|
|
-- both platforms show the same map area at the same zoom.
|
|
local cap = FaithfulRes.scaleCap()
|
|
if cap then
|
|
local uiw, uih = self:uiSize()
|
|
pw, ph = uiw * cap, uih * cap
|
|
end
|
|
local sp = Zoom.scale(self:fitScale())
|
|
local vw, vh = math.ceil(pw / sp), math.ceil(ph / sp)
|
|
-- Even sizes keep Camera:follow on integer pixels (viewW/2 is integral),
|
|
-- so unfloored FX/sprite math cannot phase-shimmer against the tile layer.
|
|
if vw % 2 ~= 0 then vw = vw + 1 end
|
|
if vh % 2 ~= 0 then vh = vh + 1 end
|
|
if Tilt.active() then
|
|
local g = Tilt.viewGrowth()
|
|
vw, vh = math.ceil(vw * g), math.ceil(vh * g)
|
|
end
|
|
return vw, vh
|
|
end
|
|
|
|
-- transparent: the world pass shows through (UI pass draws overlays only)
|
|
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
|
|
-- battle-transition wipe, drawn over the whole surface (BattleTransition)
|
|
self.battleWipe = nil
|
|
-- whole-surface veil in screen space (battle-transition flash, the
|
|
-- fade in from white after a battle) -- covers the window, not just the
|
|
-- 160x144 letterbox
|
|
self.screenVeil = nil
|
|
-- edge-anchored UI regions, re-declared by their elements each frame
|
|
self.uiAnchors = nil
|
|
-- last frame's trueColor rects and sprite redraws go before anything
|
|
-- draws this one
|
|
PaletteFX.clearTrueColor()
|
|
PaletteFX.clearSpriteRedraws()
|
|
-- rBGP is a per-frame register here: the state that draws a dark map
|
|
-- re-arms it while it draws (#322), so nothing inherits last frame's
|
|
PaletteFX.setShadeMap(nil)
|
|
PaletteFX.setPass("ui")
|
|
love.graphics.setCanvas(self.canvas)
|
|
if transparent then
|
|
love.graphics.clear(0, 0, 0, 0)
|
|
else
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
end
|
|
end
|
|
|
|
-- The battle-transition wipe, drawn once over the WHOLE surface in screen
|
|
-- space rather than as a 160x144 wipe plus a separate fill around it.
|
|
--
|
|
-- The tile grid is anchored on the letterbox and extended outward at the same
|
|
-- tile size, so the figure is continuous: a spiral begins at the outermost
|
|
-- edge of the window and works inward through the letterbox to the middle.
|
|
-- Nothing is stretched -- the pattern is continued with more tiles, not
|
|
-- scaled-up pixels -- and at 1x the grid works out to exactly 20x18, where
|
|
-- BattleTransition.gridOrder hands back the ROM's own walk, so an unzoomed
|
|
-- window is the classic wipe unchanged.
|
|
--
|
|
-- Sx/Sy are LOVE-unit scales (Sy defaults to Sx on uniform surfaces).
|
|
function Renderer:drawBattleWipe(wipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|
if not wipe or not wipe.prog or wipe.prog <= 0 then return end
|
|
Sy = Sy or Sx
|
|
local TW, TH = 8 * Sx, 8 * Sy
|
|
if TW < 1 then TW = 1 end
|
|
if TH < 1 then TH = 1 end
|
|
local prog = math.min(1, wipe.prog)
|
|
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
|
|
if prog >= 1 then
|
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
|
love.graphics.setScissor()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
return
|
|
end
|
|
|
|
-- whole-tile padding out to each window edge, keeping the grid in phase
|
|
-- with the letterbox's tiles
|
|
local padL = math.max(0, math.ceil(ox / TW))
|
|
local padT = math.max(0, math.ceil(oy / TH))
|
|
local padR = math.max(0, math.ceil((ww - ox - vpw) / TW))
|
|
local padB = math.max(0, math.ceil((wh - oy - vph) / TH))
|
|
local lbCols = math.max(1, math.floor(vpw / TW + 0.5))
|
|
local lbRows = math.max(1, math.floor(vph / TH + 0.5))
|
|
local cols, rows = padL + lbCols + padR, padT + lbRows + padB
|
|
local x0, y0 = ox - padL * TW, oy - padT * TH
|
|
|
|
-- required here rather than at the top: BattleTransition reaches the
|
|
-- renderer through game.renderer at draw time, and a module-level require
|
|
-- would put the two files in a load cycle
|
|
local BattleTransition = require("src.render.BattleTransition")
|
|
local order = BattleTransition.gridOrder(wipe.style, cols, rows)
|
|
|
|
if order then
|
|
local n = math.floor(#order * prog + 1e-6)
|
|
for i = 1, n do
|
|
local t = order[i]
|
|
love.graphics.rectangle("fill", x0 + t[1] * TW, y0 + t[2] * TH, TW, TH)
|
|
end
|
|
else
|
|
-- shrink / split / the stripes are geometry, not a walk: the same shapes
|
|
-- measured against the window instead of the letterbox
|
|
local style = wipe.style
|
|
if style == "hstripes" then
|
|
local w = ww * prog
|
|
for row = 0, rows - 1 do
|
|
local y = y0 + row * TH
|
|
if row % 2 == 0 then
|
|
love.graphics.rectangle("fill", 0, y, w, TH)
|
|
else
|
|
love.graphics.rectangle("fill", ww - w, y, w, TH)
|
|
end
|
|
end
|
|
elseif style == "vstripes" then
|
|
local h = wh * prog
|
|
for col = 0, cols - 1 do
|
|
local x = x0 + col * TW
|
|
if col % 2 == 0 then
|
|
love.graphics.rectangle("fill", x, 0, TW, h)
|
|
else
|
|
love.graphics.rectangle("fill", x, wh - h, TW, h)
|
|
end
|
|
end
|
|
elseif style == "shrink" then
|
|
local h, w = wh / 2 * prog, ww / 2 * prog
|
|
love.graphics.rectangle("fill", 0, 0, ww, h)
|
|
love.graphics.rectangle("fill", 0, wh - h, ww, h)
|
|
love.graphics.rectangle("fill", 0, 0, w, wh)
|
|
love.graphics.rectangle("fill", ww - w, 0, w, wh)
|
|
else -- split: a black cross growing out of the centre in both axes
|
|
local h, w = wh / 2 * prog, ww / 2 * prog
|
|
love.graphics.rectangle("fill", 0, wh / 2 - h, ww, h * 2)
|
|
love.graphics.rectangle("fill", ww / 2 - w, 0, w * 2, wh)
|
|
end
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
function Renderer:beginWorldPass()
|
|
local vw, vh = self:worldViewSize()
|
|
if not self.worldCanvas or self.worldCanvas:getWidth() ~= vw
|
|
or self.worldCanvas:getHeight() ~= vh then
|
|
-- free the old canvas before replacing it: a zoom/tilt tween changes
|
|
-- the view size every frame, so without this the superseded canvases
|
|
-- pile up in VRAM until a GC finalizer happens to run
|
|
if self.worldCanvas and self.worldCanvas.release then self.worldCanvas:release() end
|
|
self.worldCanvas = PixelCanvas.new(vw, vh, "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
|
|
|
|
-- Tilt mode's upright pass: standing things (sprites, tall-grass feet
|
|
-- overdraw, screen-anchored FX) draw here instead of into the ground
|
|
-- world canvas, each already projected to its ground anchor and colorized
|
|
-- with its map's SGB palette (see OverworldController:billboard). The
|
|
-- canvas is transparent so the projected ground shows through the gaps;
|
|
-- endFrame blits it flat over the projected ground. Sized/filtered like
|
|
-- the world canvas but kept separate so the ground can be projected as a
|
|
-- plane while these stay upright. Only entered while Tilt.active().
|
|
function Renderer:beginUprightPass()
|
|
local vw, vh = self:worldViewSize()
|
|
local M = self.UPRIGHT_MARGIN
|
|
local cw, ch = vw + 2 * M, vh + 2 * M
|
|
if not self.uprightCanvas or self.uprightCanvas:getWidth() ~= cw
|
|
or self.uprightCanvas:getHeight() ~= ch then
|
|
if self.uprightCanvas and self.uprightCanvas.release then self.uprightCanvas:release() end
|
|
self.uprightCanvas = PixelCanvas.new(cw, ch, "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
|
|
-- in flat world-canvas coordinates (0..vw, 0..vh) while the margin catches
|
|
-- anything that overhangs an edge; endFrame undoes it with the same offset
|
|
love.graphics.push()
|
|
love.graphics.translate(M, M)
|
|
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
|
|
|
|
-- Perspective mesh shader for tilt mode. The mesh already carries CPU-
|
|
-- projected 2D corner positions (from Tilt.groundPoint), so the vertex
|
|
-- stage does no projection; instead it passes each corner's depthScale as
|
|
-- the per-vertex "q" and pre-multiplies the texture coords by it. The
|
|
-- fragment divides back, which reconstructs perspective-correct texture
|
|
-- interpolation across the whole quad (no affine-warp seams) using the
|
|
-- exact same projection the billboards will anchor to. false = headless /
|
|
-- no shader support, in which case the renderer stays on the flat blit.
|
|
local TILT_SHADER = [[
|
|
varying float vScale;
|
|
#ifdef VERTEX
|
|
attribute float VertexScale;
|
|
vec4 position(mat4 transform_projection, vec4 vertex_position) {
|
|
vScale = VertexScale;
|
|
VaryingTexCoord = vec4(VertexTexCoord.xy * VertexScale, 0.0, 1.0);
|
|
return transform_projection * vertex_position;
|
|
}
|
|
#endif
|
|
#ifdef PIXEL
|
|
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
|
return Texel(tex, tc / vScale) * color;
|
|
}
|
|
#endif
|
|
]]
|
|
|
|
function Renderer:tiltShader()
|
|
if self._tiltShader == nil then
|
|
local ok, sh = pcall(love.graphics.newShader, TILT_SHADER)
|
|
self._tiltShader = ok and sh or false
|
|
end
|
|
return self._tiltShader or nil
|
|
end
|
|
|
|
-- Dynamic 4-vertex ground quad; positions/depthScale are refreshed each
|
|
-- frame from Tilt.meshCorners. The custom VertexScale attribute rides the
|
|
-- perspective "q" through to the shader above.
|
|
function Renderer:tiltMesh()
|
|
if self._tiltMesh == nil then
|
|
local format = {
|
|
{ "VertexPosition", "float", 2 },
|
|
{ "VertexTexCoord", "float", 2 },
|
|
{ "VertexScale", "float", 1 },
|
|
}
|
|
local ok, mesh = pcall(love.graphics.newMesh, format, 4, "fan", "dynamic")
|
|
self._tiltMesh = ok and mesh or false
|
|
end
|
|
return self._tiltMesh or nil
|
|
end
|
|
|
|
-- Draw the world pass through the tilt projection. Two steps: (1) a
|
|
-- canvas-to-canvas palette pre-pass that bakes the SGB world zones into a
|
|
-- colorized ground canvas in flat space (a perspective transform breaks
|
|
-- the rectangular scissors endFrame normally uses), then (2) project that
|
|
-- canvas onto the tilted plane via the perspective mesh, scaled/centred
|
|
-- exactly like the flat world blit. `target` is the canvas to project
|
|
-- into (nil = default framebuffer; presentCanvas when CRT is on).
|
|
-- Returns true on success; false (no shader/mesh) tells endFrame to fall
|
|
-- back to the flat blit unchanged.
|
|
function Renderer:drawTiltedWorld(zoneList, sx, sy, wox, woy, target)
|
|
local shader = self:tiltShader()
|
|
local mesh = self:tiltMesh()
|
|
if not (shader and mesh) then return false end
|
|
sy = sy or sx
|
|
local wvw = self.worldCanvas:getWidth()
|
|
local wvh = self.worldCanvas:getHeight()
|
|
|
|
-- colorized ground canvas, resized to match the world canvas. Linear
|
|
-- sampling softens the pixel shimmer the perspective warp would cause
|
|
-- (the flat path keeps nearest). TODO(tilt): optionally render this at
|
|
-- 2x for extra crispness.
|
|
if not self.tiltCanvas or self.tiltCanvas:getWidth() ~= wvw
|
|
or self.tiltCanvas:getHeight() ~= wvh then
|
|
if self.tiltCanvas and self.tiltCanvas.release then self.tiltCanvas:release() end
|
|
self.tiltCanvas = PixelCanvas.new(wvw, wvh, "linear")
|
|
end
|
|
|
|
love.graphics.setCanvas(self.tiltCanvas)
|
|
love.graphics.clear(1, 1, 1, 1)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
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
|
|
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
|
|
love.graphics.setScissor(x, y, x2 - x, y2 - y)
|
|
love.graphics.draw(self.worldCanvas, 0, 0)
|
|
end
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setShader()
|
|
else
|
|
love.graphics.draw(self.worldCanvas, 0, 0)
|
|
end
|
|
|
|
-- project onto the tilted plane into the present target (or screen)
|
|
love.graphics.setCanvas(target)
|
|
mesh:setTexture(self.tiltCanvas)
|
|
mesh:setVertices(Tilt.meshCorners(wvw, wvh))
|
|
love.graphics.push()
|
|
love.graphics.translate(wox, woy)
|
|
love.graphics.scale(sx, sy)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setShader(shader)
|
|
love.graphics.draw(mesh)
|
|
love.graphics.setShader()
|
|
love.graphics.pop()
|
|
return true
|
|
end
|
|
|
|
-- LÖVE 11 truncates scissor arguments to framebuffer pixels; the half-pixel
|
|
-- bias keeps values divided back through a fractional DPI scale from landing
|
|
-- one short. LÖVE 12 passes fractional arguments through and rounds in the
|
|
-- graphics backend instead, where that bias shifts each origin by one pixel
|
|
-- and extends its far edge by two (#673).
|
|
local SCISSOR_PIXEL_BIAS = 0.5
|
|
if love and love.getVersion and select(1, love.getVersion()) >= 12 then
|
|
SCISSOR_PIXEL_BIAS = 0
|
|
end
|
|
|
|
-- Clamp a scissor rect to the viewport box, then round it outward to whole
|
|
-- framebuffer pixels. On LÖVE 11, x, y, w and h are truncated independently,
|
|
-- so a rect with fractional unit edges (Android's non-integer DPI puts
|
|
-- fitScale/dpi in Sx/Sy) loses up to a pixel per side and two adjacent SGB
|
|
-- zones stop sharing an edge: the letterbox clear shows through as a seam at
|
|
-- every zone boundary (#373). Rounding outward makes neighbours overlap by
|
|
-- at most one row instead; SCISSOR_PIXEL_BIAS preserves that result across the
|
|
-- LÖVE 11 and 12 conversion rules.
|
|
local function scissorClamped(x, y, w, h, ox, oy, vpw, vph, dpiX, dpiY)
|
|
local x2, y2 = math.min(x + w, ox + vpw), math.min(y + h, oy + vph)
|
|
x, y = math.max(x, ox), math.max(y, oy)
|
|
if x2 <= x or y2 <= y then return false end
|
|
dpiX, dpiY = dpiX or 1, dpiY or 1
|
|
local px1, py1 = math.floor(x * dpiX), math.floor(y * dpiY)
|
|
local px2, py2 = math.ceil(x2 * dpiX), math.ceil(y2 * dpiY)
|
|
local b = SCISSOR_PIXEL_BIAS
|
|
love.graphics.setScissor((px1 + b) / dpiX, (py1 + b) / dpiY,
|
|
(px2 - px1 + b) / dpiX,
|
|
(py2 - py1 + b) / dpiY)
|
|
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
|
|
|
|
-- Palette-correct blit of `canvas` at (sx, sy) LOVE-unit scales into origin
|
|
-- (bx, by), scissored to the (boxX, boxY, boxW, boxH) screen rect. zoneSx/
|
|
-- zoneSy convert zone coords (canvas-space) into screen units. Public so a
|
|
-- render.compose mod can composite the world/UI canvases into its own layout.
|
|
function Renderer:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
|
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
|
local shader = zoneList and zoneList[1] and PaletteFX.shader() or nil
|
|
if not shader then
|
|
love.graphics.setScissor(boxX, boxY, boxW, boxH)
|
|
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
|
love.graphics.setScissor()
|
|
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
|
|
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 * zoneSx, by + z.y * zoneSy,
|
|
z.w * zoneSx, z.h * zoneSy,
|
|
boxX, boxY, boxW, boxH, dpiX, dpiY) then
|
|
love.graphics.draw(canvas, bx, by, 0, sx, sy)
|
|
end
|
|
end
|
|
love.graphics.setScissor()
|
|
love.graphics.setShader()
|
|
end
|
|
|
|
-- Take a rect out of a list of rects, splitting each overlapped one into up
|
|
-- to four pieces. Used to vacate an anchored UI region from the letterbox
|
|
-- blit, so the element is drawn at its anchor and not also in place.
|
|
local function subtractRect(list, x, y, w, h)
|
|
local out = {}
|
|
local x2, y2 = x + w, y + h
|
|
for _, r in ipairs(list) do
|
|
local rx, ry, rw, rh = r[1], r[2], r[3], r[4]
|
|
local rx2, ry2 = rx + rw, ry + rh
|
|
if x2 <= rx or x >= rx2 or y2 <= ry or y >= ry2 then
|
|
out[#out + 1] = r -- disjoint
|
|
else
|
|
if ry < y then out[#out + 1] = { rx, ry, rw, y - ry } end
|
|
if y2 < ry2 then out[#out + 1] = { rx, y2, rw, ry2 - y2 } end
|
|
local ty, ty2 = math.max(ry, y), math.min(ry2, y2)
|
|
if rx < x then out[#out + 1] = { rx, ty, x - rx, ty2 - ty } end
|
|
if x2 < rx2 then out[#out + 1] = { x2, ty, rx2 - x2, ty2 - ty } end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- A UI element that should sit against a screen edge rather than inside the
|
|
-- centred letterbox. Declared during the element's own draw, in UI-canvas
|
|
-- pixels, and consumed by endFrame this frame only.
|
|
-- anchor: "bottom" | "topright" | "topleft" | "bottomright"
|
|
function Renderer:setUIAnchor(x, y, w, h, anchor)
|
|
-- UI LAYOUT = CENTERED (uiCentered, set per frame by Game:draw from
|
|
-- save.options.uiLayout): every element stays where it was drawn in the
|
|
-- 160x144 canvas and the letterbox centres the lot, which is how the port
|
|
-- behaved before edge docking existed. This is the DEFAULT; DYNAMIC opts
|
|
-- back into docking. Gating here rather than at each caller means one
|
|
-- switch covers the dialogue box, its YES/NO, the START menu and anything
|
|
-- added later, and none of them has to know the option exists.
|
|
if self.uiCentered then return end
|
|
-- uiAnchorHold (Game:draw): a state that composes its own screen -- a
|
|
-- battle -- keeps every element inside it, so the box blits where it was
|
|
-- drawn in the canvas instead of being pulled to the window edge.
|
|
if self.uiAnchorHold then return end
|
|
self.uiAnchors = self.uiAnchors or {}
|
|
self.uiAnchors[#self.uiAnchors + 1] =
|
|
{ x = x, y = y, w = w, h = h, anchor = anchor }
|
|
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
|
|
-- visible map area separately), applied to the world pass; the world
|
|
-- pass falls back to the UI zones when absent. Each zone is drawn
|
|
-- scissored through the shade-remap shader, later zones on top.
|
|
-- When GBC FX is active the composite is drawn into presentCanvas and
|
|
-- presented through the GBC FX shader as a final pass.
|
|
function Renderer:endFrame(zones, worldZones)
|
|
love.graphics.setCanvas()
|
|
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
|
|
-- Sp = integer framebuffer pixels per GB pixel;
|
|
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
|
|
local Sp = self:fitScale()
|
|
local Sx, Sy = Sp / dpiX, Sp / dpiY
|
|
local uiw, uih = self:uiSize()
|
|
local vpw, vph = uiw * Sx, uih * Sy
|
|
-- Snap the letterbox origin to a framebuffer pixel, then convert to units.
|
|
local ox = math.floor((pw - uiw * Sp) / 2) / dpiX
|
|
local oy = math.floor((ph - uih * Sp) / 2) / dpiY
|
|
-- The UI has its own scale: it steps down as the survey zoom goes out (see
|
|
-- uiScale), so it can be smaller than the world letterbox. Un-zoomed these
|
|
-- are identical to Sp/ox/oy and every rect below is what it always was.
|
|
local Up = self:uiScale()
|
|
-- BATTLE SIZE "fill" (Renderer.uiFill, set per frame by Game:draw): scale
|
|
-- the surface to the window rather than to whole GB pixels, so the battle
|
|
-- fills vertically at any zoom or window size. Fractional by nature -- a
|
|
-- GB pixel stops being a whole number of screen pixels, which is the trade
|
|
-- the setting exists to offer. Clamped on the horizontal too, so a narrow
|
|
-- window scales to fit instead of overflowing off both sides.
|
|
if self.uiFill then
|
|
Up = math.min(ph / uih, pw / uiw)
|
|
end
|
|
local Ux, Uy = Up / dpiX, Up / dpiY
|
|
local uvpw, uvph = uiw * Ux, uih * Uy
|
|
local uox = math.floor((pw - uiw * Up) / 2) / dpiX
|
|
local uoy = math.floor((ph - uih * Up) / 2) / dpiY
|
|
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")
|
|
|
|
-- render.compose: hand a mod the finished world + UI canvases (and their
|
|
-- SGB zones), the frame metrics, Renderer:blitCanvas and the SecondScreen
|
|
-- bridge, letting it lay the two passes out however it likes -- e.g. as two
|
|
-- stacked Game Boy screens, or driving one onto a second physical display.
|
|
-- The mod returns true to take over the whole window; anything else (or no
|
|
-- mod wrapping the hook) falls through to the normal composite below.
|
|
if Runtime.wantsHook("render.compose") then
|
|
local ctx = {
|
|
renderer = self,
|
|
worldCanvas = self.worldCanvas, uiCanvas = self.canvas,
|
|
worldOverride = self.worldOverride,
|
|
worldActive = self.worldActive and true or false,
|
|
zones = zones, worldZones = worldZones,
|
|
ww = ww, wh = wh, pw = pw, ph = ph, ox = ox, oy = oy,
|
|
vpw = vpw, vph = vph, uiw = uiw, uih = uih,
|
|
scale = Sp, Sx = Sx, Sy = Sy, dpiX = dpiX, dpiY = dpiY,
|
|
secondScreen = require("src.render.SecondScreen"),
|
|
}
|
|
if Runtime.call("render.compose", function() return false end, self, ctx) == true then
|
|
self.worldActive = false
|
|
self.uprightActive = false
|
|
self.worldOverride = nil
|
|
PaletteFX.setPass(nil)
|
|
return {
|
|
width = ww, height = wh, gameX = ox, gameY = oy,
|
|
gameWidth = vpw, gameHeight = vph, scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
|
}
|
|
end
|
|
end
|
|
|
|
-- 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
|
|
or self.presentCanvas:getHeight() ~= wh then
|
|
-- The one canvas NOT built through PixelCanvas: it is sized in LOVE
|
|
-- units and blitted back at unit scale 1 (and handed to mod present
|
|
-- passes as ww x wh), so it has to keep the screen's DPI scale for its
|
|
-- texture to cover the framebuffer. Everything composited into it is
|
|
-- already native-resolution now, so #208's fractional source is gone;
|
|
-- what remains here is the dpiX vs dpiY truncation gap (well under 1%,
|
|
-- one seam across the window) that a single scalar dpiscale cannot
|
|
-- express.
|
|
self.presentCanvas = love.graphics.newCanvas(ww, wh)
|
|
self.presentCanvas:setFilter("linear", "linear")
|
|
end
|
|
present = self.presentCanvas
|
|
love.graphics.setCanvas(present)
|
|
end
|
|
-- Default letterbox is black. Battle (and any state that opts in via
|
|
-- letterboxWhite) fills the voids with the display mode's paper shade, so
|
|
-- the bars match the canvas they frame instead of showing black. Not a
|
|
-- literal white: the battle canvas is colorized, and in SGB mode its paper
|
|
-- is the pack's off-white (255,239,255), which a hardcoded 1,1,1 framed in
|
|
-- a visibly brighter border.
|
|
local clearR, clearG, clearB = 0, 0, 0
|
|
if not self.worldActive then
|
|
local ok, Game = pcall(require, "src.core.Game")
|
|
local stack = ok and Game and Game.stack
|
|
local base = stack and stack.visibleBase and stack:visibleBase()
|
|
local state = base and stack.states and stack.states[base]
|
|
-- A battle owns the surround it established until it leaves the stack.
|
|
-- Reading it off visibleBase alone loses that the moment the battle opens
|
|
-- an OPAQUE state -- the party menu, the bag -- because that state becomes
|
|
-- the base and answers no to letterboxWhite, flipping a white battle
|
|
-- surround to flat black for as long as the menu is up. Same whole-stack
|
|
-- hold as uiFill and the dim.
|
|
for i = #(stack and stack.states or {}), 1, -1 do
|
|
local s = stack.states[i]
|
|
if s and s.letterboxWhite then
|
|
state = s
|
|
break
|
|
end
|
|
end
|
|
-- BATTLE BG "black" keeps the default black clear; "white" (and any
|
|
-- non-battle state that opts in) uses the paper shade. "world" never
|
|
-- reaches here -- it makes the battle non-opaque, so the world pass is
|
|
-- active and this whole branch is skipped.
|
|
-- FAITHFUL RATIO's mobile lock promises the display outside the GB
|
|
-- screen stays black (src/core/FaithfulRes.lua); the paper surround
|
|
-- painted the whole phone white on New Game and in battle (#864), so
|
|
-- the lock keeps the default black bars.
|
|
if state and state.letterboxWhite
|
|
and not (state.bgMode and state:bgMode() == "black")
|
|
and not FaithfulRes.scaleCap() then
|
|
clearR, clearG, clearB = PaletteFX.paperShade(Game and Game.data)
|
|
end
|
|
end
|
|
love.graphics.setColor(clearR, clearG, clearB, 1)
|
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
-- render.letterbox: SGB borders / custom void art in the bars around the
|
|
-- 160x144 (or world) blit. Drawn after the clear and before the game
|
|
-- canvas so the playfield sits on top of the border.
|
|
if Runtime.wantsHook("render.letterbox") then
|
|
Runtime.call("render.letterbox", function() end, {
|
|
ww = ww, wh = wh, pw = pw, ph = ph,
|
|
ox = ox, oy = oy, vpw = vpw, vph = vph,
|
|
scale = Sp, dpiX = dpiX, dpiY = dpiY,
|
|
worldActive = self.worldActive and true or false,
|
|
})
|
|
end
|
|
|
|
-- see Renderer:blitCanvas; bound here to the frame's dpi so the composite
|
|
-- call sites below stay unchanged.
|
|
local function blit(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
|
bx, by, boxX, boxY, boxW, boxH)
|
|
return self:blitCanvas(canvas, sx, sy, zoneList, zoneSx, zoneSy,
|
|
bx, by, boxX, boxY, boxW, boxH, dpiX, dpiY)
|
|
end
|
|
|
|
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)
|
|
local loveMajor = love.getVersion()
|
|
if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then
|
|
love.graphics.draw(self.worldOverride, 0, wh, 0, 1 / dpiX, -1 / dpiY)
|
|
else
|
|
love.graphics.draw(self.worldOverride, 0, 0, 0, 1 / dpiX, 1 / dpiY)
|
|
end
|
|
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
|
|
elseif self.worldActive then
|
|
local sp = Zoom.scale(Sp)
|
|
local sx, sy = sp / dpiX, sp / dpiY
|
|
local wvw = self.worldCanvas:getWidth()
|
|
local wvh = self.worldCanvas:getHeight()
|
|
local wox = math.floor((pw - wvw * sp) / 2) / dpiX
|
|
local woy = math.floor((ph - wvh * sp) / 2) / dpiY
|
|
-- Tilt mode projects the ground world pass through the perspective mesh
|
|
-- (SGB zones baked in beforehand -- see drawTiltedWorld -- so no zone
|
|
-- scissoring here). drawTiltedWorld returns false when tilt is off or
|
|
-- projection is unavailable (headless / no shader); then the ground
|
|
-- falls through to the flat blit, keeping the flat frame byte-for-byte
|
|
-- identical to today.
|
|
local projected =
|
|
Tilt.active() and self:drawTiltedWorld(worldZones or zones, sx, sy, wox, woy, present)
|
|
if not projected then
|
|
if worldZones then
|
|
blit(self.worldCanvas, sx, sy, worldZones, sx, sy, wox, woy, 0, 0, ww, wh)
|
|
else
|
|
blit(self.worldCanvas, sx, sy, zones, Sx, Sy, wox, woy, 0, 0, ww, wh)
|
|
end
|
|
-- OBP-baked overworld sprites replay on top of the zone pass (GBC
|
|
-- mode per-object coloring; see PaletteFX.markSpriteRedraw). Grass
|
|
-- feet-overdraw entries carry `colors` and re-colorize through the
|
|
-- color-0-keyed shade-remap shader so they keep hiding sprite feet.
|
|
local redraws = PaletteFX.spriteRedraws()
|
|
if redraws[1] then
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
local activeShader = nil
|
|
for _, r in ipairs(redraws) do
|
|
local wanted = r.colors
|
|
and (r.keyed and PaletteFX.keyedShader() or PaletteFX.shader())
|
|
or nil
|
|
if wanted ~= activeShader then
|
|
activeShader = wanted
|
|
love.graphics.setShader(wanted)
|
|
end
|
|
if wanted then PaletteFX.sendColors(wanted, r.colors) end
|
|
if r.quad then
|
|
love.graphics.draw(r.image, r.quad, wox + r.x * sx, woy + r.y * sy,
|
|
0, sx * r.sx, sy)
|
|
else
|
|
love.graphics.draw(r.image, wox + r.x * sx, woy + r.y * sy,
|
|
0, sx * r.sx, sy)
|
|
end
|
|
end
|
|
if activeShader then love.graphics.setShader() end
|
|
love.graphics.setScissor()
|
|
end
|
|
end
|
|
-- Composite the tilt upright pass over the ground (projected or, in the
|
|
-- rare no-shader fallback, flat). It already carries its billboards'
|
|
-- projected positions and per-sprite SGB colorization on a transparent
|
|
-- canvas, so it just needs the same centred integer-scale blit the flat
|
|
-- world pass uses -- no zone scissoring. uprightActive is only ever
|
|
-- set in tilt mode, so flat frames skip this and stay identical.
|
|
if self.uprightActive then
|
|
local M = self.UPRIGHT_MARGIN
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.setScissor(0, 0, ww, wh)
|
|
love.graphics.draw(self.uprightCanvas, wox - M * sx, woy - M * sy, 0, sx, sy)
|
|
love.graphics.setScissor()
|
|
end
|
|
-- Screen-space warp fade (Transition) over the full world composite so
|
|
-- survey zoom / tilt edges darken with the center, not only the 160x144
|
|
-- UI letterbox. Drawn before the UI blit so menus above a fade still
|
|
-- composite normally if one is ever stacked that way.
|
|
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
|
|
end
|
|
-- BATTLE BG "world": the frozen overworld has just been composited and the
|
|
-- battle is about to blit over it. Dim the world first, so the battle
|
|
-- reads as the foreground instead of competing with a fully lit map. Goes
|
|
-- here rather than in the letterbox clear because with the world pass
|
|
-- active there is no clear -- the world already covers the surface.
|
|
--
|
|
-- The veil covers the voids ONLY, never the battle's own letterbox: "world"
|
|
-- changes what surrounds the battle and leaves the battle screen alone
|
|
-- (BattleState:bgMode). On hardware there is no "behind the battle" to dim
|
|
-- at all -- _InitBattleCommon calls ClearScreen (pokered home/copy2.asm),
|
|
-- which blanks the whole tilemap before the battle draws. A whole-surface
|
|
-- fill was invisible only because the classic battle paints an opaque paper
|
|
-- field over it a few lines below; a render pipeline that stages the fight
|
|
-- on the map and keys that field out got the veil straight onto its
|
|
-- sprites, HP bars and HUD, which is the 55% whole-window dim of #777
|
|
-- (and its duplicate #772).
|
|
if self.battleDim and self.battleDim > 0 then
|
|
love.graphics.setColor(0, 0, 0, self.battleDim)
|
|
for _, r in ipairs(subtractRect({ { 0, 0, ww, wh } }, uox, uoy, uvpw, uvph)) do
|
|
love.graphics.rectangle("fill", r[1], r[2], r[3], r[4])
|
|
end
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
-- UI: anchored regions against their screen edges, the rest in the classic
|
|
-- centred letterbox. With nothing anchored this is the single blit it has
|
|
-- always been.
|
|
local anchors = self.uiAnchors
|
|
if not anchors or #anchors == 0 then
|
|
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, uox, uoy, uvpw, uvph)
|
|
else
|
|
local rest = { { uox, uoy, uvpw, uvph } }
|
|
local placed = {}
|
|
for _, a in ipairs(anchors) do
|
|
local dw, dh = a.w * Ux, a.h * Uy
|
|
-- Anchors are edge-RELATIVE: an element keeps its distance from the
|
|
-- canvas edge, measured against the screen edge instead. That is what
|
|
-- keeps a stack together -- the yes/no box sits 48px above the canvas
|
|
-- bottom, so bottom-anchoring lands it 48px above the screen bottom,
|
|
-- still directly over the dialogue box, rather than on top of it.
|
|
local gapR = (uiw - (a.x + a.w)) * Ux
|
|
local gapB = (uih - (a.y + a.h)) * Uy
|
|
local dx, dy
|
|
if a.anchor == "bottom" then
|
|
dx = uox + a.x * Ux -- horizontally it stays with the letterbox
|
|
dy = wh - gapB - dh
|
|
elseif a.anchor == "topright" then
|
|
dx = ww - gapR - dw
|
|
dy = a.y * Uy
|
|
else -- unknown anchor: leave it where it is
|
|
dx, dy = uox + a.x * Ux, uoy + a.y * Uy
|
|
end
|
|
placed[#placed + 1] = { a = a, dx = dx, dy = dy, dw = dw, dh = dh }
|
|
rest = subtractRect(rest, uox + a.x * Ux, uoy + a.y * Uy, dw, dh)
|
|
end
|
|
for _, r in ipairs(rest) do
|
|
blit(self.canvas, Ux, Uy, zones, Ux, Uy, uox, uoy, r[1], r[2], r[3], r[4])
|
|
end
|
|
for _, p in ipairs(placed) do
|
|
-- shift the draw origin so canvas pixel (a.x, a.y) lands on (dx, dy).
|
|
-- The zone scissors are computed from the same origin, so an SGB
|
|
-- region travels with the element instead of staying in the letterbox.
|
|
blit(self.canvas, Ux, Uy, zones, Ux, Uy,
|
|
p.dx - p.a.x * Ux, p.dy - p.a.y * Uy, p.dx, p.dy, p.dw, p.dh)
|
|
end
|
|
end
|
|
|
|
-- The battle wipe covers the whole surface, letterbox included, so it goes
|
|
-- over the finished composite rather than under the UI blit. On hardware
|
|
-- it is the tilemap being overwritten -- there is nothing it does not cover.
|
|
if self.battleWipe then
|
|
self:drawBattleWipe(self.battleWipe, ww, wh, ox, oy, vpw, vph, Sx, Sy)
|
|
end
|
|
|
|
-- Palette-register effects (BattleTransition_FlashScreen's rBGP writes, the
|
|
-- GBFadeInFromWhite after a battle) tint every pixel the LCD shows -- there
|
|
-- is no "outside the screen" on hardware for them to miss. So they are
|
|
-- painted here, over the finished composite, rather than into the 160x144
|
|
-- UI canvas: at any zoom above 1x a letterbox-only veil left the
|
|
-- surrounding window untouched, which read as the effect happening inside
|
|
-- a window rather than to the whole screen. { shade, alpha }.
|
|
local veil = self.screenVeil
|
|
if veil and veil[2] > 0 then
|
|
love.graphics.setColor(veil[1], veil[1], veil[1], veil[2])
|
|
-- FAITHFUL RATIO's mobile lock: the surface the player sees is the
|
|
-- locked viewport and the bars around it are dead display, not screen
|
|
-- (src/core/FaithfulRes.lua). A whole-window veil lit the entire phone
|
|
-- for the battle flash and the post-battle fade (#864), so under the
|
|
-- lock the veil stops at the letterbox. The desktop lock is unaffected:
|
|
-- there the window IS the viewport.
|
|
if FaithfulRes.scaleCap() then
|
|
love.graphics.rectangle("fill", ox, oy, vpw, vph)
|
|
else
|
|
love.graphics.rectangle("fill", 0, 0, ww, wh)
|
|
end
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
if present then
|
|
love.graphics.setCanvas()
|
|
-- 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 = dpiY, dpiX = dpiX, dpiY = dpiY }) 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)
|
|
return {
|
|
width = ww, height = wh,
|
|
gameX = ox, gameY = oy,
|
|
gameWidth = vpw, gameHeight = vph,
|
|
scale = Sp,
|
|
dpiX = dpiX, dpiY = dpiY,
|
|
}
|
|
end
|
|
|
|
return Renderer
|