Files
DramaticShapeVoxelMod/lib/TileShape.lua
T
DramaticShape d229622b09 conditional pins must not mark the shared class shape authored
A `when_above` match returned shapes.classes[class] after setting
.authored on it -- but those canonical shapes are SHARED, and
shapes.classes.wall is the very object rule 4 hands every unauthored
solid tile. Marking it authored made all of them return early from
TileShape.at, skipping the cell rules, so walkable floors stopped
flattening: the gate houses came out as a checkerboard of raised blocks
with the room's floor standing 16px.

Conditional pins now resolve to their own authored shape per class,
built alongside the canonical ones and kept apart from them.
2026-07-27 10:35:06 -04:00

374 lines
15 KiB
Lua

-- Voxel world mode: resolve every tile of a tileset to an extrusion shape.
--
-- Reads the hand-authored groups in data/voxel_heights.lua and fills the
-- gaps from data the ROM extractor already emits. Resolution happens at two
-- granularities, and the order matters:
--
-- per tile 1. a group named in data/voxel_heights.lua (hand-authored)
-- per CELL 2. the cell is water -> "water"
-- 3. the cell is walkable -> "ground"
-- per tile 4. tile-level fallback: the map's water set -> "water",
-- its walkable set -> "ground", else -> "wall"
--
-- The cell steps (TileShape.at) are the load-bearing part. Collision in
-- this engine -- like the GB original -- is defined per 16x16 CELL, judged
-- by the cell's bottom-left 8x8 tile alone. The other three tiles of a
-- cell carry no collision meaning, and treating their walkable-list
-- membership as one (which is what a pure per-tile lookup does) misfiles
-- every decorative tile: flowers become 16px pillars, the gap tiles of a
-- fence row become wall, grass tufts extrude. A tile in a walkable cell is
-- ground the player is standing on, whatever the walkable list says about
-- it; hand-authoring (rule 1) is the only thing that overrides that.
--
-- Rule 4 covers positions whose cell IS blocked: there, walkable-listed
-- tiles (the gaps between fence posts) stay ground and the rest rise.
--
-- Every class also carries an ART mode, which is what the mesher renders:
--
-- flat ground/water/void: a single quad, no box.
-- top ledge/roof: a box with its art on the TOP face -- things
-- whose 2D art depicts a surface seen from above.
-- upright wall/tree/fence/sign: a box whose SOUTH face reconstructs
-- the 2D artwork standing up (the mesher's fold-up rule) --
-- things whose 2D art depicts a surface seen face-on, which is
-- most of Gen 1: interior walls, furniture, tree canopies,
-- building facades.
--
-- Purely presentational: a shape decides how a tile DRAWS in voxel mode
-- and nothing else. Collision still reads the same walkable list it
-- always did.
-- the mod namespace (see main.lua): V.data loads a shipped data file
local V = ...
local TileShape = {}
-- class -> height fallbacks, used when data/voxel_heights.lua is missing
-- or omits a class. Same numbers the shipped file carries; a cell is 16x16.
local FALLBACK_HEIGHTS = {
ground = 0,
water = -2,
void = 0,
ledge = 6,
fence = 10,
sign = 12,
wall = 16,
tree = 16,
roof = 28,
cylinder = 16,
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
-- (Viridian Forest's trees). The class pins only the drawing's
-- top-left corner tile; the other cells stay `cylinder` and are
-- claimed by the group build (see Structures.buildCylinders)
canopy = 32,
-- a cylinder hull whose drawn top is a CUT FACE (tree stumps): the
-- body builds from the bark rows and the drawn ellipse projects onto
-- the hull's round top
stump = 16,
billboard = 16,
signpost = 16,
post = 16,
grass = 0,
flower = 0,
-- interior furniture: face-on drawings the detector would otherwise
-- raise to wall height (or merge into the wall). A bed is drawn from
-- above and lies low; tables and desks are boxes at their real height;
-- stairs become stepped geometry rising toward the named side.
bed = 7,
stool = 8,
counter = 8,
table = 12,
desk = 24,
prop = 16,
cutout = 16,
relief = 3,
bookcase = 32,
stair_e = 16,
stair_w = 16,
stair_down_e = 16,
stair_down_w = 16,
}
-- class -> how the mesher draws it (see the header). The last three are
-- profile archetypes Structures.lua builds special geometry for:
-- cylinder round-drawn cells (tree canopies) become voxel hulls cut
-- from the art's darkest-pixel outline, round in depth
-- billboard signs, props: the art stands as a thin per-pixel voxel
-- slab, transparency respected
-- post fence posts: the same thin per-pixel slab, but every CELL
-- stands alone in its own depth band -- a north-south fence
-- line is a march of separate posts, not one tall drawing
-- (which is what a shared cluster would make of it)
-- grass tall grass: flat ground PLUS two thin standing rows of
-- tufts per tile (the art's top and bottom halves), each at
-- its drawn depth -- the player walks between them
local ART = {
ground = "flat",
water = "flat",
void = "flat",
ledge = "top",
roof = "top",
wall = "upright",
tree = "upright",
fence = "upright",
sign = "upright",
cylinder = "cylinder",
canopy = "canopy",
stump = "cylinder",
billboard = "billboard",
-- signposts share the billboard treatment but as their own pool at a
-- 2-voxel depth: a sign is a thin plate on a stick, and the standard
-- 10px standee body reads as a chunk of furniture outdoors
signpost = "billboard",
post = "post",
grass = "grass",
-- animated flowers: flat synthesized ground PLUS a standing cutout of
-- the drawing's darkest tones, one voxel deep (see Structures'
-- buildFlowers). Height 0 so a build with no pixel access degrades to
-- the flat tile it always drew, not a box
flower = "flower",
-- furniture: a bed's art depicts its top surface; tables and desks are
-- boxes whose fronts fold up (the mesher's authored-fold rule).
-- Stools, `prop` and `cutout` are standee pools alongside `billboard`
-- -- same per-pixel cutout, different thickness (see Structures'
-- PINNED_DEPTH), and separate pools cluster separately so touching
-- drawings never stack; a stool keeps its 8px height so a character
-- standing on its (walkable) cell sits at seat height. Stairs are a
-- profile archetype Structures builds real steps for -- rising flights
-- for stairs leading up, sunken stairwells for stairs leading down
bed = "top",
stool = "billboard",
-- half-cell furniture: a service counter, a low couch. One 8px band,
-- so exactly the drawing's bottom row stands up as the front and
-- every row above it rides the top face in drawn order -- which is
-- also the only way to place a figure drawn INTO the furniture (the
-- Center's seated man) without repeating him, since a taller box
-- folds two rows upright and then repeats its north row across the
-- top. Reads as something you lean on rather than a wall stub
counter = "upright",
table = "upright",
desk = "upright",
prop = "billboard",
cutout = "billboard",
relief = "relief",
-- free-standing shelves: the drawing is TALL, not deep -- Structures
-- collapses each drawn rank onto a one-cell-deep box at full height
bookcase = "bookcase",
stair_e = "stair",
stair_w = "stair",
stair_down_e = "stair",
stair_down_w = "stair",
}
local spec = nil -- the loaded data file, or false when absent
local cache = {} -- tileset id -> resolved shape list
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
-- through the mod's own file loader rather than package.path: a mod's
-- directory is not on it, and may live inside a mounted .love archive that
-- plain require cannot reach either. Absent or broken degrades to the
-- derived defaults, which is a rougher-looking world rather than no world.
local function load()
if spec == nil then
local ok, s = pcall(V.data, "voxel_heights")
spec = (ok and type(s) == "table") and s or false
end
return spec or nil
end
function TileShape.heights()
local s = load()
local out = {}
for class, h in pairs(FALLBACK_HEIGHTS) do out[class] = h end
for class, h in pairs(s and s.heights or {}) do
if type(h) == "number" and FALLBACK_HEIGHTS[class] then out[class] = h end
end
return out
end
-- tile id -> class, from the hand-authored groups for one tileset. Unknown
-- class names are dropped rather than trusted: a typo in the data file
-- should degrade to the derived default, not invent a zero-height class.
local function authoredGroups(tilesetId, heights)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local out = {}
if not entry then return out end
for class, tiles in pairs(entry) do
if heights[class] and type(tiles) == "table" then
for _, t in ipairs(tiles) do out[t] = class end
end
end
return out
end
-- Conditional pins: tile id -> list of { above = {tile ids}, class }.
--
-- A pin is per TILE ID, and one graphic can mean two things. The route
-- gates' $32/$33 is the case that forced this: the artist reuses it for
-- the wall's dark base course AND for every service counter's front, and
-- it is the bottom row of its cell either way. Pinned `wall` the counter
-- stands a full 16px; pinned `counter` the wall bank corrugates 16/8 for
-- sixteen rows. Neither is right, and no per-tile pin can be, because
-- forMap resolves an id to ONE shape.
--
-- What separates the two uses is what is drawn ABOVE: the wall's upper
-- course over a wall base, the counter's top over a counter front. So a
-- profile entry may carry `when_above = { [tile] = { { above = {...},
-- class = "..." } } }`, evaluated per POSITION in TileShape.at, where
-- the map and coordinates are in hand. First match wins; no match keeps
-- the tile's ordinary pin.
local function authoredConditions(tilesetId, heights)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local spec = entry and entry.when_above
if type(spec) ~= "table" then return nil end
local out, any = {}, false
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule.above) == "table" then
local set = {}
for _, t in ipairs(rule.above) do set[t] = true end
list[#list + 1] = { above = set, class = rule.class }
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
end
return any and out or nil
end
local function shapeFor(class, heights, authored)
return { class = class, h = heights[class] or 0,
art = ART[class] or "upright",
-- grass and flowers draw a flat ground base like any walkable
-- tile; the standing tufts and cutouts are additive geometry
-- from Structures
flat = ART[class] == "flat" or class == "grass"
or class == "flower",
authored = authored or false }
end
-- Resolved TILE-LEVEL shapes for the tileset `map` uses: a list indexed by
-- tile id holding { class, h, art, flat, authored }, plus `classes`, one
-- canonical shape per class for the cell-level overrides in TileShape.at.
-- Cached per tileset id -- this table depends only on the tileset record
-- and the data file, both constant for a given id. The per-map part of
-- resolution (cell walkability) lives in TileShape.at, NOT here.
function TileShape.forMap(map)
local tileset = map.tileset
local id = tileset.id
if cache[id] then return cache[id] end
local heights = TileShape.heights()
local authored = authoredGroups(id, heights)
local count = math.floor((tileset.imageWidth or 128) / 8)
* math.floor((tileset.imageHeight or 48) / 8)
-- derived pin: a tile the tileset animates by FRAME REWRITE (the
-- overworld's flower) is already named by its animation spec, so like
-- tall grass it needs no profile entry anywhere. Hand-authoring still
-- wins -- a mod animating a wall tile this way keeps its wall by
-- listing it. Guarded because the spec seam is engine data a stub map
-- may not carry.
local flowerTiles = {}
do
local ok, declared = pcall(function()
if tileset.animatedTiles then return tileset.animatedTiles end
local TileRenderer = require("src.render.TileRenderer")
return TileRenderer.defaultAnimatedTiles(tileset)
end)
if ok then
for _, spec in ipairs(type(declared) == "table" and declared or {}) do
if spec.kind == "frames" and spec.tile then
flowerTiles[spec.tile] = true
end
end
end
end
local shapes = { classes = {}, cond = authoredConditions(id, heights) }
for class in pairs(FALLBACK_HEIGHTS) do
shapes.classes[class] = shapeFor(class, heights)
end
-- a conditional pin's own AUTHORED shape per class it can resolve to,
-- kept apart from the shared canonical ones above (see TileShape.at)
if shapes.cond then
shapes.condShape = {}
for _, rules in pairs(shapes.cond) do
for _, rule in ipairs(rules) do
shapes.condShape[rule.class] = shapes.condShape[rule.class]
or shapeFor(rule.class, heights, true)
end
end
end
for t = 0, count - 1 do
local class = authored[t]
if class then
shapes[t] = shapeFor(class, heights, true)
elseif t == tileset.grassTile then
-- derived pin: every tileset already names its tall-grass tile, so
-- the standing-tuft treatment needs no profile entry anywhere
shapes[t] = shapeFor("grass", heights, true)
elseif flowerTiles[t] then
shapes[t] = shapeFor("flower", heights, true)
elseif map.waterTiles and map.waterTiles[t] then
shapes[t] = shapes.classes.water
elseif map.walkable and map.walkable[t] then
shapes[t] = shapes.classes.ground
else
shapes[t] = shapes.classes.wall
end
end
shapes.count = count
cache[id] = shapes
return shapes
end
-- The shape of the tile at TILE coordinates (tx, ty) -- the full
-- resolution including the cell-granularity steps (see the header).
-- `shapes` is the table forMap returned for this map; `tile` is
-- map:tileAt(tx, ty), passed in because every caller already has it.
function TileShape.at(map, shapes, tile, tx, ty)
local s = shapes[tile]
-- conditional pins first: they are authored answers that need the
-- POSITION to resolve, so they outrank both the flat pin on the same
-- tile and the cell rules below (see authoredConditions)
local rules = shapes.cond and shapes.cond[tile]
if rules then
local above = map:tileAt(tx, ty - 1)
for _, rule in ipairs(rules) do
if above and rule.above[above] then
-- shapes.condShape, NOT shapes.classes: the canonical class
-- shapes are SHARED, and `wall` in particular is the very object
-- rule 4 hands every unauthored solid tile. Marking that one
-- authored (which the first cut did) made every one of them skip
-- the cell rules below, so walkable floors stopped flattening and
-- whole rooms rose into a checkerboard of blocks.
return shapes.condShape[rule.class]
end
end
end
if not s or s.authored then return s end
local cx = math.floor(tx / 2)
local cy = math.floor(ty / 2)
if map:isWaterCell(cx, cy) then return shapes.classes.water end
if map:isWalkableCell(cx, cy) then return shapes.classes.ground end
return s
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()
spec = nil
cache = {}
end
return TileShape