diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e7f461..df7007a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,12 +4,12 @@ ### Added -- **BACK, a new row under 3D-BTL: your own Pokémon stays on the battle menu.** +- **BACK SPRITES, a new row under 3D-BTL: your own Pokémon stays on the battle menu.** The staged shot stands both mons on the map, which is the mode's whole claim -- and it costs the framing Gen 1 is most recognisable by: your own Pokémon, seen from behind, sitting on top of the battle menu with its feet on the box. - With BACK on the foe is still geometry standing on its own tile at the far + With BACK SPRITES on the foe is still geometry standing on its own tile at the far end of the arena, and the player's side goes back to being the GB's own flat back pic in the GB's own slot: same art, same 2x, same feet on row 96. It is the engine's own pics layer that draws it, through the `onlySide` argument @@ -30,6 +30,56 @@ and only on the OPTIONS menu while 3D-BTL is on, since with staged battles off the engine already draws exactly this. +### Fixed + +- **Battle pics were see-through, and it took a back sprite on a tiled floor + to make it obvious.** Gen 1 pics are two-bit art whose lightest shade is + white, and the decoded PNGs key that shade to alpha 0 -- which cost nothing + when the field behind them was white too. Over a route, every belly, every + eye white and every highlight is a hole with the world showing through, and + the mon reads as a stencil. + + `BattlePics` exists to put that paper back and, as written, put none of it + back. It flood-filled the outside from the border and filled what the flood + could not reach, which is exact and, on this game's art, empty: a Gen 1 + figure is an open drawing, and its belly walks out to the border through the + gap between its legs. Read across all 305 of the game's battle pics, that + rule finds an enclosed hole in exactly none of them. + + So a second rule: paper is what the figure is drawn OVER. A transparent + pixel with ink somewhere to its left AND to its right AND above it is under + the drawing and gets filled; the sky between a pair of ears has nothing over + it and stays sky, the ground beside a foot has nothing on one side of it and + stays ground. The silhouette is untouched either way, so the mon still cuts + cleanly against the world. Three running scans, cached per pic. + + Asking for ink BELOW as well was the obvious first cut and it is wrong, + which took a second pass to find: a battle pic is cropped flush at the feet, + bottom-aligned in its slot with all the margin at the top, so half of any + mon's belly has bare frame edge under it. Requiring it left a clean vertical + channel of world showing straight down the middle of a Clefairy. Below + cannot be accepted on its own either, or the whole sky over a mon's head + fills in -- it has the mon under it. The asymmetry is the rule. + + 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 + BACK SPRITES. + +- **The pinned back pic was lit at noon while the world behind it was not.** + Everything standing in the arena goes through the voxel shader, and that + shader multiplies by the hour's tint, so at dusk the diorama warms and at + night it goes blue -- the two mons' cards included, because they are drawn + in the same pass as the ground they stand on. A back pic pinned to the menu + is not in that pass; it is a flat blit over the finished shot, and it stayed + bright over a midnight route. + + The same tint is now applied to that one draw, by multiplying every colour + the pics layer sets on its way past -- so the alpha, the faint slide's fade + and the damage blink all compose with it instead of being overwritten. What + it does not get is the sun: the cards are shadow-mapped and a pic pinned to + the menu has no position in the scene to be shadowed at, so it carries the + hour and not the weather. + ### Changed - **The battle's text box and menus are frosted glass, like the HUDs.** The diff --git a/README.md b/README.md index 7fae02b..f9c39ad 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `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** 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 **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 | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua index 9cafa7b..e2983c7 100644 --- a/lib/BattlePics.lua +++ b/lib/BattlePics.lua @@ -9,12 +9,48 @@ -- of every eye, the highlight down a Pikachu's cheek: all of it turns into a -- hole with the world showing through, and the mon reads as a stencil. -- --- So the paper is put back, and only where the paper was: the pic is read --- back once, the transparent region OUTSIDE the figure is flood-filled from --- the border, and every transparent pixel the flood could not reach -- every --- hole enclosed by the artwork -- is filled opaque white. The silhouette is --- untouched, so the mon still cuts cleanly against the world; only its --- insides stop being see-through. +-- So the paper is put back, and only where the paper was. Which pixels those +-- are is the whole problem, and it has to be ANSWERED rather than looked up: +-- the hardware drew the mon's white belly and the white field behind it with +-- the same shade, the decoder keyed both to the same alpha, and nothing in the +-- image says which was which. There is no distinction to recover; there is one +-- to draw. +-- +-- Two rules, and a pixel filled by either is paper: +-- +-- ENCLOSED the transparent region outside the figure is flood-filled from +-- the border, and anything transparent the flood cannot reach is +-- a hole the artwork closes on all sides. +-- +-- UNDER THE anything transparent with ink somewhere to its left AND to its +-- DRAWING right AND above it. +-- +-- The first is exact and catches almost nothing: Gen 1 pics are open figures, +-- and a belly reaches the border through the gap between two legs. Read across +-- all 305 of this game's battle pics, it fills an enclosed hole in none of them +-- -- so on its own it left every mon a stencil, which is the bug this file +-- exists to fix and did not. +-- +-- The second is the one that does the work: paper is what the figure is drawn +-- OVER, and a pixel with the drawing to either side of it and over it is under +-- the drawing. The sky above a pair of ears has nothing over it and stays sky; +-- the ground beside a foot has nothing on one side of it and stays ground. +-- +-- The asymmetry is the whole trick and it is not arbitrary. Look BELOW a pixel +-- and the question has no answer, because a battle pic is CROPPED FLUSH AT THE +-- FEET -- bottom-aligned in its slot, with the margin all at the top. Half of +-- any mon's belly has bare frame edge under it and nothing else, which is why +-- requiring ink below left a clean vertical channel of world showing through +-- the middle of a Clefairy. And below cannot be accepted on its own either, or +-- the entire sky over a mon's head fills in: it has the mon under it. +-- +-- It is a heuristic and it is allowed to be one. What it fills that hardware +-- would not have distinguished is a deep notch -- the gap between two legs, +-- with a belly over it -- and the hardware drew white there too, so that is +-- the pixel the artist saw. +-- +-- The silhouette is untouched either way, so the mon still cuts cleanly +-- against the world; only its insides stop being see-through. -- -- 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, @@ -111,6 +147,52 @@ 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. @@ -125,12 +207,13 @@ function BattlePics.filled(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 fill = BattlePics.FILL local changed = false for y = 0, h - 1 do local row = y * w for x = 0, w - 1 do - if not outside[row + x] then + if not outside[row + x] or under[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/BattleScene.lua b/lib/BattleScene.lua index edb5f67..d8d0170 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -473,6 +473,11 @@ function BattleScene.render(state, arena, textures, token) -- the letterbox, so the depth-of-field pass can put its sharp band on -- the two marks rather than on a fraction of the window lx = lx, ly = ly, scale = s, pw = pw, ph = ph, + -- and the hour's light, for anything drawn over this shot that is NOT + -- geometry and so never went past the shader that applied it -- the back + -- pic pinned to the menu (see OverworldBattle.backPinned). Neutral + -- indoors, which is what DayNight.tint answers for a room. + tint = Voxel3D.tint, } end) -- the placed camera is ours for exactly this pass; anything else that diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index 75ab330..dddf97d 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -72,24 +72,25 @@ function OverworldBattle.enabled() return OverworldBattle.setting:get() and true or false end --- ------- BACK: the player's own mon stays on the menu +-- ------- BACK SPRITES: the player's own mon stays on the menu -- -- The staged shot stands BOTH mons on the map, which is the mode's whole -- claim -- but it costs the one piece of framing Gen 1 is most recognisable -- by: your own Pokemon, seen from behind, sitting on top of the battle menu -- with its feet on the box. That silhouette is the series' shot. -- --- So BACK is offered as a middle setting rather than a compromise imposed on --- everyone. With it on the foe is still geometry standing on its tile at the --- far end of the arena, and the player's side goes back to being the GB's own --- flat back pic in the GB's own slot: same art, same 2x, same feet on row 96. +-- So BACK SPRITES is offered as a middle setting rather than a compromise +-- imposed on everyone. With it on the foe is still geometry standing on its +-- tile at the far end of the arena, and the player's side goes back to being +-- the GB's own flat back pic in the GB's own slot: same art, same 2x, same +-- feet on row 96. -- Nothing else about the shot moves -- the arena, the camera and the drift are -- solved exactly as they were, so the foe stands where it always stood and the -- player's cell is simply empty ground in the foreground. -- -- OFF by default: what the mode advertises is the pair of them out there. OverworldBattle.BACK_KEY = "battleBack" -OverworldBattle.BACK_LABEL = "BACK" +OverworldBattle.BACK_LABEL = "BACK SPRITES" OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY, OverworldBattle.BACK_LABEL, @@ -112,7 +113,7 @@ end -- through the engine's own pokemon.sprite hook -- the seam that exists for -- exactly this, so no battle code has to be touched to get it. -- --- Unless BACK is on, which is the setting that asks for the back pic back: +-- Unless BACK SPRITES is on, the setting that asks for the back pic back: -- that mon is drawn in its own slot on the menu, seen from behind, and the -- front art would be it turned round to face the player it belongs to. -- @@ -594,6 +595,50 @@ local function withoutBoxFill(battle, fn) if not ok then error(err, 0) end end +-- ------- the hour's light, on a pic that is not geometry +-- +-- Everything standing in the arena goes through the voxel shader, and that +-- shader multiplies by the hour's tint: at dusk the whole diorama warms, at +-- night it goes blue, and the two mons' cards go with it because they are +-- drawn in the same pass as the ground they stand on. +-- +-- A back pic pinned to the menu is not in that pass. It is the engine's own +-- flat blit over the finished shot, so it arrived at noon while the world +-- behind it was at midnight -- a mon lit by nothing in the frame. +-- +-- So the tint is applied by hand, to that one draw. Every colour the pics +-- layer sets is multiplied on its way past, which is the whole of it: the +-- layer draws the pic with love.graphics.draw and LOVE multiplies by the draw +-- colour, so tinting the colour tints the pixels -- and the alpha, the faint +-- slide's fade and the blink's own colour all compose with it rather than +-- being overwritten. +-- +-- What this does NOT get is the sun: the cards are shadow-mapped, so one +-- standing under a tree is darker than the tint alone, and this pic has no +-- position in the scene to be shadowed at. It carries the hour and not the +-- weather, which is the part the eye reads. +local function withTint(tint, fn, ...) + if not tint then return fn(...) end + local r, g, b = tint[1] or 1, tint[2] or 1, tint[3] or 1 + if r > 0.999 and g > 0.999 and b > 0.999 then return fn(...) end + local gfx = love.graphics + local setColor = gfx.setColor + gfx.setColor = function(cr, cg, cb, ca, ...) + if type(cr) == "table" then + return setColor({ (cr[1] or 1) * r, (cr[2] or 1) * g, (cr[3] or 1) * b, + cr[4] }, cg, ...) + end + if cr == nil then return setColor(cr, cg, cb, ca, ...) end + return setColor(cr * r, (cg or 1) * g, (cb or 1) * b, ca, ...) + end + local ok, err = pcall(fn, ...) + gfx.setColor = setColor + -- the layer leaves whatever colour it last set, and that one is tinted; + -- hand the next caller plain white rather than a dimmed one + setColor(1, 1, 1, 1) + if not ok then error(err, 0) end +end + -- ------- the mons, as textures for the 3D pass -- -- The two Pokemon are not composited over the world any more: they are quads @@ -724,7 +769,7 @@ end -- Both sides, or nil when neither has anything to show. -- --- One side under BACK: the player's mon is not standing on the map at all +-- One side under BACK SPRITES: the player's mon is not standing on the map at all -- there, it is on the menu, so it has no card to be a texture for -- and -- nothing downstream has to know that. No billboard, and no shadow on the -- ground under a mon that is not on it. @@ -851,17 +896,28 @@ function OverworldBattle.install() -- nothing left to do here. Skipped rather than left to draw underneath, or -- every Pokemon would appear twice: once on its tile and once in its slot. -- - -- Except under BACK, where the player's side never became geometry and this + -- Except under BACK SPRITES, where the player's side never became geometry and this -- layer is the only thing that draws it. The engine's own onlySide argument -- does the whole job: one call, the player's branches alone, in the slot and -- at the scale the GB always put them -- feet on the box, 2x, back view. innerPics = BattleState.drawPicsLayer function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip) - if not self.dramaticShapeShot then + local shot = self.dramaticShapeShot + if not shot then return innerPics(self, slide, sx, sy, onlySide, skipMenuClip) end if OverworldBattle.backPinned() and onlySide ~= "enemy" then - return innerPics(self, slide, sx, sy, "player", skipMenuClip) + -- under the hour's own light, like everything else in the frame -- see + -- withTint, and the tint BattleScene hands over with the shot. + -- + -- Except on the wavy path, where the pic is baked into the GRAYSCALE bg + -- canvas for the zone pass to colour by region. That pass keys off the + -- red channel, and a night tint pulls red down -- it would not darken + -- the mon, it would remap it to the wrong shade. SE_WAVY_SCREEN lasts a + -- second and the hour survives it fine. + local tint = not self.grayPics and shot.tint or nil + return withTint(tint, innerPics, self, slide, sx, sy, "player", + skipMenuClip) end end @@ -894,7 +950,7 @@ function OverworldBattle.install() -- mons' projected positions, less the midpoint of the slots they used to -- sit in. A hit still lands on the mon it is aimed at. local a = OverworldBattle.ANCHOR - -- BACK leaves the player's mon exactly where the GB put it, so that side + -- BACK SPRITES leaves the player's mon exactly where the GB put it, so that side -- contributes no movement at all and the pair's centre has gone half as -- far as the foe's mark did. local px, py = shot.player[1], shot.player[2] diff --git a/main.lua b/main.lua index beba655..88cea2b 100644 --- a/main.lua +++ b/main.lua @@ -288,7 +288,7 @@ applyFull = function(level) -- 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. OverworldBattle.setting:setIndex(1, Game) - -- with both mons out there on it: BACK keeps the player's own on the menu, + -- 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. @@ -513,7 +513,7 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) end local extra = {} for _, entry in ipairs(SETTINGS) do - -- a row whose own switch is off the table this frame (BACK, which needs a + -- a row whose own switch is off the table this frame (BACK SPRITES, which -- 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 diff --git a/mod.card b/mod.card index 6f11298..79a2eb3 100644 --- a/mod.card +++ b/mod.card @@ -23,7 +23,7 @@ return { "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "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 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", + "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", "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 7a29cd5..5432cd0 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -320,7 +320,7 @@ T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label") T.eq(battles.value(), "ON", "overworld battles are on by default -- the mode's headline is the world " .. "in 3D, and a battle is where the player spends half the game") -T.eq(backRow.label, "BACK", "the back-pic row carries its label") +T.eq(backRow.label, "BACK SPRITES", "the back-pic row carries its label") T.eq(backRow.value(), "OFF", "and is off by default -- what the mode advertises is BOTH mons out on the " .. "map, so the classic slot is opt-in") @@ -1918,17 +1918,17 @@ end -- -- The staged shot stands both mons on the map, which costs the framing Gen 1 -- is most recognisable by: your own Pokemon seen from behind, sitting on the --- battle menu. BACK hands that back without giving up the fight on the map -- +-- battle menu. BACK SPRITES hands that back without giving up the fight on the map -- -- the foe is still geometry on its own tile. do T.eq(Battles.backSetting:get(), false, - "BACK is off by default: both mons out on the map is what the mode is") + "BACK SPRITES is off by default: both mons out on the map is what the mode is") T.eq(Battles.backPinned(), false, "so nothing is pinned to the menu") local backGame = { save = { options = { modOptions = {} } }, mods = { modOptions = {} } } Battles.setting:setIndex(1, backGame) -- 3D-BTL on -Battles.backSetting:setIndex(2, backGame) -- BACK on +Battles.backSetting:setIndex(2, backGame) -- BACK SPRITES on T.eq(Battles.backPinned(), true, "switched on, the back pic is pinned") T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battleBack, true, "and it persists on its own key, beside 3D-BTL rather than over it") @@ -1952,7 +1952,7 @@ local offIds = {} for _, row in ipairs(offRows) do offIds[row.id] = true end T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered") T.check(not offIds["DRAMATIC_SHAPE:battleBack"], - "but BACK is off the menu while there is no staged fight to be about") + "but BACK SPRITES is off the menu while there is no staged fight to be about") Battles.setting:setIndex(1, backGame) local onRows = Runtime.call("ui.options.rows", function(_, r) return r end, @@ -1966,6 +1966,110 @@ T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, Battles.backSetting:setIndex(1, backGame) -- and off for the rows below end +-- ------- and a pinned back pic is not a stencil +-- +-- Gen 1 battle pics are two-bit art whose lightest shade is WHITE, and the +-- decoded PNGs key that shade to alpha 0 -- free on a white field, a hole with +-- the world showing through over a route. BattlePics puts the paper back. +-- +-- It used to do that by flood-filling the outside and filling what the flood +-- could not reach, which is exact and, on this game's art, fills NOTHING: a +-- Gen 1 figure is an open drawing and its belly reaches the border through the +-- gap between two legs. Every mon was a stencil, most obviously the back pic +-- pinned to the menu, which is drawn flat over the tiles rather than shaded +-- like a card. So paper is also anything with ink to its left AND right AND +-- ABOVE it -- what the figure is drawn over. +-- +-- The asymmetry is the load-bearing part, so it is what this drives at. A pic +-- is cropped flush at the FEET, so half a mon's belly has bare frame edge +-- under it: ask for ink below as well and a clean channel of world shows +-- through the middle of the figure, which is the bug that got reported. +-- +-- Driven against a hand-drawn figure with exactly that shape -- ears with sky +-- between them, a belly, legs, and a bottom edge flush with the frame. +-- NOTHING in it is enclosed: every transparent pixel inside walks out between +-- the legs and off that bottom edge, so the flood-fill rule alone leaves this +-- figure exactly as it found it. +do +local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics") + +local FIGURE = { + "..#..#..", -- two ears, with sky between them + "..#..#..", + "..####..", -- and the head closing under them + ".#....#.", -- belly: sides beside it, head over it, bare frame under it + ".#....#.", + ".#....#.", + ".#.##.#.", -- legs, with the gap between them open to the bottom edge + ".#.##.#.", -- which the frame cuts flush, exactly as a battle pic is cut +} +local W, H = #FIGURE[1], #FIGURE + +local function fakeData(rows) + local px = {} + for y = 0, H - 1 do + for x = 0, W - 1 do + px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0 + 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 sky between two ears is NOT paper, and this is what the third ray is +-- for: nothing is drawn over it +T.check(not opaque(3, 0) and not opaque(4, 1), + "the sky between its ears stays sky -- there is no drawing over it") + +-- the outside is untouched, which is what keeps the silhouette cutting +-- cleanly against the world instead of standing in a white box +T.check(not opaque(0, 0) and not opaque(7, 0), + "the corners it never covered stay transparent") +T.check(not opaque(0, 4) and not opaque(7, 4), + "and so do the columns beside it -- ink on one side is not being inside") + +-- the 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 the answer is cached on the image, so a pic costs one readback a +-- session rather than one a frame +T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original") +BattlePics.invalidate() +end + -- ------- the way out of a battle is a fade, not a cut -- -- The engine wipes INTO a battle and cuts straight out of it. While voxel mode