From 833bee64b260584cf42537f2c024448baea13441 Mon Sep 17 00:00:00 2001 From: Colson Rice Date: Wed, 23 Sep 2026 11:44:45 -0400 Subject: [PATCH] Keep the field effects upright when a render pipeline owns the world endFrame composites a world override mirrored on LOVE 12, because a pipeline's own projection pre-flips Y. ctx.drawFx puts the standing effects into that same canvas as ordinary 2D, so the mirror inverts them instead of righting them: a trainer's "!" draws upside down, below its feet, on the mirrored half of the screen. The condition is now Renderer.mirrorsWorldOverride, named because it is not only the blit's business, and ctx.drawFx runs its block under the matching mirror about the bound canvas. project() is left alone: it already answers in screen rows. --- src/render/Renderer.lua | 13 ++- src/world/OverworldController.lua | 72 ++++++++++---- tests/engine/world_pipeline_fx_mirror.lua | 115 ++++++++++++++++++++++ 3 files changed, 177 insertions(+), 23 deletions(-) create mode 100644 tests/engine/world_pipeline_fx_mirror.lua 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")