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