fix flowers

This commit is contained in:
DramaticShape
2026-07-26 23:30:32 -04:00
parent 251bf85ba2
commit fdcd5f1fce
5 changed files with 419 additions and 8 deletions
+199
View File
@@ -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).
+77 -3
View File
@@ -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
+35 -3
View File
@@ -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
+92
View File
@@ -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
+16 -2
View File
@@ -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