mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:20:53 +02:00
Merge pull request #25 from DramaticShape/back-sprite-battles
add day/night filter to 2d
This commit is contained in:
+86
-13
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user