diff --git a/CHANGELOG.md b/CHANGELOG.md index 172bfd4..5bb313c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog +## 1.4.1 + +### Added + +- **Furniture through the building pipeline.** The band-table voxelizer + that models whole buildings from their own drawings (lib/Buildings.lua) + now reads interior furniture too, and the first four drawings are in: + + - **F01, the starter-ball table in Oak's lab** -- the tabletop's 16 + drawn rows lay flat over a 16px plot (1:1, the first template that + never cycles), the black/#555/black edge band folds into the slab's + own rim, and the base extrudes with its corner feet. Six voxels + tall, exactly the drawn elevation. + - **F03, the empty north table beside it** -- the same band table on a + grid two tiles narrower. + - **F02, the lab's computer desk** -- the first DESK-SET template: the + drawing segments into PARTS, each classified by the surface it + depicts. The monitor and the computer tower stand upright on the + desk wearing their own drawn tops as lids; the keyboards and the + mouse lie flat in front of them; the sheet of paper on the right + lies flat across the desk. Flat parts keep the drawing's own rule -- + drawn row IS depth row, the same 1:1 the tabletop is drawn with -- + so an object's height on the drawing is its position on the desk. + The Hall of Fame's recording machine is this drawing tile for tile + on the GYM atlas, and models identically for free. + - **F04, the Center PC** -- the desk-set read again: a Mac-style unit + with its screen and drive slot in relief, standing at the back of a + low white-topped desk with its keyboard lying at the front edge. + Eleven Pokemon Centers, plus the Indigo Plateau lobby, whose MART + tileset shares the atlas. + + Two measurements had to stop being assumptions for furniture to fit + the pipeline: the GROUND LINE is now read off the drawing (a building + ends on the black threshold row it stands on; a table's legs stop two + rows short of theirs, and extruding against the grid floated them in + the air), and a template may name its PLOT (`depth`) when the matched + grid runs past it onto the walkable floor the legs merely stand on. + Both are identities for every existing building. + +- **The Center couch has a backrest.** The couch is drawn from above -- + back-and-arm strip down the west side, cushions and seams on the east + -- and rendered as one seat-high box. The new `backrest` class raises + the drawn back strip to 12px over the 8px seat, in every Center and + the Celadon Hotel. The man sitting on it keeps his seat: the figure + anchor now scans under his card for the tallest authored upright (his + cushion) instead of reading the corner tile, which is the backrest + now. + +### Changed + +- **Sprites ride at the height the art actually stands.** Class heights + can now be overridden per tileset (a tileset entry's `heights`), and + DOJO's lab tables use it: they are drawn 6px tall, not the default + table's 12, so the starter balls sit exactly on the modelled tabletop + -- and the volume-built north tables drop to the same height, keeping + every table in the room level. +- The Center PC's old rendering -- a 12px table box with the unit as a + flat standee on it -- retires wherever the F04 template stamps; the + pins stay only as the degradation path when the shape profile is + absent. + ## 1.4.0 ### Added @@ -116,6 +177,72 @@ ever lands half-way between two pixels and changing a wavelength moves the speed with it. +- **AA, a new options row: OFF / 2X / 4X.** Everything else in this game is + flat art blitted at whole pixels. This mode's world is real geometry seen + through a perspective camera, and a polygon edge that lands at an angle + across the pixel grid is the one place where a hard stair-step is not a + stylistic choice -- a roof ridge, a ledge lip, a tree's silhouette against + the sky, the leaning card of a character. At the shallow rungs, where the + diorama reads most like a photograph of a model, they crawl as the camera + drifts. + + The row is SUPERSAMPLING: the whole pass renders into a canvas larger than + the window and is folded back down at the end. The ladder is samples per + display pixel, so 2X is a canvas root-two wider and taller and 4X one + exactly twice the size -- an honest 2x2 box. + + Two alternatives were tried against what this pass already is, and both + lost: + + - **MSAA** would have taken the water with it. The reflections read the + frame's own depth buffer as a texture, and a multisampled depth + attachment is not something a fragment shader in this dialect can sample. + The row would have quietly switched the WATER row off. + + - **An edge filter** (FXAA and its relatives) works from the finished + colour alone, so it would be guessing where the edges are out of one + sample per pixel -- inventing detail it never rendered, and unable to + tell a geometry edge from the boundary between two texels of a tileset. + + Rendering larger has neither problem, and nothing in the frame had to be + taught about it: every pass already measures itself in the canvas it was + handed, so the sky's dither, the water's ray march, the shadow lookups and + the camera itself come out the same picture at a higher sample rate. It + antialiases the geometry, the alpha-cut outline of a sprite card, the + wireframe and the reflections at once, because none of them know it is + happening. + + And it softens the ARTWORK with them, which is worth saying plainly. A + tileset texel out here is not a screen pixel, it is a quad in a perspective + view, and its boundary crosses the pixel grid at the same arbitrary angle a + roof ridge does -- so the fold averages across it exactly as it averages + across the ridge. That is what an honest extra sample says about that + pixel, and it is also the trade the row is: the diorama comes out smoother, + not sharper. Which is why it is a row and not something that is simply on. + + Two things are quoted in DISPLAY pixels rather than canvas ones and are + multiplied up to match: the voxel wireframe's line width -- left alone it + would fold down to half a line, so turning the smoothing up would appear to + fade the grid out -- and the scale the overworld's FX closures draw at. + + The fold is a shader rather than a scaled draw, because the void this pass + renders into is a transparent BLACK: averaging a straight-alpha edge against + it drags the colour toward black as well as toward transparent, and the + engine's composite then multiplies by that alpha a second time. Every + silhouette against the sky would have come out ringed with a dark fringe -- + the exact artefact the row exists to remove. So the taps are premultiplied + before they are averaged and divided back out after. + + The staged battle gets it too, on its own canvas: the arena is folded back + to the window's pixel size before the depth-of-field pass and the HUDs go + on, so the world is smoothed and the pics, panels and text box stay the + chunky GB art they are. + + OFF by default, and **FULL neither sets it nor takes the row away** -- it + is the one row that is not a knob on the look but on what the look COSTS, + and only the player knows what their machine can carry. No hotkey, for the + same reason: it is set once, not flicked while walking. + ### Changed @@ -216,6 +343,36 @@ depth16 as the floor every GLES3 device can read. Refused all four, the reflections are lost and nothing else, exactly as before. +- **Under BACK SPRITES some of your own Pokemon were see-through -- Pikachu, + Seel, Dewgong, Chansey, Jigglypuff -- with the arena showing through the + middle of them.** Those back pics are drawn as OUTLINES: everything inside + the ink is the lightest shade, the decoder keys that shade to nothing, and + on hardware it did not matter because the field behind them was white too. + + BattlePics already put that paper back by flooding the background inward and + filling whatever it could not reach, and along the bottom of a figure it told + a narrow opening (a belly the drawing ran out of, sealed) from a wide one (a + stride, left open for the world to show through). Right for a mon standing + on the map -- but the pinned back pic is not on the map, it is on the text + box with its feet on row 96, and there is white box under its lowest row + rather than arena. Every one of those mons leaks out through an opening far + too wide to read as a drain, so the flood walked straight up inside them. + + A pic on the box is now told so, and its bottom edge seals: nothing reaches + it from below at any width, and the rule stops being a heuristic -- paper is + whatever the background cannot walk to from the left, the right or the top. + Twelve of the game's 151 back pics turn on this; the other 139 come back + byte-identical, and no front pic is touched at all. + + **And a hole is filled with the pic's own paper rather than with white.** + Shade 0 is only white while the pic is still grays, and pics arrive here + after the bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK + across the whole screen while the blackout text is up. A hardcoded white + belly would have been the one lit thing on a blacked-out mon. The lightest + shade still standing in the pic is that colour, and every one of the game's + battler pics keeps at least one such pixel -- an eye, a highlight down a + cheek -- so what goes back is the baked shade itself. + ### Known - Screen-space reflections can only reflect what is in the frame. A tree just diff --git a/README.md b/README.md index db18394..14e2cb8 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ menu. | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | | `9`, or the **WATER** options row | FULL / SKY / OFF — waves and reflections on water. **SKY** gives the surface its pixel-tall wave columns and puts the sky, the sun, the moon and the cast in them; **FULL** adds a screen-space ray march that also reflects the shoreline, the trees and the buildings standing behind it | | 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 | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 4079a53..97f52f6 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -110,6 +110,9 @@ return { bed = 7, stool = 8, counter = 8, + -- the raised back band of low seating (the Center couch's back and + -- arm strip): half again the 8px seat it rises over + backrest = 12, table = 12, desk = 24, prop = 16, @@ -579,6 +582,12 @@ return { -- auto-extracting into standing prisms, and the ball/Pokedex -- sprites ride the table's authored height. table = { 41, 42, 57, 59, 78, 79 }, + -- These tables are drawn 6px tall (3px slab edge over 3px base -- + -- see the lab_table entry under `buildings`), not the default + -- table's 12: the override keeps the volume-built north tables + -- and the band-built starter table one height, and stands the + -- ball/Pokedex sprites exactly on the top face of both. + heights = { table = 6 }, }, -- Red's room and the Copycat's room (one tileset). The detector reads @@ -699,14 +708,25 @@ return { -- one clean band, so the drawn front panel stands up and the -- counter top stays on top; at 12 they read as wall stubs counter = { 8, 10, 24, 25, 56, 90, 91, - -- and the lounge couch with the man sitting on it. - -- Same half-cell box: its bottom row (42/43, the front - -- base) stands up as the couch's front, and the three - -- rows above it -- cushion (38/39) and the man - -- (36/37 head, 52/53 face) -- ride the top face in + -- and the lounge couch's SEAT column with the man + -- sitting on it. Same half-cell box: its bottom row + -- (43, the front base) stands up as the couch's + -- front, and the rows above it -- cushion (39) and + -- the man (37 head, 53 face) -- ride the top face in -- drawn order, each exactly once. See the note below -- on why he cannot be stood upright. - 36, 37, 38, 39, 42, 43, 52, 53 }, + 37, 39, 43, 53 }, + -- The couch's WEST tile column: the drawing's left strip is the + -- couch's back and arm running north-south (the seat's cushions + -- and seams fill the east column), so it rises over the 8px seat + -- the way a couch back does instead of lying flush in the same + -- box. Per-tile granularity puts the drawn ~6px strip plus a + -- 2px sliver of cushion on the raised band -- invisible at tile + -- scale, and the alternative is no backrest at all. The figure + -- anchor scans for the tallest authored UPRIGHT under the man + -- (see Structures.buildFigure), so he keeps sitting at seat + -- height beside it. + backrest = { 36, 38, 42, 52 }, -- standing per-pixel props, black-outline segmented: the healing -- machines' screen tops (58/59/74/75, drawn above the pinned -- bodies so they stand ON them) and the PC (66/70/82/86), which @@ -3231,5 +3251,170 @@ return { slab = 4, frontEave = 4, ledge = nil, }, }, + + DOJO = { + -- F01: the starter-ball table in Oak's lab (one placement in the + -- game: OAKS_LAB cell 6,3) -- the first FURNITURE through the + -- band pipeline, and the first drawing whose plot is smaller than + -- its grid. Its 24 rows read: 0-15 the tabletop seen from above + -- (black rim, white highlight course, grey field); 16-18 the top + -- slab's own front edge, black/#555/black -- which is exactly + -- what the rim treatment paints, so slab = 3 and those rows fold + -- into the roof band instead of extruding under it; 19-21 the + -- base band, corner feet and the inset dark panel between them. + -- + -- The legs stand on open FLOOR: the measured ground line lands + -- two rows short of the grid (see Buildings measure), and `depth` + -- keeps the plot to the blocked cell row -- the grid's third row + -- is the walkable cell the player faces the table from, matched + -- so the flat leg art is claimed off the floor, not so the model + -- stands on it. 16 top rows onto a 16px plot map 1:1: roofBack + -- covers the whole depth, nothing cycles, and roofCycle is + -- unreachable behind it. The Poke Ball sprites ride the `table` + -- pin's height (VoxelScene.groundAt reads the collision tile, not + -- this model), so the tileset entry above overrides that height + -- to the 6px this drawing actually stands. + { + id = "lab_table", + tiles = { + { 41, 59, 59, 59, 59, 42 }, + { 78, 57, 57, 57, 57, 79 }, + { 88, 89, 89, 89, 89, 90 }, + }, + roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 }, + slab = 3, frontEave = 0, ledge = nil, depth = 2, + }, + -- F02: the computer desk on the lab's west side (OAKS_LAB cell + -- 0,1) -- the one DESK-SET template: the pipeline's region + -- classification at PART granularity (see Buildings + -- deskSetModel). The desk is the sibling lab table (fascia rows + -- 16-18, base 19-21); on it stand a monitor over its keyboard + -- (left), a computer tower over a keyboard and mouse (middle), + -- and a sheet of paper LYING FLAT (right). Upright parts anchor + -- their drawn bottom row to the desk's top plane and wear their + -- own drawn tops as lids; flat parts lie one voxel proud at + -- drawn row = depth row -- the same 1:1 the tabletop itself is + -- drawn with. The roof fields are inert (roofRows = 0 keeps the + -- recess scan over the whole drawing, which is what sinks the + -- monitor's screen and the tower's slots). Same drawing, same + -- grid, stands in the Hall of Fame on the GYM atlas -- + -- registered there below. + { + id = "lab_computers", + tiles = { + { 91, 92, 93, 94 }, + { 54, 55, 85, 95 }, + { 88, 89, 89, 90 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 16, 18 }, base = { 19, 21 } }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 2 }, + facade = { 3, 10 }, depth = 4 }, -- the monitor + { kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard + { kind = "upright", x = { 14, 21 }, top = { 0, 3 }, + facade = { 4, 10 }, depth = 6 }, -- the tower + { kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse + { kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper + }, + }, + -- F03: the empty north table beside it (OAKS_LAB cell 2,1): the + -- starter table's band table verbatim on a grid two tiles + -- narrower. + { + id = "lab_table_small", + tiles = { + { 41, 59, 59, 42 }, + { 78, 57, 57, 79 }, + { 88, 89, 89, 90 }, + }, + roofRows = 19, roofBack = 16, roofFront = 0, roofCycle = { 2, 13 }, + slab = 3, frontEave = 0, ledge = nil, depth = 2, + }, + }, + + POKECENTER = { + -- F04: the PC in every Center's northeast corner (11 + -- placements; the Indigo Plateau lobby's twin is registered + -- under MART below). The lab desk-set read again: a Mac-style + -- unit drawn face-on -- white top band (rows 0-3), bezel, screen + -- and drive slot (4-14) -- standing at the back of a low desk + -- whose front face is rows 20-23; the drawn top around the unit + -- is WHITE, which is what `lid` carries. The keyboard rows 17-19 + -- are drawn below the desk's 16px top span, so the flat part's + -- `z` puts it at the desk's front edge. The old billboard pins + -- for these tiles (66/70/82/86, desk 9/88) stay as the + -- degradation path -- the claim neutralizes them wherever this + -- template stamps. + { + id = "center_pc", + tiles = { + { 66, 70 }, + { 82, 86 }, + { 9, 88 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 3 }, + facade = { 4, 14 }, depth = 6 }, -- the unit + { kind = "flat", x = { 2, 13 }, rows = { 17, 19 }, + z = 13 }, -- keyboard + }, + }, + }, + + MART = { + -- F04 again: the Indigo Plateau lobby's PC (cell 15,7) -- the + -- MART tileset shares the POKECENTER atlas, so this is the same + -- drawing tile for tile. Same part table as the POKECENTER entry + -- above. + { + id = "center_pc", + tiles = { + { 66, 70 }, + { 82, 86 }, + { 9, 88 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 20, 21 }, base = { 22, 23 }, lid = "white" }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 3 }, + facade = { 4, 14 }, depth = 6 }, -- the unit + { kind = "flat", x = { 2, 13 }, rows = { 17, 19 }, + z = 13 }, -- keyboard + }, + }, + }, + + GYM = { + -- F02 again: the Hall of Fame's recording machine is the lab's + -- computer desk drawing, tile for tile, on the GYM atlas (one + -- placement: HALL_OF_FAME cell 4,1). Same part table as the DOJO + -- entry above. + { + id = "lab_computers", + tiles = { + { 91, 92, 93, 94 }, + { 54, 55, 85, 95 }, + { 88, 89, 89, 90 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 2, + desk = { fascia = { 16, 18 }, base = { 19, 21 } }, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 0, 2 }, + facade = { 3, 10 }, depth = 4 }, -- the monitor + { kind = "flat", x = { 1, 13 }, rows = { 11, 14 } }, -- keyboard + { kind = "upright", x = { 14, 21 }, top = { 0, 3 }, + facade = { 4, 10 }, depth = 6 }, -- the tower + { kind = "flat", x = { 14, 21 }, rows = { 11, 14 } }, -- keys+mouse + { kind = "flat", x = { 22, 30 }, rows = { 1, 14 } }, -- the paper + }, + }, + }, }, } diff --git a/lib/AntiAlias.lua b/lib/AntiAlias.lua new file mode 100644 index 0000000..30f8761 --- /dev/null +++ b/lib/AntiAlias.lua @@ -0,0 +1,244 @@ +-- Voxel world mode: anti-aliasing, by supersampling. +-- +-- Everything else in this mod is flat art blitted at whole pixels; this one +-- pass is real geometry seen through a perspective camera, and a polygon +-- edge that lands at an angle across the pixel grid is the one place in the +-- game where a hard stair-step is not a stylistic choice. A roof ridge, a +-- ledge lip, a tree's silhouette against the sky and the leaning card of a +-- character are all cut by an edge that has no reason to line up with +-- anything, and at the shallow rungs -- where the diorama reads most like a +-- photograph of a model -- they crawl as the camera drifts. +-- +-- SUPERSAMPLING, not MSAA and not a filter over the finished frame, for two +-- reasons that both come out of what the pass already is: +-- +-- MSAA would take the water with it. The reflections read the frame's own +-- DEPTH buffer as a texture (Voxel3D.beginWater), and a multisampled depth +-- attachment is not a thing a fragment shader in this dialect can sample. +-- The row would have quietly switched the other row off. +-- +-- An edge filter (FXAA and its relatives) works from the finished colour +-- alone, and would be GUESSING where the edges are out of one sample per +-- pixel -- inventing detail it never rendered, and unable to tell a +-- geometry edge from the boundary between two texels of a tileset. +-- +-- Rendering the pass larger and folding it back down has neither problem: +-- the depth buffer stays an ordinary texture, every pass in the frame keeps +-- working in the canvas it was handed, and the fold is an average of samples +-- that were each rendered honestly. It antialiases everything at once -- +-- geometry, the alpha-cut outline of a sprite card, the wireframe, the +-- water's ray march -- because none of them know it is happening. +-- +-- Be clear about what "everything" means: the artwork softens too. A tileset +-- texel out here is not a screen pixel, it is a quad in a perspective view, +-- and its boundary crosses the pixel grid at the same arbitrary angle a roof +-- ridge does -- so the fold averages across it exactly as it averages across +-- the ridge. That is what an honest extra sample says about that pixel, and +-- it is also the trade the row IS: the diorama comes out smoother, not +-- sharper. Which is why this is a row and not something that is simply on. +-- +-- What it costs is pixels, which is the whole of why this is a row and not +-- something that is simply on: 2X is half again as many in each direction, +-- 4X is twice, and the scene pass is the most expensive thing in the frame. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") + +local AntiAlias = {} + +-- the key under options.modOptions.DRAMATIC_SHAPE, shared by the row in +-- OPTIONS and the mod manager's own settings page for this mod +AntiAlias.KEY = "aa" +AntiAlias.LABEL = "AA" + +-- The ladder is SAMPLES PER DISPLAY PIXEL, which is how an AA setting reads +-- everywhere else, and the canvas scale each rung costs is its square root: +-- 2 samples is a canvas 1.41x wider and taller, 4 is one exactly twice the +-- size. OFF is the default -- this is a cost knob, and a mod should not +-- quietly spend four times the fill rate of the machine it lands on. +AntiAlias.setting = ModSetting.new(AntiAlias.KEY, AntiAlias.LABEL, + { 0, 2, 4 }, { "OFF", "2X", "4X" }) + +-- The scale the pass currently open was actually expanded by (see expand). +-- 1 while there is no supersampling in force, which is also what every +-- reader gets on a frame that never opened a pass at all. +local live = 1 + +function AntiAlias.samples() + return tonumber(AntiAlias.setting:get()) or 0 +end + +-- What the row ASKS for. The scale in force is `factor()`, which is this +-- clamped to what the driver will actually allocate. +local function wanted() + local n = AntiAlias.samples() + if n <= 1 then return 1 end + return math.sqrt(n) +end + +-- The biggest canvas this driver admits to, or nil where it will not say. +-- A 4K window at 4X asks for 7680 across, which is past the limit on plenty +-- of hardware and every phone -- and a refused canvas is not a softer +-- diorama, it is beginScene returning false and the whole mode falling back +-- to the flat 2D path. +local function textureLimit() + if not (love.graphics and love.graphics.getSystemLimits) then return nil end + local ok, limits = pcall(love.graphics.getSystemLimits) + return (ok and limits and limits.texturesize) or nil +end + +-- The size to render `w` x `h` display pixels at, and the size everything +-- inside the pass then measures itself in. +-- +-- Also where `live` is set, which is why this must be called once per pass +-- immediately before beginScene: the wireframe's line width and the FX +-- overlay's sprite scale are both quoted in DISPLAY pixels and have to be +-- multiplied up into canvas ones, and the honest multiplier is the one this +-- returned rather than the one the row asked for. +function AntiAlias.expand(w, h) + local s = wanted() + local max = textureLimit() + if max and max > 0 then + -- clamped rather than abandoned: a window too big for 4X can usually + -- still carry some of it, and half a rung of smoothing is worth more + -- than a row that silently does nothing at that size + s = math.min(s, max / math.max(1, w), max / math.max(1, h)) + end + if not (s > 1.01) then + live = 1 + return w, h + end + local ew, eh = math.floor(w * s + 0.5), math.floor(h * s + 0.5) + live = ew / math.max(1, w) + return ew, eh +end + +-- The scale the open pass was expanded by; 1 when it was not. +function AntiAlias.factor() + return live +end + +-- ------- the fold +-- +-- One target per pass (the free-roam world and the battle's arena are alive +-- at different moments but reallocating on every battle entry and exit is +-- what the scene canvas's own slots exist to avoid), reallocated only when +-- that pass's DISPLAY size changes -- a window resize, or the row itself +-- moving, which changes the source and not this. + +local targets = {} + +local function targetFor(slot, w, h) + local t = targets[slot] + if not (t and t.w == w and t.h == h) then + local ok, c = pcall(love.graphics.newCanvas, w, h) + if not (ok and c) then return nil end + -- nearest, like the canvas it stands in for: this one is composited a + -- canvas pixel to a display pixel, and the smoothing has already happened + pcall(c.setFilter, c, "nearest", "nearest") + if t and t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end + t = { canvas = c, w = w, h = h } + targets[slot] = t + end + return t.canvas +end + +-- The box filter, and the whole of why it is a shader rather than a scaled +-- draw with linear filtering on. +-- +-- The void this pass renders into is cleared to a TRANSPARENT BLACK, and at +-- the rungs below FULL a good deal of the frame is still that. Averaging a +-- straight-alpha edge against it drags the result toward black as well as +-- toward transparent, and then the engine's own composite multiplies by that +-- alpha a second time -- so every silhouette against the void would come out +-- ringed with a dark fringe, which is exactly the artefact the row is here to +-- remove. +-- +-- So the taps are premultiplied before they are averaged and divided back out +-- after, which is the arithmetic that makes an edge pixel mean "half covered +-- by this colour" instead of "covered by half of this colour". +-- +-- Four taps, half a source texel from the destination centre. At 4X those +-- land dead on the four texel centres the destination pixel covers, so it is +-- an exact 2x2 box; at 2X the source grid does not divide, and the bilinear +-- fetch under each tap widens the box a little rather than missing samples. +local SHADER = [[ + uniform vec2 tap; // half a SOURCE texel, in uv + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + vec4 a = Texel(tex, tc + vec2(-tap.x, -tap.y)); + vec4 b = Texel(tex, tc + vec2( tap.x, -tap.y)); + vec4 c = Texel(tex, tc + vec2(-tap.x, tap.y)); + vec4 d = Texel(tex, tc + vec2( tap.x, tap.y)); + float al = (a.a + b.a + c.a + d.a) * 0.25; + if (al <= 0.0) return vec4(0.0); + vec3 sum = a.rgb * a.a + b.rgb * b.a + c.rgb * c.a + d.rgb * d.a; + return vec4(sum * 0.25 / al, al) * color; + } +]] + +local shader = nil -- nil = untried, false = unavailable + +local function getShader() + if shader == nil then + local ok, sh = pcall(love.graphics.newShader, SHADER) + shader = (ok and sh) or false + end + return shader or nil +end + +-- Fold `canvas` down to `w` x `h` and hand back the result. +-- +-- Returns the input untouched when there is nothing to fold -- the row is +-- off, or the canvas already IS that size -- so a caller can run it +-- unconditionally, and so can a headless test run. A target that would not +-- allocate is the same answer: the pass is lost either way if this hands back +-- something the wrong size, so it hands back the input and the frame draws at +-- the size it was rendered. +function AntiAlias.resolve(canvas, w, h, slot) + if not canvas then return canvas end + local ok, cw, ch = pcall(canvas.getDimensions, canvas) + if not ok or (cw == w and ch == h) then return canvas end + local target = targetFor(slot or "world", w, h) + if not target then return canvas end + + local sh = getShader() + local prevBlend, prevAlpha = love.graphics.getBlendMode() + -- the scene canvas filters nearest for its usual 1:1 blit; the taps want + -- linear, put back below so every other pass finds what it expects + pcall(canvas.setFilter, canvas, "linear", "linear") + love.graphics.setColor(1, 1, 1, 1) + -- replace, not alpha-blend: this is an image-processing copy, and the alpha + -- the shader worked out has to land as itself rather than be composited + -- against whatever the target held + love.graphics.setBlendMode("replace", "premultiplied") + if sh then + love.graphics.setShader(sh) + pcall(sh.send, sh, "tap", { 0.5 / cw, 0.5 / ch }) + end + local drew = pcall(function() + love.graphics.setCanvas(target) + love.graphics.clear(0, 0, 0, 0) + love.graphics.draw(canvas, 0, 0, 0, w / cw, h / ch) + end) + love.graphics.setCanvas() + love.graphics.setShader() + love.graphics.setBlendMode(prevBlend or "alpha", prevAlpha) + pcall(canvas.setFilter, canvas, "nearest", "nearest") + return drew and target or canvas +end + +-- Drop the GPU objects (window resize, hot reload). +function AntiAlias.invalidate() + for slot, t in pairs(targets) do + if t.canvas and t.canvas.release then pcall(t.canvas.release, t.canvas) end + targets[slot] = nil + end +end + +function AntiAlias.row() + return AntiAlias.setting:row() +end + +return AntiAlias diff --git a/lib/BattlePics.lua b/lib/BattlePics.lua index d74ec24..bca2b53 100644 --- a/lib/BattlePics.lua +++ b/lib/BattlePics.lua @@ -57,6 +57,26 @@ -- and they come back untouched because that is what their own shape says, not -- because they were special-cased. -- +-- The drain/mouth cut is for a pic STANDING ON THE MAP, where a mouth is a +-- real hole with real ground behind it. A pic PINNED TO THE MENU has no such +-- hole to be: under BACK SPRITES the player's mon is drawn in the GB's own +-- slot with its feet flush on the text box (BattleState.backPlacement pins +-- row 96), so the only thing under its lowest row is white box. Nothing can +-- reach it from below, whatever the opening's width, and the caller says so +-- by asking for a SEALED BOTTOM -- for which the rule stops being a heuristic +-- and becomes exact: paper is whatever the background cannot walk to from the +-- left, the right or the top. +-- +-- That is the difference between a Pikachu that reads as a mon and one that +-- reads as wireframe. The pale-bodied back pics -- Pikachu, Seel, Dewgong, +-- Chansey, Jigglypuff -- are drawn as OUTLINES: everything inside the ink is +-- shade 0 and every one of them is keyed away, so the figure is a rim with the +-- arena showing through it. Each one also has a wide opening along its bottom, +-- which the drain cut correctly reads as a mouth and the sealed bottom +-- correctly does not. Twelve of this game's 151 back pics turn on it; the +-- other 139 come back byte-identical either way, because they had nothing +-- under them the flood was getting in through. +-- -- The silhouette is untouched, so the mon still cuts cleanly against the -- world; only its insides stop being see-through. -- @@ -71,14 +91,23 @@ local V = ... local BattlePics = {} --- Cached by the image the engine handed over. Weak keys, so a pic that goes --- out of scope takes its filled twin with it rather than pinning a texture --- for the session. -local cache = setmetatable({}, { __mode = "k" }) +-- Cached by the image the engine handed over, one table per bottom rule -- +-- the same pic answers differently sealed and unsealed, and a single table +-- would hand the wrong twin back to whichever caller asked second. Weak keys, +-- so a pic that goes out of scope takes its filled twin with it rather than +-- pinning a texture for the session. +local function newCache() + return { + [false] = setmetatable({}, { __mode = "k" }), + [true] = setmetatable({}, { __mode = "k" }), + } +end +local cache = newCache() --- What an enclosed hole is filled with. White, because white is what the --- battle field was: this restores the pixel the artist drew and the engine --- then keyed away, it does not invent a new one. +-- What an enclosed hole is filled with when the pic itself offers nothing +-- better. White, because white is what the battle field was: this restores the +-- pixel the artist drew and the engine then keyed away, it does not invent a +-- new one. BattlePics.FILL = { 1, 1, 1, 1 } -- Anything at or under this alpha counts as keyed-out rather than drawn. @@ -154,6 +183,42 @@ local function inkBounds(data, w, h) return x0, y0, x1, y1 end +-- The colour the keyed-away shade would have had: the LIGHTEST colour still +-- standing in the pic. +-- +-- Pure white is only the right answer while the pic is still grays, and by the +-- time it reaches here it usually is not. picImage hands a pic over AFTER the +-- bake -- a species SGB colour, a BGP fade mid-animation, PAL_BLACK for the +-- whole screen while the blackout text is up -- and shade 0 travels with the +-- rest. A white belly inside a blacked-out mon would be the one lit thing on a +-- dark screen; inside a warm-palette mon it would be a cold patch the artist +-- never drew. +-- +-- So the paper is read off the pic rather than assumed, which needs shade 0 to +-- have survived somewhere in it. It always has: every one of this game's 151 +-- back pics keeps at least one opaque shade-0 pixel -- a highlight down a +-- cheek, the white of an eye -- because only the shade-0 pixels the decoder +-- could reach were keyed. So what comes back is the baked shade 0 itself, not +-- an approximation of it, and it tracks every palette the engine picks without +-- being told which one that was. +-- +-- Ranked by channel sum, which orders four DMG shades exactly: a palette maps +-- all three channels monotonically, so lightest by sum is lightest full stop. +local function paperColor(data, x0, y0, x1, y1) + local best, pr, pg, pb = -1, nil, nil, nil + for y = y0, y1 do + for x = x0, x1 do + local r, g, b, a = data:getPixel(x, y) + if a > CUT then + local lum = r + g + b + if lum > best then best, pr, pg, pb = lum, r, g, b end + end + end + end + if best < 0 then return nil end + return pr, pg, pb +end + -- The widest opening along the bottom of a figure that still counts as a drain -- rather than a mouth. See the header for the measurements either side of it. BattlePics.DRAIN = 6 @@ -161,7 +226,9 @@ BattlePics.DRAIN = 6 -- Mark every transparent pixel the BACKGROUND can reach, flooding inward from -- the edges of the artwork's box: the left, the right and the top whole, and -- along the bottom only those openings wide enough to be background rather --- than the underside of a figure the drawing ran out of. +-- than the underside of a figure the drawing ran out of -- or none of them at +-- all, for a pic whose feet are on the text box and which therefore has +-- nothing behind its lowest row to let in. -- -- Confined to the box as well as seeded from it, so the empty frame under a -- short pic cannot walk around a sealed drain and come back up through it. @@ -169,7 +236,7 @@ BattlePics.DRAIN = 6 -- An explicit stack rather than recursion: a 56x56 pic is three thousand -- pixels and a keyed-out background is most of them, which is a deeper call -- chain than is worth risking for no gain. -local function markOutside(data, w, h, x0, y0, x1, y1) +local function markOutside(data, w, h, x0, y0, x1, y1, sealBottom) local outside = {} local stack, top = {}, 0 local function clear(x, y) @@ -191,17 +258,21 @@ local function markOutside(data, w, h, x0, y0, x1, y1) push(x1, y) end -- the bottom, run by run: a wide one is the gap between two legs and lets - -- the world through, a narrow one is where a belly ran out and is sealed - local x = x0 - while x <= x1 do - if clear(x, y1) then - local from = x - while x <= x1 and clear(x, y1) do x = x + 1 end - if (x - from) > BattlePics.DRAIN then - for k = from, x - 1 do push(k, y1) end + -- the world through, a narrow one is where a belly ran out and is sealed. + -- Skipped whole for a pic on the box, where even the widest of them has + -- white paper behind it rather than arena. + if not sealBottom then + local x = x0 + while x <= x1 do + if clear(x, y1) then + local from = x + while x <= x1 and clear(x, y1) do x = x + 1 end + if (x - from) > BattlePics.DRAIN then + for k = from, x - 1 do push(k, y1) end + end + else + x = x + 1 end - else - x = x + 1 end end while top > 0 do @@ -219,9 +290,15 @@ end -- The pic with its enclosed holes filled, or the pic itself when that could -- not be done (no pixel access, a driver that refused the readback). Never -- nil for a non-nil argument: a caller must always have something to draw. -function BattlePics.filled(img) +-- +-- sealBottom for a pic pinned to the text box rather than standing on the map: +-- see the header. A caller that does not say defaults to the map, which is +-- where all but one of this mod's pics are. +function BattlePics.filled(img, sealBottom) if not img then return img end - local hit = cache[img] + sealBottom = sealBottom and true or false + local slot = cache[sealBottom] + local hit = slot[img] if hit ~= nil then return hit or img end local made = nil @@ -231,8 +308,12 @@ function BattlePics.filled(img) local w, h = data:getDimensions() local x0, y0, x1, y1 = inkBounds(data, w, h) if not x0 then return end -- a pic with nothing drawn in it - local outside = markOutside(data, w, h, x0, y0, x1, y1) + local outside = markOutside(data, w, h, x0, y0, x1, y1, sealBottom) local fill = BattlePics.FILL + local pr, pg, pb = paperColor(data, x0, y0, x1, y1) + local fr = pr or fill[1] + local fg = pg or fill[2] + local fb = pb or fill[3] local changed = false -- only inside the box: everything beyond it is frame the artist never -- reached, and filling that would put the mon in a white rectangle @@ -242,7 +323,7 @@ function BattlePics.filled(img) if not outside[row + x] then local _, _, _, a = data:getPixel(x, y) if a <= CUT then - data:setPixel(x, y, fill[1], fill[2], fill[3], fill[4]) + data:setPixel(x, y, fr, fg, fb, fill[4]) changed = true end end @@ -255,12 +336,12 @@ function BattlePics.filled(img) made = out end) - cache[img] = (ok and made) or false + slot[img] = (ok and made) or false return made or img end function BattlePics.invalidate() - cache = setmetatable({}, { __mode = "k" }) + cache = newCache() end return BattlePics diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 170caac..3938f89 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -42,6 +42,7 @@ local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") +local AntiAlias = V.require("AntiAlias") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -427,7 +428,16 @@ function BattleScene.render(state, arena, textures, token) -- its own canvas slot: this renders at the window's pixel size and the -- free-roam pass does too, but the two are alive at different moments -- and a shared slot would reallocate on every battle entry and exit - if not Voxel3D.beginScene(pw, ph, cx, cy, vw, vh, sky, "battle") then + -- + -- AA, if the row asks for it, renders it larger still and folds it back + -- to pw x ph below (see AntiAlias). The framing is untouched by that: + -- the lens was widened by the window's RATIO to the letterbox and the + -- rig solved in the GB's own frame, so a bigger canvas is more samples + -- of the identical shot -- which is why the pins below still measure in + -- pw and ph, and why the HUDs and the depth of field, drawn onto the + -- folded canvas afterwards, stay the chunky GB art they are. + local rw, rh = AntiAlias.expand(pw, ph) + if not Voxel3D.beginScene(rw, rh, cx, cy, vw, vh, sky, "battle") then return end Voxel3D.draw(terrain, atlasFor(host), nil) @@ -500,7 +510,7 @@ function BattleScene.render(state, arena, textures, token) Mat4.translate(nb.ox, 0, nb.oy), fpull, ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end - local canvas = Voxel3D.endScene() + local canvas = AntiAlias.resolve(Voxel3D.endScene(), pw, ph, "battle") if not canvas then return end local vp = Voxel3D.vp diff --git a/lib/Buildings.lua b/lib/Buildings.lua index 7bfc6e0..b07c913 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -190,7 +190,24 @@ local function measure(sp, t) top[x] = r end - local wallH = H - roofRows + -- The drawing's own ground line: the row after the last drawn one. A + -- building ends on the black threshold row it stands on (ground == H), + -- but furniture is drawn standing on open floor -- the lab table's + -- legs stop two rows short of its grid -- and extruding against H + -- would float it that far above its own plot. + local ground = roofRows + for sy = H - 1, roofRows, -1 do + local drawn = false + for sx = 0, W - 1 do + if sp.inside[sy * W + sx] then drawn = true break end + end + if drawn then + ground = sy + 1 + break + end + end + + local wallH = ground - roofRows local ytop = wallH - 1 + t.slab -- Side faces must not come out as slabs of outline black: where the @@ -279,20 +296,175 @@ local function measure(sp, t) -- sprite taller than its footprint -- the tower's 16-row drawing -- stands on the 8 rows of it that are actually on the map, and D = H -- would have pushed its body 64px south into the town plaza. - return { top = top, ytop = ytop, D = #t.tiles * 8, + -- `depth` (in tile rows) names the plot when the grid runs PAST it + -- onto ground the drawing merely stands its legs on: the lab table's + -- third row is the walkable cell the player faces it from, and the + -- full-grid depth would stand the model in their path. + return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8, + ground = ground, recess = recess, interior = interior, shadeTexel = shadeTexel } end -- ----------------------------------------------------------------- build -- +-- A desk with separately-classified objects on it (a template's `parts` +-- list): the methodology's region classification at part granularity. +-- Upright parts anchor their drawn bottom row to the desk's top plane +-- and wear their own drawn tops as lids; flat parts (a keyboard, a +-- sheet of paper) lie one voxel proud at drawn row = depth row -- the +-- same 1:1 the tabletop itself is drawn with, so an object's height ON +-- the drawing is its position ON the desk. The desk is the lab-table +-- slab + base; its lid is the one synthesized surface in the model +-- (the objects cover every drawn pixel of the tabletop), continued +-- from the sibling tables' pattern in the drawing's own shades. +-- tools/building_voxels.py `build_desk_set` is the reference twin. +local function deskSetModel(sp, pr, t) + local W, H, D = sp.W, sp.H, pr.D + local ground = pr.ground + local col, inside = sp.col, sp.inside + local vox = {} + local function key(x, y, z) return (y * D + z) * W + x end + local function put(x, y, z, i) vox[key(x, y, z)] = i end + + -- de-outline walk bounded to the part, so a part's side faces show + -- its own material and never the neighbour's (the sprite-wide walk + -- the facade path uses would cross the black seam between units) + local function interiorAt(sx, sy, lo, hi) + local i = sy * W + sx + if col[i] ~= BLACK then return sx end + local step = sx < math.floor((lo + hi) / 2) and 1 or -1 + for d = 1, 3 do + local nx = sx + step * d + if nx >= lo and nx <= hi then + local ni = sy * W + nx + if inside[ni] and col[ni] ~= BLACK then return nx end + end + end + return sx + end + + local f0, f1 = t.desk.fascia[1], t.desk.fascia[2] + local b0, b1 = t.desk.base[1], t.desk.base[2] + local plane = (b1 - b0 + 1) + (f1 - f0 + 1) + + -- the base band, extruded exactly like every lab table's + for sy = b0, b1 do + Budget.tick() + local y = ground - 1 - sy + for sx = 0, W - 1 do + if inside[sy * W + sx] then + local ix = interiorAt(sx, sy, 0, W - 1) + for z = 0, D - 1 do + local px = (z == 0 or z == D - 1) and sx or ix + put(sx, y, z, sy * W + px) + end + end + end + end + for i in pairs(pr.recess) do + local sy = math.floor(i / W) + if sy >= b0 and sy <= b1 then + vox[key(i % W, ground - 1 - sy, D - 1)] = nil + end + end + + -- the slab: fascia rows wrap every side; the lid continues the + -- sibling tables' top -- black rim, white highlight courses along + -- the north and west, grey field + for sy = f0, f1 do + Budget.tick() + local y = plane - 1 - (sy - f0) + for sx = 0, W - 1 do + for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end + end + end + local field = t.desk.lid == "white" and WHITE or GREY + for sx = 0, W - 1 do + for z = 0, D - 1 do + local shade = field + if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then + shade = BLACK + elseif sx == 1 or z == 1 then + shade = WHITE + end + put(sx, plane - 1, z, pr.shadeTexel[shade]) + end + end + + local ytop = plane + for _, p in ipairs(t.parts) do + Budget.tick() + local x0, x1 = p.x[1], p.x[2] + if p.kind == "flat" then + -- drawn row = depth row by default; `z` renames the origin when + -- the flat sits below the desk's own drawn top span (the Center + -- PC's keyboard) + local r0 = p.rows[1] + local z0 = p.z or r0 + for sy = r0, p.rows[2] do + local z = z0 + (sy - r0) + if z >= 0 and z < D then + for sx = x0, x1 do + if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end + end + end + end + else + local tr0, tr1 = p.top[1], p.top[2] + local fr0, fr1 = p.facade[1], p.facade[2] + local pd = p.depth + local ytp = plane + (fr1 - fr0) + if ytp > ytop then ytop = ytp end + for sx = x0, x1 do + -- the lid: the part's drawn top laid across its depth from the + -- back, last row continuing forward; the front lid row is the + -- facade's own top row -- the drawn front-top edge + for z = 0, pd - 1 do + local front = z == pd - 1 + local sy = front and fr0 or math.min(tr0 + z, tr1) + while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end + local ok = sy <= tr1 or (front and inside[fr0 * W + sx]) + if ok then + put(sx, ytp, z, (front and fr0 or sy) * W + sx) + end + end + -- the body: facade rows anchored to the desk's top plane + for sy = fr0 + 1, fr1 do + local y = plane + (fr1 - sy) + local i = sy * W + sx + if inside[i] then + local ix = interiorAt(sx, sy, x0, x1) + for z = 0, pd - 1 do + if z == pd - 1 then + if not pr.recess[i] then put(sx, y, z, i) end + elseif z == 0 then + put(sx, y, z, i) + else + put(sx, y, z, sy * W + ix) + end + end + end + end + end + end + end + + return { at = function(x, y, z) + if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end + return vox[key(x, y, z)] + end, + W = W, ytop = ytop, zmin = 0, zmax = D - 1 } +end + -- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite -- pixel that voxel wears, or nil. Build ORDER is expressed as lookup -- order -- roof first, so it overwrites the walls it intersects, and walls -- are trimmed to its underside so nothing pokes through the surface. local function model(sp, pr, t) + if t.parts then return deskSetModel(sp, pr, t) end local W, H, D = sp.W, sp.H, pr.D local slab, roofRows = t.slab, t.roofRows - local top, ytop = pr.top, pr.ytop + local top, ytop, ground = pr.top, pr.ytop, pr.ground -- The roof's drawn span. A sprite inset from its box (B03) leaves outer -- columns undrawn in the roof band; they carry no roof at all, and the @@ -367,16 +539,18 @@ local function model(sp, pr, t) -- the awning: the band juts two voxels past the walls, front and back if ledge0 and (z == -2 or z == -1 or z == D or z == D + 1) then - local sy = H - 1 - y + local sy = ground - 1 - y if sy >= ledge0 and sy <= ledge1 and sp.inside[sy * W + x] then return sy * W + x end return nil end - -- the facade, extruded straight back over the footprint + -- the facade, extruded straight back over the footprint. Rows map + -- against the measured ground line, not the grid's last row: the two + -- differ only for furniture standing on open floor (see measure). if z < 0 or z >= D then return nil end - local sy = H - 1 - y + local sy = ground - 1 - y local i = sy * W + x if y == 0 and not sp.inside[i] and sy > 0 and sp.inside[i - W] then -- the drawing's last row is the ground the building stands on, so diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index dddf97d..78fdfff 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -104,6 +104,31 @@ function OverworldBattle.backPinned() return OverworldBattle.backSetting:get() and true or false end +-- Whether a pic is the one drawn in the GB's own slot with its feet on the +-- text box, rather than geometry standing out on the map. +-- +-- Exactly the player's side under BACK SPRITES -- its mon, or the trainer back +-- that holds the slot until "Go!" -- because that is the only pic this mod +-- ever leaves flat (see drawPicsLayer below). The foe is a billboard on its +-- tile whichever mode is on, and with the mode off the player's side is one +-- too, so both of those keep the open bottom that lets the arena through a +-- stride. What the answer buys is in BattlePics: a pic on the box has nothing +-- behind its lowest row, so its bottom edge seals. +-- Read by TRUTHINESS rather than against nil, because sideTexture blanks the +-- side it is not rendering by setting the field to FALSE (see OFF) and holds +-- it that way for the whole render -- during which the pic layer runs, and +-- picImage asks this. A nil test passes a `false` straight through to the +-- index below, and the error comes out of sideTexture into the pcall that +-- calls it: the foe's billboard is dropped for the frame and the Pokemon +-- simply is not there. +function OverworldBattle.pinnedPic(battle, img) + if not (battle and img) then return false end + if not OverworldBattle.backPinned() then return false end + if img == battle.playerBackPic then return true end + local player = battle.player + return (player and img == player.sprite) and true or false +end + -- ------- both mons face you -- -- Standing on a map, seen from in front, a Pokemon showing you its BACK is @@ -831,11 +856,17 @@ function OverworldBattle.install() -- behind it. There is a world back there now, so they are filled here -- instead -- see BattlePics, which puts the paper back without touching -- the silhouette. + -- + -- The pinned pic is told that its feet are on the box, which is what lets + -- the pale-bodied back sprites be filled at all: their bellies leak out + -- through an opening too wide to read as a drain, and only the box under + -- them settles that it is not a hole. Passed the pre-bake image, because + -- that is the one the battle holds a reference to. local innerPic = BattleState.picImage function BattleState:picImage(img) local out = innerPic(self, img) if not OverworldBattle.shot() then return out end - return BattlePics.filled(out) + return BattlePics.filled(out, OverworldBattle.pinnedPic(self, img)) end -- While a billboard texture is being rendered both pics are put in the same diff --git a/lib/Structures.lua b/lib/Structures.lua index 0b5262a..2e3e70c 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -2253,14 +2253,22 @@ local function buildFigure(S, map, fig, tx, ty, perRow) -- 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. + -- seat you merely walk up to is in a walkable one. The row under his + -- card is SCANNED for the tallest authored upright rather than read at + -- its west corner: the corner tile can be furniture that is not his + -- seat (the couch's raised backrest column stands there, `top` art and + -- taller than the cushion he actually sits on). 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 = bs.h + if blocked then + for dx = 0, fig.w - 1 do + local bs = S.shapeAt[keyOf(tx + dx, ty + fig.h)] + if bs and bs.authored and bs.art == "upright" + and (bs.h or 0) > baseY then + baseY = bs.h + end + end end local atlasW = map.tileset.imageWidth or 128 diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 6cb65ce..31e5916 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -83,6 +83,10 @@ local FALLBACK_HEIGHTS = { bed = 7, stool = 8, counter = 8, + -- the raised back band of low seating: the Center couch's west strip + -- is drawn from above like the rest of the couch, but depicts the + -- back and arm rising over the 8px seat + backrest = 12, table = 12, desk = 24, prop = 16, @@ -145,6 +149,9 @@ local ART = { -- profile archetype Structures builds real steps for -- rising flights -- for stairs leading up, sunken stairwells for stairs leading down bed = "top", + -- a backrest's art is the couch seen from above, so like the bed it + -- rides the top face of its taller box + backrest = "top", stool = "billboard", -- half-cell furniture: a service counter, a low couch. One 8px band, -- so exactly the drawing's bottom row stands up as the front and @@ -295,6 +302,24 @@ function TileShape.forMap(map) if cache[id] then return cache[id] end local heights = TileShape.heights() + -- Per-tileset height overrides (a tileset entry's `heights`): the class + -- vocabulary is global but the drawings are not -- the DOJO lab tables + -- are drawn 6px tall where the default `table` is 12 -- and the height + -- a sprite RIDES at (VoxelScene.groundAt) must be the height the art + -- actually stands, or the starter balls float over their own table. + -- Same gate as the global list: known classes, numbers only. + do + local s = load() + local entry = s and s.tilesets and s.tilesets[id] + local over = entry and entry.heights + if type(over) == "table" then + for class, h in pairs(over) do + if type(h) == "number" and FALLBACK_HEIGHTS[class] then + heights[class] = h + end + end + end + end local authored = authoredGroups(id, heights) local count = math.floor((tileset.imageWidth or 128) / 8) * math.floor((tileset.imageHeight or 48) / 8) diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 471a688..54ef847 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -715,7 +715,7 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(sh.send, sh, "sunTexel", { texel, texel }) if grid then pcall(sh.send, sh, "gridDark", VoxelGrid.DARK) - pcall(sh.send, sh, "gridWidth", VoxelGrid.WIDTH) + pcall(sh.send, sh, "gridWidth", VoxelGrid.width()) end -- ordinary shading until the silhouette pass asks for otherwise. Sent -- every frame rather than once, because a scene that opened mid-ghost -- diff --git a/lib/VoxelGrid.lua b/lib/VoxelGrid.lua index 1fb7ce7..b585072 100644 --- a/lib/VoxelGrid.lua +++ b/lib/VoxelGrid.lua @@ -44,6 +44,19 @@ VoxelGrid.DARK = 0.45 -- 1.0 here is the one-pixel wireframe. VoxelGrid.WIDTH = 1.0 +-- The same width in the CANVAS pixels the shader measures in, which is what +-- every sender of it actually wants. +-- +-- The two are the same number until AA renders the pass larger than the +-- window (see AntiAlias): there a canvas pixel is a fraction of a display +-- one, and a width left at 1.0 would come out a half or a quarter of a line +-- after the fold -- the wireframe fading as the smoothing goes up, which +-- reads as one row breaking the other. Scaled, it stays a one-pixel seam and +-- simply gains the antialiasing everything else in the frame just gained. +function VoxelGrid.width() + return VoxelGrid.WIDTH * V.require("AntiAlias").factor() +end + -- where it persists and the rows that cycle it (see ModSetting) VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL, { false, true }, { "OFF", "ON" }) diff --git a/lib/Water.lua b/lib/Water.lua index 9b7956c..db1db8e 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -1192,7 +1192,7 @@ function Water.begin(ctx) if ctx.grid then local VoxelGrid = V.require("VoxelGrid") send("gridDark", VoxelGrid.DARK) - send("gridWidth", VoxelGrid.WIDTH) + send("gridWidth", VoxelGrid.width()) end Water.sendSky(sh, ctx) diff --git a/main.lua b/main.lua index 95ca933..01aaf0f 100644 --- a/main.lua +++ b/main.lua @@ -82,6 +82,7 @@ local BattleExit = V.require("BattleExit") local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") +local AntiAlias = V.require("AntiAlias") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -203,20 +204,34 @@ mod.content.render_pipelines:register("voxel", { -- a magnified low-res image, while the FX closures keep drawing in -- world-pixel units. local sw, sh = sceneSize(ctx) - local canvas = VoxelScene.render(ctx.state, sw, sh, + -- With AA on, the whole pass runs into a canvas BIGGER than the window + -- and is folded back down at the end (see AntiAlias). Nothing between + -- these two lines knows: every pass in the frame measures itself in the + -- canvas it was handed, so the sky's dither, the water's march and the + -- camera itself all come out the same picture at a higher sample rate. + local rw, rh = AntiAlias.expand(sw, sh) + local canvas = VoxelScene.render(ctx.state, rw, rh, ctx.vw, ctx.vh, ctx.paletteFor) if not canvas then return nil end -- fall back to the 2D path if Voxel3D.beginOverlay() then + -- the FX closures are ordinary 2D draws sized in DISPLAY pixels, and + -- they are drawing into the supersampled canvas alongside everything + -- else -- so the scale goes up with it, or the "!" bubble lands the + -- right place at half the size. project() already answers in canvas + -- pixels, so only the scale needs saying. ctx.drawFx(function(wx, wy) return Voxel3D.project(wx, 0, wy) end, - ctx.scale) + ctx.scale * AntiAlias.factor()) Voxel3D.endOverlay() end - return canvas + -- and back to the window's own size, which is what the engine composites + -- one canvas pixel to one display pixel. A pass-through when AA is off. + return AntiAlias.resolve(canvas, sw, sh, "world") end, invalidate = function() Voxel3D.invalidate() OverworldBattle.invalidate() + AntiAlias.invalidate() ChunkMesher.invalidate() -- no map id = every cached mesh end, }) @@ -361,6 +376,21 @@ local SETTINGS = { .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " .. "shadows, the sky and the light following -- or SYNC it to the " .. "clock on the wall, so Kanto's evening falls when yours does." }, + -- Marked `full` for the opposite reason the battle rows are: this is not a + -- knob on the look at all, it is what the look COSTS. FULL is a preset for + -- the diorama, not a licence to spend four times the fill rate on the + -- machine it happens to be running on, so it neither sets this nor takes + -- the row away -- the player decides what their hardware can carry, from + -- inside FULL like anywhere else. + { AntiAlias.setting, + "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge " + .. "lips, a tree against the sky -- by rendering the diorama larger than " + .. "the window and folding it back down. Every edge in the picture " + .. "softens with them, the tileset's own texels included, so the diorama " + .. "reads smoother rather than sharper. 2X costs half again as many " + .. "pixels in each direction and 4X twice, which makes this the most " + .. "expensive row in the mod.", + full = true }, } local schema = {} @@ -860,7 +890,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.4.0" +mod.exports.version = "1.4.1" -- exposed so a companion mod can pin its own tiles' shapes or read the -- camera without reaching into this mod's file layout mod.exports.lib = V diff --git a/manifest.json b/manifest.json index 157448b..b2382d4 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.4.0", + "version": "1.4.1", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/tests/aa_shots.lua b/tests/aa_shots.lua new file mode 100644 index 0000000..dcd484a --- /dev/null +++ b/tests/aa_shots.lua @@ -0,0 +1,195 @@ +-- Driver: one scene, once per rung of the AA row. +-- +-- The AA row is the one setting in this mod whose whole effect is a pixel +-- wide, so it is also the one that cannot be judged from a description. This +-- renders the SAME frame at each rung and writes one PNG per rung; put two of +-- them side by side, magnified, and the row is either doing something or it +-- is not. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/aa_shots.lua \ +-- SHOT_DIR= lovec.exe . +-- +-- knobs (env): +-- SHOT_DIR output directory (created if missing) (default "shots/aa") +-- AA_MAP map id (default VIRIDIAN_CITY) +-- AA_SPOT "x,y[,facing]" (default 20,26,up) +-- AA_RUNG the voxel camera rung (default 5, the 75 one) +-- +-- The scene defaults to a town at the LOW camera on purpose: roof ridges, the +-- diagonal of a fence and a tree's silhouette against the sky are the edges +-- that stair-step, and 75 degrees is the rung that puts the most of them at an +-- angle to the pixel grid. +-- +-- Determinism matters here for the same reason it does in voxel_shots_ab: the +-- three shots differ ONLY by the row under test, or comparing them means +-- nothing. The clock is pinned, the animated tile slots are frozen, the +-- townsfolk are stopped where they stand, and the tilt-shift is held at zero +-- (a gaussian over the frame would smear away the very edges being looked at). +-- +-- Nothing here writes the player's options: the row is moved with +-- ModSetting:sync, which moves the cached index and persists nothing. +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + local OverworldState = require("src.world.OverworldController") + + local ROOT = os.getenv("SHOT_DIR") or "shots/aa" + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[aa] DRAMATIC_SHAPE mod not loaded -- nothing to shoot") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local AntiAlias = V.require("AntiAlias") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local ShadowMap = V.require("ShadowMap") + + local MAP = os.getenv("AA_MAP") or "VIRIDIAN_CITY" + local SPOT = os.getenv("AA_SPOT") or "20,26,up" + local RUNG = math.floor(tonumber(os.getenv("AA_RUNG")) or 5) + local sx, sy, sf = SPOT:match("^(%-?%d+),%s*(%-?%d+),?%s*(%a*)$") + sx, sy = tonumber(sx) or 20, tonumber(sy) or 26 + if sf == "" then sf = "up" end + + OverworldState.rollEncounter = function() return nil end + + local NPC = require("src.world.NPC") + if not NPC.dramaticShapeAaFreeze then + local inner = NPC.update + function NPC:update(...) + self.frozen = true + return inner(self, ...) + end + NPC.dramaticShapeAaFreeze = true + end + pcall(love.math.setRandomSeed, 20260801) + + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function cameraStill() + local o = game.overworld + local c = o and o.camera + if not c then return true end + local lx, ly, held = nil, nil, 0 + for _ = 1, 300 do + if c.x == lx and c.y == ly then + held = held + 1 + if held >= 10 then return true end + else + held = 0 + lx, ly = c.x, c.y + end + U.wait(1) + end + return false + end + + -- The camera PITCH, which is the one that caught this driver out. The tween + -- runs on wall-clock dt and Voxel.t reaching 1 is not the same instant the + -- angle stops moving, so the first shot of a run came out at 67 degrees + -- while the two after it were at 75 -- three frames that differ by the + -- camera, in a comparison whose entire subject is a pixel. + local function angleStill() + local last, held = nil, 0 + for _ = 1, 600 do + if Voxel.angle == last then + held = held + 1 + if held >= 10 then return true end + else + held = 0 + last = Voxel.angle + end + U.wait(1) + end + return false + end + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + angleStill() + cameraStill() + -- the sun map is only redrawn when its inputs move, and the AA row is not + -- one of them -- so force one pass at the settled camera rather than + -- comparing a frame against a map fitted a few hundredths of a pixel ago + if ShadowMap.forget then ShadowMap.forget() end + U.wait(20) + end + + DayNight.setting:sync("day") + U.teleport(game, MAP, sx, sy, sf) + Pipelines.setLevel("voxel", RUNG) + Pipelines.setLevel("tiltshift", 0) + + -- Warm up before the FIRST shot, not just between them. + -- + -- Neighbour maps are requested from inside the render itself + -- (VoxelScene.prefetch), so an empty build queue right after a teleport + -- means "nothing has been asked for yet", not "everything is here". The + -- first capture of a run came out with the map beyond Viridian missing -- + -- a whole tree line absent from one frame of a three-way comparison, which + -- looks exactly like the row under test doing something enormous. Settling + -- twice lets the first render request the neighbourhood and the second + -- drain it. + settle() + settle() + + local shots, missed = 0, 0 + for _, samples in ipairs({ 0, 2, 4 }) do + AntiAlias.setting:sync(samples) + settle() + -- AA_TRACE=1 prints the state each shot was taken in. When two shots of a + -- run disagree by more than the row could account for, this is what says + -- which input moved -- it is how the camera-tween and the neighbour-mesh + -- settles above were both found. + if os.getenv("AA_TRACE") == "1" then + local Voxel3D = V.require("Voxel3D") + local o = game.overworld + local cw, chh = Voxel3D.size() + print(("[aa] trace samples=%d angle=%.6f fov=%.6f cell=%.4f canvas=%dx%d cam=(%.3f,%.3f) eye=(%.2f,%.2f,%.2f) factor=%.4f") + :format(samples, Voxel.angle or -1, Voxel3D.fovY or -1, + Voxel3D.cell or -1, cw or 0, chh or 0, + o and o.camera and o.camera.x or -1, + o and o.camera and o.camera.y or -1, + (Voxel3D.eye or {})[1] or 0, (Voxel3D.eye or {})[2] or 0, + (Voxel3D.eye or {})[3] or 0, AntiAlias.factor())) + end + local path = ("%s/aa_%d.png"):format(ROOT, samples) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then + f:close() + shots = shots + 1 + print(("[aa] %s samples=%d"):format(path, samples)) + else + missed = missed + 1 + print("[aa] capture did not reach disk: " .. path) + end + end + -- left where it was found, so a run cannot leak a rung into the next one + AntiAlias.setting:sync(0) + + print(("[aa] %d shots into %s (%d failed to reach disk)") + :format(shots, ROOT, missed)) +end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 6bdfb73..0ca9283 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -145,6 +145,9 @@ T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") -- back sprite (or no staged fights at all) can still say so from inside FULL T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL") T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") +-- and AA, for the opposite reason: it is not a knob on the look at all, it is +-- what the look COSTS, and only the player knows what their machine can carry +T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns") -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot -- be reached while FULL owns it, so a value changed underneath (the mod @@ -379,9 +382,11 @@ 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, 7, "the options hook added a row per setting") +T.eq(#hookedRows, 8, "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 +-- this chunk is one main function and has 200 local slots to spend T.eq(water.label, "WATER", "the water row carries its label") T.eq(water.value(), "FULL", "and defaults to FULL -- reflections are the point of having the row") @@ -459,6 +464,57 @@ curve.step(settingGame, 1) curve.step(settingGame, 1) T.eq(curve.value(), "OFF", "the curve is left off for the rows below") +-- ------- AA renders the pass larger and folds it back down +-- +-- The ladder is SAMPLES per display pixel, so the canvas scale each rung asks +-- for is its square root -- and the scale in force has to be readable after +-- the fact, because two things are quoted in DISPLAY pixels and have to be +-- multiplied up into the canvas the pass actually opened: the wireframe's +-- line width and the FX overlay's sprite scale. +do +local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") +local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") +local aaGame = { save = { options = {} }, mods = { modOptions = {} } } +local aa = hookedRows[8] +T.eq(aa.label, "AA", "the anti-aliasing row carries its label") +T.eq(aa.value(), "OFF", + "and starts off -- supersampling is a cost knob, and a mod must not spend " + .. "four times the fill rate of the machine it lands on unasked") + +T.eq(AntiAlias.samples(), 0, "AA is off by default") +local w, h = AntiAlias.expand(320, 200) +T.eq(w, 320, "an OFF row renders at the window's own width") +T.eq(h, 200, "and its height") +T.eq(AntiAlias.factor(), 1, "with nothing to multiply display pixels by") +T.eq(VoxelGrid.width(), VoxelGrid.WIDTH, + "so the wireframe is the one-display-pixel line it has always been") + +aa.step(aaGame, 1) +T.eq(aa.value(), "2X", "stepping the row climbs to two samples a pixel") +T.eq(aaGame.save.options.modOptions.DRAMATIC_SHAPE.aa, 2, + "the sample count persists beside the other settings, not over them") +w, h = AntiAlias.expand(320, 200) +T.eq(w, 453, "two samples a pixel is a canvas root-two wider") +T.eq(h, 283, "and root-two taller") +T.check(math.abs(AntiAlias.factor() - 453 / 320) < 1e-9, + "and the factor is what it MEASURED, not what the row asked for") + +aa.step(aaGame, 1) +T.eq(aa.value(), "4X", "and again to four") +w, h = AntiAlias.expand(320, 200) +T.eq(w, 640, "four samples a pixel is a canvas exactly twice the size") +T.eq(h, 400, "in each direction, which is the 2x2 box the fold reads") +T.eq(AntiAlias.factor(), 2, "with everything in display pixels doubled") +T.eq(VoxelGrid.width(), VoxelGrid.WIDTH * 2, + "the wireframe among them -- a seam left at 1.0 would fold down to half a " + .. "line, so the smoothing row would appear to fade the grid row out") + +aa.step(aaGame, 1) +T.eq(aa.value(), "OFF", "and the ladder wraps back to off") +AntiAlias.expand(320, 200) +T.eq(AntiAlias.factor(), 1, "leaving nothing behind for the next pass") +end + -- ------- the animated terrain atlas survives an engine without its seams -- -- Regression: cycling palette modes with voxel mode on eventually killed @@ -2476,7 +2532,35 @@ T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row") T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, "directly under the row it belongs to") +-- ------- and which pic is the pinned one is asked with the other side BLANKED +-- +-- picImage asks this so BattlePics knows whether a pic's feet are on the text +-- box -- where its bottom edge seals, and its belly stops being see-through -- +-- and it is asked DURING the billboard render, inside which sideTexture has +-- switched the side it is not drawing off by setting the field to FALSE rather +-- than to nil (see OFF). +-- +-- So the read has to be by truthiness. A test against nil passes that `false` +-- through to the index below it, the error comes back out of sideTexture into +-- the pcall that calls it, textures() reports no card for the side -- and the +-- foe is simply not on the field. Which is the whole bug: fixing the player's +-- back pic took the enemy's billboard out. +local mine, theirs = {}, {} +local live = { player = { sprite = mine }, enemy = { sprite = theirs } } +T.eq(Battles.pinnedPic(live, mine), true, + "the player's own mon is the pic on the box") +T.eq(Battles.pinnedPic(live, theirs), false, + "and the foe is geometry out on the map, whatever the mode") +T.eq(Battles.pinnedPic({ player = false, enemy = { sprite = theirs } }, theirs), + false, "asking about the foe while the player is blanked answers, not throws") +T.eq(Battles.pinnedPic({ playerBackPic = mine }, mine), true, + "the trainer back holds the slot until Go!, on the box like the mon") +T.eq(Battles.pinnedPic({ player = false, playerBackPic = false }, mine), false, + "and with the side blanked outright nothing of it is pinned") + Battles.backSetting:setIndex(1, backGame) -- and off for the rows below +T.eq(Battles.pinnedPic(live, mine), false, + "with BACK SPRITES off the player's mon is out on the map with the foe") end -- ------- the hour reaches the FLAT world too @@ -2611,21 +2695,35 @@ local BattlePics = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattlePics") -- reader over what came out. The pic is faked at the readback seam, which is -- the only thing between this and the pixels the engine would have blitted. local lastCanvas = nil -- what the readback asked newCanvas for -local function fill(rows) +-- '#' is ink and '.' the keyed-out nothing. 'W' is ink too, of the pic's +-- LIGHTEST shade -- a highlight the decoder happened not to key -- which is +-- what the paper a hole gets filled with is read off. +local SHADE = { ["#"] = 0.25, ["W"] = 0.75 } +-- reuse hands the SAME pic back through, which is how the two bottom rules +-- can be asked of one image the way a running battle would ask them +local function fill(rows, sealBottom, reuse) local W, H = #rows[1], #rows local built = nil local function fakeData() - local px = {} + local px, sh = {}, {} for y = 0, H - 1 do for x = 0, W - 1 do - px[y * W + x] = rows[y + 1]:sub(x + 1, x + 1) == "#" and 1 or 0 + local shade = SHADE[rows[y + 1]:sub(x + 1, x + 1)] + px[y * W + x] = shade and 1 or 0 + sh[y * W + x] = shade or 0 end end return { - px = px, + px = px, sh = sh, getDimensions = function() return W, H end, - getPixel = function(self, x, y) return 0, 0, 0, self.px[y * W + x] end, - setPixel = function(self, x, y, r, g, b, a) self.px[y * W + x] = a end, + getPixel = function(self, x, y) + local k = y * W + x + return self.sh[k], self.sh[k], self.sh[k], self.px[k] + end, + setPixel = function(self, x, y, r, g, b, a) + self.px[y * W + x] = a + self.sh[y * W + x] = r + end, } end @@ -2639,12 +2737,14 @@ local function fill(rows) built = data return { setFilter = function() end } end - local pic = { getDimensions = function() return W, H end } - local out = BattlePics.filled(pic) + local pic = reuse or { getDimensions = function() return W, H end } + local out = BattlePics.filled(pic, sealBottom) love.graphics.newCanvas, love.graphics.newImage = realNewCanvas, realNewImage -- deliberately NOT invalidated: each figure brings its own pic, and the -- cache check at the bottom needs one of them still in there - return out, pic, built and function(x, y) return built.px[y * W + x] > 0.5 end + return out, pic, + built and function(x, y) return built.px[y * W + x] > 0.5 end, + built and function(x, y) return built.sh[y * W + x] end end -- ------- the cut at the feet, which is what the closed bottom edge is for @@ -2727,6 +2827,75 @@ local drainOut, drainPic, drain = fill({ T.check(drainOut ~= drainPic and drain and drain(8, 4), "a narrow one is where the drawing ran out, and is paper") +-- ------- but a pic ON THE MENU has no mouth at all +-- +-- The drain/mouth cut is for a pic standing on the MAP, where a wide opening +-- along the bottom is a stride with real ground behind it. Under BACK SPRITES +-- the player's mon is drawn in the GB's own slot with its feet flush on the +-- text box, and the only thing under its lowest row is white box -- so nothing +-- reaches it from below, whatever the opening's width, and the rule stops +-- being a heuristic: paper is whatever the background cannot walk to from the +-- left, the right or the top. +-- +-- Which is the difference between a Pikachu and a wireframe. The pale-bodied +-- back pics -- Pikachu, Seel, Dewgong, Chansey, Jigglypuff -- are drawn as +-- OUTLINES, every shade-0 pixel inside the ink keyed away, and each of them +-- leaks out through a bottom opening far too wide to read as a drain. On the +-- map that reading is right; on the box it left the mon a rim with the arena +-- showing through it. +-- +-- The same stride figure the map rule leaves open, now standing on the box. +local boxOut, boxPic, boxOpaque = fill({ + "..##############..", + "..##############..", + "..##############..", + "..###........###..", + "..###........###..", + "..###........###..", +}, true) +T.check(boxOut ~= boxPic and boxOpaque, + "the gap a stride would have shown the world through is paper on the box") +T.check(boxOpaque(8, 3) and boxOpaque(8, 5), + "and it fills right down to the row the feet are on") + +-- the seal is the BOTTOM alone: the sides and the top still let the background +-- in, which is what keeps the silhouette cutting against the arena instead of +-- standing the mon in a white block +local boxGapOut, boxGapPic = fill({ + "..#####.", + "..#...#.", + "..#...#.", + "....###.", -- opens at the left, and drains out that way + "..#####.", + "..#####.", +}, true) +T.eq(boxGapOut, boxGapPic, + "a pocket that drains out to the side is background on the box too") + +-- ------- and a hole is filled with the pic's OWN paper, not with white +-- +-- Shade 0 is white only while the pic is still grays, and by the time one +-- reaches here it usually is not: picImage hands it over after the bake -- a +-- species SGB colour, a BGP fade mid-animation, PAL_BLACK across the whole +-- screen while the blackout text is up -- and shade 0 travels with the rest. +-- A hardcoded white belly would be the one lit thing on a blacked-out mon. +-- +-- So the paper is read off the pic: the lightest shade still standing in it, +-- which is shade 0 wherever the decoder could not reach one. It never has to +-- guess -- all 151 of this game's back pics keep at least one, an eye or a +-- highlight down a cheek. +local _, _, _, paperShade = fill({ + "..####..", + "..#WW#..", -- a highlight the decoder did not key: this is the paper + "..#..#..", + "..#..#..", + "..####..", +}) +T.eq(paperShade(3, 2), paperShade(3, 1), + "the hole takes the lightest shade the pic still has") +T.check(paperShade(3, 2) ~= paperShade(2, 2), + "which is not the ink beside it") + -- ------- and the readback is measured in PIXELS, which is what kept the mons -- the size of the squares they stand on -- @@ -2743,12 +2912,31 @@ T.check(drainOut ~= drainPic and drain and drain(8, 4), -- scale that was wrong everywhere. T.check(lastCanvas and lastCanvas.opts and lastCanvas.opts.dpiscale == 1, "the readback canvas is one texel per pic pixel, on a highdpi phone too") -T.eq(lastCanvas.w, 18, "and it is the size of the pic, in those pixels") +T.eq(lastCanvas.w, 8, "and it is the size of the pic, in those pixels") -- the answer is cached on the image, so a pic costs one readback a session -- rather than one a frame -- checked on the first figure, which is still in -- there because fill() does not clear it T.eq(BattlePics.filled(pic), out, "the rebuilt pic is cached on the original") +-- and cached PER BOTTOM RULE, because one image answers differently on the map +-- and on the box. A single table for both would hand whichever caller asked +-- second the other one's answer -- the map's stencil to the menu, or a menu +-- fill to a mon standing on grass -- which is this section's bug arriving +-- through the cache rather than through the flood. +-- +-- The stride figure again, on the SAME pic the map rule already answered for. +local reOut = fill({ + "..##############..", + "..##############..", + "..##############..", + "..###........###..", + "..###........###..", + "..###........###..", +}, true, stridePic) +T.check(reOut ~= stridePic, + "the pic the map left open still fills when the box asks for it") +T.eq(BattlePics.filled(stridePic), stridePic, + "and the map's own answer for it is still the pic itself") BattlePics.invalidate() end diff --git a/tools/building_voxels.py b/tools/building_voxels.py index 0fb1b12..1ca5fd2 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -547,6 +547,98 @@ TEMPLATES = { roof_rows=17, roof_back=5, roof_front=3, roof_cycle=(5, 9), slab=4, front_eave=4, ledge=None, tileset="forest", ), + # F01: the starter-ball table in Oak's lab -- the first FURNITURE + # through the pipeline, and the first drawing whose plot is smaller + # than its grid. Rows 0-15 are the tabletop seen from above; 16-18 + # are the top slab's own front edge (black/#555/black -- exactly + # what the rim treatment paints, so slab=3 and those rows fold into + # the roof band instead of extruding); 19-21 are the base: corner + # feet and the inset dark panel between them. The legs stand on + # open FLOOR, so the measured ground line lands two rows short of + # the grid; `depth` keeps the plot to the blocked cell row -- the + # legs row of the grid is the walkable cell the player faces the + # table from, and D = len(tiles) would stand the model in their + # path. 16 top rows onto a 16px plot map 1:1: no cycling, and + # roof_cycle is unreachable behind roof_back=16. + "lab_table": dict( + tiles=[ + [41, 59, 59, 59, 59, 42], + [78, 57, 57, 57, 57, 79], + [88, 89, 89, 89, 89, 90], + ], + roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), + slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, + ), + # F02: the computer desk in Oak's lab -- and the Hall of Fame's + # recording machine, the same drawing on the GYM atlas (one + # placement each; both registered in voxel_heights). The one + # DESK-SET template: the methodology's region classification at + # part granularity. The desk is the sibling lab table (fascia rows + # 16-18, base 19-21); on it stand a monitor over its keyboard + # (left), a computer tower over a keyboard and mouse (middle), and + # a sheet of paper LYING FLAT (right). Upright parts anchor their + # drawn bottom row to the desk's top plane and wear their own drawn + # tops as lids; flat parts lie one voxel proud with drawn row = + # depth row -- the same 1:1 the tabletop itself is drawn with, so + # an object's height ON the drawing is its position ON the desk. + # The desk's own top is the one synthesized surface (the objects + # cover every pixel of it), continued from the sibling tables' + # pattern in the drawing's own shades. + "lab_computers": dict( + tiles=[ + [91, 92, 93, 94], + [54, 55, 85, 95], + [88, 89, 89, 90], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="gym", depth=2, + desk=dict(fascia=(16, 18), base=(19, 21)), + parts=[ + dict(kind="upright", x=(2, 13), top=(0, 2), facade=(3, 10), + depth=4), # the monitor + dict(kind="flat", x=(1, 13), rows=(11, 14)), # its keyboard + dict(kind="upright", x=(14, 21), top=(0, 3), facade=(4, 10), + depth=6), # the tower + dict(kind="flat", x=(14, 21), rows=(11, 14)), # keys + mouse + dict(kind="flat", x=(22, 30), rows=(1, 14)), # the paper + ], + ), + # F03: the empty north table beside it -- the starter table's band + # table verbatim on a grid two tiles narrower (one placement). + "lab_table_small": dict( + tiles=[ + [41, 59, 59, 42], + [78, 57, 57, 79], + [88, 89, 89, 90], + ], + roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), + slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, + ), + # F04: the Pokemon Center's PC -- every Center's northeast corner + # (11 placements) plus the Indigo Plateau lobby, whose MART tileset + # shares this atlas. The lab desk-set read again: a + # Mac-style unit drawn face-on -- white top band (rows 0-3), bezel, + # screen and drive slot (4-14) -- standing at the back of a low + # desk whose front face is rows 20-23 and whose drawn top (the + # white sliver of row 15 and the margins beside the unit) names the + # lid shade. The keyboard rows 17-19 lie BELOW the desk's 16px top + # span, so the flat part carries an authored z origin: it lies at + # the desk's front edge, in front of the unit. + "center_pc": dict( + tiles=[ + [66, 70], + [82, 86], + [9, 88], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=2, + desk=dict(fascia=(20, 21), base=(22, 23), lid="white"), + parts=[ + dict(kind="upright", x=(2, 13), top=(0, 3), facade=(4, 14), + depth=6), # the unit + dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard + ], + ), # B23: the Victory Road entrance on Route 23: a rock face with two # barred doors. The roof band is the pale cliff top seen from above. "victory_road_gate": dict( @@ -642,7 +734,14 @@ def profile(sp, t): r = next((y for y in range(H) if inside(x, y)), t["roof_rows"]) top.append(min(r, t["roof_rows"])) - wall_h = H - t["roof_rows"] + # The drawing's own ground line: the row after the last drawn one. A + # building ends on the black threshold row it stands on (ground == H), + # but furniture is drawn standing on open floor -- the lab table's + # legs stop two rows short of its grid -- and extruding against H + # would float it that far above its own plot. + ground = max((y for y in range(H) for x in range(W) if inside(x, y)), + default=H - 1) + 1 + wall_h = ground - t["roof_rows"] ytop = wall_h - 1 + t["slab"] # Recesses: the panes the art seals behind a black frame. Non-black @@ -674,15 +773,136 @@ def profile(sp, t): if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX: recess.update(cells) + # Depth is the PLOT. For a whole-drawing building that is the grid + # itself; `depth` (in tile rows) names it when the grid runs past the + # plot onto ground the drawing merely stands its legs on. return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, - inside=inside, D=H, W=W, H=H) + inside=inside, D=t.get("depth", len(t["tiles"])) * 8, + ground=ground, W=W, H=H) # --------------------------------------------------------------- stage 3 -- +def build_desk_set(sp, pr, t): + """A desk with separately-classified objects on it (the `parts` list): + upright parts stand on the desk's top plane wearing their own drawn + tops as lids, flat parts lie one voxel proud at drawn row = depth row, + and the desk itself is the lab-table slab + base with a synthesized + lid (the objects cover every drawn pixel of the tabletop).""" + W, H, D = pr["W"], pr["H"], pr["D"] + inside = pr["inside"] + col, src = sp["col"], sp["src"] + ground = pr["ground"] + vox = {} + + def put(x, y, z, sx, sy): + vox[(x, y, z)] = (col[sy][sx], src[sy][sx]) + + shade_px = {} + for sy in range(H): + for sx in range(W): + if inside(sx, sy): + shade_px.setdefault(col[sy][sx], (sx, sy)) + + def interior(sx, sy, lo, hi): + if col[sy][sx] != BLACK: + return sx + step = 1 if sx < (lo + hi) // 2 else -1 + for d in range(1, 4): + nx = sx + step * d + if lo <= nx <= hi and inside(nx, sy) and col[sy][nx] != BLACK: + return nx + return sx + + f0, f1 = t["desk"]["fascia"] + b0, b1 = t["desk"]["base"] + plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane + + # the base band, extruded exactly like every lab table's + for sy in range(b0, b1 + 1): + y = ground - 1 - sy + for sx in range(W): + if not inside(sx, sy): + continue + ix = interior(sx, sy, 0, W - 1) + for z in range(D): + put(sx, y, z, sx if z in (0, D - 1) else ix, sy) + for sx, sy in pr["recess"]: + if b0 <= sy <= b1: + vox.pop((sx, ground - 1 - sy, D - 1), None) + + # the slab: the fascia rows wrap every side; the lid is the one + # synthesized surface in the model -- the drawing never paints the + # tabletop (its objects cover it), so the lid continues the sibling + # tables' pattern in the drawing's own shades: black rim, white + # highlight courses along the north and west, grey field + for i, sy in enumerate(range(f0, f1 + 1)): + y = plane - 1 - i + for sx in range(W): + for z in range(D): + put(sx, y, z, sx, sy) + field = WHITE if t["desk"].get("lid") == "white" else GREY + for sx in range(W): + for z in range(D): + if sx in (0, W - 1) or z in (0, D - 1): + shade = BLACK + elif sx == 1 or z == 1: + shade = WHITE + else: + shade = field + px = shade_px.get(shade) or shade_px[BLACK] + put(sx, plane - 1, z, px[0], px[1]) + + for p in t["parts"]: + x0, x1 = p["x"] + if p["kind"] == "flat": + r0, r1 = p["rows"] + for sy in range(r0, r1 + 1): + # drawn row = depth row by default; `z` renames the + # origin when the flat sits below the desk's own drawn + # top span (the Center PC's keyboard) + z = p.get("z", r0) + (sy - r0) + if not 0 <= z < D: + continue + for sx in range(x0, x1 + 1): + if inside(sx, sy): + put(sx, plane, z, sx, sy) + continue + tr0, tr1 = p["top"] + fr0, fr1 = p["facade"] + pd = p["depth"] + ytp = plane + (fr1 - fr0) + for sx in range(x0, x1 + 1): + # the lid: the part's drawn top laid across its depth from + # the back, last row continuing forward; the front lid row + # is the facade's own top row -- the drawn front-top edge + for z in range(pd): + sy = fr0 if z == pd - 1 else min(tr0 + z, tr1) + while sy <= tr1 and not inside(sx, sy): + sy += 1 + if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)): + continue + put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy) + # the body: facade rows anchored to the desk's top plane + for sy in range(fr0 + 1, fr1 + 1): + y = plane + (fr1 - sy) + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pd): + if z == pd - 1: + if (sx, sy) not in pr["recess"]: + put(sx, y, z, sx, sy) + else: + put(sx, y, z, sx if z == 0 else ix, sy) + return vox + + def build(sp, pr, t): """The voxel model. Order is load-bearing: walls, ledge, recesses, then the roof solid overwrites what it intersects and the walls are trimmed to the roof's underside.""" + if t.get("parts"): + return build_desk_set(sp, pr, t) W, H, D = pr["W"], pr["H"], pr["D"] inside, top, ytop = pr["inside"], pr["top"], pr["ytop"] col, src = sp["col"], sp["src"] @@ -720,7 +940,9 @@ def build(sp, pr, t): # ---- walls: the facade rows extruded straight back, trimmed under the roof for sy in range(t["roof_rows"], H): - y = H - 1 - sy + y = pr["ground"] - 1 - sy # rows below the ground line are floor + if y < 0: + continue for sx in range(W): if not inside(sx, sy) or trimmed(sx, y): continue @@ -742,7 +964,7 @@ def build(sp, pr, t): if t["ledge"]: l0, l1 = t["ledge"] for sy in range(l0, l1 + 1): - y = H - 1 - sy + y = pr["ground"] - 1 - sy for sx in range(W): if inside(sx, sy) and not trimmed(sx, y): for z in (-2, -1, D, D + 1): @@ -750,7 +972,7 @@ def build(sp, pr, t): # ---- recesses: the front voxel of every pane sinks, its frame stays proud for sx, sy in pr["recess"]: - vox.pop((sx, H - 1 - sy, D - 1), None) + vox.pop((sx, pr["ground"] - 1 - sy, D - 1), None) # ---- roof: flat top over the plateau, stepped diagonal ends z0, z1 = 0, D - 1 + t["front_eave"] @@ -805,6 +1027,83 @@ def verify(vox, sp, pr, t): for (x, y, z), _ in vox.items(): assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}" + # A desk set answers its own asserts; a facade-only template (roof_rows + # == 0) has no roof band, so there is no surface to hold level or slope + # and no coverage to demand of it; everything with a roof band answers + # the full set. + if t.get("parts"): + verify_desk_set(vox, pr, t) + elif t["roof_rows"] == 0: + # its one geometric intent: a straight extrusion, so each column + # of the drawing is solid from its lowest voxel to its top. NOT + # from the ground -- the drawing's base band is inset like every + # lab table's, and the body's outer columns legitimately + # overhang it -- and not on the recess layer, which sinks panes. + lo, hi = {}, {} + for (x, y, z) in vox: + lo[(x, z)] = min(lo.get((x, z), y), y) + hi[(x, z)] = max(hi.get((x, z), y), y) + for (x, z), h in hi.items(): + if z == D - 1: + continue + assert all((x, y, z) in vox for y in range(lo[(x, z)], h + 1)), \ + f"facade column has a hole at {x},{z}" + else: + verify_roof(vox, pr, t) + + shell = [k for k in vox if not all( + (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox + for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), + (0, -1, 0), (0, 0, 1), (0, 0, -1)))] + return shell + + +def verify_desk_set(vox, pr, t): + W, D = pr["W"], pr["D"] + f0, f1 = t["desk"]["fascia"] + b0, b1 = t["desk"]["base"] + plane = (b1 - b0 + 1) + (f1 - f0 + 1) + + # the slab is a full solid under everything on it + for x in range(W): + for z in range(D): + for y in range(b1 - b0 + 1, plane): + assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" + + # each part stands where authored -- upright bodies solid from the + # desk to their own drawn top (bar the front layer, whose panes + # sink) -- and nothing stands anywhere else + tops = {} + for p in t["parts"]: + x0, x1 = p["x"] + if p["kind"] == "flat": + r0, r1 = p["rows"] + z0 = p.get("z", r0) + for x in range(x0, x1 + 1): + for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1): + tops[(x, z)] = max(tops.get((x, z), 0), plane) + else: + fr0, fr1 = p["facade"] + ytp = plane + (fr1 - fr0) + for x in range(x0, x1 + 1): + for z in range(p["depth"]): + tops[(x, z)] = max(tops.get((x, z), 0), ytp) + if z == p["depth"] - 1: + continue + ys = [y for y in range(plane, ytp + 1) + if (x, y, z) in vox] + assert ys == list(range(ys[0], ys[0] + len(ys))) \ + if ys else True, f"part column hole at {x},{z}" + for (x, y, z) in vox: + assert y <= tops.get((x, z), plane - 1), \ + f"voxel above its part at {x},{y},{z}" + + +def verify_roof(vox, pr, t): + W, H, D = pr["W"], pr["H"], pr["D"] + ytop, top, slab = pr["ytop"], pr["top"], t["slab"] + T = lambda x: ytop - top[max(0, min(W - 1, x))] + # The roof surface reads over the columns the drawing actually paints: # a sprite inset from its box says nothing about the rest. roofed = [x for x in range(W) if top[x] < t["roof_rows"]] @@ -845,12 +1144,6 @@ def verify(vox, sp, pr, t): elif any((x, y, z) in vox for y in range(0, T(x) - slab + 1)): assert (x, T(x), z) in vox, f"wall uncovered at {x},{z}" - shell = [k for k in vox if not all( - (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox - for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), - (0, -1, 0), (0, 0, 1), (0, 0, -1)))] - return shell - def preview(shell, vox, sp, name, out, flip, canvas=(1700, 900), pad=20): # Mirroring depth swings the camera round to the other side. Mirror