Merge pull request #1405 from AverageConsumer/codex/mod-render-viewport

This commit is contained in:
bryanthaboi
2026-08-16 12:08:03 -04:00
committed by GitHub
14 changed files with 379 additions and 46 deletions
+1 -1
View File
@@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix.
passes `game`; positions 2-4 (mon, row, trigger) match. passes `game`; positions 2-4 (mon, row, trigger) match.
- *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, - *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`,
`render.zones`, `render.compose`, `render.output_enabled`, `render.output`, `render.zones`, `render.compose`, `render.output_enabled`, `render.output`,
`render.letterbox`, `render.hud`. Each sits `render.letterbox`, `render.hud`, `render.viewport`, `render.window`. Each sits
at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it at the same moment `src/core/Game.lua` and `src/render/Renderer.lua` raise it
-- the logic tick before the pad is read, a pointer the touch overlay gets -- the logic tick before the pad is read, a pointer the touch overlay gets
first refusal on, the palette zone list handed to the present pass, the first refusal on, the palette zone list handed to the present pass, the
+22 -3
View File
@@ -654,11 +654,14 @@ the wrapper is visible during that same fixed step. The callback receives
`input.pointer` delivers uncaptured gameplay pointer events -- touches and `input.pointer` delivers uncaptured gameplay pointer events -- touches and
real mouse input alike. The callback receives `(next, game, ev)` where `ev` real mouse input alike. The callback receives `(next, game, ev)` where `ev`
is `{ phase, source, id, x, y, dx, dy, pressure, button }`: `phase` is is `{ phase, source, id, x, y, gameX, gameY, insideGame, dx, dy, pressure,
button }`: `phase` is
`"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"` `"pressed"`, `"moved"`, `"released"` or `"cancelled"`; `source` is `"touch"`
or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates or `"mouse"`; `id` is the LÖVE touch id or `"mouse"`; and the coordinates
are LOVE window units, the same space `render.hud`'s viewport and the touch `x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the
overlay lay out in. The on-screen touch controls keep first refusal: a active game viewport and `insideGame` says whether the pointer is inside it.
Without a custom viewport both coordinate pairs are identical. The on-screen
touch controls keep first refusal: a
pointer that begins on a virtual control belongs to the pad for its whole pointer that begins on a virtual control belongs to the pad for its whole
lifecycle and never reaches the hook, while one that begins outside stays lifecycle and never reaches the hook, while one that begins outside stays
visible even if it later crosses a control. A real mouse reaches the hook visible even if it later crosses a control. A real mouse reaches the hook
@@ -691,6 +694,22 @@ composited and before touch controls draw. The window-space viewport contains
and `dpiY`, so a tool can use the letterbox margins without drawing over the and `dpiY`, so a tool can use the letterbox margins without drawing over the
playfield or pushing an updating game state. playfield or pushing an updating game state.
`render.viewport` lets a layout mod reserve the window-space rectangle in which
the game renders. It receives `(next, ctx)` with the full window's `width`,
`height`, `pixelWidth`, `pixelHeight`, `dpiX`, `dpiY`, and `generation`, and
returns `{ x, y, width, height }`. The engine clamps that rectangle to the
window and makes game layout, safe-area calculations, and rendering use it as
their display. Set `capture = true` to request a composition canvas even when
the rectangle fills the window. With no subscriber, no canvas is allocated and
the normal presentation path is unchanged.
When a viewport is active, `render.window` receives `(next, game, ctx)` after
the game frame has been captured. `ctx` contains its `canvas`, `x`, `y`,
`width`, `height`, the full `windowWidth` / `windowHeight`, `dpiX`, `dpiY`, and
`generation`. Calling `next(game, ctx)` draws the game at the requested origin;
a wrapper may instead compose that canvas with its own UI. Touch controls remain
full-size OS-window chrome and draw after this hook.
`render.compose` wraps the whole-window composite in `Renderer:endFrame`. It `render.compose` wraps the whole-window composite in `Renderer:endFrame`. It
receives `(next, renderer, ctx)`; returning `true` without calling `next` hands receives `(next, renderer, ctx)`; returning `true` without calling `next` hands
the mod full control of the window, while calling `next` runs the engine's the mod full control of the window, while calling `next` runs the engine's
+8 -1
View File
@@ -15,6 +15,7 @@ local LaunchOptions = require("src.core.LaunchOptions")
local NxDisplay = require("src.core.NxDisplay") local NxDisplay = require("src.core.NxDisplay")
local PlatformHooks = require("src.core.PlatformHooks") local PlatformHooks = require("src.core.PlatformHooks")
local HostDisplay = require("src.core.HostDisplay") local HostDisplay = require("src.core.HostDisplay")
local GameViewport = require("src.render.GameViewport")
-- Lua errors: persist a redacted trace in the save dir and surface a hint. -- Lua errors: persist a redacted trace in the save dir and surface a hint.
do do
@@ -503,24 +504,30 @@ end
function love.draw() function love.draw()
if editorMode then if editorMode then
GameViewport.reset()
HostDisplay.beginFrame("editor", EditorApp) HostDisplay.beginFrame("editor", EditorApp)
local result = EditorApp.draw() local result = EditorApp.draw()
HostDisplay.endFrame("editor", EditorApp) HostDisplay.endFrame("editor", EditorApp)
return result return result
end end
if TouchEditor then if TouchEditor then
GameViewport.reset()
HostDisplay.beginFrame("touch_editor", TouchEditor) HostDisplay.beginFrame("touch_editor", TouchEditor)
local result = TouchEditor.draw() local result = TouchEditor.draw()
HostDisplay.endFrame("touch_editor", TouchEditor) HostDisplay.endFrame("touch_editor", TouchEditor)
return result return result
end end
if Importer then if Importer then
GameViewport.reset()
HostDisplay.beginFrame("launcher", Importer) HostDisplay.beginFrame("launcher", Importer)
local result = Importer:draw() local result = Importer:draw()
HostDisplay.endFrame("launcher", Importer) HostDisplay.endFrame("launcher", Importer)
return result return result
end end
if not Game then return end if not Game then
GameViewport.reset()
return
end
HostDisplay.beginFrame("game", Game) HostDisplay.beginFrame("game", Game)
Game:draw() Game:draw()
+7 -1
View File
@@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep")
local Input = require("src.core.Input") local Input = require("src.core.Input")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Renderer = require("src.render.Renderer") local Renderer = require("src.render.Renderer")
local GameViewport = require("src.render.GameViewport")
local SaveData = require("src.core.SaveData") local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack") local StateStack = require("src.core.StateStack")
local TouchControls = require("src.core.TouchControls") local TouchControls = require("src.core.TouchControls")
@@ -452,6 +453,7 @@ local function centerClassicZones(zones, offset)
end end
function Game:draw() function Game:draw()
GameViewport.begin(1)
-- the UI canvas clears transparent when the overworld's world pass -- the UI canvas clears transparent when the overworld's world pass
-- shows through beneath it; opaque full-screen states get the classic -- shows through beneath it; opaque full-screen states get the classic
-- white clear -- white clear
@@ -563,7 +565,9 @@ function Game:draw()
if ModRuntime.wantsHook("render.hud") then if ModRuntime.wantsHook("render.hud") then
ModRuntime.call("render.hud", function() end, self, viewport) ModRuntime.call("render.hud", function() end, self, viewport)
end end
-- on-screen mobile controls: pure screen-space, over the finished frame GameViewport.finish(self)
-- OS-window chrome: keep the pad full-size and above any composed companion
-- view instead of capturing and shrinking it with the game viewport.
TouchControls:draw() TouchControls:draw()
end end
@@ -939,8 +943,10 @@ local function pointerUnclaimed() return false end
-- coordinates are LOVE window units, the same space render.hud's viewport -- coordinates are LOVE window units, the same space render.hud's viewport
-- and the touch overlay lay out in -- and the touch overlay lay out in
function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) function Game:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, { return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y, phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
}) })
end end
+24 -18
View File
@@ -35,6 +35,7 @@ local World = require("src.world.gen2.World")
-- The mod event/hook buses. Gold reaches them through Runtime like every -- The mod event/hook buses. Gold reaches them through Runtime like every
-- other engine file, so a call site here is the same call site Gen 1 has. -- other engine file, so a call site here is the same call site Gen 1 has.
local ModRuntime = require("src.mods.Runtime") local ModRuntime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- Only for the mod-supplied save migrations and the mods-changed report, which -- Only for the mod-supplied save migrations and the mods-changed report, which
-- are keyed off save.meta and know nothing about a generation; Gold's own save -- are keyed off save.meta and know nothing about a generation; Gold's own save
-- IO is src/core/gen2/Save.lua. -- IO is src/core/gen2/Save.lua.
@@ -73,8 +74,8 @@ end
-- --
-- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad -- Gold composites its own frame (Game2:draw / drawScene) and pumps its own pad
-- (the FixedStep callback in Game2:load), so none of it goes through -- (the FixedStep callback in Game2:load), so none of it goes through
-- src/render/Renderer.lua or src/core/Game.lua. That explains why the eight -- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks
-- hooks below never used to fire here; it is not a reason they should not. A -- below never used to fire here; it is not a reason they should not. A
-- hook is a contract about a MOMENT in the frame, and Gold has every one of -- hook is a contract about a MOMENT in the frame, and Gold has every one of
-- these moments -- so each is raised under the Gen 1 NAME with the Gen 1 -- these moments -- so each is raised under the Gen 1 NAME with the Gen 1
-- PAYLOAD, at the Gen 1 point in the order: -- PAYLOAD, at the Gen 1 point in the order:
@@ -86,6 +87,8 @@ end
-- render.output* the normal composed frame (Renderer.lua:1063) -- render.output* the normal composed frame (Renderer.lua:1063)
-- render.letterbox the void around the 160x144 blit (Renderer.lua:840) -- render.letterbox the void around the 160x144 blit (Renderer.lua:840)
-- render.hud screen-space UI over the frame (src/core/Game.lua:521) -- render.hud screen-space UI over the frame (src/core/Game.lua:521)
-- render.viewport the game's OS-window rectangle (GameViewport.lua:52)
-- render.window final OS-window composition (GameViewport.lua:145)
-- --
-- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the -- Where Gold genuinely cannot tell two Gen 1 things apart -- it composites the
-- world pass and the UI into ONE canvas, not two -- the call site says so and -- world pass and the UI into ONE canvas, not two -- the call site says so and
@@ -1197,9 +1200,7 @@ function Game2:frameFit(w, h)
dpi = tonumber(love.window.getDPIScale()) or 1 dpi = tonumber(love.window.getDPIScale()) or 1
end end
local pw, ph = w * dpi, h * dpi local pw, ph = w * dpi, h * dpi
if love.graphics.getPixelDimensions then pw, ph = GameViewport.pixelDimensions()
pw, ph = love.graphics.getPixelDimensions()
end
return scale, ox, oy, dpi, pw, ph return scale, ox, oy, dpi, pw, ph
end end
@@ -1217,18 +1218,15 @@ function Game2:viewport(w, h)
} }
end end
-- The screen-space layer, in the Gen 1 order: render.hud and then the -- The render.hud layer, in Gen 1's order over the finished game frame. The
-- on-screen pad (src/core/Game.lua:521 and :524, either side of -- on-screen pad is drawn separately after GameViewport.finish, because it is
-- Renderer:endFrame). Both are window-space, both sit over the finished -- OS-window chrome and must not be captured or scaled with this canvas.
-- frame -- post passes, letterbox and all -- and neither ever enters the game
-- canvas. Every exit path of Game2:draw ends here, which is what makes that
-- true of the composed frame a mod owns as well as of the plain one.
-- --
-- render.hud: persistent tool status. The call is fenced with -- render.hud: persistent tool status. The call is fenced with
-- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a -- push("all")/pop for the reason src/render/Pipelines.lua:guardRender fences a
-- mod render callback: a subscriber that returns cleanly but leaves a shader -- mod render callback: a subscriber that returns cleanly but leaves a shader
-- bound, the canvas redirected or the colour changed must not corrupt the next -- bound, the canvas redirected or the colour changed must not corrupt the next
-- frame -- or, now, the pad drawn immediately after it. -- frame.
function Game2:drawHud(w, h) function Game2:drawHud(w, h)
if ModRuntime.wantsHook("render.hud") then if ModRuntime.wantsHook("render.hud") then
local G = love.graphics local G = love.graphics
@@ -1236,10 +1234,6 @@ function Game2:drawHud(w, h)
ModRuntime.call("render.hud", noop, self, self:viewport(w, h)) ModRuntime.call("render.hud", noop, self, self:viewport(w, h))
G.pop() G.pop()
end end
-- The pad LAST, so a HUD mod cannot draw over the controls the player is
-- pressing. It draws nothing at all off Android/iOS unless POKEPORT_TOUCH=1
-- forces it, and nothing ever while a controller is in use.
TouchControls:draw()
end end
-- render.letterbox: SGB borders and custom void art in the bars around the -- render.letterbox: SGB borders and custom void art in the bars around the
@@ -1363,9 +1357,9 @@ end
-- is being shown on. Mod post-processes fold in between the two, where -- is being shown on. Mod post-processes fold in between the two, where
-- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid -- Renderer.lua:1058 folds them -- a blur or a colour grade is what the LCD grid
-- is then drawn over, rather than something that smears the grid itself. -- is then drawn over, rather than something that smears the grid itself.
function Game2:draw() function Game2:drawViewportFrame()
local G = love.graphics local G = love.graphics
local w, h = G.getDimensions() local w, h = GameViewport.dimensions()
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
@@ -1477,6 +1471,16 @@ function Game2:draw()
self:drawHud(w, h) self:drawHud(w, h)
end end
function Game2:draw()
GameViewport.begin(2)
GameViewport.setTarget()
self:drawViewportFrame()
GameViewport.finish(self)
-- OS-window chrome: draw after companion composition so viewport layouts
-- neither shrink nor cover the touch pad.
TouchControls:draw()
end
-- The paper a pushed TextBox has to sit on. A textbox is built entirely from -- The paper a pushed TextBox has to sit on. A textbox is built entirely from
-- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0 -- font-page tiles ($79-$7e frame, ' ' $7f interior), so it takes BG palette 0
-- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm -- colour 0 from the screen UNDER it (pokegold engine/pokegear/pokegear.asm
@@ -1803,8 +1807,10 @@ end
-- coordinates are LOVE window units, the same space render.hud's viewport is in -- coordinates are LOVE window units, the same space render.hud's viewport is in
function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button) function Game2:pointerEvent(phase, source, id, x, y, dx, dy, pressure, button)
local gameX, gameY, insideGame = GameViewport.toLocal(x, y)
return ModRuntime.call("input.pointer", pointerUnclaimed, self, { return ModRuntime.call("input.pointer", pointerUnclaimed, self, {
phase = phase, source = source, id = id, x = x, y = y, phase = phase, source = source, id = id, x = x, y = y,
gameX = gameX, gameY = gameY, insideGame = insideGame,
dx = dx or 0, dy = dy or 0, pressure = pressure, button = button, dx = dx or 0, dy = dy or 0, pressure = pressure, button = button,
}) })
end end
+8 -2
View File
@@ -7,12 +7,14 @@
-- (touch overlay, launcher) should prefer this over getDimensions; the game -- (touch overlay, launcher) should prefer this over getDimensions; the game
-- canvas may still letterbox into the full framebuffer for immersion. -- canvas may still letterbox into the full framebuffer for immersion.
local GameViewport = require("src.render.GameViewport")
local SafeArea = {} local SafeArea = {}
function SafeArea.rect() function SafeArea.windowRect()
local ww, wh = 0, 0 local ww, wh = 0, 0
if love and love.graphics and love.graphics.getDimensions then if love and love.graphics and love.graphics.getDimensions then
ww, wh = love.graphics.getDimensions() ww, wh = GameViewport.fullDimensions()
end end
if ww <= 0 then ww = 1 end if ww <= 0 then ww = 1 end
if wh <= 0 then wh = 1 end if wh <= 0 then wh = 1 end
@@ -55,4 +57,8 @@ function SafeArea.rect()
return x, y, w, h return x, y, w, h
end end
function SafeArea.rect()
return GameViewport.localSafeRect(SafeArea.windowRect())
end
return SafeArea return SafeArea
+7 -7
View File
@@ -339,7 +339,7 @@ end
-- demand. Mirrors it into self.orientation / self.positions / self.scale, -- demand. Mirrors it into self.orientation / self.positions / self.scale,
-- which layout(), the editor chrome and the tests read. -- which layout(), the editor chrome and the tests read.
function TouchControls:currentBucket() function TouchControls:currentBucket()
local _, _, sw, sh = SafeArea.rect() local _, _, sw, sh = SafeArea.windowRect()
local o = orientationFor(sw, sh) local o = orientationFor(sw, sh)
self.layouts = self.layouts or { portrait = {}, landscape = {} } self.layouts = self.layouts or { portrait = {}, landscape = {} }
local b = self.layouts[o] local b = self.layouts[o]
@@ -363,7 +363,7 @@ end
-- while sizes stay derived from the short edge, times the orientation's -- while sizes stay derived from the short edge, times the orientation's
-- size setting (#633). -- size setting (#633).
function TouchControls:layout() function TouchControls:layout()
local ox, oy, sw, sh = SafeArea.rect() local ox, oy, sw, sh = SafeArea.windowRect()
if self.layoutW == sw and self.layoutH == sh if self.layoutW == sw and self.layoutH == sh
and self.layoutOx == ox and self.layoutOy == oy and self.L then and self.layoutOx == ox and self.layoutOy == oy and self.L then
return self.L return self.L
@@ -397,7 +397,7 @@ end
-- Move one control to a screen-space point and persist its normalized -- Move one control to a screen-space point and persist its normalized
-- position within the safe rect. Used by the layout editor while dragging. -- position within the safe rect. Used by the layout editor while dragging.
function TouchControls:setControlCenter(name, cx, cy) function TouchControls:setControlCenter(name, cx, cy)
local ox, oy, sw, sh = SafeArea.rect() local ox, oy, sw, sh = SafeArea.windowRect()
local L = self:layout() local L = self:layout()
local zone = L[name] local zone = L[name]
if not zone then return end if not zone then return end
@@ -608,10 +608,10 @@ local function drawIcon(img, zone, pressed, alphaMul)
zone.cy - img:getHeight() * scale / 2, 0, scale, scale) zone.cy - img:getHeight() * scale / 2, 0, scale, scale)
end end
-- Screen-space, called by Game:draw after Renderer:endFrame -- and by -- OS-window space, called after GameViewport.finish so the overlay rides on
-- Game2:drawHud after Gold's own present pass -- so the overlay rides on top -- top of the game, companion composition and post-processing without being
-- of everything (world, UI, CRT/GBC FX included). Also used by the launcher -- captured or scaled with any game viewport. Also used by the launcher layout
-- layout editor under preview mode. -- editor under preview mode.
function TouchControls:draw() function TouchControls:draw()
if not self:visible() then return end if not self:visible() then return end
local L = self:layout() local L = self:layout()
+185
View File
@@ -0,0 +1,185 @@
-- Optional game viewport inside the OS window. A layout mod may reserve any
-- window-space rectangle through render.viewport; the game then renders as if
-- that rectangle were its whole display. With no subscriber this module is a
-- pass-through and allocates no canvas.
local Runtime = require("src.mods.Runtime")
local Viewport = {
rect = nil,
full = nil,
canvas = nil,
generation = nil,
frameActive = false,
}
local function finite(value)
return type(value) == "number" and value == value
and value > -math.huge and value < math.huge
end
local function realMetrics()
local G = love.graphics
local w, h = G.getDimensions()
local pw, ph = w, h
if G.getPixelDimensions then pw, ph = G.getPixelDimensions() end
local dpiX = w > 0 and pw / w or 1
local dpiY = h > 0 and ph / h or 1
if dpiX < 1e-6 then dpiX = 1 end
if dpiY < 1e-6 then dpiY = 1 end
return math.max(1, w), math.max(1, h),
math.max(1, pw), math.max(1, ph), dpiX, dpiY
end
local function clampRect(value, w, h)
if type(value) ~= "table" then
return { x = 0, y = 0, width = w, height = h }
end
local x = finite(value.x) and math.floor(value.x) or 0
local y = finite(value.y) and math.floor(value.y) or 0
local rw = finite(value.width) and math.floor(value.width) or w
local rh = finite(value.height) and math.floor(value.height) or h
x = math.max(0, math.min(x, w - 1))
y = math.max(0, math.min(y, h - 1))
rw = math.max(1, math.min(rw, w - x))
rh = math.max(1, math.min(rh, h - y))
return { x = x, y = y, width = rw, height = rh }
end
local function sameSize(canvas, w, h)
return canvas and canvas:getWidth() == w and canvas:getHeight() == h
end
function Viewport.begin(generation)
local w, h, pw, ph, dpiX, dpiY = realMetrics()
local context = {
width = w, height = h, pixelWidth = pw, pixelHeight = ph,
dpiX = dpiX, dpiY = dpiY, generation = generation,
}
local requested
if Runtime.wantsHook("render.viewport") then
requested = Runtime.call("render.viewport", function(ctx)
return { x = 0, y = 0, width = ctx.width, height = ctx.height }
end, context)
end
local rect = clampRect(requested, w, h)
Viewport.full = context
Viewport.rect = rect
Viewport.generation = generation
local active = type(requested) == "table" and requested.capture == true
or rect.x ~= 0 or rect.y ~= 0
or rect.width ~= w or rect.height ~= h
Viewport.frameActive = active
if active then
if not sameSize(Viewport.canvas, rect.width, rect.height) then
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = love.graphics.newCanvas(rect.width, rect.height)
Viewport.canvas:setFilter("nearest", "nearest")
end
else
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return rect
end
function Viewport.active()
return Viewport.frameActive == true and Viewport.canvas ~= nil
end
function Viewport.dimensions()
if Viewport.active() then
return Viewport.rect.width, Viewport.rect.height
end
return love.graphics.getDimensions()
end
function Viewport.pixelDimensions()
if Viewport.active() then
if Viewport.canvas.getPixelDimensions then
local w, h = Viewport.canvas:getPixelDimensions()
return math.max(1, w), math.max(1, h)
end
return math.max(1, math.floor(Viewport.rect.width * Viewport.full.dpiX)),
math.max(1, math.floor(Viewport.rect.height * Viewport.full.dpiY))
end
if love.graphics.getPixelDimensions then
return love.graphics.getPixelDimensions()
end
return love.graphics.getDimensions()
end
function Viewport.fullDimensions()
if Viewport.full then return Viewport.full.width, Viewport.full.height end
return love.graphics.getDimensions()
end
function Viewport.target()
return Viewport.canvas
end
function Viewport.setTarget()
love.graphics.setCanvas(Viewport.canvas)
end
function Viewport.toLocal(x, y)
local rect = Viewport.rect
if not rect then return x, y, true end
local lx, ly = x - rect.x, y - rect.y
return lx, ly,
lx >= 0 and ly >= 0 and lx < rect.width and ly < rect.height
end
function Viewport.localSafeRect(x, y, w, h)
local rect = Viewport.rect
if not Viewport.active() or not rect then return x, y, w, h end
local x1, y1 = math.max(x, rect.x), math.max(y, rect.y)
local x2 = math.min(x + w, rect.x + rect.width)
local y2 = math.min(y + h, rect.y + rect.height)
if x2 <= x1 or y2 <= y1 then
return 0, 0, rect.width, rect.height
end
return x1 - rect.x, y1 - rect.y, x2 - x1, y2 - y1
end
function Viewport.finish(game)
if not Viewport.active() then return end
local G = love.graphics
local rect, full = Viewport.rect, Viewport.full
G.setCanvas()
G.push("all")
G.origin()
G.setScissor()
G.setShader()
G.setBlendMode("alpha")
G.clear(0, 0, 0, 1)
local context = {
canvas = Viewport.canvas,
x = rect.x, y = rect.y, width = rect.width, height = rect.height,
windowWidth = full.width, windowHeight = full.height,
dpiX = full.dpiX, dpiY = full.dpiY,
generation = Viewport.generation,
}
Runtime.call("render.window", function(_, ctx)
G.setColor(1, 1, 1, 1)
G.draw(ctx.canvas, ctx.x, ctx.y)
end, game, context)
G.pop()
end
function Viewport.reset()
Viewport.frameActive = false
Viewport.rect = nil
Viewport.full = nil
Viewport.generation = nil
if Viewport.canvas and Viewport.canvas.release then
Viewport.canvas:release()
end
Viewport.canvas = nil
end
return Viewport
+5 -6
View File
@@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
local PixelCanvas = require("src.render.PixelCanvas") local PixelCanvas = require("src.render.PixelCanvas")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local GameViewport = require("src.render.GameViewport")
-- leaf module (no renderer dependency), so requiring it here cannot cycle -- leaf module (no renderer dependency), so requiring it here cannot cycle
local FaithfulRes = require("src.core.FaithfulRes") local FaithfulRes = require("src.core.FaithfulRes")
@@ -69,11 +70,9 @@ Renderer.UPRIGHT_MARGIN = 160
-- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels -- Keep separate dpiX/dpiY so each GB pixel covers fitScale() physical pixels
-- on BOTH axes (square). -- on BOTH axes (square).
local function displayMetrics() local function displayMetrics()
local ww, wh = love.graphics.getDimensions() local ww, wh = GameViewport.dimensions()
local pw, ph = ww, wh local pw, ph = ww, wh
if love.graphics.getPixelDimensions then pw, ph = GameViewport.pixelDimensions()
pw, ph = love.graphics.getPixelDimensions()
end
local dpiX, dpiY = 1, 1 local dpiX, dpiY = 1, 1
if ww > 0 and pw > 0 then dpiX = pw / ww end if ww > 0 and pw > 0 then dpiX = pw / ww end
if wh > 0 and ph > 0 then dpiY = ph / wh end if wh > 0 and ph > 0 then dpiY = ph / wh end
@@ -698,7 +697,7 @@ end
-- When GBC FX is active the composite is drawn into presentCanvas and -- When GBC FX is active the composite is drawn into presentCanvas and
-- presented through the GBC FX shader as a final pass. -- presented through the GBC FX shader as a final pass.
function Renderer:endFrame(zones, worldZones) function Renderer:endFrame(zones, worldZones)
love.graphics.setCanvas() GameViewport.setTarget()
local ww, wh, pw, ph, dpiX, dpiY = displayMetrics() local ww, wh, pw, ph, dpiX, dpiY = displayMetrics()
-- Sp = integer framebuffer pixels per GB pixel; -- Sp = integer framebuffer pixels per GB pixel;
-- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY). -- Sx/Sy = LOVE-unit draw scales (may differ when dpiX ≠ dpiY).
@@ -1064,7 +1063,7 @@ function Renderer:endFrame(zones, worldZones)
end end
if present then if present then
love.graphics.setCanvas() GameViewport.setTarget()
-- Post-process pipelines run over the finished composite -- world, UI -- 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 -- 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 -- LCD grid is then drawn over rather than something that smears the
+2 -1
View File
@@ -28,6 +28,7 @@
-- covered by tests; the state at the bottom is the only part that draws. -- covered by tests; the state at the bottom is the only part that draws.
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Palettes = require("src.world.gen2.Palettes") local Palettes = require("src.world.gen2.Palettes")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local SpriteAnims = require("src.ui.gen2.SpriteAnims") local SpriteAnims = require("src.ui.gen2.SpriteAnims")
@@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row)
end end
function BattleTransition:draw() function BattleTransition:draw()
local w, h = love.graphics.getDimensions() local w, h = GameViewport.dimensions()
self:drawWidescreen(w, h) self:drawWidescreen(w, h)
end end
+2 -1
View File
@@ -17,6 +17,7 @@
local Kit = require("src.ui.kit.Kit") local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme") local Theme = require("src.ui.kit.Theme")
local SafeArea = require("src.core.SafeArea") local SafeArea = require("src.core.SafeArea")
local GameViewport = require("src.render.GameViewport")
local Layout = {} local Layout = {}
@@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax
function Layout.metrics(maxAppW) function Layout.metrics(maxAppW)
local W, H = 0, 0 local W, H = 0, 0
if love and love.graphics and love.graphics.getDimensions then if love and love.graphics and love.graphics.getDimensions then
W, H = love.graphics.getDimensions() W, H = GameViewport.dimensions()
end end
local ox, oy, sw, sh = SafeArea.rect() local ox, oy, sw, sh = SafeArea.rect()
local s = Kit.layout(sw, sh) local s = Kit.layout(sw, sh)
+2 -1
View File
@@ -9,6 +9,7 @@ local Collision = require("src.world.Collision")
local Encounter = require("src.world.Encounter") local Encounter = require("src.world.Encounter")
local FieldDefaults = require("src.world.FieldDefaults") local FieldDefaults = require("src.world.FieldDefaults")
local GameVersion = require("src.core.GameVersion") local GameVersion = require("src.core.GameVersion")
local GameViewport = require("src.render.GameViewport")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Map = require("src.world.Map") local Map = require("src.world.Map")
local MapLoader = require("src.world.MapLoader") local MapLoader = require("src.world.MapLoader")
@@ -5131,7 +5132,7 @@ function OverworldState:drawWorld()
-- point projects under the pipeline's own camera. That is the direct -- point projects under the pipeline's own camera. That is the direct
-- analogue of what :billboard does for tilt, and it keeps exactly one -- analogue of what :billboard does for tilt, and it keeps exactly one
-- copy of every effect: the closures above are the ones that run. -- copy of every effect: the closures above are the ones that run.
local pw, ph = love.graphics.getDimensions() local pw, ph = GameViewport.dimensions()
local pscale = Zoom.scale(Game.renderer:fitScale()) local pscale = Zoom.scale(Game.renderer:fitScale())
local ctx = { local ctx = {
state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, state = self, cam = cam, vw = vw, vh = vh, bgY = bgY,
+5 -4
View File
@@ -34,6 +34,7 @@ local Font = require("src.render.Font")
-- a mod has taken a facade (src/mods/Gen2Compat.lua). -- a mod has taken a facade (src/mods/Gen2Compat.lua).
local Gen1Facade = require("src.mods.Gen2Compat") local Gen1Facade = require("src.mods.Gen2Compat")
local GbcPalette = require("src.render.GbcPalette") local GbcPalette = require("src.render.GbcPalette")
local GameViewport = require("src.render.GameViewport")
local Gen2Save = require("src.core.gen2.Save") local Gen2Save = require("src.core.gen2.Save")
local HallOfFame = require("src.core.gen2.HallOfFame") local HallOfFame = require("src.core.gen2.HallOfFame")
local HiddenItems = require("src.world.gen2.HiddenItems") local HiddenItems = require("src.world.gen2.HiddenItems")
@@ -7548,7 +7549,7 @@ function World:interactBody()
end end
function World:fitScale() function World:fitScale()
local w, h = love.graphics.getDimensions() local w, h = GameViewport.dimensions()
return math.max(1, math.floor(math.min(w / 160, h / 144))) return math.max(1, math.floor(math.min(w / 160, h / 144)))
end end
@@ -8375,7 +8376,7 @@ function World:rebuildNeighbors()
self.neighbors = {} self.neighbors = {}
if not self.map then return end if not self.map then return end
local s = self:zoomScale() local s = self:zoomScale()
local ww, wh = love.graphics.getDimensions() local ww, wh = GameViewport.dimensions()
local vw = math.ceil(ww / s) local vw = math.ceil(ww / s)
local vh = math.ceil(wh / s) local vh = math.ceil(wh / s)
if vw % 2 ~= 0 then vw = vw + 1 end if vw % 2 ~= 0 then vw = vw + 1 end
@@ -9742,7 +9743,7 @@ function World:drawGround(s)
if canvas then if canvas then
bw, bh = canvas:getDimensions() bw, bh = canvas:getDimensions()
else else
bw, bh = G.getDimensions() bw, bh = GameViewport.dimensions()
end end
BorderFill.draw(self, self:borderImageFor(self.map.id), BorderFill.draw(self, self:borderImageFor(self.map.id),
cam.x, cam.y, bw, bh, s, self.map.id) cam.x, cam.y, bw, bh, s, self.map.id)
@@ -10002,7 +10003,7 @@ end
function World:draw() function World:draw()
local G = love.graphics local G = love.graphics
local w, h = G.getDimensions() local w, h = GameViewport.dimensions()
self:refreshColorMode() self:refreshColorMode()
G.clear(0.07, 0.05, 0.02, 1) G.clear(0.07, 0.05, 0.02, 1)
+101
View File
@@ -0,0 +1,101 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
love = require("tests.love_stub")
local Hooks = require("src.mods.Hooks")
local Runtime = require("src.mods.Runtime")
local savedHooks = Runtime.hooks
local hooks = Hooks.new()
Runtime.hooks = hooks
local Viewport = require("src.render.GameViewport")
local SafeArea = require("src.core.SafeArea")
local TouchControls = require("src.core.TouchControls")
Viewport.begin(1)
assert(not Viewport.active(), "vanilla frame must not allocate a viewport")
local w, h = Viewport.dimensions()
assert(w == 640 and h == 576, "vanilla dimensions must stay unchanged")
hooks:wrap("render.viewport", function(next, ctx)
local full = next(ctx)
assert(full.width == 640 and full.height == 576,
"viewport hook receives OS-independent window geometry")
return { x = 320, y = 12, width = 320, height = 288 }
end, 0, "fixture")
local presented
hooks:wrap("render.window", function(next, game, ctx)
presented = ctx
return next(game, ctx)
end, 0, "fixture")
Viewport.begin(2)
assert(Viewport.active(), "a reserved rectangle creates a game target")
w, h = Viewport.dimensions()
assert(w == 320 and h == 288, "game renders against reserved dimensions")
Viewport.target().getPixelDimensions = function() return 737, 664 end
local pw, ph = Viewport.pixelDimensions()
assert(pw == 737 and ph == 664,
"captured rendering uses the target's real high-DPI pixel dimensions")
local x, y, inside = Viewport.toLocal(400, 100)
assert(x == 80 and y == 88 and inside,
"window pointers expose viewport-local coordinates")
local _, _, outside = Viewport.toLocal(20, 20)
assert(not outside, "reserved companion space is outside the game viewport")
local _, _, localW, localH = SafeArea.rect()
local _, _, windowW, windowH = SafeArea.windowRect()
assert(localW == 320 and localH == 288,
"game chrome may still use viewport-local safe geometry")
assert(windowW == 640 and windowH == 576,
"OS chrome can retain the full-window safe geometry")
TouchControls:init()
local controls = TouchControls:layout()
assert(controls.dpad.cx < 160 and controls.a.cx > 480,
"touch controls stay laid out across the full OS window")
local function source(path)
local file = assert(io.open(path, "r"))
local text = file:read("*a")
file:close()
return text
end
for _, path in ipairs({ "src/core/Game.lua", "src/core/Game2.lua" }) do
local text = source(path)
local finish = assert(text:find("GameViewport.finish(self)", 1, true))
local controlsDraw = assert(text:find("TouchControls:draw()", finish, true))
assert(controlsDraw > finish,
path .. " draws touch controls after final window composition")
end
Viewport.setTarget()
assert(love.graphics.getCanvas() == Viewport.target(),
"game rendering is redirected into the viewport canvas")
Viewport.finish({})
assert(presented and presented.x == 320 and presented.y == 12
and presented.width == 320 and presented.height == 288
and presented.windowWidth == 640 and presented.windowHeight == 576
and presented.generation == 2,
"window composition receives game and host geometry")
assert(love.graphics.getCanvas() == nil,
"window composition restores the OS render target")
Viewport.reset()
assert(not Viewport.active(),
"viewport geometry cannot leak into the launcher after presentation")
hooks.chains["render.viewport"] = nil
hooks:wrap("render.viewport", function(next, ctx)
local full = next(ctx)
full.capture = true
return full
end, 0, "capture-fixture")
Viewport.begin(1)
assert(Viewport.active() and Viewport.dimensions() == 640,
"a full-window capture allocates a final composition target")
presented = nil
Viewport.setTarget()
Viewport.finish({})
assert(presented and presented.width == 640 and presented.height == 576,
"a full-window capture reaches final window composition")
Viewport.reset()
Runtime.hooks = savedHooks
print("render viewport: ok")