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. + 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 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: +-- 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]) 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. +-- 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