diff --git a/README.md b/README.md index f20aa3d..4b512bc 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ menu. | the **BACK SPRITES** options row | OFF / ON — keep your own Pokémon on the battle menu, seen from behind in its classic slot, instead of standing it on the map; the foe is still out there. Only on the menu while **3D-BTL** is on, because it decides nothing without it | | the **AA** options row | OFF / 2X / 4X — smooth the stair-stepped edges of the 3D world by rendering the diorama larger than the window and folding it back down. The ladder is samples per display pixel: 2X is a canvas root-two wider and taller, 4X one exactly twice the size. Every edge in the projected picture softens with the silhouettes — the tileset's own texels are quads in a perspective view and cross the pixel grid at the same arbitrary angles — so the diorama reads smoother rather than sharper. The most expensive row in the mod, so it is OFF by default and **FULL** leaves it alone | | the **DAYTIME** options row | SYNC / DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors, on the diorama *and* on the flat 2D world; held at SYNC (and off the menu) while VOXEL is FULL | +| the **SHELF TOPS** options row | TILE / SOLID — how bookcase and shelf tops are painted. **TILE** wears the drawn top or cap trim across the plateau; **SOLID** paints a flat of the face's majority colour, the same rule interior walls use | ## VR diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 55c075c..285c0ef 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -66,7 +66,13 @@ -- so the books stand in the shelf instead of -- being painted on it. A tileset that -- borrows the collapse for something that is --- not a shelf says `bookcase_relief = false` +-- not a shelf says `bookcase_relief = false`. +-- A tileset that borrows it for a MACHINE +-- whose "lid" still reads as face art on the +-- plateau (Bill's transporter drums) says +-- `bookcase_solid_top = true` and the top +-- wears a solid of the face's majority fill, +-- the same rule interior walls use -- cylinder / canopy round scenery (tree canopies): a voxel hull -- / stump cut from the art's own darkest-pixel outline, -- round in depth -- one 16px cell, a 2x2-cell @@ -1901,6 +1907,11 @@ return { -- light regions are the barrel's own lit face and not panes -- behind a frame. Sinking them would dent the drum. bookcase_relief = false, + -- Same reason the top is a solid of the face colour rather than + -- the lid tile laid flat: 7/8/9/10 is the drum's drawn lid, but + -- laid across the plateau it still reads as the barrel's circles + -- smeared on top. Interior walls already take this path. + bookcase_solid_top = true, -- The big OCTAGONAL boardroom table -- the Fan Club's and Silph -- 11F's, the same drawing in both -- half a cell high, and half a -- cell on purpose. A `counter` is ONE band: exactly the drawing's diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index eb2dbb9..e1e2de0 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -370,12 +370,22 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) -- `to` routes the quad somewhere other than the main sink -- the water -- surface is the only caller that ever does (see runGeometry's header). - local function topQuad(x0, z0, h, tile, shade, to) - local u0, u1, v0, v1 = uvRect(tile, 0, 8) + -- `solid` paints the plateau as one texel: the majority body colour of + -- `tile` (see Structures.faceCapUV). Interior walls have no top-view + -- art, and stretching any strip of the face still reads as a smeared + -- facade -- a flat of the face's own fill does not. + local function topQuad(x0, z0, h, tile, shade, to, solid) + local uvs + if solid then + local u, v = Structures.faceCapUV(tileset, tile, atlasW, atlasH) + uvs = { { u, v }, { u, v }, { u, v }, { u, v } } + else + local u0, u1, v0, v1 = uvRect(tile, 0, 8) + uvs = { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } } + end ;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 }, { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } }, - { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, - aoShades(x0 / 8, z0 / 8, h, shade)) + uvs, aoShades(x0 / 8, z0 / 8, h, shade)) end -- vertical quad for face direction `d` of the tile column at (x0, z0), @@ -528,19 +538,38 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) { x0 + 8, neY, z0 }, { x0, nwY, z0 } }, { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95) elseif run then - local m = math.min(2, run.extent) - local topTile = map:tileAt(tx, run.north + ((ty - run.north) % m)) - topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE) + -- Indoors a volume has no roof rows: its north tiles are the + -- top of a face-on drawing (Oak's Lab wall band, unpinned + -- partitions). A solid of that face's majority body colour + -- -- sampled once from the run's north row, so every cell of + -- a deep wall shares one flat -- keeps panels on the south + -- face; outdoors the north tiles really are roof art and the + -- top rows tile across the footprint as before. + local solid = not S.outdoor and run.rise == 0 + local topTile + if solid then + topTile = map:tileAt(tx, run.north) + else + local m = math.min(2, run.extent) + topTile = map:tileAt(tx, run.north + ((ty - run.north) % m)) + end + topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE, nil, solid) else local topTile = tile + -- When a fully-folded upright has no trim row above it, only its + -- face-on north tile is left for the plateau. Stretching that + -- tile (or even its top edge) lays windows and posters across + -- the top; a solid of the face's majority fill does not. + local solidTop = false if s.art == "upright" and s.authored then -- Top art for a pinned box. A furniture drawing is top-view -- rows over floor(h/8) face-on rows the fold stands upright; -- a face row's top would repeat its front art lying flat, so -- it wears the nearest row above the face block instead -- -- the drawn tabletop (and whatever sits on it) stays on top, - -- and a fully-folded structure (wall, desk) tops with its - -- northmost row. + -- and a fully-folded structure (wall, desk) tops with a + -- solid of its northmost face when nothing above supplies a + -- trim. local north, front = ty, ty while ty - north < 6 do local bs = S.shapeAt[keyOf(tx, north - 1)] @@ -562,11 +591,15 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) if row < north then -- the whole run folded onto the face: top with the drawn -- row just above it when that row is furniture too (a - -- bookcase wearing its shelf-top trim), else with the - -- run's own top row + -- bookcase wearing its shelf-top trim), else with a solid + -- of the run's own face colour local above = S.shapeAt[keyOf(tx, north - 1)] - row = (above and above.authored and above.art == "upright") - and (north - 1) or north + if above and above.authored and above.art == "upright" then + row = north - 1 + else + row = north + solidTop = true + end end topTile = S.tileAt[keyOf(tx, row)] end @@ -577,7 +610,8 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink) -- on the pond. topQuad(x0, z0, h, topTile, s.art == "upright" and VOLUME_TOP_SHADE or 1, - (s.class == "water") and waterPush or nil) + (s.class == "water") and waterPush or nil, + solidTop) end -- sides: 8px bands wherever the neighbour is lower. Band k spans diff --git a/lib/Structures.lua b/lib/Structures.lua index ad3d5ec..ab03364 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -89,6 +89,9 @@ local cache = {} -- ---------------------------------------------------------------- pixels -- local atlasData = {} +-- tile id -> { u, v } majority-body atlas UV, keyed under atlasData's +-- path so an atlas reload drops both together +local faceCapUV = {} local function pixels(tileset) local path = tileset.image @@ -99,6 +102,60 @@ local function pixels(tileset) return atlasData[path] or nil end +-- Atlas UV of the majority non-outline texel of an 8x8 tile. Interior +-- walls have no top-view art -- their plateau used to wear the face +-- drawing laid flat -- so the mesher points every corner of the top +-- quad at this one texel and the top reads as a flat of the face's +-- body colour (white plaster, blue panel, yellow cabinet). +function Structures.faceCapUV(tileset, tile, atlasW, atlasH) + local path = tileset.image or "" + local cache = faceCapUV[path] + if not cache then + cache = {} + faceCapUV[path] = cache + end + local hit = cache[tile] + if hit then return hit[1], hit[2] end + + local perRow = tileset.tilesPerRow or 16 + local ax = (tile % perRow) * 8 + local ay = math.floor(tile / perRow) * 8 + local aw = atlasW or tileset.imageWidth or (perRow * 8) + local ah = atlasH or tileset.imageHeight or 48 + local data = pixels(tileset) + local bestN, bestPx, bestPy = 0, 4, 4 + if data then + local counts = {} + for py = 0, 7 do + for px = 0, 7 do + local r, g, b, a = data:getPixel(ax + px, ay + py) + -- skip clear and black outline: the body fill is what a wall + -- top should wear, not the stroke around the panels + if a > 0 and Structures.shadeClass(math.min(r, g, b)) ~= "black" then + local key = math.floor(r * 15 + 0.5) * 256 + + math.floor(g * 15 + 0.5) * 16 + + math.floor(b * 15 + 0.5) + local e = counts[key] + if e then + e.n = e.n + 1 + else + counts[key] = { n = 1, px = px, py = py } + end + end + end + end + for _, e in pairs(counts) do + if e.n > bestN then + bestN, bestPx, bestPy = e.n, e.px, e.py + end + end + end + local u = (ax + bestPx + 0.5) / aw + local v = (ay + bestPy + 0.5) / ah + cache[tile] = { u, v } + return u, v +end + -- tiles whose art is entirely black or transparent (interior darkness): -- these never extrude, whatever class they resolved to local function voidTiles(tileset) @@ -1843,8 +1900,19 @@ local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV, end end + -- Cap trim when the rank adopted one, otherwise the northmost drawn + -- row. Real shelves wear that tile whole on the plateau; a tileset + -- that borrowed the collapse for a machine (Bill's drums) opts into + -- a solid of the face's majority fill instead -- same rule as an + -- interior wall, see TileShape.bookcaseSolidTop. local topTile = capTile or map:tileAt(tx, northTy) - local u0, u1, v0, v1 = uvRect(topTile) + local u0, u1, v0, v1 + if TileShape.bookcaseSolidTop(map.tileset.id) then + local u, v = Structures.faceCapUV(map.tileset, topTile, atlasW, atlasH) + u0, u1, v0, v1 = u, u, v, v + else + u0, u1, v0, v1 = uvRect(topTile) + end for seg = 0, depth / 8 - 1 do local sz0 = z0 + seg * 8 quads[#quads + 1] = { { x0, h, sz0 }, { x1, h, sz0 }, @@ -3612,6 +3680,7 @@ function Structures.invalidate(mapId) else cache = {} atlasData = {} + faceCapUV = {} roundCache = {} Buildings.invalidate() end diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 0ea0ada..76c52c0 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -41,6 +41,8 @@ -- the mod namespace (see main.lua): V.data loads a shipped data file local V = ... +local ModSetting = V.require("ModSetting") + local TileShape = {} -- class -> height fallbacks, used when data/voxel_heights.lua is missing @@ -700,6 +702,40 @@ function TileShape.bookcaseRelief(tilesetId) return not (entry and entry.bookcase_relief == false) end +--- Does this tileset's `bookcase` top wear a SOLID of the face's majority +--- body colour? Yes when the SHELF TOPS row is SOLID (every shelf, like +--- an interior wall), or when the tileset opts in with +--- `bookcase_solid_top` (Bill's transporter drums -- machines that +--- borrow the collapse and whose lid still smears as face art). +--- Default / TILE: real shelves wear their northmost or cap tile whole. +TileShape.SHELF_TOP_KEY = "shelf_top" +TileShape.shelfTopSetting = ModSetting.new( + TileShape.SHELF_TOP_KEY, "SHELF TOPS", + { "tile", "solid" }, { "TILE", "SOLID" }) + +-- Tops are baked into the mesh at build time, so flipping the row has to +-- drop every cached analysis / mesh or the old plateau stays on screen. +do + local baseRow = TileShape.shelfTopSetting.row + function TileShape.shelfTopSetting:row() + local r = baseRow(self) + local step = r.step + r.step = function(game, dir) + local ok = step(game, dir) + V.require("ChunkMesher").invalidate() + return ok + end + return r + end +end + +function TileShape.bookcaseSolidTop(tilesetId) + if TileShape.shelfTopSetting:get() == "solid" then return true end + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + return entry and entry.bookcase_solid_top == true or false +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() diff --git a/main.lua b/main.lua index 1fff18a..4bc0a16 100644 --- a/main.lua +++ b/main.lua @@ -93,6 +93,7 @@ local AntiAlias = V.require("AntiAlias") local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") local VR = V.require("VR") +local TileShape = V.require("TileShape") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -445,6 +446,14 @@ local SETTINGS = { -- says why); off Windows -- mobile above all -- there is no VR to have -- and the row does not exist when = function() return VR.supported() end, full = true }, + -- Last among the look knobs: a preference about furniture tops, not a + -- mode of the diorama itself, so it sits at the end of the block. + { TileShape.shelfTopSetting, + "How shelf tops are painted. TILE wears the drawn top or cap trim " + .. "across the plateau -- the usual look for bookcases. SOLID paints " + .. "a flat of the face's majority colour, the same rule interior " + .. "walls use, which cleans up machines that borrow the shelf " + .. "collapse (and anything else whose lid still reads as face art)." }, } local schema = {} @@ -706,6 +715,11 @@ mod.events:on("mod.options_changed", function(payload) for _, entry in ipairs(SETTINGS) do if payload.key == entry[1].key then entry[1]:sync(payload.value) end end + -- Tops are baked into the mesh: flipping SHELF TOPS from the manager + -- has to drop every cached analysis the same way the OPTIONS row does. + if payload.key == TileShape.SHELF_TOP_KEY then + ChunkMesher.invalidate() + end -- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as -- the OPTIONS row does. The manager persists its own value; this is the one -- that has to follow it. diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index a6fb494..d3070e1 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -140,6 +140,7 @@ T.check(not fullIds["pipeline:tiltshift"], "FULL takes T-SHIFT off the menu -- it owns the blur") T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID") T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE") +T.check(not fullIds["DRAMATIC_SHAPE:shelf_top"], "and SHELF TOPS") T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") -- but the battle rows survive it: they are not knobs on the look, and FULL @@ -310,6 +311,8 @@ T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 4, "and sit in one unbroken block, not scattered to the end of the list") T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], "with the engine's own later rows still after them") +T.check(order["DRAMATIC_SHAPE:shelf_top"] > order["DRAMATIC_SHAPE:aa"], + "SHELF TOPS sits at the end of the mode's settings block") -- ------- the open menu notices when FULL is stepped onto or off -- @@ -387,7 +390,7 @@ end Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 9, "the options hook added a row per setting") +T.eq(#hookedRows, 10, "the options hook added a row per setting") local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[4] local battles, backRow, daytime = hookedRows[5], hookedRows[6], hookedRows[7] -- the AA row is hookedRows[8]; it is read in its own block below, because @@ -415,6 +418,10 @@ T.eq(backRow.value(), "OFF", .. "map, so the classic slot is opt-in") T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true), "on its own key, so it persists beside 3D-BTL rather than over it") +T.eq(hookedRows[#hookedRows].label, "SHELF TOPS", + "SHELF TOPS is the last of the mode's settings") +T.eq(hookedRows[#hookedRows].value(), "TILE", + "and defaults to TILE -- shelves wear their drawn top or cap trim") -- stepping writes through to the one place both rows read local settingGame = { save = { options = {} }, mods = { modOptions = {} } }