Merge pull request #51 from DramaticShape/back-sprite-transparency-fix

Back sprite transparency fix
This commit is contained in:
DramaticShape
2026-08-01 16:44:57 -04:00
committed by GitHub
18 changed files with 1709 additions and 74 deletions
+157
View File
@@ -1,5 +1,66 @@
# Changelog
## 1.4.1
### Added
- **Furniture through the building pipeline.** The band-table voxelizer
that models whole buildings from their own drawings (lib/Buildings.lua)
now reads interior furniture too, and the first four drawings are in:
- **F01, the starter-ball table in Oak's lab** -- the tabletop's 16
drawn rows lay flat over a 16px plot (1:1, the first template that
never cycles), the black/#555/black edge band folds into the slab's
own rim, and the base extrudes with its corner feet. Six voxels
tall, exactly the drawn elevation.
- **F03, the empty north table beside it** -- the same band table on a
grid two tiles narrower.
- **F02, the lab's computer desk** -- the first DESK-SET template: the
drawing segments into PARTS, each classified by the surface it
depicts. The monitor and the computer tower stand upright on the
desk wearing their own drawn tops as lids; the keyboards and the
mouse lie flat in front of them; the sheet of paper on the right
lies flat across the desk. Flat parts keep the drawing's own rule --
drawn row IS depth row, the same 1:1 the tabletop is drawn with --
so an object's height on the drawing is its position on the desk.
The Hall of Fame's recording machine is this drawing tile for tile
on the GYM atlas, and models identically for free.
- **F04, the Center PC** -- the desk-set read again: a Mac-style unit
with its screen and drive slot in relief, standing at the back of a
low white-topped desk with its keyboard lying at the front edge.
Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART
tileset shares the atlas.
Two measurements had to stop being assumptions for furniture to fit
the pipeline: the GROUND LINE is now read off the drawing (a building
ends on the black threshold row it stands on; a table's legs stop two
rows short of theirs, and extruding against the grid floated them in
the air), and a template may name its PLOT (`depth`) when the matched
grid runs past it onto the walkable floor the legs merely stand on.
Both are identities for every existing building.
- **The Center couch has a backrest.** The couch is drawn from above --
back-and-arm strip down the west side, cushions and seams on the east
-- and rendered as one seat-high box. The new `backrest` class raises
the drawn back strip to 12px over the 8px seat, in every Center and
the Celadon Hotel. The man sitting on it keeps his seat: the figure
anchor now scans under his card for the tallest authored upright (his
cushion) instead of reading the corner tile, which is the backrest
now.
### Changed
- **Sprites ride at the height the art actually stands.** Class heights
can now be overridden per tileset (a tileset entry's `heights`), and
DOJO's lab tables use it: they are drawn 6px tall, not the default
table's 12, so the starter balls sit exactly on the modelled tabletop
-- and the volume-built north tables drop to the same height, keeping
every table in the room level.
- The Center PC's old rendering -- a 12px table box with the unit as a
flat standee on it -- retires wherever the F04 template stamps; the
pins stay only as the degradation path when the shape profile is
absent.
## 1.4.0
### Added
@@ -116,6 +177,72 @@
ever lands half-way between two pixels and changing a wavelength moves the
speed with it.
- **AA, a new options row: OFF / 2X / 4X.** Everything else in this game is
flat art blitted at whole pixels. This mode's world is real geometry seen
through a perspective camera, and a polygon edge that lands at an angle
across the pixel grid is the one place where a hard stair-step is not a
stylistic choice -- a roof ridge, a ledge lip, a tree's silhouette against
the sky, the leaning card of a character. At the shallow rungs, where the
diorama reads most like a photograph of a model, they crawl as the camera
drifts.
The row is SUPERSAMPLING: the whole pass renders into a canvas larger than
the window and is folded back down at the end. The ladder is samples per
display pixel, so 2X is a canvas root-two wider and taller and 4X one
exactly twice the size -- an honest 2x2 box.
Two alternatives were tried against what this pass already is, and both
lost:
- **MSAA** would have taken the water with it. The reflections read the
frame's own depth buffer as a texture, and a multisampled depth
attachment is not something a fragment shader in this dialect can sample.
The row would have quietly switched the WATER row off.
- **An edge filter** (FXAA and its relatives) works from the finished
colour alone, so it would be guessing where the edges are out of one
sample per pixel -- inventing detail it never rendered, and unable to
tell a geometry edge from the boundary between two texels of a tileset.
Rendering larger has neither problem, and nothing in the frame had to be
taught about it: every pass already measures itself in the canvas it was
handed, so the sky's dither, the water's ray march, the shadow lookups and
the camera itself come out the same picture at a higher sample rate. It
antialiases the geometry, the alpha-cut outline of a sprite card, the
wireframe and the reflections at once, because none of them know it is
happening.
And it softens the ARTWORK with them, which is worth saying plainly. A
tileset texel out here is not a screen pixel, it is a quad in a perspective
view, and its boundary crosses the pixel grid at the same arbitrary angle a
roof ridge does -- so the fold averages across it exactly as it averages
across the ridge. That is what an honest extra sample says about that
pixel, and it is also the trade the row is: the diorama comes out smoother,
not sharper. Which is why it is a row and not something that is simply on.
Two things are quoted in DISPLAY pixels rather than canvas ones and are
multiplied up to match: the voxel wireframe's line width -- left alone it
would fold down to half a line, so turning the smoothing up would appear to
fade the grid out -- and the scale the overworld's FX closures draw at.
The fold is a shader rather than a scaled draw, because the void this pass
renders into is a transparent BLACK: averaging a straight-alpha edge against
it drags the colour toward black as well as toward transparent, and the
engine's composite then multiplies by that alpha a second time. Every
silhouette against the sky would have come out ringed with a dark fringe --
the exact artefact the row exists to remove. So the taps are premultiplied
before they are averaged and divided back out after.
The staged battle gets it too, on its own canvas: the arena is folded back
to the window's pixel size before the depth-of-field pass and the HUDs go
on, so the world is smoothed and the pics, panels and text box stay the
chunky GB art they are.
OFF by default, and **FULL neither sets it nor takes the row away** -- it
is the one row that is not a knob on the look but on what the look COSTS,
and only the player knows what their machine can carry. No hotkey, for the
same reason: it is set once, not flicked while walking.
### Changed
@@ -216,6 +343,36 @@
depth16 as the floor every GLES3 device can read. Refused all four, the
reflections are lost and nothing else, exactly as before.
- **Under BACK SPRITES some of your own Pokemon were see-through -- Pikachu,
Seel, Dewgong, Chansey, Jigglypuff -- with the arena showing through the
middle of them.** Those back pics are drawn as OUTLINES: everything inside
the ink is the lightest shade, the decoder keys that shade to nothing, and
on hardware it did not matter because the field behind them was white too.
BattlePics already put that paper back by flooding the background inward and
filling whatever it could not reach, and along the bottom of a figure it told
a narrow opening (a belly the drawing ran out of, sealed) from a wide one (a
stride, left open for the world to show through). Right for a mon standing
on the map -- but the pinned back pic is not on the map, it is on the text
box with its feet on row 96, and there is white box under its lowest row
rather than arena. Every one of those mons leaks out through an opening far
too wide to read as a drain, so the flood walked straight up inside them.
A pic on the box is now told so, and its bottom edge seals: nothing reaches
it from below at any width, and the rule stops being a heuristic -- paper is
whatever the background cannot walk to from the left, the right or the top.
Twelve of the game's 151 back pics turn on this; the other 139 come back
byte-identical, and no front pic is touched at all.
**And a hole is filled with the pic's own paper rather than with white.**
Shade 0 is only white while the pic is still grays, and pics arrive here
after the bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK
across the whole screen while the blackout text is up. A hardcoded white
belly would have been the one lit thing on a blacked-out mon. The lightest
shade still standing in the pic is that colour, and every one of the game's
battler pics keeps at least one such pixel -- an eye, a highlight down a
cheek -- so what goes back is the baked shade itself.
### Known
- Screen-space reflections can only reflect what is in the frame. A tree just
+1
View File
@@ -52,6 +52,7 @@ menu.
| `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
| `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it |
| the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it |
| the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone |
| 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
+191 -6
View File
@@ -110,6 +110,9 @@ return {
bed = 7,
stool = 8,
counter = 8,
-- the raised back band of low seating (the Center couch's back and
-- arm strip): half again the 8px seat it rises over
backrest = 12,
table = 12,
desk = 24,
prop = 16,
@@ -579,6 +582,12 @@ return {
-- auto-extracting into standing prisms, and the ball/Pokedex
-- sprites ride the table's authored height.
table = { 41, 42, 57, 59, 78, 79 },
-- These tables are drawn 6px tall (3px slab edge over 3px base --
-- see the lab_table entry under `buildings`), not the default
-- table's 12: the override keeps the volume-built north tables
-- and the band-built starter table one height, and stands the
-- ball/Pokedex sprites exactly on the top face of both.
heights = { table = 6 },
},
-- Red's room and the Copycat's room (one tileset). The detector reads
@@ -699,14 +708,25 @@ return {
-- one clean band, so the drawn front panel stands up and the
-- counter top stays on top; at 12 they read as wall stubs
counter = { 8, 10, 24, 25, 56, 90, 91,
-- and the lounge couch with the man sitting on it.
-- Same half-cell box: its bottom row (42/43, the front
-- base) stands up as the couch's front, and the three
-- rows above it -- cushion (38/39) and the man
-- (36/37 head, 52/53 face) -- ride the top face in
-- and the lounge couch's SEAT column with the man
-- sitting on it. Same half-cell box: its bottom row
-- (43, the front base) stands up as the couch's
-- front, and the rows above it -- cushion (39) and
-- the man (37 head, 53 face) -- ride the top face in
-- drawn order, each exactly once. See the note below
-- on why he cannot be stood upright.
36, 37, 38, 39, 42, 43, 52, 53 },
37, 39, 43, 53 },
-- The couch's WEST tile column: the drawing's left strip is the
-- couch's back and arm running north-south (the seat's cushions
-- and seams fill the east column), so it rises over the 8px seat
-- the way a couch back does instead of lying flush in the same
-- box. Per-tile granularity puts the drawn ~6px strip plus a
-- 2px sliver of cushion on the raised band -- invisible at tile
-- scale, and the alternative is no backrest at all. The figure
-- anchor scans for the tallest authored UPRIGHT under the man
-- (see Structures.buildFigure), so he keeps sitting at seat
-- height beside it.
backrest = { 36, 38, 42, 52 },
-- standing per-pixel props, black-outline segmented: the healing
-- machines' screen tops (58/59/74/75, drawn above the pinned
-- bodies so they stand ON them) and the PC (66/70/82/86), which
@@ -3231,5 +3251,170 @@ return {
slab = 4, frontEave = 4, ledge = nil,
},
},
DOJO = {
-- F01: the starter-ball table in Oak's lab (one placement in the
-- game: OAKS_LAB cell 6,3) -- the first FURNITURE through the
-- band pipeline, and the first drawing whose plot is smaller than
-- its grid. Its 24 rows read: 0-15 the tabletop seen from above
-- (black rim, white highlight course, grey field); 16-18 the top
-- slab's own front edge, black/#555/black -- which is exactly
-- what the rim treatment paints, so slab = 3 and those rows fold
-- into the roof band instead of extruding under it; 19-21 the
-- base band, corner feet and the inset dark panel between them.
--
-- The legs stand on open FLOOR: the measured ground line lands
-- two rows short of the grid (see Buildings measure), and `depth`
-- keeps the plot to the blocked cell row -- the grid's third row
-- is the walkable cell the player faces the table from, matched
-- so the flat leg art is claimed off the floor, not so the model
-- stands on it. 16 top rows onto a 16px plot map 1:1: roofBack
-- covers the whole depth, nothing cycles, and roofCycle is
-- unreachable behind it. The Poke Ball sprites ride the `table`
-- pin's height (VoxelScene.groundAt reads the collision tile, not
-- this model), so the tileset entry above overrides that height
-- to the 6px this drawing actually stands.
{
id = "lab_table",
tiles = {
{ 41, 59, 59, 59, 59, 42 },
{ 78, 57, 57, 57, 57, 79 },
{ 88, 89, 89, 89, 89, 90 },
},
roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 },
slab = 3, frontEave = 0, ledge = nil, depth = 2,
},
-- F02: the computer desk on the lab's west side (OAKS_LAB cell
-- 0,1) -- the one DESK-SET template: the pipeline's region
-- classification at PART granularity (see Buildings
-- deskSetModel). The desk is the sibling lab table (fascia rows
-- 16-18, base 19-21); on it stand a monitor over its keyboard
-- (left), a computer tower over a keyboard and mouse (middle),
-- and a sheet of paper LYING FLAT (right). Upright parts anchor
-- their drawn bottom row to the desk's top plane and wear their
-- own drawn tops as lids; flat parts lie one voxel proud at
-- drawn row = depth row -- the same 1:1 the tabletop itself is
-- drawn with. The roof fields are inert (roofRows = 0 keeps the
-- recess scan over the whole drawing, which is what sinks the
-- monitor's screen and the tower's slots). Same drawing, same
-- grid, stands in the Hall of Fame on the GYM atlas --
-- registered there below.
{
id = "lab_computers",
tiles = {
{ 91, 92, 93, 94 },
{ 54, 55, 85, 95 },
{ 88, 89, 89, 90 },
},
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
slab = 0, frontEave = 0, ledge = nil, depth = 2,
desk = { fascia = { 16, 18 }, base = { 19, 21 } },
parts = {
{ kind = "upright", x = { 2, 13 }, top = { 0, 2 },
facade = { 3, 10 }, depth = 4 }, -- the monitor
{ kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard
{ kind = "upright", x = { 14, 21 }, top = { 0, 3 },
facade = { 4, 10 }, depth = 6 }, -- the tower
{ kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse
{ kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper
},
},
-- F03: the empty north table beside it (OAKS_LAB cell 2,1): the
-- starter table's band table verbatim on a grid two tiles
-- narrower.
{
id = "lab_table_small",
tiles = {
{ 41, 59, 59, 42 },
{ 78, 57, 57, 79 },
{ 88, 89, 89, 90 },
},
roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 },
slab = 3, frontEave = 0, ledge = nil, depth = 2,
},
},
POKECENTER = {
-- F04: the PC in every Center's northeast corner (11
-- placements; the Indigo Plateau lobby's twin is registered
-- under MART below). The lab desk-set read again: a Mac-style
-- unit drawn face-on -- white top band (rows 0-3), bezel, screen
-- and drive slot (4-14) -- standing at the back of a low desk
-- whose front face is rows 20-23; the drawn top around the unit
-- is WHITE, which is what `lid` carries. The keyboard rows 17-19
-- are drawn below the desk's 16px top span, so the flat part's
-- `z` puts it at the desk's front edge. The old billboard pins
-- for these tiles (66/70/82/86, desk 9/88) stay as the
-- degradation path -- the claim neutralizes them wherever this
-- template stamps.
{
id = "center_pc",
tiles = {
{ 66, 70 },
{ 82, 86 },
{ 9, 88 },
},
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
slab = 0, frontEave = 0, ledge = nil, depth = 2,
desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
parts = {
{ kind = "upright", x = { 2, 13 }, top = { 0, 3 },
facade = { 4, 14 }, depth = 6 }, -- the unit
{ kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
z = 13 }, -- keyboard
},
},
},
MART = {
-- F04 again: the Indigo Plateau lobby's PC (cell 15,7) -- the
-- MART tileset shares the POKECENTER atlas, so this is the same
-- drawing tile for tile. Same part table as the POKECENTER entry
-- above.
{
id = "center_pc",
tiles = {
{ 66, 70 },
{ 82, 86 },
{ 9, 88 },
},
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
slab = 0, frontEave = 0, ledge = nil, depth = 2,
desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" },
parts = {
{ kind = "upright", x = { 2, 13 }, top = { 0, 3 },
facade = { 4, 14 }, depth = 6 }, -- the unit
{ kind = "flat", x = { 2, 13 }, rows = { 17, 19 },
z = 13 }, -- keyboard
},
},
},
GYM = {
-- F02 again: the Hall of Fame's recording machine is the lab's
-- computer desk drawing, tile for tile, on the GYM atlas (one
-- placement: HALL_OF_FAME cell 4,1). Same part table as the DOJO
-- entry above.
{
id = "lab_computers",
tiles = {
{ 91, 92, 93, 94 },
{ 54, 55, 85, 95 },
{ 88, 89, 89, 90 },
},
roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 },
slab = 0, frontEave = 0, ledge = nil, depth = 2,
desk = { fascia = { 16, 18 }, base = { 19, 21 } },
parts = {
{ kind = "upright", x = { 2, 13 }, top = { 0, 2 },
facade = { 3, 10 }, depth = 4 }, -- the monitor
{ kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard
{ kind = "upright", x = { 14, 21 }, top = { 0, 3 },
facade = { 4, 10 }, depth = 6 }, -- the tower
{ kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse
{ kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper
},
},
},
},
}
+244
View File
@@ -0,0 +1,244 @@
-- Voxel world mode: anti-aliasing, by supersampling.
--
-- Everything else in this mod is flat art blitted at whole pixels; this one
-- pass is real geometry seen through a perspective camera, and a polygon
-- edge that lands at an angle across the pixel grid is the one place in the
-- game where a hard stair-step is not a stylistic choice. A roof ridge, a
-- ledge lip, a tree's silhouette against the sky and the leaning card of a
-- character are all cut by an edge that has no reason to line up with
-- anything, and at the shallow rungs -- where the diorama reads most like a
-- photograph of a model -- they crawl as the camera drifts.
--
-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two
-- reasons that both come out of what the pass already is:
--
-- MSAA would take the water with it. The reflections read the frame's own
-- DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth
-- attachment is not a thing a fragment shader in this dialect can sample.
-- The row would have quietly switched the other row off.
--
-- An edge filter (FXAA and its relatives) works from the finished colour
-- alone, and would be GUESSING where the edges are out of one sample per
-- pixel -- inventing detail it never rendered, and unable to tell a
-- geometry edge from the boundary between two texels of a tileset.
--
-- Rendering the pass larger and folding it back down has neither problem:
-- the depth buffer stays an ordinary texture, every pass in the frame keeps
-- working in the canvas it was handed, and the fold is an average of samples
-- that were each rendered honestly. It antialiases everything at once --
-- geometry, the alpha-cut outline of a sprite card, the wireframe, the
-- water's ray march -- because none of them know it is happening.
--
-- Be clear about what "everything" means: the artwork softens too. A tileset
-- texel out here is not a screen pixel, it is a quad in a perspective view,
-- and its boundary crosses the pixel grid at the same arbitrary angle a roof
-- ridge does -- so the fold averages across it exactly as it averages across
-- the ridge. That is what an honest extra sample says about that pixel, and
-- it is also the trade the row IS: the diorama comes out smoother, not
-- sharper. Which is why this is a row and not something that is simply on.
--
-- What it costs is pixels, which is the whole of why this is a row and not
-- something that is simply on: 2X is half again as many in each direction,
-- 4X is twice, and the scene pass is the most expensive thing in the frame.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local ModSetting = V.require("ModSetting")
local AntiAlias = {}
-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in
-- OPTIONS and the mod manager's own settings page for this mod
AntiAlias.KEY = "aa"
AntiAlias.LABEL = "AA"
-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads
-- everywhere else, and the canvas scale each rung costs is its square root:
-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the
-- size. OFF is the default -- this is a cost knob, and a mod should not
-- quietly spend four times the fill rate of the machine it lands on.
AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL,
{ 0, 2, 4 }, { "OFF", "2X", "4X" })
-- The scale the pass currently open was actually expanded by (see expand).
-- 1 while there is no supersampling in force, which is also what every
-- reader gets on a frame that never opened a pass at all.
local live = 1
function AntiAlias.samples()
return tonumber(AntiAlias.setting:get()) or 0
end
-- What the row ASKS for. The scale in force is `factor()`, which is this
-- clamped to what the driver will actually allocate.
local function wanted()
local n = AntiAlias.samples()
if n <= 1 then return 1 end
return math.sqrt(n)
end
-- The biggest canvas this driver admits to, or nil where it will not say.
-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty
-- of hardware and every phone -- and a refused canvas is not a softer
-- diorama, it is beginScene returning false and the whole mode falling back
-- to the flat 2D path.
local function textureLimit()
if not (love.graphics and love.graphics.getSystemLimits) then return nil end
local ok, limits = pcall(love.graphics.getSystemLimits)
return (ok and limits and limits.texturesize) or nil
end
-- The size to render `w` x `h` display pixels at, and the size everything
-- inside the pass then measures itself in.
--
-- Also where `live` is set, which is why this must be called once per pass
-- immediately before beginScene: the wireframe's line width and the FX
-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be
-- multiplied up into canvas ones, and the honest multiplier is the one this
-- returned rather than the one the row asked for.
function AntiAlias.expand(w, h)
local s = wanted()
local max = textureLimit()
if max and max > 0 then
-- clamped rather than abandoned: a window too big for 4X can usually
-- still carry some of it, and half a rung of smoothing is worth more
-- than a row that silently does nothing at that size
s = math.min(s, max / math.max(1, w), max / math.max(1, h))
end
if not (s > 1.01) then
live = 1
return w, h
end
local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5)
live = ew / math.max(1, w)
return ew, eh
end
-- The scale the open pass was expanded by; 1 when it was not.
function AntiAlias.factor()
return live
end
-- ------- the fold
--
-- One target per pass (the free-roam world and the battle's arena are alive
-- at different moments but reallocating on every battle entry and exit is
-- what the scene canvas's own slots exist to avoid), reallocated only when
-- that pass's DISPLAY size changes -- a window resize, or the row itself
-- moving, which changes the source and not this.
local targets = {}
local function targetFor(slot, w, h)
local t = targets[slot]
if not (t and t.w == w and t.h == h) then
local ok, c = pcall(love.graphics.newCanvas, w, h)
if not (ok and c) then return nil end
-- nearest, like the canvas it stands in for: this one is composited a
-- canvas pixel to a display pixel, and the smoothing has already happened
pcall(c.setFilter, c, "nearest", "nearest")
if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
t = { canvas = c, w = w, h = h }
targets[slot] = t
end
return t.canvas
end
-- The box filter, and the whole of why it is a shader rather than a scaled
-- draw with linear filtering on.
--
-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at
-- the rungs below FULL a good deal of the frame is still that. Averaging a
-- straight-alpha edge against it drags the result toward black as well as
-- toward transparent, and then the engine's own composite multiplies by that
-- alpha a second time -- so every silhouette against the void would come out
-- ringed with a dark fringe, which is exactly the artefact the row is here to
-- remove.
--
-- So the taps are premultiplied before they are averaged and divided back out
-- after, which is the arithmetic that makes an edge pixel mean "half covered
-- by this colour" instead of "covered by half of this colour".
--
-- Four taps, half a source texel from the destination centre. At 4X those
-- land dead on the four texel centres the destination pixel covers, so it is
-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear
-- fetch under each tap widens the box a little rather than missing samples.
local SHADER = [[
uniform vec2 tap; // half a SOURCE texel, in uv
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y));
vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y));
vec4 c = Texel(tex, tc + vec2(-tap.x, tap.y));
vec4 d = Texel(tex, tc + vec2( tap.x, tap.y));
float al = (a.a + b.a + c.a + d.a) * 0.25;
if (al <= 0.0) return vec4(0.0);
vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a;
return vec4(sum * 0.25 / al, al) * color;
}
]]
local shader = nil -- nil = untried, false = unavailable
local function getShader()
if shader == nil then
local ok, sh = pcall(love.graphics.newShader, SHADER)
shader = (ok and sh) or false
end
return shader or nil
end
-- Fold `canvas` down to `w` x `h` and hand back the result.
--
-- Returns the input untouched when there is nothing to fold -- the row is
-- off, or the canvas already IS that size -- so a caller can run it
-- unconditionally, and so can a headless test run. A target that would not
-- allocate is the same answer: the pass is lost either way if this hands back
-- something the wrong size, so it hands back the input and the frame draws at
-- the size it was rendered.
function AntiAlias.resolve(canvas, w, h, slot)
if not canvas then return canvas end
local ok, cw, ch = pcall(canvas.getDimensions, canvas)
if not ok or (cw == w and ch == h) then return canvas end
local target = targetFor(slot or "world", w, h)
if not target then return canvas end
local sh = getShader()
local prevBlend, prevAlpha = love.graphics.getBlendMode()
-- the scene canvas filters nearest for its usual 1:1 blit; the taps want
-- linear, put back below so every other pass finds what it expects
pcall(canvas.setFilter, canvas, "linear", "linear")
love.graphics.setColor(1, 1, 1, 1)
-- replace, not alpha-blend: this is an image-processing copy, and the alpha
-- the shader worked out has to land as itself rather than be composited
-- against whatever the target held
love.graphics.setBlendMode("replace", "premultiplied")
if sh then
love.graphics.setShader(sh)
pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch })
end
local drew = pcall(function()
love.graphics.setCanvas(target)
love.graphics.clear(0, 0, 0, 0)
love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch)
end)
love.graphics.setCanvas()
love.graphics.setShader()
love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha)
pcall(canvas.setFilter, canvas, "nearest", "nearest")
return drew and target or canvas
end
-- Drop the GPU objects (window resize, hot reload).
function AntiAlias.invalidate()
for slot, t in pairs(targets) do
if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end
targets[slot] = nil
end
end
function AntiAlias.row()
return AntiAlias.setting:row()
end
return AntiAlias
+106 -25
View File
@@ -57,6 +57,26 @@
-- and they come back untouched because that is what their own shape says, not
-- because they were special-cased.
--
-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a
-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such
-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own
-- slot with its feet flush on the text box (BattleState.backPlacement pins
-- row 96), so the only thing under its lowest row is white box. Nothing can
-- reach it from below, whatever the opening's width, and the caller says so
-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic
-- and becomes exact: paper is whatever the background cannot walk to from the
-- left, the right or the top.
--
-- That is the difference between a Pikachu that reads as a mon and one that
-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong,
-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is
-- shade 0 and every one of them is keyed away, so the figure is a rim with the
-- arena showing through it. Each one also has a wide opening along its bottom,
-- which the drain cut correctly reads as a mouth and the sealed bottom
-- correctly does not. Twelve of this game's 151 back pics turn on it; the
-- other 139 come back byte-identical either way, because they had nothing
-- under them the flood was getting in through.
--
-- The silhouette is untouched, so the mon still cuts cleanly against the
-- world; only its insides stop being see-through.
--
@@ -71,14 +91,23 @@ local V = ...
local BattlePics = {}
-- Cached by the image the engine handed over. Weak keys, so a pic that goes
-- out of scope takes its filled twin with it rather than pinning a texture
-- for the session.
local cache = setmetatable({}, { __mode = "k" })
-- Cached by the image the engine handed over, one table per bottom rule --
-- the same pic answers differently sealed and unsealed, and a single table
-- would hand the wrong twin back to whichever caller asked second. Weak keys,
-- so a pic that goes out of scope takes its filled twin with it rather than
-- pinning a texture for the session.
local function newCache()
return {
[false] = setmetatable({}, { __mode = "k" }),
[true] = setmetatable({}, { __mode = "k" }),
}
end
local cache = newCache()
-- What an enclosed hole is filled with. White, because white is what the
-- battle field was: this restores the pixel the artist drew and the engine
-- then keyed away, it does not invent a new one.
-- What an enclosed hole is filled with when the pic itself offers nothing
-- better. White, because white is what the battle field was: this restores the
-- pixel the artist drew and the engine then keyed away, it does not invent a
-- new one.
BattlePics.FILL = { 1, 1, 1, 1 }
-- Anything at or under this alpha counts as keyed-out rather than drawn.
@@ -154,6 +183,42 @@ local function inkBounds(data, w, h)
return x0, y0, x1, y1
end
-- The colour the keyed-away shade would have had: the LIGHTEST colour still
-- standing in the pic.
--
-- Pure white is only the right answer while the pic is still grays, and by the
-- time it reaches here it usually is not. picImage hands a pic over AFTER the
-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the
-- whole screen while the blackout text is up -- and shade 0 travels with the
-- rest. A white belly inside a blacked-out mon would be the one lit thing on a
-- dark screen; inside a warm-palette mon it would be a cold patch the artist
-- never drew.
--
-- So the paper is read off the pic rather than assumed, which needs shade 0 to
-- have survived somewhere in it. It always has: every one of this game's 151
-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a
-- cheek, the white of an eye -- because only the shade-0 pixels the decoder
-- could reach were keyed. So what comes back is the baked shade 0 itself, not
-- an approximation of it, and it tracks every palette the engine picks without
-- being told which one that was.
--
-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps
-- all three channels monotonically, so lightest by sum is lightest full stop.
local function paperColor(data, x0, y0, x1, y1)
local best, pr, pg, pb = -1, nil, nil, nil
for y = y0, y1 do
for x = x0, x1 do
local r, g, b, a = data:getPixel(x, y)
if a > CUT then
local lum = r + g + b
if lum > best then best, pr, pg, pb = lum, r, g, b end
end
end
end
if best < 0 then return nil end
return pr, pg, pb
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
@@ -161,7 +226,9 @@ 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.
-- than the underside of a figure the drawing ran out of -- or none of them at
-- all, for a pic whose feet are on the text box and which therefore has
-- nothing behind its lowest row to let in.
--
-- 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.
@@ -169,7 +236,7 @@ BattlePics.DRAIN = 6
-- 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, x0, y0, x1, y1)
local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
local outside = {}
local stack, top = {}, 0
local function clear(x, y)
@@ -191,17 +258,21 @@ local function markOutside(data, w, h, x0, y0, x1, y1)
push(x1, y)
end
-- 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
-- the world through, a narrow one is where a belly ran out and is sealed.
-- Skipped whole for a pic on the box, where even the widest of them has
-- white paper behind it rather than arena.
if not sealBottom then
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
else
x = x + 1
end
end
while top > 0 do
@@ -219,9 +290,15 @@ end
-- The pic with its enclosed holes filled, or the pic itself when that could
-- not be done (no pixel access, a driver that refused the readback). Never
-- nil for a non-nil argument: a caller must always have something to draw.
function BattlePics.filled(img)
--
-- sealBottom for a pic pinned to the text box rather than standing on the map:
-- see the header. A caller that does not say defaults to the map, which is
-- where all but one of this mod's pics are.
function BattlePics.filled(img, sealBottom)
if not img then return img end
local hit = cache[img]
sealBottom = sealBottom and true or false
local slot = cache[sealBottom]
local hit = slot[img]
if hit ~= nil then return hit or img end
local made = nil
@@ -231,8 +308,12 @@ function BattlePics.filled(img)
local w, h = data:getDimensions()
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 outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom)
local fill = BattlePics.FILL
local pr, pg, pb = paperColor(data, x0, y0, x1, y1)
local fr = pr or fill[1]
local fg = pg or fill[2]
local fb = pb or fill[3]
local changed = false
-- only inside the box: everything beyond it is frame the artist never
-- reached, and filling that would put the mon in a white rectangle
@@ -242,7 +323,7 @@ function BattlePics.filled(img)
if not outside[row + x] then
local _, _, _, a = data:getPixel(x, y)
if a <= CUT then
data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4])
data:setPixel(x, y, fr, fg, fb, fill[4])
changed = true
end
end
@@ -255,12 +336,12 @@ function BattlePics.filled(img)
made = out
end)
cache[img] = (ok and made) or false
slot[img] = (ok and made) or false
return made or img
end
function BattlePics.invalidate()
cache = setmetatable({}, { __mode = "k" })
cache = newCache()
end
return BattlePics
+12 -2
View File
@@ -42,6 +42,7 @@ local BattleCam = V.require("BattleCam")
local BattleBillboard = V.require("BattleBillboard")
local VoxelGrid = V.require("VoxelGrid")
local DayNight = V.require("DayNight")
local AntiAlias = V.require("AntiAlias")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -427,7 +428,16 @@ function BattleScene.render(state, arena, textures, token)
-- its own canvas slot: this renders at the window's pixel size and the
-- free-roam pass does too, but the two are alive at different moments
-- and a shared slot would reallocate on every battle entry and exit
if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then
--
-- AA, if the row asks for it, renders it larger still and folds it back
-- to pw x ph below (see AntiAlias). The framing is untouched by that:
-- the lens was widened by the window's RATIO to the letterbox and the
-- rig solved in the GB's own frame, so a bigger canvas is more samples
-- of the identical shot -- which is why the pins below still measure in
-- pw and ph, and why the HUDs and the depth of field, drawn onto the
-- folded canvas afterwards, stay the chunky GB art they are.
local rw, rh = AntiAlias.expand(pw, ph)
if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then
return
end
Voxel3D.draw(terrain, atlasFor(host), nil)
@@ -500,7 +510,7 @@ function BattleScene.render(state, arena, textures, token)
Mat4.translate(nb.ox, 0, nb.oy), fpull,
ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy)))
end
local canvas = Voxel3D.endScene()
local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle")
if not canvas then return end
local vp = Voxel3D.vp
+180 -6
View File
@@ -190,7 +190,24 @@ local function measure(sp, t)
top[x] = r
end
local wallH = H - roofRows
-- The drawing's own ground line: the row after the last drawn one. A
-- building ends on the black threshold row it stands on (ground == H),
-- but furniture is drawn standing on open floor -- the lab table's
-- legs stop two rows short of its grid -- and extruding against H
-- would float it that far above its own plot.
local ground = roofRows
for sy = H - 1, roofRows, -1 do
local drawn = false
for sx = 0, W - 1 do
if sp.inside[sy * W + sx] then drawn = true break end
end
if drawn then
ground = sy + 1
break
end
end
local wallH = ground - roofRows
local ytop = wallH - 1 + t.slab
-- Side faces must not come out as slabs of outline black: where the
@@ -279,20 +296,175 @@ local function measure(sp, t)
-- sprite taller than its footprint -- the tower's 16-row drawing
-- stands on the 8 rows of it that are actually on the map, and D = H
-- would have pushed its body 64px south into the town plaza.
return { top = top, ytop = ytop, D = #t.tiles * 8,
-- `depth` (in tile rows) names the plot when the grid runs PAST it
-- onto ground the drawing merely stands its legs on: the lab table's
-- third row is the walkable cell the player faces it from, and the
-- full-grid depth would stand the model in their path.
return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8,
ground = ground,
recess = recess, interior = interior, shadeTexel = shadeTexel }
end
-- ----------------------------------------------------------------- build --
-- A desk with separately-classified objects on it (a template's `parts`
-- list): the methodology's region classification at part granularity.
-- Upright parts anchor their drawn bottom row to the desk's top plane
-- and wear their own drawn tops as lids; flat parts (a keyboard, a
-- sheet of paper) lie one voxel proud at drawn row = depth row -- the
-- same 1:1 the tabletop itself is drawn with, so an object's height ON
-- the drawing is its position ON the desk. The desk is the lab-table
-- slab + base; its lid is the one synthesized surface in the model
-- (the objects cover every drawn pixel of the tabletop), continued
-- from the sibling tables' pattern in the drawing's own shades.
-- tools/building_voxels.py `build_desk_set` is the reference twin.
local function deskSetModel(sp, pr, t)
local W, H, D = sp.W, sp.H, pr.D
local ground = pr.ground
local col, inside = sp.col, sp.inside
local vox = {}
local function key(x, y, z) return (y * D + z) * W + x end
local function put(x, y, z, i) vox[key(x, y, z)] = i end
-- de-outline walk bounded to the part, so a part's side faces show
-- its own material and never the neighbour's (the sprite-wide walk
-- the facade path uses would cross the black seam between units)
local function interiorAt(sx, sy, lo, hi)
local i = sy * W + sx
if col[i] ~= BLACK then return sx end
local step = sx < math.floor((lo + hi) / 2) and 1 or -1
for d = 1, 3 do
local nx = sx + step * d
if nx >= lo and nx <= hi then
local ni = sy * W + nx
if inside[ni] and col[ni] ~= BLACK then return nx end
end
end
return sx
end
local f0, f1 = t.desk.fascia[1], t.desk.fascia[2]
local b0, b1 = t.desk.base[1], t.desk.base[2]
local plane = (b1 - b0 + 1) + (f1 - f0 + 1)
-- the base band, extruded exactly like every lab table's
for sy = b0, b1 do
Budget.tick()
local y = ground - 1 - sy
for sx = 0, W - 1 do
if inside[sy * W + sx] then
local ix = interiorAt(sx, sy, 0, W - 1)
for z = 0, D - 1 do
local px = (z == 0 or z == D - 1) and sx or ix
put(sx, y, z, sy * W + px)
end
end
end
end
for i in pairs(pr.recess) do
local sy = math.floor(i / W)
if sy >= b0 and sy <= b1 then
vox[key(i % W, ground - 1 - sy, D - 1)] = nil
end
end
-- the slab: fascia rows wrap every side; the lid continues the
-- sibling tables' top -- black rim, white highlight courses along
-- the north and west, grey field
for sy = f0, f1 do
Budget.tick()
local y = plane - 1 - (sy - f0)
for sx = 0, W - 1 do
for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end
end
end
local field = t.desk.lid == "white" and WHITE or GREY
for sx = 0, W - 1 do
for z = 0, D - 1 do
local shade = field
if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then
shade = BLACK
elseif sx == 1 or z == 1 then
shade = WHITE
end
put(sx, plane - 1, z, pr.shadeTexel[shade])
end
end
local ytop = plane
for _, p in ipairs(t.parts) do
Budget.tick()
local x0, x1 = p.x[1], p.x[2]
if p.kind == "flat" then
-- drawn row = depth row by default; `z` renames the origin when
-- the flat sits below the desk's own drawn top span (the Center
-- PC's keyboard)
local r0 = p.rows[1]
local z0 = p.z or r0
for sy = r0, p.rows[2] do
local z = z0 + (sy - r0)
if z >= 0 and z < D then
for sx = x0, x1 do
if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
end
end
end
else
local tr0, tr1 = p.top[1], p.top[2]
local fr0, fr1 = p.facade[1], p.facade[2]
local pd = p.depth
local ytp = plane + (fr1 - fr0)
if ytp > ytop then ytop = ytp end
for sx = x0, x1 do
-- the lid: the part's drawn top laid across its depth from the
-- back, last row continuing forward; the front lid row is the
-- facade's own top row -- the drawn front-top edge
for z = 0, pd - 1 do
local front = z == pd - 1
local sy = front and fr0 or math.min(tr0 + z, tr1)
while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end
local ok = sy <= tr1 or (front and inside[fr0 * W + sx])
if ok then
put(sx, ytp, z, (front and fr0 or sy) * W + sx)
end
end
-- the body: facade rows anchored to the desk's top plane
for sy = fr0 + 1, fr1 do
local y = plane + (fr1 - sy)
local i = sy * W + sx
if inside[i] then
local ix = interiorAt(sx, sy, x0, x1)
for z = 0, pd - 1 do
if z == pd - 1 then
if not pr.recess[i] then put(sx, y, z, i) end
elseif z == 0 then
put(sx, y, z, i)
else
put(sx, y, z, sy * W + ix)
end
end
end
end
end
end
end
return { at = function(x, y, z)
if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end
return vox[key(x, y, z)]
end,
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
end
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
-- order -- roof first, so it overwrites the walls it intersects, and walls
-- are trimmed to its underside so nothing pokes through the surface.
local function model(sp, pr, t)
if t.parts then return deskSetModel(sp, pr, t) end
local W, H, D = sp.W, sp.H, pr.D
local slab, roofRows = t.slab, t.roofRows
local top, ytop = pr.top, pr.ytop
local top, ytop, ground = pr.top, pr.ytop, pr.ground
-- The roof's drawn span. A sprite inset from its box (B03) leaves outer
-- columns undrawn in the roof band; they carry no roof at all, and the
@@ -367,16 +539,18 @@ local function model(sp, pr, t)
-- the awning: the band juts two voxels past the walls, front and back
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
local sy = H - 1 - y
local sy = ground - 1 - y
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
return sy * W + x
end
return nil
end
-- the facade, extruded straight back over the footprint
-- the facade, extruded straight back over the footprint. Rows map
-- against the measured ground line, not the grid's last row: the two
-- differ only for furniture standing on open floor (see measure).
if z < 0 or z >= D then return nil end
local sy = H - 1 - y
local sy = ground - 1 - y
local i = sy * W + x
if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then
-- the drawing's last row is the ground the building stands on, so
+32 -1
View File
@@ -104,6 +104,31 @@ function OverworldBattle.backPinned()
return OverworldBattle.backSetting:get() and true or false
end
-- Whether a pic is the one drawn in the GB's own slot with its feet on the
-- text box, rather than geometry standing out on the map.
--
-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back
-- that holds the slot until "Go!" -- because that is the only pic this mod
-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its
-- tile whichever mode is on, and with the mode off the player's side is one
-- too, so both of those keep the open bottom that lets the arena through a
-- stride. What the answer buys is in BattlePics: a pic on the box has nothing
-- behind its lowest row, so its bottom edge seals.
-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the
-- side it is not rendering by setting the field to FALSE (see OFF) and holds
-- it that way for the whole render -- during which the pic layer runs, and
-- picImage asks this. A nil test passes a `false` straight through to the
-- index below, and the error comes out of sideTexture into the pcall that
-- calls it: the foe's billboard is dropped for the frame and the Pokemon
-- simply is not there.
function OverworldBattle.pinnedPic(battle, img)
if not (battle and img) then return false end
if not OverworldBattle.backPinned() then return false end
if img == battle.playerBackPic then return true end
local player = battle.player
return (player and img == player.sprite) and true or false
end
-- ------- both mons face you
--
-- Standing on a map, seen from in front, a Pokemon showing you its BACK is
@@ -831,11 +856,17 @@ function OverworldBattle.install()
-- behind it. There is a world back there now, so they are filled here
-- instead -- see BattlePics, which puts the paper back without touching
-- the silhouette.
--
-- The pinned pic is told that its feet are on the box, which is what lets
-- the pale-bodied back sprites be filled at all: their bellies leak out
-- through an opening too wide to read as a drain, and only the box under
-- them settles that it is not a hole. Passed the pre-bake image, because
-- that is the one the battle holds a reference to.
local innerPic = BattleState.picImage
function BattleState:picImage(img)
local out = innerPic(self, img)
if not OverworldBattle.shot() then return out end
return BattlePics.filled(out)
return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img))
end
-- While a billboard texture is being rendered both pics are put in the same
+13 -5
View File
@@ -2253,14 +2253,22 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
-- He stands ON the furniture he was drawn into -- the same lift a pinned
-- prop above a pinned box takes (see buildObject), and gated the same
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
-- seat you merely walk up to is in a walkable one.
-- seat you merely walk up to is in a walkable one. The row under his
-- card is SCANNED for the tallest authored upright rather than read at
-- its west corner: the corner tile can be furniture that is not his
-- seat (the couch's raised backrest column stands there, `top` art and
-- taller than the cushion he actually sits on).
local baseY = 0
local bs = S.shapeAt[keyOf(tx, ty + fig.h)]
local blocked = not map:isWalkableCell(math.floor(tx / 2),
math.floor((ty + fig.h - 1) / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
baseY = bs.h
if blocked then
for dx = 0, fig.w - 1 do
local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)]
if bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > baseY then
baseY = bs.h
end
end
end
local atlasW = map.tileset.imageWidth or 128
+25
View File
@@ -83,6 +83,10 @@ local FALLBACK_HEIGHTS = {
bed = 7,
stool = 8,
counter = 8,
-- the raised back band of low seating: the Center couch's west strip
-- is drawn from above like the rest of the couch, but depicts the
-- back and arm rising over the 8px seat
backrest = 12,
table = 12,
desk = 24,
prop = 16,
@@ -145,6 +149,9 @@ local ART = {
-- profile archetype Structures builds real steps for -- rising flights
-- for stairs leading up, sunken stairwells for stairs leading down
bed = "top",
-- a backrest's art is the couch seen from above, so like the bed it
-- rides the top face of its taller box
backrest = "top",
stool = "billboard",
-- half-cell furniture: a service counter, a low couch. One 8px band,
-- so exactly the drawing's bottom row stands up as the front and
@@ -295,6 +302,24 @@ function TileShape.forMap(map)
if cache[id] then return cache[id] end
local heights = TileShape.heights()
-- Per-tileset height overrides (a tileset entry's `heights`): the class
-- vocabulary is global but the drawings are not -- the DOJO lab tables
-- are drawn 6px tall where the default `table` is 12 -- and the height
-- a sprite RIDES at (VoxelScene.groundAt) must be the height the art
-- actually stands, or the starter balls float over their own table.
-- Same gate as the global list: known classes, numbers only.
do
local s = load()
local entry = s and s.tilesets and s.tilesets[id]
local over = entry and entry.heights
if type(over) == "table" then
for class, h in pairs(over) do
if type(h) == "number" and FALLBACK_HEIGHTS[class] then
heights[class] = h
end
end
end
end
local authored = authoredGroups(id, heights)
local count = math.floor((tileset.imageWidth or 128) / 8)
* math.floor((tileset.imageHeight or 48) / 8)
+1 -1
View File
@@ -715,7 +715,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
pcall(sh.send, sh, "sunTexel", { texel, texel })
if grid then
pcall(sh.send, sh, "gridDark", VoxelGrid.DARK)
pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH)
pcall(sh.send, sh, "gridWidth", VoxelGrid.width())
end
-- ordinary shading until the silhouette pass asks for otherwise. Sent
-- every frame rather than once, because a scene that opened mid-ghost --
+13
View File
@@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45
-- 1.0 here is the one-pixel wireframe.
VoxelGrid.WIDTH = 1.0
-- The same width in the CANVAS pixels the shader measures in, which is what
-- every sender of it actually wants.
--
-- The two are the same number until AA renders the pass larger than the
-- window (see AntiAlias): there a canvas pixel is a fraction of a display
-- one, and a width left at 1.0 would come out a half or a quarter of a line
-- after the fold -- the wireframe fading as the smoothing goes up, which
-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and
-- simply gains the antialiasing everything else in the frame just gained.
function VoxelGrid.width()
return VoxelGrid.WIDTH * V.require("AntiAlias").factor()
end
-- where it persists and the rows that cycle it (see ModSetting)
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
{ false, true }, { "OFF", "ON" })
+1 -1
View File
@@ -1192,7 +1192,7 @@ function Water.begin(ctx)
if ctx.grid then
local VoxelGrid = V.require("VoxelGrid")
send("gridDark", VoxelGrid.DARK)
send("gridWidth", VoxelGrid.WIDTH)
send("gridWidth", VoxelGrid.width())
end
Water.sendSky(sh, ctx)
+34 -4
View File
@@ -82,6 +82,7 @@ local BattleExit = V.require("BattleExit")
local DayNight = V.require("DayNight")
local DayTint = V.require("DayTint")
local Water = V.require("Water")
local AntiAlias = V.require("AntiAlias")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -203,20 +204,34 @@ mod.content.render_pipelines:register("voxel", {
-- a magnified low-res image, while the FX closures keep drawing in
-- world-pixel units.
local sw, sh = sceneSize(ctx)
local canvas = VoxelScene.render(ctx.state, sw, sh,
-- With AA on, the whole pass runs into a canvas BIGGER than the window
-- and is folded back down at the end (see AntiAlias). Nothing between
-- these two lines knows: every pass in the frame measures itself in the
-- canvas it was handed, so the sky's dither, the water's march and the
-- camera itself all come out the same picture at a higher sample rate.
local rw, rh = AntiAlias.expand(sw, sh)
local canvas = VoxelScene.render(ctx.state, rw, rh,
ctx.vw, ctx.vh, ctx.paletteFor)
if not canvas then return nil end -- fall back to the 2D path
if Voxel3D.beginOverlay() then
-- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and
-- they are drawing into the supersampled canvas alongside everything
-- else -- so the scale goes up with it, or the "!" bubble lands the
-- right place at half the size. project() already answers in canvas
-- pixels, so only the scale needs saying.
ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end,
ctx.scale)
ctx.scale * AntiAlias.factor())
Voxel3D.endOverlay()
end
return canvas
-- and back to the window's own size, which is what the engine composites
-- one canvas pixel to one display pixel. A pass-through when AA is off.
return AntiAlias.resolve(canvas, sw, sh, "world")
end,
invalidate = function()
Voxel3D.invalidate()
OverworldBattle.invalidate()
AntiAlias.invalidate()
ChunkMesher.invalidate() -- no map id = every cached mesh
end,
})
@@ -361,6 +376,21 @@ local SETTINGS = {
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following -- or SYNC it to the "
.. "clock on the wall, so Kanto's evening falls when yours does." },
-- Marked `full` for the opposite reason the battle rows are: this is not a
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
-- the diorama, not a licence to spend four times the fill rate on the
-- machine it happens to be running on, so it neither sets this nor takes
-- the row away -- the player decides what their hardware can carry, from
-- inside FULL like anywhere else.
{ AntiAlias.setting,
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
.. "lips, a tree against the sky -- by rendering the diorama larger than "
.. "the window and folding it back down. Every edge in the picture "
.. "softens with them, the tileset's own texels included, so the diorama "
.. "reads smoother rather than sharper. 2X costs half again as many "
.. "pixels in each direction and 4X twice, which makes this the most "
.. "expensive row in the mod.",
full = true },
}
local schema = {}
@@ -860,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx)
return DayNight.tod()
end)
mod.exports.version = "1.4.0"
mod.exports.version = "1.4.1"
-- 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
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.4.0",
"version": "1.4.1",
"api": 2,
"entry": "main.lua",
"profile": "content",
+195
View File
@@ -0,0 +1,195 @@
-- Driver: one scene, once per rung of the AA row.
--
-- The AA row is the one setting in this mod whose whole effect is a pixel
-- wide, so it is also the one that cannot be judged from a description. This
-- renders the SAME frame at each rung and writes one PNG per rung; put two of
-- them side by side, magnified, and the row is either doing something or it
-- is not.
--
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \
-- SHOT_DIR=<dir> lovec.exe .
--
-- knobs (env):
-- SHOT_DIR output directory (created if missing) (default "shots/aa")
-- AA_MAP map id (default VIRIDIAN_CITY)
-- AA_SPOT "x,y[,facing]" (default 20,26,up)
-- AA_RUNG the voxel camera rung (default 5, the 75 one)
--
-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the
-- diagonal of a fence and a tree's silhouette against the sky are the edges
-- that stair-step, and 75 degrees is the rung that puts the most of them at an
-- angle to the pixel grid.
--
-- Determinism matters here for the same reason it does in voxel_shots_ab: the
-- three shots differ ONLY by the row under test, or comparing them means
-- nothing. The clock is pinned, the animated tile slots are frozen, the
-- townsfolk are stopped where they stand, and the tilt-shift is held at zero
-- (a gaussian over the frame would smear away the very edges being looked at).
--
-- Nothing here writes the player's options: the row is moved with
-- ModSetting:sync, which moves the cached index and persists nothing.
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/aa"
local handle = game.mods.exports["DRAMATIC_SHAPE"]
if not (handle and handle.lib) then
print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot")
return
end
local V = handle.lib
local DayNight = V.require("DayNight")
local AntiAlias = V.require("AntiAlias")
local ChunkMesher = V.require("ChunkMesher")
local Voxel = V.require("VoxelState")
local ShadowMap = V.require("ShadowMap")
local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY"
local SPOT = os.getenv("AA_SPOT") or "20,26,up"
local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5)
local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$")
sx, sy = tonumber(sx) or 20, tonumber(sy) or 26
if sf == "" then sf = "up" end
OverworldState.rollEncounter = function() return nil end
local NPC = require("src.world.NPC")
if not NPC.dramaticShapeAaFreeze then
local inner = NPC.update
function NPC:update(...)
self.frozen = true
return inner(self, ...)
end
NPC.dramaticShapeAaFreeze = true
end
pcall(love.math.setRandomSeed, 20260801)
local TileRenderer = require("src.render.TileRenderer")
TileRenderer.tick = function() end
TileRenderer.animFrame = function() return 0 end
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
local Zoom = require("src.render.Zoom")
pcall(function()
game.save.options.zoom = 1
Zoom.applyOptions(game.save.options)
end)
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
-- The camera PITCH, which is the one that caught this driver out. The tween
-- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the
-- angle stops moving, so the first shot of a run came out at 67 degrees
-- while the two after it were at 75 -- three frames that differ by the
-- camera, in a comparison whose entire subject is a pixel.
local function angleStill()
local last, held = nil, 0
for _ = 1, 600 do
if Voxel.angle == last then
held = held + 1
if held >= 10 then return true end
else
held = 0
last = Voxel.angle
end
U.wait(1)
end
return false
end
local function settle()
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
angleStill()
cameraStill()
-- the sun map is only redrawn when its inputs move, and the AA row is not
-- one of them -- so force one pass at the settled camera rather than
-- comparing a frame against a map fitted a few hundredths of a pixel ago
if ShadowMap.forget then ShadowMap.forget() end
U.wait(20)
end
DayNight.setting:sync("day")
U.teleport(game, MAP, sx, sy, sf)
Pipelines.setLevel("voxel", RUNG)
Pipelines.setLevel("tiltshift", 0)
-- Warm up before the FIRST shot, not just between them.
--
-- Neighbour maps are requested from inside the render itself
-- (VoxelScene.prefetch), so an empty build queue right after a teleport
-- means "nothing has been asked for yet", not "everything is here". The
-- first capture of a run came out with the map beyond Viridian missing --
-- a whole tree line absent from one frame of a three-way comparison, which
-- looks exactly like the row under test doing something enormous. Settling
-- twice lets the first render request the neighbourhood and the second
-- drain it.
settle()
settle()
local shots, missed = 0, 0
for _, samples in ipairs({ 0, 2, 4 }) do
AntiAlias.setting:sync(samples)
settle()
-- AA_TRACE=1 prints the state each shot was taken in. When two shots of a
-- run disagree by more than the row could account for, this is what says
-- which input moved -- it is how the camera-tween and the neighbour-mesh
-- settles above were both found.
if os.getenv("AA_TRACE") == "1" then
local Voxel3D = V.require("Voxel3D")
local o = game.overworld
local cw, chh = Voxel3D.size()
print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f")
:format(samples, Voxel.angle or -1, Voxel3D.fovY or -1,
Voxel3D.cell or -1, cw or 0, chh or 0,
o and o.camera and o.camera.x or -1,
o and o.camera and o.camera.y or -1,
(Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0,
(Voxel3D.eye or {})[3] or 0, AntiAlias.factor()))
end
local path = ("%s/aa_%d.png"):format(ROOT, samples)
game.capturePath = path
U.wait(6)
local f = io.open(path, "rb")
if f then
f:close()
shots = shots + 1
print(("[aa] %s samples=%d"):format(path, samples))
else
missed = missed + 1
print("[aa] capture did not reach disk: " .. path)
end
end
-- left where it was found, so a run cannot leak a rung into the next one
AntiAlias.setting:sync(0)
print(("[aa] %d shots into %s (%d failed to reach disk)")
:format(shots, ROOT, missed))
end
+199 -11
View File
@@ -145,6 +145,9 @@ T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
-- 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")
-- and AA, for the opposite reason: it is not a knob on the look at all, it is
-- what the look COSTS, and only the player knows what their machine can carry
T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns")
-- 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
@@ -379,9 +382,11 @@ 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, 7, "the options hook added a row per setting")
T.eq(#hookedRows, 8, "the options hook added a row per setting")
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4]
local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7]
-- the AA row is hookedRows[8]; it is read in its own block below, because
-- this chunk is one main function and has 200 local slots to spend
T.eq(water.label, "WATER", "the water row carries its label")
T.eq(water.value(), "FULL",
"and defaults to FULL -- reflections are the point of having the row")
@@ -459,6 +464,57 @@ curve.step(settingGame, 1)
curve.step(settingGame, 1)
T.eq(curve.value(), "OFF", "the curve is left off for the rows below")
-- ------- AA renders the pass larger and folds it back down
--
-- The ladder is SAMPLES per display pixel, so the canvas scale each rung asks
-- for is its square root -- and the scale in force has to be readable after
-- the fact, because two things are quoted in DISPLAY pixels and have to be
-- multiplied up into the canvas the pass actually opened: the wireframe's
-- line width and the FX overlay's sprite scale.
do
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
local aa = hookedRows[8]
T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
T.eq(aa.value(), "OFF",
"and starts off -- supersampling is a cost knob, and a mod must not spend "
.. "four times the fill rate of the machine it lands on unasked")
T.eq(AntiAlias.samples(), 0, "AA is off by default")
local w, h = AntiAlias.expand(320, 200)
T.eq(w, 320, "an OFF row renders at the window's own width")
T.eq(h, 200, "and its height")
T.eq(AntiAlias.factor(), 1, "with nothing to multiply display pixels by")
T.eq(VoxelGrid.width(), VoxelGrid.WIDTH,
"so the wireframe is the one-display-pixel line it has always been")
aa.step(aaGame, 1)
T.eq(aa.value(), "2X", "stepping the row climbs to two samples a pixel")
T.eq(aaGame.save.options.modOptions.DRAMATIC_SHAPE.aa, 2,
"the sample count persists beside the other settings, not over them")
w, h = AntiAlias.expand(320, 200)
T.eq(w, 453, "two samples a pixel is a canvas root-two wider")
T.eq(h, 283, "and root-two taller")
T.check(math.abs(AntiAlias.factor() - 453 / 320) < 1e-9,
"and the factor is what it MEASURED, not what the row asked for")
aa.step(aaGame, 1)
T.eq(aa.value(), "4X", "and again to four")
w, h = AntiAlias.expand(320, 200)
T.eq(w, 640, "four samples a pixel is a canvas exactly twice the size")
T.eq(h, 400, "in each direction, which is the 2x2 box the fold reads")
T.eq(AntiAlias.factor(), 2, "with everything in display pixels doubled")
T.eq(VoxelGrid.width(), VoxelGrid.WIDTH * 2,
"the wireframe among them -- a seam left at 1.0 would fold down to half a "
.. "line, so the smoothing row would appear to fade the grid row out")
aa.step(aaGame, 1)
T.eq(aa.value(), "OFF", "and the ladder wraps back to off")
AntiAlias.expand(320, 200)
T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass")
end
-- ------- the animated terrain atlas survives an engine without its seams
--
-- Regression: cycling palette modes with voxel mode on eventually killed
@@ -2476,7 +2532,35 @@ 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")
-- ------- and which pic is the pinned one is asked with the other side BLANKED
--
-- picImage asks this so BattlePics knows whether a pic's feet are on the text
-- box -- where its bottom edge seals, and its belly stops being see-through --
-- and it is asked DURING the billboard render, inside which sideTexture has
-- switched the side it is not drawing off by setting the field to FALSE rather
-- than to nil (see OFF).
--
-- So the read has to be by truthiness. A test against nil passes that `false`
-- through to the index below it, the error comes back out of sideTexture into
-- the pcall that calls it, textures() reports no card for the side -- and the
-- foe is simply not on the field. Which is the whole bug: fixing the player's
-- back pic took the enemy's billboard out.
local mine, theirs = {}, {}
local live = { player = { sprite = mine }, enemy = { sprite = theirs } }
T.eq(Battles.pinnedPic(live, mine), true,
"the player's own mon is the pic on the box")
T.eq(Battles.pinnedPic(live, theirs), false,
"and the foe is geometry out on the map, whatever the mode")
T.eq(Battles.pinnedPic({ player = false, enemy = { sprite = theirs } }, theirs),
false, "asking about the foe while the player is blanked answers, not throws")
T.eq(Battles.pinnedPic({ playerBackPic = mine }, mine), true,
"the trainer back holds the slot until Go!, on the box like the mon")
T.eq(Battles.pinnedPic({ player = false, playerBackPic = false }, mine), false,
"and with the side blanked outright nothing of it is pinned")
Battles.backSetting:setIndex(1, backGame) -- and off for the rows below
T.eq(Battles.pinnedPic(live, mine), false,
"with BACK SPRITES off the player's mon is out on the map with the foe")
end
-- ------- the hour reaches the FLAT world too
@@ -2611,21 +2695,35 @@ local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics")
-- 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 lastCanvas = nil -- what the readback asked newCanvas for
local function fill(rows)
-- '#' is ink and '.' the keyed-out nothing. 'W' is ink too, of the pic's
-- LIGHTEST shade -- a highlight the decoder happened not to key -- which is
-- what the paper a hole gets filled with is read off.
local SHADE = { ["#"] = 0.25, ["W"] = 0.75 }
-- reuse hands the SAME pic back through, which is how the two bottom rules
-- can be asked of one image the way a running battle would ask them
local function fill(rows, sealBottom, reuse)
local W, H = #rows[1], #rows
local built = nil
local function fakeData()
local px = {}
local px, sh = {}, {}
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
local shade = SHADE[rows[y + 1]:sub(x + 1, x + 1)]
px[y * W + x] = shade and 1 or 0
sh[y * W + x] = shade or 0
end
end
return {
px = px,
px = px, sh = sh,
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,
getPixel = function(self, x, y)
local k = y * W + x
return self.sh[k], self.sh[k], self.sh[k], self.px[k]
end,
setPixel = function(self, x, y, r, g, b, a)
self.px[y * W + x] = a
self.sh[y * W + x] = r
end,
}
end
@@ -2639,12 +2737,14 @@ local function fill(rows)
built = data
return { setFilter = function() end }
end
local pic = { getDimensions = function() return W, H end }
local out = BattlePics.filled(pic)
local pic = reuse or { getDimensions = function() return W, H end }
local out = BattlePics.filled(pic, sealBottom)
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
return out, pic,
built and function(x, y) return built.px[y * W + x] > 0.5 end,
built and function(x, y) return built.sh[y * W + x] end
end
-- ------- the cut at the feet, which is what the closed bottom edge is for
@@ -2727,6 +2827,75 @@ local drainOut, drainPic, drain = fill({
T.check(drainOut ~= drainPic and drain and drain(8, 4),
"a narrow one is where the drawing ran out, and is paper")
-- ------- but a pic ON THE MENU has no mouth at all
--
-- The drain/mouth cut is for a pic standing on the MAP, where a wide opening
-- along the bottom is a stride with real ground behind it. Under BACK SPRITES
-- the player's mon is drawn in the GB's own slot with its feet flush on the
-- text box, and the only thing under its lowest row is white box -- so nothing
-- reaches it from below, whatever the opening's width, and the rule stops
-- being a heuristic: paper is whatever the background cannot walk to from the
-- left, the right or the top.
--
-- Which is the difference between a Pikachu and a wireframe. The pale-bodied
-- back pics -- Pikachu, Seel, Dewgong, Chansey, Jigglypuff -- are drawn as
-- OUTLINES, every shade-0 pixel inside the ink keyed away, and each of them
-- leaks out through a bottom opening far too wide to read as a drain. On the
-- map that reading is right; on the box it left the mon a rim with the arena
-- showing through it.
--
-- The same stride figure the map rule leaves open, now standing on the box.
local boxOut, boxPic, boxOpaque = fill({
"..##############..",
"..##############..",
"..##############..",
"..###........###..",
"..###........###..",
"..###........###..",
}, true)
T.check(boxOut ~= boxPic and boxOpaque,
"the gap a stride would have shown the world through is paper on the box")
T.check(boxOpaque(8, 3) and boxOpaque(8, 5),
"and it fills right down to the row the feet are on")
-- the seal is the BOTTOM alone: the sides and the top still let the background
-- in, which is what keeps the silhouette cutting against the arena instead of
-- standing the mon in a white block
local boxGapOut, boxGapPic = fill({
"..#####.",
"..#...#.",
"..#...#.",
"....###.", -- opens at the left, and drains out that way
"..#####.",
"..#####.",
}, true)
T.eq(boxGapOut, boxGapPic,
"a pocket that drains out to the side is background on the box too")
-- ------- and a hole is filled with the pic's OWN paper, not with white
--
-- Shade 0 is white only while the pic is still grays, and by the time one
-- reaches here it usually is not: picImage hands it over after the bake -- a
-- species SGB colour, a BGP fade mid-animation, PAL_BLACK across the whole
-- screen while the blackout text is up -- and shade 0 travels with the rest.
-- A hardcoded white belly would be the one lit thing on a blacked-out mon.
--
-- So the paper is read off the pic: the lightest shade still standing in it,
-- which is shade 0 wherever the decoder could not reach one. It never has to
-- guess -- all 151 of this game's back pics keep at least one, an eye or a
-- highlight down a cheek.
local _, _, _, paperShade = fill({
"..####..",
"..#WW#..", -- a highlight the decoder did not key: this is the paper
"..#..#..",
"..#..#..",
"..####..",
})
T.eq(paperShade(3, 2), paperShade(3, 1),
"the hole takes the lightest shade the pic still has")
T.check(paperShade(3, 2) ~= paperShade(2, 2),
"which is not the ink beside it")
-- ------- and the readback is measured in PIXELS, which is what kept the mons
-- the size of the squares they stand on
--
@@ -2743,12 +2912,31 @@ T.check(drainOut ~= drainPic and drain and drain(8, 4),
-- scale that was wrong everywhere.
T.check(lastCanvas and lastCanvas.opts and lastCanvas.opts.dpiscale == 1,
"the readback canvas is one texel per pic pixel, on a highdpi phone too")
T.eq(lastCanvas.w, 18, "and it is the size of the pic, in those pixels")
T.eq(lastCanvas.w, 8, "and it is the size of the pic, in those pixels")
-- 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")
-- and cached PER BOTTOM RULE, because one image answers differently on the map
-- and on the box. A single table for both would hand whichever caller asked
-- second the other one's answer -- the map's stencil to the menu, or a menu
-- fill to a mon standing on grass -- which is this section's bug arriving
-- through the cache rather than through the flood.
--
-- The stride figure again, on the SAME pic the map rule already answered for.
local reOut = fill({
"..##############..",
"..##############..",
"..##############..",
"..###........###..",
"..###........###..",
"..###........###..",
}, true, stridePic)
T.check(reOut ~= stridePic,
"the pic the map left open still fills when the box asks for it")
T.eq(BattlePics.filled(stridePic), stridePic,
"and the map's own answer for it is still the pic itself")
BattlePics.invalidate()
end
+304 -11
View File
@@ -547,6 +547,98 @@ TEMPLATES = {
roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9),
slab=4, front_eave=4, ledge=None, tileset="forest",
),
# F01: the starter-ball table in Oak's lab -- the first FURNITURE
# through the pipeline, and the first drawing whose plot is smaller
# than its grid. Rows 0-15 are the tabletop seen from above; 16-18
# are the top slab's own front edge (black/#555/black -- exactly
# what the rim treatment paints, so slab=3 and those rows fold into
# the roof band instead of extruding); 19-21 are the base: corner
# feet and the inset dark panel between them. The legs stand on
# open FLOOR, so the measured ground line lands two rows short of
# the grid; `depth` keeps the plot to the blocked cell row -- the
# legs row of the grid is the walkable cell the player faces the
# table from, and D = len(tiles) would stand the model in their
# path. 16 top rows onto a 16px plot map 1:1: no cycling, and
# roof_cycle is unreachable behind roof_back=16.
"lab_table": dict(
tiles=[
[41, 59, 59, 59, 59, 42],
[78, 57, 57, 57, 57, 79],
[88, 89, 89, 89, 89, 90],
],
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
),
# F02: the computer desk in Oak's lab -- and the Hall of Fame's
# recording machine, the same drawing on the GYM atlas (one
# placement each; both registered in voxel_heights). The one
# DESK-SET template: the methodology's region classification at
# part granularity. The desk is the sibling lab table (fascia rows
# 16-18, base 19-21); on it stand a monitor over its keyboard
# (left), a computer tower over a keyboard and mouse (middle), and
# a sheet of paper LYING FLAT (right). Upright parts anchor their
# drawn bottom row to the desk's top plane and wear their own drawn
# tops as lids; flat parts lie one voxel proud with drawn row =
# depth row -- the same 1:1 the tabletop itself is drawn with, so
# an object's height ON the drawing is its position ON the desk.
# The desk's own top is the one synthesized surface (the objects
# cover every pixel of it), continued from the sibling tables'
# pattern in the drawing's own shades.
"lab_computers": dict(
tiles=[
[91, 92, 93, 94],
[54, 55, 85, 95],
[88, 89, 89, 90],
],
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
slab=0, front_eave=0, ledge=None, tileset="gym", depth=2,
desk=dict(fascia=(16, 18), base=(19, 21)),
parts=[
dict(kind="upright", x=(2, 13), top=(0, 2), facade=(3, 10),
depth=4), # the monitor
dict(kind="flat", x=(1, 13), rows=(11, 14)), # its keyboard
dict(kind="upright", x=(14, 21), top=(0, 3), facade=(4, 10),
depth=6), # the tower
dict(kind="flat", x=(14, 21), rows=(11, 14)), # keys + mouse
dict(kind="flat", x=(22, 30), rows=(1, 14)), # the paper
],
),
# F03: the empty north table beside it -- the starter table's band
# table verbatim on a grid two tiles narrower (one placement).
"lab_table_small": dict(
tiles=[
[41, 59, 59, 42],
[78, 57, 57, 79],
[88, 89, 89, 90],
],
roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13),
slab=3, front_eave=0, ledge=None, tileset="gym", depth=2,
),
# F04: the Pokemon Center's PC -- every Center's northeast corner
# (11 placements) plus the Indigo Plateau lobby, whose MART tileset
# shares this atlas. The lab desk-set read again: a
# Mac-style unit drawn face-on -- white top band (rows 0-3), bezel,
# screen and drive slot (4-14) -- standing at the back of a low
# desk whose front face is rows 20-23 and whose drawn top (the
# white sliver of row 15 and the margins beside the unit) names the
# lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top
# span, so the flat part carries an authored z origin: it lies at
# the desk's front edge, in front of the unit.
"center_pc": dict(
tiles=[
[66, 70],
[82, 86],
[9, 88],
],
roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0),
slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2,
desk=dict(fascia=(20, 21), base=(22, 23), lid="white"),
parts=[
dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14),
depth=6), # the unit
dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard
],
),
# B23: the Victory Road entrance on Route 23: a rock face with two
# barred doors. The roof band is the pale cliff top seen from above.
"victory_road_gate": dict(
@@ -642,7 +734,14 @@ def profile(sp, t):
r = next((y for y in range(H) if inside(x, y)), t["roof_rows"])
top.append(min(r, t["roof_rows"]))
wall_h = H - t["roof_rows"]
# The drawing's own ground line: the row after the last drawn one. A
# building ends on the black threshold row it stands on (ground == H),
# but furniture is drawn standing on open floor -- the lab table's
# legs stop two rows short of its grid -- and extruding against H
# would float it that far above its own plot.
ground = max((y for y in range(H) for x in range(W) if inside(x, y)),
default=H - 1) + 1
wall_h = ground - t["roof_rows"]
ytop = wall_h - 1 + t["slab"]
# Recesses: the panes the art seals behind a black frame. Non-black
@@ -674,15 +773,136 @@ def profile(sp, t):
if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX:
recess.update(cells)
# Depth is the PLOT. For a whole-drawing building that is the grid
# itself; `depth` (in tile rows) names it when the grid runs past the
# plot onto ground the drawing merely stands its legs on.
return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess,
inside=inside, D=H, W=W, H=H)
inside=inside, D=t.get("depth", len(t["tiles"])) * 8,
ground=ground, W=W, H=H)
# --------------------------------------------------------------- stage 3 --
def build_desk_set(sp, pr, t):
"""A desk with separately-classified objects on it (the `parts` list):
upright parts stand on the desk's top plane wearing their own drawn
tops as lids, flat parts lie one voxel proud at drawn row = depth row,
and the desk itself is the lab-table slab + base with a synthesized
lid (the objects cover every drawn pixel of the tabletop)."""
W, H, D = pr["W"], pr["H"], pr["D"]
inside = pr["inside"]
col, src = sp["col"], sp["src"]
ground = pr["ground"]
vox = {}
def put(x, y, z, sx, sy):
vox[(x, y, z)] = (col[sy][sx], src[sy][sx])
shade_px = {}
for sy in range(H):
for sx in range(W):
if inside(sx, sy):
shade_px.setdefault(col[sy][sx], (sx, sy))
def interior(sx, sy, lo, hi):
if col[sy][sx] != BLACK:
return sx
step = 1 if sx < (lo + hi) // 2 else -1
for d in range(1, 4):
nx = sx + step * d
if lo <= nx <= hi and inside(nx, sy) and col[sy][nx] != BLACK:
return nx
return sx
f0, f1 = t["desk"]["fascia"]
b0, b1 = t["desk"]["base"]
plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane
# the base band, extruded exactly like every lab table's
for sy in range(b0, b1 + 1):
y = ground - 1 - sy
for sx in range(W):
if not inside(sx, sy):
continue
ix = interior(sx, sy, 0, W - 1)
for z in range(D):
put(sx, y, z, sx if z in (0, D - 1) else ix, sy)
for sx, sy in pr["recess"]:
if b0 <= sy <= b1:
vox.pop((sx, ground - 1 - sy, D - 1), None)
# the slab: the fascia rows wrap every side; the lid is the one
# synthesized surface in the model -- the drawing never paints the
# tabletop (its objects cover it), so the lid continues the sibling
# tables' pattern in the drawing's own shades: black rim, white
# highlight courses along the north and west, grey field
for i, sy in enumerate(range(f0, f1 + 1)):
y = plane - 1 - i
for sx in range(W):
for z in range(D):
put(sx, y, z, sx, sy)
field = WHITE if t["desk"].get("lid") == "white" else GREY
for sx in range(W):
for z in range(D):
if sx in (0, W - 1) or z in (0, D - 1):
shade = BLACK
elif sx == 1 or z == 1:
shade = WHITE
else:
shade = field
px = shade_px.get(shade) or shade_px[BLACK]
put(sx, plane - 1, z, px[0], px[1])
for p in t["parts"]:
x0, x1 = p["x"]
if p["kind"] == "flat":
r0, r1 = p["rows"]
for sy in range(r0, r1 + 1):
# drawn row = depth row by default; `z` renames the
# origin when the flat sits below the desk's own drawn
# top span (the Center PC's keyboard)
z = p.get("z", r0) + (sy - r0)
if not 0 <= z < D:
continue
for sx in range(x0, x1 + 1):
if inside(sx, sy):
put(sx, plane, z, sx, sy)
continue
tr0, tr1 = p["top"]
fr0, fr1 = p["facade"]
pd = p["depth"]
ytp = plane + (fr1 - fr0)
for sx in range(x0, x1 + 1):
# the lid: the part's drawn top laid across its depth from
# the back, last row continuing forward; the front lid row
# is the facade's own top row -- the drawn front-top edge
for z in range(pd):
sy = fr0 if z == pd - 1 else min(tr0 + z, tr1)
while sy <= tr1 and not inside(sx, sy):
sy += 1
if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)):
continue
put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy)
# the body: facade rows anchored to the desk's top plane
for sy in range(fr0 + 1, fr1 + 1):
y = plane + (fr1 - sy)
if not inside(sx, sy):
continue
ix = interior(sx, sy, x0, x1)
for z in range(pd):
if z == pd - 1:
if (sx, sy) not in pr["recess"]:
put(sx, y, z, sx, sy)
else:
put(sx, y, z, sx if z == 0 else ix, sy)
return vox
def build(sp, pr, t):
"""The voxel model. Order is load-bearing: walls, ledge, recesses, then
the roof solid overwrites what it intersects and the walls are trimmed
to the roof's underside."""
if t.get("parts"):
return build_desk_set(sp, pr, t)
W, H, D = pr["W"], pr["H"], pr["D"]
inside, top, ytop = pr["inside"], pr["top"], pr["ytop"]
col, src = sp["col"], sp["src"]
@@ -720,7 +940,9 @@ def build(sp, pr, t):
# ---- walls: the facade rows extruded straight back, trimmed under the roof
for sy in range(t["roof_rows"], H):
y = H - 1 - sy
y = pr["ground"] - 1 - sy # rows below the ground line are floor
if y < 0:
continue
for sx in range(W):
if not inside(sx, sy) or trimmed(sx, y):
continue
@@ -742,7 +964,7 @@ def build(sp, pr, t):
if t["ledge"]:
l0, l1 = t["ledge"]
for sy in range(l0, l1 + 1):
y = H - 1 - sy
y = pr["ground"] - 1 - sy
for sx in range(W):
if inside(sx, sy) and not trimmed(sx, y):
for z in (-2, -1, D, D + 1):
@@ -750,7 +972,7 @@ def build(sp, pr, t):
# ---- recesses: the front voxel of every pane sinks, its frame stays proud
for sx, sy in pr["recess"]:
vox.pop((sx, H - 1 - sy, D - 1), None)
vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None)
# ---- roof: flat top over the plateau, stepped diagonal ends
z0, z1 = 0, D - 1 + t["front_eave"]
@@ -805,6 +1027,83 @@ def verify(vox, sp, pr, t):
for (x, y, z), _ in vox.items():
assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}"
# A desk set answers its own asserts; a facade-only template (roof_rows
# == 0) has no roof band, so there is no surface to hold level or slope
# and no coverage to demand of it; everything with a roof band answers
# the full set.
if t.get("parts"):
verify_desk_set(vox, pr, t)
elif t["roof_rows"] == 0:
# its one geometric intent: a straight extrusion, so each column
# of the drawing is solid from its lowest voxel to its top. NOT
# from the ground -- the drawing's base band is inset like every
# lab table's, and the body's outer columns legitimately
# overhang it -- and not on the recess layer, which sinks panes.
lo, hi = {}, {}
for (x, y, z) in vox:
lo[(x, z)] = min(lo.get((x, z), y), y)
hi[(x, z)] = max(hi.get((x, z), y), y)
for (x, z), h in hi.items():
if z == D - 1:
continue
assert all((x, y, z) in vox for y in range(lo[(x, z)], h + 1)), \
f"facade column has a hole at {x},{z}"
else:
verify_roof(vox, pr, t)
shell = [k for k in vox if not all(
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
return shell
def verify_desk_set(vox, pr, t):
W, D = pr["W"], pr["D"]
f0, f1 = t["desk"]["fascia"]
b0, b1 = t["desk"]["base"]
plane = (b1 - b0 + 1) + (f1 - f0 + 1)
# the slab is a full solid under everything on it
for x in range(W):
for z in range(D):
for y in range(b1 - b0 + 1, plane):
assert (x, y, z) in vox, f"slab hole at {x},{y},{z}"
# each part stands where authored -- upright bodies solid from the
# desk to their own drawn top (bar the front layer, whose panes
# sink) -- and nothing stands anywhere else
tops = {}
for p in t["parts"]:
x0, x1 = p["x"]
if p["kind"] == "flat":
r0, r1 = p["rows"]
z0 = p.get("z", r0)
for x in range(x0, x1 + 1):
for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1):
tops[(x, z)] = max(tops.get((x, z), 0), plane)
else:
fr0, fr1 = p["facade"]
ytp = plane + (fr1 - fr0)
for x in range(x0, x1 + 1):
for z in range(p["depth"]):
tops[(x, z)] = max(tops.get((x, z), 0), ytp)
if z == p["depth"] - 1:
continue
ys = [y for y in range(plane, ytp + 1)
if (x, y, z) in vox]
assert ys == list(range(ys[0], ys[0] + len(ys))) \
if ys else True, f"part column hole at {x},{z}"
for (x, y, z) in vox:
assert y <= tops.get((x, z), plane - 1), \
f"voxel above its part at {x},{y},{z}"
def verify_roof(vox, pr, t):
W, H, D = pr["W"], pr["H"], pr["D"]
ytop, top, slab = pr["ytop"], pr["top"], t["slab"]
T = lambda x: ytop - top[max(0, min(W - 1, x))]
# The roof surface reads over the columns the drawing actually paints:
# a sprite inset from its box says nothing about the rest.
roofed = [x for x in range(W) if top[x] < t["roof_rows"]]
@@ -845,12 +1144,6 @@ def verify(vox, sp, pr, t):
elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)):
assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}"
shell = [k for k in vox if not all(
(k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox
for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0),
(0, -1, 0), (0, 0, 1), (0, 0, -1)))]
return shell
def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20):
# Mirroring depth swings the camera round to the other side. Mirror