1.0.4: flower/bush/statue/trash-can billboards, in-place mesh refresh

Flowers stand as 1px animated cutouts on their own pulled mesh (own-cell
flowers hide behind the walker, the cell south still overdraws feet).
Cuttable bushes, gym statues (single-block plinth) and Lt. Surge's trash
cans stand as 5-voxel prop cutouts; prop_ground names the tile painted
beneath. Block edits refresh meshes in place instead of blinking to 2D.
This commit is contained in:
DramaticShape
2026-07-27 00:11:06 -04:00
parent fdcd5f1fce
commit dcf078c2e0
9 changed files with 334 additions and 30 deletions
+104
View File
@@ -1,5 +1,109 @@
# Changelog # Changelog
## 1.0.4
### Added
- **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`.
### 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.
## 1.0.3 ## 1.0.3
### Added ### Added
+39
View File
@@ -65,6 +65,11 @@
-- monitor on its desk). prop is a second pool -- monitor on its desk). prop is a second pool
-- so two touching cutouts stay separate -- so two touching cutouts stay separate
-- --
-- A tileset entry may also carry `prop_ground` (not a class): a map of
-- prop tile id -> ground tile id naming the tile painted under that
-- pinned prop, overriding the flat-neighbour vote -- the cuttable bush
-- stands on the grass Cut leaves behind, whatever borders it.
--
-- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from -- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
-- above, a facade seen face-on and sloped ends as diagonal silhouettes, -- above, a facade seen face-on and sloped ends as diagonal silhouettes,
-- and no single class covers that. They live in the `buildings` list at -- and no single class covers that. They live in the `buildings` list at
@@ -124,6 +129,40 @@ return {
-- fence-textured tower. `post` extracts each cell alone, so these -- fence-textured tower. `post` extracts each cell alone, so these
-- render as the same thin posts, marching north -- render as the same thin posts, marching north
post = { 14, 85 }, post = { 14, 85 },
-- the cuttable bush ($2D/$2E/$3D/$3E, the four tiles Cut deletes
-- -- across the whole tileset they appear only in the five
-- cut-tree blocks): a standing per-pixel cutout 5 voxels deep,
-- black-outline segmented with the pixels the outline encloses
-- kept, its drawn grass dither flooding away as background
prop = { 45, 46, 61, 62 },
-- the ground painted under those pinned props, by the prop tile's
-- own id: the bush stands on plain grass ($2C) -- the very tile
-- Cut leaves behind (field.cutTreeSwaps' after-blocks) -- rather
-- than whatever flat tile its neighbours vote
prop_ground = { [45] = 44, [46] = 44, [61] = 44, [62] = 44 },
},
-- The badge gyms and Bruno's room (one tileset): the bird statues
-- that flank every gym's aisles, and Lt. Surge's trash cans. A
-- statue is one cell of figure ($02/$38/$12/$13) drawn over one
-- cell of plinth ($22/$23/$32/$33); the trash can is one cell
-- ($0B/$0C/$1B/$1C, blocks 38/39 only). Across the whole blockset
-- none of these tiles appears anywhere else. The plinth stays a
-- SOLID 16px block; the figure stands ON it as a per-pixel cutout
-- 5 voxels deep (the thin `prop` pool), collapsing to the plinth's
-- single cell of footprint (Structures' wall-support rule); the
-- can is the same 5-voxel cutout at floor level. Both are
-- black-outline segmented -- backgrounds flood away, the pixels
-- the outline encloses stay. Everything stands on the gyms' main
-- floor tile ($11), which is also Bruno's floor.
-- (The round boulder drawn beside some statues, $07/$08/$17/$18,
-- is NOT pinned: it also tiles wall-to-wall as Pewter's rock rows,
-- which are scenery for the detector, not a lone prop.)
GYM = {
wall = { 34, 35, 50, 51 },
prop = { 2, 56, 18, 19, 11, 12, 27, 28 },
prop_ground = { [2] = 17, [56] = 17, [18] = 17, [19] = 17,
[11] = 17, [12] = 17, [27] = 17, [28] = 17 },
}, },
-- ledge pairs from data.field.tilePairs -- ledge pairs from data.field.tilePairs
+108 -21
View File
@@ -704,15 +704,10 @@ function ChunkMesher.build(map, bodyOnly, masks)
return sink.finish() return sink.finish()
end end
-- The tall-grass rows as their own mesh: VoxelScene draws it AFTER the local function quadsMesh(quads)
-- characters so the southern row of a grass cell still overdraws a if #quads == 0 then return nil end
-- 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 verts, indices, n = {}, {}, 0 local verts, indices, n = {}, {}, 0
for _, q in ipairs(S.grassQuads) do for _, q in ipairs(quads) do
for i = 1, 4 do for i = 1, 4 do
local c = q[i] local c = q[i]
local uv = q.uv and q.uv[i] or { q.u, q.v } local uv = q.uv and q.uv[i] or { q.u, q.v }
@@ -724,6 +719,33 @@ local function buildGrassMesh(map)
return Voxel3D.newMesh(verts, indices) return Voxel3D.newMesh(verts, indices)
end 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 -- ------------------------------------------------------------- the cache
local function entry(id) local function entry(id)
@@ -736,11 +758,12 @@ local function entry(id)
end end
local function releaseEntry(c) 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] local mesh = c[slot]
if mesh and mesh.release then pcall(mesh.release, mesh) end if mesh and mesh.release then pcall(mesh.release, mesh) end
c[slot] = nil c[slot] = nil
end end
c.stale = nil
end end
-- ---------------------------------------------------------- async builds -- ---------------------------------------------------------- async builds
@@ -778,26 +801,48 @@ end
local function runJob(job) local function runJob(job)
local map = job.map local map = job.map
local c = entry(job.id) 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) local okG, grass = pcall(buildGrassMesh, map)
if (gen[job.id] or 0) ~= job.gen then return end local okF, flowers = pcall(buildFlowerMesh, map)
c.grass = (okG and grass) or false 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 end
local sink = newSink() local sink = newSink()
runGeometry(map, job.slot == "body", job.masks, sink) runGeometry(map, job.slot == "body", job.masks, sink)
local mesh = sink.finish() local mesh = sink.finish()
if (gen[job.id] or 0) ~= job.gen then return end if (gen[job.id] or 0) ~= job.gen then
c[job.slot] = mesh or false 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 end
-- Queue a build unless the slot is already cached or queued. Returns the -- Queue a build unless the slot is already cached or queued. Returns the
-- cached mesh when there is one (false-cached misses return nil). -- cached mesh when there is one (false-cached misses return nil).
-- `urgent` marks the current map's meshes: pump() gives those a bigger -- `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) function ChunkMesher.request(map, bodyOnly, masks, urgent)
local slot = bodyOnly and "body" or "full" local slot = bodyOnly and "body" or "full"
local c = cache[map.id] 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 key = jobKey(map.id, slot)
local job = jobIndex[key] local job = jobIndex[key]
if not job then if not job then
@@ -808,7 +853,7 @@ function ChunkMesher.request(map, bodyOnly, masks, urgent)
elseif urgent then elseif urgent then
job.urgent = true job.urgent = true
end end
return nil return (c and c[slot]) or nil
end end
function ChunkMesher.pending() function ChunkMesher.pending()
@@ -871,17 +916,26 @@ end
function ChunkMesher.get(map, bodyOnly, masks) function ChunkMesher.get(map, bodyOnly, masks)
local slot = bodyOnly and "body" or "full" local slot = bodyOnly and "body" or "full"
local c = entry(map.id) 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) 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 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) local ok, mesh = pcall(ChunkMesher.build, map, bodyOnly, masks)
if not ok then if not ok then
print("[warn] voxel mesh build failed for " .. tostring(map.id) print("[warn] voxel mesh build failed for " .. tostring(map.id)
.. ": " .. tostring(mesh)) .. ": " .. tostring(mesh))
end 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 key = jobKey(map.id, slot)
local job = jobIndex[key] local job = jobIndex[key]
if job then finishJob(job, true) end if job then finishJob(job, true) end
@@ -901,6 +955,39 @@ function ChunkMesher.grass(map)
return c and c.grass or nil return c and c.grass or nil
end 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 -- Evict everything outside `live` (a set of map ids): far maps' meshes
-- are released -- GPU buffer and LOVE's CPU copy both -- and their -- are released -- GPU buffer and LOVE's CPU copy both -- and their
-- Structures analysis dropped. The live set is the current map plus its -- Structures analysis dropped. The live set is the current map plus its
+38 -3
View File
@@ -193,7 +193,7 @@ function Structures.forMap(map)
-- terrain.) -- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def), S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
runs = {}, skip = {}, ground = {}, objectQuads = {}, runs = {}, skip = {}, ground = {}, objectQuads = {},
grassQuads = {}, roundStamps = {} } grassQuads = {}, flowerQuads = {}, roundStamps = {} }
Buildings.build(S, map, pixels(tileset), perRow) Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player -- Fold doors into their buildings. A door cell is WALKABLE (the player
@@ -418,6 +418,27 @@ function Structures.forMap(map)
Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data) Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
end 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 -- unresolved claimed ground (a hull with no art match, headless
-- cylinders): no flat neighbour to vote with, so fall back to the -- cylinders): no flat neighbour to vote with, so fall back to the
-- map's commonest ground tile -- map's commonest ground tile
@@ -1847,7 +1868,17 @@ function Structures.buildObject(S, map, region, cluster,
end end
for _, c in ipairs(cluster.tiles) do for _, c in ipairs(cluster.tiles) do
local k = keyOf(c[1], c[2]) 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, -- the claimed tile keeps rendering as the box the prop stands on,
-- wearing the art its own ROW would have without the drawing (the -- 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 -- trim row stays trim); only when the whole row is the prop does
@@ -2130,7 +2161,11 @@ end
function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data) function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
local templates = {} local templates = {}
local quads = S.objectQuads -- 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 ty = y0, y1 do
for tx = x0, x1 do for tx = x0, x1 do
Budget.tick() Budget.tick()
+25
View File
@@ -460,6 +460,14 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(nbMesh[i], atlasFor(nb.map), ShadowMap.draw(nbMesh[i], atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy)) Mat4.translate(nb.ox, 0, nb.oy))
end 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 for _, p in ipairs(posed) do
local def = p.sprite.def local def = p.sprite.def
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip) 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), Voxel3D.draw(ChunkMesher.grass(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy), pull) Mat4.translate(nb.ox, 0, nb.oy), pull)
end 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() return Voxel3D.endScene()
end end
+6 -2
View File
@@ -344,9 +344,13 @@ end)
-- tree that is no longer there. The 2D tile renderer invalidates its own -- tree that is no longer there. The 2D tile renderer invalidates its own
-- caches off the same edit. -- 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) mod.events:on("world.block_replaced", function(payload)
local mapId = payload and (payload.mapId or (payload.map and payload.map.id)) 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) end)
-- A reloaded map is rebuilt from scratch (warps that re-enter the same map, -- 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 if mapId then ChunkMesher.invalidate(mapId) end
end) end)
mod.exports.version = "1.0.3" mod.exports.version = "1.0.4"
-- exposed so a companion mod can pin its own tiles' shapes or read the -- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout -- camera without reaching into this mod's file layout
mod.exports.lib = V mod.exports.lib = V
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "DRAMATIC_SHAPE", "id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod", "name": "Dramatic Shape Voxel Mod",
"version": "1.0.3", "version": "1.0.4",
"api": 2, "api": 2,
"entry": "main.lua", "entry": "main.lua",
"profile": "content", "profile": "content",
+10 -3
View File
@@ -535,8 +535,10 @@ local ChunkMesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local realInvalidate = ChunkMesher.invalidate 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.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" }) Runtime.emit("map.reloaded", { mapId = "PALLET_TOWN", reason = "colors" })
T.eq(#dropped, 0, T.eq(#dropped, 0,
@@ -551,11 +553,16 @@ T.eq(dropped[1], "PALLET_TOWN", "and drops exactly the map that reloaded")
Runtime.emit("map.reloaded", { mapId = "VIRIDIAN_CITY" }) Runtime.emit("map.reloaded", { mapId = "VIRIDIAN_CITY" })
T.eq(#dropped, 2, "a reload with no stated reason is treated as a real one") 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" }) 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.invalidate = realInvalidate
ChunkMesher.refresh = realRefresh
-- ------- the non-colour palette modes must not come through as SGB -- ------- the non-colour palette modes must not come through as SGB
-- --
+3
View File
@@ -94,6 +94,8 @@ return function(game)
if S then if S then
local def = ow.map.def local def = ow.map.def
for _, spec in ipairs(specs) do 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 local flat, prop, run, first = 0, 0, 0, nil
for ty = 0, def.height * 4 - 1 do for ty = 0, def.height * 4 - 1 do
for tx = 0, def.width * 4 - 1 do for tx = 0, def.width * 4 - 1 do
@@ -112,6 +114,7 @@ return function(game)
.. " %d inside a prop%s"):format(spec.tile, flat, run, prop, .. " %d inside a prop%s"):format(spec.tile, flat, run, prop,
first and (" -- first at cell " .. math.floor(first[1] / 2) first and (" -- first at cell " .. math.floor(first[1] / 2)
.. "," .. math.floor(first[2] / 2)) or "")) .. "," .. math.floor(first[2] / 2)) or ""))
::continue::
end end
end end
end end