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
+86 -13
View File
@@ -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.
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.
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.
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
+1 -1
View File
@@ -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.
+102 -91
View File
@@ -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:
-- 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.
--
-- 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.
-- 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.
--
-- UNDER THE anything transparent with ink somewhere to its left AND to its
-- DRAWING right AND above it.
-- 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 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 BOTTOM is the interesting one, because two completely different things
-- meet the underside of a figure and they have to be told apart.
--
-- 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.
-- 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.
--
-- 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.
-- 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.
--
-- 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.
-- 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.
--
-- The silhouette is untouched either way, so the mon still cuts cleanly
-- against the world; only its insides stop being see-through.
-- 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])
+169
View File
@@ -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
+66 -28
View File
@@ -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
+1
View File
@@ -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",
+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