DramaticShapeVoxelMod archival mirror · read-only
fdcd5f1fceab99360a202ca0d4dcdba82da6ba4a

fix flowers

DramaticShape · 2026-07-26 · parent 251bf85ba2

fix flowers

Changed files

lib/Structures.lua            | 199 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 lib/TerrainAtlas.lua          |  80 ++++++++++++++++++++++++-
 lib/TileShape.lua             |  38 +++++++++++-
 tests/dramatic_shape_test.lua |  92 +++++++++++++++++++++++++++++
 tests/voxel_anim_probe.lua    |  18 +++++-
 5 files changed, 419 insertions(+), 8 deletions(-)

Diff

diff --git a/lib/Structures.lua b/lib/Structures.lua
index 8019046..c262841 100644
--- a/lib/Structures.lua
+++ b/lib/Structures.lua
@@ -413,6 +413,9 @@ 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
 
   -- unresolved claimed ground (a hull with no art match, headless
@@ -1982,6 +1985,202 @@ 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 = {}
+  local quads = S.objectQuads
+  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).
diff --git a/lib/TerrainAtlas.lua b/lib/TerrainAtlas.lua
index ca3abf2..8d12e36 100644
--- a/lib/TerrainAtlas.lua
+++ b/lib/TerrainAtlas.lua
@@ -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
diff --git a/lib/TileShape.lua b/lib/TileShape.lua
index 00deba6..5384105 100644
--- a/lib/TileShape.lua
+++ b/lib/TileShape.lua
@@ -60,6 +60,7 @@ local FALLBACK_HEIGHTS = {
   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;
@@ -110,6 +111,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`
@@ -188,9 +194,11 @@ 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,6 +218,28 @@ function TileShape.forMap(map)
   local count = math.floor((tileset.imageWidth or 128) / 8)
                 * math.floor((tileset.imageHeight or 48) / 8)
 
+  -- 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 = {} }
   for class in pairs(FALLBACK_HEIGHTS) do
     shapes.classes[class] = shapeFor(class, heights)
@@ -222,6 +252,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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index cc752fd..5b2320b 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -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
 
diff --git a/tests/voxel_anim_probe.lua b/tests/voxel_anim_probe.lua
index 5b6c5ff..fd926d7 100644
--- a/tests/voxel_anim_probe.lua
+++ b/tests/voxel_anim_probe.lua
@@ -117,12 +117,26 @@ return function(game)
   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