1.0.4: flower/bush/statue/trash-can billboards, in-place mesh refresh
DramaticShape · 2026-07-27 ·
parent fdcd5f1fce
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.
Changed files
CHANGELOG.md | 104 ++++++++++++++++++++++++++++++++++++++++++++++++++
data/voxel_heights.lua | 39 +++++++++++++++++++
lib/ChunkMesher.lua | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++----------
lib/Structures.lua | 41 ++++++++++++++++++--
lib/VoxelScene.lua | 25 ++++++++++++
main.lua | 8 +++-
manifest.json | 2 +-
tests/dramatic_shape_test.lua | 13 +++++--
tests/voxel_anim_probe.lua | 3 ++
9 files changed, 334 insertions(+), 30 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53b4294..7acff95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,109 @@
# 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
### Added
diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua
index 2757fa9..5fe4b67 100644
--- a/data/voxel_heights.lua
+++ b/data/voxel_heights.lua
@@ -65,6 +65,11 @@
-- monitor on its desk). prop is a second pool
-- 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
-- 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
@@ -124,6 +129,40 @@ return {
-- fence-textured tower. `post` extracts each cell alone, so these
-- render as the same thin posts, marching north
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
diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua
index 77c2d4b..f742a43 100644
--- a/lib/ChunkMesher.lua
+++ b/lib/ChunkMesher.lua
@@ -704,15 +704,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 +719,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 +758,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 +801,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 +853,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 +916,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 +955,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
diff --git a/lib/Structures.lua b/lib/Structures.lua
index c262841..127d3c0 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -193,7 +193,7 @@ function Structures.forMap(map)
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
runs = {}, skip = {}, ground = {}, objectQuads = {},
- grassQuads = {}, roundStamps = {} }
+ grassQuads = {}, flowerQuads = {}, roundStamps = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- 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)
end
+ -- ---- authored ground under pinned props ----
+ -- The profile can name the tile a pinned prop stands on (a tileset
+ -- entry's prop_ground: prop tile id -> ground tile id), overriding
+ -- the neighbour vote. The cuttable bush stands on the plain grass
+ -- Cut itself leaves behind, not on whatever path its neighbours
+ -- happen to vote in.
+ do
+ local okP, prof = pcall(V.data, "voxel_heights")
+ local entry = okP and type(prof) == "table" and prof.tilesets
+ and prof.tilesets[tileset.id]
+ local pg = entry and entry.prop_ground
+ if type(pg) == "table" then
+ for k, skipped in pairs(S.skip) do
+ if skipped then
+ local g = pg[S.tileAt[k]]
+ if g then S.ground[k] = g end
+ end
+ end
+ end
+ end
+
-- unresolved claimed ground (a hull with no art match, headless
-- cylinders): no flat neighbour to vote with, so fall back to the
-- map's commonest ground tile
@@ -1847,7 +1868,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
@@ -2130,7 +2161,11 @@ end
function Structures.buildFlowers(S, map, tw, th, x0, x1, y0, y1, data)
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 tx = x0, x1 do
Budget.tick()
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index c4d0742..d8b584b 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -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
diff --git a/main.lua b/main.lua
index 94f7fd8..9b01be9 100644
--- a/main.lua
+++ b/main.lua
@@ -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.4"
-- 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
diff --git a/manifest.json b/manifest.json
index be6dca3..fa53bbb 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,7 +1,7 @@
{
"id": "DRAMATIC_SHAPE",
"name": "Dramatic Shape Voxel Mod",
- "version": "1.0.3",
+ "version": "1.0.4",
"api": 2,
"entry": "main.lua",
"profile": "content",
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 5b2320b..5447507 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -535,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,
@@ -551,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
--
diff --git a/tests/voxel_anim_probe.lua b/tests/voxel_anim_probe.lua
index fd926d7..3ff4475 100644
--- a/tests/voxel_anim_probe.lua
+++ b/tests/voxel_anim_probe.lua
@@ -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,6 +114,7 @@ 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