mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 17:50:51 +02:00
292 lines
12 KiB
Lua
292 lines
12 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,
|
|
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
|