add day/night filter to 2d
DramaticShape · 2026-07-30 ·
parent 26d1d96d52
add day/night filter to 2d
Changed files
CHANGELOG.md | 101 +++++++++++++++---
README.md | 2 +-
lib/BattlePics.lua | 207 ++++++++++++++++++------------------
lib/DayTint.lua | 169 ++++++++++++++++++++++++++++++
main.lua | 94 ++++++++++++-----
mod.card | 1 +
tests/dramatic_shape_test.lua | 351 ++++++++++++++++++++++++++++++++++++++++++++++----------------
7 files changed, 696 insertions(+), 229 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 331bf61..e340500 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,20 +46,44 @@
gap between its legs. Read across all 305 of the game's battle pics, that
rule finds an enclosed hole in exactly none of them.
- So a second rule: paper is what the figure is drawn OVER. A transparent
- pixel with ink somewhere to its left AND to its right AND above it is under
- the drawing and gets filled; the sky between a pair of ears has nothing over
- it and stays sky, the ground beside a foot has nothing on one side of it and
- stays ground. The silhouette is untouched either way, so the mon still cuts
- cleanly against the world. Three running scans, cached per pic.
-
- Asking for ink BELOW as well was the obvious first cut and it is wrong,
- which took a second pass to find: a battle pic is cropped flush at the feet,
- bottom-aligned in its slot with all the margin at the top, so half of any
- mon's belly has bare frame edge under it. Requiring it left a clean vertical
- channel of world showing straight down the middle of a Clefairy. Below
- cannot be accepted on its own either, or the whole sky over a mon's head
- fills in -- it has the mon under it. The asymmetry is the rule.
+ The fix is to start the flood somewhere else: at the edges of the ARTWORK'S
+ OWN BOUNDING 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 all the margin at
+ the top, so a mon's lowest row is the last row it was given and everything
+ below the belly simply stops. Treat that cut as open and the background
+ pours up inside the figure, which is the channel of world that used to show
+ through a Clefairy.
+
+ That is exact rather than a heuristic: nothing is filled because of what
+ surrounds it, only because the background provably cannot reach it. Which is
+ why it needs no idea whether it is holding a front pic or a back one -- the
+ sky between a pair of ears reaches the top edge and stays sky, the gap
+ between a body and a raised tail reaches the side and stays gap, the belly
+ reaches neither and is paper. The silhouette is untouched, so the mon still
+ cuts cleanly against the world.
+
+ It replaces the border flood outright rather than sitting beside it, since
+ anything the border could not reach the box edges cannot reach either.
+
+ The bottom edge needs one more distinction, because two different things
+ meet the underside of a figure. A DRAIN is where the drawing ran out -- a
+ belly whose white carries on down until the artist stopped, leaking out
+ 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 it is not a close call.
+ Measured along the bottom of every battle pic, the drains run 3 and 4 pixels
+ (Clefairy's back, Wartortle's back, Red's back) and the mouths run 10, 12, 14
+ and 17 (a Rattata's underbelly, Blue's stride, Brock's, a Pikachu's back).
+ Nothing lands between 4 and 10, so the cut is taken at 6 with room either
+ side rather than tuned to one sprite. Apart from that number the rule stays
+ exact.
+
+ Front pics come back untouched, and not by being special-cased: they are
+ near-solid silhouettes with almost nothing inside them to fill, so their own
+ shape is what says so.
Both mons were affected -- the cards in the arena as much as anything -- so
this lands wherever a battle pic is drawn over the world, not just under
@@ -80,8 +104,57 @@
the menu has no position in the scene to be shadowed at, so it carries the
hour and not the weather.
+### Added
+
+- **The hour reaches the FLAT world too, not just the diorama.** DAYTIME drove
+ the 3D pass through the voxel shader's own tint uniform -- a uniform the 2D
+ tile path never runs -- so with VOXEL off, the same evening that fell on the
+ diorama left the flat world at permanent noon. One clock, two worlds, one of
+ them ignoring it. Outdoor maps now get the same multiply, painted as one
+ rectangle over the composited world.
+
+ The whole difficulty is WHERE, and it is worth writing down. Not on the world
+ canvas: in a colorized mode that canvas is grayscale art and the blit that
+ puts it on screen runs it through the palette shader, which classifies each
+ pixel into a shade BY ITS RED CHANNEL -- multiply a night blue over it first
+ and every pixel lands in the wrong bucket, so the world does not darken, it
+ changes colour. Not over the finished frame either, or the dialog boxes and
+ menus darken along with the world they are held up in front of, which is the
+ same reason the tilt-shift blur is a `worldPresent` and not a `present`.
+
+ Which leaves the instant between the world blit and the UI blit, and the
+ engine has no seam there -- `worldPresent` only runs when a PIPELINE produced
+ the world, which in flat mode is precisely what did not happen. So
+ `Renderer:endFrame` is wrapped and the UI canvas's own draw is watched for:
+ `blit` passes the canvas it is compositing as the first argument, so the
+ first draw of `Renderer.canvas` IS the boundary, by identity rather than by
+ counting. The shader and scissor that call arrives under belong to the UI
+ blit already in progress, so both are put aside for the rectangle and handed
+ straight back.
+
+ Skipped entirely when a pipeline drew the frame (it tinted itself, and twice
+ is wrong), indoors (a room has no sky to take its light from), and at midday
+ (a multiply by white) -- so a game with the clock at DAY issues not one extra
+ call.
+
### Changed
+- **FULL no longer takes the two battle rows off the menu.** It still owns the
+ rows that describe the LOOK -- the wireframe, the horizon bend, the blur, the
+ hour -- because it is a preset for the diorama and a row that no longer
+ decides anything is worse than no row. 3D-BTL and BACK SPRITES are not that:
+ one decides what a fight is drawn OVER and the other how it is framed.
+ FULL still SETS both on arrival; it does not hold them, and leaving them
+ reachable is the difference between a preset and a lock.
+
+ This makes `stagedBattles()` honest as a side effect. It used to answer yes
+ under FULL as well, on the grounds that FULL owned the 3D-BTL row and
+ switched it on -- safe only while the row was hidden. With the row reachable
+ from inside FULL, that clause would have claimed staged battles for a preset
+ the player had just switched them off inside, pinning BATTLE LAYOUT to OG for
+ a fight that never gets staged. The row is the only thing that decides now,
+ which is what `OverworldBattle.begin` and `wantsFront` already believed.
+
- **TILT and GBC FX are off the OPTIONS menu entirely while this mod is
installed.** Both fight the diorama and both were already half-taken: the
mode's own key forces them off on every press, and the registry switches
diff --git a/README.md b/README.md
index b40d6cf..6886926 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ menu.
| `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
-| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors; held at SYNC (and off the menu) while VOXEL is FULL |
+| the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL |
**3D-BTL** is on by default and is independent of **VOXEL**: battles draw
on the world whether or not the free-roam camera is pitched over.
diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua
index e2983c7..f40d816 100644
--- a/lib/BattlePics.lua
+++ b/lib/BattlePics.lua
@@ -16,41 +16,49 @@
-- image says which was which. There is no distinction to recover; there is one
-- to draw.
--
--- Two rules, and a pixel filled by either is paper:
---
--- ENCLOSED the transparent region outside the figure is flood-filled from
--- the border, and anything transparent the flood cannot reach is
--- a hole the artwork closes on all sides.
---
--- UNDER THE anything transparent with ink somewhere to its left AND to its
--- DRAWING right AND above it.
---
--- The first is exact and catches almost nothing: Gen 1 pics are open figures,
--- and a belly reaches the border through the gap between two legs. Read across
--- all 305 of this game's battle pics, it fills an enclosed hole in none of them
--- -- so on its own it left every mon a stencil, which is the bug this file
--- exists to fix and did not.
---
--- The second is the one that does the work: paper is what the figure is drawn
--- OVER, and a pixel with the drawing to either side of it and over it is under
--- the drawing. The sky above a pair of ears has nothing over it and stays sky;
--- the ground beside a foot has nothing on one side of it and stays ground.
---
--- The asymmetry is the whole trick and it is not arbitrary. Look BELOW a pixel
--- and the question has no answer, because a battle pic is CROPPED FLUSH AT THE
--- FEET -- bottom-aligned in its slot, with the margin all at the top. Half of
--- any mon's belly has bare frame edge under it and nothing else, which is why
--- requiring ink below left a clean vertical channel of world showing through
--- the middle of a Clefairy. And below cannot be accepted on its own either, or
--- the entire sky over a mon's head fills in: it has the mon under it.
---
--- It is a heuristic and it is allowed to be one. What it fills that hardware
--- would not have distinguished is a deep notch -- the gap between two legs,
--- with a belly over it -- and the hardware drew white there too, so that is
--- the pixel the artist saw.
---
--- The silhouette is untouched either way, so the mon still cuts cleanly
--- against the world; only its insides stop being see-through.
+-- The rule is a flood fill from OUTSIDE the figure: whatever the background
+-- can reach is background, and whatever it cannot is paper. What makes that
+-- work is where the flood is allowed to start.
+--
+-- Start it at the image border and it fills everything and answers nothing.
+-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
+-- out between two legs and off the bottom of the frame. Run over all 352 of
+-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
+-- it left every mon a stencil, which is the bug this file exists to fix and
+-- for a long time did not.
+--
+-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
+-- the left, the right and the top are seeded whole. The sky between a pair of
+-- ears reaches the top edge and stays sky; the gap between a body and a raised
+-- tail reaches the side and stays gap.
+--
+-- The BOTTOM is the interesting one, because two completely different things
+-- meet the underside of a figure and they have to be told apart.
+--
+-- A DRAIN is where the drawing simply ran out -- a belly whose white carries
+-- on down until the artist stopped, leaking to the outside through the inch
+-- between a body and a leg. Seal it: what is above it is the mon.
+--
+-- A MOUTH is the space BETWEEN two legs, or under an arch. It is background
+-- that happens to be enclosed on three sides. Leave it open: the world
+-- should show through the gap in a trainer's stride.
+--
+-- What separates them is how WIDE the opening is, and on this game's art that
+-- is not a close call. Measured along the bottom of every battle pic: the
+-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
+-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
+-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
+-- taken at 6 with room either side rather than tuned to a single sprite.
+--
+-- Apart from that one number the rule is exact: no pixel is filled for what
+-- surrounds it, only because the background provably cannot get to it. And it
+-- needs no idea whether it is holding a front pic, a back one or a trainer --
+-- fronts are near-solid silhouettes with almost nothing inside them to fill,
+-- and they come back untouched because that is what their own shape says, not
+-- because they were special-cased.
+--
+-- The silhouette is untouched, so the mon still cuts cleanly against the
+-- world; only its insides stop being see-through.
--
-- Read back off the GPU rather than off the asset, deliberately. What comes
-- back is the pic the engine actually decided to draw -- species palette,
@@ -108,32 +116,78 @@ local function readBack(img)
return ok and data or nil
end
--- Mark every transparent pixel reachable from the border. That set is the
--- OUTSIDE; everything transparent it does not reach is an enclosed hole.
+-- The box the artwork actually occupies, or nil for a pic with no ink in it.
+--
+-- Not the image: a pic is centred in a 7x7-tile buffer and a small mon leaves
+-- whole rows and columns of nothing around itself. The bottom of THIS box is
+-- the cut the rule below turns on, and the bottom of the image is just empty
+-- frame some distance under it.
+local function inkBounds(data, w, h)
+ local x0, y0, x1, y1 = w, h, -1, -1
+ for y = 0, h - 1 do
+ for x = 0, w - 1 do
+ local _, _, _, a = data:getPixel(x, y)
+ if a > CUT then
+ if x < x0 then x0 = x end
+ if x > x1 then x1 = x end
+ if y < y0 then y0 = y end
+ if y > y1 then y1 = y end
+ end
+ end
+ end
+ if x1 < x0 then return nil end
+ return x0, y0, x1, y1
+end
+
+-- The widest opening along the bottom of a figure that still counts as a drain
+-- rather than a mouth. See the header for the measurements either side of it.
+BattlePics.DRAIN = 6
+
+-- Mark every transparent pixel the BACKGROUND can reach, flooding inward from
+-- the edges of the artwork's box: the left, the right and the top whole, and
+-- along the bottom only those openings wide enough to be background rather
+-- than the underside of a figure the drawing ran out of.
+--
+-- Confined to the box as well as seeded from it, so the empty frame under a
+-- short pic cannot walk around a sealed drain and come back up through it.
--
-- An explicit stack rather than recursion: a 56x56 pic is three thousand
-- pixels and a keyed-out background is most of them, which is a deeper call
-- chain than is worth risking for no gain.
-local function markOutside(data, w, h)
+local function markOutside(data, w, h, x0, y0, x1, y1)
local outside = {}
local stack, top = {}, 0
+ local function clear(x, y)
+ local _, _, _, a = data:getPixel(x, y)
+ return a <= CUT
+ end
local function push(x, y)
- if x < 0 or y < 0 or x >= w or y >= h then return end
+ if x < x0 or y < y0 or x > x1 or y > y1 then return end
local key = y * w + x
if outside[key] then return end
- local _, _, _, a = data:getPixel(x, y)
- if a > CUT then return end
+ if not clear(x, y) then return end
outside[key] = true
top = top + 1
stack[top] = key
end
- for x = 0, w - 1 do
- push(x, 0)
- push(x, h - 1)
+ for x = x0, x1 do push(x, y0) end
+ for y = y0, y1 do
+ push(x0, y)
+ push(x1, y)
end
- for y = 0, h - 1 do
- push(0, y)
- push(w - 1, y)
+ -- the bottom, run by run: a wide one is the gap between two legs and lets
+ -- the world through, a narrow one is where a belly ran out and is sealed
+ local x = x0
+ while x <= x1 do
+ if clear(x, y1) then
+ local from = x
+ while x <= x1 and clear(x, y1) do x = x + 1 end
+ if (x - from) > BattlePics.DRAIN then
+ for k = from, x - 1 do push(k, y1) end
+ end
+ else
+ x = x + 1
+ end
end
while top > 0 do
local key = stack[top]
@@ -147,52 +201,6 @@ local function markOutside(data, w, h)
return outside
end
--- Mark every transparent pixel with ink to its left, to its right and above.
---
--- Three running scans rather than three searches per pixel: sweeping each row
--- left to right carries "has there been ink yet" along with it, the same sweep
--- the other way, and one down the columns. Three passes over the image however
--- big the figure is.
-local function markUnderDrawing(data, w, h)
- local ink = {}
- for y = 0, h - 1 do
- local row = y * w
- for x = 0, w - 1 do
- local _, _, _, a = data:getPixel(x, y)
- if a > CUT then ink[row + x] = true end
- end
- end
-
- local left, right, above = {}, {}, {}
- for y = 0, h - 1 do
- local row, seen = y * w, false
- for x = 0, w - 1 do
- left[row + x] = seen
- seen = seen or ink[row + x] or false
- end
- seen = false
- for x = w - 1, 0, -1 do
- right[row + x] = seen
- seen = seen or ink[row + x] or false
- end
- end
- for x = 0, w - 1 do
- local seen = false
- for y = 0, h - 1 do
- above[y * w + x] = seen
- seen = seen or ink[y * w + x] or false
- end
- end
-
- local under = {}
- for key = 0, w * h - 1 do
- if not ink[key] and left[key] and right[key] and above[key] then
- under[key] = true
- end
- end
- return under
-end
-
-- The pic with its enclosed holes filled, or the pic itself when that could
-- not be done (no pixel access, a driver that refused the readback). Never
-- nil for a non-nil argument: a caller must always have something to draw.
@@ -206,14 +214,17 @@ function BattlePics.filled(img)
local data = readBack(img)
if not data then return end
local w, h = data:getDimensions()
- local outside = markOutside(data, w, h)
- local under = markUnderDrawing(data, w, h)
+ local x0, y0, x1, y1 = inkBounds(data, w, h)
+ if not x0 then return end -- a pic with nothing drawn in it
+ local outside = markOutside(data, w, h, x0, y0, x1, y1)
local fill = BattlePics.FILL
local changed = false
- for y = 0, h - 1 do
+ -- only inside the box: everything beyond it is frame the artist never
+ -- reached, and filling that would put the mon in a white rectangle
+ for y = y0, y1 do
local row = y * w
- for x = 0, w - 1 do
- if not outside[row + x] or under[row + x] then
+ for x = x0, x1 do
+ if not outside[row + x] then
local _, _, _, a = data:getPixel(x, y)
if a <= CUT then
data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
diff --git a/lib/DayTint.lua b/lib/DayTint.lua
new file mode 100644
index 0000000..ac976f5
--- /dev/null
+++ b/lib/DayTint.lua
@@ -0,0 +1,169 @@
+-- The hour's light on the FLAT world.
+--
+-- The clock already reaches everything the 3D pass draws: VoxelScene and
+-- BattleScene multiply the whole scene by DayNight.tint, so walking around a
+-- route at dusk warms the diorama and midnight turns it blue. Switch voxel
+-- mode off and none of that happens -- the tint is a uniform in a shader the
+-- flat tile path never runs -- so the same evening that fell on the diorama
+-- left the 2D world at permanent noon. One clock, two worlds, one of them
+-- ignoring it.
+--
+-- So the flat composite gets the same multiply, painted as one rectangle.
+--
+-- ------- WHERE, which is the only difficult part
+--
+-- Not on the world canvas. In a colorized mode that canvas is grayscale art
+-- and the blit that puts it on screen runs it through the palette shader,
+-- which classifies each pixel into a shade BY ITS RED CHANNEL. Multiply a
+-- night blue over it first and every shade lands in the wrong bucket -- the
+-- world would not darken, it would change colour into whatever the palette
+-- said the wrong bucket was.
+--
+-- So it goes on AFTER that pass, on the composited world. And not after the
+-- whole frame either: the UI blit is next, and the dialog boxes, the menus and
+-- the HUD are paper held up in front of the world rather than part of it --
+-- the same reason the tilt-shift blur is a worldPresent and not a present.
+--
+-- Which leaves one instant: between the world blit and the UI blit, inside
+-- Renderer:endFrame. There is no seam there -- worldPresent, the engine's own
+-- hook for exactly this, only runs when a PIPELINE produced the world, which
+-- in flat mode is the one thing that did not happen. So endFrame is wrapped
+-- and the UI canvas's own draw call is watched for: `blit` passes the canvas
+-- as the first argument, so the first draw of Renderer.canvas IS the boundary,
+-- by identity rather than by counting or guessing.
+--
+-- The shader and scissor that call arrives under belong to the UI blit already
+-- in progress, so both are put aside for the rectangle and handed straight
+-- back -- otherwise the tint would be palette-remapped and clipped to a zone.
+--
+-- ------- WHEN
+--
+-- Outdoors, on the flat path, when the hour is not neutral. Each of those is
+-- load-bearing:
+--
+-- the flat path a pipeline that rendered the world already applied the
+-- tint inside its own shader; painting it again would apply
+-- the hour twice. worldOverride is exactly "a pipeline drew
+-- this frame".
+-- outdoors 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.
+-- not neutral midday is a multiply by white. Skipped rather than drawn,
+-- so a game with the clock at DAY issues not one extra call.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local DayNight = V.require("DayNight")
+
+local DayTint = {}
+
+-- Below this the tint is close enough to white that the rectangle would not
+-- change a pixel, and the frame is left exactly as it was.
+DayTint.NEUTRAL = 0.999
+
+local function outdoorNow()
+ local ok, Game = pcall(require, "src.core.Game")
+ if not ok then return false end
+ local ow = Game and Game.overworld
+ local map = ow and ow.map
+ if not map then return false end
+ local okMap, Map = pcall(require, "src.world.Map")
+ if not okMap then return false end
+ local outdoor = map.def and Map.isOutdoor(map.def) or false
+ -- a canopy floor takes the hour's colour and nothing else of it, exactly as
+ -- it does in the 3D pass (BattleScene, VoxelScene)
+ return outdoor or DayNight.isCanopy(map)
+end
+
+-- The colour this frame's world should be multiplied by, or nil to leave the
+-- frame alone.
+function DayTint.forFrame(renderer)
+ if not renderer then return nil end
+ if renderer.worldOverride then return nil end -- a pipeline drew, and tinted
+ if not renderer.worldActive then return nil end -- no world on screen at all
+ if not outdoorNow() then return nil end
+ local tint = DayNight.tint(true)
+ if not tint then return nil end
+ local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1
+ if r > DayTint.NEUTRAL and g > DayTint.NEUTRAL and b > DayTint.NEUTRAL then
+ return nil
+ end
+ return r, g, b
+end
+
+-- One rectangle over the window, multiplied into whatever is under it.
+--
+-- The whole window rather than the world's own rect, which is what the
+-- engine's warp fade does from the same place and for the same reason: the
+-- border fill, the letterbox bars and the world are all "the world" as far as
+-- the hour is concerned, and black multiplied by anything is still black.
+-- Every read of the graphics state is optional, because a headless driver
+-- ships some of these and not others -- the same reason TerrainAtlas reads the
+-- engine's seams guarded. What cannot be read cannot be put back either, and a
+-- missing accessor must cost the tint rather than the frame.
+local function saved(name, ...)
+ local fn = love.graphics[name]
+ if not fn then return nil end
+ local ok, a, b, c, d = pcall(fn, ...)
+ if not ok then return nil end
+ return a, b, c, d
+end
+
+function DayTint.paint(r, g, b)
+ local gfx = love.graphics
+ local shader = saved("getShader")
+ local sx, sy, sw, sh = saved("getScissor")
+ local blend, alpha = saved("getBlendMode")
+ local pr, pg, pb, pa = saved("getColor")
+ local w, h = gfx.getDimensions()
+
+ if gfx.setShader then gfx.setShader() end
+ if gfx.setScissor then gfx.setScissor() end
+ gfx.setBlendMode("multiply", "premultiplied")
+ gfx.setColor(r, g, b, 1)
+ gfx.rectangle("fill", 0, 0, w, h)
+
+ gfx.setBlendMode(blend or "alpha", alpha)
+ gfx.setColor(pr or 1, pg or 1, pb or 1, pa or 1)
+ if gfx.setScissor then
+ if sx then gfx.setScissor(sx, sy, sw, sh) else gfx.setScissor() end
+ end
+ if shader and gfx.setShader then gfx.setShader(shader) end
+end
+
+function DayTint.install()
+ local Renderer = require("src.render.Renderer")
+ if Renderer.dramaticShapeTintHook then return end
+ local inner = Renderer.endFrame
+
+ function Renderer:endFrame(zones, worldZones)
+ local r, g, b = DayTint.forFrame(self)
+ if not r then return inner(self, zones, worldZones) end
+
+ local gfx = love.graphics
+ local draw = gfx.draw
+ local ui = self.canvas
+ local painted = false
+ gfx.draw = function(tex, ...)
+ -- the UI canvas reaching the screen: the world is finished, the paper
+ -- in front of it has not started. Restored FIRST so the rectangle's own
+ -- drawing cannot re-enter this, and so a UI blit that draws one quad per
+ -- SGB zone only triggers it once.
+ if not painted and tex == ui then
+ painted = true
+ gfx.draw = draw
+ DayTint.paint(r, g, b)
+ end
+ return draw(tex, ...)
+ end
+
+ local ok, err = pcall(inner, self, zones, worldZones)
+ gfx.draw = draw
+ if not ok then error(err, 0) end
+ end
+
+ Renderer.dramaticShapeTintHook = true
+end
+
+return DayTint
diff --git a/main.lua b/main.lua
index 26c78e6..0ba9fb0 100644
--- a/main.lua
+++ b/main.lua
@@ -80,6 +80,7 @@ local WorldCurve = V.require("WorldCurve")
local OverworldBattle = V.require("OverworldBattle")
local BattleExit = V.require("BattleExit")
local DayNight = V.require("DayNight")
+local DayTint = V.require("DayTint")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -285,13 +286,13 @@ applyFull = function(level)
opts.zoom = 0
Zoom.applyOptions(opts)
-- battles on the map too: FULL means the whole mode, and a fight is where
- -- half of it is spent. Set rather than forced -- the row is gone from the
- -- menu while FULL is on, but a save that already had it off gets it on.
+ -- half of it is spent. Set and then LET GO of -- unlike the rows above, both
+ -- battle rows stay on the menu under FULL (see the rows hook), so this is
+ -- where the preset puts them and not where they are held.
OverworldBattle.setting:setIndex(1, Game)
- -- with both mons out there on it: BACK SPRITES keeps the player's own on the menu,
- -- which is the one part of the old screen FULL is least about. Set rather
- -- than held, like every other line here -- a player who wants their back pic
- -- back can say so again on the row, or from the mod manager's page.
+ -- with both mons out there on it: BACK SPRITES keeps the player's own on the
+ -- menu, which is the one part of the old screen FULL is least about. Set the
+ -- same way, and changed back on the same row a keypress later.
OverworldBattle.backSetting:setIndex(1, Game)
-- and the battle screen the staged fight is composed for. WIDE re-lays that
-- screen out on a 304x144 surface, which moves every anchor the arena camera
@@ -307,24 +308,36 @@ applyFull = function(level)
end
-- Whether a fight can be staged on the map, as far as the OPTIONS menu is
--- concerned: 3D-BTL is on, or FULL is selected -- which owns that row and
--- switches it on. Deliberately NOT gated on Voxel3D.available(): the engine
--- offers a pipeline's row whether or not the hardware can run it
--- (Pipelines.rows), so this mode's rows say ON on a machine without a depth
--- buffer too, and a menu that claims 3D battles are on must not also offer the
--- layout they cannot be drawn in.
+-- concerned: the 3D-BTL row, and nothing else.
+--
+-- It used to answer yes under FULL as well, on the grounds that FULL owned
+-- that row and switched it on. FULL no longer owns it -- the row stays on the
+-- menu under FULL and can be switched off there (see the rows hook) -- so that
+-- clause would now claim staged battles for a preset the player had just
+-- turned them off inside, pinning BATTLE LAYOUT to OG for a fight that is
+-- never staged. The row is the only thing that decides, which is what every
+-- other reader of this setting already believed: OverworldBattle.begin and
+-- wantsFront both gate on enabled() alone.
+--
+-- Deliberately NOT gated on Voxel3D.available(): the engine offers a
+-- pipeline's row whether or not the hardware can run it (Pipelines.rows), so
+-- this mode's rows say ON on a machine without a depth buffer too, and a menu
+-- that claims 3D battles are on must not also offer the layout they cannot be
+-- drawn in.
local function stagedBattles()
- local Pipelines = require("src.render.Pipelines")
- return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel"))
+ return OverworldBattle.enabled()
end
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style." },
+ -- `full` marks a row FULL does not take away. FULL owns the diorama's own
+ -- knobs; what a battle is drawn over, and how it is framed, are not that.
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
- .. "shot over the shoulder with a slow parallax drift." },
+ .. "shot over the shoulder with a slow parallax drift.",
+ full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
-- off the engine draws the classic screen, which is this row's ON already,
-- and a row that no longer decides anything is worse than no row.
@@ -332,7 +345,7 @@ local SETTINGS = {
"Keep your own Pokemon on the battle menu, seen from behind in its "
.. "original slot, instead of standing it on the map facing the foe. "
.. "The foe is still out there on its own tile.",
- when = function() return stagedBattles() end },
+ when = function() return stagedBattles() end, full = true },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
@@ -480,10 +493,12 @@ local function insertGrouped(out, extra)
return out
end
--- FULL owns every one of those settings, so while it is selected they are
--- taken off the menu rather than left to be changed under it -- including
--- T-SHIFT, which is a pipeline row the engine put there. A row that no
--- longer decides anything is worse than no row.
+-- FULL owns the settings that describe the LOOK, so while it is selected those
+-- are taken off the menu rather than left to be changed under it -- including
+-- T-SHIFT, which is a pipeline row the engine put there. A row that no longer
+-- decides anything is worse than no row.
+--
+-- The battle rows are the exception and they stay; see the rows hook.
local function dropRow(out, id)
for i = #out, 1, -1 do
if type(out[i]) == "table" and out[i].id == id then table.remove(out, i) end
@@ -546,18 +561,31 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
- if Voxel.isFull(Pipelines.level("voxel")) then
- -- FULL keeps every mod row off the menu (the early return skips the
- -- insert below), and holds DAYTIME at SYNC while the row is unreachable
+ local full = Voxel.isFull(Pipelines.level("voxel"))
+ if full then
+ -- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
+ -- horizon bend, the blur, the hour -- so those come off the menu and
+ -- DAYTIME is held at SYNC while its row is unreachable.
DayNight.forceSync(game)
- return dropRow(out, "pipeline:tiltshift")
+ dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do
- -- a row whose own switch is off the table this frame (BACK SPRITES, which
- -- needs a staged fight to be about) is left off with it; the mod manager's
- -- page still carries every one of them
- if not entry.when or entry.when() then extra[#extra + 1] = entry[1]:row() end
+ -- Two things decide whether a row is offered.
+ --
+ -- FULL: a preset that owns the look, so the rows that describe the look go
+ -- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
+ -- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
+ -- the diorama FULL is a preset for. FULL still SETS them on arrival (see
+ -- applyFull); it does not hold them, so leaving them on the menu is the
+ -- difference between a preset and a lock.
+ --
+ -- And a row whose own switch is off the table this frame (BACK SPRITES,
+ -- which needs a staged fight to be about) is left off with it. The mod
+ -- manager's page carries every one of them either way.
+ local offered = (entry.full or not full)
+ and (not entry.when or entry.when())
+ if offered then extra[#extra + 1] = entry[1]:row() end
end
return insertGrouped(out, extra)
end)
@@ -773,6 +801,16 @@ mod.content.transitions:register(BattleExit.ID, {
BattleExit.install()
+-- ------- and the hour on the flat world
+--
+-- The clock reaches the diorama through the voxel shader's own tint uniform,
+-- which the 2D tile path never runs -- so with the mode off, the same evening
+-- that fell on the diorama left the flat world at permanent noon. One clock,
+-- two worlds, one of them ignoring it. DayTint paints the same multiply over
+-- the composited flat world, between the world blit and the UI blit; the
+-- reasoning for that exact instant is in the file.
+DayTint.install()
+
-- ------- what time it is
--
-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
diff --git a/mod.card b/mod.card
index 9f72264..d7f9644 100644
--- a/mod.card
+++ b/mod.card
@@ -23,6 +23,7 @@ return {
"V-GRID on hotkey 5 and V-CURVE on hotkey 7",
"3D-BTL on hotkey 8 (ON / OFF, on by default), battles fought on the world map",
"BACK SPRITES options row (OFF / ON, off by default), which keeps your own Pokemon on the battle menu in its classic slot while the foe stands out on the map",
+ "a day/night clock that reaches the flat 2D overworld as well as the diorama -- outdoor maps only, and only when the hour is not midday",
"an over-the-shoulder battle camera on a slow parallax orbit, with a depth-of-field pass that holds both mons sharp",
"a sky behind the diorama at the 75-degree rung, outdoor maps only, coloured by the active palette mode",
"a hand-authored tile shape profile (data/voxel_heights.lua) a mod can extend",
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index a637c31..0249247 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -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.
---
--- 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.
---
--- 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.
+-- 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.
+--
+-- 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.
+--
+-- 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 = {
- "..#..#..", -- two ears, with sky between them
- "..#..#..",
- "..####..", -- and the head closing under them
- ".#....#.", -- belly: sides beside it, head over it, bare frame under it
- ".#....#.",
- ".#....#.",
- ".#.##.#.", -- 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
-
-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
+-- 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
- 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 }
+ 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
-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 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: nothing under it but the edge the drawing stops at
+ ".#....#.",
+ ".#....#.",
+ ".#.##.#.", -- 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")
--- 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 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")
+ "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")
+
+-- ------- 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")
+
+-- 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")
--- and the answer is cached on the image, so a pic costs one readback a
--- session rather than one a frame
+-- 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