Merge pull request #1 from DramaticShape/20260726_various_fixes
20260726 various fixes
This commit is contained in:
+369
@@ -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
File diff suppressed because it is too large
Load Diff
+63
-6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
--
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user