diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 2154f88..6f92b01 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -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........", }, }, }, diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index f17ea51..394fca5 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -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 diff --git a/lib/Structures.lua b/lib/Structures.lua index bcd6d3f..c4ab192 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -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 diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 6e65a14..199bed3 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -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 = , --- w = , +-- figures = { { 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... } } } -- +-- 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 diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index b3d4d6c..19724f8 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -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 diff --git a/tests/voxel_figure_test.lua b/tests/voxel_figure_test.lua index dff4a00..6f8e529 100644 --- a/tests/voxel_figure_test.lua +++ b/tests/voxel_figure_test.lua @@ -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")