Files
DramaticShapeVoxelMod/tests/voxel_figure_test.lua
T
DramaticShape 8e7a80055d fix couch man
2026-07-29 17:36:03 -04:00

246 lines
9.0 KiB
Lua

-- Figures: a person drawn INTO furniture, cut out by an authored pixel
-- mask and stood up on top of it (TileShape.figures / Structures.
-- buildFigures). The Pokemon Center's seated man is the case the feature
-- exists for, so this drives the real profile entry over a hand-built
-- copy of the couch block he is drawn in -- POKECENTER blockset entry $08,
-- as every Center places it:
--
-- y=8 36 37 57 11 36/52 west wall strip, 37/53 the MAN,
-- y=9 52 53 60 27 57/60 floor he overhangs east onto,
-- y=10 38 39 54 11 38/39 cushion, 42/43 the couch's base
-- y=11 42 43 26 27
--
-- No love, no GPU and no pixel access: a figure is authored rather than
-- detected, which is exactly what lets it build headless.
--
-- luajit mods/DramaticShapeVoxelMod/tests/voxel_figure_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
-- ------- the mod namespace (mirrors main.lua's V, minus the mod loader)
local ROOT = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod"
local V = { path = ROOT }
local function chunkFor(rel)
local f = assert(io.open(ROOT .. "/" .. rel, "rb"), rel .. " is missing")
local src = f:read("*a")
f:close()
return assert(load(src, "@" .. ROOT .. "/" .. rel))
end
local modules, dataFiles = {}, {}
function V.require(name)
if modules[name] == nil then
modules[name] = chunkFor("lib/" .. name .. ".lua")(V)
end
return modules[name]
end
function V.data(name)
if dataFiles[name] == nil then
dataFiles[name] = chunkFor("data/" .. name .. ".lua")(V)
end
return dataFiles[name]
end
local TileShape = V.require("TileShape")
local Structures = V.require("Structures")
-- ------- the authored mask parses
local figs = TileShape.figures("POKECENTER")
T.check(type(figs) == "table" and #figs == 1,
"POKECENTER carries exactly one figure")
local fig = figs[1]
T.eq(fig.w, 3, "the figure is three tiles across")
T.eq(fig.h, 2, "the figure is two tiles tall")
T.eq(fig.n, 139, "the mask claims 139 pixels of the 384 it spans")
T.check(fig.class == nil,
"a figure carries no class -- it is always a flat sprite card")
local W = fig.w * 8
local function on(lx, ly) return fig.mask[ly * W + lx] == true end
-- the BACK OF HIS HEAD, in the two rightmost columns of tile 36 (the
-- couch's west arm): rows 0-1 are the arm, row 2 is his hair in column 7
-- only, rows 3-7 are his hair in both
T.check(not on(6, 0) and not on(7, 0) and not on(6, 1) and not on(7, 1),
"the arm's own top rows stay with the couch")
T.check(not on(6, 2) and on(7, 2), "his hair starts in column 7 at row 2")
for ly = 3, 7 do
T.check(on(6, ly) and on(7, ly),
"the back of his head fills both columns at row " .. ly)
end
-- no holes in him: 37/53's column 7 (local 15) is the couch's east rule AND
-- the right side of his face, and masking it out by shade slit his cheek
for ly = 5, 8 do
T.check(on(15, ly),
"his cheek is solid at row " .. ly .. " (the column-7 slit)")
end
T.eq(fig.tiles[1], 36, "it starts at the couch's west arm")
T.eq(fig.tiles[2], 37, "then the tile his head is drawn in")
T.eq(fig.under[1], 52, "the arm wears the plain strip once his hair is off")
T.eq(fig.under[2], 39, "his head tile wears the couch's own cushion")
T.eq(fig.under[3], 1, "and the floor tiles wear the clean art (57 -> 1)")
T.eq(fig.under[6], 26, "and (60 -> 26)")
-- the mask is one connected figure: an overhang that floats free of him
-- is the failure this feature exists to avoid
local seen, first = {}, nil
for i in pairs(fig.mask) do first = first or i end
local stack, reached = { first }, 0
seen[first] = true
while #stack > 0 do
local i = table.remove(stack)
reached = reached + 1
local x, y = i % W, math.floor(i / W)
for dy = -1, 1 do
for dx = -1, 1 do
local nx, ny = x + dx, y + dy
local ni = ny * W + nx
if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < W
and fig.mask[ni] and not seen[ni] then
seen[ni] = true
stack[#stack + 1] = ni
end
end
end
end
T.eq(reached, fig.n, "the mask is a single connected figure")
-- ------- the couch block, as the Centers place it
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
local COUNTER = { class = "counter", h = 8, art = "upright",
flat = false, authored = true }
local GROUND = { class = "ground", h = 0, art = "flat",
flat = true, authored = false }
local BLOCK = { 36, 37, 57, 11,
52, 53, 60, 27,
38, 39, 54, 11,
42, 43, 26, 27 }
local COUCH = { [36] = true, [37] = true, [38] = true, [39] = true,
[42] = true, [43] = true, [52] = true, [53] = true }
local function scene()
local S = { shapeAt = {}, tileAt = {}, figures = {}, skip = {},
ground = {}, runs = {} }
for i = 1, 16 do
local tx, ty = (i - 1) % 4, 8 + math.floor((i - 1) / 4)
local tile = BLOCK[i]
S.tileAt[keyOf(tx, ty)] = tile
S.shapeAt[keyOf(tx, ty)] = COUCH[tile] and COUNTER or GROUND
end
return S
end
-- collision is per 16x16 cell: the couch's cell is blocked (he is drawn
-- sitting on it), the floor cell east of it is walkable
local map = {
tileset = { id = "POKECENTER", tilesPerRow = 16,
imageWidth = 128, imageHeight = 48 },
isWalkableCell = function(_, cx) return cx >= 1 end,
}
-- ------- he comes off the couch
local S = scene()
Structures.buildFigures(S, map, 0, 3, 8, 11)
T.eq(#S.figures, 1, "one figure card was built")
local card = S.figures[1]
T.eq(#card.quads, fig.n, "one quad per masked pixel, and nothing else")
T.eq(S.tileAt[keyOf(0, 8)], 52, "the arm wears the plain strip now")
T.eq(S.tileAt[keyOf(1, 8)], 39, "his head tile now wears the cushion")
T.eq(S.tileAt[keyOf(1, 9)], 39, "his body tile too")
T.eq(S.tileAt[keyOf(2, 8)], 1, "the floor he overhung is clean floor again")
T.eq(S.tileAt[keyOf(2, 9)], 26, "both rows of it")
T.eq(S.tileAt[keyOf(1, 10)], 39, "the couch's own cushion row is untouched")
T.eq(S.tileAt[keyOf(1, 11)], 43, "and so is its drawn base")
-- the couch keeps its box: a figure changes ART, never class
T.eq(S.shapeAt[keyOf(1, 8)].class, "counter",
"his tiles are still the couch's half-cell box")
T.check(S.skip[keyOf(1, 8)] ~= true,
"and are not skipped -- the couch still renders there")
-- ------- the card is flat, and stands at its feet
local minX, maxX, minY, maxY, minZ, maxZ
for _, q in ipairs(card.quads) do
for c = 1, 4 do
local p = q[c]
minX = math.min(minX or p[1], p[1]); maxX = math.max(maxX or p[1], p[1])
minY = math.min(minY or p[2], p[2]); maxY = math.max(maxY or p[2], p[2])
minZ = math.min(minZ or p[3], p[3]); maxZ = math.max(maxZ or p[3], p[3])
end
end
T.eq(minZ, 0, "the card is a single plane at z = 0 (no thickness)")
T.eq(maxZ, 0, "on both sides -- it is a sprite, not a slab")
T.eq(minY, 0, "local space: his feet are the card's origin")
T.eq(maxY, 16, "and he is his drawn 16px tall")
T.eq(minX, 0, "his west edge is the card's origin too")
T.eq(maxX, 12, "and he is 12px wide -- arm hair to floor overhang")
-- ------- and where VoxelScene stands it
T.eq(card.y, 8, "his feet stand on the couch's top face, not the floor")
T.eq(card.wx, 6, "anchored at the back of his head, in tile 36's column 6")
T.eq(card.wz, 76,
"and pivoting at the middle of the tile row his feet are drawn in")
-- ------- a map that does not draw him builds nothing
local other = scene()
other.tileAt[keyOf(1, 8)] = 40
Structures.buildFigures(other, map, 0, 3, 8, 11)
T.eq(#other.figures, 0, "no match, no figure")
T.eq(other.tileAt[keyOf(2, 8)], 57, "and nothing repainted")
-- ------- and it never fires twice on the same drawing
local twice = scene()
Structures.buildFigures(twice, map, 0, 3, 8, 11)
Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.figures, 1,
"the repaint replaces the pattern, so a rescan cannot match it again")
-- ------- prop_bg: the shades a pinned prop treats as background
--
-- The potted plants needed this: their pot's olive base is drawn flush on
-- the bottom of the plant block, so the ordinary vote (shades touching the
-- drawing's own bounding box) read "dark" as background and drained every
-- olive pixel in the plant.
local bgRules = TileShape.propBg("POKECENTER")
T.check(type(bgRules) == "table", "POKECENTER names prop background shades")
for _, tile in ipairs({ 32, 33, 34, 35, 48, 49, 50, 51 }) do
local set = bgRules and bgRules[tile]
T.check(type(set) == "table" and set.light and set.white
and not set.dark and not set.black,
"plant tile " .. tile .. ": light/white are background, dark is the pot")
end
-- and it is scoped: the healing consoles' screens and the PC keep the
-- ordinary vote, because they want the opposite call on those same shades
for _, tile in ipairs({ 58, 59, 66, 70, 74, 75, 82, 86 }) do
T.check(bgRules[tile] == nil,
"tile " .. tile .. " keeps the ordinary background vote")
end
-- a tileset with no prop_bg at all answers nil rather than an empty table,
-- so Structures can skip the lookup entirely
T.check(TileShape.propBg("CAVERN") == nil,
"a tileset that names none answers nil")
T.finish("DRAMATIC_SHAPE figures")