Files
DramaticShapeVoxelMod/lib/TileShape.lua
T
DramaticShape 702d8049f0 initial commit
2026-07-26 16:43:53 -04:00

260 lines
10 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,
billboard = 16,
signpost = 16,
post = 16,
grass = 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",
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",
-- 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
local function shapeFor(class, heights, authored)
return { class = class, h = heights[class] or 0,
art = ART[class] or "upright",
-- grass draws its flat ground base like any walkable tile; the
-- standing tuft rows are additive geometry from Structures
flat = ART[class] == "flat" or class == "grass",
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)
local shapes = { classes = {} }
for class in pairs(FALLBACK_HEIGHTS) do
shapes.classes[class] = shapeFor(class, heights)
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 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]
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