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