update 2d shiny sprites and shiny animation
DramaticShape · 2026-08-08 ·
parent 34e7da5c12
update 2d shiny sprites and shiny animation
Changed files
CHANGELOG.md | 44 +++++++++++
data/voxel_heights.lua | 60 ++++++++++++++-
lib/Buildings.lua | 280 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
lib/Structures.lua | 89 +++++++++++++++++++++-
lib/TileShape.lua | 7 ++
lib/Voxel3D.lua | 17 ++++-
6 files changed, 485 insertions(+), 12 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc3e552..bca1656 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -87,6 +87,50 @@
on the pattern by luck -- without that, every setting would be itself
and 1/8192 in parallel, and no setting could ever be rarer than 8192.
+- **DOORS ON A GATE HOUSE'S OTHER SIDES.** A route gate is walked
+ through, so it opens on two opposite faces -- and the overworld drawing
+ can only show one. The sprite is a facade seen face-on under a roof
+ seen from above, so a SOUTH entrance is drawn (a doorway block in the
+ facade's last rows, which `Structures` folds up into the front face)
+ and a north, east or west one is drawn as nothing at all: the warp
+ sits on the ground cell outside, the art beside it is plain wall.
+ Top-down that reads fine, because the wall is never seen. In 3D you
+ walked into a blank slab -- 33 of them, on eleven gates.
+
+ Each is now a real doorway, one cell of the tileset's own door art
+ standing on the ground of the face you walk into, hung by the SAME
+ rule the drawn facade hangs its own door by: the black frame stays
+ flush with the wall and what it seals sinks a voxel behind it, so a
+ side door and a front door are the same opening at the same depth, and
+ the jambs the recess exposes fall out of the mesher wearing the
+ frame's own texels. The art's outer ring is left alone -- a doorway
+ cell is a cell OF a facade, its border is the wall beside and above
+ the frame, and painting all 16x16 would stamp a one-pixel strip of
+ front-wall art around every door. The same flood the facade tells wall
+ from pane with, bounded to the cell, tells them apart here.
+
+ **Nothing is authored but the art.** `data/voxel_heights.lua` names
+ one door cell per tileset and no coordinates: where the doors go is
+ read off the map, from the warps that land in a gate house and the
+ building standing against them. A warp is an entrance when it leads
+ into a GATE-tileset map, is not already ON a door tile (a drawn south
+ door would be fought over), and stands on a WALKABLE cell -- the ROM
+ gives several gates an unreachable twin warp on the fence or tree
+ beside the real opening, and a door behind a fence is a door into
+ nothing. What is left is the entrance, and the two cells side by side
+ that most gates do have come out as the double door they always were.
+ So no hand list to drift out of step with a map edit, and every other
+ building in the game is untouched: exactly the eleven gate placements
+ get doors.
+
+ Doors belong to the PLACEMENT, not the drawing -- the same 6x4 block
+ is the gate on four routes and the warps sit at different rows of it
+ on each -- so the model cache is keyed by the openings as well, and
+ only placements that agree share a model. It costs about 290 quads a
+ door (a flank quad carries one texel, so the art cannot merge into
+ strips the way a facade's does) and no voxels: the recess removes as
+ many faces as it exposes.
+
## 1.8.0
### Added
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index a963ab3..2bb11c5 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -890,9 +890,8 @@ return {
-- which has a PERSON drawn into the tile art -- becomes a monolith
-- wearing his face.
POKECENTER = {
- -- the wall band stays one 16px face: striped panels (40), the high
- -- windows (92-95; 94 doubles as the map's warp tile, and pins are
- -- look-only), the pokeball poster (2/3/18/19), and the pillars
+ -- the wall band stays one 16px face: striped panels (40), the
+ -- pokeball poster (2/3/18/19), and the pillars
-- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback
-- trap and would recess into a pond lip). The healing machines'
-- console face (76/77) and button panel (6/22) are ALSO wall:
@@ -905,7 +904,30 @@ return {
-- void rule flattens them. What is NOT wall is the machines'
-- two flanks -- see `prop` below.
wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41,
- 76, 77, 92, 93, 94, 95 },
+ 76, 77 },
+ -- THE CABLE CLUB STEPS, cut into the back wall at cells (10,0) and
+ -- (12,0) of every Center -- a flight going DOWN, away from the room,
+ -- and the reason `stair_down_n` exists at all: the profile's other
+ -- stairs run east or west and are drawn from the SIDE, where a drawn
+ -- column is a step; these are drawn HEAD-ON, where a drawn row is,
+ -- and no rotation of the east/west reading produces that.
+ --
+ -- The drawing is its own band table, and it lands exactly on an even
+ -- four-step division of the cell: 4 white rows (the near tread), a
+ -- black nosing, 3 grey, a nosing, 3 checker, then 4 black rows -- the
+ -- dark the flight leaves into, which is also what the far end wall
+ -- wears. Its first and last COLUMNS are the well's black side walls.
+ -- Drawn row = depth row throughout; the rise is the only number the
+ -- head-on view cannot state, and it takes the class height over the
+ -- four steps like every other flight here.
+ --
+ -- Pinned as one cell (the class resolves off the top-left tile) but
+ -- all four ids carry it, and the scan says they cannot reach anything
+ -- else: 22 placements, exactly the two cells in each of the eleven
+ -- Centers, and the Celadon Hotel on the same id places none of them.
+ -- 94 is also the map's warp tile; pins are look-only, so the warp is
+ -- untouched and the steps stay walk-through.
+ stair_down_n = { 92, 93, 94, 95 },
-- the counters, half a cell high: top band (8) and the one cell of
-- it that carries the push bell (10, lifted off as a figure below --
-- the pin stays as the degradation path), front face (24/25, the
@@ -3072,6 +3094,36 @@ return {
FOREST = { 42, 43, 58, 59 },
},
+ -- THE DOOR A GATE HOUSE IS ENTERED BY FROM ANY SIDE BUT THE SOUTH.
+ --
+ -- A route gate is walked THROUGH, so it has an opening on two opposite
+ -- sides -- and the drawing can only show one of them. The overworld
+ -- sprite is a facade seen face-on with a roof laid over it, so a south
+ -- entrance is drawn (a doorway block in the facade's last rows, folded up
+ -- by lib/Structures.lua) and a north, east or west one is drawn as
+ -- NOTHING: the warp sits on the ground cell outside, the art beside it is
+ -- plain wall, and top-down that reads fine because you never see the
+ -- wall. In 3D you walk straight into a blank slab.
+ --
+ -- So the door is put back, on the face the player walks into. This names
+ -- only the ART -- one 16x16 cell of the tileset's own doorway block, rows
+ -- north-first, the same ids `frontOnly` above lists as facade-only.
+ -- WHERE it goes is not authored at all: lib/Buildings.lua reads it off
+ -- the map, from the warps that land in a gate and the building standing
+ -- against them (see `sideDoors` there), because the map already states it
+ -- and a hand list of thirty-odd coordinates would only be a chance to get
+ -- one wrong.
+ --
+ -- OVERWORLD is the whole table because every such entrance in the game
+ -- stands against an OVERWORLD building: the Safari Zone's north gate is a
+ -- gap between two fence stubs with no drawing to carve, and the Route 22
+ -- league gate on ROUTE_23 puts its warps on the road THROUGH the arch
+ -- rather than against a wall. Both come out with no door, which is what
+ -- they always had.
+ sideDoors = {
+ OVERWORLD = { { 11, 12 }, { 27, 28 } },
+ },
+
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
-- the pipeline in assets/docs/buidling_to_voxel/). A building is matched by its
-- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so
diff --git a/lib/Buildings.lua b/lib/Buildings.lua
index f11d1ca..46f89a8 100644
--- a/lib/Buildings.lua
+++ b/lib/Buildings.lua
@@ -136,6 +136,13 @@ end
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
local frontSets = {} -- tileset id -> { [tile] = true } or false
+-- The tileset's side-door art: the 2x2 tile grid of one doorway cell
+-- (data/voxel_heights.lua `sideDoors`), or nil when the tileset names none.
+function Buildings.sideDoorCell(tilesetId)
+ local s = profile()
+ return s and s.sideDoors and s.sideDoors[tilesetId] or nil
+end
+
-- The tileset's front-only tiles as a set (data/voxel_heights.lua
-- `frontOnly`): the doorways, shop signs and painted lettering that belong
-- on a facade and on no other face of the same building. nil when the
@@ -252,6 +259,82 @@ end
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
-- is built from the complete drawing and the tower rises to its real
-- height instead of folding as two half-buildings.
+-- Composite one doorway cell PAST the end of the sprite, at indices
+-- W*H .. W*H+255, and hand back its base. The side-door pass paints with
+-- sprite indices like everything else -- `emit` resolves a voxel's colour
+-- through sp.ax/sp.ay and knows nothing about where the index came from --
+-- so the door only has to BE in the sprite arrays to travel the rest of
+-- the pipeline untouched. Appending rather than drawing into the grid is
+-- the point: the drawing itself must not change, or the silhouette flood,
+-- the taper and every measured band would be read off art the tileset
+-- never placed here.
+--
+-- Nothing else walks past W*H (measure's shadeTexel scan and the pane
+-- flood both stop there), so the block is invisible to measurement and
+-- visible only to the code that asks for it by index.
+--
+-- WHICH OF THE 256 TEXELS ARE THE DOOR. A doorway cell is not a doorway
+-- edge to edge: the tileset draws it as a cell OF A FACADE, so its outer
+-- ring is the wall beside and above the frame, and its last row is the
+-- black threshold the building stands on with the door's own step cut into
+-- it. Painting all 16x16 onto a flank would stamp a one-pixel border of
+-- front-wall art around every door.
+--
+-- The front facade tells the ring from the door by flooding: the wall
+-- around the door is one region with the whole facade, far too big to be a
+-- pane, and only what the black frame SEALS sinks. The same test, bounded
+-- to the block: flood the left, right and top edges through their own
+-- shade class, and what the flood reaches is context -- left unpainted, so
+-- the flank keeps the texel it already had. Not the bottom edge, because
+-- the bottom edge is the ground: the step under the door is sealed there
+-- on the drawn facade too, and it recesses with the rest of the doorway.
+--
+-- What is painted then splits the way a facade's does: black is frame and
+-- stays flush with the wall, everything it seals sinks a voxel behind it.
+local function readDoor(sp, data, perRow, cell)
+ local base = sp.W * sp.H
+ local black = {}
+ for dy = 0, 15 do
+ local row = cell[math.floor(dy / 8) + 1]
+ for dx = 0, 15 do
+ local tile = row[math.floor(dx / 8) + 1]
+ local px = (tile % perRow) * 8 + dx % 8
+ local py = math.floor(tile / perRow) * 8 + dy % 8
+ local k = dy * 16 + dx
+ local i = base + k
+ sp.ax[i], sp.ay[i] = px, py
+ local r, g, b, a = data:getPixel(px, py)
+ sp.col[i] = shadeOf(r, g, b, a)
+ sp.inside[i] = true
+ black[k] = sp.col[i] == BLACK
+ end
+ end
+
+ local context, stack = {}, {}
+ local function seed(dx, dy, cls)
+ if dx < 0 or dx > 15 or dy < 0 or dy > 15 then return end
+ local k = dy * 16 + dx
+ if context[k] or black[k] ~= cls then return end
+ context[k] = true
+ stack[#stack + 1] = k
+ end
+ for dy = 0, 15 do
+ seed(0, dy, black[dy * 16])
+ seed(15, dy, black[dy * 16 + 15])
+ end
+ for dx = 0, 15 do seed(dx, 0, black[dx]) end
+ while #stack > 0 do
+ local k = table.remove(stack)
+ local dx, dy, cls = k % 16, math.floor(k / 16), black[k]
+ seed(dx + 1, dy, cls)
+ seed(dx - 1, dy, cls)
+ seed(dx, dy + 1, cls)
+ seed(dx, dy - 1, cls)
+ end
+
+ sp.door = { base = base, black = black, context = context }
+end
+
local function read(t, data, perRow, frontOnly)
local tiles = t.tiles
if t.topRows then
@@ -1087,11 +1170,113 @@ local function deskSetModel(sp, pr, t)
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
end
+-- ------- the doorway a gate house is entered by from a side the drawing
+-- never shows it on (data/voxel_heights.lua `sideDoors`, and `sideDoorsAt`
+-- below for how the placements are found).
+--
+-- One cell of the tileset's own doorway art, standing on the ground of the
+-- face the player walks into, and hung by the SAME rule the drawn facade
+-- hangs its own door by: the art's black frame stays flush with the wall
+-- and everything it seals sinks a voxel behind it (`measure`'s pane pass,
+-- applied here by hand because the art is not in the drawing to be flooded
+-- with it). So a side door and a front door are the same depth of the same
+-- opening, and the jamb faces the recess exposes come out of the mesher for
+-- free, wearing the frame's own texels.
+--
+-- ORIENTATION IS NOT FREE. A flank quad carries one texel and the mesher
+-- picks it per voxel, so which art column lands at which world coordinate
+-- is decided HERE and nowhere else -- and a face is read from outside, so
+-- the art's own left-to-right runs with the viewer's, not with the world's:
+-- facing east at a west wall, south is to your right (+z); facing west at
+-- an east wall, north is (-z); facing south at a north wall, west is (-x).
+-- Two of the three are mirrored against the axis, which is the same reason
+-- `backMap` exists -- a wall seen from behind IS the drawing mirrored.
+local DOOR = 16
+
+local function sideDoors(at, sp, doors, W)
+ if not (doors and #doors > 0 and sp.door) then return at end
+ local art = sp.door
+
+ -- The face's OUTER surface, walked in from the box edge until the wall
+ -- answers. A drawing inset from its own grid (B03's outer columns are
+ -- terrain, not building) stands its flank a column or two in, and a door
+ -- pinned to the box edge would hang in the air beside it.
+ local function faceX(from, step, off)
+ for k = 0, W - 1 do
+ local x = from + step * k
+ for y = 0, DOOR - 1 do
+ for z = off, off + DOOR - 1 do
+ if at(x, y, z) then return x end
+ end
+ end
+ end
+ return nil
+ end
+
+ local list = {}
+ for _, d in ipairs(doors) do
+ local face = 0 -- north: the facade's own z origin
+ if d.side == "w" then face = faceX(0, 1, d.at)
+ elseif d.side == "e" then face = faceX(W - 1, -1, d.at) end
+ if face then
+ list[#list + 1] = { side = d.side, off = d.at, face = face }
+ end
+ end
+ if #list == 0 then return at end
+
+ -- art column at (x, z) for door `e`, or nil when the voxel is not in it
+ local function column(e, x, z)
+ if e.side == "n" then
+ if (z == 0 or z == 1) and x >= e.off and x < e.off + DOOR then
+ return e.off + DOOR - 1 - x, z == 0
+ end
+ elseif z >= e.off and z < e.off + DOOR then
+ if e.side == "w" then
+ if x == e.face then return z - e.off, true end
+ if x == e.face + 1 then return z - e.off, false end
+ else
+ if x == e.face then return e.off + DOOR - 1 - z, true end
+ if x == e.face - 1 then return e.off + DOOR - 1 - z, false end
+ end
+ end
+ return nil
+ end
+
+ return function(x, y, z)
+ local v = at(x, y, z)
+ -- no wall here is the end of it: a door is hung ON the building, and
+ -- nothing about it may add geometry the drawing does not stand up
+ if v == nil or y >= DOOR then return v end
+ for _, e in ipairs(list) do
+ local c, outer = column(e, x, z)
+ if c then
+ -- art row 0 is the door's head, so the ground row is its last
+ local k = (DOOR - 1 - y) * DOOR + c
+ -- the art's own ring: wall, not door. The flank keeps its texel.
+ if art.context[k] then return v end
+ if art.black[k] then
+ -- the frame, flush with the wall; behind it the wall stands on
+ if outer then return art.base + k end
+ return v
+ end
+ -- and what the frame seals sinks: the face voxel goes, the one
+ -- behind it wears the art. (Written long: `outer and nil or i`
+ -- returns i for BOTH, nil being false to `and`.)
+ if outer then return nil end
+ return art.base + k
+ end
+ end
+ return v
+ end
+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)
+-- are trimmed to its underside so nothing pokes through the surface. A
+-- gate's side doors are hung on the finished lookup, last of all, because
+-- they answer to the FACE rather than to any band of the drawing.
+local function model(sp, pr, t, doors)
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
@@ -1209,6 +1394,8 @@ local function model(sp, pr, t)
return pr.interior[i]
end
+ at = sideDoors(at, sp, doors, W)
+
return { at = at, W = W, ytop = ytop,
zmin = ledge0 and -2 or 0,
zmax = math.max(rz1, ledge0 and (D + 1) or 0) }
@@ -1420,6 +1607,76 @@ local function matches(S, t, tx, ty)
return true
end
+-- ------- which of a placement's faces a gate is entered by
+--
+-- Read off the MAP, not authored: a gate entrance is a warp that lands in a
+-- gate house, and the face is whichever one of this placement the warp cell
+-- stands against. Thirty-odd doors fall out of two lines of geometry, and
+-- none of them can drift out of step with a map edit the way a hand list
+-- would. Three tests, each of them load-bearing:
+--
+-- the destination is a GATE tileset -- what makes a building a gate house
+-- rather than a house with a back door. GATE and FOREST_GATE both, so
+-- the Viridian Forest pair count; the Safari rest houses are on GATE
+-- too and are excluded by the next test, their warps being drawn doors
+-- already.
+-- the cell is not already a door tile -- a south entrance IS drawn, as a
+-- doorway block in the facade, and lib/Structures.lua folds it up into
+-- the front face. Adding a second one there would fight it.
+-- the cell is WALKABLE -- the ROM gives an unreachable twin warp to
+-- several gates (a fence cell beside the real opening on Route 7 west
+-- and Route 16 east, a tree beside Route 6's), and a door on the wall
+-- behind a fence is a door into nothing. The reachable cells are the
+-- entrance, and two of them side by side are the gate's real two-cell
+-- opening, which comes out as the double door it always was.
+--
+-- The south face is skipped whether or not it is drawn: it is the one face
+-- the drawing states in full, so anything it needs it already has.
+local function sideDoorsAt(map, tileset, tx, ty, bw, bh)
+ local defs = _G.Game and Game.data and Game.data.maps
+ local warps = map.def and map.def.warps
+ if not (defs and warps and warps[1]) then return nil end
+ if not Buildings.sideDoorCell(tileset.id) then return nil end
+
+ local out = nil
+ for _, w in ipairs(warps) do
+ local dest = defs[w.destMap]
+ if dest and dest.tileset and dest.tileset:find("GATE", 1, true)
+ and map:isWalkableCell(w.x, w.y)
+ and not map:isDoorTileCell(w.x, w.y) then
+ -- the cell in the model's own pixels: a cell is two tiles, a tile
+ -- eight pixels, and the placement's origin is (tx, ty) in tiles
+ local lx, lz = w.x * 16 - tx * 8, w.y * 16 - ty * 8
+ local side = nil
+ if lz == -16 and lx >= 0 and lx < bw * 8 then side = "n"
+ elseif lx == -16 and lz >= 0 and lz < bh * 8 then side = "w"
+ elseif lx == bw * 8 and lz >= 0 and lz < bh * 8 then side = "e" end
+ if side then
+ out = out or {}
+ out[#out + 1] = { side = side, at = side == "n" and lx or lz }
+ end
+ end
+ end
+ if out then
+ table.sort(out, function(a, b)
+ if a.side ~= b.side then return a.side < b.side end
+ return a.at < b.at
+ end)
+ end
+ return out
+end
+
+-- The model cache key's door half. A template's doors belong to the
+-- PLACEMENT -- the same 6x4 block is the gate on four routes and the warps
+-- sit at different rows of it on each -- so two placements of one drawing
+-- are two models, and only placements that agree share one.
+local function doorKey(doors)
+ if not doors then return "" end
+ local parts = {}
+ for i, d in ipairs(doors) do parts[i] = d.side .. d.at end
+ return "#" .. table.concat(parts, ",")
+end
+
-- Find every placement of every template for this map's tileset, build one
-- model per template, and stamp it. Returns nothing; the quads land in
-- S.objectQuads and the tiles are claimed so the volume path never boxes a
@@ -1440,6 +1697,9 @@ function Buildings.build(S, map, data, perRow)
if type(t.tiles) == "table" and #t.tiles > 0 then
local bh, bw = #t.tiles, #t.tiles[1]
local first = t.tiles[1][1]
+ -- the model this placement stamps. Not hoisted out of the loops any
+ -- more: a template's doors belong to the placement, so two hits of
+ -- one drawing on the same map can be two models (see doorKey).
local built = nil
for ty = 0, th - bh do
Budget.tick()
@@ -1465,8 +1725,13 @@ function Buildings.build(S, map, data, perRow)
end
end
if free and matches(S, t, tx, ty) then
- if not built then
- local key = tileset.id .. ":" .. index
+ do
+ -- keyed per PLACEMENT once a gate's doors are in play (see
+ -- doorKey): the drawing is shared, the openings are not
+ local doors = not t.claimOnly
+ and sideDoorsAt(map, tileset, tx, ty, bw, bh)
+ or nil
+ local key = tileset.id .. ":" .. index .. doorKey(doors)
if not models[key] then
if t.claimOnly then
-- claim the cells, stamp nothing: the drawing here is
@@ -1478,8 +1743,13 @@ function Buildings.build(S, map, data, perRow)
else
local sp = read(t, data, perRow,
Buildings.frontOnly(tileset.id))
+ if doors then
+ readDoor(sp, data, perRow,
+ Buildings.sideDoorCell(tileset.id))
+ end
local pr = measure(sp, t)
- models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
+ models[key] = emit(model(sp, pr, t, doors), sp,
+ atlasW, atlasH)
end
end
built = models[key]
diff --git a/lib/Structures.lua b/lib/Structures.lua
index 2f6ee1b..814364d 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -2004,7 +2004,9 @@ local function stairCell(S, map, data, cx, cy, s)
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
- local down = s.class == "stair_down_e" or s.class == "stair_down_w"
+ local north = s.class == "stair_down_n"
+ local down = north or s.class == "stair_down_e"
+ or s.class == "stair_down_w"
local east = s.class == "stair_e" or s.class == "stair_down_e"
local mx, mz = cx * 16, cy * 16
local h = s.h or 16
@@ -2053,6 +2055,90 @@ local function stairCell(S, map, data, cx, cy, s)
end
end
+ -- A flight running INTO the map instead of across it. The drawing is
+ -- the same staircase seen head-on rather than from the side, and that
+ -- changes which axis of the art means what: a drawn ROW is a step here,
+ -- and -- because looking down a well is looking along its depth -- drawn
+ -- row IS depth row, 1:1 across the cell's 16.
+ --
+ -- The Centers' steps state their own band table and it lands exactly:
+ -- 4 white rows, 1 black, 3 grey, 1 black, 3 checker, 4 black = 16. So
+ -- an even four-step division puts a black NOSING on the southmost row of
+ -- every band (15, 11, 7, 3) and leaves the rows behind it as that step's
+ -- tread. Nothing is authored but the RISE, which no head-on drawing can
+ -- state; the depths, the treads and the nosings are all measured.
+ --
+ -- A nosing is drawn as one row because it is seen nearly edge-on, so
+ -- un-projected it has real height and no depth: its row lies flat as the
+ -- tread's front lip AND stands as the riser under it. That is the one
+ -- texel in the flight used twice, and using it twice is what a nosing is.
+ --
+ -- The well's own walls come free as well: the drawing's first and last
+ -- COLUMNS are its black side walls, and its top band is the darkness the
+ -- flight leaves by, which is what the far end wants to wear.
+ --
+ -- Every quad here is split at the cell's own 8px seam, in x and in rows
+ -- both: `uv` resolves ONE tile per corner, and these four tiles are not
+ -- neighbours in the atlas, so a quad that spans a seam interpolates
+ -- between two unrelated corners of the sheet.
+ if north then
+ local runD = 16 / STAIR_STEPS
+ local HALVES = { { 0.2, 7.9, 0, 8 }, { 8.1, 15.8, 8, 16 } }
+ for i = 0, STAIR_STEPS - 1 do
+ local a0 = 16 - (i + 1) * runD -- band i, in art rows
+ local a1 = a0 + runD
+ local yTop = -(i + 1) * rise
+ local z0b, z1b = mz + a0, mz + a1
+
+ for _, H in ipairs(HALVES) do
+ local ax0, ax1, wx0, wx1 = H[1], H[2], mx + H[3], mx + H[4]
+
+ -- the tread: the whole band, drawn row = depth row, so the nosing
+ -- lies on its front lip exactly where the artist drew it
+ face({ wx0, yTop, z0b }, { wx1, yTop, z0b },
+ { wx1, yTop, z1b }, { wx0, yTop, z1b },
+ ax0, a1, ax1, a0, STAIR_SHADE.wellTread)
+
+ -- the riser under that lip. It faces NORTH -- a flight descending
+ -- away from you turns its risers away with it, and they close the
+ -- steps from below rather than being looked at. One art row tall,
+ -- so it needs none of `banded`'s row splitting; written straight
+ -- keeps the geometry flush at the seam while the art stays inside
+ -- its tile
+ local ry = -i * rise
+ face({ wx1, yTop, z1b }, { wx0, yTop, z1b },
+ { wx0, ry, z1b }, { wx1, ry, z1b },
+ ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser)
+
+ -- the deep end, closing the opening this flight is cut into: from
+ -- the floor of the well up to the top of the wall band beside it,
+ -- in the drawing's own black top rows
+ if i == STAIR_STEPS - 1 then
+ face({ wx1, -h, mz }, { wx0, -h, mz },
+ { wx0, h, mz }, { wx1, h, mz },
+ ax1, 3.9, ax0, 0.1, STAIR_SHADE.wellEnd)
+ end
+ end
+
+ -- the well's side walls above this tread, wearing the drawing's own
+ -- black edge columns -- the excavation is walled in its own texels
+ local function sideWall(px, sx0, sx1, inward)
+ local c
+ if inward then -- west wall, faces E
+ c = { { px, yTop, z1b }, { px, yTop, z0b },
+ { px, 0, z0b }, { px, 0, z1b } }
+ else -- east wall, faces W
+ c = { { px, yTop, z0b }, { px, yTop, z1b },
+ { px, 0, z1b }, { px, 0, z0b } }
+ end
+ face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN)
+ end
+ sideWall(mx, 0.1, 1.3, true)
+ sideWall(mx + 16, 14.7, 15.9, false)
+ end
+ return
+ end
+
for i = 0, STAIR_STEPS - 1 do
local sx0 = east and (i * runW) or (16 - (i + 1) * runW)
local sx1 = sx0 + runW
@@ -2152,6 +2238,7 @@ function Structures.buildStairs(S, map, x0, x1, y0, y1)
-- box or floor it. A rising flight stands on the map's common
-- floor; a stairwell IS the hole, so nothing is painted under it
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
+ or s.class == "stair_down_n"
for dy = 0, 1 do
for dx = 0, 1 do
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index c4c372f..c0c19ce 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -125,6 +125,12 @@ local FALLBACK_HEIGHTS = {
stair_w = 16,
stair_down_e = 16,
stair_down_w = 16,
+ -- a stairwell descending toward the BACK of the map, drawn head-on
+ -- instead of from the side (the Centers' Cable Club steps). Its own
+ -- class because the art reading is not the east/west one turned: there
+ -- a drawn COLUMN is a step and a drawn row is height, here a drawn ROW
+ -- is a step and drawn row = depth row, 1:1 down the well
+ stair_down_n = 16,
}
-- class -> how the mesher draws it (see the header). The last three are
@@ -215,6 +221,7 @@ local ART = {
stair_w = "stair",
stair_down_e = "stair",
stair_down_w = "stair",
+ stair_down_n = "stair",
}
local spec = nil -- the loaded data file, or false when absent
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index 5b3d7f9..3e690cd 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -503,12 +503,22 @@ local active = false
-- which is exactly the old behaviour minus the reflections.
local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
+-- dpiscale = 1, for the same reason PixelCanvas pins it and for one more:
+-- newCanvas otherwise takes the WINDOW's scale, and every canvas bound
+-- together must agree on PIXEL dimensions. The colour canvas beside this one
+-- comes from PixelCanvas at scale 1, so on any surface whose scale is not 1
+-- -- Android's density is routinely 2.625, and a retina Mac's is 2 -- this
+-- one came back 2.625x larger and the pair would not bind. beginScene then
+-- dropped the readable depth for the session (see below), depthReadable()
+-- went false, and the water pass never ran at all: the reflections were
+-- missing on every high-density display, with nothing in the log to say so,
+-- because a canvas that will not BIND is not a canvas the driver refused.
local function newDepth(w, h)
if not (love.graphics and love.graphics.newCanvas) then return nil end
local c = nil
for _, format in ipairs(DEPTH_FORMATS) do
local ok, made = pcall(love.graphics.newCanvas, w, h,
- { format = format, readable = true })
+ { format = format, readable = true, dpiscale = 1 })
if ok and made then c = made break end
end
if not c then return nil end
@@ -1344,7 +1354,10 @@ end
function Voxel3D.beginWater(paint)
if not (active and canvas and held and held.depth) then return nil end
if not held.mirror then
- local ok, c = pcall(love.graphics.newCanvas, held.w, held.h)
+ -- through PixelCanvas, because this one is bound WITH held.depth a few
+ -- lines down and the two must agree on pixel dimensions -- the same
+ -- scale trap newDepth documents
+ local ok, c = PixelCanvas.new(held.w, held.h)
if not (ok and c) then return nil end
pcall(c.setFilter, c, "nearest", "nearest")
pcall(c.setWrap, c, "clamp", "clamp")