diff --git a/docs/mod-api-gen2-compat.md b/docs/mod-api-gen2-compat.md index 48429c6f..e412b4d2 100644 --- a/docs/mod-api-gen2-compat.md +++ b/docs/mod-api-gen2-compat.md @@ -554,7 +554,7 @@ gains a field instead of the name gaining a prefix. passes `game`; positions 2-4 (mon, row, trigger) match. - *The frame (`src/core/Game2.lua`):* hooks `input.step`, `input.pointer`, `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 -- 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 diff --git a/docs/modding.md b/docs/modding.md index 264ad9d5..64f9d274 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -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 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"` 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 -overlay lay out in. The on-screen touch controls keep first refusal: a +`x` / `y` are LOVE window units, while `gameX` / `gameY` are local to the +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 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 @@ -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 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 receives `(next, renderer, ctx)`; returning `true` without calling `next` hands the mod full control of the window, while calling `next` runs the engine's diff --git a/main.lua b/main.lua index 23b2385a..c890caf9 100644 --- a/main.lua +++ b/main.lua @@ -15,6 +15,7 @@ local LaunchOptions = require("src.core.LaunchOptions") local NxDisplay = require("src.core.NxDisplay") local PlatformHooks = require("src.core.PlatformHooks") 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. do @@ -503,24 +504,30 @@ end function love.draw() if editorMode then + GameViewport.reset() HostDisplay.beginFrame("editor", EditorApp) local result = EditorApp.draw() HostDisplay.endFrame("editor", EditorApp) return result end if TouchEditor then + GameViewport.reset() HostDisplay.beginFrame("touch_editor", TouchEditor) local result = TouchEditor.draw() HostDisplay.endFrame("touch_editor", TouchEditor) return result end if Importer then + GameViewport.reset() HostDisplay.beginFrame("launcher", Importer) local result = Importer:draw() HostDisplay.endFrame("launcher", Importer) return result end - if not Game then return end + if not Game then + GameViewport.reset() + return + end HostDisplay.beginFrame("game", Game) Game:draw() diff --git a/src/core/Game.lua b/src/core/Game.lua index 0f8fb6de..35989cfd 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -6,6 +6,7 @@ local FixedStep = require("src.core.FixedStep") local Input = require("src.core.Input") local Logger = require("src.core.Logger") local Renderer = require("src.render.Renderer") +local GameViewport = require("src.render.GameViewport") local SaveData = require("src.core.SaveData") local StateStack = require("src.core.StateStack") local TouchControls = require("src.core.TouchControls") @@ -452,6 +453,7 @@ local function centerClassicZones(zones, offset) end function Game:draw() + GameViewport.begin(1) -- the UI canvas clears transparent when the overworld's world pass -- shows through beneath it; opaque full-screen states get the classic -- white clear @@ -563,7 +565,9 @@ function Game:draw() if ModRuntime.wantsHook("render.hud") then ModRuntime.call("render.hud", function() end, self, viewport) 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() end @@ -939,8 +943,10 @@ local function pointerUnclaimed() return false end -- coordinates are LOVE window units, the same space render.hud's viewport -- and the touch overlay lay out in 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, { 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, }) end diff --git a/src/core/Game2.lua b/src/core/Game2.lua index 6365cb37..112859eb 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -35,6 +35,7 @@ local World = require("src.world.gen2.World") -- 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. 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 -- are keyed off save.meta and know nothing about a generation; Gold's own save -- 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 -- (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 --- hooks below never used to fire here; it is not a reason they should not. A +-- src/render/Renderer.lua or src/core/Game.lua. That explains why the hooks +-- 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 -- these moments -- so each is raised under the Gen 1 NAME with the Gen 1 -- PAYLOAD, at the Gen 1 point in the order: @@ -86,6 +87,8 @@ end -- render.output* the normal composed frame (Renderer.lua:1063) -- 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.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 -- 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 end local pw, ph = w * dpi, h * dpi - if love.graphics.getPixelDimensions then - pw, ph = love.graphics.getPixelDimensions() - end + pw, ph = GameViewport.pixelDimensions() return scale, ox, oy, dpi, pw, ph end @@ -1217,18 +1218,15 @@ function Game2:viewport(w, h) } end --- The screen-space layer, in the Gen 1 order: render.hud and then the --- on-screen pad (src/core/Game.lua:521 and :524, either side of --- Renderer:endFrame). Both are window-space, both sit over the finished --- 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. +-- The render.hud layer, in Gen 1's order over the finished game frame. The +-- on-screen pad is drawn separately after GameViewport.finish, because it is +-- OS-window chrome and must not be captured or scaled with this canvas. -- -- render.hud: persistent tool status. The call is fenced with -- 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 -- 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) if ModRuntime.wantsHook("render.hud") then local G = love.graphics @@ -1236,10 +1234,6 @@ function Game2:drawHud(w, h) ModRuntime.call("render.hud", noop, self, self:viewport(w, h)) G.pop() 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 -- 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 -- 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. -function Game2:draw() +function Game2:drawViewportFrame() local G = love.graphics - local w, h = G.getDimensions() + local w, h = GameViewport.dimensions() local GBCFX = require("src.render.GBCFX") local GbcPalette = require("src.render.GbcPalette") local Pipelines = require("src.render.Pipelines") @@ -1477,6 +1471,16 @@ function Game2:draw() self:drawHud(w, h) 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 -- 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 @@ -1803,8 +1807,10 @@ end -- 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) + local gameX, gameY, insideGame = GameViewport.toLocal(x, y) return ModRuntime.call("input.pointer", pointerUnclaimed, self, { 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, }) end diff --git a/src/core/SafeArea.lua b/src/core/SafeArea.lua index 37d600a5..2cd150a9 100644 --- a/src/core/SafeArea.lua +++ b/src/core/SafeArea.lua @@ -7,12 +7,14 @@ -- (touch overlay, launcher) should prefer this over getDimensions; the game -- canvas may still letterbox into the full framebuffer for immersion. +local GameViewport = require("src.render.GameViewport") + local SafeArea = {} -function SafeArea.rect() +function SafeArea.windowRect() local ww, wh = 0, 0 if love and love.graphics and love.graphics.getDimensions then - ww, wh = love.graphics.getDimensions() + ww, wh = GameViewport.fullDimensions() end if ww <= 0 then ww = 1 end if wh <= 0 then wh = 1 end @@ -55,4 +57,8 @@ function SafeArea.rect() return x, y, w, h end +function SafeArea.rect() + return GameViewport.localSafeRect(SafeArea.windowRect()) +end + return SafeArea diff --git a/src/core/TouchControls.lua b/src/core/TouchControls.lua index b3422edb..7d879c41 100644 --- a/src/core/TouchControls.lua +++ b/src/core/TouchControls.lua @@ -339,7 +339,7 @@ end -- demand. Mirrors it into self.orientation / self.positions / self.scale, -- which layout(), the editor chrome and the tests read. function TouchControls:currentBucket() - local _, _, sw, sh = SafeArea.rect() + local _, _, sw, sh = SafeArea.windowRect() local o = orientationFor(sw, sh) self.layouts = self.layouts or { portrait = {}, landscape = {} } local b = self.layouts[o] @@ -363,7 +363,7 @@ end -- while sizes stay derived from the short edge, times the orientation's -- size setting (#633). 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 and self.layoutOx == ox and self.layoutOy == oy and self.L then return self.L @@ -397,7 +397,7 @@ end -- Move one control to a screen-space point and persist its normalized -- position within the safe rect. Used by the layout editor while dragging. 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 zone = L[name] 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) end --- Screen-space, called by Game:draw after Renderer:endFrame -- and by --- Game2:drawHud after Gold's own present pass -- so the overlay rides on top --- of everything (world, UI, CRT/GBC FX included). Also used by the launcher --- layout editor under preview mode. +-- OS-window space, called after GameViewport.finish so the overlay rides on +-- top of the game, companion composition and post-processing without being +-- captured or scaled with any game viewport. Also used by the launcher layout +-- editor under preview mode. function TouchControls:draw() if not self:visible() then return end local L = self:layout() diff --git a/src/render/GameViewport.lua b/src/render/GameViewport.lua new file mode 100644 index 00000000..b1ae69a8 --- /dev/null +++ b/src/render/GameViewport.lua @@ -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 diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index 7d5553a1..149e4899 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -12,6 +12,7 @@ local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local PixelCanvas = require("src.render.PixelCanvas") local Runtime = require("src.mods.Runtime") +local GameViewport = require("src.render.GameViewport") -- leaf module (no renderer dependency), so requiring it here cannot cycle 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 -- on BOTH axes (square). local function displayMetrics() - local ww, wh = love.graphics.getDimensions() + local ww, wh = GameViewport.dimensions() local pw, ph = ww, wh - if love.graphics.getPixelDimensions then - pw, ph = love.graphics.getPixelDimensions() - end + pw, ph = GameViewport.pixelDimensions() 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 @@ -698,7 +697,7 @@ end -- 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() + GameViewport.setTarget() 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). @@ -1064,7 +1063,7 @@ function Renderer:endFrame(zones, worldZones) end if present then - love.graphics.setCanvas() + GameViewport.setTarget() -- 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 diff --git a/src/ui/gen2/BattleTransition.lua b/src/ui/gen2/BattleTransition.lua index a3a22df2..aff3f69d 100644 --- a/src/ui/gen2/BattleTransition.lua +++ b/src/ui/gen2/BattleTransition.lua @@ -28,6 +28,7 @@ -- covered by tests; the state at the bottom is the only part that draws. local GbcPalette = require("src.render.GbcPalette") +local GameViewport = require("src.render.GameViewport") local Palettes = require("src.world.gen2.Palettes") local Runtime = require("src.mods.Runtime") local SpriteAnims = require("src.ui.gen2.SpriteAnims") @@ -509,7 +510,7 @@ function BattleTransition:blackAt(col, row) end function BattleTransition:draw() - local w, h = love.graphics.getDimensions() + local w, h = GameViewport.dimensions() self:drawWidescreen(w, h) end diff --git a/src/ui/kit/Layout.lua b/src/ui/kit/Layout.lua index d5087e5f..9ccc8309 100644 --- a/src/ui/kit/Layout.lua +++ b/src/ui/kit/Layout.lua @@ -17,6 +17,7 @@ local Kit = require("src.ui.kit.Kit") local Theme = require("src.ui.kit.Theme") local SafeArea = require("src.core.SafeArea") +local GameViewport = require("src.render.GameViewport") local Layout = {} @@ -41,7 +42,7 @@ local lastW, lastH, lastOx, lastOy, lastSw, lastSh, lastMax function Layout.metrics(maxAppW) local W, H = 0, 0 if love and love.graphics and love.graphics.getDimensions then - W, H = love.graphics.getDimensions() + W, H = GameViewport.dimensions() end local ox, oy, sw, sh = SafeArea.rect() local s = Kit.layout(sw, sh) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 6037233a..7436c392 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -9,6 +9,7 @@ local Collision = require("src.world.Collision") local Encounter = require("src.world.Encounter") local FieldDefaults = require("src.world.FieldDefaults") local GameVersion = require("src.core.GameVersion") +local GameViewport = require("src.render.GameViewport") local Logger = require("src.core.Logger") local Map = require("src.world.Map") 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 -- analogue of what :billboard does for tilt, and it keeps exactly one -- 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 ctx = { state = self, cam = cam, vw = vw, vh = vh, bgY = bgY, diff --git a/src/world/gen2/World.lua b/src/world/gen2/World.lua index a62f677f..a8a1f3d9 100644 --- a/src/world/gen2/World.lua +++ b/src/world/gen2/World.lua @@ -34,6 +34,7 @@ local Font = require("src.render.Font") -- a mod has taken a facade (src/mods/Gen2Compat.lua). local Gen1Facade = require("src.mods.Gen2Compat") local GbcPalette = require("src.render.GbcPalette") +local GameViewport = require("src.render.GameViewport") local Gen2Save = require("src.core.gen2.Save") local HallOfFame = require("src.core.gen2.HallOfFame") local HiddenItems = require("src.world.gen2.HiddenItems") @@ -7548,7 +7549,7 @@ function World:interactBody() end 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))) end @@ -8375,7 +8376,7 @@ function World:rebuildNeighbors() self.neighbors = {} if not self.map then return end local s = self:zoomScale() - local ww, wh = love.graphics.getDimensions() + local ww, wh = GameViewport.dimensions() local vw = math.ceil(ww / s) local vh = math.ceil(wh / s) if vw % 2 ~= 0 then vw = vw + 1 end @@ -9742,7 +9743,7 @@ function World:drawGround(s) if canvas then bw, bh = canvas:getDimensions() else - bw, bh = G.getDimensions() + bw, bh = GameViewport.dimensions() end BorderFill.draw(self, self:borderImageFor(self.map.id), cam.x, cam.y, bw, bh, s, self.map.id) @@ -10002,7 +10003,7 @@ end function World:draw() local G = love.graphics - local w, h = G.getDimensions() + local w, h = GameViewport.dimensions() self:refreshColorMode() G.clear(0.07, 0.05, 0.02, 1) diff --git a/tests/engine/render_viewport.lua b/tests/engine/render_viewport.lua new file mode 100644 index 00000000..7c100295 --- /dev/null +++ b/tests/engine/render_viewport.lua @@ -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")