add day/night filter to 2d

This commit is contained in:
DramaticShape
2026-07-30 22:36:59 -04:00
parent 26d1d96d52
commit 4da8e5dc3e
7 changed files with 685 additions and 218 deletions
+260 -85
View File
@@ -116,11 +116,16 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
local Runtime = require("src.mods.Runtime")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
-- ------- FULL is a preset that owns the other rows
-- ------- FULL is a preset that owns the rows describing the LOOK
--
-- While it is selected the settings it drives come OFF the menu -- including
-- T-SHIFT, which is a pipeline row the engine spliced in. A row that no
-- longer decides anything is worse than no row.
--
-- The two BATTLE rows are the exception and stay. 3D-BTL decides what a fight
-- is drawn over and BACK SPRITES how it is framed; neither is a knob on the
-- diorama the preset is a preset FOR. FULL sets them on arrival and then lets
-- go, which is what makes it a preset rather than a lock.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
@@ -133,9 +138,14 @@ T.check(not fullIds["pipeline:tiltshift"],
"FULL takes T-SHIFT off the menu -- it owns the blur")
T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL")
T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
-- but the battle rows survive it: they are not knobs on the look, and FULL
-- sets them once rather than holding them, so a player who wants the classic
-- back sprite (or no staged fights at all) can still say so from inside FULL
T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL")
T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
-- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
-- be reached while FULL owns it, so a value changed underneath (the mod
-- manager's page, an edited options file) snaps back when the menu asks
@@ -200,14 +210,26 @@ Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
T.eq(layoutGame.save.options.battleLayout, "wide",
"and WIDE is left alone once no battle can be staged on the map")
-- FULL owns the 3D-BTL row, so it pins the layout even with that row switched
-- off underneath it
-- and FULL does not override that. It used to: the preset owned the 3D-BTL row
-- and hid it, so "FULL is selected" was a safe stand-in for "battles are
-- staged". The row is on the menu under FULL now and can be switched off
-- there, so the stand-in would pin BATTLE LAYOUT to OG for a fight that is
-- never staged. The ROW decides, which is what every other reader of this
-- setting already believed.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "og",
"FULL pins the layout on its own, because it owns the row that would")
T.eq(layoutGame.save.options.battleLayout, "wide",
"with 3D-BTL off, FULL leaves the layout alone -- it no longer owns that row")
-- switch the row back on and the pin comes back with it, FULL or no FULL.
-- (Arriving at FULL for real runs applyFull, which switches the row on -- so
-- in the game the pin still follows the preset, by way of the row.)
Battles.setting:setIndex(1, layoutGame)
Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame,
{ { id = "battleLayout" } })
T.eq(layoutGame.save.options.battleLayout, "og",
"and the row switched back on pins it again, from inside FULL")
end
-- ------- TILT and GBC FX are off the menu entirely
@@ -249,11 +271,15 @@ T.eq(fxGame.save.options.tilt, 0,
T.eq(fxGame.save.options.gbcfx, 0, "and GBC FX with it")
T.eq(Tilt.level, 0, "the live level follows, so the frame is not still tilted")
-- and FULL, which returns early from the rows hook, must not be a way back in
-- and FULL, which takes its own branch through the rows hook, must not be a
-- way back in
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
local fullFx = Runtime.call("ui.options.rows", function(_, r) return r end,
fxGame, { { id = "tilt" }, { id = "gbcfx" } })
T.eq(#fullFx, 0, "under FULL they are gone too -- its early return is below them")
local fullFxIds = {}
for _, row in ipairs(fullFx) do fullFxIds[row.id] = true end
T.check(not fullFxIds["tilt"] and not fullFxIds["gbcfx"],
"under FULL they are gone too -- the drop is above every branch")
Pipelines.setLevel("voxel", 2)
end
@@ -2013,106 +2039,255 @@ T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
Battles.backSetting:setIndex(1, backGame) -- and off for the rows below
end
-- ------- the hour reaches the FLAT world too
--
-- The clock reaches the diorama through the voxel shader's tint uniform, which
-- the 2D tile path never runs. With the mode off the same evening left the flat
-- world at permanent noon.
--
-- The fix is one multiplied rectangle, and the whole difficulty is WHERE. Not
-- on the world canvas -- in a colorized mode that is grayscale art the palette
-- shader classifies by RED CHANNEL, so tinting first would move every pixel
-- into the wrong shade bucket rather than darkening it. Not over the finished
-- frame either, or the dialog boxes darken with the world they are held up in
-- front of. Between the two, which is the one instant with no engine seam in
-- it -- worldPresent only runs when a pipeline drew the world, which in flat
-- mode is exactly what did not happen.
--
-- So the boundary is found by identity: `blit` passes the canvas it is
-- compositing as the first argument, so the first draw of the renderer's UI
-- canvas IS the moment the world is finished and the paper has not started.
-- That is what this drives -- the gates, and the ordering.
do
local DayTint = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayTint")
local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
-- the map the hour is asked about is the one the player is standing on, read
-- off the live game rather than passed in -- so there has to be one
local Game = require("src.core.Game")
local owWas = Game.overworld
Game.overworld = { map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } } }
-- ------- the gates
--
-- A frame with a pipeline's world image in it was tinted inside that
-- pipeline's own shader; painting again would apply the hour twice.
DayNight.setting:sync("night")
T.check(DayTint.forFrame({ worldActive = true, worldOverride = {} }) == nil,
"a frame a render pipeline drew is left alone -- it tinted itself")
T.check(DayTint.forFrame({ worldActive = false }) == nil,
"and so is a frame with no world in it at all, like a menu over nothing")
T.check(DayTint.forFrame(nil) == nil, "and no renderer, no tint")
-- midday is a multiply by white, so it is skipped rather than drawn: a game
-- with the clock at DAY issues not one extra call
DayNight.setting:sync("day")
T.check(DayTint.forFrame({ worldActive = true }) == nil,
"at midday the tint is white, so nothing is painted")
-- and a room has no sky to take its light from, which is the same answer
-- DayNight.tint gives on its own and the same one applyRig gives the sun
DayNight.setting:sync("night")
T.check(DayTint.forFrame({ worldActive = true }) ~= nil,
"at night, outdoors, there is a tint to paint")
Game.overworld = { map = { id = "OAKS_LAB", def = { tileset = "HOUSE" } } }
T.check(DayTint.forFrame({ worldActive = true }) == nil,
"but indoors the hour does not reach the floor")
Game.overworld = { map = { id = "ROUTE_1", def = { tileset = "OVERWORLD" } } }
-- ------- and the ordering, driven through the real wrap
--
-- A stand-in renderer whose endFrame issues the two draws the real one does,
-- in the real order: the world canvas, then the UI canvas.
DayNight.setting:sync("night")
local Renderer = require("src.render.Renderer")
local realEnd, realHook = Renderer.endFrame, Renderer.dramaticShapeTintHook
local log = {}
local uiCanvas, worldPixels = { "the UI canvas" }, { "the world canvas" }
Renderer.dramaticShapeTintHook = nil
Renderer.endFrame = function(self)
log[#log + 1] = "world"
love.graphics.draw(worldPixels, 0, 0)
log[#log + 1] = "ui"
love.graphics.draw(self.canvas, 0, 0)
love.graphics.draw(self.canvas, 0, 0) -- a second SGB zone's quad
end
DayTint.install()
local realRect = love.graphics.rectangle
love.graphics.rectangle = function(...)
log[#log + 1] = "tint"
return realRect(...)
end
Renderer.endFrame({ canvas = uiCanvas, worldActive = true, map = true })
love.graphics.rectangle = realRect
T.eq(table.concat(log, ","), "world,ui,tint",
"the tint lands after the world is composited and before the UI blit draws")
local painted = 0
for _, step in ipairs(log) do if step == "tint" then painted = painted + 1 end end
T.eq(painted, 1,
"once, not once per SGB zone quad the UI blit issues")
-- a frame the gates decline must not leave the shim installed on love.graphics
local drawWas = love.graphics.draw
Renderer.endFrame({ canvas = uiCanvas, worldActive = true, worldOverride = {} })
T.eq(love.graphics.draw, drawWas,
"a declined frame does not leave a wrapper on love.graphics.draw")
Renderer.endFrame, Renderer.dramaticShapeTintHook = realEnd, realHook
Game.overworld = owWas
DayNight.setting:sync("sync")
end
-- ------- and a pinned back pic is not a stencil
--
-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the
-- decoded PNGs key that shade to alpha 0 -- free on a white field, a hole with
-- the world showing through over a route. BattlePics puts the paper back.
--
-- It used to do that by flood-filling the outside and filling what the flood
-- could not reach, which is exact and, on this game's art, fills NOTHING: a
-- Gen 1 figure is an open drawing and its belly reaches the border through the
-- gap between two legs. Every mon was a stencil, most obviously the back pic
-- pinned to the menu, which is drawn flat over the tiles rather than shaded
-- like a card. So paper is also anything with ink to its left AND right AND
-- ABOVE it -- what the figure is drawn over.
-- It does that by flooding the background INWARD and filling whatever the
-- background cannot reach. Started at the image border that finds nothing at
-- all -- a Gen 1 figure is an open drawing, and its belly walks out between
-- two legs and off the bottom of the frame -- so every mon was a stencil.
--
-- The asymmetry is the load-bearing part, so it is what this drives at. A pic
-- is cropped flush at the FEET, so half a mon's belly has bare frame edge
-- under it: ask for ink below as well and a clean channel of world shows
-- through the middle of the figure, which is the bug that got reported.
-- So the flood starts at the edges of the ARTWORK'S OWN BOX, and at three of
-- them: left, right and top. The bottom is closed, because it is not a side
-- the background is behind -- it is where the drawing was CUT. A pic is
-- bottom-aligned in its slot with the margin all at the top, so a mon's lowest
-- row is the last row it was given. Seed that cut and the background pours up
-- inside the figure, which was the channel of world showing through the middle
-- of a Clefairy.
--
-- Driven against a hand-drawn figure with exactly that shape -- ears with sky
-- between them, a belly, legs, and a bottom edge flush with the frame.
-- NOTHING in it is enclosed: every transparent pixel inside walks out between
-- the legs and off that bottom edge, so the flood-fill rule alone leaves this
-- figure exactly as it found it.
-- Both halves are driven here, because getting one right at the other's
-- expense is exactly what went wrong twice: an earlier rule that filled
-- anything with ink to its left, right and above closed the channel and then
-- filled the notch between a Rattata's ears and the gap between its body and
-- its tail, which are background and have the drawing over them.
do
local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics")
local FIGURE = {
-- Run one hand-drawn figure through the real BattlePics and hand back a
-- reader over what came out. The pic is faked at the readback seam, which is
-- the only thing between this and the pixels the engine would have blitted.
local function fill(rows)
local W, H = #rows[1], #rows
local built = nil
local function fakeData()
local px = {}
for y = 0, H - 1 do
for x = 0, W - 1 do
px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0
end
end
return {
px = px,
getDimensions = function() return W, H end,
getPixel = function(self, x, y) return 0, 0, 0, self.px[y * W + x] end,
setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end,
}
end
local realNewCanvas, realNewImage = love.graphics.newCanvas, love.graphics.newImage
love.graphics.newCanvas = function()
return { setFilter = function() end, release = function() end,
newImageData = fakeData }
end
love.graphics.newImage = function(data)
built = data
return { setFilter = function() end }
end
local pic = { getDimensions = function() return W, H end }
local out = BattlePics.filled(pic)
love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage
-- deliberately NOT invalidated: each figure brings its own pic, and the
-- cache check at the bottom needs one of them still in there
return out, pic, built and function(x, y) return built.px[y * W + x] > 0.5 end
end
-- ------- the cut at the feet, which is what the closed bottom edge is for
local out, pic, opaque = fill({
"..#..#..", -- two ears, with sky between them
"..#..#..",
"..####..", -- and the head closing under them
".#....#.", -- belly: sides beside it, head over it, bare frame under it
".#....#.", -- belly: nothing under it but the edge the drawing stops at
".#....#.",
".#....#.",
".#.##.#.", -- legs, with the gap between them open to the bottom edge
".#.##.#.", -- which the frame cuts flush, exactly as a battle pic is cut
}
local W, H = #FIGURE[1], #FIGURE
".#.##.#.", -- legs, with the gap between them running down to that edge
".#.##.#.",
})
T.check(out ~= pic and opaque, "the pic comes back rebuilt: there was paper to put back")
local function fakeData(rows)
local px = {}
for y = 0, H - 1 do
for x = 0, W - 1 do
px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0
end
end
return {
px = px,
getDimensions = function() return W, H end,
getPixel = function(self, x, y)
local a = self.px[y * W + x]
return 0, 0, 0, a
end,
setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end,
}
end
local built = nil
local realNewCanvas, realNewImage = love.graphics.newCanvas, love.graphics.newImage
love.graphics.newCanvas = function()
return { setFilter = function() end, release = function() end,
newImageData = function() return fakeData(FIGURE) end }
end
love.graphics.newImage = function(data)
built = data
return { setFilter = function() end, dramaticShapeFilled = true }
end
local pic = { getDimensions = function() return W, H end }
local out = BattlePics.filled(pic)
love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage
T.check(out ~= pic and built ~= nil,
"the pic comes back rebuilt: there was paper to put back")
local function opaque(x, y) return built.px[y * W + x] > 0.5 end
-- the belly, filled edge to edge. Every one of these has bare frame under it
-- rather than ink, which is the case that used to leave a channel of world
-- showing straight down the middle of the mon
T.check(opaque(2, 3) and opaque(3, 4) and opaque(5, 5),
"the figure's white insides are filled back in, so it is not see-through")
"the belly is filled edge to edge -- no channel of world down the middle")
T.check(opaque(2, 7),
"and so is the notch between its legs, which the same cut runs through")
-- the sky between two ears is NOT paper, and this is what the third ray is
-- for: nothing is drawn over it
-- the sky between two ears reaches the top of the box, so it is background
T.check(not opaque(3, 0) and not opaque(4, 1),
"the sky between its ears stays sky -- there is no drawing over it")
"the sky between its ears stays sky")
-- and everything outside the artwork's own box is never touched, which is what
-- keeps the silhouette cutting cleanly instead of standing in a white rectangle
T.check(not opaque(0, 0) and not opaque(7, 0), "the corners stay transparent")
T.check(not opaque(0, 4) and not opaque(7, 4), "and the columns beside it")
-- the outside is untouched, which is what keeps the silhouette cutting
-- cleanly against the world instead of standing in a white box
T.check(not opaque(0, 0) and not opaque(7, 0),
"the corners it never covered stay transparent")
T.check(not opaque(0, 4) and not opaque(7, 4),
"and so do the columns beside it -- ink on one side is not being inside")
-- ------- and a pocket that drains out to the SIDE is background, however
-- much of the drawing is over it
--
-- This is the regression the ray rule caused: ink to the left, ink to the
-- right, ink above, and still plainly the gap between a body and a tail.
-- Every transparent pixel in this one drains out through the notch at (2,3)
-- and away to the left, so NONE of it is paper -- and a pic with no paper to
-- put back is handed straight back, unrebuilt. That identity IS the assertion:
-- under the ray rule this figure came back rebuilt with the pocket filled in.
local gapOut, gapPic = fill({
"..#####.", -- a brow, with the drawing over the pocket
"..#...#.",
"..#...#.",
"....###.", -- which opens at the left, and drains out that way
"..#####.",
"..#####.",
})
T.eq(gapOut, gapPic,
"a pocket the background can walk into from the side is not paper")
-- the notch between the legs, on the other hand, has a belly over it and legs
-- either side, so it is paper. The hardware drew white there too
T.check(opaque(2, 7), "the notch between its legs is under the drawing")
-- ------- and neither is a wide MOUTH along the bottom
--
-- Two things meet the underside of a figure. A DRAIN is where the drawing ran
-- out -- a belly leaking through the inch between a body and a leg -- and is
-- sealed. A MOUTH is the space between two legs, background that happens to be
-- enclosed on three sides, and is left open so the world shows through a
-- trainer's stride. Width tells them apart, and on this game's art the drains
-- run 3-4 pixels and the mouths 10-17, so BattlePics.DRAIN sits at 6.
--
-- This figure's stride is eight wide, so nothing in it is paper and it comes
-- back unrebuilt -- the identity again.
local strideOut, stridePic = fill({
"..##############..",
"..##############..",
"..##############..",
"..###........###..", -- a stride eight wide, past the drain cut
"..###........###..",
"..###........###..",
})
T.eq(strideOut, stridePic,
"the world shows through the gap between a trainer's legs")
-- and the answer is cached on the image, so a pic costs one readback a
-- session rather than one a frame
-- the same figure with a two-pixel gap IS a drain, and fills
local drainOut, drainPic, drain = fill({
"..##############..",
"..##############..",
"..##############..",
"..######..######..", -- a belly running out, not a stride
"..######..######..",
"..######..######..",
})
T.check(drainOut ~= drainPic and drain and drain(8, 4),
"a narrow one is where the drawing ran out, and is paper")
-- the answer is cached on the image, so a pic costs one readback a session
-- rather than one a frame -- checked on the first figure, which is still in
-- there because fill() does not clear it
T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original")
BattlePics.invalidate()
end