From 47369c24fe2680f1ff5b0bd22088353e663f06eb Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Sat, 8 Aug 2026 22:12:07 -0400 Subject: [PATCH] fixed tops of walls in houses --- CHANGELOG.md | 36 ++++++++++++ data/voxel_heights.lua | 52 +++++++++++++----- lib/ChunkMesher.lua | 11 ++++ lib/Structures.lua | 72 ++++++++++++++++-------- lib/TileShape.lua | 35 ++++++++++-- tests/dramatic_shape_test.lua | 100 ++++++++++++++++++++++++++++++++++ 6 files changed, 266 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bca1656..4a0426d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,42 @@ strips the way a facade's does) and no voxels: the recess removes as many faces as it exposes. +### Fixed + +- **The Pokemon Center's steps climb.** The Cable Club stairs, cut into + the back wall of all eleven Centers, were built as a stairwell sunk + into the floor -- they lead UP to the Center's second floor. The + head-on stair reading was right and stays: a drawn ROW is a step and + drawn row is depth row, 1:1 into the opening, the drawing's own band + table landing exactly on four steps and its black edge columns walling + the opening. Only the sign of the rise was wrong, and two things follow + from it. The risers turn around -- a flight descending away from you + closes its steps from below and shows you their backs, one climbing + away shows you their fronts -- and the black edge columns become the + walls of the opening the flight climbs into, running from each tread up + to the top of the wall band rather than down from the floor to it. The + top step lands level with that band, in the dark rows the artist drew + there, so the flight fills the opening it leaves by. The descending + class (`stair_down_n`) is unchanged and still available; the Centers + now pin `stair_n`. + +- **Nothing is hung on the TOP of an interior wall.** A wall band is 16px + of art folded upright over a run two drawn rows deep, so it folds + entirely onto its south face and has no drawn row left to lay flat on + top -- and the top then repeated the face. The town house's town-map + poster (cell (3,0)) and its window ((5,0)), and the Pokemon Center's + pokeball poster ((3,0) and (4,0)), each came out lying across the top + of the wall as well as hanging on it: a picture you look DOWN on. + + What is really up there is the wall's own capping course, which is + exactly the plain panel the decorated column's neighbours draw -- + `wall_top` in `data/voxel_heights.lua` names it per tileset (HOUSE caps + with the blank course, POKECENTER with the striped panel cell (9,0) + draws). Per tileset rather than per tile because one room caps with one + course, and because "plain" is a fact about the drawing that no + measurement of the geometry can recover. Only the top face is + redirected; the poster still faces the room. + ## 1.8.0 ### Added diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 2bb11c5..74b605a 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -50,6 +50,9 @@ -- stair_down_e / _down_w a sunken stairwell: the cell opens into a -- hole with steps descending toward the named -- side -- stairs that lead DOWN a floor +-- stair_n / stair_down_n the same two flights running into the map +-- rather than across it, for a staircase drawn +-- HEAD-ON: a drawn row is a step, not a column -- relief a prop drawn from above (a console on the -- floor): the drawing stays flat and the -- pixels inside its black outline extrude a @@ -129,6 +132,16 @@ -- measured rather than drawn, by flooding the panel tile's own stripe out -- from behind them; see CLUB below. -- +-- And `wall_top` (not a class either): the tile every `wall` cell's TOP +-- face wears, whatever the cell itself draws. An interior wall band is +-- 16px of art folded upright, and a fully folded run has no drawn row left +-- over to lay flat on top -- so the top repeated the FACE, and the town-map +-- poster and the window of a house came out lying across the top of the +-- wall as well as hanging on it. What is really up there is the wall's own +-- capping course, which is exactly the plain panel the decorated column's +-- neighbours draw: name that tile and every wall in the tileset caps with +-- it. The face is untouched -- the poster still faces the room. +-- -- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from -- above, a facade seen face-on and sloped ends as diagonal silhouettes, -- and no single class covers that. They live in the `buildings` list at @@ -860,6 +873,12 @@ return { -- the wall band stays one 16px face: blank courses, the window, -- the framed picture, and the schoolhouse blackboard (72-75/88-91) wall = { 0, 36, 45, 46, 52, 61, 62, 72, 73, 75, 88, 89, 90, 91 }, + -- and it caps with the blank course. Cells (3,0) and (5,0) of the + -- town house are the town-map poster (45/46 over 61/62) and the + -- window (36 over 52); without this the top of the wall wore the + -- poster and the glass, laid flat, instead of the plain panel their + -- own left-hand neighbour draws + wall_top = 0, -- stools: a seat-high box that also seats Daisy stool = { 2, 3, 18, 19 }, -- the dining table (top edge 38/41 also caps the bookcases); @@ -905,21 +924,28 @@ return { -- two flanks -- see `prop` below. wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41, 76, 77 }, + -- and it caps with the striped panel, the tile cell (9,0) draws. + -- The pokeball poster spans cells (3,0) and (4,0) (2/3 over 18/19), + -- and without this the top of the wall wore the poster lying flat as + -- well as hanging it on the face + wall_top = 40, -- 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. + -- (12,0) of every Center -- a flight going UP, away from the room, to + -- the Center's second floor, and the reason the head-on stair classes + -- exist 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. + -- four-step division of the cell: 4 white rows (the near tread, the + -- one at floor level), a black nosing, 3 grey, a nosing, 3 checker, + -- then 4 black rows -- the dark the flight climbs into, which is the + -- top step, level with the wall band it is cut through. Its first and + -- last COLUMNS are the opening'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 @@ -927,7 +953,7 @@ return { -- 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 }, + stair_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 diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 4167656..d036467 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -301,6 +301,14 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) return tile end + -- The capping course an interior wall wears on its TOP face (see + -- TileShape.wallTop). A wall band folds entirely onto its own face, so + -- the top had nothing left to lay flat and repeated the face -- a house's + -- town-map poster and window, and a Center's pokeball poster, came out + -- lying across the top of the wall as well as hanging on it. Only the top + -- is redirected: the face still draws what the map draws. + local wallTop = TileShape.wallTop(tileset.id) + -- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8 local function uvRect(tile, vTop, vBot) local ax = (tile % perRow) * 8 @@ -650,6 +658,9 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) and (north - 1) or north end topTile = S.tileAt[keyOf(tx, row)] + -- ...unless the tileset names the course its walls cap with, + -- which every wall cell wears whatever it draws on its face + if wallTop and s.class == "wall" then topTile = wallTop end end -- water's surface, and only water's: the recessed sheet itself, -- never the ground's shoreline bands around it. A cell an object diff --git a/lib/Structures.lua b/lib/Structures.lua index 814364d..33e3fbd 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -1992,6 +1992,10 @@ end -- walls) wear the matching slice of that drawing -- the railing's -- diagonal lands along the stepped silhouette -- while treads sample the -- art band drawn at their own height. +-- +-- stair_n / stair_down_n are the same pair of flights running INTO the +-- map rather than across it, for a staircase drawn head-on; that changes +-- the art reading enough to need its own branch below. local STAIR_STEPS = 4 local STAIR_SHADE = { south = 1.0, north = 0.68, tread = 1.0, @@ -2004,8 +2008,8 @@ 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 north = s.class == "stair_down_n" - local down = north or s.class == "stair_down_e" + local north = s.class == "stair_n" or s.class == "stair_down_n" + local down = s.class == "stair_down_n" 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 @@ -2077,6 +2081,17 @@ local function stairCell(S, map, data, cx, cy, s) -- 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. -- + -- A flight CLIMBING away (`stair_n`) is the same reading with the sign of + -- the rise flipped -- bands still run south to north, drawn row is still + -- depth row, the nosing still serves twice. Two things follow from the + -- sign. The risers turn around: a flight descending away from you closes + -- its steps from below and shows you their backs, one climbing away shows + -- you their FRONTS, so they face south. And the drawing's black side + -- columns stop being a well's walls and become the walls of the opening + -- the flight climbs into: they run from each tread UP to the top of the + -- wall band rather than down from the floor. At the last step the flight + -- has reached that top and there is no opening left to wall. + -- -- 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 @@ -2087,7 +2102,8 @@ local function stairCell(S, map, data, cx, cy, s) 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 yTop = (down and -1 or 1) * (i + 1) * rise + local ry = (down and -1 or 1) * i * rise -- the step behind it local z0b, z1b = mz + a0, mz + a1 for _, H in ipairs(HALVES) do @@ -2097,44 +2113,56 @@ local function stairCell(S, map, data, cx, cy, s) -- 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) + ax0, a1, ax1, a0, + down and STAIR_SHADE.wellTread or STAIR_SHADE.tread) - -- 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 riser at that lip, one art row tall -- so it needs none of + -- `banded`'s row splitting, and written straight keeps the geometry + -- flush at the seam while the art stays inside its tile. Facing + -- north when the flight descends (the steps are closed from below, + -- not looked at) and south when it climbs + if down then + face({ wx1, yTop, z1b }, { wx0, yTop, z1b }, + { wx0, ry, z1b }, { wx1, ry, z1b }, + ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser) + else + face({ wx0, ry, z1b }, { wx1, ry, z1b }, + { wx1, yTop, z1b }, { wx0, yTop, z1b }, + ax0, a1 - 1, ax1, a1, STAIR_SHADE.riser) + end -- 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 + -- in the drawing's own black top rows. A climbing flight has no + -- such end -- its top tread stands at the wall's own height and + -- fills the opening + if down and 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 + -- the opening's side walls beside this tread, wearing the drawing's + -- own black edge columns -- excavation or recess, it is walled in its + -- own texels. Descending they run from the tread up to the floor, + -- climbing from the tread up to the top of the wall band + local wallTop = down and 0 or h 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 } } + { px, wallTop, z0b }, { px, wallTop, z1b } } else -- east wall, faces W c = { { px, yTop, z0b }, { px, yTop, z1b }, - { px, 0, z1b }, { px, 0, z0b } } + { px, wallTop, z1b }, { px, wallTop, 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) + if wallTop > yTop then + sideWall(mx, 0.1, 1.3, true) + sideWall(mx + 16, 14.7, 15.9, false) + end end return end diff --git a/lib/TileShape.lua b/lib/TileShape.lua index c0c19ce..a4270ea 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -125,11 +125,13 @@ 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 + -- a flight running 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 into the opening. `stair_n` climbs away + -- from the room, `stair_down_n` descends into a well + stair_n = 16, stair_down_n = 16, } @@ -221,6 +223,7 @@ local ART = { stair_w = "stair", stair_down_e = "stair", stair_down_w = "stair", + stair_n = "stair", stair_down_n = "stair", } @@ -761,6 +764,28 @@ function TileShape.bookcaseRelief(tilesetId) return not (entry and entry.bookcase_relief == false) end +--- What every `wall` cell's TOP face wears in this tileset (a tileset +--- entry's wall_top). Returns a tile id, or nil to leave the top alone. +--- +--- A wall band is 16px of art folded upright over a run two drawn rows +--- deep, so it folds ENTIRELY onto its face and has no row left to lay +--- flat on top -- the top then repeats the face, and a house's town-map +--- poster and window came out lying across the top of the wall as well as +--- hanging on it. What is up there is the wall's capping course, which is +--- the plain panel the decorated column's own neighbours draw; naming it +--- per tileset is the whole fix, because "plain" is a fact about the +--- drawing that nothing in the geometry can measure. +--- +--- Per tileset rather than per tile: one room caps with one course, and a +--- list keyed by the decorated tiles would have to be extended every time +--- a map hung something new on a wall already covered. +function TileShape.wallTop(tilesetId) + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local tile = entry and entry.wall_top + return type(tile) == "number" and tile or nil +end + -- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset -- record needs the next lookup to re-resolve (hot reload, mod toggle). function TileShape.invalidate() diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 2f3ed88..aa6cde0 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -7002,6 +7002,106 @@ end)() T.check(ball.stars == nil, "and the stars burn out on their own") end)() +-- ------- an interior wall caps with its own plain course +-- +-- A wall band is 16px of art over a run two drawn rows deep, so it folds +-- ENTIRELY onto its south face and has no drawn row left to lay flat on +-- top. The top therefore repeated the face: the town house's town-map +-- poster and window, and the Pokemon Center's pokeball poster, came out +-- lying across the top of the wall as well as hanging on it -- a picture +-- you look DOWN on from the voxel camera. +-- +-- `wall_top` names the capping course per tileset (the plain panel the +-- decorated column's own neighbours draw), and it is a fact about the +-- drawing that no measurement of the geometry can recover -- nothing +-- distinguishes "poster" from "panel" but knowing which is which. +;(function() + local Mesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher") + local Struct = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures") + local Shape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape") + + T.eq(Shape.wallTop("HOUSE"), 0, + "the town house caps its walls with the blank course") + T.eq(Shape.wallTop("POKECENTER"), 40, + "and a Center with its striped panel -- the tile cell (9,0) draws") + T.eq(Shape.wallTop("DS_NO_SUCH_TILESET"), nil, + "a tileset that says nothing keeps the top it always had") + + local FLOOR = 1 + local function topTiles(tilesetId, rows) + local map = { + id = "DS_TEST_WALLTOP_" .. tilesetId, + tileset = { id = tilesetId, image = "gfx/tilesets/ds_test.png", + tilesPerRow = 16, imageWidth = 128, imageHeight = 48, + blocks = {}, grassTile = -1 }, + def = { width = 4, height = 2, tileset = tilesetId }, + walkable = { [FLOOR] = true }, + waterTiles = {}, + doorTiles = {}, + tileAt = function(_, tx, ty) + local r = rows[ty + 1] + return (r and r[tx + 1]) or FLOOR + end, + cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end, + isWaterCell = function() return false end, + isWalkableCell = function(self, cx, cy) + return self:cellTile(cx, cy) == FLOOR + end, + inBounds = function(_, cx, cy) + return cx >= 0 and cy >= 0 and cx < 4 and cy < 2 + end, + } + Struct.invalidate(map.id) + local verts = Mesher.geometry(map, true, nil) + -- the flat quads standing at wall height, keyed by the column they cap + local out = {} + for i = 1, #verts, 4 do + local a, b, c, d = verts[i], verts[i + 1], verts[i + 2], verts[i + 3] + if a[2] == b[2] and b[2] == c[2] and c[2] == d[2] and a[2] == 16 then + local ax = math.floor(math.min(a[4], b[4], c[4], d[4]) * 128 + 0.5) + local ay = math.floor(math.min(a[5], b[5], c[5], d[5]) * 48 + 0.5) + local tx = math.floor(math.min(a[1], b[1], c[1], d[1]) / 8) + out[tx] = out[tx] or (math.floor(ay / 8) * 16 + math.floor(ax / 8)) + end + end + return out, verts + end + + -- BLUES_HOUSE's back wall: blank panel, the town-map poster (45/46 over + -- 61/62) at cell (3,0)'s block, the window (36 over 52) at (5,0)'s + local houseTops, houseVerts = topTiles("HOUSE", { + { 0, 0, 45, 46, 36, 36, 0, 0 }, + { 0, 0, 61, 62, 52, 52, 0, 0 }, + }) + for tx = 0, 7 do + T.eq(houseTops[tx], 0, + ("column %d of the house's back wall caps with the blank course"):format(tx)) + end + + -- and the FACE is untouched: the poster still hangs in the room, which is + -- the half of this the fix must not take with it + local hung = {} + for _, v in ipairs(houseVerts) do + hung[math.floor(math.floor(v[5] * 48 + 0.5) / 8) * 16 + + math.floor(math.floor(v[4] * 128 + 0.5) / 8)] = true + end + T.check(hung[45] and hung[46] and hung[61] and hung[62], + "the town-map poster is still drawn on the wall it hangs on") + T.check(hung[36] and hung[52], "and so is the window") + + -- VIRIDIAN_POKECENTER's back wall: the striped panel with the pokeball + -- poster (2/3 over 18/19) spanning cells (3,0) and (4,0) + local pcTops = topTiles("POKECENTER", { + { 40, 40, 40, 2, 3, 40, 40, 40 }, + { 40, 40, 40, 18, 19, 40, 40, 40 }, + }) + for tx = 0, 7 do + T.eq(pcTops[tx], 40, + ("column %d of the Center's back wall caps with the striped panel") + :format(tx)) + end +end)() + Pipelines.reset() run.release()