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 "" end +ChunkMesher.refresh = function(id) refreshed[#refreshed + 1] = id or "" 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