updates to oak's lab

This commit is contained in:
DramaticShape
2026-08-01 16:03:32 -04:00
parent a140980b1d
commit 6887f5d951
12 changed files with 426 additions and 25 deletions
+66
View File
@@ -116,6 +116,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
+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
+90
View File
@@ -579,6 +579,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
@@ -3231,5 +3237,89 @@ 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 bank on the lab's west table (OAKS_LAB cell
-- 0,1) -- the one FACADE-ONLY template: rows 0-14 are machines
-- drawn face-on (monitor and keys, the centre console, the right
-- cabinet) over the lab-table fascia and base bands, and nothing
-- is drawn from above. roofRows = 0, so the whole drawing
-- extrudes -- the house facade treatment with no roof band; the
-- roof fields are inert and the recess pass sinks every framed
-- pane (screens, keys, the cabinet's inset face) into relief on
-- its own. 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,
},
-- 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,
},
},
GYM = {
-- F02 again: the Hall of Fame's recording machine is the lab's
-- computer bank drawing, tile for tile, on the GYM atlas (one
-- placement: HALL_OF_FAME cell 4,1). Same band 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,
},
},
},
}
+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
+30 -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,7 +296,12 @@ 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
@@ -292,7 +314,7 @@ end
local function model(sp, pr, t)
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 +389,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
+18
View File
@@ -295,6 +295,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)
+33 -3
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 = {}
+57 -1
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
+104 -11
View File
@@ -547,6 +547,57 @@ 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 bank on Oak's lab's west table -- 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
# FACADE-ONLY template: rows 0-14 are machines drawn face-on over
# the lab-table fascia and base bands, nothing is drawn from above,
# so roof_rows=0 and the whole drawing extrudes -- the house facade
# treatment with no roof band. The recess pass turns every framed
# pane (the monitor, its keys, the right cabinet's inset face) into
# relief on its own.
"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,
),
# 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,
),
# 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 +693,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,8 +732,12 @@ 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 --
@@ -720,7 +782,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 +806,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 +814,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 +869,41 @@ 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 facade-only template (roof_rows == 0: the lab computer bank) has
# no roof band, so there is no surface to hold level or slope and no
# coverage to demand of it; the poke-through assert above and the
# facade asserts below are what it answers to. Everything with a roof
# band answers the full set.
if 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_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 +944,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