-- 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, 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", 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 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 = {} } 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 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] 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