mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 13:50:50 +02:00
89b6c8513f
Adds `when_above` conditional pins, resolved per position in TileShape.at: one graphic can mean two things (the gates' $32 is both wall base course and counter front), and a flat pin has to pick one. Gates get half-height counters and level walls. Pokemon Tower gains a buildings entry -- it is the drawing the map edge cuts off, sealed on the north -- and the Indigo Plateau statues are built like the gym statues. Fixes: grass stands one full-height clump per tile instead of two half-cut stubs at different depths; ledge pillars drop to ledge height; a prop only rides furniture when its own cell is blocked (chairs were being lifted onto tables); and the caves had water and rock pinned backwards, which stood the Seafoam sea up as rock and cut trenches through Mt Moon. No cave tile is below the datum now.
358 lines
14 KiB
Lua
358 lines
14 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
|
|
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.classes[rule.class].authored = true
|
|
return shapes.classes[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
|