mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:50:50 +02:00
4092 lines
170 KiB
Lua
4092 lines
170 KiB
Lua
-- Voxel world mode: detect the map's structures and pick a 3D model for
|
|
-- each -- the 3dSen idea applied to a tile map. 3dSen turns flat NES
|
|
-- scenes into 3D by classifying every graphic into a geometry archetype
|
|
-- (floor, wall, box, voxelized sprite) and building real geometry that
|
|
-- keeps the original art as its texture; this module does the same with
|
|
-- the map's tile layer as the scene description:
|
|
--
|
|
-- 1. Flood-fill every connected region of solid (upright, unauthored)
|
|
-- tiles -- a house with its mailbox, the potted plant, a fence row,
|
|
-- a stretch of border forest.
|
|
--
|
|
-- 2. Decide which pixels of the region's art are BACKGROUND. Tileset
|
|
-- art carries no alpha and white is a paint color (window frames,
|
|
-- wall stripes), so whiteness alone says nothing. The map does: the
|
|
-- background is the white that CONNECTS TO WALKABLE GROUND in the
|
|
-- assembled scene. Seeding a flood from the surrounding ground
|
|
-- eats the air around a fence post or a plant's leaves but cannot
|
|
-- reach an interior wall's white stripes sealed behind its dark
|
|
-- trim -- exactly the distinction a human reads.
|
|
--
|
|
-- 3. Tiles whose art turned out mostly background are SPRITE-LIKE;
|
|
-- their connected clusters become per-pixel voxel OBJECTS at the
|
|
-- art's real drawn height (a 2-row plant is a 16px silhouette, a
|
|
-- fence a row of true posts with air between), thin voxel depth,
|
|
-- standing on synthesized ground. This splits mixed regions: the
|
|
-- mailbox voxelizes even where it touches the house.
|
|
--
|
|
-- 4. Everything else becomes a VOLUME: each column rises to the height
|
|
-- the structure is actually DRAWN. A column's run gives its extent,
|
|
-- repetition caps it -- the border forest repeats a 2-row canopy
|
|
-- for forty rows and must be rows of 16px trees, not a monolith --
|
|
-- and columns answer to their region: the column above a doorway
|
|
-- repeats internally but adopts its 48px house. The south face
|
|
-- folds the artwork up (ChunkMesher's band rule).
|
|
--
|
|
-- data/voxel_heights.lua is the PROFILE over this: a tile authored there
|
|
-- (ledges, or a mod pinning a shape) bypasses detection entirely, the way
|
|
-- a 3dSen game profile pins a pattern to a geometry type.
|
|
--
|
|
-- Everything here is derived per map and cached; pixel access (object
|
|
-- voxelization, void detection) degrades gracefully headless -- regions
|
|
-- simply stay volumes and the geometry tests keep passing.
|
|
|
|
-- the mod namespace (see main.lua): V.require loads a sibling module
|
|
local V = ...
|
|
|
|
local Assets = require("src.render.Assets")
|
|
local Map = require("src.world.Map")
|
|
local Buildings = V.require("Buildings")
|
|
local TileShape = V.require("TileShape")
|
|
local Budget = V.require("BuildBudget")
|
|
|
|
local Structures = {}
|
|
|
|
-- must match ChunkMesher's ring (3 border blocks, in tiles)
|
|
local RING = 12
|
|
|
|
-- how far past the map body cells still get the hull. A route's ring is
|
|
-- nearly as big as its body; modelling all of it costs hundreds of
|
|
-- thousands of quads of border trees nobody walks near. Beyond this,
|
|
-- pinned cells simply are not claimed and fall through to the mesher's
|
|
-- plain box -- cheap distant scenery. (Declared up here rather than
|
|
-- beside buildCylinders because forMap's grid resolve reads it too.)
|
|
local ROUND_RING = 4
|
|
|
|
-- object-mode gates
|
|
local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing
|
|
local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster
|
|
local TILE_BG_RATIO = 0.20 -- art background for "sprite-like"
|
|
local CLUSTER_MIN_BG = 0.05 -- a silhouette must actually exist
|
|
local OBJECT_DEPTH = 6 -- voxel thickness of a detected prop
|
|
-- thickness of profile-pinned standees per class: a TV is a deliberate
|
|
-- object and reads better with body; `prop` doubles as the THIN pool
|
|
-- (plants, stools -- mostly silhouette); `cutout` is paper: one voxel,
|
|
-- pure profile; `post` matches the 6px the detector gives the fence
|
|
-- rows it finds on its own, so pinned and detected fences look alike;
|
|
-- `signpost` is a plate on a stick -- 2 voxels, the thinnest that still
|
|
-- shows an edge; `bike` is the same 2 for the same reason from the other
|
|
-- direction -- a bicycle drawn side-on is a LINE drawing whose negative
|
|
-- space is the drawing, and at the 5 voxels `prop` gives, the side faces
|
|
-- of neighbouring strokes close every gap in it off-axis
|
|
local PINNED_DEPTH = { billboard = 10, prop = 5, stool = 10, cutout = 1,
|
|
console = 10, post = 6, signpost = 2, bike = 2 }
|
|
|
|
local MAX_ROWS = 6 -- volume height cap: 48px
|
|
|
|
local cache = {}
|
|
|
|
-- ---------------------------------------------------------------- pixels --
|
|
|
|
local atlasData = {}
|
|
|
|
local function pixels(tileset)
|
|
local path = tileset.image
|
|
if atlasData[path] == nil then
|
|
local ok, data = pcall(Assets.imageData, path)
|
|
atlasData[path] = (ok and data and data.getPixel) and data or false
|
|
end
|
|
return atlasData[path] or nil
|
|
end
|
|
|
|
-- tiles whose art is entirely black or transparent (interior darkness):
|
|
-- these never extrude, whatever class they resolved to
|
|
local function voidTiles(tileset)
|
|
local data = pixels(tileset)
|
|
if not data then return nil end
|
|
local perRow = tileset.tilesPerRow or 16
|
|
local iw, ih = data:getDimensions()
|
|
local set = {}
|
|
for t = 0, (iw / 8) * (ih / 8) - 1 do
|
|
local ox = (t % perRow) * 8
|
|
local oy = math.floor(t / perRow) * 8
|
|
local void = true
|
|
for py = 0, 7 do
|
|
for px = 0, 7 do
|
|
local r, g, b, a = data:getPixel(ox + px, oy + py)
|
|
if a > 0 and math.max(r, g, b) > 0.17 then
|
|
void = false
|
|
break
|
|
end
|
|
end
|
|
if not void then break end
|
|
end
|
|
if void then set[t] = true end
|
|
end
|
|
return set
|
|
end
|
|
|
|
-- ----------------------------------------------------------------- build --
|
|
|
|
local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
|
|
|
|
local function keyOf(tx, ty)
|
|
return (ty + 64) * 4096 + (tx + 64)
|
|
end
|
|
|
|
function Structures.forMap(map)
|
|
local S = cache[map.id]
|
|
if S then return S end
|
|
|
|
local tileset = map.tileset
|
|
local shapes = TileShape.forMap(map)
|
|
local void = voidTiles(tileset)
|
|
local perRow = tileset.tilesPerRow or 16
|
|
|
|
local def = map.def
|
|
local tw, th = def.width * 4, def.height * 4
|
|
local x0, x1 = -RING, tw + RING - 1
|
|
local y0, y1 = -RING, th + RING - 1
|
|
|
|
-- resolve the whole grid once: shape + tile per key. Ring positions use
|
|
-- the same border override the 2D renderer draws with
|
|
-- (TileRenderer.borderBlockFor: outdoor maps ring with the solid tree
|
|
-- wall, NOT their own borderBlock) -- a route's borderBlock is the GRASS
|
|
-- block, and meshing that grew a 12-tile apron of tall grass past every
|
|
-- route edge, which leaked into the neighbouring town's plaza.
|
|
-- BLACK void fill is not a block at all: borderBlockFor answers `false`,
|
|
-- and there is simply nothing out there to build. tileLookup then returns
|
|
-- nil past the body and the ring keys are never written, which the whole
|
|
-- file already copes with -- every neighbour query reaches one step
|
|
-- outside the analysed range and reads nil for its trouble, so an absent
|
|
-- cell is the shape "nothing" has always had here. (It used to add 1 to
|
|
-- that `false`, which threw, failed the mesh build for every map on the
|
|
-- route, and dropped the mode to the flat 2D path entirely.)
|
|
local TileRenderer = require("src.render.TileRenderer")
|
|
local borderId = TileRenderer.borderBlockFor(map)
|
|
local borderBlk = borderId and tileset.blocks[borderId + 1] or nil
|
|
-- TREES fill stops at ROUND_RING instead of running the full RING.
|
|
-- Only that far out does a tree cell get carved into a hull; past it
|
|
-- the cells fall through to the mesher's plain box, and a slab of
|
|
-- flat-topped boxes beside the modelled wall reads as a painted-on
|
|
-- plateau -- the wall looking like it was cut off with scissors. So
|
|
-- the far ring is simply not built: beyond ROUND_RING tileLookup
|
|
-- answers nil, which is the same "nothing out there" BLACK already
|
|
-- produces and every pass below already copes with. The cut lands on
|
|
-- the carve boundary exactly -- the 2x2-cell canopy scan starts at
|
|
-- floor(-RING/2) and RING, ROUND_RING and the body are all multiples
|
|
-- of 4 tiles, so no group is left half-resolved at the edge.
|
|
--
|
|
-- WATER and the other tilesets' own borders keep the full ring: a flat
|
|
-- sheet of water is what water looks like from above anyway, and an
|
|
-- interior's border is black already.
|
|
local hullRingOnly = borderBlk and def.tileset == "OVERWORLD"
|
|
and (TileRenderer.voidFill or "trees") == "trees"
|
|
local tw2, th2 = tw, th
|
|
local function tileLookup(tx, ty)
|
|
if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then
|
|
return map:tileAt(tx, ty)
|
|
end
|
|
if not borderBlk then return nil end
|
|
if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING
|
|
or tx >= tw2 + ROUND_RING
|
|
or ty >= th2 + ROUND_RING) then
|
|
return nil
|
|
end
|
|
return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0
|
|
end
|
|
local shapeAt, tileAt = {}, {}
|
|
for ty = y0, y1 do
|
|
for tx = x0, x1 do
|
|
Budget.tick()
|
|
local tile = tileLookup(tx, ty)
|
|
if tile then
|
|
local k = keyOf(tx, ty)
|
|
local s = TileShape.at(map, shapes, tile, tx, ty)
|
|
if s and void and void[tile] and not s.authored then
|
|
s = shapes.classes.void
|
|
end
|
|
shapeAt[k], tileAt[k] = s, tile
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- buildings: whole sprites voxelized band by band ----
|
|
--
|
|
-- Before anything else looks at this grid. A profiled building is a
|
|
-- drawing whose bands depict DIFFERENT 3D surfaces (roof from above,
|
|
-- facade face-on, ends sloped), and the passes below -- the door fold,
|
|
-- the region flood, the volume builder -- all assume one drawing is one
|
|
-- upright thing. Modelling the building first and claiming its tiles
|
|
-- keeps every one of them off it.
|
|
--
|
|
-- (grassQuads live apart from objectQuads: grass renders as its own mesh
|
|
-- AFTER the characters -- see VoxelScene -- so the southern tuft row
|
|
-- still overdraws a walker's feet even though characters stamp over
|
|
-- terrain.)
|
|
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
|
|
hideBareRing = hullRingOnly or nil,
|
|
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
|
|
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {},
|
|
-- tile key -> the row a collapsed bookcase rank's box actually
|
|
-- stands on, so a standee supported by one lands on it rather than
|
|
-- where the drawing put it (see buildBookcases)
|
|
bookcaseBox = {} }
|
|
Buildings.build(S, map, pixels(tileset), perRow)
|
|
|
|
-- Fold doors into their buildings. A door cell is WALKABLE (the player
|
|
-- steps onto it to warp), so it resolves to ground and punches a hole in
|
|
-- the facade: the door lies flat, the rows above it recess, and -- worse
|
|
-- -- the hole lets the background flood into the building's interior
|
|
-- whites, shredding it into misdetected sprite clusters. Visually the
|
|
-- door is part of the facade, so mark the door cell's tiles structural:
|
|
-- the fold then shows the door art standing at ground level in the
|
|
-- building's front face. Door graphics only (the tileset's doorTiles);
|
|
-- interior stair/mat warps stay flat.
|
|
--
|
|
-- A PROFILE PIN WINS over the fold. The fold is detection, and rule 1
|
|
-- of the resolution order is that an authored tile bypasses detection
|
|
-- -- but this used to overwrite shapeAt unconditionally, so a pin on
|
|
-- any tile the tileset also lists in doorTiles was dead on arrival.
|
|
-- Celadon Mansion is the case that found it: all four of its
|
|
-- staircases are door tiles, so `stair_e` / `stair_down_w` pins there
|
|
-- silently did nothing and the flights stayed painted on the floor.
|
|
for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
|
|
for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
|
|
if map.doorTiles[map:cellTile(cx, cy)] then
|
|
local northK = keyOf(cx * 2, cy * 2 - 1)
|
|
local ns = shapeAt[northK]
|
|
if ns and ns.art == "upright" then
|
|
for dy = 0, 1 do
|
|
for dx = 0, 1 do
|
|
local dk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
|
local ds = shapeAt[dk]
|
|
if not (ds and ds.authored) then
|
|
shapeAt[dk] = shapes.classes.wall
|
|
-- remembered for buildVolume: a folded doorway column
|
|
-- answers to its REGION for height and top, not to its
|
|
-- own drawn extent (see the door adoption there)
|
|
S.doorFold[dk] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- a structure cell: solid art the detector may model (authored tiles are
|
|
-- profile-pinned and keep their authored shape)
|
|
local function structural(k)
|
|
local s = shapeAt[k]
|
|
return s and s.art == "upright" and not s.authored
|
|
end
|
|
|
|
-- ---- cylinders: profile-pinned round graphics, one per 16x16 cell ----
|
|
-- the flat ground tiles this map actually places, for the hull's
|
|
-- ground matching: the ball's own drawn background picks its floor
|
|
local groundTiles = {}
|
|
do
|
|
local seenG = {}
|
|
for k, s in pairs(shapeAt) do
|
|
if s and s.flat and s.class == "ground" then
|
|
local t = tileAt[k]
|
|
if t and not seenG[t] then
|
|
seenG[t] = true
|
|
groundTiles[#groundTiles + 1] = t
|
|
end
|
|
end
|
|
end
|
|
end
|
|
Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
|
|
|
-- ---- stairs: profile-pinned cells that render as real steps ----
|
|
Structures.buildStairs(S, map, x0, x1, y0, y1)
|
|
|
|
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
|
|
-- The atlas comes along so the shelf front can carry its own measured
|
|
-- relief: the panes it seals behind its black frames sink a voxel.
|
|
Structures.buildBookcases(S, map, x0, x1, y0, y1, pixels(tileset), perRow)
|
|
|
|
-- ---- figures: a person drawn INTO furniture, lifted off it ----
|
|
-- Before the region flood and the volume pass, so everything after this
|
|
-- reads the tiles the profile says are there once the figure is gone.
|
|
-- (Its own tiles are authored furniture or walkable floor either way, so
|
|
-- no pass below would have claimed them -- but the repaint is what those
|
|
-- passes should see, and this needs no pixel access to do it.)
|
|
Structures.buildFigures(S, map, x0, x1, y0, y1)
|
|
|
|
-- ---- mounted: a thing drawn INTO a wall band, stood proud of it ----
|
|
-- Here for the same reason and with the same guarantee as the figures
|
|
-- above: the repaint hands every pass below the plain panel the profile
|
|
-- says is behind the object, so the wall band it was painted into keeps
|
|
-- resolving as the wall it is -- without a second copy of the drawing
|
|
-- flat on its face.
|
|
Structures.buildMounted(S, map, x0, x1, y0, y1)
|
|
|
|
-- ---- flood-fill regions of structural tiles ----
|
|
local seen = {}
|
|
local regions = {}
|
|
for ty = y0, y1 do
|
|
for tx = x0, x1 do
|
|
local k = keyOf(tx, ty)
|
|
if structural(k) and not seen[k] then
|
|
local region = { tiles = {}, minX = tx, maxX = tx,
|
|
minY = ty, maxY = ty }
|
|
local queue = { { tx, ty } }
|
|
seen[k] = true
|
|
while #queue > 0 do
|
|
Budget.tick()
|
|
local c = table.remove(queue)
|
|
local cx, cy = c[1], c[2]
|
|
region.tiles[#region.tiles + 1] = c
|
|
region.minX = math.min(region.minX, cx)
|
|
region.maxX = math.max(region.maxX, cx)
|
|
region.minY = math.min(region.minY, cy)
|
|
region.maxY = math.max(region.maxY, cy)
|
|
for _, d in ipairs(DIRS4) do
|
|
local nx, ny = cx + d[1], cy + d[2]
|
|
if nx >= x0 and nx <= x1 and ny >= y0 and ny <= y1 then
|
|
local nk = keyOf(nx, ny)
|
|
if structural(nk) and not seen[nk] then
|
|
seen[nk] = true
|
|
queue[#queue + 1] = { nx, ny }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
regions[#regions + 1] = region
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- model each region: carve out per-pixel objects, volume the rest --
|
|
local data = pixels(tileset)
|
|
for _, region in ipairs(regions) do
|
|
local leftover = region.tiles
|
|
if data then
|
|
leftover = Structures.extractObjects(S, map, region, data, perRow)
|
|
end
|
|
if #leftover > 0 then
|
|
Structures.buildVolume(S, map, leftover)
|
|
end
|
|
end
|
|
|
|
-- ---- profile-pinned billboards (signs): forced per-pixel slabs ----
|
|
if data then
|
|
local seenB = {}
|
|
for ty = y0, y1 do
|
|
for tx = x0, x1 do
|
|
local k = keyOf(tx, ty)
|
|
local s = shapeAt[k]
|
|
if s and s.art == "billboard" and not seenB[k] then
|
|
local reg = { tiles = {}, minX = tx, maxX = tx,
|
|
minY = ty, maxY = ty }
|
|
local queue = { { tx, ty } }
|
|
seenB[k] = true
|
|
while #queue > 0 do
|
|
local c = table.remove(queue)
|
|
reg.tiles[#reg.tiles + 1] = c
|
|
reg.minX = math.min(reg.minX, c[1])
|
|
reg.maxX = math.max(reg.maxX, c[1])
|
|
reg.minY = math.min(reg.minY, c[2])
|
|
reg.maxY = math.max(reg.maxY, c[2])
|
|
for _, d in ipairs(DIRS4) do
|
|
local nk = keyOf(c[1] + d[1], c[2] + d[2])
|
|
local ns = shapeAt[nk]
|
|
-- same CLASS, not just billboard art: `billboard` and
|
|
-- `prop` are two pools precisely so touching drawings (a TV
|
|
-- behind its console) become two standing objects instead
|
|
-- of one stacked cutout
|
|
if ns and ns.art == "billboard" and ns.class == s.class
|
|
and not seenB[nk] then
|
|
seenB[nk] = true
|
|
queue[#queue + 1] = { c[1] + d[1], c[2] + d[2] }
|
|
end
|
|
end
|
|
end
|
|
Structures.extractObjects(S, map, reg, data, perRow, true)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- profile-pinned fence posts: per-CELL standee slabs ----
|
|
-- A fence line repeats one drawing for a dozen cells, and its art
|
|
-- touches across cell seams. Pooled like a billboard the whole line
|
|
-- would stand as ONE drawing-tall tower at one depth (the detector's
|
|
-- vertical-repetition guard exists precisely to refuse that, which
|
|
-- is why undetected fence columns fell to the volume path as boxes).
|
|
-- Each CELL extracts alone instead: its posts stand in their own row
|
|
-- band and the fence marches north cell by cell.
|
|
local postCells = {}
|
|
for ty = y0, y1 do
|
|
for tx = x0, x1 do
|
|
local s = shapeAt[keyOf(tx, ty)]
|
|
if s and s.art == "post" then
|
|
local ck = keyOf(math.floor(tx / 2), math.floor(ty / 2))
|
|
postCells[ck] = postCells[ck] or {}
|
|
local list = postCells[ck]
|
|
list[#list + 1] = { tx, ty }
|
|
end
|
|
end
|
|
end
|
|
for _, tiles in pairs(postCells) do
|
|
local reg = { tiles = tiles,
|
|
minX = tiles[1][1], maxX = tiles[1][1],
|
|
minY = tiles[1][2], maxY = tiles[1][2] }
|
|
for _, c in ipairs(tiles) do
|
|
reg.minX = math.min(reg.minX, c[1])
|
|
reg.maxX = math.max(reg.maxX, c[1])
|
|
reg.minY = math.min(reg.minY, c[2])
|
|
reg.maxY = math.max(reg.maxY, c[2])
|
|
end
|
|
Structures.extractObjects(S, map, reg, data, perRow, "opaque")
|
|
end
|
|
|
|
-- ---- profile-pinned relief props: top-down drawings that extrude ----
|
|
local seenR = {}
|
|
for ty = y0, y1 do
|
|
for tx = x0, x1 do
|
|
local k = keyOf(tx, ty)
|
|
local s = shapeAt[k]
|
|
if s and s.art == "relief" and not seenR[k] then
|
|
local reg = { tiles = {}, minX = tx, maxX = tx,
|
|
minY = ty, maxY = ty }
|
|
local queue = { { tx, ty } }
|
|
seenR[k] = true
|
|
while #queue > 0 do
|
|
local c = table.remove(queue)
|
|
reg.tiles[#reg.tiles + 1] = c
|
|
reg.minX = math.min(reg.minX, c[1])
|
|
reg.maxX = math.max(reg.maxX, c[1])
|
|
reg.minY = math.min(reg.minY, c[2])
|
|
reg.maxY = math.max(reg.maxY, c[2])
|
|
for _, d in ipairs(DIRS4) do
|
|
local nk = keyOf(c[1] + d[1], c[2] + d[2])
|
|
local ns = shapeAt[nk]
|
|
if ns and ns.art == "relief" and ns.class == s.class
|
|
and not seenR[nk] then
|
|
seenR[nk] = true
|
|
queue[#queue + 1] = { c[1] + d[1], c[2] + d[2] }
|
|
end
|
|
end
|
|
end
|
|
for _, c in ipairs(reg.tiles) do
|
|
local ck = keyOf(c[1], c[2])
|
|
S.skip[ck] = true
|
|
S.ground[ck] = false
|
|
end
|
|
Structures.buildRelief(S, map, reg, data, perRow, s.h or 5)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- tall grass: two standing tuft rows per tile. BODY only: the 2D
|
|
-- renderer never draws a neighbour's ring, and standing scenery past a
|
|
-- map's edge would poke into the map next door ----
|
|
Structures.buildGrass(S, map, 0, tw - 1, 0, th - 1, data)
|
|
|
|
-- ---- flowers: the animated meadow tile stands as a 1px cutout ----
|
|
Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
|
|
end
|
|
|
|
-- ---- authored ground under pinned props ----
|
|
-- The profile can name the tile a pinned prop stands on (a tileset
|
|
-- entry's prop_ground: prop tile id -> ground tile id), overriding
|
|
-- the neighbour vote. The cuttable bush stands on the plain grass
|
|
-- Cut itself leaves behind, not on whatever path its neighbours
|
|
-- happen to vote in.
|
|
do
|
|
local okP, prof = pcall(V.data, "voxel_heights")
|
|
local entry = okP and type(prof) == "table" and prof.tilesets
|
|
and prof.tilesets[tileset.id]
|
|
local pg = entry and entry.prop_ground
|
|
if type(pg) == "table" then
|
|
for k, skipped in pairs(S.skip) do
|
|
if skipped then
|
|
local g = pg[S.tileAt[k]]
|
|
if g then S.ground[k] = g end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- unresolved claimed ground (a hull with no art match, headless
|
|
-- cylinders): no flat neighbour to vote with, so fall back to the
|
|
-- map's commonest ground tile
|
|
local votes, best, bestN = {}, nil, 0
|
|
for k, s in pairs(shapeAt) do
|
|
if s and s.flat and s.class == "ground" then
|
|
local t = tileAt[k]
|
|
votes[t] = (votes[t] or 0) + 1
|
|
if votes[t] > bestN then best, bestN = t, votes[t] end
|
|
end
|
|
end
|
|
for k, g in pairs(S.ground) do
|
|
if g == false then S.ground[k] = best end
|
|
end
|
|
|
|
cache[map.id] = S
|
|
return S
|
|
end
|
|
|
|
-- ---- round scenery: outline-hulled voxel balls ----
|
|
|
|
-- Cells the profile pins as round (tree canopies -- the class keeps its
|
|
-- historical `cylinder` name in the data file) render as a VOXEL HULL cut
|
|
-- from the drawing itself. The first shipped attempt was a lathe -- the
|
|
-- per-row silhouette width revolved into a 12-segment column with the art
|
|
-- wrapped by sin(angle) -- and it read exactly like what it was: the
|
|
-- sprite pasted on a cylinder, with the wrap smearing the pixels into
|
|
-- vertical stripes. This replaces it with real voxels.
|
|
--
|
|
-- Segmentation first, silhouette-width second: the tree cell's art is a
|
|
-- ball drawn over background grass, and the background's mid greens pass
|
|
-- any brightness test (they inflated every lathe row to full width). The
|
|
-- ball's own DARKEST pixels are what bound it, so the mask is "the
|
|
-- darkest-shade outline plus everything it encloses": flood from the cell
|
|
-- border through every non-black pixel; what the flood cannot reach is
|
|
-- the tree, and the cast shadow under the canopy (dark but not enclosed)
|
|
-- floods away with the grass. Art with no closed black outline -- the
|
|
-- border tree wall is a dither of black and canopy with no drawn ring --
|
|
-- encloses nothing; there the flood passes only through the LIGHT shades
|
|
-- (the methodology doc's rule: black and dark together form the
|
|
-- boundary), and the dither mass itself becomes the mask, checker holes
|
|
-- and all, because a 4-connected flood cannot thread a diagonal checker.
|
|
--
|
|
-- Volume: each mask row is a disc. The row's span gives a center and
|
|
-- half-width, and every mask pixel's column runs that circle's chord in
|
|
-- z, quantized to whole voxels -- the front view IS the sprite, the plan
|
|
-- view is the sprite's own width profile turned in depth, and both step
|
|
-- pixel by pixel. Rows below the mask (the drawn shadow) repeat the
|
|
-- bottom row's discs down to the ground so the canopy stands on a short
|
|
-- dark foot instead of floating.
|
|
--
|
|
-- Skin: front and back faces carry the drawing per-pixel (the back reads
|
|
-- mirrored, sprite-pure); side and step faces take their column's own
|
|
-- texel, which puts the drawn outline exactly on the silhouette's rim;
|
|
-- and a fully exposed cap keeps its outline only on the rim cells while
|
|
-- the interior samples the canopy a couple of rows deeper -- painting the
|
|
-- whole cap with the outline row blacked out every dome on the first
|
|
-- attempt (the lathe hit the same bug with its top discs).
|
|
--
|
|
-- Tree walls repeat the same four tiles for hundreds of cells, so the
|
|
-- hull is built once per distinct art signature and stamped per cell.
|
|
local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78,
|
|
top = 1.0, bottom = 0.55 }
|
|
|
|
-- The potted plant's ORGANIC HALF: the leaf crown (16 rows), then the
|
|
-- trunk, its root flare and the strands draping over the pot's rim (8
|
|
-- more) -- all of it stands as a slab this many voxels deep instead of
|
|
-- revolving. `depth` 5 is the thin standee pool's depth, what every other
|
|
-- interior plant already uses.
|
|
--
|
|
-- `rows` = 24 puts the slab/revolve boundary AT THE VESSEL'S RIM ROW, and
|
|
-- that placement is what makes the pot read as a pot. The first cut put
|
|
-- it at the cell seam (16), which let the root and drape rows revolve:
|
|
-- their drawn spans are 8-12 wide, so they stacked 8-12-deep discs on top
|
|
-- of the rim and the whole base read as one bulbous onion instead of a
|
|
-- flat-mouthed planter with a trunk standing out of it. Only rows 24-31
|
|
-- -- black rim edge, gold band, body, foot, the drawn flowerpot profile
|
|
-- -- are the vessel, and only they revolve.
|
|
local PLANTER_SPRAY = { rows = 24, depth = 5 }
|
|
|
|
-- `spray`, when given, caps the chord over the canvas's top `rows` rows to
|
|
-- `depth` voxels instead of revolving them.
|
|
--
|
|
-- Revolving a row turns its DRAWN WIDTH into depth, which only means
|
|
-- something when the drawing states a width to turn -- the pot's rows do
|
|
-- (a 3px stem opening to a 12px belly and closing to a 6px foot, an urn's
|
|
-- profile), and a tree canopy's do (the ball's outline is drawn). A leaf
|
|
-- crown's do NOT: the leaves are a spray that runs off all four sides of
|
|
-- its tile, so every row measures the full canvas and the revolve can only
|
|
-- produce a solid cylinder -- the "hedge column" a plant must never become,
|
|
-- with one row of texels smeared down its whole top face. Where the drawing
|
|
-- states no profile, the honest reading is the one the thin standee pools
|
|
-- exist for: the foliage stands as a per-pixel slab and keeps the airy
|
|
-- silhouette that makes it read as leaves.
|
|
-- `squash`, when given, is the PERCENT of its revolved depth every chord
|
|
-- keeps -- 100 (or nil) is the identity, 50 halves the hull front to back.
|
|
--
|
|
-- A full revolve assumes the drawing's width is also its depth, which is
|
|
-- true of a thing that really is round in plan (a hedge ball, a boulder,
|
|
-- a trash can). A TREE is round in its canopy and thin at every other
|
|
-- reading: the trunk is a stick, the crown is more air than wood, and the
|
|
-- drawing is scenery seen from one side. Revolved at full width the little
|
|
-- tree eats a whole cell of depth and reads as a boulder wearing bark, so
|
|
-- the plan stays a circle and shrinks toward an ellipse: still round in
|
|
-- section, still stepping pixel by pixel, just shallower. The chord is
|
|
-- re-centred on the mid-plane, so the model neither slides nor detaches
|
|
-- from the cells around it.
|
|
local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows,
|
|
NYin, spray, baseRows, bodyRows, wellRows,
|
|
taperVox, squash)
|
|
-- The canvas is NX wide and NX DEEP (a hull is round in plan, so its
|
|
-- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell
|
|
-- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing
|
|
-- STACKED two cells high on one cell of plot (the potted plant).
|
|
local NX = N or 16
|
|
local NY = NYin or NX
|
|
local N2 = NX / 2
|
|
local perRow = map.tileset.tilesPerRow or 16
|
|
local atlasW = map.tileset.imageWidth or 128
|
|
local atlasH = map.tileset.imageHeight or 48
|
|
|
|
-- cell-space art access (NX x NY, row 0 = top), anchored at cell (cx, cy)
|
|
local function tileOf(px, py)
|
|
return S.tileAt[keyOf(cx * 2 + math.floor(px / 8),
|
|
cy * 2 + math.floor(py / 8))]
|
|
end
|
|
local function texel(px, py)
|
|
local tile = tileOf(px, py)
|
|
return (tile % perRow) * 8 + px % 8,
|
|
math.floor(tile / perRow) * 8 + py % 8
|
|
end
|
|
|
|
-- shade class of every canvas pixel, indexed py * NX + px
|
|
local cls = {}
|
|
for py = 0, NY - 1 do
|
|
for px = 0, NX - 1 do
|
|
local ax, ay = texel(px, py)
|
|
local r, g, b, a = data:getPixel(ax, ay)
|
|
cls[py * NX + px] = a == 0 and "off"
|
|
or Structures.shadeClass(math.min(r, g, b))
|
|
end
|
|
end
|
|
|
|
-- 4-connected flood from a row band's border through `passable` classes
|
|
local function floodOutside(passable, y0, y1)
|
|
local out, stack = {}, {}
|
|
local function seed(i)
|
|
if not out[i] and passable[cls[i]] then
|
|
out[i] = true
|
|
stack[#stack + 1] = i
|
|
end
|
|
end
|
|
for px = 0, NX - 1 do
|
|
seed(y0 * NX + px); seed(y1 * NX + px)
|
|
end
|
|
for py = y0, y1 do
|
|
seed(py * NX); seed(py * NX + NX - 1)
|
|
end
|
|
while #stack > 0 do
|
|
local i = table.remove(stack)
|
|
local px, py = i % NX, math.floor(i / NX)
|
|
if px > 0 then seed(i - 1) end
|
|
if px < NX - 1 then seed(i + 1) end
|
|
if py > y0 then seed(i - NX) end
|
|
if py < y1 then seed(i + NX) end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- The mask -- darkest-pixel outline plus its enclosure, with the dither
|
|
-- rule as fallback -- computed per CELL BAND of NX rows.
|
|
--
|
|
-- A square canvas is ONE band, so this is exactly the whole-canvas rule
|
|
-- it replaces. A STACKED canvas needs it per band because its two halves
|
|
-- want opposite answers: the potted plant's leaf crown is a black-outlined
|
|
-- dither drawn over floor (outline enclosure keeps it), while its pot is a
|
|
-- solid DARK body whose base runs flush to the band's bottom edge (the
|
|
-- enclosure flood walks in through dark and guts it, and the fallback --
|
|
-- which the band's own `enclosed` count asks for -- keeps it). Measured on
|
|
-- the Center plant: one flood over both bands keeps 53% of the drawing and
|
|
-- leaves the pot a hollow black frame; per band keeps 68% and both read.
|
|
local mask = {}
|
|
for band = 0, NY / NX - 1 do
|
|
local y0, y1 = band * NX, band * NX + NX - 1
|
|
local out = floodOutside({ off = true, dark = true,
|
|
light = true, white = true }, y0, y1)
|
|
local enclosed = 0
|
|
for i = y0 * NX, (y1 + 1) * NX - 1 do
|
|
if not out[i] then
|
|
mask[i] = true
|
|
if cls[i] ~= "black" then enclosed = enclosed + 1 end
|
|
end
|
|
end
|
|
if enclosed < NX * NX / 8 then
|
|
out = floodOutside({ off = true, light = true, white = true }, y0, y1)
|
|
for i = y0 * NX, (y1 + 1) * NX - 1 do
|
|
mask[i] = (not out[i] and cls[i] ~= "off") or nil
|
|
end
|
|
end
|
|
end
|
|
local any = nil
|
|
for i = 0, NX * NY - 1 do any = any or mask[i] end
|
|
if not any then return {} end
|
|
|
|
-- a CAPPED hull (the stump): the top capRows rows of the mask are the
|
|
-- drawn cut face -- a surface seen at an angle, not body. Strip them
|
|
-- from the mask and remember their art span; the top-face quads below
|
|
-- project that ellipse across the round cap.
|
|
local capY0, capY1 = nil, nil
|
|
if capRows and capRows > 0 then
|
|
local top = nil
|
|
for iy = 0, NY - 1 do
|
|
for ix = 0, NX - 1 do
|
|
if mask[iy * NX + ix] then top = iy break end
|
|
end
|
|
if top then break end
|
|
end
|
|
if top then
|
|
capY0 = top
|
|
capY1 = math.min(top + capRows - 1, NY - 2)
|
|
for iy = capY0, capY1 do
|
|
for ix = 0, NX - 1 do mask[iy * NX + ix] = nil end
|
|
end
|
|
any = nil
|
|
for i = 0, NX * NY - 1 do any = any or mask[i] end
|
|
if not any then return {} end
|
|
end
|
|
end
|
|
|
|
-- a FLAT-BASED hull (the can): the bottom baseRows rows of the mask are
|
|
-- the BASE circle's front arc -- the drawing's mirror of the cut face
|
|
-- above, ground contact seen from above rather than body. A can is only
|
|
-- round in the horizontal plane, so the drop those rows make toward the
|
|
-- middle is DEPTH, not a narrowing of the plan: left as body they revolve
|
|
-- into ever smaller discs and the can ends up balanced on a stem three
|
|
-- voxels wide (which is exactly what the first build did). Strip them and
|
|
-- the foot rule below runs the last body row's full disc straight to the
|
|
-- floor; the rows keep their own texels there, so the front view is still
|
|
-- the drawing, base rim and all.
|
|
local baseArt = nil
|
|
if baseRows and baseRows > 0 then
|
|
local bot = nil
|
|
for iy = NY - 1, 0, -1 do
|
|
for ix = 0, NX - 1 do
|
|
if mask[iy * NX + ix] then bot = iy break end
|
|
end
|
|
if bot then break end
|
|
end
|
|
if bot then
|
|
baseArt = {}
|
|
for iy = math.max(bot - baseRows + 1, (capY1 or -1) + 2), bot do
|
|
for ix = 0, NX - 1 do
|
|
local i = iy * NX + ix
|
|
if mask[i] then baseArt[i] = true end
|
|
mask[i] = nil
|
|
end
|
|
end
|
|
any = nil
|
|
for i = 0, NX * NY - 1 do any = any or mask[i] end
|
|
if not any then return {} end
|
|
end
|
|
end
|
|
|
|
-- The can's HEIGHT, and the one place this file departs from the drawing
|
|
-- on purpose. Strictly un-projected, the drawing states a squat drum: cut
|
|
-- the mouth ellipse off the top and the base circle off the bottom and
|
|
-- barely two rows of straight side are left between them, because the GB
|
|
-- artist spent most of a 16px cell on the opening. A real bin is TALLER
|
|
-- than it is wide, and the flat game reads as one because the drawing is
|
|
-- 14px tall next to a 16px player -- so the height is authored (can_height
|
|
-- voxels) rather than measured, and the surviving body band is repeated
|
|
-- upward to fill it, bottom row first, which continues the drawn rib
|
|
-- rhythm instead of inventing a texel. Everything else still comes off
|
|
-- the pixels.
|
|
local artRow = {}
|
|
if bodyRows and bodyRows > 0 then
|
|
local body = {}
|
|
for iy = 0, NY - 1 do
|
|
for ix = 0, NX - 1 do
|
|
if mask[iy * NX + ix] then body[#body + 1] = iy break end
|
|
end
|
|
end
|
|
local nb = #body
|
|
if nb > 0 then
|
|
local top = body[1]
|
|
for iy = top - 1, math.max(NY - bodyRows, 0), -1 do
|
|
-- the LOWEST surviving body row, repeated: it is the widest and
|
|
-- plainest reading of the material (outline, shaded flank, lit
|
|
-- face) and stacks into a clean metal cylinder. Cycling the whole
|
|
-- surviving band instead stacks the drawn rim arcs into a barcode
|
|
-- of hoops, which is detail the drawing never states about the
|
|
-- side of the can.
|
|
local from = body[nb]
|
|
artRow[iy] = from
|
|
for ix = 0, NX - 1 do
|
|
mask[iy * NX + ix] = mask[from * NX + ix]
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the ground the ball stands on: the drawing's own background names
|
|
-- it. Score every flat ground tile the map places against the cell's
|
|
-- unmasked light pixels and keep the closest -- mid-forest trees have
|
|
-- no flat neighbour to vote with, and the commonest-ground fallback
|
|
-- paints pale path under trees whose art sits on grass. Dark unmasked
|
|
-- pixels (the drawn cast shadow) stay out of the score: no ground
|
|
-- tile carries a shadow, and their darks would drag every match.
|
|
local bg = nil
|
|
if groundTiles and #groundTiles > 0 then
|
|
local bestScore = nil
|
|
for _, t in ipairs(groundTiles) do
|
|
local ox = (t % perRow) * 8
|
|
local oy = math.floor(t / perRow) * 8
|
|
local score, n = 0, 0
|
|
for py = 0, NY - 1 do
|
|
for px = 0, NX - 1 do
|
|
local i = py * NX + px
|
|
local c = cls[i]
|
|
-- a stripped base row is the OBJECT's own rim, not background:
|
|
-- scoring its whites against the floor tiles matches paper-white
|
|
-- ground under a can whose art stands on the gym's grey
|
|
if not mask[i] and not (baseArt and baseArt[i])
|
|
and (c == "light" or c == "white") then
|
|
local ax, ay = texel(px, py)
|
|
local r1, g1, b1 = data:getPixel(ax, ay)
|
|
local r2, g2, b2 = data:getPixel(ox + px % 8, oy + py % 8)
|
|
local dr, dg, db = r1 - r2, g1 - g2, b1 - b2
|
|
score = score + dr * dr + dg * dg + db * db
|
|
n = n + 1
|
|
end
|
|
end
|
|
end
|
|
if n > 0 then
|
|
score = score / n
|
|
if not bestScore or score < bestScore then bestScore, bg = score, t end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- discs: per mask pixel a z chord [z0, z1), from its row's span circle.
|
|
-- z2/z3 is an optional SECOND chord for the same pixel, which only the
|
|
-- can's hollow mouth uses: a ring in plan needs a front wall and a back
|
|
-- wall at the same column, and one interval cannot say that.
|
|
local z0, z1, z2, z3, src, srcX = {}, {}, {}, {}, {}, {}
|
|
local loRow, hiRow = {}, {}
|
|
local yBot = nil
|
|
for iy = 0, NY - 1 do
|
|
local lo, hi = nil, nil
|
|
for ix = 0, NX - 1 do
|
|
if mask[iy * NX + ix] then
|
|
lo = lo or ix
|
|
hi = ix
|
|
end
|
|
end
|
|
if lo then
|
|
loRow[iy], hiRow[iy] = lo, hi
|
|
yBot = iy
|
|
local c = (lo + hi + 1) / 2
|
|
local hw = (hi - lo + 1) / 2
|
|
for ix = lo, hi do
|
|
local i = iy * NX + ix
|
|
if mask[i] then
|
|
local dx = ix + 0.5 - c
|
|
local n = 1
|
|
if hw * hw > dx * dx then
|
|
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
|
|
+ 0.5))
|
|
end
|
|
if spray and iy < spray.rows then n = math.min(n, spray.depth) end
|
|
if squash then n = math.max(1, math.floor(n * squash / 100 + 0.5)) end
|
|
z0[i] = math.floor(N2 - n / 2 + 0.5)
|
|
z1[i] = z0[i] + n
|
|
-- a row the can's body band was repeated into wears the row it
|
|
-- was copied from, never a texel of its own
|
|
src[i] = artRow[iy] or iy
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Spray-gap BACKING: the drawing's own gap pixels, one voxel deep at
|
|
-- the slab's mid-plane. The flat crown is full of floor showing
|
|
-- between leaves; carved as an open slab those gaps became TUNNELS --
|
|
-- the Center couch, the man sitting on it and the void wall all read
|
|
-- as pink/orange/black confetti INSIDE the foliage, and the sparse
|
|
-- bottom rows (lone drawn leaf tips) floated as disconnected specks
|
|
-- against them. The drawing itself backs every gap with its own
|
|
-- pixels, so the hull does the same: each in-span gap below drawn
|
|
-- foliage takes ITS OWN texel as a plate recessed behind the leaf
|
|
-- relief. Coverage is monotone down a column, so the first backed
|
|
-- cell always sits directly under a leaf chord -- and every chord
|
|
-- spans the mid-plane, so no plate ever caps the crown's top: columns
|
|
-- open to the sky stay open and the silhouette keeps its notches.
|
|
if spray then
|
|
for iy = 1, math.min(spray.rows, NY) - 1 do
|
|
if loRow[iy] then
|
|
for ix = loRow[iy], hiRow[iy] do
|
|
local i = iy * NX + ix
|
|
if not z0[i] then
|
|
local covered = false
|
|
for iy2 = 0, iy - 1 do
|
|
if mask[iy2 * NX + ix] then covered = true break end
|
|
end
|
|
if covered then
|
|
z0[i], z1[i], src[i] = N2, N2 + 1, iy
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- foot: rows under the mask repeat the bottom row's discs, wearing the
|
|
-- bottom row's (outline-dark) pixels -- except where a stripped base row
|
|
-- DREW something at that pixel, which keeps its own texel, so a can's
|
|
-- drawn base rim lands on the model's base instead of being painted over
|
|
-- by the body band above it
|
|
for iy = yBot + 1, NY - 1 do
|
|
loRow[iy], hiRow[iy] = loRow[yBot], hiRow[yBot]
|
|
for ix = loRow[yBot], hiRow[yBot] do
|
|
local b = yBot * NX + ix
|
|
if z0[b] then
|
|
local i = iy * NX + ix
|
|
z0[i], z1[i] = z0[b], z1[b]
|
|
src[i] = (baseArt and baseArt[i]) and iy or yBot
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the TAPER: a bin is a truncated cone, not a tube -- wide at the rim,
|
|
-- drawn in a couple of voxels toward the base. The drawing agrees as far
|
|
-- as it can (its own base arc pulls in to 9px from the 11px flanks), but
|
|
-- it cannot state the whole run, so taperVox is the diameter the base
|
|
-- loses and the rows in between interpolate. Every row keeps its plan
|
|
-- ROUND: narrow the span, then re-cut the chords from the narrowed span,
|
|
-- or the model comes out a cylinder with its corners shaved.
|
|
local stepped = {}
|
|
if taperVox and taperVox > 0 then
|
|
local yTopRow = nil
|
|
for iy = 0, NY - 1 do
|
|
if loRow[iy] then yTopRow = iy break end
|
|
end
|
|
local span = NY - 1 - (yTopRow or 0)
|
|
if yTopRow and span > 0 then
|
|
for iy = yTopRow, NY - 1 do
|
|
local inset = math.floor(taperVox / 2 * (iy - yTopRow) / span + 0.5)
|
|
if inset > 0 and loRow[iy] then
|
|
local lo = loRow[iy] + inset
|
|
local hi = hiRow[iy] - inset
|
|
if hi - lo < 1 then
|
|
lo = math.floor((loRow[iy] + hiRow[iy]) / 2)
|
|
hi = lo + 1
|
|
end
|
|
for ix = loRow[iy], hiRow[iy] do
|
|
if ix < lo or ix > hi then
|
|
local i = iy * NX + ix
|
|
z0[i], z1[i], z2[i], z3[i] = nil, nil, nil, nil
|
|
end
|
|
end
|
|
-- squeeze the row's ART into the narrowed span rather than
|
|
-- clipping its ends off: the drawn outline is the last column
|
|
-- either side, and dropping it leaves the taper's new edge
|
|
-- wearing an interior texel -- a white chip down the rim
|
|
for ix = lo, hi do
|
|
srcX[iy * NX + ix] = loRow[iy]
|
|
+ math.floor((ix - lo) * (hiRow[iy] - loRow[iy])
|
|
/ (hi - lo) + 0.5)
|
|
end
|
|
loRow[iy], hiRow[iy] = lo, hi
|
|
stepped[iy] = true
|
|
local c = (lo + hi + 1) / 2
|
|
local hw = (hi - lo + 1) / 2
|
|
for ix = lo, hi do
|
|
local i = iy * NX + ix
|
|
if z0[i] then
|
|
local dx = ix + 0.5 - c
|
|
local n = 1
|
|
if hw * hw > dx * dx then
|
|
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
|
|
+ 0.5))
|
|
end
|
|
if squash then
|
|
n = math.max(1, math.floor(n * squash / 100 + 0.5))
|
|
end
|
|
z0[i] = math.floor(N2 - n / 2 + 0.5)
|
|
z1[i] = z0[i] + n
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the MOUTH: a bin is open, and a solid top wearing the drawn opening
|
|
-- only paints one. Hollow the top wellRows voxel rows -- every chord
|
|
-- long enough to hold two walls plus a gap keeps a wall at each end and
|
|
-- loses its middle, which is a ring in plan, so the model has a real rim
|
|
-- to look into. The short chords at the left and right of the row ARE
|
|
-- the ring's sides and stay solid on their own.
|
|
local wellTop = nil
|
|
if wellRows and wellRows > 0 then
|
|
for iy = 0, NY - 1 do
|
|
if loRow[iy] then wellTop = iy break end
|
|
end
|
|
local wall = 2
|
|
for iy = wellTop or 0, math.min((wellTop or 0) + wellRows - 1, NY - 1) do
|
|
if loRow[iy] then
|
|
for ix = loRow[iy], hiRow[iy] do
|
|
local i = iy * NX + ix
|
|
if z0[i] and z1[i] - z0[i] > wall * 2 then
|
|
z2[i], z3[i] = z1[i] - wall, z1[i]
|
|
z1[i] = z0[i] + wall
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the round cap's top row and z extent, for the stump's ring
|
|
-- projection below
|
|
local capTopRow, capZ0, capZ1 = nil, nil, nil
|
|
if capY0 then
|
|
for iy = 0, NY - 1 do
|
|
if loRow[iy] then capTopRow = iy break end
|
|
end
|
|
if capTopRow then
|
|
for ix = loRow[capTopRow], hiRow[capTopRow] do
|
|
local i = capTopRow * NX + ix
|
|
if z0[i] then
|
|
-- the OUTER extent, so a hollowed row still projects the mouth
|
|
-- across the whole opening and not just its front wall
|
|
local back = z3[i] or z1[i]
|
|
capZ0 = math.min(capZ0 or z0[i], z0[i])
|
|
capZ1 = math.max(capZ1 or back, back)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the art row the mouth projection puts at depth iz -- the drawn
|
|
-- opening's north arc at the far side of the hull, its south arc at the
|
|
-- near one. The top-face pass below reads the same mapping; this is the
|
|
-- vertical faces inside the well asking it the same question.
|
|
local function mouthRow(iz)
|
|
if not (capY0 and capZ0 and capZ1) then return 0 end
|
|
local t = capZ1 - 1 > capZ0 and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0
|
|
t = math.max(0, math.min(1, t))
|
|
return capY0 + math.floor(t * (capY1 - capY0) + 0.5)
|
|
end
|
|
|
|
local function solidAt(ix, iy, iz)
|
|
if ix < 0 or ix > NX - 1 or iy < 0 or iy > NY - 1 then return false end
|
|
local i = iy * NX + ix
|
|
if z0[i] == nil then return false end
|
|
if iz >= z0[i] and iz < z1[i] then return true end
|
|
return z2[i] ~= nil and iz >= z2[i] and iz < z3[i]
|
|
end
|
|
|
|
-- cap interiors sample the canopy a couple of rows below the rim,
|
|
-- skipping outline-dark pixels
|
|
local function deepTexel(ix, iy)
|
|
for iy2 = iy + 2, math.min(NY - 1, iy + 4) do
|
|
local i = iy2 * NX + ix
|
|
if mask[i] and cls[i] ~= "black" then return texel(ix, iy2) end
|
|
end
|
|
return texel(ix, iy)
|
|
end
|
|
|
|
-- side walls read as material, not outline: walk inward past black
|
|
-- pixels (the building extruder's de-outline rule). The silhouette's
|
|
-- edge columns are all outline, and without this every flank of the
|
|
-- ball paints solid black the moment the camera turns. The foot rows
|
|
-- stay dark on purpose: their whole source row is outline-black.
|
|
local function sideTexel(ix, iy)
|
|
-- A foot row's SIDE keeps the last body row's material even where its
|
|
-- FRONT wears a stripped base row (the can). The drawn base rim is
|
|
-- front-face art; walking the de-outline inside a row that is no longer
|
|
-- in the mask breaks at once and hands back the silhouette's own
|
|
-- outline, which painted every flank of the can solid black.
|
|
local r = (yBot and iy > yBot) and yBot or src[iy * NX + ix]
|
|
-- the walk runs in ART columns, so a tapered row starts from the drawn
|
|
-- pixel its squeezed span put here rather than from the model column
|
|
local a = srcX[iy * NX + ix] or ix
|
|
local dir = ix + ix < loRow[iy] + hiRow[iy] and 1 or -1
|
|
for step = 0, 3 do
|
|
local x2 = a + dir * step
|
|
local i2 = r * NX + x2
|
|
if x2 < 0 or x2 > NX - 1 or not mask[i2] then break end
|
|
if cls[i2] ~= "black" then return texel(x2, r) end
|
|
end
|
|
return texel(a, r)
|
|
end
|
|
|
|
local quads = {}
|
|
|
|
for iy = 0, NY - 1 do
|
|
if loRow[iy] then
|
|
local yB, yT = NY - 1 - iy, NY - iy
|
|
|
|
-- front and back: the drawing per-pixel, columns merged where they
|
|
-- share a chord plane; a run never crosses the 8px atlas tile seam
|
|
-- (its u range must interpolate inside one tile)
|
|
local ix = loRow[iy]
|
|
while ix <= hiRow[iy] do
|
|
local i = iy * NX + ix
|
|
if z0[i] then
|
|
local ix2 = ix
|
|
while ix2 + 1 <= hiRow[iy] do
|
|
local j = iy * NX + ix2 + 1
|
|
-- src too: a can's foot row draws part of its span from the
|
|
-- stripped base rim and the rest from the body band above it,
|
|
-- so a run must not straddle two source rows (the u range is
|
|
-- interpolated from one row's texels)
|
|
if z0[j] == z0[i] and z1[j] == z1[i] and src[j] == src[i]
|
|
and z2[j] == z2[i] and z3[j] == z3[i]
|
|
and math.floor((ix2 + 1) / 8) == math.floor(ix / 8) then
|
|
ix2 = ix2 + 1
|
|
else
|
|
break
|
|
end
|
|
end
|
|
local x0, x1 = ix - N2, ix2 - N2 + 1
|
|
-- one facing pair per chord, each face given the art row it
|
|
-- should wear. A hollowed mouth row has two chords, and the two
|
|
-- faces that look into the well take the drawn OPENING (via the
|
|
-- same projection the rim does) rather than the body band: the
|
|
-- drawing paints its mouth dark, and an inside-out white wall
|
|
-- across the opening is the one thing that stops a bin reading
|
|
-- as a bin.
|
|
local function facing(za, zb, rowF, rowB)
|
|
local zF, zB = zb - N2, za - N2
|
|
local function pair(z, row, shade, back)
|
|
local ax0, ay = texel(srcX[i] or ix, row)
|
|
local ax1 = (texel(srcX[iy * NX + ix2] or ix2, row))
|
|
local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW
|
|
local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH
|
|
if back then
|
|
quads[#quads + 1] = {
|
|
{ x1, yB, z }, { x0, yB, z }, { x0, yT, z }, { x1, yT, z },
|
|
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
|
|
shade = shade,
|
|
}
|
|
else
|
|
quads[#quads + 1] = {
|
|
{ x0, yB, z }, { x1, yB, z }, { x1, yT, z }, { x0, yT, z },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = shade,
|
|
}
|
|
end
|
|
end
|
|
pair(zF, rowF, ROUND_SHADE.front, false)
|
|
pair(zB, rowB, ROUND_SHADE.back, true)
|
|
end
|
|
local body = src[i]
|
|
if z2[i] then
|
|
-- z grows toward the viewer: the low chord is the can's FAR
|
|
-- wall, so its +z face is the inside you look across, and the
|
|
-- near chord's -z face is the inside of the wall facing you
|
|
facing(z0[i], z1[i], mouthRow(z1[i]), body)
|
|
facing(z2[i], z3[i], body, mouthRow(z2[i] - 1))
|
|
else
|
|
facing(z0[i], z1[i], body, body)
|
|
end
|
|
ix = ix2 + 1
|
|
else
|
|
ix = ix + 1
|
|
end
|
|
end
|
|
|
|
-- sides, steps, undersides: constant-texel quads over the z runs a
|
|
-- neighbour doesn't cover
|
|
for ix = loRow[iy], hiRow[iy] do
|
|
local i = iy * NX + ix
|
|
if z0[i] then
|
|
local ax, ay = texel(srcX[i] or ix, src[i])
|
|
local u, v = (ax + 0.5) / atlasW, (ay + 0.5) / atlasH
|
|
local x0, x1 = ix - N2, ix - N2 + 1
|
|
|
|
-- exposed z pieces against one neighbouring column, over each of
|
|
-- the pixel's chords (a hollowed mouth row has two)
|
|
local function chordPieces(nx, ny, emit, zLo, zHi)
|
|
local iz = zLo
|
|
while iz < zHi do
|
|
if not solidAt(nx, ny, iz) then
|
|
local iz2 = iz
|
|
while iz2 + 1 < zHi and not solidAt(nx, ny, iz2 + 1) do
|
|
iz2 = iz2 + 1
|
|
end
|
|
emit(iz - N2, iz2 - N2 + 1, iz, iz2)
|
|
iz = iz2 + 1
|
|
else
|
|
iz = iz + 1
|
|
end
|
|
end
|
|
end
|
|
local function pieces(nx, ny, emit)
|
|
chordPieces(nx, ny, emit, z0[i], z1[i])
|
|
if z2[i] then chordPieces(nx, ny, emit, z2[i], z3[i]) end
|
|
end
|
|
|
|
local sax, say = sideTexel(ix, iy)
|
|
local su, sv = (sax + 0.5) / atlasW, (say + 0.5) / atlasH
|
|
pieces(ix - 1, iy, function(zA, zB)
|
|
quads[#quads + 1] = {
|
|
{ x0, yB, zA }, { x0, yB, zB }, { x0, yT, zB }, { x0, yT, zA },
|
|
u = su, v = sv, shade = ROUND_SHADE.side,
|
|
}
|
|
end)
|
|
pieces(ix + 1, iy, function(zA, zB)
|
|
quads[#quads + 1] = {
|
|
{ x1, yB, zB }, { x1, yB, zA }, { x1, yT, zA }, { x1, yT, zB },
|
|
u = su, v = sv, shade = ROUND_SHADE.side,
|
|
}
|
|
end)
|
|
pieces(ix, iy - 1, function(zA, zB, izA, izB)
|
|
local function top(za, zb, tu, tv)
|
|
quads[#quads + 1] = {
|
|
{ x0, yT, za }, { x1, yT, za }, { x1, yT, zb }, { x0, yT, zb },
|
|
u = tu, v = tv, shade = ROUND_SHADE.top,
|
|
}
|
|
end
|
|
-- the whole hollowed band takes the projection, not just its
|
|
-- top row: the rim ring gets the mouth's outer arcs and the
|
|
-- floor of the well gets its middle, so looking in reads as
|
|
-- one opening rather than a lid with a hole punched in it
|
|
if capTopRow and capZ1
|
|
and iy >= capTopRow and iy <= capTopRow + (wellRows or 0) then
|
|
-- the CUT FACE (a capped hull's top): project the drawn
|
|
-- ellipse across the round cap voxel row by voxel row --
|
|
-- its top arc at the cap's north rim, its bottom arc at
|
|
-- the south, the perspective the 2D art already implies
|
|
for iz = izA, izB do
|
|
local t = capZ1 - 1 > capZ0
|
|
and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0
|
|
local ry = capY0 + math.floor(t * (capY1 - capY0) + 0.5)
|
|
local cax, cay = texel(srcX[i] or ix, ry)
|
|
top(iz - N2, iz - N2 + 1,
|
|
(cax + 0.5) / atlasW, (cay + 0.5) / atlasH)
|
|
end
|
|
elseif izA == z0[i] and izB == z1[i] - 1 and izB - izA >= 2 then
|
|
-- the dome cap: outline on the rim cells, canopy inside
|
|
local du, dv = deepTexel(ix, iy)
|
|
top(zA, zA + 1, u, v)
|
|
top(zA + 1, zB - 1, (du + 0.5) / atlasW, (dv + 0.5) / atlasH)
|
|
top(zB - 1, zB, u, v)
|
|
elseif stepped[iy] then
|
|
-- a taper STEP: the chord narrowing leaves a ring facing up
|
|
-- at the front of the can, and wearing the lit body band it
|
|
-- reads as a bright chip taken out of the wall. The drawing's
|
|
-- own rim column is black, so the step wears that and the
|
|
-- taper reads as a hoop line -- which is how the reference
|
|
-- object is banded anyway.
|
|
local rx = srcX[iy * NX + loRow[iy]] or loRow[iy]
|
|
local rax, ray = texel(rx, src[i])
|
|
top(zA, zB, (rax + 0.5) / atlasW, (ray + 0.5) / atlasH)
|
|
else
|
|
top(zA, zB, u, v)
|
|
end
|
|
end)
|
|
if iy < NY - 1 then
|
|
pieces(ix, iy + 1, function(zA, zB)
|
|
quads[#quads + 1] = {
|
|
{ x0, yB, zB }, { x1, yB, zB }, { x1, yB, zA }, { x0, yB, zA },
|
|
u = u, v = v, shade = ROUND_SHADE.bottom,
|
|
}
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return quads, bg
|
|
end
|
|
|
|
-- Hull templates dedupe GLOBALLY per (tileset, four tiles, ground set):
|
|
-- the same four-tile tree repeats for hundreds of cells on a map and
|
|
-- across every route of its tileset, so the carve runs once per distinct
|
|
-- drawing per session. What a map keeps is a STAMP LIST -- (template,
|
|
-- cell offset) pairs the mesher expands while packing vertices -- rather
|
|
-- than materialized per-cell quad tables, which retained ~500 quads x
|
|
-- hundreds of tree cells x six Lua tables each PER MAP (the multi-GB
|
|
-- heap growth on a cross-region trek).
|
|
local roundCache = {}
|
|
|
|
function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
|
|
local data = pixels(map.tileset)
|
|
local tw, th = map.def.width * 4, map.def.height * 4
|
|
|
|
-- ground-set fingerprint: the template's art-matched floor depends on
|
|
-- which ground tiles this map places, so maps sharing a tileset but
|
|
-- not a palette of floors carve separately
|
|
local gsig
|
|
do
|
|
local g = {}
|
|
for i, t in ipairs(groundTiles or {}) do g[i] = t end
|
|
table.sort(g)
|
|
gsig = table.concat(g, ",")
|
|
end
|
|
local tsid = tostring(map.tileset.id or map.tileset.image or "?")
|
|
|
|
-- the stump class's drawn-ellipse height, hand-authored per tileset
|
|
-- (the profile's stump_cap, in art rows), and the can class's three: the
|
|
-- mouth ellipse over the top (can_cap) and the base ellipse under the
|
|
-- bottom (can_base), both in art rows, plus the authored can_height in
|
|
-- voxels the body band is repeated up to
|
|
local stumpCap, canCap, canBase, canHeight, canWell, canTaper
|
|
= 6, 9, 4, 9, 5, 4
|
|
-- the sapling class's depth, as a PERCENT of the revolved chord
|
|
local saplingSquash = 50
|
|
do
|
|
local okP, prof = pcall(V.data, "voxel_heights")
|
|
local entry = okP and type(prof) == "table" and prof.tilesets
|
|
and prof.tilesets[map.tileset.id]
|
|
if entry and type(entry.stump_cap) == "number" then
|
|
stumpCap = entry.stump_cap
|
|
end
|
|
if entry and type(entry.can_cap) == "number" then
|
|
canCap = entry.can_cap
|
|
end
|
|
if entry and type(entry.can_base) == "number" then
|
|
canBase = entry.can_base
|
|
end
|
|
if entry and type(entry.can_height) == "number" then
|
|
canHeight = entry.can_height
|
|
end
|
|
if entry and type(entry.can_well) == "number" then
|
|
canWell = entry.can_well
|
|
end
|
|
if entry and type(entry.can_taper) == "number" then
|
|
canTaper = entry.can_taper
|
|
end
|
|
if entry and type(entry.sapling_squash) == "number" then
|
|
saplingSquash = entry.sapling_squash
|
|
end
|
|
end
|
|
|
|
-- cells consumed by a 2x2 `canopy` group; the scan runs north to
|
|
-- south, west to east, so an anchor always claims its partners
|
|
-- before they are visited
|
|
local grouped = {}
|
|
for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
|
|
for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
|
|
Budget.tick()
|
|
local ckey = cy * 8192 + cx
|
|
local k = keyOf(cx * 2, cy * 2)
|
|
local s = (not grouped[ckey]) and S.shapeAt[k] or nil
|
|
local near = cx * 2 >= -ROUND_RING and cx * 2 < tw + ROUND_RING
|
|
and cy * 2 >= -ROUND_RING and cy * 2 < th + ROUND_RING
|
|
if s and s.art == "canopy" and near then
|
|
-- ONE 32px hull over the 2x2-cell drawing. The partner cells
|
|
-- must be round-pinned too, or the drawing is partial (a map
|
|
-- edit, a mod's stray anchor tile) and the anchor is left
|
|
-- alone rather than carved into a half-empty giant.
|
|
local whole = true
|
|
for _, d in ipairs({ { 1, 0 }, { 0, 1 }, { 1, 1 } }) do
|
|
local ps = S.shapeAt[keyOf((cx + d[1]) * 2, (cy + d[2]) * 2)]
|
|
if not (ps and (ps.art == "cylinder" or ps.art == "canopy")) then
|
|
whole = false
|
|
end
|
|
end
|
|
if whole then
|
|
local ground = false
|
|
if data then
|
|
local ids = {}
|
|
for dy = 0, 3 do
|
|
for dx = 0, 3 do
|
|
ids[#ids + 1] = S.tileAt[keyOf(cx * 2 + dx, cy * 2 + dy)]
|
|
end
|
|
end
|
|
local sig = tsid .. "|g32|" .. gsig .. "|"
|
|
.. table.concat(ids, ":")
|
|
local tpl = roundCache[sig]
|
|
if not tpl then
|
|
local tq, tbg = roundTemplate(S, map, data, cx, cy,
|
|
groundTiles, 32)
|
|
tpl = { quads = tq, bg = tbg }
|
|
roundCache[sig] = tpl
|
|
end
|
|
ground = tpl.bg or false
|
|
S.roundStamps[#S.roundStamps + 1] =
|
|
{ quads = tpl.quads, mx = cx * 16 + 16, mz = cy * 16 + 16,
|
|
r = 16 }
|
|
end
|
|
for dy = 0, 3 do
|
|
for dx = 0, 3 do
|
|
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
|
S.skip[tk] = true
|
|
S.ground[tk] = ground
|
|
end
|
|
end
|
|
grouped[ckey + 1] = true
|
|
grouped[ckey + 8192] = true
|
|
grouped[ckey + 8193] = true
|
|
end
|
|
elseif s and s.art == "planter" and near then
|
|
-- ONE 16x32x16 hull over a drawing stacked TWO CELLS HIGH on one
|
|
-- cell of plot: the Pokemon Centers' potted plants (a leaf crown
|
|
-- over a flared pot, 78 placements across 13 maps).
|
|
--
|
|
-- The anchor is the NORTH cell -- the crown, where the canvas
|
|
-- starts -- but the hull stands in the SOUTH cell, because that is
|
|
-- where the pot is drawn and an object's ground contact is its
|
|
-- plot. The crown is therefore HEIGHT, not depth: the north cell
|
|
-- is claimed and left as floor for the crown to overhang, which is
|
|
-- what un-projecting the 3/4 view means here. Pinning only one of
|
|
-- the two cells leaves the drawing partial (a map edit, a mod's
|
|
-- stray tile), so the anchor is left alone rather than carved into
|
|
-- half a plant.
|
|
local below = S.shapeAt[keyOf(cx * 2, (cy + 1) * 2)]
|
|
if below and below.art == "planter" then
|
|
local ground = false
|
|
if data then
|
|
local ids = {}
|
|
for dy = 0, 3 do
|
|
for dx = 0, 1 do
|
|
ids[#ids + 1] = S.tileAt[keyOf(cx * 2 + dx, cy * 2 + dy)]
|
|
end
|
|
end
|
|
local sig = tsid .. "|p32|" .. gsig .. "|"
|
|
.. table.concat(ids, ":")
|
|
local tpl = roundCache[sig]
|
|
if not tpl then
|
|
local tq, tbg = roundTemplate(S, map, data, cx, cy,
|
|
groundTiles, 16, nil, 32,
|
|
PLANTER_SPRAY)
|
|
tpl = { quads = tq, bg = tbg }
|
|
roundCache[sig] = tpl
|
|
end
|
|
ground = tpl.bg or false
|
|
S.roundStamps[#S.roundStamps + 1] =
|
|
{ quads = tpl.quads, mx = cx * 16 + 8,
|
|
mz = (cy + 1) * 16 + 8 }
|
|
end
|
|
for dy = 0, 3 do
|
|
for dx = 0, 1 do
|
|
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
|
S.skip[tk] = true
|
|
S.ground[tk] = ground
|
|
end
|
|
end
|
|
grouped[ckey + 8192] = true
|
|
end
|
|
elseif s and s.art == "cylinder" and near then
|
|
-- a `stump`-class cell is the same hull with a cut face: its
|
|
-- top capRows of drawing project onto the round top. A `can`-class
|
|
-- cell is that hull cut at BOTH ends -- lid on top, base circle on
|
|
-- the floor -- which is what a drum standing on a floor is.
|
|
local cap = (s.class == "stump" and stumpCap)
|
|
or (s.class == "can" and canCap) or nil
|
|
local base = s.class == "can" and canBase or nil
|
|
local tall = s.class == "can" and canHeight or nil
|
|
local well = s.class == "can" and canWell or nil
|
|
local taper = s.class == "can" and canTaper or nil
|
|
-- 100% is the full revolve, so it is the identity: never signed
|
|
-- into the cache key, and never passed, by a class that has no
|
|
-- squash of its own
|
|
local squash = (s.class == "sapling" and saplingSquash ~= 100)
|
|
and saplingSquash or nil
|
|
local ground = false
|
|
if data then
|
|
local sig = tsid .. (cap and ("|c" .. cap) or "")
|
|
.. (base and ("|b" .. base) or "")
|
|
.. (tall and ("|h" .. tall) or "")
|
|
.. (well and ("|w" .. well) or "")
|
|
.. (taper and ("|t" .. taper) or "")
|
|
.. (squash and ("|q" .. squash) or "") .. "|"
|
|
.. gsig .. "|" .. table.concat({
|
|
S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)],
|
|
S.tileAt[keyOf(cx * 2, cy * 2 + 1)],
|
|
S.tileAt[keyOf(cx * 2 + 1, cy * 2 + 1)] }, ":")
|
|
local tpl = roundCache[sig]
|
|
if not tpl then
|
|
local tq, tbg = roundTemplate(S, map, data, cx, cy,
|
|
groundTiles, 16, cap, nil, nil,
|
|
base, tall, well, taper, squash)
|
|
tpl = { quads = tq, bg = tbg }
|
|
roundCache[sig] = tpl
|
|
end
|
|
ground = tpl.bg or false
|
|
S.roundStamps[#S.roundStamps + 1] =
|
|
{ quads = tpl.quads, mx = cx * 16 + 8, mz = cy * 16 + 8 }
|
|
end
|
|
-- headless (no pixels): no hull, but still claim the tiles so
|
|
-- the volume path never boxes a pinned cell. Ground is the
|
|
-- template's own art-matched tile; `false` (no match, headless)
|
|
-- falls to the commonest-ground pass below.
|
|
for dy = 0, 1 do
|
|
for dx = 0, 1 do
|
|
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
|
S.skip[tk] = true
|
|
S.ground[tk] = ground
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- relief props: top-down drawings lying on their surface ----
|
|
|
|
-- A cell pinned `relief` is a prop DRAWN FROM ABOVE (a game console on
|
|
-- the floor): standing it up would be wrong, and a solid box would carry
|
|
-- the drawn floor around it. The drawing is segmented like any forced
|
|
-- prop (black outline; the shades touching the cluster's edge are the
|
|
-- background) and the object pixels extrude straight up a few voxels,
|
|
-- art on the top face -- a piece of the drawing pushed out of the
|
|
-- ground. The floor the flood removed is repainted by the claimed
|
|
-- tiles' common-ground fill.
|
|
local RELIEF_SHADE = { top = 1.0, south = 0.9, north = 0.62, side = 0.75 }
|
|
|
|
function Structures.buildRelief(S, map, region, data, perRow, h)
|
|
local atlasW = map.tileset.imageWidth or 128
|
|
local atlasH = map.tileset.imageHeight or 48
|
|
local bw = (region.maxX - region.minX + 1) * 8
|
|
local bh = (region.maxY - region.minY + 1) * 8
|
|
local member = {}
|
|
for _, c in ipairs(region.tiles) do member[keyOf(c[1], c[2])] = true end
|
|
|
|
local cls, srcU, srcV = {}, {}, {}
|
|
for py = 0, bh - 1 do
|
|
for px = 0, bw - 1 do
|
|
local i = py * bw + px
|
|
local k = keyOf(region.minX + math.floor(px / 8),
|
|
region.minY + math.floor(py / 8))
|
|
if member[k] then
|
|
local tile = S.tileAt[k]
|
|
local ax = (tile % perRow) * 8 + px % 8
|
|
local ay = math.floor(tile / perRow) * 8 + py % 8
|
|
srcU[i], srcV[i] = ax, ay
|
|
local r, g, b, a = data:getPixel(ax, ay)
|
|
cls[i] = a == 0 and "off" or Structures.shadeClass(math.min(r, g, b))
|
|
end
|
|
end
|
|
end
|
|
|
|
local bg = {}
|
|
for py = 0, bh - 1 do
|
|
for px = 0, bw - 1 do
|
|
if px == 0 or px == bw - 1 or py == 0 or py == bh - 1 then
|
|
local c = cls[py * bw + px]
|
|
if c and c ~= "black" and c ~= "off" then bg[c] = true end
|
|
end
|
|
end
|
|
end
|
|
|
|
local flooded, queue = {}, {}
|
|
local function seed(i)
|
|
local c = cls[i]
|
|
if c and c ~= "black" and (c == "off" or bg[c]) and not flooded[i] then
|
|
flooded[i] = true
|
|
queue[#queue + 1] = i
|
|
end
|
|
end
|
|
for px = 0, bw - 1 do
|
|
seed(px)
|
|
seed((bh - 1) * bw + px)
|
|
end
|
|
for py = 0, bh - 1 do
|
|
seed(py * bw)
|
|
seed(py * bw + bw - 1)
|
|
end
|
|
while #queue > 0 do
|
|
local i = table.remove(queue)
|
|
local px, py = i % bw, math.floor(i / bw)
|
|
for _, d in ipairs(DIRS4) do
|
|
local nx, ny = px + d[1], py + d[2]
|
|
if nx >= 0 and nx < bw and ny >= 0 and ny < bh then
|
|
seed(ny * bw + nx)
|
|
end
|
|
end
|
|
end
|
|
|
|
local function on(px, py)
|
|
if px < 0 or px >= bw or py < 0 or py >= bh then return false end
|
|
local i = py * bw + px
|
|
return cls[i] ~= nil and cls[i] ~= "off" and not flooded[i]
|
|
end
|
|
|
|
local quads = S.objectQuads
|
|
local wx0, wz0 = region.minX * 8, region.minY * 8
|
|
for py = 0, bh - 1 do
|
|
for px = 0, bw - 1 do
|
|
if on(px, py) then
|
|
local i = py * bw + px
|
|
local u = (srcU[i] + 0.5) / atlasW
|
|
local v = (srcV[i] + 0.5) / atlasH
|
|
local x, z = wx0 + px, wz0 + py
|
|
local function quad(c1, c2, c3, c4, shade)
|
|
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
|
|
end
|
|
quad({ x, h, z }, { x + 1, h, z }, { x + 1, h, z + 1 },
|
|
{ x, h, z + 1 }, RELIEF_SHADE.top)
|
|
if not on(px, py + 1) then
|
|
quad({ x, 0, z + 1 }, { x + 1, 0, z + 1 }, { x + 1, h, z + 1 },
|
|
{ x, h, z + 1 }, RELIEF_SHADE.south)
|
|
end
|
|
if not on(px, py - 1) then
|
|
quad({ x + 1, 0, z }, { x, 0, z }, { x, h, z },
|
|
{ x + 1, h, z }, RELIEF_SHADE.north)
|
|
end
|
|
if not on(px - 1, py) then
|
|
quad({ x, 0, z }, { x, 0, z + 1 }, { x, h, z + 1 },
|
|
{ x, h, z }, RELIEF_SHADE.side)
|
|
end
|
|
if not on(px + 1, py) then
|
|
quad({ x + 1, 0, z + 1 }, { x + 1, 0, z }, { x + 1, h, z },
|
|
{ x + 1, h, z + 1 }, RELIEF_SHADE.side)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- bookcases: free-standing shelves collapsed to true depth ----
|
|
|
|
-- A drawn bookcase is TALL, not deep: the graphic spans two cell rows
|
|
-- because the shelf is 32px high, while the object stands one cell
|
|
-- (16px) deep. Columns of tiles pinned `bookcase` collapse in ranks
|
|
-- (at most four drawn rows each, measured from the south): every rank
|
|
-- raises one box over its front two tile rows -- the drawing folded up
|
|
-- its south face band by band -- and its back rows become hidden floor.
|
|
-- When the row just above a rank is undetected structure (a shared trim
|
|
-- tile the profile cannot pin), the rank adopts it as its CAP: one more
|
|
-- band of height and the art its top face wears.
|
|
local BOOK_SHADE = { south = 1.0, north = 0.68, flank = 0.8, top = 0.85,
|
|
-- a pane's reveal: the one-voxel side of the frame
|
|
-- standing proud of it. The sill catches the light
|
|
-- the top face does; the lintel is in shadow.
|
|
sill = 0.85, lintel = 0.5 }
|
|
|
|
-- A pane is a shelf opening, a glass door or an inset panel: a non-black
|
|
-- region the drawing SEALS OFF behind its own black frame. Anything
|
|
-- wider or taller than this is a band of the front itself -- a trim
|
|
-- course, a plinth -- and stays flush. The same number and the same
|
|
-- rule lib/Buildings.lua measures a facade's panes with, so a shelf the
|
|
-- band pipeline models and a shelf this class collapses carry the same
|
|
-- relief.
|
|
local BOOK_RECESS_MAX = 24
|
|
|
|
-- The panes of a BANK of ranks -- every rank of the same height standing
|
|
-- side by side -- as a mask over the bank's south face, plus the atlas
|
|
-- pixel each face texel comes from. Measured over the whole bank rather
|
|
-- than per column, because a door panel drawn across two tiles is one
|
|
-- region and not two halves, and because the size test that keeps a
|
|
-- broad course flush has to see the course's real width.
|
|
--
|
|
-- `fx` runs across the bank and `fy` DOWN from its top, so the grid
|
|
-- reads like the drawing: the rank folds its tiles up band by band, the
|
|
-- southmost row lowest, and fy = 0 is the topmost drawn row.
|
|
local function bookcasePanes(map, data, perRow, run, i, j)
|
|
if not data then return nil end
|
|
local bands = run[i].bands
|
|
local size = run[i].front - run[i].top + 1
|
|
local W, H = (j - i + 1) * 8, bands * 8
|
|
local light, srcU, srcV = {}, {}, {}
|
|
for fy = 0, H - 1 do
|
|
local band = bands - 1 - math.floor(fy / 8)
|
|
local row = fy % 8
|
|
for fx = 0, W - 1 do
|
|
local col = run[i + math.floor(fx / 8)]
|
|
local tile = band < size and map:tileAt(col.tx, col.front - band)
|
|
or col.cap
|
|
if tile then
|
|
local k = fy * W + fx
|
|
local ax = (tile % perRow) * 8 + fx % 8
|
|
local ay = math.floor(tile / perRow) * 8 + row
|
|
srcU[k], srcV[k] = ax, ay
|
|
local r, g, b, a = data:getPixel(ax, ay)
|
|
light[k] = a ~= 0
|
|
and Structures.shadeClass(math.min(r, g, b)) ~= "black"
|
|
end
|
|
end
|
|
end
|
|
|
|
-- The drawing's non-black regions, split across its black frames. A
|
|
-- region that reaches the face's own border is not sealed by anything
|
|
-- -- it is a course of the front running edge to edge, the way a
|
|
-- masonry band or a wall of siding does -- and it stays flush. That
|
|
-- test is what keeps this rule to shelves: `bookcase` also collapses
|
|
-- the League's gate walls and the terraces, and their courses run off
|
|
-- the drawing, so nothing there sinks.
|
|
local pane, seen = {}, {}
|
|
for k0 = 0, W * H - 1 do
|
|
if light[k0] and not seen[k0] then
|
|
local cells, stack = {}, { k0 }
|
|
seen[k0] = true
|
|
local ax0, ax1 = k0 % W, k0 % W
|
|
local ay0, ay1 = math.floor(k0 / W), math.floor(k0 / W)
|
|
local edge = false
|
|
while #stack > 0 do
|
|
local k = table.remove(stack)
|
|
cells[#cells + 1] = k
|
|
local cx, cy = k % W, math.floor(k / W)
|
|
if cx < ax0 then ax0 = cx end
|
|
if cx > ax1 then ax1 = cx end
|
|
if cy < ay0 then ay0 = cy end
|
|
if cy > ay1 then ay1 = cy end
|
|
if cx == 0 or cx == W - 1 or cy == 0 or cy == H - 1 then
|
|
edge = true
|
|
end
|
|
for _, d in ipairs(DIRS4) do
|
|
local nx, ny = cx + d[1], cy + d[2]
|
|
if nx >= 0 and nx < W and ny >= 0 and ny < H then
|
|
local nk = ny * W + nx
|
|
if light[nk] and not seen[nk] then
|
|
seen[nk] = true
|
|
stack[#stack + 1] = nk
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if not edge and ax1 - ax0 < BOOK_RECESS_MAX
|
|
and ay1 - ay0 < BOOK_RECESS_MAX then
|
|
for _, k in ipairs(cells) do pane[k] = true end
|
|
end
|
|
end
|
|
end
|
|
return pane, srcU, srcV, W, H
|
|
end
|
|
|
|
local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV,
|
|
bankW, bankH)
|
|
local r = run[k]
|
|
local tx, northTy, frontTy, capTile = r.tx, r.top, r.front, r.cap
|
|
local quads = S.objectQuads
|
|
local atlasW = map.tileset.imageWidth or 128
|
|
local atlasH = map.tileset.imageHeight or 48
|
|
local function uvRect(tile)
|
|
local ax = (tile % perRow) * 8
|
|
local ay = math.floor(tile / perRow) * 8
|
|
return (ax + 0.5) / atlasW, (ax + 7.5) / atlasW,
|
|
(ay + 0.5) / atlasH, (ay + 7.5) / atlasH
|
|
end
|
|
|
|
local size = frontTy - northTy + 1
|
|
local bands = r.bands
|
|
local h = bands * 8
|
|
local depth = math.min(2, size) * 8
|
|
local x0, x1 = tx * 8, tx * 8 + 8
|
|
local z1 = frontTy * 8 + 8
|
|
local z0 = z1 - depth
|
|
local fx0 = (k - i) * 8 -- this rank's columns within the bank
|
|
|
|
-- does the neighbouring column continue this shelf? (flanks only cap
|
|
-- the ends of a run of bookcases standing side by side)
|
|
local function joined(nx)
|
|
local ns = S.shapeAt[keyOf(nx, frontTy)]
|
|
return ns ~= nil and ns.art == "bookcase"
|
|
end
|
|
|
|
local function sunk(fx, fy)
|
|
if not pane or fx < 0 or fx >= bankW or fy < 0 or fy >= bankH then
|
|
return false
|
|
end
|
|
return pane[fy * bankW + fx] == true
|
|
end
|
|
|
|
for band = 0, bands - 1 do
|
|
local tile = band < size and map:tileAt(tx, frontTy - band) or capTile
|
|
local u0, u1, v0, v1 = uvRect(tile)
|
|
local y0, y1 = band * 8, band * 8 + 8
|
|
local fyTop = (bands - 1 - band) * 8
|
|
|
|
-- The south face: the drawing folded upright. A band with no pane
|
|
-- in it is the single quad it has always been; a band that seals
|
|
-- one splits into per-row runs of texels, and the pane's run sinks
|
|
-- a voxel behind the frame that stays proud around it.
|
|
local relief = false
|
|
if pane then
|
|
for row = 0, 7 do
|
|
for c = 0, 7 do
|
|
if sunk(fx0 + c, fyTop + row) then relief = true break end
|
|
end
|
|
if relief then break end
|
|
end
|
|
end
|
|
if not relief then
|
|
quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 },
|
|
{ x1, y1, z1 }, { x0, y1, z1 },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = BOOK_SHADE.south }
|
|
else
|
|
local ax = (tile % perRow) * 8
|
|
local ay = math.floor(tile / perRow) * 8
|
|
for row = 0, 7 do
|
|
local fy = fyTop + row
|
|
local wy = y0 + 7 - row -- the drawing's row 0 is the top
|
|
local c = 0
|
|
while c < 8 do
|
|
local s = sunk(fx0 + c, fy)
|
|
local n = 1
|
|
while c + n < 8 and sunk(fx0 + c + n, fy) == s do n = n + 1 end
|
|
local pz = s and z1 - 1 or z1
|
|
local qu0 = (ax + c + 0.05) / atlasW
|
|
local qu1 = (ax + c + n - 0.05) / atlasW
|
|
local qv0 = (ay + row + 0.05) / atlasH
|
|
local qv1 = (ay + row + 1 - 0.05) / atlasH
|
|
quads[#quads + 1] = { { x0 + c, wy, pz }, { x0 + c + n, wy, pz },
|
|
{ x0 + c + n, wy + 1, pz }, { x0 + c, wy + 1, pz },
|
|
uv = { { qu0, qv1 }, { qu1, qv1 }, { qu1, qv0 }, { qu0, qv0 } },
|
|
shade = BOOK_SHADE.south }
|
|
c = c + n
|
|
end
|
|
end
|
|
-- the reveals: where a sunk texel meets a proud one, the frame's
|
|
-- own one-voxel side shows. It wears the PROUD neighbour's texel,
|
|
-- because that is the block it belongs to. A pane running off the
|
|
-- bank, or off the top or bottom of the rank, needs none: the
|
|
-- flank and top faces already close it.
|
|
for row = 0, 7 do
|
|
local fy = fyTop + row
|
|
local wy = y0 + 7 - row
|
|
for c = 0, 7 do
|
|
if sunk(fx0 + c, fy) then
|
|
local X = x0 + c
|
|
local function reveal(nfx, nfy, verts, shade)
|
|
if nfx < 0 or nfx >= bankW or nfy < 0 or nfy >= bankH then
|
|
return
|
|
end
|
|
if sunk(nfx, nfy) then return end
|
|
local nk = nfy * bankW + nfx
|
|
if not srcU[nk] then return end
|
|
quads[#quads + 1] = { verts[1], verts[2], verts[3], verts[4],
|
|
u = (srcU[nk] + 0.5) / atlasW, v = (srcV[nk] + 0.5) / atlasH,
|
|
shade = shade }
|
|
end
|
|
reveal(fx0 + c - 1, fy, {
|
|
{ X, wy, z1 }, { X, wy, z1 - 1 },
|
|
{ X, wy + 1, z1 - 1 }, { X, wy + 1, z1 } }, BOOK_SHADE.flank)
|
|
reveal(fx0 + c + 1, fy, {
|
|
{ X + 1, wy, z1 - 1 }, { X + 1, wy, z1 },
|
|
{ X + 1, wy + 1, z1 }, { X + 1, wy + 1, z1 - 1 } },
|
|
BOOK_SHADE.flank)
|
|
reveal(fx0 + c, fy + 1, {
|
|
{ X, wy, z1 - 1 }, { X + 1, wy, z1 - 1 },
|
|
{ X + 1, wy, z1 }, { X, wy, z1 } }, BOOK_SHADE.sill)
|
|
reveal(fx0 + c, fy - 1, {
|
|
{ X, wy + 1, z1 }, { X + 1, wy + 1, z1 },
|
|
{ X + 1, wy + 1, z1 - 1 }, { X, wy + 1, z1 - 1 } },
|
|
BOOK_SHADE.lintel)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
quads[#quads + 1] = { { x1, y0, z0 }, { x0, y0, z0 },
|
|
{ x0, y1, z0 }, { x1, y1, z0 },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = BOOK_SHADE.north }
|
|
if not joined(tx - 1) then
|
|
quads[#quads + 1] = { { x0, y0, z0 }, { x0, y0, z1 },
|
|
{ x0, y1, z1 }, { x0, y1, z0 },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = BOOK_SHADE.flank }
|
|
end
|
|
if not joined(tx + 1) then
|
|
quads[#quads + 1] = { { x1, y0, z1 }, { x1, y0, z0 },
|
|
{ x1, y1, z0 }, { x1, y1, z1 },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = BOOK_SHADE.flank }
|
|
end
|
|
end
|
|
|
|
local topTile = capTile or map:tileAt(tx, northTy)
|
|
local u0, u1, v0, v1 = uvRect(topTile)
|
|
for seg = 0, depth / 8 - 1 do
|
|
local sz0 = z0 + seg * 8
|
|
quads[#quads + 1] = { { x0, h, sz0 }, { x1, h, sz0 },
|
|
{ x1, h, sz0 + 8 }, { x0, h, sz0 + 8 },
|
|
uv = { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
|
shade = BOOK_SHADE.top }
|
|
end
|
|
end
|
|
|
|
-- The arts a `bookcase_backfill = "above"` row may inherit: terrain and
|
|
-- solid bodies only (see the note at the backfill itself). Everything
|
|
-- absent here -- billboard, post, cylinder, grass, flower -- is a per-pixel
|
|
-- object STANDING on terrain rather than terrain.
|
|
local BACKFILL_ART = { flat = true, top = true, upright = true }
|
|
|
|
function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow)
|
|
perRow = perRow or map.tileset.tilesPerRow or 16
|
|
-- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill).
|
|
-- Read once: it is a property of the tileset, not of the column.
|
|
local backfill = TileShape.bookcaseBackfill(map.tileset.id)
|
|
-- the front's measured relief: on for a shelf, off for the tilesets
|
|
-- that borrow the collapse for masonry or machinery
|
|
if not TileShape.bookcaseRelief(map.tileset.id) then data = nil end
|
|
-- Ranks are collected here and emitted after the sweep: a rank's panes
|
|
-- are measured over the whole BANK it stands in (see bookcasePanes),
|
|
-- and the bank is only known once every column has been read. Nothing
|
|
-- below this loop mutates what the sweep reads, so deferring is free.
|
|
local order, banks = {}, {}
|
|
for tx = x0, x1 do
|
|
local ty = y1
|
|
while ty >= y0 do
|
|
local s = S.shapeAt[keyOf(tx, ty)]
|
|
if s and s.art == "bookcase" then
|
|
-- the contiguous pinned run above this front row
|
|
local north = ty
|
|
while north > y0 do
|
|
local ns = S.shapeAt[keyOf(tx, north - 1)]
|
|
if ns and ns.art == "bookcase" then north = north - 1 else break end
|
|
end
|
|
-- ranks of at most four drawn rows, southmost first
|
|
local front = ty
|
|
while front >= north do
|
|
local top = math.max(north, front - 3)
|
|
-- adopt the trim row just above as the cap: either undetected
|
|
-- structure the profile could not pin, or a row pinned `table`
|
|
-- because the same trim tiles cap other furniture too
|
|
local capTile = nil
|
|
if top == north then
|
|
local ck = keyOf(tx, north - 1)
|
|
local cs = S.shapeAt[ck]
|
|
if cs and not cs.flat and not S.skip[ck] and not S.runs[ck]
|
|
and (not cs.authored or cs.class == "table") then
|
|
capTile = S.tileAt[ck]
|
|
end
|
|
end
|
|
-- The box is one cell deep, so it covers only the run's southmost
|
|
-- rows; everything north of that is vacated. By default a vacated
|
|
-- row is skipped and painted with synthesized ground -- right for a
|
|
-- shelf standing in a room. `bookcase_backfill = "above"` hands it
|
|
-- the cell above the run instead, shape and art, so a wall cut into
|
|
-- a terrace has more terrace behind it rather than a trench.
|
|
--
|
|
-- Only BODY above backfills: a vacated row wants more of the
|
|
-- terrace the wall is cut into, and the terrace is whatever lies
|
|
-- flat, tops out or stands as a solid face. A per-pixel STANDEE
|
|
-- above -- a statue, a sign, a bush -- is an object standing ON
|
|
-- that terrace, and copying it northward builds a second and a
|
|
-- third of it: Indigo Plateau's avenue statues sit directly on
|
|
-- the pilasters that collapse here, so every bird came out
|
|
-- duplicated twice down the shaft behind itself. A standee
|
|
-- above means the row has no terrace to inherit, so it takes the
|
|
-- default and is painted with synthesized ground.
|
|
local covered = math.min(2, front - top + 1)
|
|
local srcK = keyOf(tx, top - 1)
|
|
local src = backfill == "above" and S.shapeAt[srcK] or nil
|
|
if src and not BACKFILL_ART[src.art] then src = nil end
|
|
-- Where the box ACTUALLY ends up, remembered for every row of the
|
|
-- rank: the collapse walks the whole drawn run onto its southmost
|
|
-- cell, so anything that has to stand ON the box has to be told
|
|
-- where the box went. A statue keys off the cell below its own
|
|
-- drawing, which is the run's NORTH end -- two rows away from the
|
|
-- box on a two-cell pilaster, which is exactly the distance the
|
|
-- Plateau's birds floated by.
|
|
local boxTop = front - covered + 1
|
|
for cy = top, front do
|
|
local tk = keyOf(tx, cy)
|
|
S.bookcaseBox[tk] = boxTop
|
|
if src and cy <= front - covered then
|
|
S.shapeAt[tk] = src
|
|
S.tileAt[tk] = S.tileAt[srcK]
|
|
else
|
|
S.skip[tk] = true
|
|
S.ground[tk] = false
|
|
end
|
|
end
|
|
-- ranks of the same height standing side by side are one bank
|
|
local bands = (front - top + 1) + (capTile and 1 or 0)
|
|
local key = top .. ":" .. front .. ":" .. bands
|
|
local bank = banks[key]
|
|
if not bank then
|
|
bank = {}
|
|
banks[key] = bank
|
|
order[#order + 1] = key
|
|
end
|
|
bank[#bank + 1] = { tx = tx, top = top, front = front,
|
|
cap = capTile, bands = bands }
|
|
front = top - 1
|
|
end
|
|
ty = north - 1
|
|
else
|
|
ty = ty - 1
|
|
end
|
|
end
|
|
end
|
|
|
|
-- tx ascends in the sweep above, so each bank's columns are already in
|
|
-- order; split them into the contiguous runs that actually touch
|
|
for _, key in ipairs(order) do
|
|
local run = banks[key]
|
|
local i = 1
|
|
while i <= #run do
|
|
local j = i
|
|
while j < #run and run[j + 1].tx == run[j].tx + 1 do j = j + 1 end
|
|
local pane, srcU, srcV, bankW, bankH =
|
|
bookcasePanes(map, data, perRow, run, i, j)
|
|
for k = i, j do
|
|
bookcaseRank(S, map, perRow, run, i, j, k,
|
|
pane, srcU, srcV, bankW, bankH)
|
|
end
|
|
i = j + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- stairs: pinned cells that render as real steps ----
|
|
|
|
-- A cell the profile pins stair_e / stair_w (art "stair") becomes a
|
|
-- flight of STAIR_STEPS boxes rising evenly across the cell toward the
|
|
-- named side, each the full cell deep. stair_down_e / stair_down_w is
|
|
-- the same flight EXCAVATED: the cell opens into a stairwell and the
|
|
-- steps descend below floor level toward the named side -- the shape a
|
|
-- staircase leading down a floor actually has. The 2D staircase is
|
|
-- drawn from the side, so vertical faces (step fronts and stairwell
|
|
-- walls) wear the matching slice of that drawing -- the railing's
|
|
-- diagonal lands along the stepped silhouette -- while treads sample the
|
|
-- art band drawn at their own height.
|
|
--
|
|
-- stair_n / stair_down_n are the same pair of flights running INTO the
|
|
-- map rather than across it, for a staircase drawn head-on; that changes
|
|
-- the art reading enough to need its own branch below.
|
|
local STAIR_STEPS = 4
|
|
|
|
local STAIR_SHADE = { south = 1.0, north = 0.68, tread = 1.0,
|
|
riser = 0.82, cap = 0.78,
|
|
wellN = 0.9, wellS = 0.55, wellEnd = 0.15,
|
|
wellTread = 0.8 }
|
|
|
|
local function stairCell(S, map, data, cx, cy, s)
|
|
local perRow = map.tileset.tilesPerRow or 16
|
|
local atlasW = map.tileset.imageWidth or 128
|
|
local atlasH = map.tileset.imageHeight or 48
|
|
local quads = S.objectQuads
|
|
local north = s.class == "stair_n" or s.class == "stair_down_n"
|
|
local down = s.class == "stair_down_n" or s.class == "stair_down_e"
|
|
or s.class == "stair_down_w"
|
|
local east = s.class == "stair_e" or s.class == "stair_down_e"
|
|
local mx, mz = cx * 16, cy * 16
|
|
local h = s.h or 16
|
|
local rise = h / STAIR_STEPS
|
|
local runW = 16 / STAIR_STEPS
|
|
local z0, z1 = mz, mz + 16
|
|
|
|
-- cell-space art coords (16x16, row 0 the top) -> atlas uv; callers keep
|
|
-- a quad's range inside one 8px tile so it never samples across a seam
|
|
local function uv(px, py)
|
|
px = math.max(0.05, math.min(15.95, px))
|
|
py = math.max(0.05, math.min(15.95, py))
|
|
local tile = S.tileAt[keyOf(cx * 2 + (px >= 8 and 1 or 0),
|
|
cy * 2 + (py >= 8 and 1 or 0))]
|
|
return ((tile % perRow) * 8 + px % 8) / atlasW,
|
|
(math.floor(tile / perRow) * 8 + py % 8) / atlasH
|
|
end
|
|
-- corners run bottom-left, bottom-right, top-right, top-left as seen
|
|
-- from outside (the mesher's side convention); art rect in cell space
|
|
local function face(c1, c2, c3, c4, ax0, ay0, ax1, ay1, shade)
|
|
local u0, v0 = uv(ax0, ay0)
|
|
local u1, v1 = uv(ax1, ay1)
|
|
quads[#quads + 1] = { c1, c2, c3, c4,
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = shade }
|
|
end
|
|
-- a vertical face spanning heights [fy0, fy1] wearing art rows
|
|
-- [ay0, ay1], emitted per 8-row art band so no quad crosses the seam
|
|
local function banded(z, ax0, ax1, fy0, fy1, ay0, ay1, shade, flip)
|
|
local scale = (fy1 - fy0) / math.max(ay1 - ay0, 0.001)
|
|
for _, band in ipairs({ { ay0, math.min(8, ay1) },
|
|
{ math.max(ay0, 8), ay1 } }) do
|
|
local a0, a1 = band[1], band[2]
|
|
if a1 > a0 then
|
|
local by1 = fy1 - (a0 - ay0) * scale
|
|
local by0 = fy1 - (a1 - ay0) * scale
|
|
local xa, xb = mx + ax0, mx + ax1
|
|
if flip then
|
|
face({ xb, by0, z }, { xa, by0, z }, { xa, by1, z },
|
|
{ xb, by1, z }, ax0, a0, ax1, a1, shade)
|
|
else
|
|
face({ xa, by0, z }, { xb, by0, z }, { xb, by1, z },
|
|
{ xa, by1, z }, ax0, a0, ax1, a1, shade)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- A flight running INTO the map instead of across it. The drawing is
|
|
-- the same staircase seen head-on rather than from the side, and that
|
|
-- changes which axis of the art means what: a drawn ROW is a step here,
|
|
-- and -- because looking down a well is looking along its depth -- drawn
|
|
-- row IS depth row, 1:1 across the cell's 16.
|
|
--
|
|
-- The Centers' steps state their own band table and it lands exactly:
|
|
-- 4 white rows, 1 black, 3 grey, 1 black, 3 checker, 4 black = 16. So
|
|
-- an even four-step division puts a black NOSING on the southmost row of
|
|
-- every band (15, 11, 7, 3) and leaves the rows behind it as that step's
|
|
-- tread. Nothing is authored but the RISE, which no head-on drawing can
|
|
-- state; the depths, the treads and the nosings are all measured.
|
|
--
|
|
-- A nosing is drawn as one row because it is seen nearly edge-on, so
|
|
-- un-projected it has real height and no depth: its row lies flat as the
|
|
-- tread's front lip AND stands as the riser under it. That is the one
|
|
-- texel in the flight used twice, and using it twice is what a nosing is.
|
|
--
|
|
-- The well's own walls come free as well: the drawing's first and last
|
|
-- COLUMNS are its black side walls, and its top band is the darkness the
|
|
-- flight leaves by, which is what the far end wants to wear.
|
|
--
|
|
-- A flight CLIMBING away (`stair_n`) is the same reading with the sign of
|
|
-- the rise flipped -- bands still run south to north, drawn row is still
|
|
-- depth row, the nosing still serves twice. Two things follow from the
|
|
-- sign. The risers turn around: a flight descending away from you closes
|
|
-- its steps from below and shows you their backs, one climbing away shows
|
|
-- you their FRONTS, so they face south. And the drawing's black side
|
|
-- columns stop being a well's walls and become the walls of the opening
|
|
-- the flight climbs into: they run from each tread UP to the top of the
|
|
-- wall band rather than down from the floor. At the last step the flight
|
|
-- has reached that top and there is no opening left to wall.
|
|
--
|
|
-- Every quad here is split at the cell's own 8px seam, in x and in rows
|
|
-- both: `uv` resolves ONE tile per corner, and these four tiles are not
|
|
-- neighbours in the atlas, so a quad that spans a seam interpolates
|
|
-- between two unrelated corners of the sheet.
|
|
if north then
|
|
local runD = 16 / STAIR_STEPS
|
|
local HALVES = { { 0.2, 7.9, 0, 8 }, { 8.1, 15.8, 8, 16 } }
|
|
for i = 0, STAIR_STEPS - 1 do
|
|
local a0 = 16 - (i + 1) * runD -- band i, in art rows
|
|
local a1 = a0 + runD
|
|
local yTop = (down and -1 or 1) * (i + 1) * rise
|
|
local ry = (down and -1 or 1) * i * rise -- the step behind it
|
|
local z0b, z1b = mz + a0, mz + a1
|
|
|
|
for _, H in ipairs(HALVES) do
|
|
local ax0, ax1, wx0, wx1 = H[1], H[2], mx + H[3], mx + H[4]
|
|
|
|
-- the tread: the whole band, drawn row = depth row, so the nosing
|
|
-- lies on its front lip exactly where the artist drew it
|
|
face({ wx0, yTop, z0b }, { wx1, yTop, z0b },
|
|
{ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
|
ax0, a1, ax1, a0,
|
|
down and STAIR_SHADE.wellTread or STAIR_SHADE.tread)
|
|
|
|
-- the riser at that lip, one art row tall -- so it needs none of
|
|
-- `banded`'s row splitting, and written straight keeps the geometry
|
|
-- flush at the seam while the art stays inside its tile. Facing
|
|
-- north when the flight descends (the steps are closed from below,
|
|
-- not looked at) and south when it climbs
|
|
if down then
|
|
face({ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
|
{ wx0, ry, z1b }, { wx1, ry, z1b },
|
|
ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser)
|
|
else
|
|
face({ wx0, ry, z1b }, { wx1, ry, z1b },
|
|
{ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
|
ax0, a1 - 1, ax1, a1, STAIR_SHADE.riser)
|
|
end
|
|
|
|
-- the deep end, closing the opening this flight is cut into: from
|
|
-- the floor of the well up to the top of the wall band beside it,
|
|
-- in the drawing's own black top rows. A climbing flight has no
|
|
-- such end -- its top tread stands at the wall's own height and
|
|
-- fills the opening
|
|
if down and i == STAIR_STEPS - 1 then
|
|
face({ wx1, -h, mz }, { wx0, -h, mz },
|
|
{ wx0, h, mz }, { wx1, h, mz },
|
|
ax1, 3.9, ax0, 0.1, STAIR_SHADE.wellEnd)
|
|
end
|
|
end
|
|
|
|
-- the opening's side walls beside this tread, wearing the drawing's
|
|
-- own black edge columns -- excavation or recess, it is walled in its
|
|
-- own texels. Descending they run from the tread up to the floor,
|
|
-- climbing from the tread up to the top of the wall band
|
|
local wallTop = down and 0 or h
|
|
local function sideWall(px, sx0, sx1, inward)
|
|
local c
|
|
if inward then -- west wall, faces E
|
|
c = { { px, yTop, z1b }, { px, yTop, z0b },
|
|
{ px, wallTop, z0b }, { px, wallTop, z1b } }
|
|
else -- east wall, faces W
|
|
c = { { px, yTop, z0b }, { px, yTop, z1b },
|
|
{ px, wallTop, z1b }, { px, wallTop, z0b } }
|
|
end
|
|
face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN)
|
|
end
|
|
if wallTop > yTop then
|
|
sideWall(mx, 0.1, 1.3, true)
|
|
sideWall(mx + 16, 14.7, 15.9, false)
|
|
end
|
|
end
|
|
return
|
|
end
|
|
|
|
for i = 0, STAIR_STEPS - 1 do
|
|
local sx0 = east and (i * runW) or (16 - (i + 1) * runW)
|
|
local sx1 = sx0 + runW
|
|
local x0, x1 = mx + sx0, mx + sx1
|
|
|
|
if down then
|
|
-- stairwell: tread i sits (i+1) rises below the floor; the walls
|
|
-- above it are the excavation, wearing the drawing at its depth
|
|
local yTop = -(i + 1) * rise
|
|
local dep = (i + 1) * rise
|
|
|
|
face({ x0, yTop, z0 }, { x1, yTop, z0 },
|
|
{ x1, yTop, z1 }, { x0, yTop, z1 },
|
|
sx0, dep - 1.4, sx1, dep, STAIR_SHADE.wellTread)
|
|
|
|
-- stairwell walls above this tread: north wall faces the camera
|
|
banded(z0, sx0, sx1, yTop, 0, 0, dep, STAIR_SHADE.wellN)
|
|
banded(z1, sx0, sx1, yTop, 0, 0, dep, STAIR_SHADE.wellS, true)
|
|
|
|
-- riser dropping to this tread from the shallower step
|
|
local rx = east and x0 or x1
|
|
local ry1 = -i * rise
|
|
local rax = east and (sx0 + 0.1) or (sx1 - 1.3)
|
|
if east then
|
|
face({ rx, yTop, z0 }, { rx, yTop, z1 },
|
|
{ rx, ry1, z1 }, { rx, ry1, z0 },
|
|
rax, i * rise, rax + 1.2, dep, STAIR_SHADE.riser)
|
|
else
|
|
face({ rx, yTop, z1 }, { rx, yTop, z0 },
|
|
{ rx, ry1, z0 }, { rx, ry1, z1 },
|
|
rax, i * rise, rax + 1.2, dep, STAIR_SHADE.riser)
|
|
end
|
|
|
|
-- the deep end: a dark opening under the wall the flight leaves by
|
|
if i == STAIR_STEPS - 1 then
|
|
local px = east and (mx + 16) or mx
|
|
local cax = east and 14.7 or 0.1
|
|
if east then
|
|
face({ px, -h, z1 }, { px, -h, z0 }, { px, 0, z0 }, { px, 0, z1 },
|
|
cax, 0, cax + 1.2, 16, STAIR_SHADE.wellEnd)
|
|
else
|
|
face({ px, -h, z0 }, { px, -h, z1 }, { px, 0, z1 }, { px, 0, z0 },
|
|
cax, 0, cax + 1.2, 16, STAIR_SHADE.wellEnd)
|
|
end
|
|
end
|
|
else
|
|
-- rising flight
|
|
local yTop = (i + 1) * rise
|
|
local py0 = 16 - yTop
|
|
|
|
-- south + north faces: the drawn flight sliced at this step's column
|
|
banded(z1, sx0, sx1, 0, yTop, py0, 16, STAIR_SHADE.south)
|
|
banded(z0, sx0, sx1, 0, yTop, py0, 16, STAIR_SHADE.north, true)
|
|
|
|
-- tread: the step's top, wearing the art band drawn at its height
|
|
face({ x0, yTop, z0 }, { x1, yTop, z0 },
|
|
{ x1, yTop, z1 }, { x0, yTop, z1 },
|
|
sx0, py0, sx1, py0 + 1.4, STAIR_SHADE.tread)
|
|
|
|
-- riser: the vertical strip exposed above the previous step
|
|
local rx = east and x0 or x1
|
|
local ry0 = i * rise
|
|
local rax = east and (sx0 + 0.1) or (sx1 - 1.3)
|
|
if east then
|
|
face({ rx, ry0, z0 }, { rx, ry0, z1 },
|
|
{ rx, yTop, z1 }, { rx, yTop, z0 },
|
|
rax, 16 - yTop, rax + 1.2, 16 - ry0, STAIR_SHADE.riser)
|
|
else
|
|
face({ rx, ry0, z1 }, { rx, ry0, z0 },
|
|
{ rx, yTop, z0 }, { rx, yTop, z1 },
|
|
rax, 16 - yTop, rax + 1.2, 16 - ry0, STAIR_SHADE.riser)
|
|
end
|
|
|
|
-- cap the tall end of the flight so it never shows a hole
|
|
if i == STAIR_STEPS - 1 then
|
|
local px = east and (mx + 16) or mx
|
|
local cax = east and 14.7 or 0.1
|
|
if east then
|
|
face({ px, 0, z1 }, { px, 0, z0 }, { px, h, z0 }, { px, h, z1 },
|
|
cax, 0, cax + 1.2, 16, STAIR_SHADE.cap)
|
|
else
|
|
face({ px, 0, z0 }, { px, 0, z1 }, { px, h, z1 }, { px, h, z0 },
|
|
cax, 0, cax + 1.2, 16, STAIR_SHADE.cap)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Structures.buildStairs(S, map, x0, x1, y0, y1)
|
|
local data = pixels(map.tileset)
|
|
for cy = math.floor(y0 / 2), math.floor(y1 / 2) do
|
|
for cx = math.floor(x0 / 2), math.floor(x1 / 2) do
|
|
local s = S.shapeAt[keyOf(cx * 2, cy * 2)]
|
|
if s and s.art == "stair" then
|
|
-- claim the cell whichever way the quads go: the mesher must not
|
|
-- box or floor it. A rising flight stands on the map's common
|
|
-- floor; a stairwell IS the hole, so nothing is painted under it
|
|
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
|
or s.class == "stair_down_n"
|
|
for dy = 0, 1 do
|
|
for dx = 0, 1 do
|
|
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
|
S.skip[tk] = true
|
|
if not down then S.ground[tk] = false end
|
|
end
|
|
end
|
|
if data then stairCell(S, map, data, cx, cy, s) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- volume mode: per-column runs with real drawn heights ----
|
|
|
|
-- `tiles` is a list of {tx, ty} forming one region (or what is left of one
|
|
-- after object extraction); runs are column-local, heights are measured
|
|
-- per column and reconciled per region.
|
|
function Structures.buildVolume(S, map, tiles)
|
|
local cols = {}
|
|
for _, c in ipairs(tiles) do
|
|
cols[c[1]] = cols[c[1]] or {}
|
|
cols[c[1]][c[2]] = true
|
|
end
|
|
|
|
local runs = {}
|
|
local heightVotes = {}
|
|
local repeatVotes = {}
|
|
for tx, ys in pairs(cols) do
|
|
-- visit each contiguous vertical run in this column
|
|
local sorted = {}
|
|
for y in pairs(ys) do sorted[#sorted + 1] = y end
|
|
table.sort(sorted)
|
|
local i = 1
|
|
while i <= #sorted do
|
|
local north = sorted[i]
|
|
local front = north
|
|
while i + 1 <= #sorted and sorted[i + 1] == front + 1 do
|
|
i = i + 1
|
|
front = sorted[i]
|
|
end
|
|
i = i + 1
|
|
local extent = front - north + 1
|
|
|
|
-- the column's own reading: its extent, unless its tile sequence
|
|
-- repeats -- then the repeat period is the drawn unit. Both readings
|
|
-- cap at MAX_ROWS (a long-period repeat is still not one column of
|
|
-- drawing).
|
|
local unit, repeatRead = math.min(extent, MAX_ROWS), false
|
|
if extent > 1 then
|
|
local t0 = map:tileAt(tx, front)
|
|
for k = 1, extent - 1 do
|
|
if map:tileAt(tx, front - k) == t0 then
|
|
unit = math.min(math.max(k, 2), MAX_ROWS)
|
|
repeatRead = true
|
|
break
|
|
end
|
|
end
|
|
-- A one-row TRIM at the column's foot hides a repeat from the
|
|
-- scan above, which anchors at the front tile: a cliff plateau
|
|
-- ends its south edge in a rounded corner tile, the corner
|
|
-- never recurs, and the column read its whole capped extent --
|
|
-- a 48px fin (or a whole tent of them) sticking out of a 16px
|
|
-- mesa on Routes 3 and 4. When the two rows directly above the
|
|
-- front are IDENTICAL, the column is that repeat wearing a trim
|
|
-- foot: one course plus the trim is its drawn unit. Doorway
|
|
-- columns are untouched -- their run answers to the region
|
|
-- (see below) before the unit matters.
|
|
if not repeatRead and extent > 2
|
|
and map:tileAt(tx, front - 1) == map:tileAt(tx, front - 2) then
|
|
unit = 2
|
|
repeatRead = true
|
|
end
|
|
end
|
|
local isDoor = false
|
|
for ty = north, front do
|
|
if S.doorFold[keyOf(tx, ty)] then
|
|
isDoor = true
|
|
break
|
|
end
|
|
end
|
|
local run = { front = front, north = north, extent = extent,
|
|
unit = unit, fromRepeat = repeatRead, door = isDoor }
|
|
runs[#runs + 1] = { tx = tx, run = run }
|
|
local h = unit * 8
|
|
heightVotes[h] = (heightVotes[h] or 0) + 1
|
|
if repeatRead then repeatVotes[h] = (repeatVotes[h] or 0) + 1 end
|
|
end
|
|
end
|
|
|
|
-- region consensus: the dominant height. A column whose reading came
|
|
-- from a repeat adopts it when taller (the column above a doorway
|
|
-- repeats internally but belongs to a 48px house); a column that read
|
|
-- its full extent keeps it (an attached low wing stays low).
|
|
local modeH, modeN = 16, 0
|
|
for h, n in pairs(heightVotes) do
|
|
if n > modeN or (n == modeN and h > modeH) then modeH, modeN = h, n end
|
|
end
|
|
-- whether the region's dominant columns are flat repeats (a cliff
|
|
-- mound's plateau) rather than drawn facades (a house's front)
|
|
local modeRepeat = (repeatVotes[modeH] or 0) * 2 > modeN
|
|
|
|
-- Whether this REGION's tops are a rim over a uniform body -- what every
|
|
-- cliff mound is drawn as: a top edge, then the same rock the whole way
|
|
-- down. The top face may then lay that rim once along its north edge and
|
|
-- hold the body after it, instead of cycling the rim back every second
|
|
-- tile and striping a plateau with edges it should not have.
|
|
--
|
|
-- Answered per column AND per region, because each catches what the
|
|
-- other misses. A mound is one structure many columns wide, and the
|
|
-- columns carrying its cave mouth read differently from their neighbours
|
|
-- (their drawing ends in the mouth's own tiles): per column alone, those
|
|
-- kept cycling while the rest held, leaving rim stubs above the doorway.
|
|
-- But a region vote alone silences a genuine rim-over-body column that
|
|
-- happens to stand in a region of repeating art -- three of them in the
|
|
-- Safari Zone. A column holds if EITHER says so.
|
|
--
|
|
-- Art that genuinely repeats is not uniform and keeps cycling: the
|
|
-- Safari Zone's fence alternates two tiles the whole way down, and there
|
|
-- the repeat IS what the drawing says.
|
|
local uniformVotes, uniformTotal = 0, 0
|
|
for _, r in ipairs(runs) do
|
|
local run = r.run
|
|
if run.extent > 2 then
|
|
uniformTotal = uniformTotal + 1
|
|
local body = map:tileAt(r.tx, run.north + 1)
|
|
local uniform = true
|
|
for d = 2, run.extent - 1 do
|
|
if map:tileAt(r.tx, run.north + d) ~= body then
|
|
uniform = false
|
|
break
|
|
end
|
|
end
|
|
run.ownUniform = uniform
|
|
if uniform then uniformVotes = uniformVotes + 1 end
|
|
end
|
|
end
|
|
local regionUniform = uniformTotal > 0 and uniformVotes * 2 > uniformTotal
|
|
for _, r in ipairs(runs) do
|
|
local run = r.run
|
|
local h = run.unit * 8
|
|
local adopted = false
|
|
local flatDoor = false
|
|
if run.door then
|
|
-- A folded doorway column answers to its region ENTIRELY. Its own
|
|
-- reading spans the door plus everything drawn above it -- a
|
|
-- house's full height when the door is a house's, but a 32px
|
|
-- tower over a 16px plateau when the door is a cave mouth cut
|
|
-- into a cliff mound (Diglett's Cave: the entrance jumped a block
|
|
-- above the mound around it). Height and top both come from the
|
|
-- region: the mode height, roofed like a facade when the mode
|
|
-- columns are drawn facades, flat when they are flat repeats.
|
|
h = modeH
|
|
adopted = not modeRepeat
|
|
flatDoor = modeRepeat
|
|
elseif run.fromRepeat and modeH > h then
|
|
h = modeH
|
|
adopted = true
|
|
end
|
|
-- Outdoors, a structure's top rows are its ROOF: the drawn height
|
|
-- splits into a vertical facade and a slope rising north to the drawn
|
|
-- peak (the mesher builds it; hips close the exposed flanks). Repeat
|
|
-- patterns (a border wall) stay flat-topped -- unless they adopted
|
|
-- their region's height, which means they are part of a building (the
|
|
-- column above a doorway) and roof with it. Total height is always
|
|
-- the drawn height: facade + rise = extent rows * 8.
|
|
--
|
|
-- But only PITCHED roofs slope. Gen 1 draws two kinds: a pitched roof
|
|
-- has distinct ridge and eaves rows (the houses' stripes), while a
|
|
-- flat ROOFTOP (the lab, the mart) repeats one texture tile over the
|
|
-- whole roof area -- and a rooftop tilted into a 48px ramp reads
|
|
-- wrong instantly. Distinct top rows -> slope; repeated -> level top.
|
|
local roofRows = 0
|
|
if S.outdoor and (not run.fromRepeat or adopted) and h >= 16
|
|
and not flatDoor then
|
|
roofRows = math.min(2, math.floor(h / 8) - 1)
|
|
if roofRows > 0 and map:tileAt(r.tx, run.north)
|
|
== map:tileAt(r.tx, run.north + 1) then
|
|
roofRows = 0
|
|
end
|
|
end
|
|
run.roofRows = roofRows
|
|
run.rise = roofRows * 8
|
|
run.peak = h
|
|
run.h = h - run.rise -- facade height: what sides build to
|
|
run.topUniform = run.ownUniform or regionUniform
|
|
for ty = run.north, run.front do
|
|
S.runs[keyOf(r.tx, ty)] = run
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- object mode: per-pixel voxelization of drawn props ----
|
|
|
|
local OBJ_SHADE = { front = 1.0, back = 0.68, side = 0.78,
|
|
top = 1.0, bottom = 0.55 }
|
|
|
|
-- The four GB shades, by a pixel's darkest channel. Force-mode
|
|
-- segmentation reasons in these: black is always outline/object, the
|
|
-- other three are background only where they touch the cluster's edge.
|
|
function Structures.shadeClass(v)
|
|
if v <= 0.25 then return "black" end
|
|
if v <= 0.55 then return "dark" end
|
|
if v <= 0.85 then return "light" end
|
|
return "white"
|
|
end
|
|
|
|
-- Analyze one region's art against its surroundings, voxelize the
|
|
-- sprite-like clusters, and return the tiles that remain for volume mode.
|
|
-- `force` (profile-pinned billboards) voxelizes every tile of the region
|
|
-- unconditionally -- the pin IS the classification. `force = "opaque"`
|
|
-- (the `post` pool) keeps the decree -- every tile is a prop, aprons
|
|
-- seed the flood, validation is skipped -- but classifies pixels the way
|
|
-- the DETECTOR does (everything non-white is solid) instead of by
|
|
-- outline shade: a fence's mid browns are its body, and the outline
|
|
-- rule would strip the posts to black skeletons.
|
|
function Structures.extractObjects(S, map, region, data, perRow, force)
|
|
local bw = (region.maxX - region.minX + 1) * 8
|
|
local bh = (region.maxY - region.minY + 1) * 8
|
|
|
|
local member = {}
|
|
for _, c in ipairs(region.tiles) do member[keyOf(c[1], c[2])] = true end
|
|
|
|
-- Image over the region bbox plus a 1px ground apron. Pixel states:
|
|
-- solid opaque member art (non-white, or white that survives)
|
|
-- cand member white: background candidate, the flood decides
|
|
-- air ground the flood may travel: INSIDE the bbox (the gaps
|
|
-- between fence posts), or the SOUTH apron row. This is the
|
|
-- direction the viewer reads background from -- a prop's
|
|
-- white meets the ground at its feet. OUTDOORS the other
|
|
-- aprons are barriers on purpose: a building's roof stripes
|
|
-- touch the grass BEHIND it, and a flood allowed to walk
|
|
-- around the sides would pour in from the north and shred the
|
|
-- roof into misdetected sprite clusters (it did). INDOORS all
|
|
-- four aprons seed: furniture backs onto walls and bottom-row
|
|
-- props meet the void ring, so the south row alone often
|
|
-- cannot reach the background at all -- and there are no
|
|
-- roofs inside to protect.
|
|
-- barrier everything else
|
|
local W, H = bw + 2, bh + 2
|
|
local state = {}
|
|
local srcU, srcV = {}, {}
|
|
for iy = 0, H - 1 do
|
|
for ix = 0, W - 1 do
|
|
Budget.tick()
|
|
local i = iy * W + ix
|
|
local px, py = ix - 1, iy - 1
|
|
local tx = region.minX + math.floor(px / 8)
|
|
local ty = region.minY + math.floor(py / 8)
|
|
local k = keyOf(tx, ty)
|
|
local inside = px >= 0 and px < bw and py >= 0 and py < bh
|
|
-- a forced (pinned) prop floods from every apron even when the
|
|
-- neighbours are solid: the pin itself declares the art a prop
|
|
-- whose whites are background -- a monitor pinned atop its desk has
|
|
-- no flat neighbour anywhere to seed from
|
|
local apron = iy == H - 1
|
|
or ((force or not S.outdoor)
|
|
and (iy == 0 or ix == 0 or ix == W - 1))
|
|
if inside and member[k] then
|
|
local tile = S.tileAt[k]
|
|
local ax = (tile % perRow) * 8 + px % 8
|
|
local ay = math.floor(tile / perRow) * 8 + py % 8
|
|
srcU[i], srcV[i] = ax, ay
|
|
local r, g, b, a = data:getPixel(ax, ay)
|
|
if a == 0 then
|
|
state[i] = "cand"
|
|
elseif force and force ~= "opaque" then
|
|
state[i] = Structures.shadeClass(math.min(r, g, b))
|
|
else
|
|
state[i] = math.min(r, g, b) > 0.83 and "cand" or "solid"
|
|
end
|
|
elseif inside or apron then
|
|
if force then
|
|
-- a pinned prop's surroundings are background BY DECREE -- the
|
|
-- pin declares the drawing a prop even when every neighbour is
|
|
-- solid furniture (a vase boxed in by its table). Ring pixels
|
|
-- seed the flood outright; interior non-member pixels ("iair")
|
|
-- seed it too but never drain paint whites -- only a white run
|
|
-- reaching the RING is background white.
|
|
state[i] = inside and "iair" or "air"
|
|
else
|
|
local s = S.shapeAt[k]
|
|
state[i] = (s and s.flat and s.class ~= "void") and "air"
|
|
or "barrier"
|
|
end
|
|
else
|
|
state[i] = "barrier"
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Forced (pinned) props are segmented the way the art is authored:
|
|
-- objects wear a BLACK OUTLINE, and the background is whatever shades
|
|
-- actually touch the cluster's edge -- the white floor around a TV,
|
|
-- the grey tabletop around a vase. Only those shades flood; the
|
|
-- outline, its interior, the drawing's paint whites and anything they
|
|
-- enclose all survive as the object.
|
|
--
|
|
-- The `cutout` pool is STRICTER, per the pure-profile contract: mid
|
|
-- shades are always background (a drawn cast shadow must not ring the
|
|
-- object in brown), and whites flood only along white runs from the
|
|
-- edge -- a background white sheet drains away, but paint whites the
|
|
-- flood could only reach through grey are the object.
|
|
if force and force ~= "opaque" then
|
|
local strict = false
|
|
do
|
|
local fs = S.shapeAt[keyOf(region.tiles[1][1], region.tiles[1][2])]
|
|
strict = fs ~= nil and fs.class == "cutout"
|
|
end
|
|
-- The rim vote reads the shades on the DRAWING'S OWN bounding box, so a
|
|
-- prop whose body reaches its own edge votes itself out. The Center's
|
|
-- potted plants are the case: the pot's olive base is drawn flush on the
|
|
-- bottom row of the block, so "dark" came back as background and every
|
|
-- dark pixel in the whole plant drained with it -- the pots rendered as
|
|
-- hollow black frames while the 2D art has solid olive bodies.
|
|
--
|
|
-- Where the vote misreads the art, the profile can name the background
|
|
-- shades outright (a tileset entry's prop_bg). Keyed BY TILE rather than
|
|
-- per tileset, because the answer is per drawing: the healing consoles'
|
|
-- screens really do stand on a dark wall band and really do need dark
|
|
-- voted out, and the PC really does need light kept.
|
|
local bg = {}
|
|
do
|
|
local named = TileShape.propBg(map.tileset.id)
|
|
if named then
|
|
for _, c in ipairs(region.tiles) do
|
|
local rule = named[S.tileAt[keyOf(c[1], c[2])]]
|
|
if rule then
|
|
for shadeName in pairs(rule) do bg[shadeName] = true end
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if not next(bg) then
|
|
for iy = 0, H - 1 do
|
|
for ix = 0, W - 1 do
|
|
local px, py = ix - 1, iy - 1
|
|
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
|
|
local st = state[iy * W + ix]
|
|
if edge and (st == "dark" or st == "light" or st == "white") then
|
|
bg[st] = true
|
|
end
|
|
end
|
|
end
|
|
if not (bg.dark or bg.light or bg.white) then bg.white = true end
|
|
end
|
|
for i, st in pairs(state) do
|
|
if strict then
|
|
if st == "dark" or st == "light" then
|
|
state[i] = "cand"
|
|
elseif st == "white" then
|
|
state[i] = "wcand"
|
|
elseif st == "black" then
|
|
state[i] = "solid"
|
|
end
|
|
elseif st == "dark" or st == "light" or st == "white" then
|
|
state[i] = bg[st] and "cand" or "solid"
|
|
elseif st == "black" then
|
|
state[i] = "solid"
|
|
end
|
|
end
|
|
end
|
|
|
|
-- flood background in from the ground at the structure's feet
|
|
local flooded = {}
|
|
local queue = {}
|
|
for i, st in pairs(state) do
|
|
if st == "air" or st == "iair" then
|
|
flooded[i] = true
|
|
queue[#queue + 1] = i
|
|
end
|
|
end
|
|
while #queue > 0 do
|
|
Budget.tick()
|
|
local i = table.remove(queue)
|
|
local ix, iy = i % W, math.floor(i / W)
|
|
for _, d in ipairs(DIRS4) do
|
|
local nx, ny = ix + d[1], iy + d[2]
|
|
if nx >= 0 and nx < W and ny >= 0 and ny < H then
|
|
local ni = ny * W + nx
|
|
if not flooded[ni] then
|
|
local ns = state[ni]
|
|
-- "wcand" (a strict cutout's white) drains only along a white
|
|
-- run that reaches the RING: entered from the outer apron or
|
|
-- from another flooded white, never through grey or through
|
|
-- interior air
|
|
if ns == "cand" or ns == "air" or ns == "iair"
|
|
or (ns == "wcand"
|
|
and (state[i] == "air" or state[i] == "wcand")) then
|
|
flooded[ni] = true
|
|
queue[#queue + 1] = ni
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- per-tile background ratio -> sprite-like tiles (a pinned billboard is
|
|
-- sprite-like by decree)
|
|
local sprite = {}
|
|
for _, c in ipairs(region.tiles) do
|
|
Budget.tick()
|
|
if force then
|
|
sprite[keyOf(c[1], c[2])] = true
|
|
else
|
|
local bx = (c[1] - region.minX) * 8
|
|
local by = (c[2] - region.minY) * 8
|
|
local bg = 0
|
|
for py = 0, 7 do
|
|
for px = 0, 7 do
|
|
if flooded[(by + py + 1) * W + (bx + px + 1)] then bg = bg + 1 end
|
|
end
|
|
end
|
|
if bg / 64 >= TILE_BG_RATIO then sprite[keyOf(c[1], c[2])] = true end
|
|
end
|
|
end
|
|
|
|
-- cluster sprite-like tiles; validate each cluster as one prop
|
|
local leftover, claimed = {}, {}
|
|
local clusterSeen = {}
|
|
for _, c in ipairs(region.tiles) do
|
|
local k = keyOf(c[1], c[2])
|
|
if sprite[k] and not clusterSeen[k] then
|
|
local cluster = { tiles = {}, minX = c[1], maxX = c[1],
|
|
minY = c[2], maxY = c[2] }
|
|
local queue2 = { c }
|
|
clusterSeen[k] = true
|
|
while #queue2 > 0 do
|
|
local cc = table.remove(queue2)
|
|
cluster.tiles[#cluster.tiles + 1] = cc
|
|
cluster.minX = math.min(cluster.minX, cc[1])
|
|
cluster.maxX = math.max(cluster.maxX, cc[1])
|
|
cluster.minY = math.min(cluster.minY, cc[2])
|
|
cluster.maxY = math.max(cluster.maxY, cc[2])
|
|
for _, d in ipairs(DIRS4) do
|
|
local nk = keyOf(cc[1] + d[1], cc[2] + d[2])
|
|
if sprite[nk] and not clusterSeen[nk] then
|
|
clusterSeen[nk] = true
|
|
queue2[#queue2 + 1] = { cc[1] + d[1], cc[2] + d[2] }
|
|
end
|
|
end
|
|
end
|
|
if Structures.buildObject(S, map, region, cluster,
|
|
state, flooded, srcU, srcV, W, force) then
|
|
for _, cc in ipairs(cluster.tiles) do
|
|
claimed[keyOf(cc[1], cc[2])] = true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
for _, c in ipairs(region.tiles) do
|
|
if not claimed[keyOf(c[1], c[2])] then leftover[#leftover + 1] = c end
|
|
end
|
|
return leftover
|
|
end
|
|
|
|
-- One sprite-like cluster -> a per-pixel voxel prism, or false when it
|
|
-- fails validation (too tall, vertically repeating, too big) and should
|
|
-- stay part of the volume.
|
|
function Structures.buildObject(S, map, region, cluster,
|
|
state, flooded, srcU, srcV, W, force)
|
|
local rows = cluster.maxY - cluster.minY + 1
|
|
if not force then
|
|
if rows > OBJECT_MAX_ROWS then return false end
|
|
|
|
-- a prop stands ON the ground: somewhere the cluster must meet flat
|
|
-- ground to its south. A cluster carved out of a structure's middle
|
|
-- (roof rows whose whites leaked) fails this and stays in the volume.
|
|
-- Indoors any side will do -- furniture backs onto walls and bottom-row
|
|
-- props meet the void ring, so south alone is too strict.
|
|
local dirs = S.outdoor and { { 0, 1 } } or DIRS4
|
|
local touchesGround = false
|
|
for _, c in ipairs(cluster.tiles) do
|
|
for _, d in ipairs(dirs) do
|
|
local ss = S.shapeAt[keyOf(c[1] + d[1], c[2] + d[2])]
|
|
if ss and ss.flat and ss.class ~= "void" then
|
|
touchesGround = true
|
|
break
|
|
end
|
|
end
|
|
if touchesGround then break end
|
|
end
|
|
if not touchesGround then return false end
|
|
|
|
-- a vertically repeating cluster (tree wall edge) is scenery, not a
|
|
-- prop
|
|
local cols = {}
|
|
for _, c in ipairs(cluster.tiles) do
|
|
cols[c[1]] = cols[c[1]] or {}
|
|
cols[c[1]][c[2]] = true
|
|
end
|
|
for tx, ys in pairs(cols) do
|
|
local front = nil
|
|
for y in pairs(ys) do front = math.max(front or y, y) end
|
|
local extent = 0
|
|
while ys[front - extent] do extent = extent + 1 end
|
|
if extent > 1 then
|
|
local t0 = map:tileAt(tx, front)
|
|
for k = 1, extent - 1 do
|
|
if map:tileAt(tx, front - k) == t0 then return false end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local memberC = {}
|
|
for _, c in ipairs(cluster.tiles) do memberC[keyOf(c[1], c[2])] = true end
|
|
|
|
-- solid pixels of this cluster (art minus flooded background)
|
|
local solidPx, count, bgCount = {}, 0, 0
|
|
local bw = (cluster.maxX - cluster.minX + 1) * 8
|
|
local bh = (cluster.maxY - cluster.minY + 1) * 8
|
|
for _, c in ipairs(cluster.tiles) do
|
|
local rx = (c[1] - region.minX) * 8
|
|
local ry = (c[2] - region.minY) * 8
|
|
for py = 0, 7 do
|
|
Budget.tick()
|
|
for px = 0, 7 do
|
|
local i = (ry + py + 1) * W + (rx + px + 1)
|
|
local on = state[i] ~= nil and state[i] ~= "air"
|
|
and state[i] ~= "iair" and state[i] ~= "barrier"
|
|
and not flooded[i]
|
|
if on then
|
|
local lx = (c[1] - cluster.minX) * 8 + px
|
|
local ly = (c[2] - cluster.minY) * 8 + py
|
|
solidPx[ly * bw + lx] = i
|
|
count = count + 1
|
|
else
|
|
bgCount = bgCount + 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
if count == 0 or count > OBJECT_MAX_QUADS then return false end
|
|
if not force and bgCount / (count + bgCount) < CLUSTER_MIN_BG then
|
|
return false
|
|
end
|
|
|
|
-- geometry: each solid pixel is one voxel column deep enough to read as
|
|
-- a body, standing at the cluster's south row, base on the ground plane
|
|
local depth = OBJECT_DEPTH
|
|
if force then
|
|
local cs = S.shapeAt[keyOf(cluster.tiles[1][1], cluster.tiles[1][2])]
|
|
depth = (cs and PINNED_DEPTH[cs.class]) or PINNED_DEPTH.billboard
|
|
end
|
|
local wx0 = cluster.minX * 8
|
|
|
|
-- A pinned prop drawn directly above an authored box stands ON it -- a
|
|
-- monitor on its desk, a flower pot on the table. The prism rises from
|
|
-- the box's top with its feet on the box's north row, and the claimed
|
|
-- tiles keep rendering as that box (wearing its plain art) instead of
|
|
-- punching a floor-level hole through it.
|
|
--
|
|
-- Only when the prop's OWN CELL IS BLOCKED, though. "Is something
|
|
-- drawn above me?" is not the same question as "am I standing on it":
|
|
-- a chair drawn against the north side of a table is above the table's
|
|
-- trim row too, and it was being lifted onto the tabletop -- three
|
|
-- chairs standing on the furniture in Cinnabar's trade room and
|
|
-- Fuchsia's meeting room, with the claimed cells re-tiled as tabletop
|
|
-- so the table marched two rows north with them. The world already
|
|
-- knows which is which: a thing that sits ON furniture occupies a
|
|
-- blocked cell (you cannot walk through the gym statue, Red's plant,
|
|
-- the PC), while a seat you walk up to is in a walkable one.
|
|
--
|
|
-- FENCE POSTS (the `post` pool, force == "opaque") never take the lift
|
|
-- at all. A post stands in the ground by definition -- it is not a
|
|
-- thing set down on top of something -- and its cell is blocked like
|
|
-- any other post, so the test above cannot tell it apart. Lavender
|
|
-- Town is where it showed: pinning the cliff's slope chain gave the
|
|
-- posts along the cliff edge an authored 16px box to their south, and
|
|
-- they were hoisted to stand on the clifftop instead of the path.
|
|
local baseY, support, supportRow = 0, nil, nil
|
|
if force and force ~= "opaque" then
|
|
local belowK = keyOf(cluster.minX, cluster.maxY + 1)
|
|
local bs = S.shapeAt[belowK]
|
|
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
|
|
math.floor(cluster.maxY / 2))
|
|
-- `bookcase` supports as well as `upright`. A prop drawn above an
|
|
-- authored box stands ON it whatever art the box renders with, and a
|
|
-- stacked box is still a box: the Plateau's gate pilasters carry a
|
|
-- statue on 48 of their tops, and collapsing the pilaster to a stacked
|
|
-- run made every one of them fail this test and drop to ground level.
|
|
-- A `building` claim supports too, when it carries a height: a
|
|
-- Buildings template that names `support` is furniture modelled in
|
|
-- full with a standee left standing on it (Red's dining table under
|
|
-- its potted plant), and the height it states is the model's top
|
|
-- plane. A plain claim stays at h = 0 and supports nothing.
|
|
if blocked and bs and bs.authored and (bs.h or 0) > 0
|
|
and (bs.art == "upright" or bs.art == "bookcase"
|
|
or bs.class == "building") then
|
|
baseY, support = bs.h, bs
|
|
-- A bookcase support has MOVED: the collapse walks the whole drawn
|
|
-- run onto its southmost cell, and the cell tested above is the run's
|
|
-- north end. On the Plateau's two-cell pilasters that is a full cell
|
|
-- away, and the bird stood at the right HEIGHT over open ground with
|
|
-- its pillar behind it -- floating. Stand it on the box's own north
|
|
-- row instead of one row south of its drawing.
|
|
supportRow = S.bookcaseBox[belowK]
|
|
end
|
|
end
|
|
local atlasW = map.tileset.imageWidth or 128
|
|
local atlasH = map.tileset.imageHeight or 48
|
|
local quads = S.objectQuads
|
|
|
|
local function at(lx, ly)
|
|
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return nil end
|
|
return solidPx[ly * bw + lx]
|
|
end
|
|
|
|
-- Connected components: one cluster can hold several OBJECTS -- two
|
|
-- stools stacked in adjacent cells, a loose leaf beside a vase. Each
|
|
-- component stands on its own feet (base on the ground or the support
|
|
-- box, never floating at its bbox height) in the depth band of the
|
|
-- tile row its lowest pixel is drawn in, so stacked drawings become
|
|
-- separate standees in their own cells instead of one tower.
|
|
-- 8-connectivity keeps diagonal strokes whole.
|
|
local comp, comps = {}, {}
|
|
for ly = 0, bh - 1 do
|
|
Budget.tick()
|
|
for lx = 0, bw - 1 do
|
|
local idx = ly * bw + lx
|
|
if solidPx[idx] and not comp[idx] then
|
|
local c = { lowY = ly, n = 0 }
|
|
comps[#comps + 1] = c
|
|
local stack = { idx }
|
|
comp[idx] = c
|
|
while #stack > 0 do
|
|
local p = table.remove(stack)
|
|
local px, py = p % bw, math.floor(p / bw)
|
|
c.n = c.n + 1
|
|
if py > c.lowY then c.lowY = py end
|
|
for dy = -1, 1 do
|
|
for dx = -1, 1 do
|
|
local nx, ny = px + dx, py + dy
|
|
if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < bw
|
|
and ny >= 0 and ny < bh then
|
|
local ni = ny * bw + nx
|
|
if solidPx[ni] and not comp[ni] then
|
|
comp[ni] = c
|
|
stack[#stack + 1] = ni
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for _, c in ipairs(comps) do
|
|
c.z0 = supportRow and (supportRow * 8 + (8 - depth) / 2)
|
|
or (cluster.minY * 8 + math.floor(c.lowY / 8) * 8
|
|
+ (support and 8 or 0) + (8 - depth) / 2)
|
|
c.z1 = c.z0 + depth
|
|
end
|
|
|
|
-- A `cutout` or `console` pin is ONE object by contract: keep only
|
|
-- the largest connected drawing. Loose black scraps -- a cast
|
|
-- shadow's drawn edge, a seam, the vertical rules the surrounding
|
|
-- furniture draws down its own edges -- are background even though
|
|
-- black pixels always survive the shade flood, and this is what
|
|
-- removes them. Every other pool may hold several objects per
|
|
-- cluster (two stools side by side, a leaf beside a vase), so this
|
|
-- cannot be the default.
|
|
if force then
|
|
local cs = S.shapeAt[keyOf(cluster.tiles[1][1], cluster.tiles[1][2])]
|
|
if cs and (cs.class == "cutout" or cs.class == "console")
|
|
and #comps > 1 then
|
|
local biggest = comps[1]
|
|
for _, c in ipairs(comps) do
|
|
if c.n > biggest.n then biggest = c end
|
|
end
|
|
for idx, c in pairs(comp) do
|
|
if c ~= biggest then solidPx[idx] = nil end
|
|
end
|
|
end
|
|
end
|
|
|
|
for ly = 0, bh - 1 do
|
|
Budget.tick()
|
|
for lx = 0, bw - 1 do
|
|
local i = at(lx, ly)
|
|
if i then
|
|
local c = comp[ly * bw + lx]
|
|
local z0, z1 = c.z0, c.z1
|
|
local x, y = wx0 + lx, baseY + c.lowY - ly
|
|
local u = (srcU[i] + 0.5) / atlasW
|
|
local v = (srcV[i] + 0.5) / atlasH
|
|
local function quad(c1, c2, c3, c4, shade)
|
|
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
|
|
end
|
|
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y + 1, z1 },
|
|
{ x, y + 1, z1 }, OBJ_SHADE.front)
|
|
quad({ x + 1, y, z0 }, { x, y, z0 }, { x, y + 1, z0 },
|
|
{ x + 1, y + 1, z0 }, OBJ_SHADE.back)
|
|
if not at(lx, ly - 1) then
|
|
quad({ x, y + 1, z0 }, { x + 1, y + 1, z0 }, { x + 1, y + 1, z1 },
|
|
{ x, y + 1, z1 }, OBJ_SHADE.top)
|
|
end
|
|
if y > baseY and not at(lx, ly + 1) then
|
|
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y, z0 },
|
|
{ x, y, z0 }, OBJ_SHADE.bottom)
|
|
end
|
|
if not at(lx - 1, ly) then
|
|
quad({ x, y, z0 }, { x, y, z1 }, { x, y + 1, z1 },
|
|
{ x, y + 1, z0 }, OBJ_SHADE.side)
|
|
end
|
|
if not at(lx + 1, ly) then
|
|
quad({ x + 1, y, z1 }, { x + 1, y, z0 }, { x + 1, y + 1, z0 },
|
|
{ x + 1, y + 1, z1 }, OBJ_SHADE.side)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- the ground the prop stands on: the commonest flat tile touching the
|
|
-- cluster, painted under every cluster tile (the art that was there is
|
|
-- now standing up as the object)
|
|
local votes, best, bestN = {}, nil, 0
|
|
for _, c in ipairs(cluster.tiles) do
|
|
for _, d in ipairs(DIRS4) do
|
|
local nk = keyOf(c[1] + d[1], c[2] + d[2])
|
|
local ns = S.shapeAt[nk]
|
|
if ns and ns.flat and ns.class ~= "void" and not memberC[nk] then
|
|
local t = S.tileAt[nk]
|
|
votes[t] = (votes[t] or 0) + 1
|
|
if votes[t] > bestN then best, bestN = t, votes[t] end
|
|
end
|
|
end
|
|
end
|
|
for _, c in ipairs(cluster.tiles) do
|
|
local k = keyOf(c[1], c[2])
|
|
if support and (support.class == "wall" or support.class == "cliff"
|
|
or support.art == "bookcase"
|
|
or support.class == "building") then
|
|
-- a figure drawn above a FULL-HEIGHT block (the gym statue on its
|
|
-- plinth) is a statue on a pillar with ONE cell of footprint: the
|
|
-- block below already carries the whole base, so the drawn cell
|
|
-- becomes synthesized floor rather than a second block marching
|
|
-- the base backwards. Furniture supports (a monitor on its desk)
|
|
-- keep the box-extension below -- their drawn cell is the
|
|
-- furniture's own upper rows, and floor there would amputate it.
|
|
--
|
|
-- STRUCTURE, not height, decides which: `cliff` and `bookcase` are
|
|
-- full-height blocks like `wall` and belong here, while `desk` is
|
|
-- 24px and still furniture. The Plateau's statues on stacked
|
|
-- pilasters found this -- taking the furniture branch turned each
|
|
-- statue's own two rows into a 32px box wearing the pilaster's art,
|
|
-- so every one of them stood inside a slab of its own plinth.
|
|
-- A `building` support belongs here too: the template's stamped
|
|
-- model already carries every surface under the standee (that is
|
|
-- what its `support` height asserts), so a box here would stand
|
|
-- INSIDE the modelled tabletop. Its stamp pre-painted the floor
|
|
-- under these tiles, which the `or` keeps when no flat tile
|
|
-- touches a cluster ringed by its own furniture.
|
|
S.skip[k] = true
|
|
S.ground[k] = best or S.ground[k]
|
|
elseif support then
|
|
-- the claimed tile keeps rendering as the box the prop stands on,
|
|
-- wearing the art its own ROW would have without the drawing (the
|
|
-- trim row stays trim); only when the whole row is the prop does
|
|
-- it fall back to the row below
|
|
S.shapeAt[k] = support
|
|
local src = keyOf(c[1], cluster.maxY + 1)
|
|
for dx = 1, 3 do
|
|
for _, sx in ipairs({ c[1] - dx, c[1] + dx }) do
|
|
local nk = keyOf(sx, c[2])
|
|
local ns = S.shapeAt[nk]
|
|
if not memberC[nk] and ns and ns.authored
|
|
and ns.class == support.class then
|
|
src = nk
|
|
break
|
|
end
|
|
end
|
|
if src ~= keyOf(c[1], cluster.maxY + 1) then break end
|
|
end
|
|
S.tileAt[k] = S.tileAt[src]
|
|
else
|
|
S.skip[k] = true
|
|
S.ground[k] = best
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- ---- authored masks with a body ----
|
|
|
|
-- One authored mask emitted as a per-pixel voxel slab in WORLD space --
|
|
-- the treatment every solid standee in this file gets, driven by a hand
|
|
-- drawn silhouette instead of a flood.
|
|
--
|
|
-- The caller owns placement entirely, because placement is the whole
|
|
-- difference between the two things that use this: `x0` is the world x of
|
|
-- the mask's west edge, `yOf(ly)` the world y a drawn row lands at, and
|
|
-- `bandOf(ly)` its z span. A bicycle hung on a wall keeps its drawn
|
|
-- elevation and juts south of the band; a cash register stands on the
|
|
-- counter's top plane and sits inside its own cell.
|
|
--
|
|
-- `bandOf` is per ROW rather than per object so one drawing can hold parts
|
|
-- of different thickness (the register's receipt curl over its body).
|
|
-- Where the band CHANGES between two stacked rows the lower row still gets
|
|
-- its top face: without that the body would be open along the strip the
|
|
-- thinner part does not cover, and you would see into the machine.
|
|
--
|
|
-- `omit` is a rect of the mask this pass does NOT extrude, because it is
|
|
-- not a face at all -- maskPlate lays it flat instead. It leaves the mask
|
|
-- for good here, neighbours included, so the extrusion closes up around
|
|
-- the notch exactly as if the drawing had never filled it.
|
|
local function maskSlab(quads, m, perRow, atlasW, atlasH, x0, yOf, bandOf,
|
|
yFloor, omit)
|
|
local bw, bh = m.w * 8, m.h * 8
|
|
|
|
local function at(lx, ly)
|
|
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
|
|
if omit and lx >= omit.x0 and lx <= omit.x1
|
|
and ly >= omit.r0 and ly <= omit.r1 then return false end
|
|
return m.mask[ly * bw + lx] or false
|
|
end
|
|
|
|
for ly = 0, bh - 1 do
|
|
Budget.tick()
|
|
local z0, z1 = bandOf(ly)
|
|
local pz0, pz1 = bandOf(ly - 1)
|
|
local capped = (pz0 ~= z0 or pz1 ~= z1)
|
|
for lx = 0, bw - 1 do
|
|
if at(lx, ly) then
|
|
local tile = m.tiles[math.floor(ly / 8) * m.w
|
|
+ math.floor(lx / 8) + 1]
|
|
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
|
|
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
|
|
local x, y = x0 + lx, yOf(ly)
|
|
local function quad(c1, c2, c3, c4, shade)
|
|
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
|
|
end
|
|
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y + 1, z1 },
|
|
{ x, y + 1, z1 }, OBJ_SHADE.front)
|
|
quad({ x + 1, y, z0 }, { x, y, z0 }, { x, y + 1, z0 },
|
|
{ x + 1, y + 1, z0 }, OBJ_SHADE.back)
|
|
if capped or not at(lx, ly - 1) then
|
|
quad({ x, y + 1, z0 }, { x + 1, y + 1, z0 }, { x + 1, y + 1, z1 },
|
|
{ x, y + 1, z1 }, OBJ_SHADE.top)
|
|
end
|
|
if y > yFloor and not at(lx, ly + 1) then
|
|
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y, z0 },
|
|
{ x, y, z0 }, OBJ_SHADE.bottom)
|
|
end
|
|
if not at(lx - 1, ly) then
|
|
quad({ x, y, z0 }, { x, y, z1 }, { x, y + 1, z1 },
|
|
{ x, y + 1, z0 }, OBJ_SHADE.side)
|
|
end
|
|
if not at(lx + 1, ly) then
|
|
quad({ x + 1, y, z1 }, { x + 1, y, z0 }, { x + 1, y + 1, z0 },
|
|
{ x + 1, y + 1, z1 }, OBJ_SHADE.side)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- The other half of the same drawing: a rect of the mask that is a
|
|
-- TOP-VIEW surface, laid HORIZONTAL instead of extruded.
|
|
--
|
|
-- This is the methodology's band classification at rect granularity, and
|
|
-- the reason the register is not a box. A GB cell packs several facings,
|
|
-- and the register's keypad is drawn from ABOVE -- its keys lie on the
|
|
-- machine's deck, sealed behind their own black border inside the outer
|
|
-- silhouette. Extruding it stands that surface on end and paints the keys
|
|
-- up the machine's face, which is the extruded-picture failure exactly.
|
|
--
|
|
-- So the rect lands one voxel proud of what maskSlab left below it, at `y`,
|
|
-- one voxel thick, filling the body's whole depth band (`z0`, `D`).
|
|
--
|
|
-- The rect STRETCHES over that band rather than laying its rows 1:1: it is
|
|
-- the machine's whole deck, so it has to reach the machine's whole depth,
|
|
-- and the alternative -- panel at the front, bare deck behind -- leaves a
|
|
-- strip of the base band's top showing through where the keys should be.
|
|
-- Sampled at the voxel's CENTRE, the same rule Stage 1 samples the atlas
|
|
-- with, so a band scales by whole voxels and nothing blurs: at 8 rows over
|
|
-- 12 voxels every second drawn row doubles. The one place in the model
|
|
-- where a texel is not 1:1 with a drawn pixel, and the reason `depth` is an
|
|
-- authored number again. No bottom faces: it rests on the box.
|
|
local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D)
|
|
local bw, bh = m.w * 8, m.h * 8
|
|
local rows = r.r1 - r.r0 + 1
|
|
|
|
-- depth voxel -> the drawn row it wears
|
|
local function rowAt(k)
|
|
if k < 0 or k >= D then return nil end
|
|
return r.r0 + math.min(rows - 1, math.floor((k + 0.5) * rows / D))
|
|
end
|
|
|
|
local function at(lx, k)
|
|
local ly = rowAt(k)
|
|
if not ly or lx < r.x0 or lx > r.x1 then return false end
|
|
return m.mask[ly * bw + lx] or false
|
|
end
|
|
|
|
-- The plate's rim, in the two directions the drawing treats differently.
|
|
-- ACROSS the rows the neighbour is the extrusion standing BESIDE the
|
|
-- notch (the register's display unit), which is tall and covers the
|
|
-- plate's edge, so that face must not be drawn twice. ALONG them the
|
|
-- neighbour is the extrusion BELOW it (the base band, whose own front
|
|
-- face stops one voxel short), so the plate's front lip is exposed and
|
|
-- is the deck's own front edge.
|
|
local function beside(lx, ly)
|
|
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
|
|
return m.mask[ly * bw + lx] or false
|
|
end
|
|
|
|
for k = 0, D - 1 do
|
|
Budget.tick()
|
|
local ly, z = rowAt(k), z0 + k
|
|
for lx = r.x0, r.x1 do
|
|
if at(lx, k) then
|
|
local tile = m.tiles[math.floor(ly / 8) * m.w
|
|
+ math.floor(lx / 8) + 1]
|
|
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
|
|
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
|
|
local x = x0 + lx
|
|
local function quad(c1, c2, c3, c4, shade)
|
|
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
|
|
end
|
|
quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 },
|
|
{ x, y + 1, z + 1 }, OBJ_SHADE.top)
|
|
if not at(lx, k + 1) then
|
|
quad({ x, y, z + 1 }, { x + 1, y, z + 1 }, { x + 1, y + 1, z + 1 },
|
|
{ x, y + 1, z + 1 }, OBJ_SHADE.front)
|
|
end
|
|
if not at(lx, k - 1) then
|
|
quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z },
|
|
{ x + 1, y + 1, z }, OBJ_SHADE.back)
|
|
end
|
|
if not beside(lx - 1, ly) then
|
|
quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 },
|
|
{ x, y + 1, z }, OBJ_SHADE.side)
|
|
end
|
|
if not beside(lx + 1, ly) then
|
|
quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z },
|
|
{ x + 1, y + 1, z + 1 }, OBJ_SHADE.side)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- An AUTHORED solid standing on furniture, given as plan layers instead of
|
|
-- extruded from the drawing (see TileShape's `model`). The one thing it
|
|
-- shares with the mask paths is that nothing here is a colour: each layer
|
|
-- names the atlas texels its top and its sides wear, and every quad below
|
|
-- samples one of them, so the Centers' bell is painted out of the counter's
|
|
-- own pixels and recolours with it.
|
|
--
|
|
-- Placement is by CELL, not by drawn row. A model exists because the
|
|
-- drawing was too small to un-project, so its drawn row says nothing about
|
|
-- depth worth keeping -- what says something is which piece of furniture it
|
|
-- is on and which end of it a person reaches: the solid is centred on the
|
|
-- mask's own columns and pushed to the SOUTH edge of the support cell, the
|
|
-- face the aisle is on, less the entry's `inset` -- the one number here
|
|
-- taste can move, because flush against the counter's own front lip is a
|
|
-- real position and so is a couple of voxels back from it.
|
|
local function maskModel(quads, m, perRow, atlasW, atlasH, xMid, zSouth, y0)
|
|
local function uvOf(t)
|
|
local tile, row, col = t[1], t[2], t[3] or 0
|
|
return ((tile % perRow) * 8 + col + 0.5) / atlasW,
|
|
(math.floor(tile / perRow) * 8 + row + 0.5) / atlasH
|
|
end
|
|
|
|
for k, L in ipairs(m) do
|
|
local u, v = uvOf(L.side)
|
|
local ut, vt = uvOf(L.top)
|
|
local above = m[k + 1]
|
|
local x0 = xMid - math.floor(L.w / 2)
|
|
local z0 = zSouth - L.d
|
|
local function solid(layer, dx, dz)
|
|
if not layer or dx < 0 or dx >= layer.w or dz < 0 or dz >= layer.d then
|
|
return false
|
|
end
|
|
return layer.cells[dz * layer.w + dx] or false
|
|
end
|
|
for dz = 0, L.d - 1 do
|
|
for dx = 0, L.w - 1 do
|
|
if solid(L, dx, dz) then
|
|
local x, y, z = x0 + dx, y0 + k - 1, z0 + dz
|
|
local function quad(c1, c2, c3, c4, uu, vv, shade)
|
|
quads[#quads + 1] = { c1, c2, c3, c4, u = uu, v = vv,
|
|
shade = shade }
|
|
end
|
|
-- a layer's own plan is what closes it: a face is drawn wherever
|
|
-- the neighbouring cell of this layer is empty, and the top
|
|
-- wherever the layer ABOVE does not stand on it. Nothing needs a
|
|
-- bottom -- layer 1 rests on the furniture and the rest rest on
|
|
-- each other.
|
|
if not solid(above, dx, dz) then
|
|
quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 },
|
|
{ x, y + 1, z + 1 }, ut, vt, OBJ_SHADE.top)
|
|
end
|
|
if not solid(L, dx, dz + 1) then
|
|
quad({ x, y, z + 1 }, { x + 1, y, z + 1 },
|
|
{ x + 1, y + 1, z + 1 }, { x, y + 1, z + 1 }, u, v,
|
|
OBJ_SHADE.front)
|
|
end
|
|
if not solid(L, dx, dz - 1) then
|
|
quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z },
|
|
{ x + 1, y + 1, z }, u, v, OBJ_SHADE.back)
|
|
end
|
|
if not solid(L, dx - 1, dz) then
|
|
quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 },
|
|
{ x, y + 1, z }, u, v, OBJ_SHADE.side)
|
|
end
|
|
if not solid(L, dx + 1, dz) then
|
|
quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z },
|
|
{ x + 1, y + 1, z + 1 }, u, v, OBJ_SHADE.side)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- figures: a thing drawn INTO furniture, cut out and stood up ----
|
|
|
|
-- One authored figure at one matched position.
|
|
--
|
|
-- The mask IS the classification: no flood, no shade segmentation, no
|
|
-- validation gate. Every automatic route in this file asks the art where
|
|
-- the object ends, and a figure painted into its own furniture has no
|
|
-- answer to give -- so the profile answers instead, and this only has to
|
|
-- believe it. Which also means figures build HEADLESS: unlike every
|
|
-- other standee here, nothing below reads a pixel.
|
|
--
|
|
-- A PERSON is a SPRITE, not a prop, and an entry that states no `depth`
|
|
-- gets exactly the treatment SpriteBillboards gives a character: one flat
|
|
-- plane of the drawing's own pixels, no thickness, standing at its feet
|
|
-- and leaned back by the camera's pitch at draw time so it always reads
|
|
-- face-on -- because that is what the artwork is. A seated man drawn
|
|
-- face-on is a 2D icon like every other Gen 1 figure; extruding him into
|
|
-- a slab reconstructs a body nobody drew (the ten-voxel version read as a
|
|
-- wedge of furniture, and even one voxel showed an edge the sprites never
|
|
-- show).
|
|
--
|
|
-- So the card's quads are emitted in its OWN LOCAL SPACE -- x from the
|
|
-- mask's west edge, y from his feet, all at z = 0 -- and the placement
|
|
-- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix
|
|
-- from. One quad per pixel rather than one alpha-keyed texture: the
|
|
-- tileset atlas has no alpha to key on, and per-pixel quads cut the exact
|
|
-- same silhouette straight out of the live atlas, so every palette bake
|
|
-- (SGB, RED++ per-tile groups, a mod's own art) textures him for free.
|
|
--
|
|
-- An entry that DOES state a `depth` is not a person, and takes the other
|
|
-- branch: a per-pixel voxel slab in world space (maskSlab above), standing
|
|
-- on the same furniture the card would have stood on. The Marts' cash
|
|
-- register is why -- a machine set down on a counter is a box seen from
|
|
-- the front, and a card of it is the billboard failure the standee pools
|
|
-- exist to avoid. It keeps the card's anchoring exactly: its feet on the
|
|
-- support's top plane, and its body in the 8px depth band of the tile row
|
|
-- its lowest pixel is drawn in, which is where a character card would
|
|
-- have pivoted. So the machine sits at the FRONT of the counter cell it
|
|
-- is drawn low in, and never leans into the aisle behind it.
|
|
local function buildFigure(S, map, fig, tx, ty, perRow)
|
|
local bw, bh = fig.w * 8, fig.h * 8
|
|
|
|
local function at(lx, ly)
|
|
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
|
|
return fig.mask[ly * bw + lx] or false
|
|
end
|
|
|
|
-- his feet and his west edge: the card's own origin
|
|
local lowY, minX = 0, bw - 1
|
|
for ly = 0, bh - 1 do
|
|
for lx = 0, bw - 1 do
|
|
if at(lx, ly) then
|
|
if ly > lowY then lowY = ly end
|
|
if lx < minX then minX = lx end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- He stands ON the furniture he was drawn into -- the same lift a pinned
|
|
-- prop above a pinned box takes (see buildObject), and gated the same
|
|
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
|
|
-- seat you merely walk up to is in a walkable one. The row under his
|
|
-- card is SCANNED for the tallest authored upright rather than read at
|
|
-- its west corner: the corner tile can be furniture that is not his
|
|
-- seat (the couch's raised backrest column stands there, `top` art and
|
|
-- taller than the cushion he actually sits on).
|
|
local baseY = 0
|
|
local blocked = not map:isWalkableCell(math.floor(tx / 2),
|
|
math.floor((ty + fig.h - 1) / 2))
|
|
if blocked then
|
|
for dx = 0, fig.w - 1 do
|
|
local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)]
|
|
if bs and bs.authored and bs.art == "upright"
|
|
and (bs.h or 0) > baseY then
|
|
baseY = bs.h
|
|
end
|
|
end
|
|
end
|
|
|
|
local atlasW = map.tileset.imageWidth or 128
|
|
local atlasH = map.tileset.imageHeight or 48
|
|
|
|
if fig.model then
|
|
-- An authored solid: centred on the mask's own columns, standing on
|
|
-- the furniture's top plane at the front of its cell.
|
|
local maxX = minX
|
|
for ly = 0, bh - 1 do
|
|
for lx = 0, bw - 1 do
|
|
if at(lx, ly) and lx > maxX then maxX = lx end
|
|
end
|
|
end
|
|
local xMid = tx * 8 + math.floor((minX + maxX + 1) / 2)
|
|
local zSouth = (math.floor((ty + fig.h - 1) / 2) + 1) * 16 - (fig.inset or 0)
|
|
maskModel(S.objectQuads, fig.model, perRow, atlasW, atlasH,
|
|
xMid, zSouth, baseY)
|
|
elseif fig.depth then
|
|
-- An OBJECT: the standee slab, standing on the FRONT edge of the tile
|
|
-- row its feet are drawn in -- the south face of the 8px band a
|
|
-- character card would have pivoted in. It is anchored there and
|
|
-- grows NORTH rather than being centred, so that `depth` is free to
|
|
-- exceed the 8px band without the machine ever creeping toward the
|
|
-- aisle: a till drawn low on a counter is at the counter's front, and
|
|
-- a deeper one just eats more of the bare top behind it. (At the
|
|
-- 8 the band itself is, the two rules agree.)
|
|
--
|
|
-- `thin` caps the top rows to their own thickness, centred in the
|
|
-- body's depth -- the register's receipt curl leaves the arm's top
|
|
-- face by a slot in the middle of it, not flush with its front.
|
|
local south = ty * 8 + math.floor(lowY / 8) * 8 + 8
|
|
local function bandOf(ly)
|
|
local z0 = south - fig.depth
|
|
if fig.thin and ly < fig.thin.rows then
|
|
local m = math.floor((fig.depth - fig.thin.depth) / 2)
|
|
return z0 + m, z0 + m + fig.thin.depth
|
|
end
|
|
return z0, south
|
|
end
|
|
local function yOf(ly) return baseY + lowY - ly end
|
|
maskSlab(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8,
|
|
yOf, bandOf, baseY, fig.flat)
|
|
if fig.flat then
|
|
-- The top-view rect lands on the plane its own BOTTOM row would
|
|
-- have stood at -- which is the top of whatever the extrusion left
|
|
-- under it (the register's base band), so the keys lie on the deck
|
|
-- and never float.
|
|
--
|
|
-- In depth it fills the body's whole band, STRETCHED to it: the rect
|
|
-- is the machine's deck, so it reaches as deep as the machine does,
|
|
-- and its last drawn row stays the deck's front edge directly over
|
|
-- the fascia below it -- an object drawn LOW on a surface is drawn
|
|
-- NEAR its front.
|
|
maskPlate(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8,
|
|
fig.flat, yOf(fig.flat.r1), south - fig.depth, fig.depth)
|
|
end
|
|
else
|
|
local quads = {}
|
|
for ly = 0, bh - 1 do
|
|
Budget.tick()
|
|
for lx = 0, bw - 1 do
|
|
if at(lx, ly) then
|
|
local tile = fig.tiles[math.floor(ly / 8) * fig.w
|
|
+ math.floor(lx / 8) + 1]
|
|
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
|
|
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
|
|
local x, y = lx - minX, lowY - ly
|
|
quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 },
|
|
{ x + 1, y + 1, 0 }, { x, y + 1, 0 },
|
|
u = u, v = v, shade = 1 }
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Where the card stands. `wz` is the MIDDLE of the tile row his feet
|
|
-- are drawn in, which is the same convention a character card uses
|
|
-- (its feet plane sits at its cell's middle) -- so he sorts against
|
|
-- the couch and against a player walking past exactly the way an NPC
|
|
-- standing there would.
|
|
S.figures[#S.figures + 1] = {
|
|
quads = quads,
|
|
wx = tx * 8 + minX,
|
|
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
|
|
y = baseY,
|
|
}
|
|
end
|
|
|
|
-- What each covered tile wears now that he is off it. Only the ART
|
|
-- changes: the couch tiles keep their `counter` box (they ARE the
|
|
-- couch) and the floor tiles he overhung stay flat floor -- the
|
|
-- profile just names the version of each drawing without him in it,
|
|
-- so nothing has to be synthesized or repainted from a neighbour vote.
|
|
for i = 1, #fig.tiles do
|
|
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
|
|
S.tileAt[keyOf(tx + dx, ty + dy)] = fig.under[i]
|
|
end
|
|
end
|
|
|
|
-- Every authored figure, wherever the map draws it.
|
|
--
|
|
-- Matched by TILE PATTERN rather than by coordinates: one blockset entry
|
|
-- places this couch once in each of the eleven Pokemon Centers (and the
|
|
-- Celadon Hotel), so the pattern finds all of them without the profile
|
|
-- naming a single map or cell. The repaint above replaces the pattern's
|
|
-- own tiles, so a match can never fire twice on the same drawing.
|
|
function Structures.buildFigures(S, map, x0, x1, y0, y1)
|
|
local figures = TileShape.figures(map.tileset.id)
|
|
if not figures then return end
|
|
local perRow = map.tileset.tilesPerRow or 16
|
|
for _, fig in ipairs(figures) do
|
|
for ty = y0, y1 - fig.h + 1 do
|
|
for tx = x0, x1 - fig.w + 1 do
|
|
Budget.tick()
|
|
local hit = true
|
|
for i = 1, #fig.tiles do
|
|
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
|
|
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= fig.tiles[i] then
|
|
hit = false
|
|
break
|
|
end
|
|
end
|
|
if hit then buildFigure(S, map, fig, tx, ty, perRow) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- mounted: a thing drawn INTO a wall band, stood proud of it ----
|
|
|
|
-- One authored mounted object at one matched position.
|
|
--
|
|
-- Same authoring premise as a figure -- the mask IS the classification,
|
|
-- because a drawing painted onto the wall it hangs on has no background
|
|
-- margin for a flood to enter by, and here the wall's own #555 stripes
|
|
-- are a flood boundary as well, so a silhouette comes back striped.
|
|
-- Like a figure it therefore builds HEADLESS: nothing below reads a
|
|
-- pixel.
|
|
--
|
|
-- But a mounted object is an OBJECT, so it is built the way every other
|
|
-- standee here is -- a per-pixel voxel slab wearing the drawing's own
|
|
-- texels, quads emitted in world space -- and not as a sprite card:
|
|
--
|
|
-- ELEVATION is the drawn one. A figure stands on its own feet; this
|
|
-- keeps the row it is painted in, because the band it is painted into
|
|
-- is a measured 16px face rising off the floor. So drawn row `ly`
|
|
-- becomes world y = (band height - 1) - ly, and a bicycle whose wheels
|
|
-- are drawn on the band's bottom row lands on the floor while one hung
|
|
-- clear of it stays hung.
|
|
-- DEPTH juts SOUTH of the band's own face (z0 at the drawing's south
|
|
-- edge), so the object stands in front of the wall rather than inside
|
|
-- it. It overhangs the walkable cell in front, which is what a bicycle
|
|
-- leaning on a wall does; nothing about collision changes.
|
|
local function buildMountedAt(S, map, m, tx, ty, perRow)
|
|
local bh = m.h * 8
|
|
local z0 = (ty + m.h) * 8
|
|
local z1 = z0 + (m.depth or 2)
|
|
|
|
maskSlab(S.objectQuads, m, perRow, map.tileset.imageWidth or 128,
|
|
map.tileset.imageHeight or 48, tx * 8,
|
|
function(ly) return (bh - 1) - ly end,
|
|
function() return z0, z1 end, 0)
|
|
|
|
-- What the band wears now that the object is off it: the plain panel
|
|
-- the artist drew everywhere else along the same wall. Only the ART
|
|
-- changes -- these tiles keep the `wall` box they always resolved to,
|
|
-- because they ARE the wall.
|
|
for i = 1, #m.tiles do
|
|
local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w)
|
|
S.tileAt[keyOf(tx + dx, ty + dy)] = m.under[i]
|
|
end
|
|
end
|
|
|
|
-- Every authored mounted object, wherever the map draws it. Matched by
|
|
-- TILE PATTERN like a figure, and for the same reason -- one blockset
|
|
-- entry can place the same drawing in several rooms -- and the repaint
|
|
-- above replaces the pattern's own tiles, so a match never fires twice
|
|
-- on one drawing.
|
|
function Structures.buildMounted(S, map, x0, x1, y0, y1)
|
|
local list = TileShape.mounted(map.tileset.id)
|
|
if not list then return end
|
|
local perRow = map.tileset.tilesPerRow or 16
|
|
for _, m in ipairs(list) do
|
|
for ty = y0, y1 - m.h + 1 do
|
|
for tx = x0, x1 - m.w + 1 do
|
|
Budget.tick()
|
|
local hit = true
|
|
for i = 1, #m.tiles do
|
|
local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w)
|
|
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= m.tiles[i] then
|
|
hit = false
|
|
break
|
|
end
|
|
end
|
|
if hit then buildMountedAt(S, map, m, tx, ty, perRow) end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- tall grass ----
|
|
|
|
-- ---- closing a standee's sides ----
|
|
--
|
|
-- The grass tufts and the flowers are both built the same way: each row of
|
|
-- the 8x8 drawing becomes a horizontal RUN of lit pixels, stood up as a
|
|
-- front face and a back face one voxel apart, with a lid on top. What that
|
|
-- leaves open is the two ENDS of every run -- so the slab was a pair of
|
|
-- billboards rather than a solid, and from any angle off square you looked
|
|
-- in through the edge and straight out the other side. At the low cameras
|
|
-- this mod has grown (1ST, 3RD, the battle's floor-level seat) that is
|
|
-- most of the time.
|
|
--
|
|
-- A wall goes on an end only where the pixel beyond it is actually clear,
|
|
-- which for a run's end it is by construction -- except where two runs on
|
|
-- the same row meet across a gap of nothing, which cannot happen, and at
|
|
-- the tile's border, where the neighbouring tile's own standee may or may
|
|
-- not continue the shape. The border is closed anyway: tufts sit on their
|
|
-- own half-cells with a gap between them, so an open border edge is a hole
|
|
-- in the open, not a seam with anything.
|
|
--
|
|
-- Each wall samples ONE texel at its centre -- the end pixel it is closing
|
|
-- off -- so it wears that pixel's own colour, which is the nearest coloured
|
|
-- pixel to the surface being filled. Sampling a single texel is also what
|
|
-- carries the animation: when a frame keys that pixel out, the wall's own
|
|
-- fragments discard with the faces either side of it, so a swaying tuft
|
|
-- never leaves a wall standing where its blade no longer is.
|
|
-- `everyPixel` is for a standee whose silhouette ANIMATES. The mesh is
|
|
-- built once, over the UNION of every frame's mask, and each frame is cut
|
|
-- out again in texture space -- so a run that is six pixels wide in the
|
|
-- union may be two pixels wide in the frame on screen, and the four pixels
|
|
-- that dropped out took the union's end walls with them. What is left
|
|
-- exposed is an interior boundary, which had no wall because in the union
|
|
-- it was not a boundary at all. That is the gap that survived closing the
|
|
-- run ends: the first frame looked solid and every other frame did not.
|
|
--
|
|
-- So an animated standee gets a wall on BOTH sides of EVERY pixel. A wall
|
|
-- between two lit pixels is enclosed by the front and back faces and never
|
|
-- seen; the moment its neighbour is keyed out it becomes the edge, already
|
|
-- in place and already wearing the right colour. Each is inset a hair into
|
|
-- its own pixel so the two that meet at a boundary are not coplanar -- the
|
|
-- voxel pass draws with culling off, and two quads in the same plane would
|
|
-- z-fight rather than politely take turns.
|
|
local SIDE_INSET = 0.03
|
|
|
|
local function sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
|
ax0, ay0, atlasW, atlasH, py, lit, everyPixel)
|
|
local function texel(px)
|
|
return (ax0 + px + 0.5) / atlasW, (ay0 + py + 0.5) / atlasH
|
|
end
|
|
local function left(px, at)
|
|
local u, v = texel(px)
|
|
quads[#quads + 1] = { -- facing -X
|
|
{ at, yBot, zB }, { at, yBot, zF },
|
|
{ at, yTop, zF }, { at, yTop, zB },
|
|
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
|
|
shade = OBJ_SHADE.side,
|
|
}
|
|
end
|
|
local function right(px, at)
|
|
local u, v = texel(px)
|
|
quads[#quads + 1] = { -- facing +X
|
|
{ at, yBot, zF }, { at, yBot, zB },
|
|
{ at, yTop, zB }, { at, yTop, zF },
|
|
uv = { { u, v }, { u, v }, { u, v }, { u, v } },
|
|
shade = OBJ_SHADE.side,
|
|
}
|
|
end
|
|
if everyPixel then
|
|
for px = ix, ix2 do
|
|
left(px, px + SIDE_INSET)
|
|
right(px, px + 1 - SIDE_INSET)
|
|
end
|
|
return
|
|
end
|
|
if not lit(ix - 1, py) then left(ix, ix) end
|
|
if not lit(ix2 + 1, py) then right(ix2, ix2 + 1) end
|
|
end
|
|
|
|
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
|
|
-- whole clump of grass. Each tile stands as its own thin per-pixel slab
|
|
-- at ITS OWN depth -- the cell's north tile row in the north half of the
|
|
-- cell, the south row in the south half -- over the flat grass base the
|
|
-- tile already renders. So the player walks BETWEEN the two rows, and
|
|
-- the southern row occludes their feet the way the 2D grass overdraw
|
|
-- did. Transparency respected: only the tuft strokes stand. Runs of
|
|
-- adjacent pixels merge into single quads, and one template per grass
|
|
-- tile id is stamped across the map (grass comes in fields).
|
|
--
|
|
-- One tile is ONE standing piece, full height. The first cut split each
|
|
-- tile again into its top and bottom four art rows and stood those at
|
|
-- two different depths, which cut every blade that runs down the tile
|
|
-- clean in half -- the two halves ended up 4px tall and 4px apart in
|
|
-- depth, so a clump read as two stubs rather than one tuft.
|
|
local GRASS_THICK = 2
|
|
|
|
local function grassTemplate(map, data, tileId)
|
|
local perRow = map.tileset.tilesPerRow or 16
|
|
local atlasW = map.tileset.imageWidth or 128
|
|
local atlasH = map.tileset.imageHeight or 48
|
|
local ax0 = (tileId % perRow) * 8
|
|
local ay0 = math.floor(tileId / perRow) * 8
|
|
|
|
local function opaque(px, py)
|
|
if px < 0 or px > 7 or py < 0 or py > 7 then return false end
|
|
local r, g, b, a = data:getPixel(ax0 + px, ay0 + py)
|
|
return a > 0 and math.min(r, g, b) <= 0.83
|
|
end
|
|
|
|
local quads = {}
|
|
-- the slab stands across the middle of its own tile, so the two tile
|
|
-- rows of a cell are half a cell apart in depth
|
|
local zMid = 4
|
|
local zB, zF = zMid - GRASS_THICK / 2, zMid + GRASS_THICK / 2
|
|
for iy = 0, 7 do
|
|
local yTop = 8 - iy
|
|
local yBot = yTop - 1
|
|
local ix = 0
|
|
while ix < 8 do
|
|
if opaque(ix, iy) then
|
|
local ix2 = ix
|
|
while ix2 + 1 < 8 and opaque(ix2 + 1, iy) do
|
|
ix2 = ix2 + 1
|
|
end
|
|
local u0 = (ax0 + ix + 0.05) / atlasW
|
|
local u1 = (ax0 + ix2 + 0.95) / atlasW
|
|
local v0 = (ay0 + iy + 0.05) / atlasH
|
|
local v1 = (ay0 + iy + 0.95) / atlasH
|
|
quads[#quads + 1] = { -- front
|
|
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
|
|
{ ix2 + 1, yTop, zF }, { ix, yTop, zF },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = 1,
|
|
}
|
|
quads[#quads + 1] = { -- back
|
|
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
|
|
{ ix, yTop, zB }, { ix2 + 1, yTop, zB },
|
|
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
|
|
shade = 0.68,
|
|
}
|
|
-- blade tips: a top strip where the row above is clear
|
|
if not opaque(ix, iy - 1) then
|
|
quads[#quads + 1] = {
|
|
{ ix, yTop, zB }, { ix2 + 1, yTop, zB },
|
|
{ ix2 + 1, yTop, zF }, { ix, yTop, zF },
|
|
uv = { { u0, v0 }, { u1, v0 }, { u1, v0 }, { u0, v0 } },
|
|
shade = 1,
|
|
}
|
|
end
|
|
-- and underneath, where a blade ends in mid-air over the ground
|
|
if not opaque(ix, iy + 1) then
|
|
quads[#quads + 1] = {
|
|
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
|
|
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v1 }, { u0, v1 } },
|
|
shade = OBJ_SHADE.bottom,
|
|
}
|
|
end
|
|
-- and the run's two end walls, which is what makes a blade a solid
|
|
-- thing rather than two billboards you can see between (sideQuads
|
|
-- above argues it, and why each wall wears its end pixel's colour)
|
|
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
|
ax0, ay0, atlasW, atlasH, iy, opaque)
|
|
ix = ix2 + 1
|
|
else
|
|
ix = ix + 1
|
|
end
|
|
end
|
|
end
|
|
return quads
|
|
end
|
|
|
|
function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
|
|
local templates = {}
|
|
local quads = S.grassQuads
|
|
for ty = y0, y1 do
|
|
for tx = x0, x1 do
|
|
Budget.tick()
|
|
local k = keyOf(tx, ty)
|
|
local s = S.shapeAt[k]
|
|
-- tufts only where the CELL is tall grass by the engine's own rule
|
|
-- (isGrassCell: the cell's collision tile). The grass GRAPHIC also
|
|
-- appears as decorative filler inside ordinary ground blocks, and a
|
|
-- tile-level test sprouted tufts all over town plazas.
|
|
if s and s.art == "grass"
|
|
and map:isGrassCell(math.floor(tx / 2), math.floor(ty / 2)) then
|
|
local tileId = S.tileAt[k]
|
|
local tpl = templates[tileId]
|
|
if not tpl then
|
|
tpl = grassTemplate(map, data, tileId)
|
|
templates[tileId] = tpl
|
|
end
|
|
local wx, wz = tx * 8, ty * 8
|
|
-- Stable diagonal phase per tuft. Both ends of every quad receive
|
|
-- the same value, so a gust bends the slab without shearing it.
|
|
local sway = wx * 0.050 + wz * 0.031
|
|
for _, q in ipairs(tpl) do
|
|
quads[#quads + 1] = {
|
|
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
|
|
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
|
|
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
|
|
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
|
|
uv = q.uv, shade = q.shade, sway = sway,
|
|
cx = wx + 4, cz = wz + 4,
|
|
}
|
|
end
|
|
|
|
-- Sparse wind-borne leaf. It reuses one opaque grass texel and is
|
|
-- animated entirely on the GPU, so no per-frame Lua particles exist.
|
|
if #tpl > 0 and ((tx * 13 + ty * 7) % 11 == 0) then
|
|
local src = tpl[1]
|
|
local uv = src.uv and src.uv[1] or { src.u, src.v }
|
|
local lx = wx + 2 + ((tx * 5 + ty * 3) % 5)
|
|
local lz = wz + 4
|
|
local ly, size = 9 + ((tx + ty) % 3), 1.25
|
|
quads[#quads + 1] = {
|
|
{ lx - size, ly, lz }, { lx + size, ly, lz },
|
|
{ lx + size, ly + size, lz }, { lx - size, ly + size, lz },
|
|
uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 0.73,
|
|
cx = lx, cz = lz, leaf = true,
|
|
}
|
|
end
|
|
|
|
-- Rarer one-pixel firefly. Geometry exists all day, but the shader
|
|
-- gives it zero glow outside outdoor night.
|
|
if #tpl > 0 and ((tx * 17 + ty * 11) % 29 == 0) then
|
|
local src = tpl[1]
|
|
local uv = src.uv and src.uv[1] or { src.u, src.v }
|
|
local fx = wx + 2 + ((tx * 3 + ty * 5) % 5)
|
|
local fz = wz + 4
|
|
local fy, half = 9 + ((tx + ty) % 4), 0.5
|
|
quads[#quads + 1] = {
|
|
{ fx - half, fy, fz }, { fx + half, fy, fz },
|
|
{ fx + half, fy + 1, fz }, { fx - half, fy + 1, fz },
|
|
uv = { uv, uv, uv, uv }, shade = 1, sway = sway + 1.37,
|
|
cx = fx, cz = fz, firefly = true,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- ---- flowers ----
|
|
|
|
-- The animated flower tile stands up as a billboard ONE VOXEL deep, cut
|
|
-- to the drawing's darkest tones PLUS everything they enclose -- the
|
|
-- round-scenery hull's rule: flood the tile border through every
|
|
-- non-dark pixel, and what the flood cannot reach is the flower, its
|
|
-- pale petal insides included. The mesh is static and the flower is
|
|
-- not, so the geometry spans the UNION of that mask over the base art
|
|
-- and every animation frame, and TerrainAtlas rewrites the tile's slot
|
|
-- each step with only the CURRENT frame's mask opaque -- the rest keyed
|
|
-- to alpha, which the voxel shader discards. The standing silhouette
|
|
-- trims itself frame by frame in texture space; the sway animates
|
|
-- without a vertex moving, off the same engine clock as the flat path.
|
|
--
|
|
-- The ground beneath is synthesized from the commonest flat neighbour,
|
|
-- like the ground under a detected prop: the tile's own slot no longer
|
|
-- holds art anyone can draw flat.
|
|
local FLOWER_THICK = 1
|
|
|
|
local function flowerFrames(tileset, tileId)
|
|
local out = {}
|
|
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 not ok then return out end
|
|
for _, spec in ipairs(type(declared) == "table" and declared or {}) do
|
|
if spec.kind == "frames" and spec.tile == tileId then
|
|
for _, path in pairs(spec.images or {}) do
|
|
local okF, frame = pcall(Assets.imageData, path)
|
|
if okF and frame then out[#out + 1] = frame end
|
|
end
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function flowerTemplate(map, data, tileId)
|
|
local tileset = map.tileset
|
|
local perRow = tileset.tilesPerRow or 16
|
|
local atlasW = tileset.imageWidth or 128
|
|
local atlasH = tileset.imageHeight or 48
|
|
local ax0 = (tileId % perRow) * 8
|
|
local ay0 = math.floor(tileId / perRow) * 8
|
|
|
|
-- per image: dark tones, then the border flood that finds what they
|
|
-- enclose. Each image closes over ITS OWN outline before the union --
|
|
-- a pocket two frames only enclose together is not part of either.
|
|
local dark = {}
|
|
local function markMask(img, ox, oy)
|
|
local d, reach, stack = {}, {}, {}
|
|
for py = 0, 7 do
|
|
for px = 0, 7 do
|
|
local r, g, b, a = img:getPixel(ox + px, oy + py)
|
|
if a > 0 and math.min(r, g, b) <= 0.5 then
|
|
d[py * 8 + px] = true
|
|
end
|
|
end
|
|
end
|
|
for i = 0, 7 do
|
|
for _, s in ipairs({ i, 56 + i, i * 8, i * 8 + 7 }) do
|
|
if not d[s] and not reach[s] then
|
|
reach[s] = true
|
|
stack[#stack + 1] = s
|
|
end
|
|
end
|
|
end
|
|
while #stack > 0 do
|
|
local p = table.remove(stack)
|
|
local px, py = p % 8, math.floor(p / 8)
|
|
for _, dir in ipairs(DIRS4) do
|
|
local nx, ny = px + dir[1], py + dir[2]
|
|
if nx >= 0 and nx < 8 and ny >= 0 and ny < 8 then
|
|
local ni = ny * 8 + nx
|
|
if not d[ni] and not reach[ni] then
|
|
reach[ni] = true
|
|
stack[#stack + 1] = ni
|
|
end
|
|
end
|
|
end
|
|
end
|
|
for i = 0, 63 do
|
|
if d[i] or not reach[i] then dark[i] = true end
|
|
end
|
|
end
|
|
markMask(data, ax0, ay0)
|
|
for _, frame in ipairs(flowerFrames(tileset, tileId)) do
|
|
pcall(markMask, frame, 0, 0)
|
|
end
|
|
|
|
local function on(px, py)
|
|
if px < 0 or px > 7 or py < 0 or py > 7 then return false end
|
|
return dark[py * 8 + px] == true
|
|
end
|
|
|
|
local quads = {}
|
|
local zB = 4 - FLOWER_THICK / 2 -- one slab at the tile's middle
|
|
local zF = zB + FLOWER_THICK
|
|
for py = 0, 7 do
|
|
Budget.tick()
|
|
local yTop, yBot = 8 - py, 7 - py
|
|
local ix = 0
|
|
while ix < 8 do
|
|
if on(ix, py) then
|
|
local ix2 = ix
|
|
while ix2 + 1 < 8 and on(ix2 + 1, py) do ix2 = ix2 + 1 end
|
|
local u0 = (ax0 + ix + 0.05) / atlasW
|
|
local u1 = (ax0 + ix2 + 0.95) / atlasW
|
|
local v0 = (ay0 + py + 0.05) / atlasH
|
|
local v1 = (ay0 + py + 0.95) / atlasH
|
|
quads[#quads + 1] = { -- front
|
|
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
|
|
{ ix2 + 1, yTop, zF }, { ix, yTop, zF },
|
|
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
|
|
shade = OBJ_SHADE.front,
|
|
}
|
|
quads[#quads + 1] = { -- back
|
|
{ ix2 + 1, yBot, zB }, { ix, yBot, zB },
|
|
{ ix, yTop, zB }, { ix2 + 1, yTop, zB },
|
|
uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } },
|
|
shade = OBJ_SHADE.back,
|
|
}
|
|
-- ------- the shell, closed on all four remaining faces
|
|
--
|
|
-- A flower SWAYS: the geometry spans the union of every animation
|
|
-- frame's mask and each frame is cut back out of it in texture
|
|
-- space (see the header). So "is there a pixel next door" has two
|
|
-- different answers -- one in the union this mesh was built from,
|
|
-- and one in the frame actually on screen -- and only the second
|
|
-- decides what is exposed.
|
|
--
|
|
-- Closing the union's own edges is therefore not enough, and was
|
|
-- the bug the first cut of this shipped: the base frame looked
|
|
-- solid and every other frame still had gaps, because a pixel that
|
|
-- drops out of a frame takes the union's wall with it and leaves an
|
|
-- interior boundary that never had one.
|
|
--
|
|
-- So every pixel gets a cap on all four of its remaining faces,
|
|
-- whatever its neighbours do. A cap between two lit pixels sits
|
|
-- inside the slab, enclosed by the front and back faces, and is
|
|
-- never seen; the moment its neighbour is keyed out it IS the edge,
|
|
-- already there and already wearing the right colour. Each samples
|
|
-- its own pixel's texel, so it appears and vanishes with the pixel
|
|
-- it belongs to rather than with the one it is closing off.
|
|
--
|
|
-- Inset a hair into its own pixel, because the voxel pass draws
|
|
-- with culling off: the two caps that meet at a boundary would be
|
|
-- coplanar and z-fight rather than politely take turns.
|
|
for px = ix, ix2 do
|
|
local tu = (ax0 + px + 0.5) / atlasW
|
|
local tv = (ay0 + py + 0.5) / atlasH
|
|
local xa, xb = px, px + 1
|
|
local yT = yTop - SIDE_INSET
|
|
local yB = yBot + SIDE_INSET
|
|
quads[#quads + 1] = { -- the pixel's own lid
|
|
{ xa, yT, zB }, { xb, yT, zB }, { xb, yT, zF }, { xa, yT, zF },
|
|
uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
|
|
shade = OBJ_SHADE.top,
|
|
}
|
|
quads[#quads + 1] = { -- and its floor
|
|
{ xa, yB, zF }, { xb, yB, zF }, { xb, yB, zB }, { xa, yB, zB },
|
|
uv = { { tu, tv }, { tu, tv }, { tu, tv }, { tu, tv } },
|
|
shade = OBJ_SHADE.bottom,
|
|
}
|
|
end
|
|
sideQuads(quads, ix, ix2, yBot, yTop, zB, zF,
|
|
ax0, ay0, atlasW, atlasH, py, on, true)
|
|
ix = ix2 + 1
|
|
else
|
|
ix = ix + 1
|
|
end
|
|
end
|
|
end
|
|
return quads
|
|
end
|
|
|
|
function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
|
|
local templates = {}
|
|
-- flowerQuads, not objectQuads: flowers sit on WALKABLE cells, so
|
|
-- their mesh draws after the characters with the character pull
|
|
-- (ChunkMesher's flower mesh) -- terrain-baked they lose the depth
|
|
-- fight against the pulled card whenever the player stands among them
|
|
local quads = S.flowerQuads
|
|
for ty = y0, y1 do
|
|
for tx = x0, x1 do
|
|
Budget.tick()
|
|
local k = keyOf(tx, ty)
|
|
local s = S.shapeAt[k]
|
|
if s and s.art == "flower" then
|
|
-- the tile's atlas slot carries only the standing cutout now, so
|
|
-- EVERY flower position -- ring included -- paints synthesized
|
|
-- ground instead of its own art: the commonest flat neighbour
|
|
-- that is not itself a flower, else the map's commonest ground
|
|
-- (forMap's end-of-build vote resolves the `false`)
|
|
S.skip[k] = true
|
|
local votes, best, bestN = {}, nil, 0
|
|
for _, d in ipairs(DIRS4) do
|
|
local nk = keyOf(tx + d[1], ty + d[2])
|
|
local ns = S.shapeAt[nk]
|
|
if ns and ns.flat and ns.class ~= "void"
|
|
and ns.class ~= "flower" then
|
|
local t = S.tileAt[nk]
|
|
votes[t] = (votes[t] or 0) + 1
|
|
if votes[t] > bestN then best, bestN = t, votes[t] end
|
|
end
|
|
end
|
|
S.ground[k] = best or false
|
|
|
|
-- standee BODY only, like grass: standing scenery past a map's
|
|
-- edge would poke into the map next door
|
|
if tx >= 0 and ty >= 0 and tx < tw and ty < th then
|
|
local tileId = S.tileAt[k]
|
|
local tpl = templates[tileId]
|
|
if not tpl then
|
|
tpl = flowerTemplate(map, data, tileId)
|
|
templates[tileId] = tpl
|
|
end
|
|
local wx, wz = tx * 8, ty * 8
|
|
for _, q in ipairs(tpl) do
|
|
quads[#quads + 1] = {
|
|
{ q[1][1] + wx, q[1][2], q[1][3] + wz },
|
|
{ q[2][1] + wx, q[2][2], q[2][3] + wz },
|
|
{ q[3][1] + wx, q[3][2], q[3][3] + wz },
|
|
{ q[4][1] + wx, q[4][2], q[4][3] + wz },
|
|
uv = q.uv, shade = q.shade,
|
|
}
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Drop one map's analysis (Cut changed the block layer) or everything.
|
|
-- Hull templates key on art content (tileset + tiles), which a block edit
|
|
-- cannot change, so only the full drop clears them (atlas reload).
|
|
function Structures.invalidate(mapId)
|
|
if mapId then
|
|
cache[mapId] = nil
|
|
else
|
|
cache = {}
|
|
atlasData = {}
|
|
roundCache = {}
|
|
Buildings.invalidate()
|
|
end
|
|
end
|
|
|
|
Assets.register(function() Structures.invalidate() end)
|
|
|
|
return Structures
|