mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 15:50:52 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4da8e5dc3e | |||
| 26d1d96d52 | |||
| d9a000d7ad | |||
| f12b564dcd | |||
| d1a1c69c7d | |||
| 500556c3fc | |||
| e621c28a74 | |||
| eabc8af716 | |||
| 785838e6cd | |||
| cb325af6cf | |||
| e898cedad6 |
+223
@@ -1,5 +1,228 @@
|
||||
# Changelog
|
||||
|
||||
## 1.3.0
|
||||
|
||||
### Added
|
||||
|
||||
- **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 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
|
||||
that layer already takes, so every pic effect -- the grow-out-of-the-ball,
|
||||
the faint slide, the damage blink, the send-out trainer pic -- comes along
|
||||
unchanged and none of it is reimplemented.
|
||||
|
||||
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. Two things follow the
|
||||
setting: the `pokemon.sprite` hook stops asking for the front pic on the
|
||||
player's side (it is a back view again, and the front art would be that mon
|
||||
turned round to face the player it belongs to), and the move-animation offset
|
||||
drops that side's contribution, because a pic that has not moved cannot have
|
||||
moved the pair's centre.
|
||||
|
||||
OFF by default -- what the mode advertises is the two of them out there --
|
||||
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.
|
||||
|
||||
The fix is to start the flood somewhere else: at the edges of the ARTWORK'S
|
||||
OWN BOUNDING BOX, and at three of them -- left, right and top. The bottom is
|
||||
closed, because it is not a side the background is behind, it is where the
|
||||
drawing was CUT. A pic is bottom-aligned in its slot with all the margin at
|
||||
the top, so a mon's lowest row is the last row it was given and everything
|
||||
below the belly simply stops. Treat that cut as open and the background
|
||||
pours up inside the figure, which is the channel of world that used to show
|
||||
through a Clefairy.
|
||||
|
||||
That is exact rather than a heuristic: nothing is filled because of what
|
||||
surrounds it, only because the background provably cannot reach it. Which is
|
||||
why it needs no idea whether it is holding a front pic or a back one -- the
|
||||
sky between a pair of ears reaches the top edge and stays sky, the gap
|
||||
between a body and a raised tail reaches the side and stays gap, the belly
|
||||
reaches neither and is paper. The silhouette is untouched, so the mon still
|
||||
cuts cleanly against the world.
|
||||
|
||||
It replaces the border flood outright rather than sitting beside it, since
|
||||
anything the border could not reach the box edges cannot reach either.
|
||||
|
||||
The bottom edge needs one more distinction, because two different things
|
||||
meet the underside of a figure. A DRAIN is where the drawing ran out -- a
|
||||
belly whose white carries on down until the artist stopped, leaking out
|
||||
through the inch between a body and a leg -- and is sealed. A MOUTH is the
|
||||
space between two legs, background that happens to be enclosed on three
|
||||
sides, and is left open so the world shows through a trainer's stride.
|
||||
|
||||
Width tells them apart, and on this game's art it is not a close call.
|
||||
Measured along the bottom of every battle pic, the drains run 3 and 4 pixels
|
||||
(Clefairy's back, Wartortle's back, Red's back) and the mouths run 10, 12, 14
|
||||
and 17 (a Rattata's underbelly, Blue's stride, Brock's, a Pikachu's back).
|
||||
Nothing lands between 4 and 10, so the cut is taken at 6 with room either
|
||||
side rather than tuned to one sprite. Apart from that number the rule stays
|
||||
exact.
|
||||
|
||||
Front pics come back untouched, and not by being special-cased: they are
|
||||
near-solid silhouettes with almost nothing inside them to fill, so their own
|
||||
shape is what says so.
|
||||
|
||||
Both mons were affected -- the cards in the arena as much as anything -- so
|
||||
this lands wherever a battle pic is drawn over the world, not just under
|
||||
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.
|
||||
|
||||
### 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
|
||||
TILT off whenever a world pipeline takes the pass. What was left was two
|
||||
rows a player could set and watch get reverted -- TILT being the flat fake
|
||||
of what this mode does for real, and GBC FX a full-screen present pass over
|
||||
the top of the whole thing.
|
||||
|
||||
Dropped AND held at zero, which is the part that matters: hiding a live
|
||||
setting is a trap, because a save written before the mod was installed can
|
||||
carry TILT 3 and a row that is not there cannot turn it back off. Pinned
|
||||
wherever the value could arrive from -- the menu opening, a save being
|
||||
loaded or begun -- so there is no route by which either is on and
|
||||
unreachable. Uninstalling the mod puts both rows back, at whatever they were
|
||||
last set to.
|
||||
|
||||
- **The battle's text box and menus are frosted glass, like the HUDs.** The
|
||||
HUD blocks got panels because black glyphs on grass are not readable. The box
|
||||
at the bottom had the opposite problem and the same cause: it is drawn as an
|
||||
opaque white slab with a black border, which was the field's own colour back
|
||||
when the field was white and is a sheet of paper laid over the bottom third
|
||||
of the diorama now that it is not.
|
||||
|
||||
It gets exactly what the HUDs get -- the world behind it, blurred to frosted
|
||||
glass and laid back down translucent, at the same frost and the same tint --
|
||||
and it is measured into the same brightness verdict, so the ink over the menu
|
||||
flips white with the ink over the HUDs rather than against it. Only the FILL
|
||||
is taken away: the border, the text, the cursor and the down arrow are the
|
||||
engine's own glyphs in their own places. The move menu's TYPE/PP box and
|
||||
Mimic's copy menu get their own panels, trimmed to the rows above the box
|
||||
below them so no pixel is frosted twice.
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Fixed
|
||||
|
||||
- **On Android the sky went black below its first couple of bands.** A hard-edged
|
||||
band of black ran from partway down the gradient to the horizon point, with the
|
||||
moon still hanging correctly inside it. Desktop was unaffected.
|
||||
|
||||
What gave it away is that the same colour reached the screen by two routes and
|
||||
only one of them was wrong. The haze filling the void UNDER the horizon is the
|
||||
sky's palest band, and it is delivered by `love.graphics.clear` -- it landed
|
||||
correctly. The bottom of the sky above it is that same band delivered by the
|
||||
shader, and it was black. So the palette was not reaching the fragment shader,
|
||||
and nothing was wrong with the palette, the layout or the camera.
|
||||
|
||||
The bands went in as `uniform vec3 bands[8]`, filled from Lua and read through
|
||||
a loop counter, and on Android's GLSL ES the tail of that array arrived as
|
||||
zero -- which is black. The likeliest reason is the fragment uniform budget:
|
||||
ES 2.0 only guarantees sixteen uniform VECTORS, and eight band slots plus the
|
||||
twilight glow plus LOVE's own built-ins is over it. A driver that truncates a
|
||||
partly-filled array, or one that reflects `bands[0]` and nothing after it,
|
||||
fails identically -- so the fix removes the whole class rather than the one
|
||||
cause.
|
||||
|
||||
The bands are a one-texel-per-band TEXTURE now, sampled nearest, with the
|
||||
band index clamped against the ramp's width. One texture unit replaces eight
|
||||
uniform vectors, there is no array to index and no budget to overrun, and a
|
||||
sample past the last band lands on the last band instead of on nothing. It is
|
||||
still a palette and not a picture -- one texel per band on a single row -- so
|
||||
the sky is still computed per pixel at the size it is displayed at, with
|
||||
nothing resampled and nothing baked.
|
||||
|
||||
Also gone with it: `clamp(x, 0.0, 0.999999)`, which rounds its bound to 1.0 at
|
||||
mediump -- the fragment default on GLSL ES -- and would have indexed one past
|
||||
the last band on the sky's bottom row for the same black result.
|
||||
|
||||
## 1.1.1
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -34,7 +34,21 @@ 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 **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 **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, 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.
|
||||
on the world whether or not the free-roam camera is pitched over.
|
||||
|
||||
Two of the engine's own rows are taken away while this mod is installed:
|
||||
**TILT**, which is the flat fake of what this mode does for real, and **GBC
|
||||
FX**, a full-screen present pass over the top of the diorama. Both are held at
|
||||
off rather than merely hidden — a row that is not there cannot switch off a
|
||||
value an older save arrived with. Uninstall and both come back, at whatever
|
||||
they were last set to.
|
||||
|
||||
Everything the battle screen draws as a box — the two HUD blocks, the text
|
||||
box and the menus over it — sits on frosted glass rather than on the white
|
||||
field it used to have behind it: the world underneath, blurred and laid back
|
||||
down translucent, with the ink flipping white where the ground it lands on is
|
||||
dark. Nothing the engine draws inside a box moves; only the paper is gone.
|
||||
@@ -41,8 +41,15 @@ return {
|
||||
-- ------- routes
|
||||
-- narrow, deliberately: the route's interior is a 3-cell-wide lane and the
|
||||
-- wide shape only fits in the western connection border, which staged every
|
||||
-- fight at the edge of the world instead of on the road
|
||||
["ROUTE_1"] = { x = 9, y = 16, shape = "narrow" },
|
||||
-- fight at the edge of the world instead of on the road.
|
||||
--
|
||||
-- Of the seventeen spots the route has outside that border, fourteen are
|
||||
-- this one mid-route clearing and the other three bury the near mon behind
|
||||
-- a hedge -- which the clearance test passes, since it measures terrain
|
||||
-- height along the sightline and a hedge in the apron row is not terrain.
|
||||
-- So the choice is where in the clearing, and this is its west end: tree
|
||||
-- line square behind the pair, nothing crossing either of them.
|
||||
["ROUTE_1"] = { x = 4, y = 14, shape = "narrow" },
|
||||
["ROUTE_2"] = { x = 1, y = 49, shape = "wide" },
|
||||
["ROUTE_3"] = { x = 57, y = 1, shape = "wide" },
|
||||
["ROUTE_4"] = { x = 46, y = 7, shape = "wide" },
|
||||
|
||||
+115
-21
@@ -9,12 +9,56 @@
|
||||
-- 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.
|
||||
--
|
||||
-- The rule is a flood fill from OUTSIDE the figure: whatever the background
|
||||
-- can reach is background, and whatever it cannot is paper. What makes that
|
||||
-- work is where the flood is allowed to start.
|
||||
--
|
||||
-- Start it at the image border and it fills everything and answers nothing.
|
||||
-- Gen 1 figures are open drawings and a belly is not a sealed room: it walks
|
||||
-- out between two legs and off the bottom of the frame. Run over all 352 of
|
||||
-- this game's battle pics, that finds an enclosed hole in NONE of them -- so
|
||||
-- it left every mon a stencil, which is the bug this file exists to fix and
|
||||
-- for a long time did not.
|
||||
--
|
||||
-- So the flood is started at the edges of the artwork's own BOUNDING BOX, and
|
||||
-- the left, the right and the top are seeded whole. The sky between a pair of
|
||||
-- ears reaches the top edge and stays sky; the gap between a body and a raised
|
||||
-- tail reaches the side and stays gap.
|
||||
--
|
||||
-- The BOTTOM is the interesting one, because two completely different things
|
||||
-- meet the underside of a figure and they have to be told apart.
|
||||
--
|
||||
-- A DRAIN is where the drawing simply ran out -- a belly whose white carries
|
||||
-- on down until the artist stopped, leaking to the outside through the inch
|
||||
-- between a body and a leg. Seal it: what is above it is the mon.
|
||||
--
|
||||
-- A MOUTH is the space BETWEEN two legs, or under an arch. It is background
|
||||
-- that happens to be enclosed on three sides. Leave it open: the world
|
||||
-- should show through the gap in a trainer's stride.
|
||||
--
|
||||
-- What separates them is how WIDE the opening is, and on this game's art that
|
||||
-- is not a close call. Measured along the bottom of every battle pic: the
|
||||
-- drains run 3 and 4 pixels (Clefairy's back, Wartortle's back, Red's back)
|
||||
-- and the mouths run 10, 12, 14 and 17 (a Rattata's underbelly, Blue's stride,
|
||||
-- Brock's, a Pikachu's back). Nothing lands between 4 and 10, so the cut is
|
||||
-- taken at 6 with room either side rather than tuned to a single sprite.
|
||||
--
|
||||
-- Apart from that one number the rule is exact: no pixel is filled for what
|
||||
-- surrounds it, only because the background provably cannot get to it. And it
|
||||
-- needs no idea whether it is holding a front pic, a back one or a trainer --
|
||||
-- fronts are near-solid silhouettes with almost nothing inside them to fill,
|
||||
-- and they come back untouched because that is what their own shape says, not
|
||||
-- because they were special-cased.
|
||||
--
|
||||
-- The silhouette is untouched, so the mon still cuts cleanly against the
|
||||
-- world; only its insides stop being see-through.
|
||||
--
|
||||
-- Read back off the GPU rather than off the asset, deliberately. What comes
|
||||
-- back is the pic the engine actually decided to draw -- species palette,
|
||||
@@ -72,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]
|
||||
@@ -124,12 +214,16 @@ 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 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
|
||||
for x = x0, x1 do
|
||||
if not outside[row + x] then
|
||||
local _, _, _, a = data:getPixel(x, y)
|
||||
if a <= CUT then
|
||||
|
||||
@@ -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
|
||||
|
||||
+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
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Voxel world mode: decoded pixels, kept.
|
||||
--
|
||||
-- Assets.imageData is deliberately uncached upstream -- "pixel-level reads
|
||||
-- resolve the same way but stay uncached: the caller keeps the derived
|
||||
-- product" (src/render/Assets.lua) -- which is the right contract for the
|
||||
-- flat renderer, whose one caller decodes a strip once and keeps the strip.
|
||||
--
|
||||
-- This mod is not that caller. It reads the same handful of images over and
|
||||
-- over, from several places that do not know about each other:
|
||||
--
|
||||
-- * the tileset atlas, decoded by Structures (its own cache), by
|
||||
-- TerrainAtlas twice (the SGB bake and the RED++ rebake), by
|
||||
-- TerrainAtlas again to learn a tile's shades, and by GlassMask;
|
||||
-- * the FLOWER FRAME files, decoded inside patch() -- which runs every
|
||||
-- time the animation step turns over, about three times a second, for
|
||||
-- as long as the map is on screen. That one is not a load cost at all,
|
||||
-- it is a recurring per-second cost on the render thread, and it was
|
||||
-- the single clearest waste the first profile turned up.
|
||||
--
|
||||
-- So: one table, keyed by the path as the CALLER gave it, holding the
|
||||
-- decoded ImageData. Registered with Assets.invalidate so a hot reload
|
||||
-- drops it alongside every other downstream cache.
|
||||
--
|
||||
-- The entries are never evicted by size. That is deliberate and bounded:
|
||||
-- what lands here is tileset art and animation frames -- a few dozen small
|
||||
-- images for a whole session, tens of kilobytes each -- not per-map bakes,
|
||||
-- which have their own eviction in TerrainAtlas.setLive.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Assets = require("src.render.Assets")
|
||||
local Perf = V.require("Perf")
|
||||
|
||||
local ImageCache = {}
|
||||
|
||||
local cache = {}
|
||||
|
||||
-- The decoded pixels for `path`, or nil when it cannot be read.
|
||||
--
|
||||
-- `false` is cached for an unreadable path, so a missing or corrupt asset
|
||||
-- costs one failed decode for the session rather than one per frame -- the
|
||||
-- same sticky-failure shape the rest of this mod uses for GPU objects.
|
||||
function ImageCache.get(path)
|
||||
if not path then return nil end
|
||||
local hit = cache[path]
|
||||
if hit ~= nil then
|
||||
Perf.count("imageCache.hit")
|
||||
return hit or nil
|
||||
end
|
||||
local t0 = Perf.now()
|
||||
local ok, data = pcall(Assets.imageData, path)
|
||||
Perf.add("ImageCache.decode", t0)
|
||||
Perf.count("imageCache.miss")
|
||||
cache[path] = (ok and data) or false
|
||||
return cache[path] or nil
|
||||
end
|
||||
|
||||
function ImageCache.invalidate()
|
||||
cache = {}
|
||||
end
|
||||
|
||||
Assets.register(ImageCache.invalidate)
|
||||
|
||||
return ImageCache
|
||||
+246
-16
@@ -72,6 +72,38 @@ function OverworldBattle.enabled()
|
||||
return OverworldBattle.setting:get() and true or false
|
||||
end
|
||||
|
||||
-- ------- 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 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 SPRITES"
|
||||
|
||||
OverworldBattle.backSetting = ModSetting.new(OverworldBattle.BACK_KEY,
|
||||
OverworldBattle.BACK_LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
-- Gated on 3D-BTL rather than read alone: with staged battles off there is no
|
||||
-- staged shot for a back pic to be pinned in FRONT of, and the engine's own
|
||||
-- battle screen already draws exactly this.
|
||||
function OverworldBattle.backPinned()
|
||||
if not OverworldBattle.enabled() then return false end
|
||||
return OverworldBattle.backSetting:get() and true or false
|
||||
end
|
||||
|
||||
-- ------- both mons face you
|
||||
--
|
||||
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
|
||||
@@ -81,6 +113,10 @@ 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 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.
|
||||
--
|
||||
-- Answered BEFORE a battle exists, because the battler is built before the
|
||||
-- battle is pushed. So it cannot ask whether this fight is staged; it asks
|
||||
-- whether one on this map WOULD be -- the row is on, the 3D pass is
|
||||
@@ -91,6 +127,7 @@ local staged = { mapId = nil, ok = false }
|
||||
|
||||
function OverworldBattle.wantsFront()
|
||||
if not OverworldBattle.enabled() then return false end
|
||||
if OverworldBattle.backPinned() then return false end
|
||||
if not Voxel3D.available() then return false end
|
||||
-- required here rather than through the file's own helper: this runs
|
||||
-- while a battler is being built, which is before that helper is defined
|
||||
@@ -140,6 +177,51 @@ OverworldBattle.HUD_RECT = {
|
||||
player = { 72, 56, 88, 40 },
|
||||
}
|
||||
|
||||
-- ------- the box at the bottom, on the same glass
|
||||
--
|
||||
-- The HUDs got frosted panels because black glyphs on grass are not readable.
|
||||
-- The battle's text box and its menu had the opposite problem and the same
|
||||
-- cause: they are drawn as an OPAQUE WHITE slab with a black border, which was
|
||||
-- the field's own colour when the field was white and is a sheet of paper laid
|
||||
-- over the bottom third of the diorama now that it is not.
|
||||
--
|
||||
-- So the box gets exactly what the HUDs get: the world behind it, blurred to
|
||||
-- frosted glass and laid back down translucent, with the border and the text
|
||||
-- drawn over it unchanged, and the same brightness verdict flipping the ink
|
||||
-- when the ground under it is dark. Only the FILL is taken away -- every glyph
|
||||
-- the engine draws inside the box is still the engine's own, in its own place.
|
||||
--
|
||||
-- These are the boxes BattleState:drawTextArea lays down, as GB-frame rects.
|
||||
-- READ-ONLY duplicates of that function's own branches, the same kind of
|
||||
-- mirror hudLive is and for the same reason: there is no seam that reports "a
|
||||
-- move menu is up", and glass has to go down BEFORE the box that sits on it.
|
||||
-- The worst a future engine change can do is frost a rectangle nothing lands
|
||||
-- on, or leave a box unfrosted -- never break a battle.
|
||||
--
|
||||
-- Each rect stops where the next one starts rather than overlapping it: two
|
||||
-- panels over the same pixels would frost it twice and leave a visible step
|
||||
-- along the seam.
|
||||
OverworldBattle.TEXT_RECT = {
|
||||
box = { 0, 96, 160, 48 }, -- Font.drawBox(0, 12, 20, 6), always
|
||||
-- moveSelect's TYPE/PP box, Font.drawBox(0, 8, 11, 5), trimmed to the rows
|
||||
-- above the box above -- its last tile row sits inside that one
|
||||
moves = { 0, 64, 88, 32 },
|
||||
-- mimicSelect's copy menu, Font.drawBox(0, 7, 16, 6), trimmed the same way
|
||||
mimic = { 0, 56, 128, 40 },
|
||||
}
|
||||
|
||||
function OverworldBattle.textRects(battle)
|
||||
if not battle or battle.blankForAskName then return {} end
|
||||
local r = OverworldBattle.TEXT_RECT
|
||||
local out = { box = r.box }
|
||||
if battle.phase == "moveSelect" then
|
||||
out.moves = r.moves
|
||||
elseif battle.phase == "mimicSelect" then
|
||||
out.mimic = r.mimic
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- the HUDs, out at the window's own edges
|
||||
--
|
||||
-- The battle screen is 160x144 in the MIDDLE of the window and the world is the
|
||||
@@ -188,6 +270,17 @@ function OverworldBattle.snapRects(shot)
|
||||
return rects, { enemy = ex, player = px }
|
||||
end
|
||||
|
||||
-- A rect measured in the GB frame, in WORLD-canvas pixels: where the letterbox
|
||||
-- blit will actually put it. The text box has not moved anywhere -- it is drawn
|
||||
-- where it always was -- but its glass is laid into the world image alongside
|
||||
-- the HUDs' (see snapHUDs), which is the surface that reaches the screen a
|
||||
-- pixel to a pixel rather than magnified out of a 160x144 canvas.
|
||||
local function toWorld(rect, shot)
|
||||
local s = shot.scale
|
||||
return { shot.lx + rect[1] * s, shot.ly + rect[2] * s,
|
||||
rect[3] * s, rect[4] * s }
|
||||
end
|
||||
|
||||
-- ------- the live battle
|
||||
--
|
||||
-- nil when no overworld battle is running. Never more than one: battles do
|
||||
@@ -471,6 +564,81 @@ local function withoutBackgroundFill(battle, fn)
|
||||
if not ok then error(err, 0) end
|
||||
end
|
||||
|
||||
-- ------- the box, without its paper
|
||||
--
|
||||
-- Font.drawBox is a white fill and then six border glyphs, and the fill is the
|
||||
-- opaque slab the frosted panel underneath is there to replace. So for the
|
||||
-- length of one drawTextArea the white fills are dropped and everything else
|
||||
-- -- the border, the text, the cursor, the down arrow -- draws exactly as it
|
||||
-- always did, over the glass instead of over paper.
|
||||
--
|
||||
-- Every fill drawTextArea issues is one of those: the box's own, and the two
|
||||
-- eight-pixel cells MoveSelectionMenu wipes back to box white before it writes
|
||||
-- the border glyphs that hardware would have overwritten. Both are opaque
|
||||
-- white, both are paper, and both go.
|
||||
--
|
||||
-- The same shim shape as withoutBackgroundFill above, and scoped as tightly:
|
||||
-- installed around a single call, removed on the way out including on error,
|
||||
-- never live outside a battle frame this mode is drawing.
|
||||
local function withoutBoxFill(battle, fn)
|
||||
local g = love.graphics
|
||||
local rectangle = g.rectangle
|
||||
g.rectangle = function(mode, ...)
|
||||
if mode == "fill" then
|
||||
local r, gr, b, a = g.getColor()
|
||||
if r > 0.99 and gr > 0.99 and b > 0.99 and a > 0.99 then return end
|
||||
end
|
||||
return rectangle(mode, ...)
|
||||
end
|
||||
local ok, err = pcall(fn, battle)
|
||||
g.rectangle = rectangle
|
||||
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
|
||||
@@ -600,11 +768,19 @@ function OverworldBattle.flashing(battle)
|
||||
end
|
||||
|
||||
-- Both sides, or nil when neither has anything to show.
|
||||
--
|
||||
-- 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.
|
||||
function OverworldBattle.textures(battle)
|
||||
if not battle then return nil end
|
||||
local out = {}
|
||||
local okE, enemy = pcall(OverworldBattle.sideTexture, battle, "enemy")
|
||||
local okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
||||
local okP, player = true, nil
|
||||
if not OverworldBattle.backPinned() then
|
||||
okP, player = pcall(OverworldBattle.sideTexture, battle, "player")
|
||||
end
|
||||
out.enemy = okE and enemy or nil
|
||||
out.player = okP and player or nil
|
||||
if not (out.enemy or out.player) then return nil end
|
||||
@@ -719,10 +895,44 @@ function OverworldBattle.install()
|
||||
-- before this screen is composited at all, so the flat pics layer has
|
||||
-- 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 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)
|
||||
if self.dramaticShapeShot then return end
|
||||
return innerPics(self, slide, sx, sy)
|
||||
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, skipMenuClip)
|
||||
local shot = self.dramaticShapeShot
|
||||
if not shot then
|
||||
return innerPics(self, slide, sx, sy, onlySide, skipMenuClip)
|
||||
end
|
||||
if OverworldBattle.backPinned() and onlySide ~= "enemy" then
|
||||
-- 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
|
||||
|
||||
-- The battle's text box and its menus, over the frosted glass laid down for
|
||||
-- them rather than over their own white paper -- and their ink flipped with
|
||||
-- the HUD's when the ground under the frame is dark, by the same rule and
|
||||
-- off the same verdict.
|
||||
local innerText = BattleState.drawTextArea
|
||||
function BattleState:drawTextArea()
|
||||
if not self.dramaticShapeShot then return innerText(self) end
|
||||
local battle = self
|
||||
if not self.dramaticShapeDark then return withoutBoxFill(battle, innerText) end
|
||||
BattleHud.flipGlyphs(BattleScene.GB_W, BattleScene.GB_H, function()
|
||||
withoutBoxFill(battle, innerText)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Move animations are authored against the pics' fixed slots, and a single
|
||||
@@ -740,10 +950,13 @@ 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
|
||||
local dx = (shot.enemy[1] + shot.player[1]) / 2
|
||||
- (a.enemy[1] + a.player[1]) / 2
|
||||
local dy = (shot.enemy[2] + shot.player[2]) / 2
|
||||
- (a.enemy[2] + a.player[2]) / 2
|
||||
-- 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]
|
||||
if OverworldBattle.backPinned() then px, py = a.player[1], a.player[2] end
|
||||
local dx = (shot.enemy[1] + px) / 2 - (a.enemy[1] + a.player[1]) / 2
|
||||
local dy = (shot.enemy[2] + py) / 2 - (a.enemy[2] + a.player[2]) / 2
|
||||
love.graphics.push()
|
||||
love.graphics.translate(math.floor(dx + 0.5), math.floor(dy + 0.5))
|
||||
local ok, err = pcall(innerAnim, self, colorized)
|
||||
@@ -882,10 +1095,21 @@ function OverworldBattle.snapHUDs(battle, shot)
|
||||
local live = {}
|
||||
if enemy then live.enemy = rects.enemy end
|
||||
if player then live.player = rects.player end
|
||||
-- and the text box's own glass, on the same pass. It stays in the middle of
|
||||
-- the frame where the engine draws it -- only the HUDs were snapped out --
|
||||
-- so its GB rect is mapped into the letterbox rather than to an edge.
|
||||
for key, rect in pairs(OverworldBattle.textRects(battle)) do
|
||||
live[key] = toWorld(rect, shot)
|
||||
end
|
||||
-- measured under the SNAPPED rects: the panels are over whatever the world
|
||||
-- shows at the window's edges now, which is not what was behind them in the
|
||||
-- middle of the frame
|
||||
-- middle of the frame. ONE verdict over all of them, HUDs and box together,
|
||||
-- for the reason BattleHud.verdict gives: a frame with white glyphs in the
|
||||
-- corner and black ones on the menu reads as a bug rather than as adaptation.
|
||||
local dark = BattleHud.verdict(live, shot, true)
|
||||
-- the box's own ink is flipped where the engine draws it, in the GB frame,
|
||||
-- so the answer has to outlive this function (see drawHudPanels)
|
||||
if session then session.dark = dark end
|
||||
local layer = OverworldBattle.hudTexture(battle, slide, dark)
|
||||
if not layer then return false end
|
||||
|
||||
@@ -911,23 +1135,29 @@ function OverworldBattle.snapHUDs(battle, shot)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Lay the frosted glass down under whichever HUD is about to draw, and
|
||||
-- record which way the glyphs have to flip.
|
||||
-- Lay the frosted glass down under whichever HUD and box are about to draw,
|
||||
-- and record which way the glyphs have to flip.
|
||||
--
|
||||
-- The fallback path only: with the HUDs snapped out to the window's edges their
|
||||
-- panels went with them, and there is nothing left inside the GB frame to lay
|
||||
-- glass under.
|
||||
-- The panels are the fallback path only: normally the HUDs are snapped out to
|
||||
-- the window's edges and their glass, and the box's, went into the world image
|
||||
-- with them (snapHUDs). The VERDICT is needed either way -- the box's ink is
|
||||
-- drawn here, in the GB frame, whichever path laid the glass under it.
|
||||
function OverworldBattle.drawHudPanels(battle)
|
||||
local shot = battle.dramaticShapeShot
|
||||
battle.dramaticShapeDark = nil
|
||||
if not shot or snapped() then return end
|
||||
if not shot then return end
|
||||
if snapped() then
|
||||
battle.dramaticShapeDark = session and session.dark or nil
|
||||
return
|
||||
end
|
||||
local slide = (battle.introSlide or 0) * 4
|
||||
local enemy, player = OverworldBattle.hudLive(battle, slide)
|
||||
if not (enemy or player) then return end
|
||||
local rect = OverworldBattle.HUD_RECT
|
||||
local live = {}
|
||||
if enemy then live.enemy = rect.enemy end
|
||||
if player then live.player = rect.player end
|
||||
for key, r in pairs(OverworldBattle.textRects(battle)) do live[key] = r end
|
||||
if not next(live) then return end
|
||||
local dark = BattleHud.verdict(live, shot)
|
||||
battle.dramaticShapeDark = dark
|
||||
for _, r in pairs(live) do BattleHud.panel(r, shot, dark) end
|
||||
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
-- Voxel world mode: the instrumentation core.
|
||||
--
|
||||
-- Ships DARK. Every entry point is one boolean test away from doing
|
||||
-- nothing, and the boolean is false unless a run explicitly asks for
|
||||
-- measurement (DS_PERF in the environment, or a ds_perf.flag file in the
|
||||
-- save directory for a device that has no environment to set). A mod that
|
||||
-- measures itself in every player's session is a mod that costs every
|
||||
-- player the measurement, so the default has to be off and the off path
|
||||
-- has to be free.
|
||||
--
|
||||
-- What it measures, and why those three things:
|
||||
--
|
||||
-- * LABELS -- named spans (a bake, a mesh build, a shader compile),
|
||||
-- accumulated as {n, total, max}. `max` is the one that matters: a
|
||||
-- bake that costs 40ms ONCE is a visible hitch, and an average hides
|
||||
-- it completely.
|
||||
-- * FRAMES -- a ring of the last N whole-frame times, stamped once per
|
||||
-- rendered frame. Frame time is the only number the player actually
|
||||
-- experiences; every label total is a hypothesis about which frames.
|
||||
-- * COUNTERS -- plain integers a caller bumps (sun-pass redraws, atlas
|
||||
-- rebakes). Cheaper than a span when the question is "how often",
|
||||
-- not "how long".
|
||||
--
|
||||
-- Spans are wall time, and on a GPU that means submission time, not
|
||||
-- completion time -- the driver is free to finish the work later. So a
|
||||
-- GPU-side saving shows up in the FRAME numbers rather than in the label
|
||||
-- for the pass that caused it, and both are reported.
|
||||
|
||||
local Perf = {}
|
||||
|
||||
local clock = (love and love.timer and love.timer.getTime) or os.clock
|
||||
|
||||
-- Read through pcall: the loader's sandbox does not hand a mod `os`, and
|
||||
-- instrumentation must never be the reason the mod fails to load. Same
|
||||
-- shape as OverworldBattle's DS_BATTLE_DEBUG probe.
|
||||
local function envFlag(name)
|
||||
local ok, value = pcall(function() return os.getenv(name) end)
|
||||
if not ok then return nil end
|
||||
if value == nil or value == "" or value == "0" then return nil end
|
||||
return value
|
||||
end
|
||||
|
||||
local function flagFile()
|
||||
if not (love and love.filesystem and love.filesystem.getInfo) then
|
||||
return false
|
||||
end
|
||||
local ok, info = pcall(love.filesystem.getInfo, "ds_perf.flag")
|
||||
return ok and info ~= nil
|
||||
end
|
||||
|
||||
Perf.enabled = (envFlag("DS_PERF") ~= nil) or flagFile()
|
||||
|
||||
Perf.labels = {} -- label -> { n, total, max }
|
||||
Perf.order = {} -- insertion order, so a report reads chronologically
|
||||
Perf.counters = {} -- name -> integer
|
||||
Perf.frames = {} -- ring of frame times, seconds
|
||||
Perf.frameCount = 0
|
||||
Perf.RING = 4096
|
||||
|
||||
-- The segment a frame belongs to ("map:ROUTE_1:first"). A benchmark
|
||||
-- names the phase it is driving; every frame and every label span
|
||||
-- recorded while that name is set is attributed to it, which is what
|
||||
-- turns "the walk was slow" into "the walk was slow ONLY on the frames
|
||||
-- right after ROUTE_1 came into view".
|
||||
Perf.segment = nil
|
||||
Perf.segments = {} -- name -> { frames = {}, labels = {}, order = {} }
|
||||
|
||||
local function segmentEntry()
|
||||
local name = Perf.segment
|
||||
if not name then return nil end
|
||||
local s = Perf.segments[name]
|
||||
if not s then
|
||||
s = { name = name, frames = {}, labels = {}, order = {} }
|
||||
Perf.segments[name] = s
|
||||
Perf.segments[#Perf.segments + 1] = s -- array half preserves order
|
||||
end
|
||||
return s
|
||||
end
|
||||
|
||||
function Perf.setSegment(name)
|
||||
Perf.segment = name
|
||||
if name then segmentEntry() end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- spans
|
||||
--
|
||||
-- Call shape at the measured site:
|
||||
--
|
||||
-- local t0 = Perf.now()
|
||||
-- ... the work ...
|
||||
-- Perf.add("TerrainAtlas.staticAtlas", t0)
|
||||
--
|
||||
-- When disabled, now() returns nil and add() returns on the nil -- two
|
||||
-- function calls and a branch, no table touched, no string built. Sites
|
||||
-- that would run thousands of times a frame (per draw call, per vertex)
|
||||
-- are still too hot for that and are deliberately NOT instrumented; the
|
||||
-- frame ring covers them in aggregate.
|
||||
|
||||
function Perf.now()
|
||||
if not Perf.enabled then return nil end
|
||||
return clock()
|
||||
end
|
||||
|
||||
local function bump(store, order, label, dt)
|
||||
local s = store[label]
|
||||
if not s then
|
||||
s = { n = 0, total = 0, max = 0 }
|
||||
store[label] = s
|
||||
order[#order + 1] = label
|
||||
end
|
||||
s.n = s.n + 1
|
||||
s.total = s.total + dt
|
||||
if dt > s.max then s.max = dt end
|
||||
end
|
||||
|
||||
function Perf.add(label, t0)
|
||||
if t0 == nil then return end
|
||||
local dt = clock() - t0
|
||||
bump(Perf.labels, Perf.order, label, dt)
|
||||
local seg = segmentEntry()
|
||||
if seg then bump(seg.labels, seg.order, label, dt) end
|
||||
end
|
||||
|
||||
-- Wrap a function in a table, in place. Used by drivers to instrument
|
||||
-- module internals they do not own; the mod's own code calls now()/add()
|
||||
-- directly so the label is visible at the site.
|
||||
function Perf.wrap(tbl, name, label)
|
||||
local orig = tbl and tbl[name]
|
||||
if not orig then return false end
|
||||
tbl[name] = function(...)
|
||||
if not Perf.enabled then return orig(...) end
|
||||
local t0 = clock()
|
||||
local a, b, c, d = orig(...)
|
||||
Perf.add(label or name, t0)
|
||||
return a, b, c, d
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- counters
|
||||
|
||||
function Perf.count(name, by)
|
||||
if not Perf.enabled then return end
|
||||
Perf.counters[name] = (Perf.counters[name] or 0) + (by or 1)
|
||||
end
|
||||
|
||||
-- --------------------------------------------------------------- frames
|
||||
--
|
||||
-- Called once per RENDERED frame (the endFrame seam), not once per
|
||||
-- logic update: a scripted run can step the game many times per render,
|
||||
-- and a frame the player never saw cannot have hitched for them.
|
||||
|
||||
local lastFrame = nil
|
||||
|
||||
function Perf.frame()
|
||||
if not Perf.enabled then return end
|
||||
local t = clock()
|
||||
if lastFrame then
|
||||
local dt = t - lastFrame
|
||||
local n = Perf.frameCount + 1
|
||||
Perf.frameCount = n
|
||||
Perf.frames[(n - 1) % Perf.RING + 1] = dt
|
||||
local seg = segmentEntry()
|
||||
if seg then seg.frames[#seg.frames + 1] = dt end
|
||||
end
|
||||
lastFrame = t
|
||||
end
|
||||
|
||||
-- Discard the pending frame stamp: after a long blocking operation the
|
||||
-- next frame delta would include it and libel the renderer.
|
||||
function Perf.resync()
|
||||
lastFrame = Perf.enabled and clock() or nil
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------ reporting
|
||||
|
||||
local function percentile(sorted, p)
|
||||
local n = #sorted
|
||||
if n == 0 then return 0 end
|
||||
local i = math.ceil(p * n)
|
||||
if i < 1 then i = 1 end
|
||||
if i > n then i = n end
|
||||
return sorted[i]
|
||||
end
|
||||
|
||||
-- Frame statistics in MILLISECONDS. p95/p99 rather than the average
|
||||
-- because smoothness is a tail property: a run that averages 9ms and
|
||||
-- spikes to 60ms four times reads as stuttering, and its average reads
|
||||
-- as fine.
|
||||
function Perf.frameStats(list)
|
||||
local src = list or Perf.frames
|
||||
local sorted = {}
|
||||
for i = 1, #src do sorted[i] = src[i] * 1000 end
|
||||
table.sort(sorted)
|
||||
local n = #sorted
|
||||
local total = 0
|
||||
for i = 1, n do total = total + sorted[i] end
|
||||
local over16, over33 = 0, 0
|
||||
for i = 1, n do
|
||||
if sorted[i] > 16.7 then over16 = over16 + 1 end
|
||||
if sorted[i] > 33.3 then over33 = over33 + 1 end
|
||||
end
|
||||
return {
|
||||
n = n,
|
||||
avg = n > 0 and total / n or 0,
|
||||
p50 = percentile(sorted, 0.50),
|
||||
p95 = percentile(sorted, 0.95),
|
||||
p99 = percentile(sorted, 0.99),
|
||||
worst = n > 0 and sorted[n] or 0,
|
||||
over16 = over16,
|
||||
over33 = over33,
|
||||
}
|
||||
end
|
||||
|
||||
function Perf.reset()
|
||||
Perf.labels, Perf.order = {}, {}
|
||||
Perf.counters = {}
|
||||
Perf.frames, Perf.frameCount = {}, 0
|
||||
Perf.segments = {}
|
||||
Perf.segment = nil
|
||||
lastFrame = nil
|
||||
end
|
||||
|
||||
local function sortedLabels(store, order)
|
||||
local out = {}
|
||||
for _, lbl in ipairs(order) do out[#out + 1] = lbl end
|
||||
table.sort(out, function(a, b) return store[a].total > store[b].total end)
|
||||
return out
|
||||
end
|
||||
|
||||
function Perf.printReport(title)
|
||||
print(("[perf] ==== %s ===="):format(tostring(title or "report")))
|
||||
local f = Perf.frameStats()
|
||||
print(("[perf] frames n=%d avg=%.2fms p50=%.2f p95=%.2f p99=%.2f worst=%.2f >16.7ms=%d >33.3ms=%d")
|
||||
:format(f.n, f.avg, f.p50, f.p95, f.p99, f.worst, f.over16, f.over33))
|
||||
for _, seg in ipairs(Perf.segments) do
|
||||
local s = Perf.frameStats(seg.frames)
|
||||
print(("[perf] seg %-28s n=%4d avg=%6.2f p95=%6.2f p99=%6.2f worst=%7.2f >16.7=%3d >33.3=%3d")
|
||||
:format(seg.name, s.n, s.avg, s.p95, s.p99, s.worst, s.over16, s.over33))
|
||||
end
|
||||
print("[perf] ---- labels (ms, sorted by total) ----")
|
||||
for _, lbl in ipairs(sortedLabels(Perf.labels, Perf.order)) do
|
||||
local s = Perf.labels[lbl]
|
||||
print(("[perf] %-46s n=%6d total=%9.1f max=%8.2f")
|
||||
:format(lbl, s.n, s.total * 1000, s.max * 1000))
|
||||
end
|
||||
local names = {}
|
||||
for k in pairs(Perf.counters) do names[#names + 1] = k end
|
||||
table.sort(names)
|
||||
if #names > 0 then print("[perf] ---- counters ----") end
|
||||
for _, k in ipairs(names) do
|
||||
print(("[perf] %-46s %d"):format(k, Perf.counters[k]))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------------ json
|
||||
--
|
||||
-- Hand-rolled rather than pulled from the engine: the report has to be
|
||||
-- readable by a diff tool between two runs, and that means stable key
|
||||
-- ORDER, which a generic serializer does not promise.
|
||||
|
||||
local function q(s)
|
||||
return '"' .. tostring(s):gsub('[%c"\\]', function(c)
|
||||
if c == '"' then return '\\"' end
|
||||
if c == "\\" then return "\\\\" end
|
||||
return ("\\u%04x"):format(c:byte())
|
||||
end) .. '"'
|
||||
end
|
||||
|
||||
local function num(x)
|
||||
return ("%.4f"):format(x)
|
||||
end
|
||||
|
||||
local function statsJson(f)
|
||||
return ("{\"n\":%d,\"avg\":%s,\"p50\":%s,\"p95\":%s,\"p99\":%s,\"worst\":%s,\"over16\":%d,\"over33\":%d}")
|
||||
:format(f.n, num(f.avg), num(f.p50), num(f.p95), num(f.p99),
|
||||
num(f.worst), f.over16, f.over33)
|
||||
end
|
||||
|
||||
local function labelsJson(store, order)
|
||||
local parts = {}
|
||||
for _, lbl in ipairs(sortedLabels(store, order)) do
|
||||
local s = store[lbl]
|
||||
parts[#parts + 1] = ("%s:{\"n\":%d,\"total\":%s,\"max\":%s}")
|
||||
:format(q(lbl), s.n, num(s.total * 1000), num(s.max * 1000))
|
||||
end
|
||||
return "{" .. table.concat(parts, ",") .. "}"
|
||||
end
|
||||
|
||||
function Perf.toJson(meta)
|
||||
local parts = {}
|
||||
parts[#parts + 1] = "{"
|
||||
parts[#parts + 1] = "\"meta\":{"
|
||||
local m = {}
|
||||
for k, v in pairs(meta or {}) do
|
||||
m[#m + 1] = q(k) .. ":" .. (type(v) == "number" and num(v) or q(v))
|
||||
end
|
||||
table.sort(m)
|
||||
parts[#parts + 1] = table.concat(m, ",") .. "},"
|
||||
parts[#parts + 1] = "\"frames\":" .. statsJson(Perf.frameStats()) .. ","
|
||||
parts[#parts + 1] = "\"segments\":{"
|
||||
local segs = {}
|
||||
for _, seg in ipairs(Perf.segments) do
|
||||
segs[#segs + 1] = q(seg.name) .. ":{\"frames\":"
|
||||
.. statsJson(Perf.frameStats(seg.frames))
|
||||
.. ",\"labels\":" .. labelsJson(seg.labels, seg.order) .. "}"
|
||||
end
|
||||
parts[#parts + 1] = table.concat(segs, ",") .. "},"
|
||||
parts[#parts + 1] = "\"labels\":" .. labelsJson(Perf.labels, Perf.order) .. ","
|
||||
local cs = {}
|
||||
for k, v in pairs(Perf.counters) do cs[#cs + 1] = q(k) .. ":" .. v end
|
||||
table.sort(cs)
|
||||
parts[#parts + 1] = "\"counters\":{" .. table.concat(cs, ",") .. "}"
|
||||
parts[#parts + 1] = "}"
|
||||
return table.concat(parts, "")
|
||||
end
|
||||
|
||||
-- Written through love.filesystem (the save directory) rather than io:
|
||||
-- a driver run and an Android session both have one, and neither is
|
||||
-- guaranteed a writable working directory.
|
||||
function Perf.write(name, meta)
|
||||
local body = Perf.toJson(meta)
|
||||
if love and love.filesystem then
|
||||
pcall(love.filesystem.createDirectory, "ds_bench")
|
||||
local ok = pcall(love.filesystem.write, "ds_bench/" .. name .. ".json", body)
|
||||
if ok then
|
||||
print("[perf] wrote " .. tostring(love.filesystem.getSaveDirectory())
|
||||
.. "/ds_bench/" .. name .. ".json")
|
||||
return true
|
||||
end
|
||||
end
|
||||
print("[perf] JSON " .. name .. ": " .. body)
|
||||
return false
|
||||
end
|
||||
|
||||
-- ----------------------------------------------------------- draw stats
|
||||
--
|
||||
-- love.graphics.getStats() resets per frame, so it is only meaningful
|
||||
-- read at the END of a frame -- which is where Perf.frame() runs.
|
||||
|
||||
function Perf.drawStats()
|
||||
if not (love and love.graphics and love.graphics.getStats) then return end
|
||||
local s = love.graphics.getStats()
|
||||
Perf.count("stat.drawcalls", s.drawcalls or 0)
|
||||
Perf.count("stat.canvasswitches", s.canvasswitches or 0)
|
||||
Perf.count("stat.shaderswitches", s.shaderswitches or 0)
|
||||
Perf.count("stat.frames", 1)
|
||||
Perf.texturememory = s.texturememory
|
||||
Perf.canvases = s.canvases
|
||||
Perf.images = s.images
|
||||
end
|
||||
|
||||
return Perf
|
||||
+98
-39
@@ -16,11 +16,13 @@
|
||||
-- as four stripes. No clouds, nothing moving.
|
||||
--
|
||||
-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is
|
||||
-- no baked 160x144 picture scaled up to the window, no downsized buffer blown
|
||||
-- back up, no texture of any kind: one full-region rectangle through a shader
|
||||
-- that answers every pixel from its own canvas coordinate. A pixel of sky is
|
||||
-- computed at the size it is displayed at, so there is nothing for a filter to
|
||||
-- soften and nothing to go stale when the window or the zoom changes.
|
||||
-- no baked 160x144 picture scaled up to the window and no downsized buffer blown
|
||||
-- back up: one full-region rectangle through a shader that answers every pixel
|
||||
-- from its own canvas coordinate. A pixel of sky is computed at the size it is
|
||||
-- displayed at, so there is nothing for a filter to soften and nothing to go
|
||||
-- stale when the window or the zoom changes. The shader does bind one texture,
|
||||
-- but it is a palette rather than an image -- the bands, one texel each, sampled
|
||||
-- nearest (see rampFor, and why it is not a uniform array).
|
||||
--
|
||||
-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells
|
||||
-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed
|
||||
@@ -51,13 +53,11 @@ local V = ...
|
||||
local DayNight = V.require("DayNight")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local unpack = table.unpack or unpack
|
||||
|
||||
local Sky = {}
|
||||
|
||||
-- The shader carries a fixed-size array, because a GLSL uniform array is a
|
||||
-- fixed size; eight leaves headroom over DayNight's six-band phase palettes
|
||||
-- without paying for more.
|
||||
-- The most bands a phase palette may paint with. Eight leaves headroom over
|
||||
-- DayNight's six-band ones without paying for more; the ramp the shader reads
|
||||
-- them from is built at the width actually used, so the cap costs nothing.
|
||||
Sky.MAX_BANDS = 8
|
||||
|
||||
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
|
||||
@@ -84,7 +84,7 @@ Sky.SPAN = 0.23
|
||||
--
|
||||
-- Memoised, because this runs once a frame and the answer only moves when the
|
||||
-- mode does.
|
||||
local cache = { bands = nil, key = {} }
|
||||
local cache = { bands = nil, key = {}, ramp = nil }
|
||||
|
||||
function Sky.bands()
|
||||
local pal = DayNight.palette()
|
||||
@@ -102,6 +102,11 @@ function Sky.bands()
|
||||
end
|
||||
if same then return cache.bands end
|
||||
|
||||
-- the ramp is these bands as a texture (see rampFor); a new list is a new
|
||||
-- ramp, and the old one is nothing's to keep
|
||||
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
|
||||
cache.ramp, cache.rampFor = nil, nil
|
||||
|
||||
local bands = {}
|
||||
for i = 1, n do
|
||||
-- backwards: the palette's darkest rung is the top band
|
||||
@@ -150,17 +155,18 @@ end
|
||||
|
||||
-- ------- the pass
|
||||
--
|
||||
-- One rectangle, one shader, no texture. Every pixel answers for itself from its
|
||||
-- canvas coordinate, so the sky is drawn at exactly the resolution it is
|
||||
-- displayed at -- there is no image being scaled and so nothing to be soft.
|
||||
-- One rectangle, one shader. Every pixel answers for itself from its canvas
|
||||
-- coordinate, so the sky is drawn at exactly the resolution it is displayed at
|
||||
-- -- there is no image being scaled and so nothing to be soft. The one texture
|
||||
-- bound is the band ramp, which is a PALETTE and not a picture: n texels wide,
|
||||
-- sampled nearest, one lookup per pixel (see rampFor).
|
||||
--
|
||||
-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged
|
||||
-- by is the top of its own cell row, so a whole cell row is one colour and every
|
||||
-- edge in the sky lands on the diorama's pixel grid.
|
||||
local SHADER_SRC = [[
|
||||
#define MAXB %d
|
||||
uniform vec3 bands[MAXB];
|
||||
uniform int count;
|
||||
uniform Image ramp; // the bands, one texel each, top of the sky first
|
||||
uniform float count; // how many texels wide that ramp is
|
||||
uniform float edge; // the sky's bottom, in canvas pixels
|
||||
uniform float cell; // the diorama's pixel size, in canvas pixels
|
||||
uniform float start; // where the checker begins inside a band
|
||||
@@ -170,26 +176,23 @@ uniform vec2 glowPos; // the sun disc, in canvas pixels
|
||||
uniform float glowInvR; // 1 / the glow's reach
|
||||
uniform vec3 glowColor;
|
||||
|
||||
// Indexed through a loop counter, which every GLSL ES compiler accepts for a
|
||||
// uniform array; a bare bands[idx] is not portable.
|
||||
vec3 bandAt(int idx) {
|
||||
vec3 c = bands[0];
|
||||
for (int i = 1; i < MAXB; i++) {
|
||||
if (i == idx) { c = bands[i]; }
|
||||
}
|
||||
return c;
|
||||
// Band `i`, read from its own texel centre. The index is clamped rather than
|
||||
// trusted: `pos` below can land exactly on `count` when the arithmetic is
|
||||
// carried at mediump -- which is the fragment default on GLSL ES -- and a
|
||||
// sample past the last band must be the last band, not whatever is off the
|
||||
// end of the image.
|
||||
vec3 bandAt(float i) {
|
||||
return Texel(ramp, vec2((clamp(i, 0.0, count - 1.0) + 0.5) / count, 0.5)).rgb;
|
||||
}
|
||||
|
||||
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
float n = float(count);
|
||||
float row = floor(sc.y / cell) * cell; // top of this cell row
|
||||
float pos = clamp(row / max(edge, 1.0), 0.0, 0.999999) * n;
|
||||
float base = floor(pos);
|
||||
int idx = int(base);
|
||||
vec3 c = bandAt(idx);
|
||||
float pos = min(row / max(edge, 1.0), 1.0) * count;
|
||||
float base = min(floor(pos), count - 1.0);
|
||||
vec3 c = bandAt(base);
|
||||
float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
|
||||
if (idx < count - 1 && (pos - base) > start) {
|
||||
if (parity < 0.5) { c = bandAt(idx + 1); }
|
||||
if (base < count - 1.0 && (pos - base) > start) {
|
||||
if (parity < 0.5) { c = bandAt(base + 1.0); }
|
||||
}
|
||||
// The sunset's warmth, radiating from the disc: posterised to a few rungs
|
||||
// and checker-dithered between them -- the same 8-bit move as the bands,
|
||||
@@ -207,14 +210,66 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
|
||||
}
|
||||
]]
|
||||
|
||||
-- ------- the ramp
|
||||
--
|
||||
-- The bands as a one-texel-per-band TEXTURE rather than as a uniform array,
|
||||
-- which is what they used to be: `uniform vec3 bands[8]`, filled from Lua and
|
||||
-- read through a loop counter. On desktop GL that is as portable as it looks.
|
||||
-- On Android it was not. The sky's lower bands came back BLACK -- a hard-edged
|
||||
-- strip running from partway down the gradient to the horizon point, with the
|
||||
-- moon still drawn correctly over it, and with the haze BELOW the sky (the
|
||||
-- palest band again, but delivered by love.graphics.clear instead of by the
|
||||
-- array) landing in exactly the right colour. Same colour, two routes, one of
|
||||
-- them black: the fault was the array, not the palette.
|
||||
--
|
||||
-- Which of the ES failure modes it was hardly matters -- a driver that
|
||||
-- truncates a partially-filled array, a fragment uniform budget the guaranteed
|
||||
-- floor of which is sixteen vectors (eight bands plus the glow plus LOVE's own
|
||||
-- built-ins is over it), a reflection that finds bands[0] and nothing after --
|
||||
-- because they all have the same shape: slots past the first few read as zero,
|
||||
-- and zero is black.
|
||||
--
|
||||
-- A sampler has none of them. One texture unit replaces eight uniform vectors,
|
||||
-- there is no array to index and no budget to overrun, and a texel that does
|
||||
-- not exist cannot read as black because the image is built at exactly the
|
||||
-- width the shader divides by. Nearest and clamped, so a sample lands on one
|
||||
-- band's own colour and an out-of-range one lands on the end band rather than
|
||||
-- on nothing.
|
||||
--
|
||||
-- Rebuilt only when the bands move, which is when the clock or the display
|
||||
-- mode does; Sky.bands drops it as it rebuilds the list it is made from.
|
||||
local function rampFor(bands)
|
||||
if cache.ramp and cache.rampFor == bands then return cache.ramp end
|
||||
if not (love.image and love.image.newImageData
|
||||
and love.graphics and love.graphics.newImage) then return nil end
|
||||
local n = #bands
|
||||
if n < 1 then return nil end
|
||||
local ok, data = pcall(love.image.newImageData, n, 1)
|
||||
if not (ok and data) then return nil end
|
||||
for i = 1, n do
|
||||
local c = bands[i]
|
||||
pcall(data.setPixel, data, i - 1, 0, c[1], c[2], c[3], 1)
|
||||
end
|
||||
local built, img = pcall(love.graphics.newImage, data)
|
||||
if not (built and img) then return nil end
|
||||
-- nearest: a band is a flat colour, not something to interpolate between.
|
||||
-- clamp: the shader clamps its index too, so this is the second of two
|
||||
-- guards against ever sampling off the end -- and it returns the edge band.
|
||||
pcall(img.setFilter, img, "nearest", "nearest")
|
||||
pcall(img.setWrap, img, "clamp", "clamp")
|
||||
cache.ramp, cache.rampFor = img, bands
|
||||
return img
|
||||
end
|
||||
|
||||
Sky._rampFor = rampFor -- named for the suite
|
||||
|
||||
local shader = nil -- nil = untried, false = unavailable
|
||||
|
||||
local function getShader()
|
||||
if shader == nil then
|
||||
shader = false
|
||||
if love.graphics and love.graphics.newShader then
|
||||
local ok, sh = pcall(love.graphics.newShader,
|
||||
SHADER_SRC:format(Sky.MAX_BANDS))
|
||||
local ok, sh = pcall(love.graphics.newShader, SHADER_SRC)
|
||||
if ok and sh then
|
||||
shader = sh
|
||||
elseif V and V.mod and V.mod.log then
|
||||
@@ -358,12 +413,13 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
|
||||
local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
|
||||
local sh = getShader()
|
||||
local ramp = sh and rampFor(bands)
|
||||
if not ramp then sh = nil end -- no ramp, no gradient: paint it flat
|
||||
if sh then
|
||||
local sent = pcall(function()
|
||||
-- one send per band would be one uniform lookup per band; the array takes
|
||||
-- them all at once, and it must be the LAST argument or Lua truncates the
|
||||
-- unpack to a single value
|
||||
sh:send("bands", unpack(bands))
|
||||
-- the bands arrive as a texture, one texel each, and `count` is that
|
||||
-- texture's width -- see rampFor for why they are not a uniform array
|
||||
sh:send("ramp", ramp)
|
||||
sh:send("count", #bands)
|
||||
sh:send("edge", edge)
|
||||
sh:send("cell", cell)
|
||||
@@ -399,9 +455,12 @@ function Sky.paint(w, h, sky, horizonY, cell, body)
|
||||
end
|
||||
|
||||
-- Drop the compiled shader (window resize, hot reload), so a re-created graphics
|
||||
-- context builds a new one instead of drawing with a handle from the old.
|
||||
-- context builds a new one instead of drawing with a handle from the old. The
|
||||
-- ramp is a GPU object on the same context and goes with it.
|
||||
function Sky.invalidate()
|
||||
shader = nil
|
||||
if cache.ramp and cache.ramp.release then pcall(cache.ramp.release, cache.ramp) end
|
||||
cache.ramp, cache.rampFor = nil, nil
|
||||
end
|
||||
|
||||
return Sky
|
||||
|
||||
@@ -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,9 +286,14 @@ 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 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
|
||||
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
|
||||
@@ -302,24 +308,44 @@ 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.
|
||||
{ OverworldBattle.backSetting,
|
||||
"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, 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 "
|
||||
@@ -354,9 +380,12 @@ mod.options:define(schema)
|
||||
-- AND the engine's TILT on the same press.
|
||||
--
|
||||
-- Consequences worth being explicit about: while this mod is enabled, TILT
|
||||
-- (3) and GBC FX (5) are unreachable by key. Both are still reachable on
|
||||
-- the OPTIONS menu, and TILT is the one this mode supersedes anyway -- the
|
||||
-- registry already forces it off whenever a world pipeline takes the pass.
|
||||
-- (3) and GBC FX (5) are unreachable by key -- and unreachable on the OPTIONS
|
||||
-- menu too, where both rows are taken away and both values held at zero (see
|
||||
-- pinEngineFx). Nothing is being hidden that still does something: TILT is the
|
||||
-- flat fake of what this mode does for real, the registry already forces it
|
||||
-- off whenever a world pipeline takes the pass, and GBC FX is a full-screen
|
||||
-- present pass over the top of the diorama. Uninstalling puts both back.
|
||||
--
|
||||
-- Everything the engine does around a pipeline hotkey has to happen here
|
||||
-- too, so the work is DELEGATED rather than reimplemented: Pipelines.hotkey
|
||||
@@ -464,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
|
||||
@@ -475,12 +506,50 @@ local function dropRow(out, id)
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------- TILT and GBC FX are gone while this mod is installed
|
||||
--
|
||||
-- Both fight the diorama, and both were already half-taken: the mode's own key
|
||||
-- (3) forces them off on every press, and the registry switches TILT off
|
||||
-- whenever a world pipeline takes the pass. What was left was two rows the
|
||||
-- player could set and watch get reverted -- TILT is the flat fake of what
|
||||
-- this mode does for real, and GBC FX is a full-screen present pass over the
|
||||
-- top of the whole thing.
|
||||
--
|
||||
-- So they come OFF the menu, and are HELD at zero rather than merely dropped.
|
||||
-- Hiding a live setting is a trap: a save written before the mod was installed
|
||||
-- can carry TILT 3, and a row that is not there is a row that cannot turn it
|
||||
-- back off. Pinned wherever the value could have arrived from -- the menu
|
||||
-- opening, a save being loaded or begun -- so there is no route by which one
|
||||
-- of them is on and unreachable.
|
||||
--
|
||||
-- Everything they did is still reachable: uninstall the mod and both rows are
|
||||
-- back, at whatever they were last set to.
|
||||
local function pinEngineFx(game)
|
||||
game = game or require("src.core.Game")
|
||||
local opts = game and game.save and game.save.options
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
local changed = false
|
||||
if opts then
|
||||
changed = (opts.tilt or 0) ~= 0 or (opts.gbcfx or 0) ~= 0
|
||||
opts.tilt, opts.gbcfx = 0, 0
|
||||
end
|
||||
pcall(Tilt.setLevel, 0)
|
||||
pcall(GBCFX.setLevel, 0)
|
||||
if changed and game.writeOptions then pcall(game.writeOptions, game) end
|
||||
end
|
||||
|
||||
-- call next() first and decorate what comes back, so every other mod's
|
||||
-- rows survive this one
|
||||
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
|
||||
local out = next(game, rows)
|
||||
if type(out) ~= "table" then return out end
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
-- ahead of every branch below, including FULL's early return: these two are
|
||||
-- off the menu whatever else this mod is or is not doing
|
||||
pinEngineFx(game)
|
||||
dropRow(out, "tilt")
|
||||
dropRow(out, "gbcfx")
|
||||
-- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes
|
||||
-- one away. While a fight can be staged on the map, OG is the only layout it
|
||||
-- can be composed in (OverworldBattle.forceOG), so the value is pinned there
|
||||
@@ -492,14 +561,32 @@ 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 extra[#extra + 1] = entry[1]:row() end
|
||||
for _, entry in ipairs(SETTINGS) do
|
||||
-- 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)
|
||||
|
||||
@@ -714,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
|
||||
@@ -728,10 +825,16 @@ end)
|
||||
|
||||
mod.events:on("save.loaded", function()
|
||||
DayNight.restore()
|
||||
-- a save written before this mod was installed can carry TILT or GBC FX
|
||||
-- switched on, and their rows are not there to switch them back off (see
|
||||
-- pinEngineFx). Answered here rather than only when the menu opens, so a
|
||||
-- player who never opens it is not left playing under one.
|
||||
pinEngineFx()
|
||||
end)
|
||||
|
||||
mod.events:on("save.created", function()
|
||||
DayNight.restore()
|
||||
pinEngineFx()
|
||||
end)
|
||||
|
||||
-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
|
||||
@@ -745,7 +848,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
|
||||
return DayNight.tod()
|
||||
end)
|
||||
|
||||
mod.exports.version = "1.2.0"
|
||||
mod.exports.version = "1.3.0"
|
||||
-- exposed so a companion mod can pin its own tiles' shapes or read the
|
||||
-- camera without reaching into this mod's file layout
|
||||
mod.exports.lib = V
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
@@ -11,17 +11,19 @@ return {
|
||||
"with VOXEL on, the overworld draws as 3D geometry instead of flat tiles",
|
||||
"occlusion comes from a depth buffer rather than a y-sort, so buildings really hide what is behind them",
|
||||
"with 3D-BTL on, a battle draws over the map's nearest clear ground instead of over a white field",
|
||||
"the battle's text box and menu are frosted glass over that ground rather than an opaque white slab, on the same panels the HUDs sit on",
|
||||
"the map's NPCs are culled for the length of a battle, so the wipe plays over an empty map",
|
||||
"a battle's letterbox voids go black rather than white, because the battle canvas is no longer white",
|
||||
"VOXEL and the engine's TILT are mutually exclusive -- turning one on switches the other off",
|
||||
"hotkeys 3 and 5 are taken over from the engine's TILT and GBC FX; both remain on the OPTIONS menu",
|
||||
"the VOXEL key (3) turns TILT and GBC FX off on every press -- both fight the diorama, and 3 is now the only key that reaches either",
|
||||
"the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back",
|
||||
"hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded",
|
||||
},
|
||||
added = {
|
||||
"VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)",
|
||||
"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 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",
|
||||
|
||||
+479
-13
@@ -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,77 @@ 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
|
||||
--
|
||||
-- Two ENGINE rows, taken away for as long as 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 TILT off whenever a world
|
||||
-- pipeline takes the pass -- so what was left was two rows a player could set
|
||||
-- and watch get reverted.
|
||||
--
|
||||
-- Dropped AND held at zero, which is the part that matters: a save written
|
||||
-- before the mod was installed can carry TILT 3, and a row that is not there
|
||||
-- is a row that cannot turn it back off.
|
||||
do
|
||||
local fxGame = {
|
||||
data = Data,
|
||||
save = { options = { tilt = 3, gbcfx = 2, pipelines = {}, modOptions = {} } },
|
||||
mods = { modOptions = {} },
|
||||
writeOptions = function() end,
|
||||
}
|
||||
local Tilt = require("src.render.Tilt")
|
||||
local GBCFX = require("src.render.GBCFX")
|
||||
Tilt.setLevel(3)
|
||||
GBCFX.setLevel(2)
|
||||
|
||||
local fxRows = Runtime.call("ui.options.rows", function(_, r) return r end,
|
||||
fxGame,
|
||||
{ { id = "tilt" }, { id = "gbcfx" },
|
||||
{ id = "colors" }, { id = "pipeline:voxel" } })
|
||||
local fxIds = {}
|
||||
for _, row in ipairs(fxRows) do fxIds[row.id] = true end
|
||||
T.check(not fxIds["tilt"], "TILT is off the OPTIONS menu")
|
||||
T.check(not fxIds["gbcfx"], "and so is GBC FX")
|
||||
T.check(fxIds["colors"] and fxIds["pipeline:voxel"],
|
||||
"with every other row the engine offered still on it")
|
||||
|
||||
T.eq(fxGame.save.options.tilt, 0,
|
||||
"a save that had TILT on is pinned back to off, not left on with no row")
|
||||
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 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" } })
|
||||
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
|
||||
|
||||
-- ------- and off FULL, the rows come back, grouped with the mode
|
||||
@@ -306,9 +379,9 @@ end
|
||||
Pipelines.setLevel("voxel", 2)
|
||||
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
|
||||
{ data = Data }, { { id = "text_speed" } })
|
||||
T.eq(#hookedRows, 5, "the options hook added a row per setting")
|
||||
T.eq(#hookedRows, 6, "the options hook added a row per setting")
|
||||
local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
|
||||
local daytime = hookedRows[5]
|
||||
local backRow, daytime = hookedRows[5], hookedRows[6]
|
||||
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
|
||||
T.eq(daytime.value(), "SYNC",
|
||||
"and defaults to SYNC -- no value set follows the clock on the wall")
|
||||
@@ -320,6 +393,12 @@ 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 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")
|
||||
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true),
|
||||
"on its own key, so it persists beside 3D-BTL rather than over it")
|
||||
|
||||
-- stepping writes through to the one place both rows read
|
||||
local settingGame = { save = { options = {} }, mods = { modOptions = {} } }
|
||||
@@ -1278,22 +1357,45 @@ T.check(Sky.SPAN > 0.1 and Sky.SPAN < 0.5,
|
||||
|
||||
-- ------- the pass, as it is actually issued
|
||||
--
|
||||
-- One rectangle through one shader: no texture, no baked image, nothing being
|
||||
-- One rectangle through one shader: no baked picture of a sky, nothing being
|
||||
-- resampled -- which is the whole reason it is drawn this way rather than
|
||||
-- generated once and scaled. Every pixel answers from its own canvas coordinate,
|
||||
-- so it is computed at the size it is shown at.
|
||||
--
|
||||
-- The one texture bound is the band RAMP, and it is a palette rather than a
|
||||
-- picture: one texel per band, sampled nearest. It used to be a uniform array,
|
||||
-- and that is the bug this shape exists to have fixed -- on Android the array's
|
||||
-- later slots arrived as zero and painted the bottom of the sky black, while the
|
||||
-- identical colour delivered by love.graphics.clear (the haze under the horizon)
|
||||
-- landed correctly. So the assertions below pin the ramp, not an array.
|
||||
--
|
||||
-- And the depth mode is put back to what it was, which is the piece that would
|
||||
-- break the frame: a rectangle drawn under the pass's own ("lequal", true) stamps
|
||||
-- itself across the depth buffer at the near plane and hides the whole world
|
||||
-- behind the sky.
|
||||
local realGraphics = love.graphics
|
||||
local realGraphics, realImage = love.graphics, love.image
|
||||
local rects, depthCalls, sent, shaderUses = {}, {}, {}, 0
|
||||
local fakeShader = {
|
||||
send = function(_, name, a, b, c, d)
|
||||
sent[name] = { a, b, c, d }
|
||||
end,
|
||||
}
|
||||
-- enough of an image to be built and measured; the ramp only ever has pixels
|
||||
-- written into it and its dimensions read back
|
||||
local function fakeImage(w, h)
|
||||
return {
|
||||
pixels = {},
|
||||
getWidth = function(self) return w end,
|
||||
getHeight = function(self) return h end,
|
||||
getDimensions = function(self) return w, h end,
|
||||
setPixel = function(self, x, _, r, g, b, a)
|
||||
self.pixels[x] = { r, g, b, a }
|
||||
end,
|
||||
setFilter = function() end,
|
||||
setWrap = function() end,
|
||||
}
|
||||
end
|
||||
love.image = { newImageData = function(w, h) return fakeImage(w, h) end }
|
||||
love.graphics = {
|
||||
getShader = function() return nil end,
|
||||
setShader = function(sh) if sh then shaderUses = shaderUses + 1 end end,
|
||||
@@ -1303,14 +1405,17 @@ love.graphics = {
|
||||
end,
|
||||
setColor = function() end,
|
||||
newShader = function() return fakeShader end,
|
||||
newImage = function(data) return data end,
|
||||
rectangle = function(_, x, y, w, h)
|
||||
rects[#rects + 1] = { x = x, y = y, w = w, h = h }
|
||||
end,
|
||||
}
|
||||
Sky.invalidate() -- so the ramp is built through the fakes above, not held
|
||||
|
||||
-- 320x288 canvas, horizon at 66.83, diorama pixels 7 canvas pixels square
|
||||
local painted = Sky.paint(320, 288, skyGrad, 66.83, 7)
|
||||
love.graphics = realGraphics
|
||||
local ramp = Sky._rampFor(skyGrad.bands)
|
||||
love.graphics, love.image = realGraphics, realImage
|
||||
|
||||
T.eq(painted, true, "the sky paints")
|
||||
T.eq(shaderUses, 1, "through one shader")
|
||||
@@ -1327,8 +1432,24 @@ T.eq(sent.cell[1], 7,
|
||||
.. "cells on the world's own grid")
|
||||
T.eq(sent.start[1], Sky.DITHER_START, "and where in a band the checker begins")
|
||||
T.eq(sent.alpha[1], 1, "and the tween strength")
|
||||
T.check(sent.bands[1] and sent.bands[1][1] ~= nil,
|
||||
"the palette goes as one array rather than a send per band")
|
||||
-- The palette goes as ONE ramp texture, not as eight uniform vectors. The width
|
||||
-- is the contract the shader divides by: it samples texel (i + 0.5) / count, so
|
||||
-- a ramp of any other width reads between two bands or off the end -- and off
|
||||
-- the end is exactly the black the Android bug painted.
|
||||
T.eq(sent.ramp[1], ramp, "the palette goes to the shader as its ramp texture")
|
||||
T.eq(ramp:getWidth(), #skyGrad.bands, "one texel per band, and no spare slots")
|
||||
T.eq(ramp:getHeight(), 1, "on a single row -- it is a palette, not a picture")
|
||||
T.eq(Sky._rampFor(skyGrad.bands), ramp,
|
||||
"and it is built once and held, not rebuilt per frame")
|
||||
-- every texel is a real colour: the failure being fixed here is a slot that
|
||||
-- was never written reading back as zero, which is black
|
||||
for i = 1, #skyGrad.bands do
|
||||
local texel = ramp.pixels[i - 1]
|
||||
T.check(texel ~= nil, "band " .. i .. " was written into the ramp")
|
||||
T.check(texel[1] == skyGrad.bands[i][1] and texel[2] == skyGrad.bands[i][2]
|
||||
and texel[3] == skyGrad.bands[i][3],
|
||||
"and it is that band's own colour, in the order the sky reads them")
|
||||
end
|
||||
|
||||
T.eq(depthCalls[1], "always/false", "the sky is drawn with depth writes OFF")
|
||||
T.eq(depthCalls[#depthCalls], "lequal/true",
|
||||
@@ -1826,6 +1947,351 @@ T.eq(e[1], 0, "the bands are full width")
|
||||
T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block")
|
||||
end
|
||||
|
||||
-- ------- and the box at the bottom is on the same glass
|
||||
--
|
||||
-- The HUDs got frosted panels because black glyphs on grass are not readable.
|
||||
-- The text box had the opposite problem and the same cause: an opaque white
|
||||
-- slab over the bottom third of the diorama, which was the field's own colour
|
||||
-- back when the field was white. The rects here are what the glass is cut to,
|
||||
-- and they are a READ-ONLY mirror of drawTextArea's own branches -- so this is
|
||||
-- where a future engine that moves a box says so.
|
||||
do
|
||||
local rects = Battles.textRects({ phase = "messages" })
|
||||
T.check(rects.box ~= nil, "there is always a box: drawTextArea opens with one")
|
||||
T.eq(rects.box[2] + rects.box[4], 144,
|
||||
"and it reaches the bottom of the frame")
|
||||
T.eq(rects.box[3], 160, "full width, like Font.drawBox(0, 12, 20, 6)")
|
||||
T.eq(rects.box[2], 96, "starting on the row the player's mon stands on")
|
||||
|
||||
-- the menu the player picks FIGHT on is that same box, so nothing is added
|
||||
T.eq(Battles.textRects({ phase = "menu" }).moves, nil,
|
||||
"the battle menu draws inside the box already there")
|
||||
|
||||
-- the two phases that put a SECOND box above it get a second panel, trimmed
|
||||
-- to the rows above the first: two panels over the same pixels would frost it
|
||||
-- twice and leave a step along the seam
|
||||
for _, phase in ipairs({ "moveSelect", "mimicSelect" }) do
|
||||
local more = Battles.textRects({ phase = phase })
|
||||
local extra = more.moves or more.mimic
|
||||
T.check(extra ~= nil, phase .. " raises a box of its own, and it is frosted")
|
||||
T.eq(extra[2] + extra[4], more.box[2],
|
||||
"which stops exactly where the box below it starts, so they never overlap")
|
||||
T.check(extra[1] >= 0 and extra[1] + extra[3] <= 160 and extra[2] >= 0,
|
||||
"and stays inside the frame the battle is drawn in")
|
||||
end
|
||||
|
||||
-- AskName blanks the field on purpose -- the nickname prompt is meant to sit
|
||||
-- on nothing -- so there is no box and no glass under one
|
||||
T.eq(next(Battles.textRects({ phase = "menu", blankForAskName = true })), nil,
|
||||
"the nickname prompt's blank field gets no glass")
|
||||
T.eq(next(Battles.textRects(nil)), nil, "and no battle, no boxes")
|
||||
end
|
||||
|
||||
-- ------- BACK: the player's own mon stays on the menu
|
||||
--
|
||||
-- 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 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 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 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")
|
||||
T.eq(backGame.save.options.modOptions.DRAMATIC_SHAPE.battles, true,
|
||||
"which is still where it always was")
|
||||
|
||||
-- and it means nothing at all with staged battles off: there is no staged
|
||||
-- shot for a back pic to be pinned in front of, and the engine's own battle
|
||||
-- screen already draws exactly this
|
||||
Battles.setting:setIndex(2, backGame)
|
||||
T.eq(Battles.backPinned(), false,
|
||||
"with 3D-BTL off the setting decides nothing, whatever it is left at")
|
||||
T.eq(Battles.backSetting:get(), true, "without being rewritten underneath")
|
||||
|
||||
-- ...so the row comes off the menu with it, on the same reasoning the mod's
|
||||
-- other absent rows come off: a row that no longer decides anything is worse
|
||||
-- than no row
|
||||
local offRows = Runtime.call("ui.options.rows", function(_, r) return r end,
|
||||
backGame, { { id = "tilt" } })
|
||||
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 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,
|
||||
backGame, { { id = "tilt" } })
|
||||
local onAt = {}
|
||||
for i, row in ipairs(onRows) do onAt[row.id] = i end
|
||||
T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row")
|
||||
T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
|
||||
"directly under the row it belongs to")
|
||||
|
||||
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 does that by flooding the background INWARD and filling whatever the
|
||||
-- background cannot reach. Started at the image border that finds nothing at
|
||||
-- all -- a Gen 1 figure is an open drawing, and its belly walks out between
|
||||
-- two legs and off the bottom of the frame -- so every mon was a stencil.
|
||||
--
|
||||
-- So the flood starts at the edges of the ARTWORK'S OWN BOX, and at three of
|
||||
-- them: left, right and top. The bottom is closed, because it is not a side
|
||||
-- the background is behind -- it is where the drawing was CUT. A pic is
|
||||
-- bottom-aligned in its slot with the margin all at the top, so a mon's lowest
|
||||
-- row is the last row it was given. Seed that cut and the background pours up
|
||||
-- inside the figure, which was the channel of world showing through the middle
|
||||
-- of a Clefairy.
|
||||
--
|
||||
-- Both halves are driven here, because getting one right at the other's
|
||||
-- expense is exactly what went wrong twice: an earlier rule that filled
|
||||
-- anything with ink to its left, right and above closed the channel and then
|
||||
-- filled the notch between a Rattata's ears and the gap between its body and
|
||||
-- its tail, which are background and have the drawing over them.
|
||||
do
|
||||
local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics")
|
||||
|
||||
-- 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: nothing under it but the edge the drawing stops at
|
||||
".#....#.",
|
||||
".#....#.",
|
||||
".#.##.#.", -- legs, with the gap between them running down to that edge
|
||||
".#.##.#.",
|
||||
})
|
||||
T.check(out ~= pic and opaque, "the pic comes back rebuilt: there was paper to put back")
|
||||
|
||||
T.check(opaque(2, 3) and opaque(3, 4) and opaque(5, 5),
|
||||
"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 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")
|
||||
-- and everything outside the artwork's own box is never touched, which is what
|
||||
-- keeps the silhouette cutting cleanly instead of standing in a white rectangle
|
||||
T.check(not opaque(0, 0) and not opaque(7, 0), "the corners stay transparent")
|
||||
T.check(not opaque(0, 4) and not opaque(7, 4), "and the columns beside it")
|
||||
|
||||
-- ------- and a pocket that drains out to the SIDE is background, however
|
||||
-- much of the drawing is over it
|
||||
--
|
||||
-- This is the regression the ray rule caused: ink to the left, ink to the
|
||||
-- right, ink above, and still plainly the gap between a body and a tail.
|
||||
-- Every transparent pixel in this one drains out through the notch at (2,3)
|
||||
-- and away to the left, so NONE of it is paper -- and a pic with no paper to
|
||||
-- put back is handed straight back, unrebuilt. That identity IS the assertion:
|
||||
-- under the ray rule this figure came back rebuilt with the pocket filled in.
|
||||
local gapOut, gapPic = fill({
|
||||
"..#####.", -- a brow, with the drawing over the pocket
|
||||
"..#...#.",
|
||||
"..#...#.",
|
||||
"....###.", -- which opens at the left, and drains out that way
|
||||
"..#####.",
|
||||
"..#####.",
|
||||
})
|
||||
T.eq(gapOut, gapPic,
|
||||
"a pocket the background can walk into from the side is not paper")
|
||||
|
||||
-- ------- and neither is a wide MOUTH along the bottom
|
||||
--
|
||||
-- Two things meet the underside of a figure. A DRAIN is where the drawing ran
|
||||
-- out -- a belly leaking through the inch between a body and a leg -- and is
|
||||
-- sealed. A MOUTH is the space between two legs, background that happens to be
|
||||
-- enclosed on three sides, and is left open so the world shows through a
|
||||
-- trainer's stride. Width tells them apart, and on this game's art the drains
|
||||
-- run 3-4 pixels and the mouths 10-17, so BattlePics.DRAIN sits at 6.
|
||||
--
|
||||
-- This figure's stride is eight wide, so nothing in it is paper and it comes
|
||||
-- back unrebuilt -- the identity again.
|
||||
local strideOut, stridePic = fill({
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..###........###..", -- a stride eight wide, past the drain cut
|
||||
"..###........###..",
|
||||
"..###........###..",
|
||||
})
|
||||
T.eq(strideOut, stridePic,
|
||||
"the world shows through the gap between a trainer's legs")
|
||||
|
||||
-- the same figure with a two-pixel gap IS a drain, and fills
|
||||
local drainOut, drainPic, drain = fill({
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..##############..",
|
||||
"..######..######..", -- a belly running out, not a stride
|
||||
"..######..######..",
|
||||
"..######..######..",
|
||||
})
|
||||
T.check(drainOut ~= drainPic and drain and drain(8, 4),
|
||||
"a narrow one is where the drawing ran out, and is paper")
|
||||
|
||||
-- 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
|
||||
|
||||
-- ------- 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
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
-- Driver: propose and photograph several battle arenas for ONE map.
|
||||
--
|
||||
-- data/battle_arenas.lua holds a single authored spot per area, chosen by
|
||||
-- arena_pick's nearest-to-the-middle search and then looked at. This is the
|
||||
-- other half of that job: when the shipped spot is up for review, it lays out
|
||||
-- the ALTERNATIVES -- every arena on the map both mons can be seen in, spread
|
||||
-- along the map so the shortlist is places rather than neighbours -- and
|
||||
-- stages a real battle in each so they can be compared by eye.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/route1_candidates CAND_MAP=ROUTE_1 CAND_N=5 \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/route1_candidates.lua love .
|
||||
--
|
||||
-- CAND_MAP is the map id (default ROUTE_1), CAND_N how many to photograph.
|
||||
-- One `CAND` line per shot, ready to paste into the data file, plus a PNG
|
||||
-- named for its corner and shape.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route1_candidates"
|
||||
local MAP = os.getenv("CAND_MAP") or "ROUTE_1"
|
||||
local WANT = tonumber(os.getenv("CAND_N") or "") or 5
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
|
||||
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
|
||||
game.save.player.name = "RED"
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local Arena = lib.require("BattleArena")
|
||||
local Battles = lib.require("OverworldBattle")
|
||||
|
||||
U.teleport(game, MAP, 1, 1, "down")
|
||||
local map = game.overworld.map
|
||||
U.log(("%s is %dx%d cells"):format(MAP, map.widthCells, map.heightCells))
|
||||
|
||||
-- ------- every arena the map can offer
|
||||
--
|
||||
-- BattleArena.search answers "the nearest one", which is the wrong question
|
||||
-- for a shortlist -- it returns one spot and hides the rest. So walk the
|
||||
-- same grid ourselves and keep them all, tagged with whether the pair would
|
||||
-- actually be SEEN there (Arena.clearance), because an obstructed spot is
|
||||
-- not a candidate no matter how good the ground looks.
|
||||
--
|
||||
-- The map's outermost cells are its CONNECTION BORDER -- the strip the
|
||||
-- neighbouring map is drawn into, walkable so the player can step across.
|
||||
-- Ground there passes every test and is still the wrong answer: a fight
|
||||
-- staged on it happens at the edge of the world with the border ring's tree
|
||||
-- wall at the mons' backs, and every spot in the strip looks like every
|
||||
-- other one. CAND_MARGIN keeps the shortlist on the route proper.
|
||||
local MARGIN = tonumber(os.getenv("CAND_MARGIN") or "") or 2
|
||||
local cands = {}
|
||||
for _, shape in ipairs(Arena.SHAPES) do
|
||||
for y = MARGIN, map.heightCells - shape.h - MARGIN do
|
||||
for x = MARGIN, map.widthCells - shape.w - MARGIN do
|
||||
local fits = true
|
||||
for cy = y, y + shape.h - 1 do
|
||||
for cx = x, x + shape.w - 1 do
|
||||
if not Arena.openCell(map, cx, cy, false) then fits = false break end
|
||||
end
|
||||
if not fits then break end
|
||||
end
|
||||
if fits then
|
||||
local a = Arena.at(x, y, shape.id)
|
||||
if a and Arena.clearance(map, a) then
|
||||
cands[#cands + 1] = { x = x, y = y, shape = shape.id,
|
||||
mx = x + (shape.w - 1) / 2,
|
||||
my = y + (shape.h - 1) / 2 }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
U.log(("%d clear arenas on %s"):format(#cands, MAP))
|
||||
if #cands == 0 then U.log("done -- nothing to propose") return end
|
||||
|
||||
for _, c in ipairs(cands) do
|
||||
U.log((" fit %d,%d %s"):format(c.x, c.y, c.shape))
|
||||
end
|
||||
|
||||
-- CAND_AT=x,y,shape;x,y,shape;... photographs an explicit shortlist instead
|
||||
-- of the spread one. The spread is a first pass over ground the clearance
|
||||
-- test approved, and that test measures terrain height along the sightline
|
||||
-- only -- it has no opinion on a hedge sitting in the apron row between the
|
||||
-- camera and the near mon, which is the failure that keeps turning up. So
|
||||
-- the loop is: spread, look, then re-shoot the survivors and the
|
||||
-- replacements by hand.
|
||||
local explicit = os.getenv("CAND_AT")
|
||||
if explicit and explicit ~= "" then
|
||||
local list = {}
|
||||
for spot in explicit:gmatch("[^;]+") do
|
||||
local x, y, s = spot:match("^%s*(%-?%d+)%s*,%s*(%-?%d+)%s*,%s*(%a+)%s*$")
|
||||
if x then
|
||||
list[#list + 1] = { x = tonumber(x), y = tonumber(y), shape = s }
|
||||
else
|
||||
U.log("BAD CAND_AT entry: " .. spot)
|
||||
end
|
||||
end
|
||||
cands = list
|
||||
WANT = #list
|
||||
U.log(("%d spots given explicitly"):format(#list))
|
||||
end
|
||||
|
||||
local function shapeOf(id)
|
||||
for _, s in ipairs(Arena.SHAPES) do if s.id == id then return s end end
|
||||
end
|
||||
for _, c in ipairs(cands) do
|
||||
local s = shapeOf(c.shape)
|
||||
c.mx = c.x + ((s and s.w or 1) - 1) / 2
|
||||
c.my = c.y + ((s and s.h or 1) - 1) / 2
|
||||
end
|
||||
|
||||
-- ------- thin them down to a shortlist that is actually a CHOICE
|
||||
--
|
||||
-- Adjacent corners are the same patch of ground shifted a cell, so a naive
|
||||
-- top-N is five photographs of one place, and pure farthest-point selection
|
||||
-- goes straight to the extremes -- which on a route means the ends, where
|
||||
-- the ground is emptiest and the shots are least distinguishable.
|
||||
--
|
||||
-- So: spread along the route's LONG AXIS, one pick per band, and within a
|
||||
-- band take the spot nearest the middle of the road. The road is measured
|
||||
-- rather than assumed -- the median cross-axis position of everywhere a
|
||||
-- fight fits IS the lane, on a map whose walkable ground is mostly lane.
|
||||
local horizontal = map.widthCells > map.heightCells
|
||||
local function along(c) return horizontal and c.mx or c.my end
|
||||
local function across(c) return horizontal and c.my or c.mx end
|
||||
|
||||
local xs = {}
|
||||
for _, c in ipairs(cands) do xs[#xs + 1] = across(c) end
|
||||
table.sort(xs)
|
||||
local road = xs[math.ceil(#xs / 2)]
|
||||
U.log(("road runs %s, centre of the lane is %s = %.1f")
|
||||
:format(horizontal and "east-west" or "north-south",
|
||||
horizontal and "y" or "x", road))
|
||||
|
||||
local lo, hi = math.huge, -math.huge
|
||||
for _, c in ipairs(cands) do
|
||||
lo, hi = math.min(lo, along(c)), math.max(hi, along(c))
|
||||
end
|
||||
|
||||
-- an explicit shortlist is already the answer; the spread below would only
|
||||
-- thin it, and its overlap rule would silently drop two spots deliberately
|
||||
-- asked for a cell apart
|
||||
local picked, taken = {}, {}
|
||||
for band = 1, (explicit and explicit ~= "") and 0 or WANT do
|
||||
-- band centres, not band edges: the first and last picks sit inside the
|
||||
-- route rather than on its two connection mouths
|
||||
local target = lo + (hi - lo) * (band - 0.5) / WANT
|
||||
local best, bestScore
|
||||
for _, c in ipairs(cands) do
|
||||
if not taken[c] then
|
||||
local da = along(c) - target
|
||||
local dr = across(c) - road
|
||||
-- distance from the band centre, plus a heavier penalty for being off
|
||||
-- the lane; wide arenas are worth a detour, being the shot this mode
|
||||
-- is framed for
|
||||
local score = da * da + 4 * dr * dr - (c.shape == "wide" and 100 or 0)
|
||||
if not bestScore or score < bestScore then best, bestScore = c, score end
|
||||
end
|
||||
end
|
||||
if best then
|
||||
picked[#picked + 1] = best
|
||||
-- everything overlapping the pick is off the table, so two bands whose
|
||||
-- best spots touch cannot return the same patch of ground twice
|
||||
for _, c in ipairs(cands) do
|
||||
if math.abs(along(c) - along(best)) < 3
|
||||
and math.abs(across(c) - across(best)) < 3 then
|
||||
taken[c] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if #picked == 0 then picked = cands end
|
||||
|
||||
-- north to south (or west to east), so the filenames read along the route
|
||||
table.sort(picked, function(a, b)
|
||||
if along(a) ~= along(b) then return along(a) < along(b) end
|
||||
return across(a) < across(b)
|
||||
end)
|
||||
|
||||
for i, c in ipairs(picked) do
|
||||
U.log(("CAND %d [%q] = { x = %d, y = %d, shape = %q },")
|
||||
:format(i, MAP, c.x, c.y, c.shape))
|
||||
-- forced through the authored-entry seam, so what gets staged is exactly
|
||||
-- this spot rather than whatever the search would pick from the player's
|
||||
-- cell
|
||||
Arena.setOverride(MAP, { x = c.x, y = c.y, shape = c.shape })
|
||||
local staged = Arena.find(map, 0, 0, false)
|
||||
if not staged then
|
||||
U.log(("SKIP %d -- override did not stage"):format(i))
|
||||
else
|
||||
game.overworld.player.cellX = staged.playerCell[1]
|
||||
game.overworld.player.cellY = staged.playerCell[2]
|
||||
U.wait(90) -- let the meshes land
|
||||
local battle = BattleState.newWild(game, "NIDORINO", 20)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
U.wait(70)
|
||||
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
|
||||
local got = Battles.arena()
|
||||
U.log(("SHOT %d staged at %s,%s"):format(i, tostring(got and got.x),
|
||||
tostring(got and got.y)))
|
||||
U.shot(game, ("%s/%d_%s_x%d_y%d_%s.png")
|
||||
:format(DIR, i, MAP:lower(), c.x, c.y, c.shape))
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(6)
|
||||
end
|
||||
end
|
||||
Arena.setOverride(MAP, nil)
|
||||
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Driver: prove the banded sky still paints from the ramp texture.
|
||||
--
|
||||
-- The bands used to reach the shader as `uniform vec3 bands[8]`, which on
|
||||
-- Android delivered only its first few slots and painted the rest of the sky
|
||||
-- black (see lib/Sky.lua, rampFor). They are a texture now. This checks the
|
||||
-- three things that swap could have broken, on the machine it CAN be checked
|
||||
-- on: that the shader still compiles, that the ramp is built and is one texel
|
||||
-- per band, and that what lands on screen is still a gradient that pales
|
||||
-- downward rather than a flat plate or a black one.
|
||||
--
|
||||
-- SHOT_DIR=.scratchpad/skyramp POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/sky_ramp_probe.lua love .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local DIR = os.getenv("SHOT_DIR") or ".scratchpad/skyramp"
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then
|
||||
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
||||
return
|
||||
end
|
||||
local Sky = lib.require("Sky")
|
||||
local DayNight = lib.require("DayNight")
|
||||
|
||||
-- which copy of the mod is actually live: only the ramp build has this
|
||||
U.log("live copy has _rampFor:", tostring(Sky._rampFor ~= nil))
|
||||
U.log("shader compiled:", tostring(Sky._getShader() ~= nil))
|
||||
|
||||
U.teleport(game, "PALLET_TOWN", 12, 10, "up")
|
||||
require("src.render.Pipelines").setLevel("voxel", 5)
|
||||
U.wait(150)
|
||||
|
||||
for _, phase in ipairs({ "day", "dusk", "night" }) do
|
||||
DayNight.setting:sync(phase)
|
||||
DayNight.update(0)
|
||||
U.wait(30)
|
||||
|
||||
local bands = Sky.bands()
|
||||
local ramp = Sky._rampFor and Sky._rampFor(bands)
|
||||
local w = ramp and ramp:getWidth() or -1
|
||||
U.log(("%s: %d bands, ramp %dx%d"):format(
|
||||
phase, #bands, w, ramp and ramp:getHeight() or -1))
|
||||
-- one texel per band is the whole contract: the shader divides by `count`
|
||||
-- and samples texel centres, so a ramp of any other width samples between
|
||||
-- bands or off the end
|
||||
if w ~= #bands then U.log("FAIL ramp width does not match band count") end
|
||||
-- and the ramp is the ramp: the same table gives the same image back
|
||||
if ramp ~= Sky._rampFor(bands) then U.log("FAIL ramp rebuilt per call") end
|
||||
|
||||
U.shot(game, ("%s/%s.png"):format(DIR, phase))
|
||||
end
|
||||
|
||||
DayNight.setting:sync("day")
|
||||
U.log("done -- " .. DIR)
|
||||
end
|
||||
@@ -0,0 +1,246 @@
|
||||
-- Driver: the A/B performance benchmark.
|
||||
--
|
||||
-- One scripted, deterministic session that exercises the three things the
|
||||
-- mod is slow at, and writes the numbers to a JSON file so two runs can be
|
||||
-- diffed:
|
||||
--
|
||||
-- 1. LOADING IN -- engaging the mode from flat: shader compiles,
|
||||
-- canvas allocations, the first map's mesh, its
|
||||
-- atlas bakes and its glass mask, all at once.
|
||||
-- 2. A NEW AREA -- walking Pallet -> Route 1 -> Viridian with cold
|
||||
-- caches, then walking the SAME route again with
|
||||
-- them warm. The gap between the two is the
|
||||
-- complaint; closing it is the fix.
|
||||
-- 3. A LOW CAMERA -- standing still at each pitch rung. The 75 degree
|
||||
-- rung puts the horizon in frame, which triples the
|
||||
-- sun frustum and hands the sky its disc to draw.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_bench.lua \
|
||||
-- DS_PERF=1 BENCH_TAG=baseline lovec.exe .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- DS_PERF must be set, or lib/Perf.lua stays dark and measures nothing
|
||||
-- BENCH_TAG output name, ds_bench/<tag>.json (default "run")
|
||||
-- BENCH_HOLD frames to walk per leg (default 900)
|
||||
--
|
||||
-- THREE THINGS THIS RUN CONTROLS FOR, because a benchmark that does not is
|
||||
-- measuring the weather:
|
||||
--
|
||||
-- * VSYNC OFF. With it on every frame costs exactly one refresh interval
|
||||
-- and the whole exercise reads as 16.7ms flat, saving or no saving.
|
||||
-- * THE CLOCK PINNED to day. The day/night cycle changes the sky, the sun
|
||||
-- angle, the shadow frustum and whether windows are lit -- so an
|
||||
-- unpinned run compares two different scenes.
|
||||
-- * ENCOUNTERS OFF. A wild battle mid-walk derails the route and charges
|
||||
-- its frames to whichever map the script thought it was on. Stubbed on
|
||||
-- the state class for this process only; nothing is written to disk.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local TAG = os.getenv("BENCH_TAG") or "run"
|
||||
-- Route 1 is 36 cells tall and a walk step is 16 frames, so a leg that
|
||||
-- means to reach Viridian needs about 1200 -- short of that the "new
|
||||
-- area" the benchmark is named for never gets entered.
|
||||
local HOLD = math.floor(tonumber(os.getenv("BENCH_HOLD")) or 1300)
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[bench] DRAMATIC_SHAPE mod not loaded -- nothing to measure")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local Perf = V.require("Perf")
|
||||
if not Perf.enabled then
|
||||
print("[bench] DS_PERF is not set -- run with DS_PERF=1 or this measures nothing")
|
||||
return
|
||||
end
|
||||
local loadBytes = V.loadBytes
|
||||
local Structures = V.require("Structures")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Buildings = V.require("Buildings")
|
||||
local DayNight = V.require("DayNight")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
|
||||
-- module internals worth naming that the mod does not time itself
|
||||
Perf.wrap(Structures, "forMap", "Structures.forMap")
|
||||
Perf.wrap(Buildings, "build", "Buildings.build")
|
||||
Perf.wrap(ChunkMesher, "pump", "ChunkMesher.pump")
|
||||
Perf.wrap(VoxelScene, "render", "VoxelScene.render")
|
||||
|
||||
if love.window and love.window.setVSync then
|
||||
pcall(love.window.setVSync, 0)
|
||||
end
|
||||
DayNight.setting:sync("day")
|
||||
OverworldState.rollEncounter = function() return nil end
|
||||
|
||||
-- ---- the run ------------------------------------------------------
|
||||
|
||||
local function seg(name)
|
||||
Perf.setSegment(name)
|
||||
-- the frame that STRADDLES a segment boundary belongs to neither: it
|
||||
-- carries the teleport, the level change or the report print that
|
||||
-- opened it, and charging that to the new segment libels it
|
||||
Perf.resync()
|
||||
end
|
||||
|
||||
local function settle(frames)
|
||||
Perf.setSegment(nil)
|
||||
U.wait(frames or 60)
|
||||
Perf.resync()
|
||||
end
|
||||
|
||||
-- Hold a direction, attributing each frame to the map the player is
|
||||
-- STANDING ON as it lands. Crossing a seam mid-leg is the whole point of
|
||||
-- the walk, so the segment has to follow the player rather than the
|
||||
-- script's idea of where they are.
|
||||
local function walk(dir, frames, prefix)
|
||||
local last = nil
|
||||
for _ = 1, frames do
|
||||
local o = game.overworld
|
||||
local id = o and o.map and o.map.id
|
||||
if id ~= last then
|
||||
last = id
|
||||
seg(prefix .. ":" .. tostring(id))
|
||||
print(("[bench] %s entered %s at frame %d"):format(prefix, tostring(id),
|
||||
U.frame()))
|
||||
end
|
||||
-- press and RELEASE each frame, the way tests/voxel_perf_probe's seam
|
||||
-- crossing does: a direction left held accumulates in pressQueue and
|
||||
-- the walk stalls where a single tap would have stepped
|
||||
table.insert(game.input.pressQueue, dir)
|
||||
game.input.state[dir] = true
|
||||
coroutine.yield()
|
||||
game.input.state[dir] = false
|
||||
end
|
||||
local o = game.overworld
|
||||
print(("[bench] %s ended on %s at cell (%d,%d)"):format(
|
||||
prefix, tostring(o and o.map and o.map.id),
|
||||
o and o.player and o.player.cellX or -1,
|
||||
o and o.player and o.player.cellY or -1))
|
||||
Perf.setSegment(nil)
|
||||
end
|
||||
|
||||
print("[bench] tag=" .. TAG .. " loadBytes=" .. tostring(loadBytes))
|
||||
|
||||
-- ORDER MATTERS. The cold walk has to be the first time this session
|
||||
-- draws Route 1 and Viridian, so everything before it stays in Pallet
|
||||
-- Town -- a map whose caches the walk does not depend on. Measuring a
|
||||
-- "first entry" into a map an earlier segment already warmed is the one
|
||||
-- way to make this whole benchmark lie.
|
||||
|
||||
-- 1. the flat reference: the game with this mod present but not
|
||||
-- drawing. Every later number is only interesting against this one.
|
||||
U.teleport(game, "PALLET_TOWN", 10, 8, "down")
|
||||
Pipelines.setLevel("voxel", 0)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle(90)
|
||||
seg("flat")
|
||||
U.wait(180)
|
||||
|
||||
-- 2. loading in: FULL is what a player picks first and it is the most
|
||||
-- expensive configuration there is (tilt-shift to maximum, 3D
|
||||
-- battles on). Measured from the frame the level changes, so it
|
||||
-- carries the shader compiles, the canvas allocations, the first
|
||||
-- mesh, the first atlas bake and the first glass scan together.
|
||||
settle(60)
|
||||
seg("engage.full")
|
||||
Pipelines.setLevel("voxel", 1) -- FULL
|
||||
U.wait(240)
|
||||
Perf.setSegment(nil)
|
||||
|
||||
-- 3. the low camera. 75 degrees puts the horizon in frame; the rungs
|
||||
-- below it are the control. Standing still, so what is measured is
|
||||
-- the frame's own cost and not the walk's mesh streaming.
|
||||
for _, rung in ipairs({ 2, 3, 4, 5 }) do -- 15, 35, 50, 75 degrees
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
settle(60) -- let the tween finish
|
||||
seg("pitch:" .. tostring(V.require("VoxelState").ANGLE_LABELS[rung + 1]))
|
||||
U.wait(180)
|
||||
Perf.setSegment(nil)
|
||||
end
|
||||
|
||||
-- 3b. the same low camera at DUSK, which is where the sky costs most:
|
||||
-- the horizon is in frame, so the banded region is at its tallest,
|
||||
-- and the sun is low enough to be in it -- the disc only draws at
|
||||
-- all when it is above the horizon point, so a midday run never
|
||||
-- touches that code and would report it as free.
|
||||
for _, when in ipairs({ "dusk", "night" }) do
|
||||
DayNight.setting:sync(when)
|
||||
settle(60)
|
||||
seg("pitch:75:" .. when)
|
||||
U.wait(180)
|
||||
Perf.setSegment(nil)
|
||||
end
|
||||
DayNight.setting:sync("day")
|
||||
settle(30)
|
||||
|
||||
-- 4/5. arriving somewhere new, at the rung that hurts.
|
||||
--
|
||||
-- Arrival is measured by LOADING each map rather than by walking into
|
||||
-- it. Walking would be more lifelike, but Route 1's ledges make a held
|
||||
-- direction stall against geometry, so a fixed frame count buys a
|
||||
-- different amount of travel on every run -- and a benchmark whose
|
||||
-- route drifts cannot compare two runs at all. A load is the same
|
||||
-- arrival stripped of the travel: the map swaps, and the next frames
|
||||
-- pay for its mesh, its structure analysis, its atlas bake and its
|
||||
-- glass mask exactly as they do behind a door fade.
|
||||
--
|
||||
-- Then the identical list a second time. Every cost in the gap between
|
||||
-- the two passes is a cache that was cold, and that gap IS the
|
||||
-- complaint.
|
||||
Pipelines.setLevel("voxel", 5) -- 75 degrees
|
||||
local TOUR = { "ROUTE_1", "VIRIDIAN_CITY", "ROUTE_2", "ROUTE_22",
|
||||
"VIRIDIAN_FOREST", "PEWTER_CITY" }
|
||||
local DWELL = math.max(60, math.floor(HOLD / #TOUR))
|
||||
|
||||
-- Stand in the middle of each map, derived from its own def rather than
|
||||
-- written down: a hardcoded cell that falls outside a map teleports the
|
||||
-- player nowhere and the segment silently measures the previous map.
|
||||
local function centreOf(id)
|
||||
local def = game.data.maps and game.data.maps[id]
|
||||
if not def then return nil end
|
||||
return math.floor(def.width), math.floor(def.height)
|
||||
end
|
||||
|
||||
local function tour(prefix)
|
||||
for _, id in ipairs(TOUR) do
|
||||
local cx, cy = centreOf(id)
|
||||
if cx then
|
||||
U.teleport(game, id, cx, cy, "up")
|
||||
-- the segment opens on the frame AFTER the teleport, so the load
|
||||
-- itself is not charged to the arrival it caused
|
||||
seg(prefix .. ":" .. id)
|
||||
U.wait(DWELL)
|
||||
Perf.setSegment(nil)
|
||||
else
|
||||
print("[bench] skipping unknown map " .. id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
tour("first")
|
||||
tour("revisit")
|
||||
|
||||
-- 6. streaming while walking: the one crossing that is reliably
|
||||
-- walkable (tests/voxel_perf_probe crosses the same seam) -- Route 1
|
||||
-- south into Pallet, which pulls a neighbour's meshes in mid-stride.
|
||||
U.teleport(game, "ROUTE_1", 10, 34, "down")
|
||||
settle(120)
|
||||
walk("down", 240, "walk")
|
||||
|
||||
-- ---- the report ---------------------------------------------------
|
||||
|
||||
Perf.setSegment(nil)
|
||||
Perf.printReport("bench " .. TAG)
|
||||
Perf.write(TAG, {
|
||||
tag = TAG,
|
||||
loadBytes = loadBytes,
|
||||
hold = HOLD,
|
||||
texturememory = Perf.texturememory or 0,
|
||||
canvases = Perf.canvases or 0,
|
||||
images = Perf.images or 0,
|
||||
})
|
||||
print("[bench] done")
|
||||
end
|
||||
@@ -0,0 +1,346 @@
|
||||
-- Driver: the pixel-identity gate for performance work.
|
||||
--
|
||||
-- Every optimization in this mod's performance pass claims the frame comes
|
||||
-- out the same. This driver is what makes that claim checkable rather than
|
||||
-- asserted: it renders a fixed set of scenes -- several maps, indoors and
|
||||
-- out, at every camera rung, in every display mode -- and writes one PNG
|
||||
-- per scene. Run it before a change and after it, hash the two directories,
|
||||
-- and any file whose hash moved is a scene the change altered.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_shots_ab.lua \
|
||||
-- SHOT_DIR=<dir> AB_TAG=before lovec.exe .
|
||||
--
|
||||
-- knobs (env):
|
||||
-- SHOT_DIR output directory (created if missing) (default "shots/ab")
|
||||
-- AB_TAG subdirectory under SHOT_DIR (default "before")
|
||||
-- AB_MODES display modes to sweep, comma list (default all four)
|
||||
--
|
||||
-- DETERMINISM is the whole game here, because a shot that differs for a
|
||||
-- reason other than the change under test makes the gate useless:
|
||||
--
|
||||
-- * the day/night clock is PINNED (an unpinned sky is a different sky
|
||||
-- every second, and it drives the sun angle and the shadow frustum);
|
||||
-- * the animated tile slots ride the engine's 60Hz counter, so every
|
||||
-- scene is reached after the SAME number of frames from the same
|
||||
-- starting state, and the water is at the same point in its roll;
|
||||
-- * levels are set through Pipelines.setLevel, never the hotkey, so the
|
||||
-- run cannot write the player's options;
|
||||
-- * encounters are stubbed off -- a wild battle would replace the scene
|
||||
-- the shot is named for.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local Pipelines = require("src.render.Pipelines")
|
||||
local OverworldState = require("src.world.OverworldController")
|
||||
|
||||
local ROOT = (os.getenv("SHOT_DIR") or "shots/ab")
|
||||
.. "/" .. (os.getenv("AB_TAG") or "before")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[ab] DRAMATIC_SHAPE mod not loaded -- nothing to compare")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
|
||||
OverworldState.rollEncounter = function() return nil end
|
||||
|
||||
-- NPCs roam on a random timer (src/world/NPC.lua), and LOVE's RNG is
|
||||
-- seeded differently every launch -- so two runs of this driver put the
|
||||
-- same townsfolk in different places and every shot with a person in it
|
||||
-- differs for a reason that has nothing to do with the change under
|
||||
-- test. Freeze them: `frozen` is the flag the NPC's own update already
|
||||
-- honours, and an NPC mid-step still finishes it, so the settle below
|
||||
-- lands on a still scene. They are still POSED and still drawn, so the
|
||||
-- billboard, its lean and its shadow are all still under test.
|
||||
local NPC = require("src.world.NPC")
|
||||
if not NPC.dramaticShapeAbFreeze then
|
||||
local inner = NPC.update
|
||||
function NPC:update(...)
|
||||
self.frozen = true
|
||||
return inner(self, ...)
|
||||
end
|
||||
NPC.dramaticShapeAbFreeze = true
|
||||
end
|
||||
pcall(love.math.setRandomSeed, 20260730)
|
||||
|
||||
-- Freeze the tile-animation clock, on BOTH routes to it.
|
||||
--
|
||||
-- TileRenderer.tick consumes WALL-CLOCK dt (so the water rolls at the
|
||||
-- same speed on a 60Hz and a 144Hz panel), which means the step a shot
|
||||
-- catches depends on how fast the machine got there rather than on
|
||||
-- anything the run did. Stubbing tick pins the counter the flat tile
|
||||
-- layer reads.
|
||||
--
|
||||
-- The mod reads the SAME counter but through its own chain
|
||||
-- (TerrainAtlas.animFrame): TileRenderer.animFrame if the build exports
|
||||
-- one, else the local off tick's upvalues, else -- and this is the trap
|
||||
-- -- wall-clock time. A stubbed tick has no upvalues, so stubbing it
|
||||
-- ALONE knocks the mod onto the wall-clock fallback and makes the
|
||||
-- flowers drift between two otherwise identical runs. Exporting a
|
||||
-- constant animFrame takes the first branch and pins that route too.
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
|
||||
-- The scenes. Chosen for what each one can BREAK, not for looks:
|
||||
-- ROUTE_1 open ground, grass billboards, a long view north --
|
||||
-- the case a shadow-frustum or culling change moves
|
||||
-- VIRIDIAN_CITY buildings, window panes (the glass mask), signs
|
||||
-- PALLET_TOWN the seam with Route 1: neighbour meshes and ring
|
||||
-- VIRIDIAN_FOREST dense round-tree hulls, heavy occlusion
|
||||
-- REDS_HOUSE_1F indoors: no sky, no sun, authored figures
|
||||
-- PEWTER_CITY a second tileset with its own atlas bake
|
||||
local SCENES = {
|
||||
{ id = "ROUTE_1", x = 10, y = 20, face = "up", label = "open" },
|
||||
{ id = "VIRIDIAN_CITY", x = 20, y = 26, face = "up", label = "town" },
|
||||
{ id = "PALLET_TOWN", x = 10, y = 2, face = "up", label = "seam" },
|
||||
{ id = "VIRIDIAN_FOREST", x = 16, y = 24, face = "up", label = "trees" },
|
||||
{ id = "REDS_HOUSE_1F", x = 4, y = 4, face = "up", label = "indoor" },
|
||||
{ id = "PEWTER_CITY", x = 16, y = 20, face = "down", label = "pewter" },
|
||||
}
|
||||
|
||||
-- OFF is in the list deliberately: a change that speeds the 3D path up
|
||||
-- must not have touched the flat one either. Then the three camera
|
||||
-- rungs, 75 last because it is the low camera this performance work is
|
||||
-- aimed at.
|
||||
--
|
||||
-- FULL (rung 1) is NOT here, and cannot usefully be. It is a settings
|
||||
-- PRESET, not a render path: it sets tilt-shift to maximum, flattens the
|
||||
-- world curve, fits the zoom, switches 3D battles on -- and pins DAYTIME
|
||||
-- to SYNC and HOLDS it there (main.lua's applyFull / DayNight.forceSync),
|
||||
-- which overrides this driver's pinned clock and makes every shot after
|
||||
-- it depend on the wall clock. It also persists all of that, so one run's
|
||||
-- FULL changes the options the NEXT run starts from. What FULL renders is
|
||||
-- 35 degrees with the blur at 3, which rung 3 plus AB_TSHIFT=3 covers
|
||||
-- exactly.
|
||||
local RUNGS = {}
|
||||
for n in (os.getenv("AB_RUNGS") or "0,2,3,5"):gmatch("%d+") do
|
||||
RUNGS[#RUNGS + 1] = tonumber(n)
|
||||
end
|
||||
|
||||
local TSHIFT = math.floor(tonumber(os.getenv("AB_TSHIFT")) or 0)
|
||||
|
||||
-- AB_SHADOW=0 renders with the sun pass's contribution turned off
|
||||
-- (SHADOW_ALPHA 0 short-circuits the lookup in the scene shader). A
|
||||
-- bisection tool: when a set of shots will not reproduce, this says
|
||||
-- whether what is moving is in the shadow map or somewhere else.
|
||||
if os.getenv("AB_SHADOW") == "0" then
|
||||
V.require("Voxel3D").SHADOW_ALPHA = 0
|
||||
end
|
||||
|
||||
-- PaletteFX.MODES, minus the inverted novelties: `ogred` and `classic`
|
||||
-- are the SGB paths this mod bakes an atlas for, `gbc` is the shared
|
||||
-- default, and `redpp` is the one that rebakes an atlas PER MAP -- four
|
||||
-- genuinely different routes through TerrainAtlas.
|
||||
local MODES = {}
|
||||
for m in (os.getenv("AB_MODES") or "ogred,classic,gbc,redpp"):gmatch("[^,]+") do
|
||||
MODES[#MODES + 1] = m
|
||||
end
|
||||
|
||||
-- Two times of day, because half the shader only runs in one of them:
|
||||
-- the window lamps, the moon disc and the night tint are all dark-only,
|
||||
-- and the glint sweep and the sun disc are day-only.
|
||||
local TIMES = { "day", "night" }
|
||||
|
||||
local shots, missed = 0, 0
|
||||
|
||||
-- U.shot's own mkdir is the POSIX one, which cmd.exe does not
|
||||
-- understand, and a missing directory makes every capture vanish
|
||||
-- silently. Try both spellings once, up front.
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
-- A capture that always costs the SAME number of frames.
|
||||
--
|
||||
-- U.shot spins up to 120 frames waiting for the capture to land, which
|
||||
-- is right for a screenshot and wrong for this: the animated tile slots
|
||||
-- (water rolling, flowers opening) ride the engine's frames-since-boot
|
||||
-- counter, so a scene reached after a different number of frames renders
|
||||
-- its water at a different point in the roll and the shot differs for a
|
||||
-- reason no change caused. A driver resume and a rendered frame are 1:1
|
||||
-- here, so the capture lands on the next draw and a fixed budget is both
|
||||
-- enough and constant.
|
||||
local CAPTURE_FRAMES = 4
|
||||
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local ShadowMap = V.require("ShadowMap")
|
||||
|
||||
-- Wait for the scene to actually BE the scene the shot is named for.
|
||||
-- Two things are still in motion after a teleport, and both are timed in
|
||||
-- wall-clock seconds rather than frames, so "wait N frames" settles them
|
||||
-- by a different amount on every machine and every run:
|
||||
--
|
||||
-- the build queue -- meshes are built on a per-frame time budget, so a
|
||||
-- slower run captures a half-built neighbour;
|
||||
-- the camera tween -- Voxel.t runs on dt over TWEEN_TIME, so a shot
|
||||
-- taken before it lands is at some arbitrary intermediate pitch.
|
||||
--
|
||||
-- Both are waited on by their own completion flag, then a short fixed
|
||||
-- settle. The variable wait is harmless now that the animation clock is
|
||||
-- frozen above -- otherwise it would move the water instead.
|
||||
-- and the CAMERA, which is the subtle one. It eases toward the player
|
||||
-- over wall-clock dt, so after a fixed wait it has covered a distance
|
||||
-- that depends on how fast the machine ran -- and the sun pass is only
|
||||
-- redrawn when the camera crosses a quarter-world-pixel (VoxelScene's
|
||||
-- shadow signature), so a frame caught mid-ease carries a shadow map
|
||||
-- fitted for a slightly different camera than the one it is drawn with.
|
||||
-- That is a real and deliberate tolerance in the mod, but it makes the
|
||||
-- gate compare two arbitrary points inside it. Waiting for the camera
|
||||
-- to stop moving entirely puts every shot at the same steady state.
|
||||
local function cameraStill()
|
||||
local o = game.overworld
|
||||
local c = o and o.camera
|
||||
if not c then return true end
|
||||
local lx, ly, held = nil, nil, 0
|
||||
for _ = 1, 300 do
|
||||
if c.x == lx and c.y == ly then
|
||||
held = held + 1
|
||||
if held >= 10 then return true end
|
||||
else
|
||||
held = 0
|
||||
lx, ly = c.x, c.y
|
||||
end
|
||||
U.wait(1)
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function settleBuild()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
cameraStill()
|
||||
-- and then force one final sun pass at the settled camera. The map is
|
||||
-- only redrawn when the camera crosses a quarter world pixel, so a
|
||||
-- still camera holds whatever was drawn at the moment it last did --
|
||||
-- correct to within that tolerance, but fitted from a position that
|
||||
-- depends on where the easing happened to be, which differs by a few
|
||||
-- hundredths of a pixel between runs and moves every shadow edge by a
|
||||
-- shade or two. Forgetting the stamp redraws from the state the shot
|
||||
-- is actually taken in, and two runs then agree exactly.
|
||||
-- guarded so this driver can also be pointed at a build that predates
|
||||
-- the seam, which is exactly what capturing a "before" reference means
|
||||
if ShadowMap.forget then ShadowMap.forget() end
|
||||
U.wait(20)
|
||||
end
|
||||
|
||||
-- AB_TRACE=1 prints the state each shot was taken in. When two runs of
|
||||
-- this driver disagree, this is what says which input moved.
|
||||
local TRACE = os.getenv("AB_TRACE") == "1"
|
||||
|
||||
local function trace(name)
|
||||
if not TRACE then return end
|
||||
local o = game.overworld
|
||||
local e = ShadowMap.extent or {}
|
||||
print(("[ab] %-28s cam=(%.4f,%.4f) player=(%.3f,%.3f) res=%d extent=(%.3f,%.3f,%.3f) KX=%.6f KZ=%.6f angle=%.6f pend=%d")
|
||||
:format(name,
|
||||
o and o.camera and o.camera.x or -1,
|
||||
o and o.camera and o.camera.y or -1,
|
||||
o and o.player and o.player.px or -1,
|
||||
o and o.player and o.player.py or -1,
|
||||
ShadowMap.res or 0,
|
||||
e[1] or 0, e[2] or 0, e[3] or 0,
|
||||
ShadowMap.KX or 0, ShadowMap.KZ or 0,
|
||||
Voxel.angle or 0,
|
||||
ChunkMesher.pending()))
|
||||
end
|
||||
|
||||
-- AB_SHADOWDUMP=1 also writes the packed depth map itself, into the save
|
||||
-- directory. When the scene differs but every input to the sun pass is
|
||||
-- identical, the map is the only place left to look.
|
||||
local DUMP = os.getenv("AB_SHADOWDUMP") == "1"
|
||||
|
||||
local function dumpShadow(name)
|
||||
if not DUMP then return end
|
||||
local tex = ShadowMap.texture()
|
||||
if not (tex and tex.newImageData) then return end
|
||||
pcall(function()
|
||||
love.filesystem.createDirectory("ab_shadow")
|
||||
tex:newImageData():encode("png", "ab_shadow/" .. name .. ".png")
|
||||
end)
|
||||
end
|
||||
|
||||
local function capture(name)
|
||||
trace(name)
|
||||
dumpShadow(name)
|
||||
local path = ("%s/%s.png"):format(ROOT, name)
|
||||
game.capturePath = path
|
||||
U.wait(CAPTURE_FRAMES)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
shots = shots + 1
|
||||
else
|
||||
missed = missed + 1
|
||||
print("[ab] capture did not reach disk: " .. path)
|
||||
end
|
||||
end
|
||||
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local function setMode(mode)
|
||||
local known = false
|
||||
for _, m in ipairs(PaletteFX.MODES) do
|
||||
if m == mode then known = true break end
|
||||
end
|
||||
if not known then return false end
|
||||
return (pcall(PaletteFX.setMode, mode))
|
||||
end
|
||||
|
||||
-- A fixed zoom, so the view size every shot is composed at is the same
|
||||
-- one. Zoom is persisted, so without this a session that ever ran the
|
||||
-- FULL preset (which fits the zoom to the window) leaves a different
|
||||
-- view size behind for every later run.
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
for _, mode in ipairs(MODES) do
|
||||
if setMode(mode) then
|
||||
for _, when in ipairs(TIMES) do
|
||||
DayNight.setting:sync(when)
|
||||
for _, s in ipairs(SCENES) do
|
||||
for _, rung in ipairs(RUNGS) do
|
||||
U.teleport(game, s.id, s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", rung)
|
||||
-- pinned AFTER the voxel rung, because FULL is a preset that
|
||||
-- reaches over and sets this row itself (main.lua's applyFull)
|
||||
-- and persists it -- so without this, one run's FULL leaks a
|
||||
-- blur level into the NEXT run's options.lua and every shot
|
||||
-- differs for a reason no code change caused. Sharp by
|
||||
-- default: a gaussian smears a one-pixel geometry difference
|
||||
-- across the whole frame, which is exactly what a gate meant
|
||||
-- to localise differences must not do. AB_TSHIFT=3 runs the
|
||||
-- blur path deliberately.
|
||||
Pipelines.setLevel("tiltshift", TSHIFT)
|
||||
-- Wait for the build queue to drain rather than for a fixed
|
||||
-- number of frames. Meshes are built on a per-frame time
|
||||
-- budget, so how much of a map exists after N frames is a
|
||||
-- property of the MACHINE -- a slower run captures a
|
||||
-- half-built neighbour and the shot differs for no reason the
|
||||
-- change caused. Draining first, then settling a fixed 40
|
||||
-- frames for the camera tween, makes the scene the same
|
||||
-- scene everywhere. (Safe now that the animation clock above
|
||||
-- is frozen: a variable wait no longer moves the water.)
|
||||
settleBuild()
|
||||
capture(("%s_%s_%s_v%d"):format(mode, when, s.label, rung))
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
print("[ab] display mode " .. mode .. " unavailable, skipped")
|
||||
end
|
||||
end
|
||||
|
||||
print(("[ab] %d shots into %s (%d failed to reach disk)")
|
||||
:format(shots, ROOT, missed))
|
||||
end
|
||||
Reference in New Issue
Block a user