-- 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