fix vertical line in battle sprites

This commit is contained in:
DramaticShape
2026-07-29 15:35:28 -04:00
parent 0f303e7975
commit 3c1781f6b1
8 changed files with 570 additions and 16 deletions
+63 -16
View File
@@ -70,6 +70,15 @@
-- pinned prop, overriding the flat-neighbour vote -- the cuttable bush
-- stands on the grass Cut leaves behind, whatever borders it.
--
-- And it may carry `figures` (not a class either): hand-drawn pixel masks
-- that lift a FIGURE PAINTED INTO furniture off it and stand it up as a
-- standee, leaving the furniture its own geometry. A pin resolves a
-- whole 8x8 tile, so it can never separate two things that share one --
-- and nothing automatic can either when the figure has no background
-- margin to flood from and wears the same shades as what it sits on.
-- The Pokemon Center's seated man is the case; see POKECENTER below for
-- the format.
--
-- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
-- above, a facade seen face-on and sloped ends as diagonal silhouettes,
-- and no single class covers that. They live in the `buildings` list at
@@ -694,22 +703,17 @@ return {
-- bodies so they stand ON them) and the PC (66/70/82/86), which
-- stands on its pinned desk
billboard = { 58, 59, 66, 70, 74, 75, 82, 86 },
-- Why the man on the couch rides the top face instead of standing
-- up. He is drawn across two tile rows (skin pixels appear in
-- rows 8 and 9 and stop dead at the row 9/10 seam), and folding
-- two rows upright requires both to share a class, which makes the
-- box two tiles deep -- and a fully folded box repeats its north
-- row across its whole top face. So every upright arrangement
-- puts his head on screen two or three times: as a 16px seat-back
-- his head lands on the front AND twice on the top; as a 32px
-- bookcase he becomes a cabinet taller than the room's walls.
-- Dropping the seat to floor level to clear his face does not help
-- either, it only moves which copy you see. He also cannot be a
-- standee: the drawing has no floor margin, so all three non-black
-- shades touch the cluster rim and the mask drains 307 of its 420
-- interior pixels -- 46% of him even segmented alone, because his
-- skin is the same light shade as the couch behind him. Riding
-- the top face is the one arrangement that draws him exactly once.
-- The man on the couch, cut out by hand and stood up (see `figures`
-- below). Nothing automatic reaches him. A class pin resolves a
-- whole 8x8 tile and he SHARES his tiles with the couch, so pinning
-- them stands the furniture up with him; riding the couch's top
-- face (what this did before) draws him lying flat on the cushion;
-- and he cannot be segmented into a standee either, because the
-- drawing has no background margin for a flood to enter by and his
-- skin is the same light shade as the couch -- the mask drained 307
-- of his 420 interior pixels, 46% of him, even segmented alone.
-- An authored mask is the only thing that can tell a man from the
-- sofa he is painted into, so that is what `figures` carries.
-- the PC's desk body, which the PC stands on
table = { 9, 88 },
-- the potted plants: bush (32/33/48/49) over pot (34/35/50/51,
@@ -721,6 +725,49 @@ return {
-- them; the standee reads darker than the flat art, which is the
-- accepted trade for a real plant silhouette
prop = { 32, 33, 34, 35, 48, 49, 50, 51 },
-- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture --
-- tiles 37 (head) and 53 (face and body) are his, and his hair and
-- his foot spill one tile east onto the floor (57 and 60 are the
-- plain floor tiles 1 and 26 with those pixels painted over him).
-- `pixels` is the hand-drawn line between the man and the couch;
-- `under` is what each tile wears once he is lifted off it: the
-- couch's own cushion (39) where he sat, and 1 / 26, the clean
-- floor the artist had already drawn. So the couch keeps its
-- polygonal box and he stands on top of it, at NPC scale.
--
-- The mask keeps the couch's east rule (column 7) wherever it is
-- drawn dark, because that is where his hair crosses the tile seam
-- -- without it the overhang would float free of his head. The
-- cushion wedge under his legs and the two floor pixels above his
-- head are the only interior pixels it gives back.
figures = {
{
class = "billboard",
w = 2,
tiles = { 37, 57,
53, 60 },
under = { 39, 1,
39, 26 },
pixels = {
"..XXXXX.........",
"XXXXXXXX........",
"XXXXXXXXX.......",
"XXXXXXXXXX......",
"XXXXXXXXXX......",
"XXXXXXX.X.......",
"XXXXXXX.X.......",
"XXXXXXX.X.......",
"XXXXXXX.X.......",
"XXXXXXXX........",
"XXXXXXXX........",
"XXXXXXXX........",
"XXXXXXXXX.......",
".XXXXXXXXX......",
"...XXXXXX.......",
"......XX........",
},
},
},
},
-- The Poke Marts (one 4x4 layout serves every city; the tileset
+17
View File
@@ -42,6 +42,20 @@ local quad = nil -- nil = untried, false = unavailable
-- The unit card: x in -0.5..0.5, y in 0..1, z = 0, UV over the whole
-- texture. Feet on the model origin, so the model matrix only has to say
-- where the mon is standing and how big it is.
--
-- Which puts this card OFF the voxel grid, alone among the meshes in this
-- mode: the rest are built one unit per voxel in their own model space --
-- terrain in world pixels, a character's card in the sprite's own pixels --
-- and the wireframe is the integer planes of that space (see VoxelGrid).
-- One unit here is the whole card, so the only integer plane inside it is
-- x = 0, which is the pic's centre column: a single stray line straight
-- down the middle of every Pokemon and no seams anywhere else.
--
-- The card stays a unit card, because a mon's size on screen is decided by
-- the artwork's own dimensions and the distance it is standing at, and a
-- unit card is what lets one matrix say both. Whoever draws it turns the
-- wireframe off instead (Voxel3D.seams) -- a mesh that is not on the voxel
-- grid does not get a voxel grid drawn on it.
local function unitQuad()
if quad ~= nil then return quad or nil end
local verts = {
@@ -93,8 +107,11 @@ function BattleBillboard.draw(tex, x, y, z, grow)
if grow then w, h = w * grow, h * grow end
if w <= 0 or h <= 0 then return false end
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
-- off the voxel grid, so no wireframe on it (see unitQuad)
Voxel3D.seams(false)
Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw),
BattleBillboard.PULL)
Voxel3D.seams(true)
return true
end
+6
View File
@@ -378,10 +378,16 @@ function BattleScene.render(state, arena, textures, token)
if flashing then
Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH)
end
-- and no voxel wireframe on the pair. Everything else in this frame is
-- built a unit per voxel and wears the seams that fall out of that; a
-- mon's card is one quad wearing the battle screen (see
-- BattleBillboard), so it is off the grid and has no seams to draw.
Voxel3D.seams(false)
for _, card in ipairs(monCards(arena, groundY, textures)) do
Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model,
BattleBillboard.PULL)
end
Voxel3D.seams(true)
if flashing then Voxel3D.flatten(nil) end
-- grass and flowers ride the same camera-ward pull the free-roam pass
-- gives them, measured against THIS camera's pitch rather than the
+143
View File
@@ -268,6 +268,14 @@ function Structures.forMap(map)
-- ---- bookcases: pinned shelves collapsed to one cell of depth ----
Structures.buildBookcases(S, map, x0, x1, y0, y1)
-- ---- figures: a person drawn INTO furniture, lifted off it ----
-- Before the region flood and the volume pass, so everything after this
-- reads the tiles the profile says are there once the figure is gone.
-- (Its own tiles are authored furniture or walkable floor either way, so
-- no pass below would have claimed them -- but the repaint is what those
-- passes should see, and this needs no pixel access to do it.)
Structures.buildFigures(S, map, x0, x1, y0, y1)
-- ---- flood-fill regions of structural tiles ----
local seen = {}
local regions = {}
@@ -2116,6 +2124,141 @@ function Structures.buildObject(S, map, region, cluster,
return true
end
-- ---- figures: a person drawn INTO furniture, cut out and stood up ----
-- One authored figure at one matched position.
--
-- The mask IS the classification: no flood, no shade segmentation, no
-- validation gate. Every automatic route in this file asks the art where
-- the object ends, and a figure painted into its own furniture has no
-- answer to give -- so the profile answers instead, and this only has to
-- believe it. Which also means figures build HEADLESS: unlike every
-- other standee here, nothing below reads a pixel.
local function buildFigure(S, map, fig, tx, ty, perRow)
local bw, bh = fig.w * 8, fig.h * 8
local function at(lx, ly)
if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end
return fig.mask[ly * bw + lx] or false
end
-- his feet: the lowest drawn row, which is what lands on the support
local lowY = 0
for ly = bh - 1, 0, -1 do
local any = false
for lx = 0, bw - 1 do
if at(lx, ly) then any = true break end
end
if any then lowY = ly break end
end
-- He stands ON the furniture he was drawn into -- the same lift a pinned
-- prop above a pinned box takes (see buildObject), and gated the same
-- way: a thing set down on furniture occupies a BLOCKED cell, while a
-- seat you merely walk up to is in a walkable one.
local baseY, support = 0, nil
local bs = S.shapeAt[keyOf(tx, ty + fig.h)]
local blocked = not map:isWalkableCell(math.floor(tx / 2),
math.floor((ty + fig.h - 1) / 2))
if blocked and bs and bs.authored and bs.art == "upright"
and (bs.h or 0) > 0 then
baseY, support = bs.h, bs
end
local depth = PINNED_DEPTH[fig.class] or PINNED_DEPTH.billboard
local z0 = ty * 8 + math.floor(lowY / 8) * 8
+ (support and 8 or 0) + (8 - depth) / 2
local z1 = z0 + depth
-- ONE object by construction: the figure keeps its drawn proportions
-- whatever the mask's connectivity, so an overhang (his hair crossing
-- the tile seam) stays at its drawn height instead of being dropped to
-- the floor as a component of its own.
local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48
local quads = S.objectQuads
local wx0 = tx * 8
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
if at(lx, ly) then
local tile = fig.tiles[math.floor(ly / 8) * fig.w
+ math.floor(lx / 8) + 1]
local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW
local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH
local x, y = wx0 + lx, baseY + lowY - ly
local function quad(c1, c2, c3, c4, shade)
quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade }
end
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y + 1, z1 },
{ x, y + 1, z1 }, OBJ_SHADE.front)
quad({ x + 1, y, z0 }, { x, y, z0 }, { x, y + 1, z0 },
{ x + 1, y + 1, z0 }, OBJ_SHADE.back)
if not at(lx, ly - 1) then
quad({ x, y + 1, z0 }, { x + 1, y + 1, z0 }, { x + 1, y + 1, z1 },
{ x, y + 1, z1 }, OBJ_SHADE.top)
end
if y > baseY and not at(lx, ly + 1) then
quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y, z0 },
{ x, y, z0 }, OBJ_SHADE.bottom)
end
if not at(lx - 1, ly) then
quad({ x, y, z0 }, { x, y, z1 }, { x, y + 1, z1 },
{ x, y + 1, z0 }, OBJ_SHADE.side)
end
if not at(lx + 1, ly) then
quad({ x + 1, y, z1 }, { x + 1, y, z0 }, { x + 1, y + 1, z0 },
{ x + 1, y + 1, z1 }, OBJ_SHADE.side)
end
end
end
end
-- What each covered tile wears now that he is off it. Only the ART
-- changes: the couch tiles keep their `counter` box (they ARE the
-- couch) and the floor tiles he overhung stay flat floor -- the
-- profile just names the version of each drawing without him in it,
-- so nothing has to be synthesized or repainted from a neighbour vote.
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
S.tileAt[keyOf(tx + dx, ty + dy)] = fig.under[i]
end
end
-- Every authored figure, wherever the map draws it.
--
-- Matched by TILE PATTERN rather than by coordinates: one blockset entry
-- places this couch once in each of the eleven Pokemon Centers (and the
-- Celadon Hotel), so the pattern finds all of them without the profile
-- naming a single map or cell. The repaint above replaces the pattern's
-- own tiles, so a match can never fire twice on the same drawing.
function Structures.buildFigures(S, map, x0, x1, y0, y1)
local figures = TileShape.figures(map.tileset.id)
print("[fig] map", map.id, "tileset", map.tileset.id, "figures",
figures and #figures or "nil")
if not figures then return end
local perRow = map.tileset.tilesPerRow or 16
for _, fig in ipairs(figures) do
for ty = y0, y1 - fig.h + 1 do
for tx = x0, x1 - fig.w + 1 do
Budget.tick()
local hit = true
for i = 1, #fig.tiles do
local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w)
if S.tileAt[keyOf(tx + dx, ty + dy)] ~= fig.tiles[i] then
hit = false
break
end
end
if hit then
print("[fig] MATCH at", tx, ty)
buildFigure(S, map, fig, tx, ty, perRow)
end
end
end
end
end
-- ---- tall grass ----
-- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one
+74
View File
@@ -168,6 +168,7 @@ local ART = {
local spec = nil -- the loaded data file, or false when absent
local cache = {} -- tileset id -> resolved shape list
local figCache = {} -- tileset id -> parsed figure masks, or false
-- The shape profile ships with the mod (data/voxel_heights.lua) and is read
-- through the mod's own file loader rather than package.path: a mod's
@@ -369,11 +370,84 @@ function TileShape.at(map, shapes, tile, tx, ty)
return s
end
-- Hand-authored FIGURES for one tileset: a drawing painted INTO furniture,
-- cut out by an explicit pixel mask and stood up on top of it.
--
-- Every other route in this file resolves a whole 8x8 TILE, which is
-- exactly why none of them can reach a figure that shares its tiles with
-- the thing it sits on -- and the detector's segmentation cannot either
-- when the drawing has no background margin to flood from and wears the
-- same shades as its furniture. So the profile authors the silhouette
-- pixel by pixel (see data/voxel_heights.lua):
--
-- figures = { { class = <standee pool, for its depth>,
-- w = <tiles across>,
-- tiles = { ...w*h tile ids, row-major... },
-- under = { ...w*h ids: what each tile wears once the
-- figure is lifted off it... },
-- pixels = { ...h*8 strings of w*8 chars, "." = not the
-- figure... } } }
--
-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so
-- Structures can read it as a bitmap without re-parsing per position.
-- A malformed entry is dropped rather than half-applied -- a typo in a
-- mask should leave the couch alone, not carve a hole in it.
function TileShape.figures(tilesetId)
local hit = figCache[tilesetId]
if hit ~= nil then return hit or nil end
local s = load()
local entry = s and s.tilesets and s.tilesets[tilesetId]
local list = entry and entry.figures
local out = {}
if type(list) == "table" then
for _, f in ipairs(list) do
local ok = type(f) == "table" and type(f.w) == "number"
and type(f.tiles) == "table" and type(f.under) == "table"
and type(f.pixels) == "table"
local w = ok and math.floor(f.w) or 0
local h = (w >= 1) and (#f.tiles / w) or 0
ok = ok and w >= 1 and h >= 1 and h == math.floor(h)
and #f.under == #f.tiles and #f.pixels == h * 8
if ok then
for i = 1, h * 8 do
local row = f.pixels[i]
if type(row) ~= "string" or #row ~= w * 8 then
ok = false
break
end
end
end
if ok then
local mask, n = {}, 0
for ly = 0, h * 8 - 1 do
local row = f.pixels[ly + 1]
for lx = 0, w * 8 - 1 do
if row:sub(lx + 1, lx + 1) ~= "." then
mask[ly * (w * 8) + lx] = true
n = n + 1
end
end
end
if n > 0 then
out[#out + 1] = { class = f.class or "billboard",
w = w, h = h, n = n, mask = mask,
tiles = f.tiles, under = f.under }
end
end
end
end
figCache[tilesetId] = (#out > 0) and out or false
return figCache[tilesetId] or nil
end
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
function TileShape.invalidate()
spec = nil
cache = {}
figCache = {}
end
return TileShape
+25
View File
@@ -558,6 +558,31 @@ function Voxel3D.flatten(color, amount)
end
end
-- Whether what is drawn next carries the voxel wireframe. false for the
-- length of a draw, true to put it back.
--
-- The wireframe reads a mesh's OWN model space and darkens its integer
-- planes (see VoxelGrid), which is only a wireframe because every mesh in
-- this mode is built ONE UNIT PER VOXEL: terrain in world pixels, a
-- character card in the sprite's own pixels. A mesh whose model space does
-- not mean that gets no wireframe out of the same shader -- it gets
-- whichever of its integer planes happen to fall inside it, which is a
-- stray line rather than a seam.
--
-- So this is not a style switch. It is how a mesh that is not on the voxel
-- grid says so, and the alternative -- rescaling such a mesh until its
-- units happen to be voxels -- would change what it IS to satisfy a
-- shading pass.
--
-- Sent rather than branched because the plain scene shader has no such
-- uniform, and the send simply does not take there -- which is right: with
-- no wireframe compiled in there is nothing to suppress.
function Voxel3D.seams(on)
if not (active and activeShader) then return end
pcall(activeShader.send, activeShader, "gridDark",
on and VoxelGrid.DARK or 0)
end
function Voxel3D.endGhost()
if not active then return end
pcall(love.graphics.setDepthMode, "lequal", true)
+50
View File
@@ -0,0 +1,50 @@
-- Probe: one overworld battle, held on the menu beat, shot large.
--
-- SHOT_DIR=... POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/monline_probe.lua love .
return function(game)
local U = dofile("tests/drivers/util.lua")
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
local Pokemon = require("src.pokemon.Pokemon")
local BattleState = require("src.battle.BattleState")
love.math.setRandomSeed(20260727)
game.save.party = {
Pokemon.new(game.data, "CHARIZARD", 45),
Pokemon.new(game.data, "PIKACHU", 40),
}
game.save.player.name = "RED"
local exports = game.mods and game.mods.exports
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
U.log("DRAMATIC_SHAPE lib:", tostring(lib))
local Battles = lib and lib.require("OverworldBattle")
U.log("OverworldBattle:", tostring(Battles))
U.teleport(game, "ROUTE_1", 5, 8, "down")
U.wait(120)
local classes = {}
for id, rec in pairs(game.data.trainers) do
if type(id) == "string" and id:sub(1, 1) ~= "_"
and type(rec) == "table" and rec.parties and rec.parties[1] then
classes[#classes + 1] = id
end
end
table.sort(classes)
U.log("class:", classes[1])
local battle = BattleState.newTrainer(game, classes[1], 1)
battle.onFinish = function() end
game.overworld:pushBattle(battle)
U.wait(70)
for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
local arena = Battles and Battles.arena()
U.log("arena:", arena and arena.shape or "none")
U.shot(game, DIR .. "/probe_menu.png")
for _ = 1, 20 do U.tap(game, "a"); U.wait(8) end
U.wait(90)
U.shot(game, DIR .. "/probe_menu2.png")
U.log("done -- " .. DIR)
love.event.quit()
end
+192
View File
@@ -0,0 +1,192 @@
-- 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, 2, "the figure is two tiles across")
T.eq(fig.h, 2, "the figure is two tiles tall")
T.eq(fig.n, 124, "the mask claims 124 pixels of the 256 it spans")
T.eq(fig.tiles[1], 37, "it starts at the tile his head is drawn in")
T.eq(fig.under[1], 39, "which wears the couch's own cushion once he is off")
T.eq(fig.under[2], 1, "and the floor tiles wear the clean art (57 -> 1)")
T.eq(fig.under[4], 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 W = fig.w * 8
local seen, first = {}, nil
for i in pairs(fig.mask) do first = first or i end
local stack, n = { first }, 0
seen[first] = true
while #stack > 0 do
local i = table.remove(stack)
n = n + 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(n, 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 = {}, objectQuads = {}, 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.check(#S.objectQuads > 0, "the figure built geometry")
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(0, 8)], 36, "the west wall strip beside him is untouched")
T.eq(S.tileAt[keyOf(1, 10)], 39, "and the couch's own cushion row is too")
-- 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")
-- ------- where he stands
local minX, maxX, minY, maxY, minZ, maxZ
for _, q in ipairs(S.objectQuads) 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(minY, 8, "his feet are on the couch's top face, not the floor")
T.eq(maxY, 24, "and he stands his drawn 16px tall from there")
T.eq(minX, 8, "he starts at the tile column he is drawn in")
T.eq(maxX, 18, "and reaches his hair's overhang, two pixels east of it")
T.eq(maxZ - minZ, 10, "a billboard-pool standee, ten voxels of body")
-- the cushion wedge under his legs stayed with the couch: his lowest row
-- is his foot alone, not the whole tile width
local footMinX
for _, q in ipairs(S.objectQuads) do
for c = 1, 4 do
if q[c][2] == 8 then footMinX = math.min(footMinX or q[c][1], q[c][1]) end
end
end
T.eq(footMinX, 14, "the couch cushion beside his foot was left behind")
-- ------- 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.objectQuads, 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)
local once = #twice.objectQuads
Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.objectQuads, once,
"the repaint replaces the pattern, so a rescan cannot match it again")
T.finish("DRAMATIC_SHAPE figures")