iterate version
DramaticShape · 2026-08-01 ·
parent 752653e243
iterate version
Changed files
CHANGELOG.md | 61 +++++++++++++++++
data/voxel_heights.lua | 82 +++++++++++++++++++++--
lib/AntiAlias.lua | 244 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib/Buildings.lua | 15 +++--
lib/Structures.lua | 18 +++--
lib/TileShape.lua | 7 ++
main.lua | 2 +-
manifest.json | 2 +-
tests/aa_shots.lua | 195 +++++++++++++++++++++++++++++++++++++++++++++++++++++
tools/building_voxels.py | 39 +++++++++--
10 files changed, 644 insertions(+), 21 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f9ea089..5bb313c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index cbf4fda..97f52f6 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -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,
@@ -705,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
@@ -3320,6 +3334,62 @@ return {
},
},
+ 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
diff --git a/lib/AntiAlias.lua b/lib/AntiAlias.lua
new file mode 100644
index 0000000..30f8761
--- /dev/null
+++ b/lib/AntiAlias.lua
@@ -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
diff --git a/lib/Buildings.lua b/lib/Buildings.lua
index d378ec7..b07c913 100644
--- a/lib/Buildings.lua
+++ b/lib/Buildings.lua
@@ -378,9 +378,10 @@ local function deskSetModel(sp, pr, t)
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 = GREY
+ 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
@@ -395,10 +396,16 @@ local function deskSetModel(sp, pr, t)
Budget.tick()
local x0, x1 = p.x[1], p.x[2]
if p.kind == "flat" then
- for sy = p.rows[1], p.rows[2] do
- if sy >= 0 and sy < D 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, sy, sy * W + sx) end
+ if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end
end
end
end
diff --git a/lib/Structures.lua b/lib/Structures.lua
index 0b5262a..2e3e70c 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -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
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index f36f87d..31e5916 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -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
diff --git a/main.lua b/main.lua
index 6ca5f8a..01aaf0f 100644
--- a/main.lua
+++ b/main.lua
@@ -890,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
diff --git a/manifest.json b/manifest.json
index 157448b..b2382d4 100644
--- a/manifest.json
+++ b/manifest.json
@@ -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",
diff --git a/tests/aa_shots.lua b/tests/aa_shots.lua
new file mode 100644
index 0000000..dcd484a
--- /dev/null
+++ b/tests/aa_shots.lua
@@ -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
diff --git a/tools/building_voxels.py b/tools/building_voxels.py
index 6cb00b7..1ca5fd2 100644
--- a/tools/building_voxels.py
+++ b/tools/building_voxels.py
@@ -614,6 +614,31 @@ TEMPLATES = {
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(
@@ -815,6 +840,7 @@ def build_desk_set(sp, pr, t):
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):
@@ -822,7 +848,7 @@ def build_desk_set(sp, pr, t):
elif sx == 1 or z == 1:
shade = WHITE
else:
- shade = GREY
+ shade = field
px = shade_px.get(shade) or shade_px[BLACK]
put(sx, plane - 1, z, px[0], px[1])
@@ -831,11 +857,15 @@ def build_desk_set(sp, pr, t):
if p["kind"] == "flat":
r0, r1 = p["rows"]
for sy in range(r0, r1 + 1):
- if not 0 <= sy < D:
+ # 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, sy, sx, sy)
+ put(sx, plane, z, sx, sy)
continue
tr0, tr1 = p["top"]
fr0, fr1 = p["facade"]
@@ -1048,8 +1078,9 @@ def verify_desk_set(vox, pr, t):
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(r0, 0), min(r1, D - 1) + 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"]