Files
DramaticShape dcf078c2e0 1.0.4: flower/bush/statue/trash-can billboards, in-place mesh refresh
Flowers stand as 1px animated cutouts on their own pulled mesh (own-cell
flowers hide behind the walker, the cell south still overdraws feet).
Cuttable bushes, gym statues (single-block plinth) and Lt. Surge's trash
cans stand as 5-voxel prop cutouts; prop_ground names the tile painted
beneath. Block edits refresh meshes in place instead of blinking to 2D.
2026-07-27 00:11:06 -04:00

151 lines
5.9 KiB
Lua

-- Driver: does the water/flower tile animation reach voxel mode?
--
-- Shoots a burst of frames spaced about one animation period apart, in
-- voxel mode and (for reference) flat. The evidence is the difference
-- between consecutive shots: water tile $14 rolls sideways a pixel a step
-- and flower tile $03 cycles three frames, so the burst should NOT be
-- eight copies of the same picture.
--
-- POKEPORT_DRIVER=mods/DRAMATIC_SHAPE/tests/voxel_anim_probe.lua lovec .
--
-- knobs (env):
-- ANIM_MAP map id (default PALLET_TOWN)
-- ANIM_SPOT "x,y[,facing]" (default 5,6,down)
-- ANIM_SHOTS frames in the burst (default 6)
-- ANIM_LEVEL voxel rung, 0 = flat (default 3)
-- SHOT_DIR output directory, must exist (default "shots")
return function(game)
local U = dofile("tests/drivers/util.lua")
local Pipelines = require("src.render.Pipelines")
local TileRenderer = require("src.render.TileRenderer")
local SPEED = math.max(1,
math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1))
local function wait(n) U.wait(n * SPEED) end
local DIR = os.getenv("SHOT_DIR") or "shots"
local mapId = os.getenv("ANIM_MAP") or "PALLET_TOWN"
local shots = math.floor(tonumber(os.getenv("ANIM_SHOTS")) or 6)
local level = math.floor(tonumber(os.getenv("ANIM_LEVEL")) or 3)
local sx, sy, facing = (os.getenv("ANIM_SPOT") or "5,6,down")
:match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)")
facing = (facing ~= "" and facing) or "down"
local tileset = game.data.maps[mapId] and game.data.maps[mapId].tileset
local specs = tileset and game.data.tilesets
and TileRenderer.defaultAnimatedTiles(game.data.tilesets[tileset])
print(("[anim] %s tileset=%s animated entries=%d")
:format(mapId, tostring(tileset), specs and #specs or 0))
for _, s in ipairs(specs or {}) do
print(("[anim] tile $%02X kind=%s period=%s")
:format(s.tile or -1, tostring(s.kind), tostring(s.period)))
end
local Zoom = require("src.render.Zoom")
Zoom.reset()
Pipelines.setLevel("tiltshift", 0)
U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing)
wait(20)
Pipelines.setLevel("voxel", level)
wait(30) -- outlast the camera tween
-- the atlas the terrain mesh actually samples, dumped per step: if these
-- differ but the frames do not, the animated tiles are not reaching the
-- geometry (baked into prop prisms, say) rather than not animating
local V = game.mods.exports["DRAMATIC_SHAPE"]
V = V and V.lib
local TerrainAtlas = V and V.require("TerrainAtlas")
local PaletteFX = require("src.render.PaletteFX")
local function dumpAtlas(tag)
if not TerrainAtlas then return end
local ow = game.overworld
local map = ow and ow.map
if not map then return end
local colors = nil
if not PaletteFX.usesGbcPack() and ow.paletteFor then
colors = ow:paletteFor(map)
end
local img = TerrainAtlas.forMap(map, colors)
if not img then return end
print(("[anim] atlas %s: animated copy=%s")
:format(tag, tostring(img ~= map.renderer.image)))
local ok = pcall(function()
local w, h = img:getDimensions()
local c = love.graphics.newCanvas(w, h)
love.graphics.setCanvas(c)
love.graphics.clear(0, 0, 0, 0)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img)
love.graphics.setCanvas()
c:newImageData():encode("png", "atlas_" .. tag .. ".png")
end)
if not ok then print("[anim] atlas dump failed") end
end
-- where the animated tiles ended up in the geometry: a flat top quad
-- samples its tile through uvRect and picks the atlas patch up, but a
-- cell Structures turned into a prop prism has its pixels baked as
-- point-sampled voxels and would not
if V and specs then
local Structures = V.require("Structures")
local ow = game.overworld
local S = ow and ow.map and Structures.forMap(ow.map)
if S then
local def = ow.map.def
for _, spec in ipairs(specs) do
if not spec.tile then goto continue end -- "toggle" specs claim
-- tile LISTS, not one slot
local flat, prop, run, first = 0, 0, 0, nil
for ty = 0, def.height * 4 - 1 do
for tx = 0, def.width * 4 - 1 do
local k = (ty + 64) * 4096 + (tx + 64)
if S.tileAt[k] == spec.tile then
if S.skip[k] then prop = prop + 1
elseif S.runs[k] then run = run + 1
else
flat = flat + 1
first = first or { tx, ty }
end
end
end
end
print(("[anim] tile $%02X on this map: %d flat, %d in a volume run,"
.. " %d inside a prop%s"):format(spec.tile, flat, run, prop,
first and (" -- first at cell " .. math.floor(first[1] / 2)
.. "," .. math.floor(first[2] / 2)) or ""))
::continue::
end
end
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.
-- 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, clock(), math.floor(clock() / 20) % 8))
dumpAtlas(tostring(i))
wait(22)
end
Pipelines.setLevel("voxel", 0)
wait(5)
print("[anim] done")
end