Merge pull request #2438 from colsonrice/fix/world-pipeline-field-fx-mirror

This commit is contained in:
bryanthaboi
2026-09-23 22:45:45 -04:00
committed by GitHub
3 changed files with 177 additions and 23 deletions
+11 -2
View File
@@ -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)
+51 -21
View File
@@ -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
+115
View File
@@ -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")