Merge pull request #1 from DramaticShape/20260726_various_fixes

20260726 various fixes
This commit is contained in:
DramaticShape
2026-07-27 04:58:07 -04:00
committed by GitHub
14 changed files with 3616 additions and 180 deletions
+369
View File
@@ -1,5 +1,374 @@
# Changelog
## 1.0.6
### Added
- **Conditional pins.** A profile entry may now carry `when_above`:
tile id -> rules keyed on the tile drawn directly north of it,
resolved per POSITION in `TileShape.at`. A pin is per tile id and one
graphic can mean two things -- the route gates' `$32` is both the
wall's dark base course and every service counter's front, and it is
the bottom row of its cell either way. Pinned `wall` the counters
stood a full 16px; pinned `counter` the deep wall banks corrugated
16/8 for sixteen rows and the room read as crates. What separates the
two uses is what sits on top, so that is what the rule reads. The
gates now have half-height counters AND level walls.
- **The Pokemon Tower has an exterior.** It is the one catalogued
building drawing the map edge cuts off (no roof band is on the map at
all), and it had no `buildings` entry, so it fell to the volume path
-- which tops a run by repeating its first two rows, laying window
courses flat across the plateau. Sealing the silhouette on the north
alone closes it (88% fill, one piece, against 37% and 126 pieces
unsealed), and one row of roof band spent on the drawing's top margin
costs no window course. It stands as a real tower, panes recessed,
door on the ground.
- **The Indigo Plateau statues stand up**, built exactly like the gym
statues: plinth a solid 16px block, figure a per-pixel cutout riding
it. On the avenue the statues stack with no gap, so the flood joined
six of them into one 24-row region and the volume builder raised
ridges of boxes with the statue art folded on the front. The same
bird is drawn at the foot of every badge-check pillar, so those are
crowned too.
### Fixed
- **Tall grass: one clump per tile.** Each 8x8 tile is a whole clump,
but the template split every tile AGAIN into its top and bottom four
art rows and stood those at two different depths -- so any blade
running down a tile was cut in half, into two 4px stubs 4px apart.
One tile is now one full-height standing slab at its own depth; a
cell's 2x2 tiles still stand independently, so the player walks
between the north and south rows.
- **Ledge lines are continuous.** `$34` is the cliff slope's foot and
also the pillar between hop-down segments; pinned `wall` with the
rest of the slope chain (the Diglett's Cave fix) it stood those
pillars 16px beside a 6px lip. At ledge height the run reads as one
lip, and the mound is unchanged -- its foot row reads as the talus it
is drawn as.
- **A prop only stands on furniture when its own cell is blocked.**
"Is something drawn above me" is not "am I standing on it": a chair
drawn against the north side of a table is above the table's trim row
too, and was being lifted onto the tabletop, with its claimed cells
re-tiled as tabletop so the table marched two rows north. Three
chairs in Cinnabar's trade room and Fuchsia's meeting room, and the
Celadon diner's stools. The world already knows the difference: a
thing that sits ON furniture occupies a blocked cell, a seat you walk
up to is in a walkable one.
- **Caves: nothing below sea level, and the water is water.** Two tiles
were identified backwards in the first pass. `$14` -- the tile the
engine animates, that `Map.WATER_TILES` names and that Surf runs on
-- was pinned `wall`, so Cerulean Cave's lake and the Seafoam sea
stood up as rock slabs. And the pale dithered rock fill was pinned
`water`, cutting 612 tiles of two-cell-wide trench through four maps.
Both corrected; a sweep of all 19 cave maps now reports zero tiles
below the datum. The elevation scheme is documented in the entry and
derived from the game's own `tilePairs`: dark floor, water and drop
holes at 0, the lit shelf a 6px step above, rock at 16.
- **Cave ladders climb.** Which ladder graphic goes up and which goes
down is unanimous in the warp table -- 37 cells of one always warp
down, 40 of the other always up -- so they are real stepped flights
now, not painted plates.
- **Poke Mart's register stands on the counter.** The pin was on the
wrong tile: `$08` is the counter's own top band, not the register, so
the standee flood ate everything but two black lines. The register is
the keypad-and-receipt drawing one row up.
- **Celadon's televisions**, which were solid 16px boxes wearing the TV
art on one face, and the **Pokemon Tower reception desk** and the
**gate counters**, which stood at wall height, are all their drawn
heights now. The **Fan Club and Silph boardroom statues** were read
as seated chairmen and painted onto the tabletop; they are cutouts
standing on their pedestals, and the tables are cut to a true
octagonal footprint.
## 1.0.5
### Added
- **Every remaining interior is furnished.** The profile covered ten
tilesets; it now covers twenty-three -- 1,190 pinned tiles across
`GATE`, `FOREST_GATE`, `LOBBY`, `MUSEUM`, `LAB`, `MANSION`,
`INTERIOR`, `CLUB`, `SHIP`, `SHIP_PORT`, `FACILITY`, `CEMETERY` and
`UNDERGROUND`, plus full entries for `CAVERN` and `GYM` which had only
stubs. Roughly 130 maps, surveyed against the standard the finished
interiors already set: one 16px wall band carrying whatever is drawn
built into it, half-cell counters so the drawn front folds up and the
top stays on top, `bookcase` collapse for free-standing shelves,
thin-pool standees for plants, and small objects riding the furniture
they are drawn above.
What the detector was doing before, by way of what changed:
- **Rooms with no walls.** Three tile ids (`$14`, `$32`, `$48`) are
claimed by the engine's water set in EVERY tileset, and collision is
per CELL, so one of them in a cell's bottom-left corner sank the
whole cell. `$32` draws both the route gates' wall base course and
every counter front, so all 25 gate maps were a checkered floor in a
moat; the same trap put ponds through two thirds of Seafoam B4F,
under every museum vitrine, along the S.S. Anne's wall corners and
across Silph Co 1F's lobby island. The set is wider than three ids
in practice -- `LAB`, `MANSION` and `INTERIOR` each hit six to nine
-- because the test is per cell, so an innocent tile sharing a cell
with a trapped one sinks with it and has to be pinned too.
- **Towers and fused monoliths.** Counters raised to 48px dragging
their wall band with them, merchandise racks fused sideways into
32px blocks four tiles deep, cave shelf edges standing as 48px fins
beside a 16px band, the Vermilion liner folded upright into a lumpy
48px slab.
- **Furniture that was not there at all.** Anything whose cell is
walkable resolved to flat ground: 59 department-store stools, every
gate lounge table and pair of binoculars, both museum staircases,
the gym-lounge chairs, and -- via the void rule -- the black
partition walls every gym is divided by, which left their white rim
columns standing as hollow 48px fins.
- **314 gravestones** in Pokemon Tower were 8px stubs, because the
volume path measured only their bottom row and dropped the arch.
Notable readings: the S.S. Anne's hull is `roof` (a drawing seen from
above, so the art belongs on the top face); the Warden's specimens and
Celadon Gym's shrubs are `cylinder` voxel balls, the first indoor use
of that class; the Fan Club's octagonal boardroom table is `counter`
rather than `table`, because only a counter rides its upper rows onto
the top face in drawn order -- which is what draws the seated chairman
exactly once, the Pokemon Center couch case verbatim.
Fuchsia Gym's invisible maze is deliberately left flat. Raising it
would read better as a room, and would also hand the player the
solution; a shape is purely presentational, so the drawn answer wins
and the gym plays as the flat game does.
### Fixed
- **A profile pin now outranks the door fold.** `Structures.forMap`
folds a door cell into its facade so the doorway does not punch a hole
in the wall -- but it overwrote the resolved shape unconditionally,
including for AUTHORED tiles, which contradicts rule 1 of the
documented resolution order. Any pin on a tile its tileset also lists
in `doorTiles` was dead on arrival: all four Celadon Mansion
staircases and Pokemon Mansion 3F's descent are door tiles, so
`stair_*` pins there silently did nothing and the flights stayed
painted flat on the floor. The fold now skips authored tiles.
## 1.0.4
### Added
- **Viridian Forest grows real trees.** Nearly everything drawn in the
forest is ROUND, and the detector was boxing all of it: the big trees
came out as ragged mixed-height volumes (their sparse canopy-rim
tiles read 0px against 32px bodies, leaving gap-toothed hedge walls),
the stump rows merged into 16px crate walls wearing folded stump art,
and the trail signs were broken piles -- their $32 tile is the
water-fallback trap and recessed into a pond lip in the middle of the
woods. All of it is now profile-pinned to the treatments the rest of
the world already uses: every tile of the tree drawing (ball, rim
wisps, feet) and the stumps take the per-cell voxel HULL the overworld
border forest wears -- a tree spans 2x2 cells, so its four
quarter-hulls tile into one big lumpy canopy, and each stump becomes a
round bollard; the signs take the standing thin-slab `signpost`
treatment every town sign gets; and the white sparkle filler inside
the tree masses is flat ground instead of an invisible zero-height
box. The whole map now resolves to hulls, signs, ledges and ground --
a detector sweep finds no stray boxed column anywhere.
Two refinements over the first cut, both new hull-builder abilities.
A tree's drawing spans 2x2 CELLS, and per-cell hulls unfolded it onto
the ground -- the ball's top half sat one cell north of its bottom
half at the same elevation, reading as a tree cut in half. The new
`canopy` class pins the drawing's corner tile as a group anchor and
the whole 2x2-cell drawing carves as ONE 32px hull, so every tree is
a single tall round canopy (the carver is now parametric over its
canvas size, and hull stamps carry their footprint radius). And the
stumps' drawn tops are a CUT FACE -- an ellipse of growth rings seen
at an angle, not body: the new `stump` class builds the hull from the
bark rows alone and projects the ellipse across the round flat top,
near arc to the south (`stump_cap` names the ellipse's drawn height),
so the rings ride the round part in perspective.
- **Flowers stand up, and keep swaying.** The animated meadow tile
($03, the one tile the overworld animates by frame rewrite) now
renders as a billboard one voxel deep: the drawing's darkest tones
plus everything they enclose are cut out per pixel, and the ground
beneath is synthesized from the commonest flat neighbour, exactly
like the ground under a detected prop.
The interesting part is that the cutout still animates. A mesh is
static, so the geometry spans the UNION of the mask over the base art
and all three animation frames, and the animation lives entirely in
the texture: TerrainAtlas already rewrites the flower's slot in the
private animated atlas each step, and for this tile it now writes
only the current frame's mask opaque with everything else keyed to
alpha 0 -- which the voxel shader discards, and the shadow pass with
it. The standing silhouette trims itself frame by frame in texture
space, off the same engine clock as the flat path, without a vertex
moving. The class is derived, not authored: any frames-animated tile
resolves to the new `flower` class with no profile entry, the same
way tall grass derives from `grassTile` (hand-authoring still wins).
- **The cuttable bush is a standing cutout.** The four tiles Cut
deletes ($2D/$2E/$3D/$3E -- across the whole tileset they appear only
in the five cut-tree blocks) are pinned to the thin `prop` pool: a
per-pixel standee 5 voxels deep, black-outline segmented with its
enclosed pixels kept, the drawn grass dither flooding away. It
stands on plain grass ($2C) -- the very tile Cut leaves behind per
field.cutTreeSwaps -- via the profile's new `prop_ground` key, which
names the tile painted under a pinned prop instead of whatever flat
tile its neighbours vote in.
- **Gym statues: a solid plinth, a standing bird.** The statue pair
flanking every badge gym's aisle (and Bruno's room) is one cell of
figure over one cell of plinth. The plinth ($22/$23/$32/$33) is
pinned `wall`: a solid 16px block. The figure ($02/$38/$12/$13) is
pinned `prop`: a 5-voxel cutout that stands ON the plinth through the
authored-box support rule, its checkered background flooded away and
the pixels its outline encloses kept.
The whole statue keeps ONE cell of footprint. The support rule used
to extend the box under the claimed cell (the monitor-on-desk path),
which marched the plinth a second block backwards; a figure whose
support is a FULL-HEIGHT block now collapses instead -- the drawn
figure cell becomes synthesized floor, since the block below already
carries the whole base. Furniture supports keep the extension: their
drawn cell is the furniture's own upper rows, and floor there would
amputate the desk. The round boulder drawn beside some statues is
deliberately NOT pinned -- it also tiles wall-to-wall as Pewter's
rock rows, which are scenery for the detector.
- **Lt. Surge's trash cans stand up.** The can ($0B/$0C/$1B/$1C, the
lone graphic of blocks 38/39) takes the same treatment as the
cuttable bush: a 5-voxel `prop` cutout, black-outline segmented with
its enclosed pixels kept, standing on the gyms' main floor tile
($11) via `prop_ground`.
- **The Poke Marts furnished to the Center's standard.** The MART
tileset shares the Center's atlas image but is its own id, so none
of the Center's pins applied, and every mart was raw detector
output: a 32px double-height display band for a back wall, the two
shelf racks fused into one four-tile-deep monolith, and the clerk's
booth towered into a 48px slab wearing the juice poster. Pinned the
way the finished interiors are -- the back wall's SALE cases and
drink fridges one 16px face like the Center's healing consoles, the
racks collapsed to one-cell-deep shelves at drawn height like Red's
bookcases, the counter half a cell with the poster riding its top
like the nurse's tray, and the cash register standing ON the counter
through the authored-box support rule. One 4x4 layout serves every
city, so this covers all eight marts.
### Fixed
- **Cut trees now vanish in voxel mode -- and grow back.** The
engine's Cut path swapped the block with a raw `setBlock` + renderer
rebuild, never emitting `world.block_replaced` -- so this mod's
listener (which rebuilds the map's mesh exactly for this) never
heard about it, and the diorama kept showing the tree. The engine
now routes Cut through `replaceBlock`
(src/world/OverworldController.lua), whose whole purpose -- per its
own comment, "Victory Road barriers, Cut trees" -- is that same swap
plus the event. The regrowth path had the same hole one door away:
cut trees are restored block by block when the map is re-entered,
and the card-key doors are stamped closed on floor load, both
through the same silent `setBlock` -- with the mesh cache staying
warm across a round trip (that is what prevLive is for), the world
kept showing the stump you left. Both paths now announce each block.
- **A block edit no longer blinks the world down to 2D.** The
listener used to drop the edited map's mesh outright, and mesh
builds are asynchronous -- so cutting a tree (or stepping out of a
door onto a map whose trees just regrew) flashed the flat 2D world
for the frames the rebuild took. `ChunkMesher.refresh` rebuilds in
place instead: the stale mesh keeps drawing, the replacement cooks
in the background, and each slot swaps as its build lands -- the
tree pops out (or back in) with the scene never leaving 3D.
- **Flowers cull the player correctly from every angle.** The flower
billboards were baked into the terrain mesh, which draws without
the characters' camera-ward pull -- so a walker standing among
flowers won the depth test against ALL of them, including the
flower south of their feet that should overdraw them. The flower
quads now ride their own mesh, drawn after the characters with
exactly the characters' pull (the tall-grass trick): the flower in
front of a walker occludes their feet, the one behind them hides,
at every camera angle. Unlike grass the flower mesh still casts
shadows -- it is a handful of cutouts per meadow, not thousands of
tufts.
- The `voxel_anim_probe` driver crashed on engine builds without the
optional `TileRenderer.animFrame` seam, and again on tilesets whose
animation list carries a "toggle" entry (spinner rooms), which claims
a tile LIST rather than one slot. It now reads the clock through the
mod's own fallback chain and skips toggle entries in the placement
census.
- **The shoreline no longer opens into the sky beside buildings and
signs.** Water recesses 2px below the ground, and the ground tile beside
it closes the step with a small below-ground side band -- but a tile
CLAIMED by a standing object (a building footprint, a sign standee, the
bushes ringing Fuchsia's ponds) only painted its synthesized flat ground
and never emitted sides. Along every stretch where such a tile met
water, the two-pixel step was an open slit straight through to the sky
behind the mesh. The skip branch now emits the same below-ground bands
ordinary ground does, cut from the synthesized ground's own art, so the
shoreline lip is continuous whatever stands on the bank.
- **Edge-row buildings keep their facades.** The south wall of Saffron's
row houses -- profiled buildings whose front row is the map's last tile
row -- lies exactly on the boundary plane shared with Route 6, and the
prebuilt-quad keep rules dropped it: the strict body test excludes the
plane, and the closed neighbour mask (which exists to kill ring scraps
whose rects sit exactly on that line) swallowed what was left, so from
Route 6 the houses stood hollow. The two cases are geometrically
identical degenerate rects, but they FACE opposite ways: a face pointing
away from the body is this map's own facade and nothing in the
neighbour will ever draw that plane, while a face pointing into the
body is the scrap the mask is for. The mesher now reads the winding and
keeps outward faces on the body's boundary planes, on all four edges.
The roof RIM had the same problem one step further out: an edge-row
house's eave overhangs `frontEave` voxels PAST the boundary plane into
the neighbour's airspace, and those quads are neither on the plane
(the winding rescue) nor over the body -- the neighbour-body mask ate
them as ring scraps, so from across the seam the roof edge was open
sky at low camera angles. Building placements only ever scan the map
BODY, so every building quad is this map's own structure by
construction: they now carry an `own` flag the edge keep-rules never
touch.
- **Diglett's Cave mounds (and cliffs everywhere) stop sprouting
towers.** Two detector misreadings stacked up on the cave-entrance
mound. The dark east slope of the cliff drawing ($02/$24/$34) is one
texture repeated over the mound's whole height, but its corner tiles
break the repeat scan, so those columns rose to 32px -- the rock
pillar beside the entrance. And a folded doorway column reads its own
drawn extent (the door plus everything above it), which is a house's
real height when the door is a house's, but a 32px tower over a 16px
plateau when the door is a cave mouth -- the entrance jumped a block
above the mound around it. The slope chain is now profile-pinned to
one 16px course, and a doorway column answers to its REGION entirely:
height from the region's dominant column, top flat when those columns
are flat repeats (the mound) and roofed when they are drawn facades
(a house). Both cave entrances -- and every cliff built from the same
slope tiles -- now read as one level mesa with the cave mouth at
ground level. A new `voxel_mound_probe` driver prints the detector's
per-column class and height over any rectangle, which is how this was
diagnosed.
Routes 3 and 4 had a third variant of the same misreading: the repeat
scan anchors at a column's FRONT tile, and a plateau column that ends
in a one-off rounded corner tile ($13/$35) never matched -- it read
its whole capped extent and shot up as a 48px fin (several together
made a tent). When the two rows directly above the front are
identical, the column is now read as that repeat wearing a trim foot:
its unit is one course plus the trim. Doorway columns still answer to
their region first, so houses are untouched.
## 1.0.3
### Added
+1996 -22
View File
File diff suppressed because it is too large Load Diff
+63 -6
View File
@@ -98,9 +98,24 @@ local models = {} -- "<tileset>:<index>" -> prebuilt local quads
-- Composite the template out of the atlas and flood the silhouette in from
-- the border. Returns flat arrays indexed y * W + x.
--
-- `topRows`, when a template carries it, is extra drawing rows composited
-- ABOVE the matched grid: rows of the same drawing that are not on the
-- map this template places on. The Pokemon Tower is the case that needs
-- it -- the drawing straddles the LAVENDER_TOWN / ROUTE_10 boundary, its
-- roof band and top window courses standing in the route's last rows, so
-- no single map's grid holds the whole building. The matcher never sees
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
-- is built from the complete drawing and the tower rises to its real
-- height instead of folding as two half-buildings.
local function read(t, data, perRow)
local tiles = t.tiles
local bh, bw = #tiles, #tiles[1]
if t.topRows then
tiles = {}
for _, row in ipairs(t.topRows) do tiles[#tiles + 1] = row end
for _, row in ipairs(t.tiles) do tiles[#tiles + 1] = row end
end
local bh, bw = #tiles, #t.tiles[1]
local W, H = bw * 8, bh * 8
local col, ax, ay = {}, {}, {}
for sy = 0, H - 1 do
@@ -258,7 +273,13 @@ local function measure(sp, t)
shadeTexel[s] = shadeTexel[s] or shadeTexel[BLACK] or 0
end
return { top = top, ytop = ytop, D = H,
-- Depth is the MATCHED footprint, not the sprite height. The two are
-- the same number for every whole-drawing template (the sprite is
-- built from `tiles` alone), but a template with `topRows` has a
-- sprite taller than its footprint -- the tower's 16-row drawing
-- stands on the 8 rows of it that are actually on the map, and D = H
-- would have pushed its body 64px south into the town plaza.
return { top = top, ytop = ytop, D = #t.tiles * 8,
recess = recess, interior = interior, shadeTexel = shadeTexel }
end
@@ -606,13 +627,42 @@ function Buildings.build(S, map, data, perRow)
for ty = 0, th - bh do
Budget.tick()
for tx = 0, tw - bw do
if S.tileAt[keyOf(tx, ty)] == first and matches(S, t, tx, ty) then
-- A placement never stamps into cells another template already
-- claimed. Templates are matched independently, and one
-- drawing can satisfy two grids: the Pokemon Tower's upper
-- twelve rows on ROUTE_10 are a standard 6-cell block tile for
-- tile, so `gabled_block_6x6` matched there and stood a whole
-- second building behind the tower. First claim wins, so the
-- list order below is the priority order -- the tower's own
-- templates come first precisely so they take those cells.
local free = S.tileAt[keyOf(tx, ty)] == first
if free then
for r = 0, bh - 1 do
for c = 0, bw - 1 do
if S.skip[keyOf(tx + c, ty + r)] then
free = false
break
end
end
if not free then break end
end
end
if free and matches(S, t, tx, ty) then
if not built then
local key = tileset.id .. ":" .. index
if not models[key] then
local sp = read(t, data, perRow)
local pr = measure(sp, t)
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
if t.claimOnly then
-- claim the cells, stamp nothing: the drawing here is
-- the off-map half of a building another map models in
-- full (the tower's roof rows on ROUTE_10 -- Lavender's
-- placement composites them via topRows). Left to the
-- detector they stood as a second half-building.
models[key] = {}
else
local sp = read(t, data, perRow)
local pr = measure(sp, t)
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
end
end
built = models[key]
end
@@ -669,6 +719,13 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh)
{ q[3][1] + mx, q[3][2], q[3][3] + mz },
{ q[4][1] + mx, q[4][2], q[4][3] + mz },
uv = q.uv, shade = q.shade,
-- placements only ever scan the BODY, so a building is always this
-- map's own structure: the mesher's edge keep-rules must not eat
-- the parts that poke past the boundary (an edge-row house's eave
-- juts frontEave voxels into the neighbour's airspace, and the
-- neighbour-body mask read that overhang as a ring scrap -- which
-- opened the roof rim into the sky from across the seam)
own = true,
}
end
end
+178 -29
View File
@@ -430,7 +430,36 @@ local function runGeometry(map, bodyOnly, masks, sink)
if s and S.skip[k] then
-- an object stands here; paint its synthesized ground and let the
-- prebuilt prism quads (appended below) carry the art
if S.ground[k] then topQuad(tx * 8, ty * 8, 0, S.ground[k], 1) end
local g = S.ground[k]
if g then
topQuad(tx * 8, ty * 8, 0, g, 1)
-- the claimed tile is still ground at height 0, and water next
-- door still recesses below it: without the same below-ground
-- side bands ordinary ground emits, the two-pixel shoreline
-- face is a slit into the sky behind the mesh -- which is
-- exactly what a building plot or a sign standing at the
-- waterline showed. Same bands, cut from the synthesized
-- ground's own art
for _, side in ipairs(SIDES) do
local nh = heightAt(tx + side[1], ty + side[2])
if nh < 0 then
local d = side[3]
local lat = LATERAL[d]
local hl = lat and heightAt(tx + lat[1], ty + lat[2]) or 0
local hr = lat and heightAt(tx + lat[3], ty + lat[4]) or 0
for band = math.floor(nh / 8), -1 do
local y0 = math.max(nh, band * 8)
local y1 = math.min(0, band * 8 + 8)
if y1 > y0 then
sideQuad(d, tx * 8, ty * 8, y0, y1, g,
(band * 8 + 8) - y1, (band * 8 + 8) - y0,
sideShades(hl, hr, y0, y1, y0 <= nh,
Voxel3D.FACE_SHADE[d]))
end
end
end
end
end
elseif s then
local run = S.runs[k]
local h = run and run.h or s.h
@@ -610,6 +639,31 @@ local function runGeometry(map, bodyOnly, masks, sink)
return overBody or not maskedClosed(x0, z0, x1, z1)
end
-- A face lying EXACTLY on a body boundary plane is ambiguous to the
-- rect tests above: a body structure's outward facade (a Saffron row
-- house whose front row is the map's last row, its south wall on the
-- shared plane with Route 6) and the inward face of a ring scrap
-- occupy the same degenerate rect, and the strict overBody plus the
-- closed mask dropped BOTH -- which is why those facades were missing.
-- The winding tells them apart: a face pointing AWAY from the body
-- belongs to this map's own edge-row structure and nothing in the
-- neighbour will ever draw that plane, so it stays; a face pointing
-- INTO the body is the scrap the mask rules exist to kill, and falls
-- through to them.
local function outwardOnEdge(q, x0, z0, x1, z1)
if z0 == z1 and (z0 == 0 or z0 == bh) and x1 > 0 and x0 < bw then
local nz = (q[2][1] - q[1][1]) * (q[3][2] - q[1][2])
- (q[2][2] - q[1][2]) * (q[3][1] - q[1][1])
return (z0 == bh and nz > 0) or (z0 == 0 and nz < 0)
end
if x0 == x1 and (x0 == 0 or x0 == bw) and z1 > 0 and z0 < bh then
local nx = (q[2][2] - q[1][2]) * (q[3][3] - q[1][3])
- (q[2][3] - q[1][3]) * (q[3][2] - q[1][2])
return (x0 == bw and nx > 0) or (x0 == 0 and nx < 0)
end
return false
end
local scUV = { { 0, 0 }, { 0, 0 }, { 0, 0 }, { 0, 0 } }
local function quadUV(q)
if q.uv then return q.uv end
@@ -625,7 +679,13 @@ local function runGeometry(map, bodyOnly, masks, sink)
local x1 = math.max(q[1][1], q[2][1], q[3][1], q[4][1])
local z0 = math.min(q[1][3], q[2][3], q[3][3], q[4][3])
local z1 = math.max(q[1][3], q[2][3], q[3][3], q[4][3])
if keepQuad(x0, z0, x1, z1) then
-- q.own: a body-anchored structure's own quad (a building placed by
-- Buildings.build, whose scan never leaves the body). Exempt from
-- the edge keep-rules entirely: its eave legitimately overhangs the
-- boundary plane into the neighbour's airspace, and no variant of
-- the neighbour will ever draw that geometry
if q.own or outwardOnEdge(q, x0, z0, x1, z1)
or keepQuad(x0, z0, x1, z1) then
push({ q[1], q[2], q[3], q[4] }, quadUV(q), groundShades(q, q.shade))
end
end
@@ -643,15 +703,17 @@ local function runGeometry(map, bodyOnly, masks, sink)
-- round-tree stamps: the shared hull template translated per cell,
-- through reusable scratch corners so expansion allocates nothing.
-- A hull spans at most its own 16px cell, so one rect test usually
-- answers for the whole stamp: strictly interior stamps keep every
-- quad, ring stamps buried under a neighbour body (or, body-only, ring
-- stamps full stop) skip without touching their quads. Only stamps
-- crossing a boundary walk quad by quad.
-- A hull spans at most its own footprint -- one 16px cell unless the
-- stamp carries a wider radius (the 2x2-cell canopy groups) -- so one
-- rect test usually answers for the whole stamp: strictly interior
-- stamps keep every quad, ring stamps buried under a neighbour body
-- (or, body-only, ring stamps full stop) skip without touching their
-- quads. Only stamps crossing a boundary walk quad by quad.
local sc = { { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 }, { 0, 0, 0 } }
for _, st in ipairs(S.roundStamps or {}) do
local mx, mz = st.mx, st.mz
local sx0, sz0, sx1, sz1 = mx - 8, mz - 8, mx + 8, mz + 8
local sr = st.r or 8
local sx0, sz0, sx1, sz1 = mx - sr, mz - sr, mx + sr, mz + sr
local interior = sx0 > 0 and sx1 < bw and sz0 > 0 and sz1 < bh
local overBody = sx1 > 0 and sx0 < bw and sz1 > 0 and sz0 < bh
local keepAll, skipAll
@@ -704,15 +766,10 @@ function ChunkMesher.build(map, bodyOnly, masks)
return sink.finish()
end
-- The tall-grass rows as their own mesh: VoxelScene draws it AFTER the
-- characters so the southern row of a grass cell still overdraws a
-- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary
-- terrain could never do this).
local function buildGrassMesh(map)
local S = Structures.forMap(map)
if #S.grassQuads == 0 then return nil end
local function quadsMesh(quads)
if #quads == 0 then return nil end
local verts, indices, n = {}, {}, 0
for _, q in ipairs(S.grassQuads) do
for _, q in ipairs(quads) do
for i = 1, 4 do
local c = q[i]
local uv = q.uv and q.uv[i] or { q.u, q.v }
@@ -724,6 +781,33 @@ local function buildGrassMesh(map)
return Voxel3D.newMesh(verts, indices)
end
-- The tall-grass rows as their own mesh: VoxelScene draws it AFTER the
-- characters so the southern row of a grass cell still overdraws a
-- walker's feet (characters stamp over terrain, Gen 1 style, so ordinary
-- terrain could never do this).
local function buildGrassMesh(map)
return quadsMesh(Structures.forMap(map).grassQuads)
end
-- The flower billboards as their own mesh, for the same reason as the
-- grass one: it draws AFTER the characters WITH the same camera-ward
-- pull, so a flower south of a walker occludes their feet and one north
-- of them hides behind them. Baked into the terrain mesh they lost that
-- depth fight against the pulled character card whenever the player
-- stood among flowers. Unlike grass this mesh still CASTS shadows (the
-- sun pass draws it): a handful of flowers per meadow, not thousands of
-- tufts.
local function buildFlowerMesh(map)
return quadsMesh(Structures.forMap(map).flowerQuads)
end
-- Replace a cached slot, releasing whatever mesh it held.
local function swapSlot(c, slot, mesh)
local old = c[slot]
if old and old ~= mesh and old.release then pcall(old.release, old) end
c[slot] = mesh
end
-- ------------------------------------------------------------- the cache
local function entry(id)
@@ -736,11 +820,12 @@ local function entry(id)
end
local function releaseEntry(c)
for _, slot in ipairs({ "full", "body", "grass" }) do
for _, slot in ipairs({ "full", "body", "grass", "flowers" }) do
local mesh = c[slot]
if mesh and mesh.release then pcall(mesh.release, mesh) end
c[slot] = nil
end
c.stale = nil
end
-- ---------------------------------------------------------- async builds
@@ -778,26 +863,48 @@ end
local function runJob(job)
local map = job.map
local c = entry(job.id)
if c.grass == nil then
if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
local okG, grass = pcall(buildGrassMesh, map)
if (gen[job.id] or 0) ~= job.gen then return end
c.grass = (okG and grass) or false
local okF, flowers = pcall(buildFlowerMesh, map)
if (gen[job.id] or 0) ~= job.gen then
if okG and grass and grass.release then pcall(grass.release, grass) end
if okF and flowers and flowers.release then
pcall(flowers.release, flowers)
end
return
end
swapSlot(c, "grass", (okG and grass) or false)
swapSlot(c, "flowers", (okF and flowers) or false)
if c.stale then c.stale.aux = nil end
end
local sink = newSink()
runGeometry(map, job.slot == "body", job.masks, sink)
local mesh = sink.finish()
if (gen[job.id] or 0) ~= job.gen then return end
c[job.slot] = mesh or false
if (gen[job.id] or 0) ~= job.gen then
if mesh and mesh.release then pcall(mesh.release, mesh) end
return
end
swapSlot(c, job.slot, mesh or false)
if c.stale then
c.stale[job.slot] = nil
if not (c.stale.full or c.stale.body or c.stale.aux) then
c.stale = nil
end
end
end
-- Queue a build unless the slot is already cached or queued. Returns the
-- cached mesh when there is one (false-cached misses return nil).
-- `urgent` marks the current map's meshes: pump() gives those a bigger
-- slice and runs them before neighbour jobs.
-- slice and runs them before neighbour jobs. A slot refresh() marked
-- stale queues its rebuild AND keeps handing back the old mesh, so a
-- one-block edit never drops the scene to the flat 2D path while the
-- replacement cooks.
function ChunkMesher.request(map, bodyOnly, masks, urgent)
local slot = bodyOnly and "body" or "full"
local c = cache[map.id]
if c and c[slot] ~= nil then return c[slot] or nil end
local stale = c and c.stale and (c.stale[slot] or c.stale.aux)
if c and c[slot] ~= nil and not stale then return c[slot] or nil end
local key = jobKey(map.id, slot)
local job = jobIndex[key]
if not job then
@@ -808,7 +915,7 @@ function ChunkMesher.request(map, bodyOnly, masks, urgent)
elseif urgent then
job.urgent = true
end
return nil
return (c and c[slot]) or nil
end
function ChunkMesher.pending()
@@ -871,17 +978,26 @@ end
function ChunkMesher.get(map, bodyOnly, masks)
local slot = bodyOnly and "body" or "full"
local c = entry(map.id)
if c.grass == nil then
if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
local okG, grass = pcall(buildGrassMesh, map)
c.grass = (okG and grass) or false
local okF, flowers = pcall(buildFlowerMesh, map)
swapSlot(c, "grass", (okG and grass) or false)
swapSlot(c, "flowers", (okF and flowers) or false)
if c.stale then c.stale.aux = nil end
end
if c[slot] == nil then
if c[slot] == nil or (c.stale and c.stale[slot]) then
local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks)
if not ok then
print("[warn] voxel mesh build failed for " .. tostring(map.id)
.. ": " .. tostring(mesh))
end
c[slot] = (ok and mesh) or false
swapSlot(c, slot, (ok and mesh) or false)
if c.stale then
c.stale[slot] = nil
if not (c.stale.full or c.stale.body or c.stale.aux) then
c.stale = nil
end
end
local key = jobKey(map.id, slot)
local job = jobIndex[key]
if job then finishJob(job, true) end
@@ -901,6 +1017,39 @@ function ChunkMesher.grass(map)
return c and c.grass or nil
end
function ChunkMesher.flowers(map)
local c = cache[map.id]
return c and c.flowers or nil
end
-- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while
-- replacements cook, and each slot swaps as its build lands. This is
-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
-- the mesh outright, and until the async rebuild landed the scene fell
-- to the flat 2D path, a whole-world blink for a one-block edit.
function ChunkMesher.refresh(mapId)
if not mapId then return ChunkMesher.invalidate() end
local c = cache[mapId]
-- nothing drawable cached: the plain drop costs nothing visible
if not (c and (c.full or c.body)) then
return ChunkMesher.invalidate(mapId)
end
Structures.invalidate(mapId)
gen[mapId] = (gen[mapId] or 0) + 1
for i = #jobs, 1, -1 do
local job = jobs[i]
if job.id == mapId then
jobIndex[jobKey(job.id, job.slot)] = nil
table.remove(jobs, i)
end
end
-- false-cached slots count as stale too: a retry after a failed build
-- is exactly a rebuild
c.stale = { aux = true,
full = (c.full ~= nil) or nil,
body = (c.body ~= nil) or nil }
end
-- Evict everything outside `live` (a set of map ids): far maps' meshes
-- are released -- GPU buffer and LOVE's CPU copy both -- and their
-- Structures analysis dropped. The live set is the current map plus its
+545 -108
View File
@@ -192,8 +192,8 @@ function Structures.forMap(map)
-- still overdraws a walker's feet even though characters stamp over
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
runs = {}, skip = {}, ground = {}, objectQuads = {},
grassQuads = {}, roundStamps = {} }
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player
@@ -205,6 +205,14 @@ function Structures.forMap(map)
-- 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
@@ -213,7 +221,15 @@ function Structures.forMap(map)
if ns and ns.art == "upright" then
for dy = 0, 1 do
for dx = 0, 1 do
shapeAt[keyOf(cx * 2 + dx, cy * 2 + dy)] = shapes.classes.wall
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
@@ -413,6 +429,30 @@ function Structures.forMap(map)
-- 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
@@ -479,15 +519,17 @@ end
local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78,
top = 1.0, bottom = 0.55 }
local function roundTemplate(S, map, data, cx, cy, groundTiles)
local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows)
N = N or 16 -- art canvas: 16 = one cell, 32 = 2x2 cells
local N2 = N / 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 (16x16, row 0 = top)
-- cell-space art access (NxN, row 0 = top), anchored at cell (cx, cy)
local function tileOf(px, py)
return S.tileAt[keyOf(cx * 2 + (px >= 8 and 1 or 0),
cy * 2 + (py >= 8 and 1 or 0))]
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)
@@ -495,18 +537,18 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
math.floor(tile / perRow) * 8 + py % 8
end
-- shade class of every cell pixel, indexed py * 16 + px
-- shade class of every canvas pixel, indexed py * N + px
local cls = {}
for py = 0, 15 do
for px = 0, 15 do
for py = 0, N - 1 do
for px = 0, N - 1 do
local ax, ay = texel(px, py)
local r, g, b, a = data:getPixel(ax, ay)
cls[py * 16 + px] = a == 0 and "off"
or Structures.shadeClass(math.min(r, g, b))
cls[py * N + px] = a == 0 and "off"
or Structures.shadeClass(math.min(r, g, b))
end
end
-- 4-connected flood from the cell border through `passable` classes
-- 4-connected flood from the canvas border through `passable` classes
local function floodOutside(passable)
local out, stack = {}, {}
local function seed(i)
@@ -515,16 +557,16 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
stack[#stack + 1] = i
end
end
for i = 0, 15 do
seed(i); seed(240 + i); seed(i * 16); seed(i * 16 + 15)
for i = 0, N - 1 do
seed(i); seed(N * (N - 1) + i); seed(i * N); seed(i * N + N - 1)
end
while #stack > 0 do
local i = table.remove(stack)
local px = i % 16
local px = i % N
if px > 0 then seed(i - 1) end
if px < 15 then seed(i + 1) end
if i >= 16 then seed(i - 16) end
if i < 240 then seed(i + 16) end
if px < N - 1 then seed(i + 1) end
if i >= N then seed(i - N) end
if i < N * (N - 1) then seed(i + N) end
end
return out
end
@@ -533,23 +575,48 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
local out = floodOutside({ off = true, dark = true,
light = true, white = true })
local mask, enclosed = {}, 0
for i = 0, 255 do
for i = 0, N * N - 1 do
if not out[i] then
mask[i] = true
if cls[i] ~= "black" then enclosed = enclosed + 1 end
end
end
if enclosed < 32 then
if enclosed < N * N / 8 then
out = floodOutside({ off = true, light = true, white = true })
mask = {}
for i = 0, 255 do
for i = 0, N * N - 1 do
if not out[i] and cls[i] ~= "off" then mask[i] = true end
end
end
local any = nil
for i = 0, 255 do any = any or mask[i] end
for i = 0, N * N - 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, N - 1 do
for ix = 0, N - 1 do
if mask[iy * N + ix] then top = iy break end
end
if top then break end
end
if top then
capY0 = top
capY1 = math.min(top + capRows - 1, N - 2)
for iy = capY0, capY1 do
for ix = 0, N - 1 do mask[iy * N + ix] = nil end
end
any = nil
for i = 0, N * N - 1 do any = any or mask[i] end
if not any then return {} 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
@@ -564,9 +631,9 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
local ox = (t % perRow) * 8
local oy = math.floor(t / perRow) * 8
local score, n = 0, 0
for py = 0, 15 do
for px = 0, 15 do
local i = py * 16 + px
for py = 0, N - 1 do
for px = 0, N - 1 do
local i = py * N + px
local c = cls[i]
if not mask[i] and (c == "light" or c == "white") then
local ax, ay = texel(px, py)
@@ -589,10 +656,10 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
local z0, z1, src = {}, {}, {}
local loRow, hiRow = {}, {}
local yBot = nil
for iy = 0, 15 do
for iy = 0, N - 1 do
local lo, hi = nil, nil
for ix = 0, 15 do
if mask[iy * 16 + ix] then
for ix = 0, N - 1 do
if mask[iy * N + ix] then
lo = lo or ix
hi = ix
end
@@ -603,7 +670,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
local c = (lo + hi + 1) / 2
local hw = (hi - lo + 1) / 2
for ix = lo, hi do
local i = iy * 16 + ix
local i = iy * N + ix
if mask[i] then
local dx = ix + 0.5 - c
local n = 1
@@ -611,7 +678,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx)
+ 0.5))
end
z0[i] = math.floor(8 - n / 2 + 0.5)
z0[i] = math.floor(N2 - n / 2 + 0.5)
z1[i] = z0[i] + n
src[i] = iy
end
@@ -621,28 +688,46 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
-- foot: rows under the mask repeat the bottom row's discs, wearing the
-- bottom row's (outline-dark) pixels
for iy = yBot + 1, 15 do
for iy = yBot + 1, N - 1 do
loRow[iy], hiRow[iy] = loRow[yBot], hiRow[yBot]
for ix = loRow[yBot], hiRow[yBot] do
local b = yBot * 16 + ix
local b = yBot * N + ix
if z0[b] then
local i = iy * 16 + ix
local i = iy * N + ix
z0[i], z1[i], src[i] = z0[b], z1[b], yBot
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, N - 1 do
if loRow[iy] then capTopRow = iy break end
end
if capTopRow then
for ix = loRow[capTopRow], hiRow[capTopRow] do
local i = capTopRow * N + ix
if z0[i] then
capZ0 = math.min(capZ0 or z0[i], z0[i])
capZ1 = math.max(capZ1 or z1[i], z1[i])
end
end
end
end
local function solidAt(ix, iy, iz)
if ix < 0 or ix > 15 or iy < 0 or iy > 15 then return false end
local i = iy * 16 + ix
if ix < 0 or ix > N - 1 or iy < 0 or iy > N - 1 then return false end
local i = iy * N + ix
return z0[i] ~= nil and iz >= z0[i] and iz < z1[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(15, iy + 4) do
local i = iy2 * 16 + ix
for iy2 = iy + 2, math.min(N - 1, iy + 4) do
local i = iy2 * N + ix
if mask[i] and cls[i] ~= "black" then return texel(ix, iy2) end
end
return texel(ix, iy)
@@ -654,12 +739,12 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
-- 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)
local r = src[iy * 16 + ix]
local r = src[iy * N + ix]
local dir = ix + ix < loRow[iy] + hiRow[iy] and 1 or -1
for step = 0, 3 do
local x2 = ix + dir * step
local i2 = r * 16 + x2
if x2 < 0 or x2 > 15 or not mask[i2] then break end
local i2 = r * N + x2
if x2 < 0 or x2 > N - 1 or not mask[i2] then break end
if cls[i2] ~= "black" then return texel(x2, r) end
end
return texel(ix, r)
@@ -667,20 +752,20 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
local quads = {}
for iy = 0, 15 do
for iy = 0, N - 1 do
if loRow[iy] then
local yB, yT = 15 - iy, 16 - iy
local yB, yT = N - 1 - iy, N - 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 * 16 + ix
local i = iy * N + ix
if z0[i] then
local ix2 = ix
while ix2 + 1 <= hiRow[iy] do
local j = iy * 16 + ix2 + 1
local j = iy * N + ix2 + 1
if z0[j] == z0[i] and z1[j] == z1[i]
and math.floor((ix2 + 1) / 8) == math.floor(ix / 8) then
ix2 = ix2 + 1
@@ -692,8 +777,8 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
local ax1 = (texel(ix2, src[i]))
local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW
local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH
local x0, x1 = ix - 8, ix2 - 7
local zF, zB = z1[i] - 8, z0[i] - 8
local x0, x1 = ix - N2, ix2 - N2 + 1
local zF, zB = z1[i] - N2, z0[i] - N2
quads[#quads + 1] = {
{ x0, yB, zF }, { x1, yB, zF }, { x1, yT, zF }, { x0, yT, zF },
uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
@@ -713,11 +798,11 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
-- 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 * 16 + ix
local i = iy * N + ix
if z0[i] then
local ax, ay = texel(ix, src[i])
local u, v = (ax + 0.5) / atlasW, (ay + 0.5) / atlasH
local x0, x1 = ix - 8, ix - 7
local x0, x1 = ix - N2, ix - N2 + 1
-- exposed z pieces against one neighbouring column
local function pieces(nx, ny, emit)
@@ -728,7 +813,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
while iz2 + 1 < z1[i] and not solidAt(nx, ny, iz2 + 1) do
iz2 = iz2 + 1
end
emit(iz - 8, iz2 - 7, iz, iz2)
emit(iz - N2, iz2 - N2 + 1, iz, iz2)
iz = iz2 + 1
else
iz = iz + 1
@@ -757,7 +842,20 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
u = tu, v = tv, shade = ROUND_SHADE.top,
}
end
if izA == z0[i] and izB == z1[i] - 1 and izB - izA >= 2 then
if capTopRow and iy == capTopRow and capZ1 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(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)
@@ -767,7 +865,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles)
top(zA, zB, u, v)
end
end)
if iy < 15 then
if iy < N - 1 then
pieces(ix, iy + 1, function(zA, zB)
quads[#quads + 1] = {
{ x0, yB, zB }, { x1, yB, zB }, { x1, yB, zA }, { x0, yB, zA },
@@ -815,23 +913,91 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles)
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)
local stumpCap = 6
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
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 = S.shapeAt[k]
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 == "cylinder" and near then
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 == "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
local cap = s.class == "stump" and stumpCap or nil
local ground = false
if data then
local sig = tsid .. "|" .. gsig .. "|" .. table.concat({
local sig = tsid .. (cap and ("|c" .. cap) 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)
local tq, tbg = roundTemplate(S, map, data, cx, cy,
groundTiles, 16, cap)
tpl = { quads = tq, bg = tbg }
roundCache[sig] = tpl
end
@@ -1290,6 +1456,7 @@ function Structures.buildVolume(S, map, tiles)
local runs = {}
local heightVotes = {}
local repeatVotes = {}
for tx, ys in pairs(cols) do
-- visit each contiguous vertical run in this column
local sorted = {}
@@ -1320,12 +1487,35 @@ function Structures.buildVolume(S, map, tiles)
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 }
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
@@ -1337,11 +1527,27 @@ function Structures.buildVolume(S, map, tiles)
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
for _, r in ipairs(runs) do
local run = r.run
local h = run.unit * 8
local adopted = false
if run.fromRepeat and modeH > h then
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
@@ -1359,7 +1565,8 @@ function Structures.buildVolume(S, map, tiles)
-- 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 then
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
@@ -1712,10 +1919,24 @@ function Structures.buildObject(S, map, region, cluster,
-- 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.
local baseY, support = 0, nil
if force then
local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)]
if bs and bs.authored and bs.art == "upright" and (bs.h or 0) > 0 then
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
math.floor(cluster.maxY / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
baseY, support = bs.h, bs
end
end
@@ -1844,7 +2065,17 @@ function Structures.buildObject(S, map, region, cluster,
end
for _, c in ipairs(cluster.tiles) do
local k = keyOf(c[1], c[2])
if support then
if support and support.class == "wall" 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.
S.skip[k] = true
S.ground[k] = best
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
@@ -1874,14 +2105,21 @@ end
-- ---- tall grass ----
-- A tall-grass tile draws its four tuft segments in two ROWS: the art's
-- top half is one row of grass, the bottom half another. Each row stands
-- as a thin per-pixel slab at its own drawn depth inside the tile, over
-- the flat grass base the tile already renders -- so the player walks
-- BETWEEN 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).
-- 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)
@@ -1898,49 +2136,48 @@ local function grassTemplate(map, data, tileId)
end
local quads = {}
for half = 0, 1 do
local rowBase = half * 4
local zMid = half == 0 and 2.5 or 6.5
local zB, zF = zMid - GRASS_THICK / 2, zMid + GRASS_THICK / 2
for iy = 0, 3 do
local yTop = 4 - iy
local yBot = yTop - 1
local ix = 0
while ix < 8 do
if opaque(ix, rowBase + iy) then
local ix2 = ix
while ix2 + 1 < 8 and opaque(ix2 + 1, rowBase + iy) do
ix2 = ix2 + 1
end
local u0 = (ax0 + ix + 0.05) / atlasW
local u1 = (ax0 + ix2 + 0.95) / atlasW
local v0 = (ay0 + rowBase + iy + 0.05) / atlasH
local v1 = (ay0 + rowBase + iy + 0.95) / atlasH
quads[#quads + 1] = { -- front
{ ix, yBot, zF }, { ix2 + 1, yBot, zF },
-- 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, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } },
uv = { { u0, v0 }, { u1, v0 }, { 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, rowBase + 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
ix = ix2 + 1
else
ix = ix + 1
end
ix = ix2 + 1
else
ix = ix + 1
end
end
end
@@ -1982,6 +2219,206 @@ function Structures.buildGrass(S, map, x0, x1, y0, y1, data)
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,
}
-- petal tips: a top strip where the row above is clear. The
-- strip samples its own row's texel, so a tip that is not in
-- the current frame discards with the face beneath it
if not on(ix, py - 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 = OBJ_SHADE.top,
}
end
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).
+77 -3
View File
@@ -27,6 +27,9 @@
-- the first place (home/vcopy.asm rewrites the tile's VRAM bytes); the 2D
-- overdraw is the port's workaround, not the original.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Assets = require("src.render.Assets")
local TileRenderer = require("src.render.TileRenderer")
local PaletteFX = require("src.render.PaletteFX")
@@ -146,6 +149,8 @@ local function learnShades(raw, baked, tile, perRow)
return map
end
local DIRS4 = { { 1, 0 }, { -1, 0 }, { 0, 1 }, { 0, -1 } }
-- One animated entry's tile slot, written into `out` at step `step`.
local function patch(out, entry, spec, step)
local perRow, tile = entry.perRow, spec.tile
@@ -167,12 +172,59 @@ local function patch(out, entry, spec, step)
local ok, frame = pcall(Assets.imageData, path)
if not ok or not frame then return end
local shades = entry.shades and entry.shades[tile]
-- a `cut` tile is the flower billboard's slot (Structures'
-- buildFlowers): only the frame's darkest tones AND what they
-- enclose stay opaque -- the round-scenery hull's rule, flooding
-- the frame border through every non-dark pixel so the pale petal
-- insides survive with the outline. The true background is keyed
-- to alpha; nothing else samples this slot (the ground under a
-- flower is synthesized), and the shader's discard is what lets
-- the billboard's silhouette change per frame under geometry that
-- never moves.
local mask = nil
if entry.cut and entry.cut[tile] then
local dark, reach, stack = {}, {}, {}
for y = 0, 7 do
for x = 0, 7 do
local r, _, _, a = frame:getPixel(x, y)
if a > 0 and shadeOf(r) >= 3 then dark[y * 8 + x] = true end
end
end
for i = 0, 7 do
for _, s in ipairs({ i, 56 + i, i * 8, i * 8 + 7 }) do
if not dark[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 _, d in ipairs(DIRS4) do
local nx, ny = px + d[1], py + d[2]
if nx >= 0 and nx < 8 and ny >= 0 and ny < 8 then
local ni = ny * 8 + nx
if not dark[ni] and not reach[ni] then
reach[ni] = true
stack[#stack + 1] = ni
end
end
end
end
mask = {}
for i = 0, 63 do mask[i] = dark[i] or not reach[i] end
end
for y = 0, 7 do
for x = 0, 7 do
local r, g, b, a = frame:getPixel(x, y)
local col = shades and shades[shadeOf(r)]
if col and a > 0 then r, g, b = col[1], col[2], col[3] end
out:setPixel(dx + x, dy + y, r, g, b, a)
if mask and not mask[y * 8 + x] then
out:setPixel(dx + x, dy + y, 0, 0, 0, 0)
else
local col = shades and shades[shadeOf(r)]
if col and a > 0 then r, g, b = col[1], col[2], col[3] end
out:setPixel(dx + x, dy + y, r, g, b, a)
end
end
end
end
@@ -427,6 +479,28 @@ local function newEntry(map, base, baked)
end
end
end
-- a frame-animated tile that resolved to the `flower` class stands as
-- a 1px billboard (Structures.buildFlowers), and its slot in THIS
-- copy carries only each frame's dark tones with the rest keyed to
-- alpha -- see patch(). Gated on the resolved shape so a profile that
-- pins the tile to something solid keeps a fully opaque slot, and on
-- the tileset art being readable: without pixels Structures cannot
-- have stood the billboard up (or synthesized the ground), and an
-- alpha-keyed slot under an ordinary flat quad is a hole in the world.
for _, spec in ipairs(specs) do
if spec.kind == "frames" then
local okCut, isFlower = pcall(function()
local okA, art = pcall(Assets.imageData, tileset.image)
if not (okA and art and art.getPixel) then return false end
local sh = V.require("TileShape").forMap(map)[spec.tile]
return sh ~= nil and sh.class == "flower"
end)
if okCut and isFlower then
e.cut = e.cut or {}
e.cut[spec.tile] = true
end
end
end
return e
end)
return (ok and entry) or false
+102 -4
View File
@@ -56,10 +56,20 @@ local FALLBACK_HEIGHTS = {
tree = 16,
roof = 28,
cylinder = 16,
-- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull
-- (Viridian Forest's trees). The class pins only the drawing's
-- top-left corner tile; the other cells stay `cylinder` and are
-- claimed by the group build (see Structures.buildCylinders)
canopy = 32,
-- a cylinder hull whose drawn top is a CUT FACE (tree stumps): the
-- body builds from the bark rows and the drawn ellipse projects onto
-- the hull's round top
stump = 16,
billboard = 16,
signpost = 16,
post = 16,
grass = 0,
flower = 0,
-- interior furniture: face-on drawings the detector would otherwise
-- raise to wall height (or merge into the wall). A bed is drawn from
-- above and lies low; tables and desks are boxes at their real height;
@@ -103,6 +113,8 @@ local ART = {
fence = "upright",
sign = "upright",
cylinder = "cylinder",
canopy = "canopy",
stump = "cylinder",
billboard = "billboard",
-- signposts share the billboard treatment but as their own pool at a
-- 2-voxel depth: a sign is a thin plate on a stick, and the standard
@@ -110,6 +122,11 @@ local ART = {
signpost = "billboard",
post = "post",
grass = "grass",
-- animated flowers: flat synthesized ground PLUS a standing cutout of
-- the drawing's darkest tones, one voxel deep (see Structures'
-- buildFlowers). Height 0 so a build with no pixel access degrades to
-- the flat tile it always drew, not a box
flower = "flower",
-- furniture: a bed's art depicts its top surface; tables and desks are
-- boxes whose fronts fold up (the mesher's authored-fold rule).
-- Stools, `prop` and `cutout` are standee pools alongside `billboard`
@@ -185,12 +202,56 @@ local function authoredGroups(tilesetId, heights)
return out
end
-- Conditional pins: tile id -> list of { above = {tile ids}, class }.
--
-- A pin is per TILE ID, and one graphic can mean two things. The route
-- gates' $32/$33 is the case that forced this: the artist reuses it for
-- the wall's dark base course AND for every service counter's front, and
-- it is the bottom row of its cell either way. Pinned `wall` the counter
-- stands a full 16px; pinned `counter` the wall bank corrugates 16/8 for
-- sixteen rows. Neither is right, and no per-tile pin can be, because
-- forMap resolves an id to ONE shape.
--
-- What separates the two uses is what is drawn ABOVE: the wall's upper
-- course over a wall base, the counter's top over a counter front. So a
-- profile entry may carry `when_above = { [tile] = { { above = {...},
-- class = "..." } } }`, evaluated per POSITION in TileShape.at, where
-- the map and coordinates are in hand. First match wins; no match keeps
-- the tile's ordinary pin.
local function authoredConditions(tilesetId, heights)
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local spec = entry and entry.when_above
if type(spec) ~= "table" then return nil end
local out, any = {}, false
for tile, rules in pairs(spec) do
if type(tile) == "number" and type(rules) == "table" then
local list = {}
for _, rule in ipairs(rules) do
if type(rule) == "table" and heights[rule.class]
and type(rule.above) == "table" then
local set = {}
for _, t in ipairs(rule.above) do set[t] = true end
list[#list + 1] = { above = set, class = rule.class }
end
end
if #list > 0 then
out[tile] = list
any = true
end
end
end
return any and out or nil
end
local function shapeFor(class, heights, authored)
return { class = class, h = heights[class] or 0,
art = ART[class] or "upright",
-- grass draws its flat ground base like any walkable tile; the
-- standing tuft rows are additive geometry from Structures
flat = ART[class] == "flat" or class == "grass",
-- grass and flowers draw a flat ground base like any walkable
-- tile; the standing tufts and cutouts are additive geometry
-- from Structures
flat = ART[class] == "flat" or class == "grass"
or class == "flower",
authored = authored or false }
end
@@ -210,7 +271,29 @@ function TileShape.forMap(map)
local count = math.floor((tileset.imageWidth or 128) / 8)
* math.floor((tileset.imageHeight or 48) / 8)
local shapes = { classes = {} }
-- derived pin: a tile the tileset animates by FRAME REWRITE (the
-- overworld's flower) is already named by its animation spec, so like
-- tall grass it needs no profile entry anywhere. Hand-authoring still
-- wins -- a mod animating a wall tile this way keeps its wall by
-- listing it. Guarded because the spec seam is engine data a stub map
-- may not carry.
local flowerTiles = {}
do
local ok, declared = pcall(function()
if tileset.animatedTiles then return tileset.animatedTiles end
local TileRenderer = require("src.render.TileRenderer")
return TileRenderer.defaultAnimatedTiles(tileset)
end)
if ok then
for _, spec in ipairs(type(declared) == "table" and declared or {}) do
if spec.kind == "frames" and spec.tile then
flowerTiles[spec.tile] = true
end
end
end
end
local shapes = { classes = {}, cond = authoredConditions(id, heights) }
for class in pairs(FALLBACK_HEIGHTS) do
shapes.classes[class] = shapeFor(class, heights)
end
@@ -222,6 +305,8 @@ function TileShape.forMap(map)
-- derived pin: every tileset already names its tall-grass tile, so
-- the standing-tuft treatment needs no profile entry anywhere
shapes[t] = shapeFor("grass", heights, true)
elseif flowerTiles[t] then
shapes[t] = shapeFor("flower", heights, true)
elseif map.waterTiles and map.waterTiles[t] then
shapes[t] = shapes.classes.water
elseif map.walkable and map.walkable[t] then
@@ -241,6 +326,19 @@ end
-- map:tileAt(tx, ty), passed in because every caller already has it.
function TileShape.at(map, shapes, tile, tx, ty)
local s = shapes[tile]
-- conditional pins first: they are authored answers that need the
-- POSITION to resolve, so they outrank both the flat pin on the same
-- tile and the cell rules below (see authoredConditions)
local rules = shapes.cond and shapes.cond[tile]
if rules then
local above = map:tileAt(tx, ty - 1)
for _, rule in ipairs(rules) do
if above and rule.above[above] then
shapes.classes[rule.class].authored = true
return shapes.classes[rule.class]
end
end
end
if not s or s.authored then return s end
local cx = math.floor(tx / 2)
local cy = math.floor(ty / 2)
+25
View File
@@ -460,6 +460,14 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- flower billboards live outside the terrain mesh (they draw after the
-- characters, pulled -- see render), but the sun still sees them: a
-- handful of cutouts per meadow, unlike the grass left out below
ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil)
for _, nb in ipairs(state.neighbors or {}) do
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
for _, p in ipairs(posed) do
local def = p.sprite.def
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
@@ -559,6 +567,23 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull)
end
-- flower billboards: pulled like the characters and the grass, MINUS
-- the depth of 8 world pixels along the view (8 sin a -- the camera
-- looks along (0, -cos a, -sin a), so that is exactly one tile row of
-- northness). A pure depth handicap with zero screen drift: every
-- flower is judged as if it stood one tile row further north. The
-- character card's feet plane sits at its cell's MIDDLE (py + 8), so
-- a flower on the walker's own cell (z +4 or +12 across the cell)
-- lands behind the card and the player obscures the patch they stand
-- ON, while the nearest flower of the cell south (+20) stays in front
-- and keeps overdrawing their feet.
local fpull = math.max(0, pull - 8 * math.sin(math.max(Voxel.angle, 0.05)))
Voxel3D.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil,
fpull)
for _, nb in ipairs(state.neighbors or {}) do
Voxel3D.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), fpull)
end
return Voxel3D.endScene()
end
+6 -2
View File
@@ -344,9 +344,13 @@ end)
-- tree that is no longer there. The 2D tile renderer invalidates its own
-- caches off the same edit.
-- refresh, not invalidate: the stale mesh keeps drawing while the
-- replacement builds in the background, so a one-block edit (Cut, a
-- door stamp, the tree regrowing on re-entry) repopulates in place
-- instead of blinking the whole scene down to the flat 2D path
mod.events:on("world.block_replaced", function(payload)
local mapId = payload and (payload.mapId or (payload.map and payload.map.id))
if mapId then ChunkMesher.invalidate(mapId) end
if mapId then ChunkMesher.refresh(mapId) end
end)
-- A reloaded map is rebuilt from scratch (warps that re-enter the same map,
@@ -372,7 +376,7 @@ mod.events:on("map.reloaded", function(payload)
if mapId then ChunkMesher.invalidate(mapId) end
end)
mod.exports.version = "1.0.3"
mod.exports.version = "1.0.6"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
"version": "1.0.3",
"version": "1.0.6",
"api": 2,
"entry": "main.lua",
"profile": "content",
+102 -3
View File
@@ -420,6 +420,98 @@ T.check(okClock and type(clockVal) == "number",
"a clock that throws falls back to a working one rather than propagating")
TileRenderer.animFrame = nil
-- ------- the flower's slot carries an animated SILHOUETTE, not a tile
--
-- The flower tile stands in voxel mode as a billboard one voxel deep
-- (Structures.buildFlowers), cut to the drawing's darkest tones. Meshes
-- are static, so the geometry spans the union of every frame's dark
-- pixels and the animation lives in the atlas: patch() keys everything
-- lighter to alpha 0, the shader discards it, and the silhouette trims
-- itself frame by frame. Two halves to pin down: the CLASS is derived
-- (any frames-animated tile resolves `flower` with no profile entry),
-- and the PATCH writes alpha where the frame is not dark.
local TileShape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape")
local flowerSet = {
id = "T_FLOWER_PIN", image = "assets/tilesets/stub.png",
tilesPerRow = 16, imageWidth = 128, imageHeight = 48,
animatedTiles = { { tile = 0x03, kind = "frames", period = 20,
images = { "stub_flowerframe.png" },
sequence = { 1 } } },
}
local flowerShapes = TileShape.forMap({ tileset = flowerSet })
T.eq(flowerShapes[0x03].class, "flower",
"a frames-animated tile is pinned `flower` with no profile entry, like grass")
T.check(flowerShapes[0x03].flat,
"the flower cell still counts as flat ground for its neighbours")
T.eq(flowerShapes[0x03].h, 0,
"and carries no height, so a build with no pixel access degrades to the flat tile")
T.check(flowerShapes[0x03].authored,
"the pin is authored-strength: cell walkability cannot re-file it as plain ground")
-- the patch, observed through a recording atlas copy: a crafted frame
-- whose dark pixels form a diamond ring around one light pixel -- the
-- billboard must keep the ring AND the pale pixel it encloses, and key
-- the reachable background (light or transparent) to alpha
local slotPx = {}
local sectionNewImageData = love.image.newImageData
do
local crafted = fakePixels()
local ring = { ["1,0"] = true, ["0,1"] = true,
["2,1"] = true, ["1,2"] = true }
local frame = fakePixels()
function frame:getPixel(x, y)
if ring[x .. "," .. y] then return 0.3, 0.3, 0.3, 1 end -- dark outline
if x == 7 and y == 0 then return 0.9, 0.9, 0.9, 0 end -- transparent
return 1, 1, 1, 1 -- light: petal inside at (1,1),
end -- background everywhere else
love.image.newImageData = function(a, b)
if type(a) == "number" then
local d = fakePixels(a, b)
function d:setPixel(x, y, r, g, b2, al)
slotPx[x .. "," .. y] = { r, g, b2, al }
end
return d
end
if tostring(a):find("flowerframe") then return frame end
return crafted
end
end
TerrainAtlas.invalidate()
local fmap = {
id = "T_FLOWER_PATCH_MAP",
tileset = {
id = "T_FLOWER_PATCH", image = "assets/tilesets/stub2.png",
tilesPerRow = 16, imageWidth = 128, imageHeight = 48,
animatedTiles = { { tile = 0x03, kind = "frames", period = 20,
images = { "stub_flowerframe.png" },
sequence = { 1 } } },
},
renderer = { image = base },
}
local okFlower, flowerImg = pcall(TerrainAtlas.animate, fmap, nil, base, false)
T.check(okFlower and flowerImg ~= nil,
"a flower map animates: " .. tostring(flowerImg))
local fdx = (0x03 % 16) * 8
local function slotAlpha(x, y)
local p = slotPx[(fdx + x) .. "," .. y]
return p and p[4]
end
T.eq(slotAlpha(1, 0), 1,
"a dark frame pixel lands opaque -- it is the standing cutout")
T.eq(slotAlpha(1, 1), 1,
"and so does the pale pixel the outline encloses -- the petal's inside "
.. "rides the billboard, not just its outline")
T.eq(slotAlpha(5, 5), 0,
"a background pixel is keyed to alpha, so the billboard's shader discards it")
T.eq(slotAlpha(7, 0), 0,
"a transparent frame pixel stays clear rather than painting black")
love.image.newImageData = sectionNewImageData
TerrainAtlas.invalidate()
love.image, love.graphics.newImage = realImage, realNewImage
@@ -443,8 +535,10 @@ local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher")
local Runtime = require("src.mods.Runtime")
local realInvalidate = ChunkMesher.invalidate
local dropped = {}
local realRefresh = ChunkMesher.refresh
local dropped, refreshed = {}, {}
ChunkMesher.invalidate = function(id) dropped[#dropped + 1] = id or "<every map>" end
ChunkMesher.refresh = function(id) refreshed[#refreshed + 1] = id or "<every map>" end
Runtime.emit("map.reloaded", { mapId = "PALLET_TOWN", reason = "colors" })
T.eq(#dropped, 0,
@@ -459,11 +553,16 @@ T.eq(dropped[1], "PALLET_TOWN", "and drops exactly the map that reloaded")
Runtime.emit("map.reloaded", { mapId = "VIRIDIAN_CITY" })
T.eq(#dropped, 2, "a reload with no stated reason is treated as a real one")
-- and the edits that genuinely change geometry are untouched by any of this
-- a block edit (Cut, a door stamp, the tree regrowing on re-entry) is a
-- REFRESH, not a drop: the stale mesh keeps drawing while the rebuild
-- cooks, so the scene never blinks down to the flat 2D path
Runtime.emit("world.block_replaced", { mapId = "PALLET_TOWN" })
T.eq(#dropped, 3, "a replaced block still drops the mesh it changed")
T.eq(#dropped, 2, "a replaced block does not drop the mesh outright")
T.eq(#refreshed, 1, "it refreshes the mesh in place instead")
T.eq(refreshed[1], "PALLET_TOWN", "and refreshes exactly the edited map")
ChunkMesher.invalidate = realInvalidate
ChunkMesher.refresh = realRefresh
-- ------- the non-colour palette modes must not come through as SGB
--
+19 -2
View File
@@ -94,6 +94,8 @@ return function(game)
if S then
local def = ow.map.def
for _, spec in ipairs(specs) do
if not spec.tile then goto continue end -- "toggle" specs claim
-- tile LISTS, not one slot
local flat, prop, run, first = 0, 0, 0, nil
for ty = 0, def.height * 4 - 1 do
for tx = 0, def.width * 4 - 1 do
@@ -112,17 +114,32 @@ return function(game)
.. " %d inside a prop%s"):format(spec.tile, flat, run, prop,
first and (" -- first at cell " .. math.floor(first[1] / 2)
.. "," .. math.floor(first[2] / 2)) or ""))
::continue::
end
end
end
-- one animation period is 20 logic frames at 60Hz, and TileRenderer.tick
-- runs on wall-clock steps, so ~22 rendered frames lands the next step
-- runs on wall-clock steps, so ~22 rendered frames lands the next step.
-- animFrame is an OPTIONAL engine seam this build may not export; the
-- mod's own clock (TerrainAtlas._animFrame) reads the same counter by
-- whatever route exists, so ask it first.
local function clock()
if TerrainAtlas and TerrainAtlas._animFrame then
local ok, f = pcall(TerrainAtlas._animFrame)
if ok and type(f) == "number" then return f end
end
if TileRenderer.animFrame then
local ok, f = pcall(TileRenderer.animFrame)
if ok and type(f) == "number" then return f end
end
return -1
end
for i = 1, shots do
game.capturePath = ("%s/anim_%s_L%d_%02d.png"):format(DIR, mapId, level, i)
wait(3)
print(("[anim] shot %d at animFrame %d step %d"):format(
i, TileRenderer.animFrame(), math.floor(TileRenderer.animFrame() / 20) % 8))
i, clock(), math.floor(clock() / 20) % 8))
dumpAtlas(tostring(i))
wait(22)
end
+67
View File
@@ -0,0 +1,67 @@
-- Driver: which building models exist, and how much of this map is
-- claimed by objects?
--
-- Buildings.build matches every profiled template against the whole map
-- and stamps each hit, so two templates whose tile grids both match one
-- drawing stamp TWO models into the same cells.
--
-- READ THE CAVEAT BEFORE TRUSTING THIS. Buildings.stats() reports the
-- module's `models` cache, which is keyed "<tileset>:<index>" and is
-- GLOBAL AND CUMULATIVE for the session -- it lists every model built
-- since boot, including ones from maps visited earlier, and says nothing
-- about where they were placed. It is a memoisation table, not a
-- placement log. Reading it as "what is on this map" will convince you a
-- foreign building has stamped here when it has not.
--
-- To answer "does template X place on map Y", match its `tiles` grid
-- against the map's tile grid OFFLINE (data/generated/maps.lua plus
-- tilesets.lua) -- no engine needed, and it gives you the coordinates.
-- And note a drawing can STRADDLE A MAP BOUNDARY (the Pokemon Tower's
-- roof is on ROUTE_10, its facade on LAVENDER_TOWN), in which case no
-- single map's grid holds the whole thing and each half meshes alone.
--
-- BUILD_MAP=LAVENDER_TOWN POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_building_probe.lua lovec .
return function(game)
local U = dofile("tests/drivers/util.lua")
local mapId = os.getenv("BUILD_MAP") or "LAVENDER_TOWN"
U.teleport(game, mapId, tonumber(os.getenv("BUILD_X") or "3"),
tonumber(os.getenv("BUILD_Y") or "5"), "up")
U.wait(10)
local V = game.mods.exports["DRAMATIC_SHAPE"]
V = V and V.lib
local Structures = V and V.require("Structures")
local Buildings = V and V.require("Buildings")
local ow = game.overworld
if not (Structures and Buildings and ow and ow.map) then
print("[bld] mod or map unavailable")
return
end
-- placements are recorded as the templates are stamped, so force the
-- analysis first
Structures.forMap(ow.map)
print(("[bld] %s"):format(mapId))
local stats = Buildings.stats and Buildings.stats() or {}
local keys = {}
for k in pairs(stats) do keys[#keys + 1] = k end
table.sort(keys)
for _, k in ipairs(keys) do
local s = stats[k]
print(("[bld] %-24s voxels=%s shell=%s quads=%s"):format(
k, tostring(s.voxels), tostring(s.shell), tostring(s.quads)))
end
-- and the per-cell truth: which cells a building claimed
local S = Structures.forMap(ow.map)
local def = ow.map.def
local claimed = 0
for ty = 0, def.height * 4 - 1 do
for tx = 0, def.width * 4 - 1 do
if S.skip[(ty + 64) * 4096 + (tx + 64)] then claimed = claimed + 1 end
end
end
print(("[bld] tiles claimed by objects/buildings: %d"):format(claimed))
print("[bld] done")
end
+66
View File
@@ -0,0 +1,66 @@
-- Driver: what did the detector decide over a rectangle of tiles?
--
-- Prints, for every tile of MOUND_RECT on MOUND_MAP, the resolved shape
-- class and the volume run height Structures gave it -- the ground truth
-- for diagnosing cliff/mound columns that extrude to the wrong height
-- (Diglett's Cave entrance ridge, bug of 2026-07-27). Then a shot.
--
-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_mound_probe.lua lovec .
--
-- knobs (env):
-- MOUND_MAP map id (default ROUTE_2)
-- MOUND_RECT "tx0,ty0,tx1,ty1" in tiles (default 16,12,35,23)
-- MOUND_SPOT "x,y[,facing]" player cell (default 12,12,up)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("MOUND_MAP") or "ROUTE_2"
local rx0, ry0, rx1, ry1 = (os.getenv("MOUND_RECT") or "16,12,35,23")
:match("^(%-?%d+),(%-?%d+),(%-?%d+),(%-?%d+)$")
rx0, ry0, rx1, ry1 = tonumber(rx0), tonumber(ry0), tonumber(rx1), tonumber(ry1)
local sx, sy, facing = (os.getenv("MOUND_SPOT") or "12,12,up")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "up"
local Zoom = require("src.render.Zoom")
Zoom.reset()
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
U.wait(20)
Pipelines.setLevel("voxel",
math.floor(tonumber(os.getenv("MOUND_LEVEL")) or 3))
U.wait(30)
local V = game.mods.exports["DRAMATIC_SHAPE"]
V = V and V.lib
local Structures = V and V.require("Structures")
local ow = game.overworld
if Structures and ow and ow.map then
local S = Structures.forMap(ow.map)
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
print(("[mound] %s tiles (%d,%d)-(%d,%d): tile/class/h per column")
:format(mapId, rx0, ry0, rx1, ry1))
for ty = ry0, ry1 do
local row = {}
for tx = rx0, rx1 do
local k = keyOf(tx, ty)
local s = S.shapeAt[k]
local run = S.runs[k]
local h = run and run.h or (s and s.h) or 0
if S.skip[k] then h = 0 end
row[#row + 1] = ("%02X%s%02d"):format(S.tileAt[k] or 0xFF,
s and s.class:sub(1, 1) or "?", h)
end
print("[mound] " .. table.concat(row, " "))
end
end
game.capturePath = ("%s/mound_%s.png"):format(DIR, mapId)
U.wait(5)
Pipelines.setLevel("voxel", 0)
U.wait(5)
print("[mound] done")
end