From 3c1781f6b17c9329714623cebf69ffd1f14edafe Mon Sep 17 00:00:00 2001 From: DramaticShape Date: Wed, 29 Jul 2026 15:35:28 -0400 Subject: [PATCH] fix vertical line in battle sprites --- data/voxel_heights.lua | 79 ++++++++++++--- lib/BattleBillboard.lua | 17 ++++ lib/BattleScene.lua | 6 ++ lib/Structures.lua | 143 +++++++++++++++++++++++++++ lib/TileShape.lua | 74 ++++++++++++++ lib/Voxel3D.lua | 25 +++++ tests/monline_probe.lua | 50 ++++++++++ tests/voxel_figure_test.lua | 192 ++++++++++++++++++++++++++++++++++++ 8 files changed, 570 insertions(+), 16 deletions(-) create mode 100644 tests/monline_probe.lua create mode 100644 tests/voxel_figure_test.lua diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 574e3e2..e1aed77 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -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 diff --git a/lib/BattleBillboard.lua b/lib/BattleBillboard.lua index 306a2de..88693cd 100644 --- a/lib/BattleBillboard.lua +++ b/lib/BattleBillboard.lua @@ -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 diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 43c6003..50d8f3e 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -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 diff --git a/lib/Structures.lua b/lib/Structures.lua index 84c1ce4..5045e77 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -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 diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 4f0e9d5..6e65a14 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -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 = , +-- w = , +-- 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 diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 78679b7..aa41aff 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -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) diff --git a/tests/monline_probe.lua b/tests/monline_probe.lua new file mode 100644 index 0000000..a642202 --- /dev/null +++ b/tests/monline_probe.lua @@ -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 diff --git a/tests/voxel_figure_test.lua b/tests/voxel_figure_test.lua new file mode 100644 index 0000000..c60f052 --- /dev/null +++ b/tests/voxel_figure_test.lua @@ -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")