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