fix couch man

This commit is contained in:
DramaticShape
2026-07-29 17:36:03 -04:00
parent 5ea70a278f
commit 8e7a80055d
6 changed files with 389 additions and 145 deletions
+74 -42
View File
@@ -70,6 +70,12 @@
-- pinned prop, overriding the flat-neighbour vote -- the cuttable bush
-- stands on the grass Cut leaves behind, whatever borders it.
--
-- And `prop_bg` (not a class either): which GB shades count as BACKGROUND
-- when a pinned per-pixel prop is cut out, for the drawings whose own body
-- reaches the edge of their bounding box and so vote themselves away.
-- Keyed by tile, because two props sharing an atlas can want opposite
-- answers on the same shade.
--
-- 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
@@ -725,53 +731,79 @@ 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.
-- ...but the POTS were draining outright. The plant pair is drawn
-- as one 4x4-tile block and the pot's olive base is flush on that
-- block's bottom row, so the background vote -- which reads the
-- shades touching the drawing's own bounding box -- came back with
-- "dark" in it, and every dark pixel in the plant left with the
-- floor. The pots rendered as hollow black frames around one or
-- two surviving pixels while the flat art has solid olive bodies.
--
-- The mask keeps ALL of column 7, the couch's east edge. Where that
-- column is drawn dark it is the couch's own rule -- but it is also
-- where his hair crosses the tile seam, so dropping it floats the
-- overhang free of his head. And where it is drawn WHITE it is the
-- right side of his face, so dropping it opens a slit down his cheek
-- (rows 5-8, which is exactly what the first cut did). Keeping the
-- rule twice costs nothing: tile 39 redraws it on the couch beneath
-- him either way. The background corners around his head and the
-- So name the background outright for these eight tiles: the floor
-- IS light and white here, and nothing else is. That restores 152
-- pixels of the block (55% -> 69% of it kept) and cannot move any
-- other prop, which matters -- the same call tileset-wide would pull
-- the dark wall band into the healing consoles' screens and strip
-- three pixels off the PC, because those two want the opposite
-- answer on the very same shades.
prop_bg = {
{ tiles = { 32, 33, 34, 35, 48, 49, 50, 51 },
shades = { "light", "white" } },
},
-- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture and
-- spills out of it in BOTH directions, which is why he needs three
-- tile columns:
--
-- 36 / 52 the couch's west arm. 36's two RIGHTMOST columns are
-- the back of his head; 52 is the plain arm, and is also
-- what 36 wears once his hair comes off it
-- 37 / 53 him: head, then face and body
-- 57 / 60 the floor east of the couch, which his hair and his
-- foot overhang. 1 and 26 are those same two floor
-- tiles as the artist drew them WITHOUT him -- 8 and 5
-- pixels apart respectively -- so lifting him off is
-- lossless there
--
-- `pixels` is the hand-drawn line between the man and the furniture;
-- `under` is what each tile wears once he is lifted off it. The
-- couch keeps its polygonal box and he stands on top of it.
--
-- The mask keeps ALL of 37/53's column 7, the couch's east edge.
-- Where that column is drawn dark it is the couch's own rule -- but
-- it is also where his hair crosses the tile seam, so dropping it
-- floats the overhang free of his head. And where it is drawn WHITE
-- it is the right side of his face, so dropping it opens a slit down
-- his cheek (rows 5-8, which is exactly what the first cut did).
-- Keeping the rule twice costs nothing: tile 39 redraws it on the
-- couch beneath him either way. Repainting 36 as 52 does cost one
-- row -- 36's top trim band, which 52 does not carry -- and that is
-- the accepted price for not leaving a second copy of his head lying
-- on the arm. The background corners around his head and the
-- cushion wedge under his legs are the only pixels given back.
figures = {
{
-- the `cutout` pool: one voxel, a flat card -- the same thing
-- the walking NPCs are, so the man reads as a person and not
-- as a ten-voxel wedge of furniture
class = "cutout",
w = 2,
tiles = { 37, 57,
53, 60 },
under = { 39, 1,
39, 26 },
w = 3,
tiles = { 36, 37, 57,
52, 53, 60 },
under = { 52, 39, 1,
52, 39, 26 },
pixels = {
"..XXXXX.........",
"XXXXXXXX........",
"XXXXXXXXX.......",
"XXXXXXXXXX......",
"XXXXXXXXXX......",
"XXXXXXXXX.......",
"XXXXXXXXX.......",
"XXXXXXXXX.......",
"XXXXXXXXX.......",
"XXXXXXXX........",
"XXXXXXXX........",
"XXXXXXXX........",
"XXXXXXXXX.......",
".XXXXXXXXX......",
"...XXXXXX.......",
"......XX........",
"..........XXXXX.........",
"........XXXXXXXX........",
".......XXXXXXXXXX.......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXXX......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"......XXXXXXXXXXX.......",
"........XXXXXXXXX.......",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXX........",
"........XXXXXXXXX.......",
".........XXXXXXXXX......",
"...........XXXXXX.......",
"..............XX........",
},
},
},
+43 -1
View File
@@ -801,6 +801,33 @@ local function buildFlowerMesh(map)
return quadsMesh(Structures.forMap(map).flowerQuads)
end
-- Authored FIGURES (a person drawn into furniture) as one mesh each, in
-- the card's own local space -- because each one is placed by its own
-- matrix at draw time, leaned back by the camera pitch exactly like a
-- character card (VoxelScene). A figure baked into the terrain mesh could
-- not lean, and a shared mesh could not carry per-figure placement.
--
-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or
-- none, so the loop that draws them is shorter than the terrain's.
local function buildFigureMeshes(map)
local out = {}
for _, f in ipairs(Structures.forMap(map).figures or {}) do
local mesh = quadsMesh(f.quads)
if mesh then
out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y }
end
end
return out
end
-- Figure lists hold their meshes one level down, so the generic slot
-- release cannot reach them.
local function releaseFigures(list)
for _, f in ipairs(type(list) == "table" and list or {}) do
if f.mesh and f.mesh.release then pcall(f.mesh.release, f.mesh) end
end
end
-- Replace a cached slot, releasing whatever mesh it held.
local function swapSlot(c, slot, mesh)
local old = c[slot]
@@ -825,6 +852,8 @@ local function releaseEntry(c)
if mesh and mesh.release then pcall(mesh.release, mesh) end
c[slot] = nil
end
releaseFigures(c.figures)
c.figures = nil
c.stale = nil
end
@@ -863,18 +892,23 @@ end
local function runJob(job)
local map = job.map
local c = entry(job.id)
if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then
if c.grass == nil or c.flowers == nil or c.figures == nil
or (c.stale and c.stale.aux) then
local okG, grass = pcall(buildGrassMesh, map)
local okF, flowers = pcall(buildFlowerMesh, map)
local okX, figures = pcall(buildFigureMeshes, map)
if (gen[job.id] or 0) ~= job.gen then
if okG and grass and grass.release then pcall(grass.release, grass) end
if okF and flowers and flowers.release then
pcall(flowers.release, flowers)
end
if okX then releaseFigures(figures) end
return
end
swapSlot(c, "grass", (okG and grass) or false)
swapSlot(c, "flowers", (okF and flowers) or false)
releaseFigures(c.figures)
c.figures = (okX and figures) or false
if c.stale then c.stale.aux = nil end
end
local sink = newSink()
@@ -1022,6 +1056,14 @@ function ChunkMesher.flowers(map)
return c and c.flowers or nil
end
-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its
-- own leaning matrix at draw time, so they cannot share one mesh.
function ChunkMesher.figures(map)
local c = cache[map.id]
local list = c and c.figures
return (type(list) == "table") and list or nil
end
-- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while
-- replacements cook, and each slot swaps as its build lands. This is
-- the block-edit path (a cut tree, a door stamp) -- invalidate() drops
+78 -57
View File
@@ -193,7 +193,7 @@ function Structures.forMap(map)
-- terrain.)
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
grassQuads = {}, flowerQuads = {}, roundStamps = {} }
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
Buildings.build(S, map, pixels(tileset), perRow)
-- Fold doors into their buildings. A door cell is WALKABLE (the player
@@ -1708,18 +1708,44 @@ function Structures.extractObjects(S, map, region, data, perRow, force)
local fs = S.shapeAt[keyOf(region.tiles[1][1], region.tiles[1][2])]
strict = fs ~= nil and fs.class == "cutout"
end
-- The rim vote reads the shades on the DRAWING'S OWN bounding box, so a
-- prop whose body reaches its own edge votes itself out. The Center's
-- potted plants are the case: the pot's olive base is drawn flush on the
-- bottom row of the block, so "dark" came back as background and every
-- dark pixel in the whole plant drained with it -- the pots rendered as
-- hollow black frames while the 2D art has solid olive bodies.
--
-- Where the vote misreads the art, the profile can name the background
-- shades outright (a tileset entry's prop_bg). Keyed BY TILE rather than
-- per tileset, because the answer is per drawing: the healing consoles'
-- screens really do stand on a dark wall band and really do need dark
-- voted out, and the PC really does need light kept.
local bg = {}
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
do
local named = TileShape.propBg(map.tileset.id)
if named then
for _, c in ipairs(region.tiles) do
local rule = named[S.tileAt[keyOf(c[1], c[2])]]
if rule then
for shadeName in pairs(rule) do bg[shadeName] = true end
break
end
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
if not next(bg) then
for iy = 0, H - 1 do
for ix = 0, W - 1 do
local px, py = ix - 1, iy - 1
local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1
local st = state[iy * W + ix]
if edge and (st == "dark" or st == "light" or st == "white") then
bg[st] = true
end
end
end
if not (bg.dark or bg.light or bg.white) then bg.white = true end
end
for i, st in pairs(state) do
if strict then
if st == "dark" or st == "light" then
@@ -2134,6 +2160,23 @@ end
-- 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.
--
-- A figure is a SPRITE, not a prop. It gets exactly the treatment
-- SpriteBillboards gives a character: one flat plane of the drawing's own
-- pixels, no thickness, standing at its feet and leaned back by the
-- camera's pitch at draw time so it always reads face-on -- because that
-- is what the artwork is. A seated man drawn face-on is a 2D icon like
-- every other Gen 1 figure; extruding him into a slab reconstructs a body
-- nobody drew (the ten-voxel version read as a wedge of furniture, and
-- even one voxel showed an edge the sprites never show).
--
-- So the quads are emitted in the card's OWN LOCAL SPACE -- x from the
-- mask's west edge, y from his feet, all at z = 0 -- and the placement
-- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix
-- from. One quad per pixel rather than one alpha-keyed texture: the
-- tileset atlas has no alpha to key on, and per-pixel quads cut the exact
-- same silhouette straight out of the live atlas, so every palette bake
-- (SGB, RED++ per-tile groups, a mod's own art) textures him for free.
local function buildFigure(S, map, fig, tx, ty, perRow)
local bw, bh = fig.w * 8, fig.h * 8
@@ -2142,47 +2185,33 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
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
-- his feet and his west edge: the card's own origin
local lowY, minX = 0, bw - 1
for ly = 0, bh - 1 do
for lx = 0, bw - 1 do
if at(lx, ly) then any = true break end
if at(lx, ly) then
if ly > lowY then lowY = ly end
if lx < minX then minX = lx end
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 baseY = 0
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
baseY = bs.h
end
-- He stands in the depth band of the tile row he is DRAWN in, centred.
-- No south shift: buildObject nudges a supported prop one row back
-- because a monitor is drawn in the rows ABOVE the desk it stands on,
-- so its drawn row is not its standing row. A figure's mask already
-- says exactly which row is his -- shifting it walked him off his own
-- cell and onto the couch's southern half.
local depth = PINNED_DEPTH[fig.class] or PINNED_DEPTH.billboard
local z0 = ty * 8 + math.floor(lowY / 8) * 8 + (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
local quads = {}
for ly = 0, bh - 1 do
Budget.tick()
for lx = 0, bw - 1 do
@@ -2191,34 +2220,26 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
+ 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
local x, y = lx - minX, lowY - ly
quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 },
{ x + 1, y + 1, 0 }, { x, y + 1, 0 },
u = u, v = v, shade = 1 }
end
end
end
-- Where the card stands. `wz` is the MIDDLE of the tile row his feet are
-- drawn in, which is the same convention a character card uses (its feet
-- plane sits at its cell's middle) -- so he sorts against the couch and
-- against a player walking past exactly the way an NPC standing there
-- would.
S.figures[#S.figures + 1] = {
quads = quads,
wx = tx * 8 + minX,
wz = ty * 8 + math.floor(lowY / 8) * 8 + 4,
y = baseY,
}
-- 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
+59 -4
View File
@@ -169,6 +169,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
local bgCache = {} -- tileset id -> prop background shades, 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
@@ -380,14 +381,16 @@ end
-- 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>,
-- figures = { { 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... } } }
--
-- No class: a figure is always a flat sprite card, drawn the way
-- SpriteBillboards draws a character (see Structures.buildFigures).
--
-- 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
@@ -430,8 +433,7 @@ function TileShape.figures(tilesetId)
end
end
if n > 0 then
out[#out + 1] = { class = f.class or "billboard",
w = w, h = h, n = n, mask = mask,
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
tiles = f.tiles, under = f.under }
end
end
@@ -442,12 +444,65 @@ function TileShape.figures(tilesetId)
return figCache[tilesetId] or nil
end
-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile
-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil.
--
-- Structures normally votes on this by reading the shades that touch the
-- drawing's own bounding box, which is right whenever the drawing has a
-- margin of floor around it and wrong when it does not: a prop whose body
-- reaches its own edge votes itself out. Naming the shades is the override,
-- and it is keyed by TILE because the answer is per drawing rather than per
-- tileset -- two props in one atlas can want opposite calls on the same
-- shade (see the POKECENTER entry).
--
-- prop_bg = { { tiles = { ...ids... }, shades = { "light", "white" } } }
--
-- Only the four GB shade names exist; anything else is dropped, so a typo
-- degrades to the ordinary vote rather than emptying the background.
local SHADES = { black = true, dark = true, light = true, white = true }
function TileShape.propBg(tilesetId)
local hit = bgCache[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.prop_bg
local out, any = {}, false
if type(list) == "table" then
for _, rule in ipairs(list) do
if type(rule) == "table" and type(rule.tiles) == "table"
and type(rule.shades) == "table" then
local set, n = {}, 0
for _, name in ipairs(rule.shades) do
if SHADES[name] then
set[name] = true
n = n + 1
end
end
if n > 0 then
for _, t in ipairs(rule.tiles) do
if type(t) == "number" then
out[t] = set
any = true
end
end
end
end
end
end
bgCache[tilesetId] = any and out or false
return bgCache[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 = {}
bgCache = {}
end
return TileShape
+54
View File
@@ -205,6 +205,36 @@ local function billboardPull()
return VoxelScene.pull(math.max(Voxel.angle, 0.05))
end
-- An authored FIGURE's card -- a person the tileset draws INTO a piece of
-- furniture, cut out by the profile's mask (Structures.buildFigures). It is
-- a sprite, so it gets the sprite treatment: the mesh arrives in its own
-- local space with its feet on y = 0, and this stands it at its drawn
-- position and tips it back by exactly the camera's pitch -- the same
-- pivot-at-the-feet lean billboardMatrix gives a character, so the man on
-- the Pokemon Center couch reads face-on at every tilt like the NPCs
-- around him. No cell centring: unlike a character he is not standing on a
-- cell, he is standing where he was drawn, which may straddle two.
local function figureMatrix(f, offX, offZ)
local Voxel = V.require("VoxelState")
return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.rotateX(Voxel.angle - math.pi / 2))
end
-- What the sun sees: the same card UNLEANED and flattened, exactly as
-- Voxel3D.casterMatrix does it for a character.
local function figureCaster(f, offX, offZ)
return Mat4.mul(
Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)),
Mat4.scale(1, 1, 0))
end
-- Every figure on `map`, drawn with `draw(mesh, model, caster)`.
local function eachFigure(map, offX, offZ, draw)
for _, f in ipairs(ChunkMesher.figures(map) or {}) do
draw(f.mesh, figureMatrix(f, offX, offZ), figureCaster(f, offX, offZ))
end
end
-- Draw one posed entity. Returns true if 3D geometry carried it, false
-- when nothing could be built and the caller should fall back.
-- `colors` is the 4-color world palette the entity stands under in the SGB
@@ -448,6 +478,16 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh,
ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map),
Mat4.translate(nb.ox, 0, nb.oy))
end
-- authored figures cast too, for the same reason the flowers do: a
-- handful of cards per map, and a person with no shadow reads as pasted on
eachFigure(state.map, 0, 0, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(state.map), caster)
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster)
ShadowMap.draw(mesh, atlasFor(nb.map), caster)
end)
end
for _, p in ipairs(posed) do
local def = p.sprite.def
local frame, mirror = frameFor(def, p.facing, p.phase, p.flip)
@@ -534,6 +574,20 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh,
p.colors, p.lift)
end
-- Authored figures, alongside the characters and with the same lean and
-- the same camera-ward pull -- they ARE characters as far as the artwork
-- is concerned, just ones the tileset draws instead of a sprite sheet.
-- Drawn after the walkers so a player standing in front of the couch
-- wins the overlap, which is the order the flat game draws them in.
local figPull = billboardPull()
eachFigure(state.map, 0, 0, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, caster)
end)
for _, nb in ipairs(state.neighbors or {}) do
eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster)
Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, caster)
end)
end
-- tall grass last, pulled camera-ward exactly as far as the characters
-- were (same per-vertex shader bias, so grass never drifts either):
-- relative depth between a walker and the tuft row south of their feet
+81 -41
View File
@@ -55,31 +55,49 @@ 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.w, 3, "the figure is three tiles across")
T.eq(fig.h, 2, "the figure is two tiles tall")
T.eq(fig.n, 128, "the mask claims 128 pixels of the 256 it spans")
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")
-- no holes in him: his tiles' column 7 is the couch's east rule AND the
-- right side of his face, and masking it out by shade slit his cheek open
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(fig.mask[ly * (fig.w * 8) + 7] == true,
T.check(on(15, ly),
"his cheek is solid at row " .. ly .. " (the column-7 slit)")
end
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)")
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 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
local stack, reached = { first }, 0
seen[first] = true
while #stack > 0 do
local i = table.remove(stack)
n = n + 1
reached = reached + 1
local x, y = i % W, math.floor(i / W)
for dy = -1, 1 do
for dx = -1, 1 do
@@ -93,7 +111,7 @@ while #stack > 0 do
end
end
end
T.eq(n, fig.n, "the mask is a single connected figure")
T.eq(reached, fig.n, "the mask is a single connected figure")
-- ------- the couch block, as the Centers place it
@@ -112,7 +130,7 @@ 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 = {},
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)
@@ -136,14 +154,17 @@ local map = {
local S = scene()
Structures.buildFigures(S, map, 0, 3, 8, 11)
T.check(#S.objectQuads > 0, "the figure built geometry")
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(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")
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",
@@ -151,10 +172,10 @@ T.eq(S.shapeAt[keyOf(1, 8)].class, "counter",
T.check(S.skip[keyOf(1, 8)] ~= true,
"and are not skipped -- the couch still renders there")
-- ------- where he stands
-- ------- the card is flat, and stands at its feet
local minX, maxX, minY, maxY, minZ, maxZ
for _, q in ipairs(S.objectQuads) do
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])
@@ -163,43 +184,62 @@ for _, q in ipairs(S.objectQuads) do
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, 1, "a flat card, one voxel deep, like the walking NPCs")
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 he stands in the depth band of the row he is DRAWN in -- the couch's
-- northern cell (tile row 9 spans z 72..80), not shifted onto its southern
-- half the way a supported prop is
T.check(minZ >= 72 and maxZ <= 80,
"he stands on his own cell, not the couch's southern half")
-- ------- and where VoxelScene stands it
-- 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")
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.objectQuads, 0, "no match, no figure")
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)
local once = #twice.objectQuads
Structures.buildFigures(twice, map, 0, 3, 8, 11)
T.eq(#twice.objectQuads, once,
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")