fix couch man
This commit is contained in:
+74
-42
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user