fix back facade of buildings
DramaticShape · 2026-08-08 ·
parent dfe2f1ae1a
fix back facade of buildings
Changed files
CHANGELOG.md | 22 ++++++++++++
data/voxel_heights.lua | 26 +++++++++++++++
lib/Buildings.lua | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
lib/ChunkMesher.lua | 44 +++++++++++++++++++++---
4 files changed, 208 insertions(+), 9 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3425ca2..c468440 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -79,6 +79,28 @@
### Fixed
+- **A building's back no longer wears its own front door.** Every voxelized
+ building is its drawing extruded straight through the footprint, so the
+ far wall is the facade again -- and read from behind, the facade mirrored:
+ a door on the back of every house, a POKe sign readable backwards on
+ every Center, MART on every mart and GYM painted across the back of every
+ gym. Those tiles are now named per tileset (`frontOnly` in
+ `data/voxel_heights.lua` -- the doorways, the hanging shop signs and the
+ gyms' lettering) and every cell wearing one takes the art of the nearest
+ ordinary cell beside it in the same tile row instead. The donor is picked
+ per RUN, so a two-tile doorway comes out as two tiles of the same wall
+ rather than borrowing left from one side and right from the other, and
+ between the two neighbours the one that row uses more often wins -- which
+ is what reaches past a gable's sloped corner for the wall behind it. At
+ the base course the donor lifts one row with the model, because the
+ drawing's last row is the black threshold a door stands on and the wall
+ beside it does not paint; without that the doorway kept its own foot and
+ the back's bottom course had a notch in it. Windows are deliberately left
+ alone: a back wall with windows is right. The generic volume path folds
+ the same drawing up all four sides and had the same bug on its back AND
+ its flanks, so it takes the same substitution -- only the south face,
+ which IS the drawing, keeps every tile of it.
+
- **B now actually runs from capture mode -- and A throws, and L/R switch
balls.** The capture session read its button presses on the RENDER clock,
along with everything else it does per frame. Button edges do not survive
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 367b034..6af0a03 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -2976,6 +2976,32 @@ return {
},
},
+ -- Tiles that are FRONT art: they belong on the drawn facade and nowhere
+ -- else. A building's back is the same drawing extruded straight through
+ -- the footprint (lib/Buildings.lua `model`, and the volume path's north
+ -- face in lib/ChunkMesher.lua), so without this every house wears a
+ -- second door on its far wall and every Center a POKe sign readable
+ -- backwards. A cell wearing one of these ids takes the art of the
+ -- nearest ordinary cell beside it in the same tile row instead -- left or
+ -- right, whichever tile that row uses more, which is what reaches PAST a
+ -- gable's sloped corner for the wall behind it.
+ --
+ -- Windows are deliberately absent: a back wall with windows is right.
+ -- These are the doorways, the hanging shop signs and the painted GYM
+ -- lettering -- the three things a facade has that its back does not.
+ -- Ids are per tileset, indexing that tileset's own atlas.
+ frontOnly = {
+ -- doorway 11/12 over 27/28 (27 is the tileset's own doorTile); the
+ -- POKe (66/67) and MART (68/69) signs over their bracket row 74; the
+ -- GYM lettering 47/63 painted across the gyms' upper course.
+ OVERWORLD = { 11, 12, 27, 28, 47, 63, 66, 67, 68, 69, 74 },
+ -- the Indigo Plateau and Victory Road entrances: the same doorway
+ -- block, drawn into the cliff face.
+ PLATEAU = { 11, 12, 27, 28 },
+ -- the Safari Zone gate's double door, 42/43 over 58/59.
+ FOREST = { 42, 43, 58, 59 },
+ },
+
-- 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 a21e515..f11d1ca 100644
--- a/lib/Buildings.lua
+++ b/lib/Buildings.lua
@@ -134,9 +134,112 @@ local function profile()
end
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
+local frontSets = {} -- tileset id -> { [tile] = true } or false
+
+-- 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
+-- tileset names none, which is every indoor one.
+function Buildings.frontOnly(tilesetId)
+ local hit = frontSets[tilesetId]
+ if hit == nil then
+ local s = profile()
+ local list = s and s.frontOnly and s.frontOnly[tilesetId]
+ if list then
+ hit = {}
+ for _, id in ipairs(list) do hit[id] = true end
+ else
+ hit = false
+ end
+ frontSets[tilesetId] = hit
+ end
+ return hit or nil
+end
-- ------------------------------------------------------------------ read --
+-- Which sprite pixel a BACK-facing voxel shows, where that is not the one
+-- the front shows. The facade extrudes straight through the footprint, so
+-- the far wall is the drawing again -- and read from behind it is the
+-- drawing mirrored, doorway, shop sign, GYM lettering and all. Those tiles
+-- are named per tileset in data/voxel_heights.lua `frontOnly`; every cell
+-- wearing one takes the art of an ordinary cell beside it in the same tile
+-- row.
+--
+-- The donor is chosen per RUN of front-only cells, not per cell, so a
+-- two-tile doorway comes out as two tiles of the SAME wall rather than
+-- borrowing left from one side and right from the other. Between the two
+-- neighbours the one whose tile the row uses more often wins, which is
+-- what reaches past a gable's sloped corner (a 4x2 house draws its door
+-- against the slope: the corner is unique to the row, the wall beside it
+-- is not) for the wall the back should actually wear.
+--
+-- Returns a SPARSE map, sprite index -> sprite index, empty entries meaning
+-- "unchanged"; nil when the drawing has no front-only tile at all, which is
+-- most of them.
+local function backMap(tiles, bw, bh, W, inside, frontOnly)
+ if not frontOnly then return nil end
+ local donor, any = {}, false
+ for r = 1, bh do
+ local row = tiles[r]
+ local freq = {}
+ for c = 1, bw do
+ local id = row[c]
+ if not frontOnly[id] then freq[id] = (freq[id] or 0) + 1 end
+ end
+ local c = 1
+ while c <= bw do
+ if frontOnly[row[c]] then
+ local c1 = c
+ while c1 < bw and frontOnly[row[c1 + 1]] do c1 = c1 + 1 end
+ local l, rt = c - 1, c1 + 1
+ local pick = nil
+ if l >= 1 and rt <= bw then
+ pick = ((freq[row[rt]] or 0) > (freq[row[l]] or 0)) and rt or l
+ elseif l >= 1 then
+ pick = l
+ elseif rt <= bw then
+ pick = rt
+ end
+ -- a row that is front-only end to end has no donor; it keeps its
+ -- own art rather than inventing one
+ if pick then
+ for k = c, c1 do donor[(r - 1) * bw + (k - 1)] = pick - 1 end
+ any = true
+ end
+ c = c1 + 1
+ else
+ c = c + 1
+ end
+ end
+ end
+ if not any then return nil end
+
+ local back = {}
+ for cell, dc in pairs(donor) do
+ local r, c = math.floor(cell / bw), cell % bw
+ for oy = 0, 7 do
+ local sy = r * 8 + oy
+ for ox = 0, 7 do
+ local i = sy * W + c * 8 + ox
+ local j = sy * W + dc * 8 + ox
+ -- The donor must be DRAWN, or the substitution would hand the wall
+ -- a texel from outside the silhouette. One row up is tried first,
+ -- because the drawing's last row is the black threshold the
+ -- building stands on: the doorway paints it (a door sits on the
+ -- ground) and the wall beside it does not, so at the base course
+ -- the same row of the donor column is off the shape. The model
+ -- lifts that column's foot by exactly one row for the same reason
+ -- (see `at`), and lifting the donor with it is what makes the back
+ -- wall's bottom course continuous.
+ if not inside[j] then j = j - W end
+ if j >= 0 and inside[j] then back[i] = j end
+ end
+ end
+ end
+ return back
+end
+
-- Composite the template out of the atlas and flood the silhouette in from
-- the border. Returns flat arrays indexed y * W + x.
--
@@ -149,7 +252,7 @@ local models = {} -- "<tileset>:<index>" -> prebuilt local quads
-- 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.
-local function read(t, data, perRow)
+local function read(t, data, perRow, frontOnly)
local tiles = t.tiles
if t.topRows then
tiles = {}
@@ -244,7 +347,8 @@ local function read(t, data, perRow)
end
end
end
- return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside }
+ return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside,
+ back = backMap(tiles, bw, bh, W, inside, frontOnly) }
end
-- --------------------------------------------------------------- measure --
@@ -1034,6 +1138,11 @@ local function model(sp, pr, t)
local T = {}
for x = 0, W - 1 do T[x] = ytop - top[x] end
+ -- the back layer's texel: the drawing again, minus what only the front
+ -- may wear (see backMap)
+ local back = sp.back
+ local function backOf(i) return (back and back[i]) or i end
+
local function at(x, y, z)
if x < 0 or x >= W then return nil end
local tx = T[x]
@@ -1070,7 +1179,11 @@ local function model(sp, pr, t)
if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then
local sy = ground - 1 - y
if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then
- return sy * W + x
+ -- z < 0 is the awning's NORTH end: same substitution the wall
+ -- behind it makes, so a band that carries a sign does not carry
+ -- it round the back
+ local i = sy * W + x
+ return z < 0 and backOf(i) or i
end
return nil
end
@@ -1092,7 +1205,7 @@ local function model(sp, pr, t)
if pr.recess[i] then return nil end
return i
end
- if z == 0 then return i end
+ if z == 0 then return backOf(i) end
return pr.interior[i]
end
@@ -1363,7 +1476,8 @@ function Buildings.build(S, map, data, perRow)
-- detector they stood as a second half-building.
models[key] = {}
else
- local sp = read(t, data, perRow)
+ local sp = read(t, data, perRow,
+ Buildings.frontOnly(tileset.id))
local pr = measure(sp, t)
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
end
@@ -1474,6 +1588,7 @@ end
function Buildings.invalidate()
spec = nil
models = {}
+ frontSets = {}
end
return Buildings
diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua
index 532c3d4..6344b90 100644
--- a/lib/ChunkMesher.lua
+++ b/lib/ChunkMesher.lua
@@ -52,6 +52,7 @@ local V = ...
local Assets = require("src.render.Assets")
local Structures = V.require("Structures")
+local Buildings = V.require("Buildings")
local TileShape = V.require("TileShape")
local Voxel3D = V.require("Voxel3D")
local Budget = V.require("BuildBudget")
@@ -102,6 +103,12 @@ local SIDES = {
{ 0, -1, 6 }, -- -Z north
}
+-- How far sideways a face reaches for ordinary wall when the column it
+-- stands over draws a doorway or a sign (see wallTile), nearest ring
+-- first. Left before right at each distance is arbitrary and only decides
+-- symmetric cases.
+local SPAN = { { -1, 1 }, { -2, 2 } }
+
local function keyOf(tx, ty)
return (ty + 64) * 4096 + (tx + 64)
end
@@ -248,6 +255,32 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
return s and s.h or 0
end
+ -- The tiles that belong on a DRAWN FACADE and nowhere else -- doorways,
+ -- shop signs, the gyms' lettering (data/voxel_heights.lua `frontOnly`).
+ -- A volume folds its column's drawing up all four sides, so without this
+ -- a house's back and flanks each carry their own copy of its front door.
+ -- The face keeps the same map row and reaches sideways for an ordinary
+ -- column instead, which is the neighbouring course of the same wall.
+ -- The search stays inside the structure -- a neighbour column with no run
+ -- of its own is the ground beside the building, and a doorway that
+ -- borrowed grass would be a hole. Two columns is as far as it needs to
+ -- reach: every doorway in the game is two tiles wide.
+ local frontOnly = Buildings.frontOnly(tileset.id)
+ local function wallTile(tx, ty)
+ local tile = map:tileAt(tx, ty)
+ if not (frontOnly and frontOnly[tile]) then return tile end
+ for d = 1, 2 do
+ for _, nx in ipairs(SPAN[d]) do
+ nx = tx + nx
+ if S.runs[keyOf(nx, ty)] then
+ local n = map:tileAt(nx, ty)
+ if not frontOnly[n] then return n end
+ end
+ end
+ end
+ return tile
+ end
+
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
local function uvRect(tile, vTop, vBot)
local ax = (tile % perRow) * 8
@@ -605,13 +638,16 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- drawing itself (full brightness); the other sides wear
-- the same rows darkened, so a building's flank matches
-- its face instead of smearing one tile
+ local sy
if d == 6 then
- src = map:tileAt(tx, math.min(run.front,
- run.north + band))
+ sy = math.min(run.front, run.north + band)
else
- src = map:tileAt(tx, math.max(run.north,
- run.front - band))
+ sy = math.max(run.north, run.front - band)
end
+ -- the south face IS the drawing and keeps every tile of
+ -- it; the back and the flanks are the same wall seen from
+ -- somewhere the door and the sign are not
+ src = (d == 5) and map:tileAt(tx, sy) or wallTile(tx, sy)
if d == 5 then shade = 1 end
elseif s.art == "upright" then
-- profile-authored upright (a pinned wall or furniture