mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 12:11:03 +02:00
246 lines
9.0 KiB
Lua
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")
|