diff --git a/src/render/Renderer.lua b/src/render/Renderer.lua index ebc364c9..e5daa3d3 100644 --- a/src/render/Renderer.lua +++ b/src/render/Renderer.lua @@ -53,6 +53,16 @@ end -- endFrame composites the padded canvas back with a matching offset. Renderer.UPRIGHT_MARGIN = 160 +-- Whether endFrame composites a world override MIRRORED. A pipeline owns its +-- own clip convention and the ones in the wild pre-flip Y for LOVE 11, which +-- LOVE 12 turns into one flip too many. Named because it is not only the +-- blit's business: see ctx.drawFx in src/world/OverworldController.lua. +function Renderer.mirrorsWorldOverride() + local sys = love.system + if not (sys and sys.getOS and sys.getOS() == "iOS") then return false end + return (love.getVersion()) >= 12 +end + -- 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 @@ -1056,8 +1066,7 @@ function Renderer:endFrame(zones, worldZones) -- runs, so dialogs, menus and the HUD sit on top as usual. love.graphics.setColor(1, 1, 1, 1) love.graphics.setScissor(vux, vuy, vuw, vuh) - local loveMajor = love.getVersion() - if love.system and love.system.getOS and love.system.getOS() == "iOS" and loveMajor >= 12 then + if Renderer.mirrorsWorldOverride() then love.graphics.draw(self.worldOverride, vux, vuy + vuh, 0, 1 / dpiX, -1 / dpiY) else love.graphics.draw(self.worldOverride, vux, vuy, 0, 1 / dpiX, 1 / dpiY) diff --git a/src/world/OverworldController.lua b/src/world/OverworldController.lua index 0a977ca9..99dfd3c0 100644 --- a/src/world/OverworldController.lua +++ b/src/world/OverworldController.lua @@ -18,6 +18,7 @@ local NPC = require("src.world.NPC") local PaletteFX = require("src.render.PaletteFX") local Pipelines = require("src.render.Pipelines") local Player = require("src.world.Player") +local Renderer = require("src.render.Renderer") local Runtime = require("src.mods.Runtime") local Screens = require("src.ui.Screens") local ScriptRunner = require("src.script.ScriptRunner") @@ -33,6 +34,33 @@ local Strings = require("src.core.Strings") -- isOverworld marks the live world state for WorldAPI's stack scan local OverworldState = { isOpaque = true, isOverworld = true } +-- The pipeline's own target, not the playfield: a pipeline with a render +-- scale draws smaller and lets endFrame scale it up. +local function overrideCanvasHeight() + local bound = love.graphics.getCanvas() + if bound and not bound.getHeight then bound = bound[1] end -- several bound + if bound and not bound.getHeight then bound = bound.canvas end -- one face of one + return bound and bound.getHeight and bound:getHeight() +end + +-- Run a block of field FX the way the composite expects them. Where endFrame +-- mirrors the override, these are the one part of that canvas the mirror would +-- invert rather than right: the pipeline's pass went in pre-flipped, these are +-- ordinary 2D. project() already answers in screen rows, so mirroring the +-- block about the bound canvas is the whole correction, and project is left +-- alone -- fixing it too would move each anchor twice. Exposed for tests. +local function withOverrideMirror(draw) + local height = Renderer.mirrorsWorldOverride() and overrideCanvasHeight() + if not height then return draw() end + love.graphics.push() + love.graphics.translate(0, height) + love.graphics.scale(1, -1) + draw() + love.graphics.pop() +end + +OverworldState.withOverrideMirror = withOverrideMirror + local Game -- set on enter (avoids circular require at load time) local mapScripts -- registry of hand-ported map scripts @@ -6214,27 +6242,29 @@ function OverworldState:drawWorld() love.graphics.pop() if shader then love.graphics.setShader() end end - -- ground-hugging effects sit on the cell they belong to - if self.dustAnim then - local da = self.dustAnim - at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0)) - end - if self.cutAnim then - at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16) - end - if self.healAnim then - at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16) - end - -- standing effects anchor at the foot of whoever they belong to - if self.emote and self.emote.npc then - at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16) - end - if self.flyAnim then - at(fxBird, self.player.px + 8, self.player.py + 16) - end - if self.fishing then - at(fxRod, self.player.px + 8, self.player.py + 16) - end + withOverrideMirror(function() + -- ground-hugging effects sit on the cell they belong to + if self.dustAnim then + local da = self.dustAnim + at(fxDust, da.x * 16 + 8 + (da.ox or 0), da.y * 16 + 8 + (da.oy or 0)) + end + if self.cutAnim then + at(fxCutTree, self.cutAnim.x * 16 + 8, self.cutAnim.y * 16 + 16) + end + if self.healAnim then + at(fxHeal, self.healAnim.px + 8, self.healAnim.py + 16) + end + -- standing effects anchor at the foot of whoever they belong to + if self.emote and self.emote.npc then + at(fxEmote, self.emote.npc.px + 8, self.emote.npc.py + 16) + end + if self.flyAnim then + at(fxBird, self.player.px + 8, self.player.py + 16) + end + if self.fishing then + at(fxRod, self.player.px + 8, self.player.py + 16) + end + end) end override = Pipelines.drawWorld(pipelineId, ctx) -- world post-processes (a miniature-diorama blur, a colour grade) fold diff --git a/tests/engine/world_pipeline_fx_mirror.lua b/tests/engine/world_pipeline_fx_mirror.lua new file mode 100644 index 00000000..0c0c5264 --- /dev/null +++ b/tests/engine/world_pipeline_fx_mirror.lua @@ -0,0 +1,115 @@ +-- The field FX a render pipeline composites, where endFrame mirrors the +-- override. The pipeline's pass goes in pre-flipped and comes back level; +-- ctx.drawFx puts these in as ordinary 2D, so without the matching mirror the +-- trainer "!" lands upside down and below its feet. +-- +-- The checks ask where a pixel is SEEN: they run the arithmetic `at` does and +-- then apply the composite by hand, so a mirror about the wrong axis, the +-- wrong height or in the wrong order fails here. +-- luajit tests/engine/world_pipeline_fx_mirror.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.modkit") +local Renderer = require("src.render.Renderer") +local Overworld = require("src.world.OverworldController") + +-- ------------------------------------------------- a transform we can read + +local g = love.graphics +local real = { push = g.push, pop = g.pop, translate = g.translate, + scale = g.scale, getCanvas = g.getCanvas } +local stack + +local function reset() stack = { { oy = 0, sy = 1 } } end +local function top() return stack[#stack] end + +g.push = function() local t = top(); stack[#stack + 1] = { oy = t.oy, sy = t.sy } end +g.pop = function() if #stack > 1 then stack[#stack] = nil end end +g.translate = function(_, dy) local t = top(); t.oy = t.oy + t.sy * dy end +g.scale = function(_, ky) local t = top(); t.sy = t.sy * (ky or 1) end + +local CANVAS_H = 400 +g.getCanvas = function() return { getHeight = function() return CANVAS_H end } end + +local osName, major = "iOS", 12 +love.system = love.system or {} +local realOS, realVersion = love.system.getOS, love.getVersion +love.system.getOS = function() return osName end +love.getVersion = function() return major, 0, 0, "" end + +reset() + +-- ------------------------------------------------- which hosts mirror + +T.check(Renderer.mirrorsWorldOverride(), "LOVE 12 on iOS mirrors the override") +major = 11 +T.check(not Renderer.mirrorsWorldOverride(), "LOVE 11 stores a canvas the old way") +major, osName = 12, "Android" +T.check(not Renderer.mirrorsWorldOverride(), "and no other host reaches that branch") +osName = "iOS" + +-- ------------------------------------------------- the geometry + +local CAM_X, CAM_Y = 100, 200 +local NPC_X, NPC_Y = 148, 264 -- the foot `at` anchors to +local ROW, SCALE = 120, 2 -- where the pipeline projects that foot +local function project() return 260, ROW end + +-- src/world/OverworldController.lua's `at`, for the row a drawn point reaches +local function at(dy) + local sx, sy = project() + local fy = NPC_Y - CAM_Y + g.push() + g.scale(SCALE, SCALE) + g.translate(sx / SCALE, sy / SCALE - fy) + local t = top() + local row = t.oy + t.sy * (fy + dy) + g.pop() + return row +end + +-- endFrame draws the override from the bottom edge with a negative Y scale +local function seen(row) return CANVAS_H - row end + +-- fxEmote draws the bubble 36 world pixels above the foot it anchors to +reset() +T.eq(seen(at(0)), CANVAS_H - ROW, "unmirrored, the anchor is seen at the mirrored row") +T.check(seen(at(-36)) > seen(at(0)), "and the bubble that belongs above is seen below") + +reset() +Overworld.withOverrideMirror(function() + T.eq(seen(at(0)), ROW, "mirrored, the anchor is seen where the pipeline put it") + T.eq(seen(at(-36)), ROW - 36 * SCALE, "and the bubble sits above it, upright") +end) +T.eq(#stack, 1, "the transform stack is left balanced") + +-- ------------------------------------------------- what it leaves alone + +-- no mirror in the composite either, so the drawn row IS the row seen +osName = "Android" +reset() +Overworld.withOverrideMirror(function() + T.eq(at(0), ROW, "a host that composites straight draws them untouched") +end) +osName = "iOS" + +-- a pipeline at a reduced render scale mirrors about ITS canvas, not the playfield +CANVAS_H = 240 +reset() +Overworld.withOverrideMirror(function() + T.eq(seen(at(0)), ROW, "a reduced render scale mirrors about its own canvas") +end) +CANVAS_H = 400 + +reset() +g.getCanvas = function() return nil end +Overworld.withOverrideMirror(function() + T.eq(seen(at(0)), CANVAS_H - ROW, "with nothing bound the effects still draw") +end) +T.eq(#stack, 1, "and no transform is pushed for them") + +for name, fn in pairs(real) do g[name] = fn end +love.system.getOS, love.getVersion = realOS, realVersion + +T.finish("world pipeline fx mirror")