diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be5443..8056582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,156 @@ ### Added +- **A gradient sky behind the diorama, on every `VOXEL` rung.** The void behind + the world used to be a black plate at every rung but the top, where it became + one flat blue -- enough while that void was a sliver, and a wall of paint once + the horizon came into frame. + + It is the 8-bit skybox recipe now: four blues painted as flat horizontal bands, + deepest overhead and palest at the bottom, with a CHECKERBOARD of the next band + dithered into the bottom 40% of each one. Alternating two colours on a pixel + grid is how a machine with four to a palette got a fifth, sixth and seventh out + of them, and it is what keeps four bands reading as a gradient rather than as + four stripes. Every channel of the palette is a multiple of 8 -- where a + five-bit GBC channel lands -- so no colour in it is one the hardware could not + have shown. No clouds, nothing moving. + + Where the bands END is the camera's own answer. At `75` the ground plane's + vanishing line is genuinely in frame -- projected through the same matrix the + geometry is drawn with -- and the pale end meets it. At the steeper rungs that + line is above the top edge, and what shows up there is the ground running OUT + past the map edge instead, so the bands take a fixed slice of the frame and the + haze fills the rest. One sky across the whole ladder either way. + + **Nothing is resampled**, which is why it is drawn the way it is: no baked + 160x144 image scaled up to the window, no downsized buffer blown back up, no + texture at all. One rectangle through a shader answers every pixel from its own + canvas coordinate, so a pixel of sky is computed at the size it is displayed + at and there is nothing for a filter to soften. The band edges and the dither + cells are measured in the pass's own pixels-per-world-pixel, handed in fresh + every frame -- so a `ZOOM` keypress is reflected in the frame that follows it, + with nothing cached at the old scale, and the sky's grid is the same grid the + world's own texels sit on. + + The palette goes through the display-mode transform like every other palette in + this mod, so GRAY gets four greys and CLASSIC four greens. Below the bands the + void is filled with the palest of them -- which is also what the bottom band + ends on -- so the join has no seam, and a driver that cannot compile the shader + gets flat bands and a logged line rather than a wrong sky. + + The overworld only. A battle is a staged shot whose placed camera has the + horizon above the frame, so the arena keeps exactly the flat sky it had. + +- **A day/night cycle**, on a new **DAYTIME** options row: `DAY`, `NIGHT`, + `DUSK`, `DAWN`, `CYCLE`. One twenty-minute clock underneath all five -- ten + minutes of sun, ten of moon -- where the four named settings are PINS on + that dial (noon, mid-night, sunset, sunrise) and `CYCLE` lets it run, + picking up from whichever pin the player was just looking at. Everything is + a pure function of the clock, so the pinned DUSK is exactly the running + cycle stopped at sunset. Setting **VOXEL** to `FULL` switches DAYTIME to + `CYCLE` along with the rest of the preset. + + **The sun and the moon are in the sky**, and their positions are honest: + the disc is the light's own direction projected through the same matrix the + geometry is drawn with, so it stands over the point on the horizon its + shadows point away from, at every pitch, window shape and zoom. The sun's + noon is this mod's existing sun to the digit -- southeast, 45 degrees up, + overhead behind the north-facing camera and correctly out of frame -- and + its arc swings north at both ends, so the disc stands IN frame through dawn + and dusk, rising half-set on the horizon. The moon arcs the northern sky + all night, due north (screen centre) at mid-night, with scaled crater + cells. Both are cell art on the sky's own dither grid, sized by the frame + (a celestial body's apparent size is an angle, so zooming the ground does + not swell it), and both are SCISSORED to the sky's region: the horizon + point is where a setting body disappears -- it never hangs under the map. + + **The sky follows the clock.** Phase palettes -- the daytime blues, + gold-to-violet dawn, a hotter gold-to-indigo dusk, moonlit navy -- six + bands each now, blended along the dial and re-quantised onto the 5-bit + lattice, so every mixed frame is still a colour the hardware could show. + The blends bend through designed WAYPOINTS rather than straight across -- + a golden hour on the way into dusk, a violet civil twilight either side of + the night -- because day's blue and dusk's gold are near-complements, and + a straight lerp between complements bottoms out in dishwater grey. Through the twilights a posterised, checker-dithered GLOW + warms the bands around the low sun -- painted light, not an airbrush. The + blends are 75 seconds wide either side of each twilight and the pins land + on their phase palette unmixed. + + **The shadows follow the sun and the moon.** The shear every shadow is + thrown by (direction opposite the body's bearing, length its elevation's + cotangent, clamped at twice the caster's height) comes off the clock, the + shadow map's signature carries it, and the light's press fades out over the + last twelve degrees before the horizon -- so sunset hands off to moonrise + through a soft shadowless gap, and moonlight presses at about two-thirds + the sun's weight. The scene shader also multiplies every surface by the + hour's tint: neutral at noon, warm at the twilights, dim blue at night. + + **Outdoors only**, by the same `Map.isOutdoor` test the sky already rests + on: indoors keeps the noon rig, the neutral tint and no sky -- a cave at + midnight is exactly as dark as a cave at noon. Viridian Forest is the case + between, a CANOPY map (DayNight.CANOPY): there is no sky to paint and no + sun to see, so the shadow rig stays the mod's fixed noon light -- all that + ever filtered through the leaves -- but night still FALLS in a forest, so + of everything the clock does, exactly one thing reaches it: the hour's + tint, in free-roam and staged battles alike. A battle staged on an + outdoor map fights under the hour: the night sky behind the arena, the + tint on the mons, the sunset taking the arena's shadows with it; an indoor + arena is untouched. The engine's own `world.tod` hook is answered + (`MORNING`/`DAY`/`EVENING`/`NIGHT`), so palette or music packs keyed to the + period ride this clock for free. + + **The clock rides the save slot.** On the engine's `save.writing` event the + cycle's time is written into the mod's own save-file bucket + (`save.modData.DRAMATIC_SHAPE`), and read back when a save is opened. A + save with no clock in it starts at day. + +- **Window glass.** The panes in the overworld art -- the framed squares on + building fronts, the small lights in doors -- are found by SHAPE in the + tileset image (a black border row, four or five black-flanked glass rows, + a closing border), at pixel granularity because the door's pane straddles + a 2x2 tile block. No tile ids are hardcoded: a conversion that draws its + own windows in the same idiom gets glass for free. The scan yields a mask + texture aligned to the tileset atlas, which the scene shader samples with + the same coordinates the terrain does -- so the effect lands on any wall, + at any angle, in free-roam and staged battles alike, with no geometry + work. + + By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES: the + sweep's phase is fed by the camera's own travel and its strength fades out + within a beat of standing still -- a reflection is something the viewpoint + does, so still camera means still glass. It lifts the texels toward + sky-white and leaves the diagonal shine art visible through it. The mask + is consulted only by meshes textured from the tileset atlas + (Voxel3D.glass), never by sprite sheets, whose coordinates would land on + the panes' atlas positions by accident. + After dark the panes are LIT: the texel's own pattern carried into a warm + lamp colour, replacing the shaded answer entirely -- a lit window ignores + the sun, every shadow and the hour's tint, exactly as a window with a lamp + behind it does. The lamps follow the clock (DayNight.windowLight): on + through dusk, full all night, mostly out by dawn, and never lit indoors. + +- **A fade out of a battle, where there used to be a hard cut.** The engine + wipes INTO a fight with one of the original's eight transitions and cuts + straight out of it: `BattleState:finish` pops itself and the map is simply + there on the next frame. Between a white field and a tile map the original got + away with that; between a placed camera looking across an arena and a diorama + looking down on a walking player it reads as a glitch. The battle now fades to + black, closes behind it, and the map fades up out of it -- twelve frames each + way, registered as a `voxel_battle_exit` transitions record so the timing is + retunable in data like the wipes it answers. + + Only while voxel mode is on, and then for EVERY battle, including one that + found no arena and drew on the flat battle screen: what is being smoothed over + is the return to the map, and the map is a diorama either way. With the mode + off, the vanilla cut is untouched. + + One black rectangle over the FINISHED composite does the fading, so the world, + the letterbox bars and the battle's own text box all darken by the same amount + -- the renderer's existing warp-fade overlay is painted between the world and + the UI, which would have left the text box bright over the black. A blackout's + own warp fade or an evolution prompt still owns the way out when it takes the + screen: the fade stops at the cut rather than fading in over the top of it. + - **A `FULL` rung on the VOXEL row**, directly after `OFF`. One choice that puts the whole mode in its intended state -- the 35-degree camera, the miniature blur at maximum, the horizon flat, the view fitted, and battles @@ -41,8 +191,63 @@ Pokemon go solid white for those frames, silhouette and all, and nothing else in the frame moves. +- **A standing figure's shadow detached from its feet under a low sun.** The + shadow compare forgives `slack` world pixels so lit ground does not acne + against its own texels, and that same forgiveness lit the first `slack` of + every cast shadow -- so the shadow started a bias-width away from the feet, + further the lower the sun reached (the classic peter-panning, invisible at + the old fixed 45 degrees and plain at a day/night golden hour or under the + moon). Sprite cards -- characters, authored figures, flowers, battle mons + -- are now drawn into the shadow map snugged TOWARD the sun along their + own ray (`ShadowMap.snug`): moving along the ray changes nothing about + where a shadow falls, but storing the card shallower takes three quarters + of the forgiveness back for the shadow it throws -- and for nothing else: + no terrain moved, so the acne margin is untouched where it matters, and + the quarter left keeps the card off the float-equality knife edge against + its own stored depth. The shadow root lands back under the feet at every + hour. + ### Changed +- **Under `VOID FILL: TREES` the border wall is modelled trees or nothing.** + Only the first block past the map body gets carved into round trunks and + canopies; the two blocks past that were too far out to be worth the quads, + so they fell through to the mesher's plain box and came out as a flat-topped + slab of tree ART sitting beside the modelled forest -- a painted-on plateau, + and the more obvious the lower the camera got. Rather than pay to carve + hulls nobody walks near, the wall now simply STOPS where the carving does: + `Structures` does not build the ring past that distance (the same "nothing + out there" `BLACK` already produces), and the mesher drops any cell inside + it the 2x2 canopy grouping could not claim, so no strip of boxes survives at + a corner. `WATER` and every indoor border are untouched -- a flat sheet of + water is what water looks like from above anyway. + +- **The two HP boxes snap to the window's edges during a staged battle.** The + battle screen is 160x144 in the middle of the window and the world is the + whole of it, which left both HUD blocks huddled together in the middle of the + frame with map showing on either side of them -- a Game Boy screenshot pasted + over a diorama rather than the diorama's own furniture. The foe's block now + sits against the left edge and the player's against the right, on the same + frosted glass, with the same tiles at the same size on the same rows. The + pokeball rows and the safari ball count travel with the block whose rows they + share. On a window shaped like the GB screen there is nowhere to go and + nothing moves. + + The engine draws them into the 160x144 canvas, which clips at its own edges, + so the layer is rendered to a texture and composited into the world image -- + the one surface here that covers the whole window. A driver that cannot do + that falls back to the HUD in the frame rather than to no HUD. + +- **`BATTLE LAYOUT` is pinned to `OG` while battles are staged on the map**, and + the row comes off the OPTIONS menu with the rows `FULL` owns. The staged shot + is composed in the GB's own frame -- the arena camera is solved to put a cell + under each pic's feet, and the HUD rects and the intercepted background fill + are measured there too -- and `WIDE` re-lays that screen out on a 304x144 + surface, moving every one of them. Set rather than worked around, on every + route in: the options row, hotkey `8`, the mod manager's page, `FULL`'s + preset, and a save that arrived with `WIDE` already on. Switching `3D-BTL` + off hands the row back with `WIDE` selectable again. + - **Hotkey `3` walks the angle rungs only and steps over `FULL`.** The key is a display-mode cycler -- it should change the camera and nothing else -- and `FULL` reaches in and rewrites four other settings. Landing on it diff --git a/README.md b/README.md index 1828838..d89bbd7 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ menu. | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) | | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon | | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field | +| the **DAYTIME** options row | DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors | **3D-BTL** is on by default and is independent of **VOXEL**: battles draw -on the world whether or not the free-roam camera is pitched over. +on the world whether or not the free-roam camera is pitched over. \ No newline at end of file diff --git a/data/battle_arenas.lua b/data/battle_arenas.lua index df2d343..96da891 100644 --- a/data/battle_arenas.lua +++ b/data/battle_arenas.lua @@ -39,7 +39,10 @@ return { -- ------- routes - ["ROUTE_1"] = { x = 0, y = 16, shape = "wide" }, + -- narrow, deliberately: the route's interior is a 3-cell-wide lane and the + -- wide shape only fits in the western connection border, which staged every + -- fight at the edge of the world instead of on the road + ["ROUTE_1"] = { x = 9, y = 16, shape = "narrow" }, ["ROUTE_2"] = { x = 1, y = 49, shape = "wide" }, ["ROUTE_3"] = { x = 57, y = 1, shape = "wide" }, ["ROUTE_4"] = { x = 46, y = 7, shape = "wide" }, diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 574e3e2..4079a53 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -70,6 +70,21 @@ -- pinned prop, overriding the flat-neighbour vote -- the cuttable bush -- stands on the grass Cut leaves behind, whatever borders it. -- +-- And `prop_bg` (not a class either): which GB shades count as BACKGROUND +-- when a pinned per-pixel prop is cut out, for the drawings whose own body +-- reaches the edge of their bounding box and so vote themselves away. +-- Keyed by tile, because two props sharing an atlas can want opposite +-- answers on the same shade. +-- +-- And it may carry `figures` (not a class either): hand-drawn pixel masks +-- that lift a FIGURE PAINTED INTO furniture off it and stand it up as a +-- standee, leaving the furniture its own geometry. A pin resolves a +-- whole 8x8 tile, so it can never separate two things that share one -- +-- and nothing automatic can either when the figure has no background +-- margin to flood from and wears the same shades as what it sits on. +-- The Pokemon Center's seated man is the case; see POKECENTER below for +-- the format. +-- -- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from -- above, a facade seen face-on and sloped ends as diagonal silhouettes, -- and no single class covers that. They live in the `buildings` list at @@ -87,6 +102,9 @@ return { fence = 10, sign = 12, wall = 16, + -- masonry drawn two courses tall (the Indigo Plateau's rim, the + -- badge-check gates): as tall as a statue on its plinth + cliff = 32, tree = 16, roof = 28, bed = 7, @@ -694,22 +712,17 @@ return { -- bodies so they stand ON them) and the PC (66/70/82/86), which -- stands on its pinned desk billboard = { 58, 59, 66, 70, 74, 75, 82, 86 }, - -- Why the man on the couch rides the top face instead of standing - -- up. He is drawn across two tile rows (skin pixels appear in - -- rows 8 and 9 and stop dead at the row 9/10 seam), and folding - -- two rows upright requires both to share a class, which makes the - -- box two tiles deep -- and a fully folded box repeats its north - -- row across its whole top face. So every upright arrangement - -- puts his head on screen two or three times: as a 16px seat-back - -- his head lands on the front AND twice on the top; as a 32px - -- bookcase he becomes a cabinet taller than the room's walls. - -- Dropping the seat to floor level to clear his face does not help - -- either, it only moves which copy you see. He also cannot be a - -- standee: the drawing has no floor margin, so all three non-black - -- shades touch the cluster rim and the mask drains 307 of its 420 - -- interior pixels -- 46% of him even segmented alone, because his - -- skin is the same light shade as the couch behind him. Riding - -- the top face is the one arrangement that draws him exactly once. + -- The man on the couch, cut out by hand and stood up (see `figures` + -- below). Nothing automatic reaches him. A class pin resolves a + -- whole 8x8 tile and he SHARES his tiles with the couch, so pinning + -- them stands the furniture up with him; riding the couch's top + -- face (what this did before) draws him lying flat on the cushion; + -- and he cannot be segmented into a standee either, because the + -- drawing has no background margin for a flood to enter by and his + -- skin is the same light shade as the couch -- the mask drained 307 + -- of his 420 interior pixels, 46% of him, even segmented alone. + -- An authored mask is the only thing that can tell a man from the + -- sofa he is painted into, so that is what `figures` carries. -- the PC's desk body, which the PC stands on table = { 9, 88 }, -- the potted plants: bush (32/33/48/49) over pot (34/35/50/51, @@ -721,6 +734,82 @@ return { -- them; the standee reads darker than the flat art, which is the -- accepted trade for a real plant silhouette prop = { 32, 33, 34, 35, 48, 49, 50, 51 }, + -- ...but the POTS were draining outright. The plant pair is drawn + -- as one 4x4-tile block and the pot's olive base is flush on that + -- block's bottom row, so the background vote -- which reads the + -- shades touching the drawing's own bounding box -- came back with + -- "dark" in it, and every dark pixel in the plant left with the + -- floor. The pots rendered as hollow black frames around one or + -- two surviving pixels while the flat art has solid olive bodies. + -- + -- So name the background outright for these eight tiles: the floor + -- IS light and white here, and nothing else is. That restores 152 + -- pixels of the block (55% -> 69% of it kept) and cannot move any + -- other prop, which matters -- the same call tileset-wide would pull + -- the dark wall band into the healing consoles' screens and strip + -- three pixels off the PC, because those two want the opposite + -- answer on the very same shades. + prop_bg = { + { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 }, + shades = { "light", "white" } }, + }, + -- THE MAN ON THE COUCH. He is drawn INTO the lounge furniture and + -- spills out of it in BOTH directions, which is why he needs three + -- tile columns: + -- + -- 36 / 52 the couch's west arm. 36's two RIGHTMOST columns are + -- the back of his head; 52 is the plain arm, and is also + -- what 36 wears once his hair comes off it + -- 37 / 53 him: head, then face and body + -- 57 / 60 the floor east of the couch, which his hair and his + -- foot overhang. 1 and 26 are those same two floor + -- tiles as the artist drew them WITHOUT him -- 8 and 5 + -- pixels apart respectively -- so lifting him off is + -- lossless there + -- + -- `pixels` is the hand-drawn line between the man and the furniture; + -- `under` is what each tile wears once he is lifted off it. The + -- couch keeps its polygonal box and he stands on top of it. + -- + -- The mask keeps ALL of 37/53's column 7, the couch's east edge. + -- Where that column is drawn dark it is the couch's own rule -- but + -- it is also where his hair crosses the tile seam, so dropping it + -- floats the overhang free of his head. And where it is drawn WHITE + -- it is the right side of his face, so dropping it opens a slit down + -- his cheek (rows 5-8, which is exactly what the first cut did). + -- Keeping the rule twice costs nothing: tile 39 redraws it on the + -- couch beneath him either way. Repainting 36 as 52 does cost one + -- row -- 36's top trim band, which 52 does not carry -- and that is + -- the accepted price for not leaving a second copy of his head lying + -- on the arm. The background corners around his head and the + -- cushion wedge under his legs are the only pixels given back. + figures = { + { + w = 3, + tiles = { 36, 37, 57, + 52, 53, 60 }, + under = { 52, 39, 1, + 52, 39, 26 }, + pixels = { + "..........XXXXX.........", + "........XXXXXXXX........", + ".......XXXXXXXXXX.......", + "......XXXXXXXXXXXX......", + "......XXXXXXXXXXXX......", + "......XXXXXXXXXXX.......", + "......XXXXXXXXXXX.......", + "......XXXXXXXXXXX.......", + "........XXXXXXXXX.......", + "........XXXXXXXX........", + "........XXXXXXXX........", + "........XXXXXXXX........", + "........XXXXXXXXX.......", + ".........XXXXXXXXX......", + "...........XXXXXX.......", + "..............XX........", + }, + }, + }, }, -- The Poke Marts (one 4x4 layout serves every city; the tileset @@ -731,12 +820,61 @@ return { -- boxed the two free-standing shelf racks into one 4-tile-deep -- monolith. MART = { - -- the wall band stays one 16px face: the trim and case tops - -- (40/90/91), the SALE cases (78/79) with their feet (23/29), and - -- the glass drink fridges (44-47) with their feet (62/63) -- all - -- drawn built INTO the back wall, exactly like the Center's - -- healing consoles - wall = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 78, 79, 90, 91 }, + -- THE BACK WALL. It is drawn FOUR tile rows tall -- trim (40, or + -- the fridge tops 90/91), the SALE signs (78/79) or glass upper + -- (44/45), the black display niche (76/77) or glass lower (46/47), + -- and the cases' base and goods (23/29, 62/63) -- which is 32px of + -- artwork depicting ONE wall, not four things at four depths. + -- + -- Pinned `wall` that is exactly what it became: every row got its + -- own 16px box marching north, so the only face you ever saw was + -- the southmost row (the cases' base), the signs and the niche hid + -- behind it, and the trim ended up lying flat as a gold shelf on + -- the roofs of the boxes behind. Laid out in depth instead of + -- stacked up. + -- + -- `bookcase` is the class that collapses a tall drawing onto a + -- one-cell-deep box at its real drawn height, so the run of four + -- rows becomes a single 32px wall: bands from the south are base, + -- niche, sign, trim, its top face wears the trim, and the three + -- rows behind become hidden floor. Same treatment the shelf racks + -- below already get -- a Mart's back wall IS a wall of display + -- cases, and the geometry does not care which we call it. + bookcase = { 23, 29, 40, 44, 45, 46, 47, 62, 63, 76, 77, 78, 79, + 90, 91, + -- the free-standing shelf racks: TALL drawings, not + -- deep ones -- each rank collapses onto a + -- one-cell-deep shelf at its drawn height (the + -- Dojo/Red's-house treatment). 64/65/67 and 80/81/83 + -- are the bottle rows the clerk's booth also wears as + -- its top display; 68/69/71 and 84/85/87 the goods + -- rows below + 64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 }, + -- The wall's own tiles are reused elsewhere, and the back wall is + -- the one place they are drawn on the map's TOP EDGE. So `bookcase` + -- is their default and this hands every other use back to `wall`, + -- which is what all of them were before: 40 is also the clerk's + -- booth back panel (under the shelf rows 80/81 or under itself), and + -- 40/76/77/90/91 all recur in INDIGO_PLATEAU_LOBBY, the ninth map on + -- this id, where they draw the hall and the lift bank. + -- + -- NEVER put 0 in an `above` set. Map:tileAt does not answer nil off + -- the top of a map -- it border-extends, so row 0 reads the map's + -- borderBlock, which indoors is the black void block 0. Listing 0 + -- to catch the Lobby's void-backed 90/91 fired the rule on every + -- Mart's fridge row as well: row 0 dropped out of the bookcase run, + -- the fridges came out 24px against the SALE cases' 32px, and their + -- trim row stood as a separate box BEHIND the wall instead of on top + -- of it. Nothing can separate those two cases from above -- both + -- see void -- so the Lobby (already out of scope here) gets the + -- bookcase reading too, and the Marts come out right. + when_above = { + [40] = { { above = { 40, 80, 81, 83, 90, 91 }, class = "wall" } }, + [76] = { { above = { 74, 75 }, class = "wall" } }, + [77] = { { above = { 74, 75 }, class = "wall" } }, + [90] = { { above = { 17 }, class = "wall" } }, + [91] = { { above = { 27 }, class = "wall" } }, + }, -- the clerk's counter, half a cell high like every service -- counter. It is a C wrapping the alcove the clerk stands in: the -- south arm's drawn front (24/25) under its top band (8/56), the @@ -746,43 +884,66 @@ return { -- cash register, pinned it `billboard`, and got a bare black -- outline standing on the counter with the tile's own art replaced -- by its neighbour's. - counter = { 8, 16, 24, 25, 41, 56, 89 }, - -- The CASH REGISTER (14/15 over 30/31), a keypad and a curl of - -- receipt paper drawn face-on across two tile rows in the middle of - -- the counter's east arm. Pinned `counter` it was just paint on - -- the work surface. In the `billboard` pool it is a standing - -- per-pixel cutout ten voxels deep -- the flower-and-vase treatment - -- of Red's house, but with a machine's body rather than a stem -- - -- and the support rule lifts it onto the counter automatically, - -- because the tile drawn directly BELOW it (16/41) is a pinned 8px - -- box. Its own pool, so the counter can never absorb it. The art - -- has a clean light margin on three sides and a black outline all - -- round, so the shade flood drains the work surface away and leaves - -- the machine whole. - -- - -- `console`, not `billboard`, for the two vertical black rules the - -- COUNTER draws down the outer edges of those same tiles (columns - -- 0 and 15 of the pair, full height). They are the counter's own - -- edging, not the register, but black always survives the shade - -- flood, so the billboard pool extruded them too and the machine - -- stood flanked by a pair of tall black slabs. A white column - -- separates them from the register body, so they are their own - -- connected components -- and `console` carries the same - -- one-object contract `cutout` has, which keeps the largest - -- drawing and drops them, at ten voxels of body rather than one. - console = { 14, 15, 30, 31 }, - -- the free-standing shelf racks: TALL drawings, not deep ones -- - -- each rank collapses onto a one-cell-deep shelf at its drawn - -- height (the Dojo/Red's-house treatment). 64/65/67 and 80/81/83 - -- are the bottle rows the clerk's booth also wears as its top - -- display; 68/69/71 and 84/85/87 the goods rows below - bookcase = { 64, 65, 67, 68, 69, 71, 80, 81, 83, 84, 85, 87 }, + -- ...and the register's own two tile rows (14/15 over 30/31) are + -- part of that same work surface now that the machine has been cut + -- off them as a sprite -- see `figures` below. + counter = { 8, 14, 15, 16, 24, 25, 30, 31, 41, 56, 89 }, -- Left to the derived default on purpose: the floor checker and -- its shadowed variants (1/11/17/26/27/54) and the exit mat -- (12/28) all sit in cells the ROM marks walkable, so the cell - -- rule lays them flat unaided; 76/77 is the SALE case's black - -- interior, which the volume path recesses half a course into the - -- band -- a dark display niche, which is what it is drawn as. + -- rule lays them flat unaided. (76/77, the SALE case's black + -- interior, used to be left to the volume path here; it is now the + -- back wall's third band, because a run of four rows has to be + -- contiguous for the wall to collapse into one box at all.) + -- THE CASH REGISTER: a keypad and a curl of receipt paper drawn + -- face-on across two tile rows in the middle of the counter's east + -- arm. Like the Center's seated man it is a FIGURE drawn into the + -- furniture, so it gets the same treatment -- a flat sprite card + -- standing on the counter -- and for the same reason: no class pin + -- can separate it from the counter, because the counter draws its + -- own edging down columns 0 and 15 of the very same tiles. + -- + -- That edging is what defeated every automatic reading. Black + -- always survives the shade flood, so the standee pools extruded + -- the two rules along with the machine and it stood flanked by a + -- pair of tall black slabs; `console`'s keep-the-largest-drawing + -- rule dropped them, but only by throwing away the receipt curl + -- too whenever the flood happened to cut it loose. The mask just + -- says where the machine is: columns 2-13, plus the curl climbing + -- to the top right, and the counter keeps its edging and its light + -- work surface. + -- + -- `under` is 16/41 twice -- the plain work surface, which already + -- carries the west edging (black over white) and the east (dark + -- over black), so the counter closes up behind the machine with no + -- synthesis at all. + figures = { + { + w = 2, + tiles = { 14, 15, + 30, 31 }, + under = { 16, 41, + 16, 41 }, + pixels = { + ".........XX.....", + "........XXXX....", + "........XXXXX...", + "........XXXXXX..", + "...XXXXXXXXXXX..", + "..XXXXXXXXXXXX..", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + "..XXXXXXXXXXX...", + }, + }, + }, -- NOT covered: INDIGO_PLATEAU_LOBBY, the ninth map on this id and -- the only one that is not the 4x4 shop. It draws its own hall, -- lift bank and rope stanchions from tiles no Mart places, three @@ -2100,6 +2261,231 @@ return { -- south-east). Neither map places them; they belong with the -- wall band above if one ever does. }, + + -- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the + -- forecourt the League itself stands on) and ROUTE_23 (the long + -- climb up to it, with its badge-check gates). Everything this + -- blockset draws is one piece of architecture -- striated rock + -- walls, white pillars, and the bird STATUES that line both the + -- avenue and the plaza. 29 of its 73 blocks are never placed, so + -- every tile named below really is one of these two maps'. + -- + -- A statue is built exactly like the badge gyms' (see GYM above): + -- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of + -- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them -- + -- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more + -- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that + -- stand the same statue on grass are never placed). + -- + -- What the detector made of them is the bug this entry exists for. + -- On the avenue the statues stack with NO gap: the plinth's plaque + -- row is drawn directly above the next figure's head, so the + -- flood-fill joined all six of a column into ONE region 24 tile rows + -- tall, and the volume builder's repeat scan read 32px down one half + -- of the drawing and 24px down the other. Each row came out as a + -- continuous stepped RIDGE of boxes wearing the statue art folded + -- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35) + -- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues + -- stand alone and fared no better: 24px boxes with the figure's top + -- row skipped outright. + -- + -- Pinned the gyms' way the ridge becomes statues. The plinth is a + -- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels + -- deep (the thin `prop` pool) that rides the plinth's top face + -- through the authored-box support rule and collapses to the + -- plinth's SINGLE cell of footprint -- Structures' wall-support case, + -- so the base never marches backwards. + -- + -- The one thing the gyms did not have to deal with: $28/$29 is + -- SHARED. The same bird is drawn again at the foot of every white + -- pillar, framed there by the pillar's black edge ($25/$26 over that + -- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and + -- four on INDIGO_PLATEAU (its two outer corners and the pair + -- flanking the League's recess). So $25/$26 joins the same pool: + -- pinning half a cell would have stood a half-height bird under a + -- wall. That in turn is why the pillar itself is pinned -- see the + -- last paragraph of `wall`. + PLATEAU = { + -- ONE 16px course for every piece of masonry here. + -- + -- $03 is the striated rock face, 2436 placements and the bulk of + -- both maps. It already read 16 nearly everywhere, but in the + -- columns of INDIGO_PLATEAU's rim that stand over a corner pillar + -- the repeat scan came out 24 -- a stagger in the plateau's + -- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and + -- their two mirrors). Authored, the rim is one course. + -- + -- $0D/$0F/$0E are the League's outer wall -- top band, face and + -- base. The same three tiles draw the Pokemon League's own + -- facade, the long walls flanking the avenue, and every + -- badge-check gate down ROUTE_23. + -- + -- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and + -- the plaque base. $15/$16 over $30/$31 IS the statue's plinth -- + -- the artist drew the same stone twice -- which is why one pin + -- serves the gate corners and the statues alike. + -- + -- $2E/$2F the white pillar shaft and $20/$21 its cap change no + -- HEIGHT: they derive 16 already. What the pin buys is + -- `authored`, which is exactly what the prop support rule tests. + -- Without it a pillar-foot bird finds no support, drops to ground + -- level, and leaves a hole punched clean through the pillar + -- (probed, shot, then fixed). + -- TWO courses (32px) for the masonry that ENCLOSES both maps -- the + -- plateau's rim and every wall around the terraces. It is drawn two + -- cells tall, which is exactly the height of a statue on its plinth + -- (a 16px plinth under a 16px standee), and that is the read: you are + -- walking in a walled compound whose wall matches the statues lining + -- it, not a room with a 16px skirting. At one course the rim was a + -- kerb you appeared to look over. + -- + -- $03 the striated rock face, $0D/$0F/$0E the League's outer wall + -- (top band, face, base -- the same three tiles draw the League's + -- facade, the walls flanking the avenue and every badge-check gate + -- down ROUTE_23), and $2E/$2F the white pillar shaft with $20/$21 its + -- cap. These four groups are the enclosure. + cliff = { 3, 13, + 32, 33, 46, 47 }, + -- THE GATE WALLS, stacked rather than laid out in depth. $0D/$0F/$0E + -- (13/15/14 -- top band, face, base) draw the League's facade, the + -- walls flanking the avenue and every badge-check gate down ROUTE_23, + -- as FOUR tile rows: 13 / 15 / 15 / 14. That is 32px of artwork + -- depicting one wall, and at one class per row it built four boxes + -- marching north, so the wall was 32px DEEP -- you saw the southmost + -- row's face and the rest hid behind it. + -- + -- `bookcase` collapses the run onto a single one-cell-deep box at its + -- full drawn height: bands from the south are base, face, face, top + -- band, and the two rows behind are vacated. Same mechanism the + -- Mart's back wall uses. + -- ...and the PILASTER stacks with it. $05/$06 is its shaft, and it + -- is drawn only ever inside a pilaster, so it needs no rule. Without + -- this the pale columns stood 16px against a 32px brown wall and + -- their top vertices sat a whole course below the wall's crown. + bookcase = { 14, 15, 5, 6 }, + -- Tile 13 is DUAL-USE and needs resolving per position. It is the + -- gate wall's TOP BAND (block $28's first row) and it is also the + -- BASE COURSE under a column of rock face (blocks $18/$1A/$1B, last + -- row). Pinned `bookcase` outright the second use became a one-row + -- rank -- an 8px stub under the rock, 352 of them over both maps. + -- + -- ABOVE cannot tell them apart, which is what `when_below` is for. + -- Scanned over both maps through the engine's own tileAt, the tile + -- above a 13 is the rock face $03 for 140 base courses AND for 64 + -- gate bands -- so a rule on `above` misfires on those 64 (it did: + -- their runs came out 2 and 3 bands instead of 4). BELOW splits it + -- exactly: the wall's own face $0F sits under the top band and under + -- nothing else, 336 against 352. So 13 defaults to `cliff` and is + -- promoted where the face is drawn beneath it. + -- + -- 14 and 15 need no rule: 14 only ever sits under 15, 15 only ever + -- under 13 or 15. + when_below = { + [13] = { { below = { 15 }, class = "bookcase" } }, + -- The pilaster's CAP ($15/$16) is the same stone as the statue's + -- plinth top -- the artist drew it twice -- so it too resolves per + -- position. Scanned over both maps: a cap with the shaft $05/$06 + -- beneath it is a pilaster (76 of them) and one with the plaque + -- base $30/$31 beneath it is a statue's plinth (47). Default + -- `wall`, promoted here, so the plinth keeps its single course and + -- the statue standing on it still totals the 32px the wall is. + [21] = { { below = { 5 }, class = "bookcase" } }, + [22] = { { below = { 6 }, class = "bookcase" } }, + }, + -- The other two ends of the same pair of drawings, keyed the other + -- way round. A pilaster is capped at BOTH ends, so its lower cap has + -- the shaft ABOVE it (8 placements); and the plaque base $30/$31 is a + -- pilaster's foot when the shaft is above it (68) but a statue's + -- plinth bottom when the cap $15/$16 is (47). + when_above = { + [21] = { { above = { 5 }, class = "bookcase" } }, + [22] = { { above = { 6 }, class = "bookcase" } }, + [48] = { { above = { 5 }, class = "bookcase" } }, + [49] = { { above = { 6 }, class = "bookcase" } }, + }, + -- The vacated rows behind a collapsed wall take the cell ABOVE the + -- run rather than the default hidden floor: here that is more rock + -- face on ROUTE_23 and the plateau's paving or grass on INDIGO_ + -- PLATEAU, so the wall reads as set INTO the terrace instead of + -- standing in front of a trench of synthesized ground. + bookcase_backfill = "above", + -- ONE course, and it must stay one: $15/$16 + $05/$06 + $30/$31 is + -- the pilaster -- cap, shaft, plaque base -- and $15/$16 over $30/$31 + -- IS the statue's plinth, the artist having drawn the same stone + -- twice. Raising it would carry every statue standee up to 48px and + -- break the very match the cliff height was chosen for. + -- $05/$06 is NOT here: it moved to `bookcase` above, and a tile + -- listed in two groups resolves by whichever `pairs` order wins -- + -- half the pilasters kept the shaft as `wall`, which broke the run + -- and left their caps as isolated 8px stubs. One group per tile. + wall = { 21, 22, 48, 49 }, + -- The statues, and the same bird at the pillar feet. Black-outline + -- segmented: the outline and everything it encloses stay, the sky + -- and the paving around them flood away. + prop = { 16, 18, 40, 41, 37, 38 }, + -- Round drawings, one voxel ball per 16x16 cell -- the treatment + -- the overworld's canopies and Celadon's hedge take. + -- + -- $07/$08 over $17/$18: the seven canopies planted on pillar tops + -- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of + -- them together is never placed). Boxed, the canopy art smeared + -- down the whole pillar column beneath it. + -- + -- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's + -- middle terrace (blocks $02/$13/$16), one per cell and NOT + -- walkable. As boxes they sat flat enough to look painted onto + -- the path; as balls they read as the obstacles they are. + cylinder = { 7, 8, 23, 24, + 29, 34, 42, 43 }, + -- The Route 23 sign, one cell, block $48's only placement. + -- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub + -- with its board skipped. Same thin plate on a stick every other + -- outdoor sign gets. + signpost = { 9, 10, 25, 26 }, + -- The Route 22 gate's roof, drawn from ABOVE and filling + -- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its + -- edges, $40/$41 the corners). Art on the TOP face -- the way + -- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb. + -- It stands past the map's last walkable row (you warp to + -- ROUTE_22_GATE before you reach it), so this is a tidy-up rather + -- than a fix. + roof = { 61, 62, 64, 65, 68 }, + -- The ground painted where a pinned figure's cell used to be: + -- $23, the pale paving both maps are floored with. It is also the + -- white the pillars are drawn in, so the cell a pillar-foot bird + -- vacates reads as more pillar -- where the neighbour vote left a + -- BLACK hole, having nothing to elect (all four neighbours of that + -- cell are wall). On the avenue and the plaza the statues' own + -- cells simply keep the paving they stand on. + prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35, + [37] = 35, [38] = 35 }, + -- Deliberately NOT pinned: + -- + -- $14, the water -- 1446 placements, the pond on ROUTE_23's middle + -- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are + -- two of the three stale-cache water ids the trap is named for, + -- but here they are honestly water: TILEANIM_WATER animates $14, + -- the pond is real, and $32/$33/$1F are drawn in the TOP half of + -- water cells as the waterline itself, so the cell rule dropping + -- them to -2 is what the art means. Left to fall through to the + -- engine's water set. ($48, the third trap id, is not in this + -- atlas at all -- it stops at $45.) + -- + -- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two + -- and Victory Road's two. They are the tileset's own doorTiles, + -- so the door fold already stands them upright in the facade; + -- probed 16px identically before and after this entry. + -- + -- $23/$2C/$2D are in the walkable list outright, and $45 is the + -- tileset's grassTile, which derives its own standing-tuft pin. + -- + -- The Victory Road entrance is a `buildings` template + -- (victory_road_gate, at the bottom of this file): 36x6 tiles at + -- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29 + -- and $15/$16/$05/$06. Buildings claim their tiles before any of + -- the above can reach them -- probed `b` class over all 216 of + -- them, unchanged by this entry. + }, }, -- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua, @@ -2845,146 +3231,5 @@ return { slab = 4, frontEave = 4, ledge = nil, }, }, - - -- The approach to the Pokemon League: two maps, INDIGO_PLATEAU (the - -- forecourt the League itself stands on) and ROUTE_23 (the long - -- climb up to it, with its badge-check gates). Everything this - -- blockset draws is one piece of architecture -- striated rock - -- walls, white pillars, and the bird STATUES that line both the - -- avenue and the plaza. 29 of its 73 blocks are never placed, so - -- every tile named below really is one of these two maps'. - -- - -- A statue is built exactly like the badge gyms' (see GYM above): - -- one cell of FIGURE ($10/$12 over $28/$29) standing on one cell of - -- PLINTH ($15/$16 the cap over $30/$31 the plaque). 47 of them -- - -- 12 on INDIGO_PLATEAU, six a side down the avenue, and 35 more - -- across ROUTE_23's plaza (blocks $42/$43; the $25/$26 twins that - -- stand the same statue on grass are never placed). - -- - -- What the detector made of them is the bug this entry exists for. - -- On the avenue the statues stack with NO gap: the plinth's plaque - -- row is drawn directly above the next figure's head, so the - -- flood-fill joined all six of a column into ONE region 24 tile rows - -- tall, and the volume builder's repeat scan read 32px down one half - -- of the drawing and 24px down the other. Each row came out as a - -- continuous stepped RIDGE of boxes wearing the statue art folded - -- onto its south face -- probed INDIGO_PLATEAU tiles (16,12)-(17,35) - -- at 32/24 and (22,12)-(23,35) mirrored. ROUTE_23's plaza statues - -- stand alone and fared no better: 24px boxes with the figure's top - -- row skipped outright. - -- - -- Pinned the gyms' way the ridge becomes statues. The plinth is a - -- SOLID 16px `wall` block; the figure is a per-pixel cutout 5 voxels - -- deep (the thin `prop` pool) that rides the plinth's top face - -- through the authored-box support rule and collapses to the - -- plinth's SINGLE cell of footprint -- Structures' wall-support case, - -- so the base never marches backwards. - -- - -- The one thing the gyms did not have to deal with: $28/$29 is - -- SHARED. The same bird is drawn again at the foot of every white - -- pillar, framed there by the pillar's black edge ($25/$26 over that - -- same $28/$29, blocks $18/$1B) -- 80 of them, 76 down ROUTE_23 and - -- four on INDIGO_PLATEAU (its two outer corners and the pair - -- flanking the League's recess). So $25/$26 joins the same pool: - -- pinning half a cell would have stood a half-height bird under a - -- wall. That in turn is why the pillar itself is pinned -- see the - -- last paragraph of `wall`. - PLATEAU = { - -- ONE 16px course for every piece of masonry here. - -- - -- $03 is the striated rock face, 2436 placements and the bulk of - -- both maps. It already read 16 nearly everywhere, but in the - -- columns of INDIGO_PLATEAU's rim that stand over a corner pillar - -- the repeat scan came out 24 -- a stagger in the plateau's - -- skyline (probed `03w24` at tiles (8,0)-(9,2), (12,0)-(13,2) and - -- their two mirrors). Authored, the rim is one course. - -- - -- $0D/$0F/$0E are the League's outer wall -- top band, face and - -- base. The same three tiles draw the Pokemon League's own - -- facade, the long walls flanking the avenue, and every - -- badge-check gate down ROUTE_23. - -- - -- $15/$16 + $05/$06 + $30/$31 are the pilaster: cap, shaft, and - -- the plaque base. $15/$16 over $30/$31 IS the statue's plinth -- - -- the artist drew the same stone twice -- which is why one pin - -- serves the gate corners and the statues alike. - -- - -- $2E/$2F the white pillar shaft and $20/$21 its cap change no - -- HEIGHT: they derive 16 already. What the pin buys is - -- `authored`, which is exactly what the prop support rule tests. - -- Without it a pillar-foot bird finds no support, drops to ground - -- level, and leaves a hole punched clean through the pillar - -- (probed, shot, then fixed). - wall = { 3, - 13, 14, 15, - 21, 22, 48, 49, 5, 6, - 32, 33, 46, 47 }, - -- The statues, and the same bird at the pillar feet. Black-outline - -- segmented: the outline and everything it encloses stay, the sky - -- and the paving around them flood away. - prop = { 16, 18, 40, 41, 37, 38 }, - -- Round drawings, one voxel ball per 16x16 cell -- the treatment - -- the overworld's canopies and Celadon's hedge take. - -- - -- $07/$08 over $17/$18: the seven canopies planted on pillar tops - -- along ROUTE_23 (blocks $44/$45; the $0F block that tiles four of - -- them together is never placed). Boxed, the canopy art smeared - -- down the whole pillar column beneath it. - -- - -- $2A/$2B over $22/$1D: the boulders strewn across ROUTE_23's - -- middle terrace (blocks $02/$13/$16), one per cell and NOT - -- walkable. As boxes they sat flat enough to look painted onto - -- the path; as balls they read as the obstacles they are. - cylinder = { 7, 8, 23, 24, - 29, 34, 42, 43 }, - -- The Route 23 sign, one cell, block $48's only placement. - -- Unpinned it probed `09w00 0Aw00 / 19w08 1Aw08` -- an 8px stub - -- with its board skipped. Same thin plate on a stick every other - -- outdoor sign gets. - signpost = { 9, 10, 25, 26 }, - -- The Route 22 gate's roof, drawn from ABOVE and filling - -- ROUTE_23's last two block rows ($3D the tiling, $3E/$44 its - -- edges, $40/$41 the corners). Art on the TOP face -- the way - -- SHIP_PORT's hull is pinned -- rather than folded up a 16px kerb. - -- It stands past the map's last walkable row (you warp to - -- ROUTE_22_GATE before you reach it), so this is a tidy-up rather - -- than a fix. - roof = { 61, 62, 64, 65, 68 }, - -- The ground painted where a pinned figure's cell used to be: - -- $23, the pale paving both maps are floored with. It is also the - -- white the pillars are drawn in, so the cell a pillar-foot bird - -- vacates reads as more pillar -- where the neighbour vote left a - -- BLACK hole, having nothing to elect (all four neighbours of that - -- cell are wall). On the avenue and the plaza the statues' own - -- cells simply keep the paving they stand on. - prop_ground = { [16] = 35, [18] = 35, [40] = 35, [41] = 35, - [37] = 35, [38] = 35 }, - -- Deliberately NOT pinned: - -- - -- $14, the water -- 1446 placements, the pond on ROUTE_23's middle - -- terrace -- and its bank shading $32/$33/$1F. $14 and $32 are - -- two of the three stale-cache water ids the trap is named for, - -- but here they are honestly water: TILEANIM_WATER animates $14, - -- the pond is real, and $32/$33/$1F are drawn in the TOP half of - -- water cells as the waterline itself, so the cell rule dropping - -- them to -2 is what the art means. Left to fall through to the - -- engine's water set. ($48, the third trap id, is not in this - -- atlas at all -- it stops at $45.) - -- - -- $0B/$0C over $1B/$1C, the barred doors: the Pokemon League's two - -- and Victory Road's two. They are the tileset's own doorTiles, - -- so the door fold already stands them upright in the facade; - -- probed 16px identically before and after this entry. - -- - -- $23/$2C/$2D are in the walkable list outright, and $45 is the - -- tileset's grassTile, which derives its own standing-tuft pin. - -- - -- The Victory Road entrance is a `buildings` template - -- (victory_road_gate, at the bottom of this file): 36x6 tiles at - -- ROUTE_23 (0,58), and its ends are built out of $25/$26/$28/$29 - -- and $15/$16/$05/$06. Buildings claim their tiles before any of - -- the above can reach them -- probed `b` class over all 216 of - -- them, unchanged by this entry. - }, }, } diff --git a/lib/BattleBillboard.lua b/lib/BattleBillboard.lua index 306a2de..88693cd 100644 --- a/lib/BattleBillboard.lua +++ b/lib/BattleBillboard.lua @@ -42,6 +42,20 @@ local quad = nil -- nil = untried, false = unavailable -- The unit card: x in -0.5..0.5, y in 0..1, z = 0, UV over the whole -- texture. Feet on the model origin, so the model matrix only has to say -- where the mon is standing and how big it is. +-- +-- Which puts this card OFF the voxel grid, alone among the meshes in this +-- mode: the rest are built one unit per voxel in their own model space -- +-- terrain in world pixels, a character's card in the sprite's own pixels -- +-- and the wireframe is the integer planes of that space (see VoxelGrid). +-- One unit here is the whole card, so the only integer plane inside it is +-- x = 0, which is the pic's centre column: a single stray line straight +-- down the middle of every Pokemon and no seams anywhere else. +-- +-- The card stays a unit card, because a mon's size on screen is decided by +-- the artwork's own dimensions and the distance it is standing at, and a +-- unit card is what lets one matrix say both. Whoever draws it turns the +-- wireframe off instead (Voxel3D.seams) -- a mesh that is not on the voxel +-- grid does not get a voxel grid drawn on it. local function unitQuad() if quad ~= nil then return quad or nil end local verts = { @@ -93,8 +107,11 @@ function BattleBillboard.draw(tex, x, y, z, grow) if grow then w, h = w * grow, h * grow end if w <= 0 or h <= 0 then return false end local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye) + -- off the voxel grid, so no wireframe on it (see unitQuad) + Voxel3D.seams(false) Voxel3D.draw(mesh, tex, BattleBillboard.matrix(x, y, z, w, h, yaw), BattleBillboard.PULL) + Voxel3D.seams(true) return true end diff --git a/lib/BattleExit.lua b/lib/BattleExit.lua new file mode 100644 index 0000000..c60004f --- /dev/null +++ b/lib/BattleExit.lua @@ -0,0 +1,201 @@ +-- Leaving a battle: the fade the way back to the map never had. +-- +-- Going IN is a whole production -- one of the original's eight wipes, picked by +-- three bits, over a flash (src/render/BattleTransition.lua). Coming OUT was a +-- hard cut: BattleState:finish pops itself and the map is simply THERE on the +-- next frame. On the flat battle screen that is a cut between a white field and +-- a tile map, which the original got away with. In this mode it is a cut between +-- a placed camera looking across an arena and a diorama looking down on a +-- walking player, and a jump that big reads as a glitch rather than as an edit. +-- +-- So the battle fades out, closes behind the black, and the map fades up out of +-- it. The timing is a transitions record this mod registers rather than a +-- constant in here, so it is retunable in data like the engine's own eight. +-- +-- WHEN. Only while voxel mode is on: this is the diorama's own exit, and a +-- vanilla battle keeps the cut it always had. While the mode IS on, every battle +-- gets it -- including one that found no arena and drew on the flat battle +-- screen -- because what is being smoothed over is the return to the MAP, and +-- the map is a diorama either way. +-- +-- HOW IT IS DRAWN, which is the part worth reading. Not by this state: it draws +-- nothing at all. It owns a NUMBER, and one black rectangle over the FINISHED +-- composite in a wrap around Renderer:endFrame paints it -- after the world +-- blit, after the letterbox, after the UI blit, which is the only point where a +-- single rect covers everything on screen at once. +-- +-- The renderer's own fade (worldFadeAlpha, which the warp fade uses) is painted +-- BETWEEN the world and the UI, because a warp has no UI over it. A fade that +-- borrowed it would darken the arena and leave the battle's text box sitting +-- bright on top of the black -- and the letterbox bars of a flat battle screen, +-- painted by the renderer's clear before any state draws, would not darken at +-- all. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local BattleExit = {} +BattleExit.__index = BattleExit + +-- The battle underneath keeps drawing while this is up -- what fades is its own +-- last live frame, camera drift, HUD and all. Only the top state UPDATES, so +-- nothing the battle does can outrun the fade either. +BattleExit.isOpaque = false + +-- The registered record's id, and the fallback timing if it is missing (a +-- headless caller, or a total conversion that dropped the namespace). Per HALF, +-- matching the engine's warp fade, so the whole edit is 24 frames. +BattleExit.ID = "voxel_battle_exit" +BattleExit.FRAMES = 12 + +-- The fade in progress, or nil. Kept here rather than on the state so the +-- endFrame wrap has one place to look and nothing can go stale the frame after +-- the state leaves the stack. +local live = nil + +-- How black the composite is this frame: 0 on the battle's last live frame, 1 at +-- the cut, 0 again once the map is up. nil when no fade is running, which is +-- every other frame the game ever draws. +function BattleExit.veil() + if not live then return nil end + -- A fade can be taken off the stack by something that is not the fade: a + -- script or a shot driver popping down to the overworld, a state teardown. + -- Then it is not running, whatever its counter says -- and a veil left behind + -- would black the game out for good, because nothing is going to fade it back + -- in. Checked here rather than trusted, because this is the one place the + -- answer is used. The walk only happens while a fade is live. + local stack = live.game and live.game.stack + local states = stack and stack.states + local onStack = false + for i = #(states or {}), 1, -1 do + if states[i] == live then onStack = true break end + end + if not onStack then live = nil; return nil end + local a = live.t / live.frames + if live.phase == "in" then a = 1 - a end + return math.max(0, math.min(1, a)) +end + +local function framesFor(game) + local records = game and game.data and game.data.transitions + local record = records and records[BattleExit.ID] + local frames = record and record.frames + if type(frames) == "number" and frames > 0 then return frames end + return BattleExit.FRAMES +end + +function BattleExit.new(game, battle, onMidpoint) + return setmetatable({ game = game, battle = battle, onMidpoint = onMidpoint, + frames = framesFor(game), t = 0, phase = "out" }, + BattleExit) +end + +-- Push the fade over the battle it is closing. +function BattleExit.start(battle, onMidpoint) + local game = battle.game + local self = BattleExit.new(game, battle, onMidpoint) + live = self + game.stack:push(self) + return self +end + +function BattleExit:update() + self.t = self.t + 1 + if self.t < self.frames then return end + self.t = 0 + + if self.phase == "in" then + live = nil + self.game.stack:pop() + return + end + + -- ------- the cut, at full black + -- + -- Off the stack FIRST. BattleState:finish pops whatever is on TOP, and while + -- this fade is up that is the fade -- so a fade that stayed would eat the + -- battle's own pop and leave the battle running underneath, finished but + -- still on the stack. Popping ourselves hands the top back to the battle so + -- its pop lands on itself. + self.phase = "in" + local stack = self.game.stack + stack:pop() + if self.onMidpoint then self.onMidpoint() end + if stack:top() == self.game.overworld then + stack:push(self) -- and the map comes up out of it + return + end + + -- We are not going back to the map after all. Either the battle did not + -- actually leave -- finish() can be a false start, and wanted() mirrors the + -- one the engine has today -- or something else took the screen on the way + -- out: a blackout's own warp fade, an evolution prompt. Whatever it is owns + -- the transition from here, so this one ends at the cut instead of fading in + -- over the top of it. The flag goes back too, so a second finish() that does + -- leave gets its own fade. + live = nil + if self.battle then self.battle.dramaticShapeLeaving = nil end +end + +-- "Voxel mode is on", as the ENGINE answers it: switched on, not retired by a +-- fault, and runnable on this machine. A function on the table rather than an +-- inline call so a driver or a headless test can pin it -- the test harness has +-- no depth buffer, where the honest answer is no on every rung. +function BattleExit.modeOn() + return require("src.render.Pipelines").eligible("voxel") and true or false +end + +-- Whether this ending gets the fade. +function BattleExit.wanted(battle) + local game = battle and battle.game + if not (game and game.stack) then return false end + -- finish() is not always the end: an unpaid PAY DAY prints its takings and + -- comes back through here a moment later (BattleState:finish's first branch). + -- Mirrored read-only, so the fade starts on the call that really leaves rather + -- than fading to black and snapping back for one more message. + if battle.payDay and battle.result == "win" then return false end + return BattleExit.modeOn() +end + +-- ------- engine seams +-- +-- Two wraps, each idempotent so a hot reload cannot stack them. +function BattleExit.install() + local BattleState = require("src.battle.BattleState") + if not BattleState.dramaticShapeExitHook then + local inner = BattleState.finish + -- The one place a battle ends. Wrapped rather than listened for: the + -- battle.ended event is emitted AFTER the pop, and by then the battle + -- screen is gone and there is nothing left to fade out. + function BattleState:finish() + if self.dramaticShapeLeaving or not BattleExit.wanted(self) then + return inner(self) + end + self.dramaticShapeLeaving = true + BattleExit.start(self, function() inner(self) end) + end + BattleState.dramaticShapeExitHook = true + end + + local Renderer = require("src.render.Renderer") + if not Renderer.dramaticShapeExitHook then + local inner = Renderer.endFrame + function Renderer:endFrame(zones, worldZones) + inner(self, zones, worldZones) + local a = BattleExit.veil() + if not a or a <= 0 then return end + -- The composite is on the screen by now, in LOVE units, so one rect over + -- the window darkens the world, the letterbox bars, the text box and + -- anything a present pass put on top, all by the same amount. Left to + -- last on purpose: this is a shutter closing on the finished frame, not a + -- layer inside it. + local w, h = love.graphics.getDimensions() + love.graphics.setColor(0, 0, 0, a) + love.graphics.rectangle("fill", 0, 0, w, h) + love.graphics.setColor(1, 1, 1, 1) + end + Renderer.dramaticShapeExitHook = true + end +end + +return BattleExit diff --git a/lib/BattleHud.lua b/lib/BattleHud.lua index f88f264..34432e7 100644 --- a/lib/BattleHud.lua +++ b/lib/BattleHud.lua @@ -201,6 +201,23 @@ local function frostRect(rect, box) return fx, fy, math.max(1, fw), math.max(1, fh) end +-- The same map for a rect that is ALREADY in world-canvas pixels. A HUD +-- snapped out to the window's edge has left the GB frame, so it has no GB +-- coordinates to be placed from -- see OverworldBattle.snapRects. +local function frostRectWorld(rect, box) + local kx = frostW / box.pw + local ky = frostH / box.ph + return rect[1] * kx, rect[2] * ky, + math.max(1, rect[3] * kx), math.max(1, rect[4] * ky) +end + +-- Which of the two the caller's rects are in. One frost buffer, one panel +-- draw, two coordinate spaces: the GB frame (rects land in the 160x144 UI +-- canvas) or world pixels (rects land in the window-resolution world image). +local function mapper(world) + return world and frostRectWorld or frostRect +end + -- ------- the verdict -- -- ONE answer for the whole frame, not one per panel. Both HUDs draw in a @@ -211,11 +228,12 @@ end -- the tint below then commits both panels to that reading. local wasDark = false -function BattleHud.verdict(rects, box) +function BattleHud.verdict(rects, box, world) if not (frost and box and box.scale and box.scale > 0) then return false end + local toFrost = mapper(world) local darkest = nil for key, rect in pairs(rects) do - local fx, fy, fw, fh = frostRect(rect, box) + local fx, fy, fw, fh = toFrost(rect, box) local v = sampleLuma(key, fx, fy, fw, fh) if v and (not darkest or v < darkest) then darkest = v end end @@ -230,14 +248,16 @@ function BattleHud.verdict(rects, box) return wasDark end --- Draw one HUD panel into the current target, in GB coordinates. +-- Draw one HUD panel into the current target, in that target's own +-- coordinates: GB ones for the 160x144 UI canvas, world pixels (world = true) +-- for a panel laid straight onto the world image. -- -- The tint always pushes AWAY from the glyph colour that is about to be -- used, so the contrast is guaranteed rather than hoped for: a dark panel -- gets darker under white text, a bright one brighter under black text. -function BattleHud.panel(rect, box, dark) +function BattleHud.panel(rect, box, dark, world) if not (frost and box and box.scale and box.scale > 0) then return false end - local fx, fy, fw, fh = frostRect(rect, box) + local fx, fy, fw, fh = mapper(world)(rect, box) local ok = pcall(function() local quad = love.graphics.newQuad(fx, fy, fw, fh, frostW, frostH) love.graphics.setColor(1, 1, 1, BattleHud.FROST) @@ -330,6 +350,44 @@ function BattleHud.flipGlyphs(w, h, fn) love.graphics.setShader() end +-- ------- the whole HUD layer as a texture +-- +-- The two blocks do not sit in the same place any more: each is snapped to its +-- own side of the WINDOW, which is outside the 160x144 canvas the engine draws +-- them in (see OverworldBattle.snapRects). A draw cannot be aimed at two +-- places at once, so the layer is rendered ONCE into a GB-sized canvas and +-- each block is then blitted out of it as a quad. +-- +-- `dark` runs the ink through the same flip the in-frame HUD uses, here baked +-- into the texture rather than composited into the caller's target -- the world +-- image the quads land on is a colour canvas, and a flip pass over it would +-- whiten the terrain behind the glyphs along with them. +local hudLayer = nil + +function BattleHud.layerTexture(w, h, dark, fn) + if not hudLayer or hudLayer:getWidth() ~= w or hudLayer:getHeight() ~= h then + hudLayer = canvasOf(w, h, "nearest") + if not hudLayer then return nil end + end + local g = love.graphics + local prevCanvas = g.getCanvas() + local prevBlend, prevAlpha = g.getBlendMode() + local ok, err = pcall(function() + g.setCanvas(hudLayer) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + g.setColor(1, 1, 1, 1) + -- flipGlyphs renders fn into its own scratch layer and composites the + -- whitened result into whatever is bound, which is this canvas + if dark then BattleHud.flipGlyphs(w, h, fn) else fn() end + end) + if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end + g.setBlendMode(prevBlend or "alpha", prevAlpha) + g.setColor(1, 1, 1, 1) + if not ok then error(err, 0) end + return hudLayer +end + -- The last luminance measured, for the shot driver's log. function BattleHud.lastLuma() local best = nil @@ -344,6 +402,7 @@ function BattleHud.invalidate() frostW, frostH = 0, 0 luma = {} wasDark = false + layer, hudLayer = nil, nil end return BattleHud diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua index 43c6003..6a64bf9 100644 --- a/lib/BattleScene.lua +++ b/lib/BattleScene.lua @@ -1,4 +1,4 @@ --- Overworld battles: one frame of the arena, as geometry. +-- Overworld battles: one frame of the arena, as geometry. -- -- The same world the free-roam mode draws, from a placed camera instead of -- the orbit, at the WINDOW's own pixel resolution -- not the GB's. The @@ -41,7 +41,9 @@ local VoxelScene = V.require("VoxelScene") local BattleCam = V.require("BattleCam") local BattleBillboard = V.require("BattleBillboard") local VoxelGrid = V.require("VoxelGrid") +local DayNight = V.require("DayNight") local PaletteFX = require("src.render.PaletteFX") +local Map = require("src.world.Map") local BattleScene = {} @@ -215,7 +217,11 @@ BattleScene.monCards = monCards local function shadowSignature(state, arena, terrain, nbMesh, token) local host = arena.map or state.map local parts = { "battle", host.id, arena.x, arena.y, arena.shape, - tostring(terrain), tostring(token or 0) } + tostring(terrain), tostring(token or 0), + -- the cycle keeps running through a fight, and an arena lit + -- from somewhere new must be re-cast from there + math.floor(ShadowMap.KX * 128), + math.floor(ShadowMap.KZ * 128) } for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end return table.concat(parts, ",") end @@ -231,17 +237,21 @@ local function castShadows(state, arena, terrain, nbMesh, cx, cy, vw, vh, for i, nb in ipairs(neighbors) do ShadowMap.draw(nbMesh[i], atlasFor(nb.map), Mat4.translate(nb.ox, 0, nb.oy)) end - ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), nil) + -- thin cards are snugged toward the sun (ShadowMap.snug) so their shadows + -- keep contact with their bases instead of starting a bias-width away + ShadowMap.draw(ChunkMesher.flowers(host), atlasFor(host), + ShadowMap.snug(nil)) for _, nb in ipairs(neighbors) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) end -- the mons themselves, as the same cards the camera will see. Their alpha -- is the silhouette, so what lands on the ground is the shape of the -- Pokemon rather than a blob standing in for one. for _, card in ipairs(cards or {}) do - ShadowMap.draw(BattleBillboard.mesh(), card.tex, card.model) + ShadowMap.draw(BattleBillboard.mesh(), card.tex, + ShadowMap.snug(card.model)) end ShadowMap.finish(sig) @@ -298,6 +308,22 @@ function BattleScene.render(state, arena, textures, token) local host = arena.map or state.map local neighbors = (host == state.map) and (state.neighbors or {}) or {} + -- the hour's light reaches the arena exactly as it reaches free-roam: the + -- shared rig follows the clock on an outdoor floor and stays at noon on an + -- indoor one, and the same tint multiplies the staged shot -- with the + -- same window glass on whatever buildings stand in the background + local outdoor = host.def and Map.isOutdoor(host.def) or false + DayNight.applyRig(outdoor) + -- a canopy floor (Viridian Forest) fights under the hour's tint too, + -- with the rig and the void exactly as they were + Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(host)) + local GlassMask = V.require("GlassMask") + Voxel3D.glassMask = outdoor and GlassMask.texture(host.tileset) or nil + Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0 + -- no glint in the arena: the drift is the shot breathing, not the player + -- moving, and a shimmer on background windows would fight the mons + Voxel3D.glassGlint = 0 + -- shares the free-roam mode's request/evict bookkeeping, so a battle warms -- exactly the meshes walking around would have and nothing extra local terrain, nbMesh = prefetchArena(state, host) @@ -342,9 +368,12 @@ function BattleScene.render(state, arena, textures, token) Voxel3D.camera = cam -- the sun is turned up for the arena and put back afterwards, so the - -- free-roam world it shares this module with keeps its own weight + -- free-roam world it shares this module with keeps its own weight -- and + -- the hour still has the last word: a sunset fades the arena's shadows + -- out and the moon presses more softly, exactly as it does outside local sunWas = Voxel3D.SHADOW_ALPHA Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA + * DayNight.shadowScale(outdoor) -- and the wireframe is ON for a battle whatever the V-GRID row says. The -- arena is a staged shot rather than the world being walked through, and -- the seams are what make it read as built rather than photographed. Forced @@ -378,10 +407,20 @@ function BattleScene.render(state, arena, textures, token) if flashing then Voxel3D.flatten(BattleScene.FLASH_COLOR, BattleScene.FLASH_STRENGTH) end + -- and no voxel wireframe on the pair. Everything else in this frame is + -- built a unit per voxel and wears the seams that fall out of that; a + -- mon's card is one quad wearing the battle screen (see + -- BattleBillboard), so it is off the grid and has no seams to draw. + Voxel3D.seams(false) + -- and no glass either: the cards wear the battle screen, not the + -- tileset atlas, so the mask's coordinates mean nothing on them + Voxel3D.glass(false) for _, card in ipairs(monCards(arena, groundY, textures)) do Voxel3D.draw(BattleBillboard.mesh(), card.tex, card.model, BattleBillboard.PULL) end + Voxel3D.glass(true) + Voxel3D.seams(true) if flashing then Voxel3D.flatten(nil) end -- grass and flowers ride the same camera-ward pull the free-roam pass -- gives them, measured against THIS camera's pitch rather than the diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index f17ea51..d8b2310 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -427,6 +427,18 @@ local function runGeometry(map, bodyOnly, masks, sink) s = nil end + -- Under the TREES fill the border wall is MODELLED or it is not there + -- (see Structures' hullRingOnly): a ring cell nothing claimed would + -- be a flat-topped box standing beside carved trunks, which reads as + -- a painted-on plateau rather than forest. Structures already stops + -- the ring at the carve distance; this catches the odd cell inside it + -- that the 2x2 grouping could not take -- a canopy whose partners + -- fall outside the shortened ring is left unclaimed, and one strip of + -- boxes along an edge is the whole artefact this avoids. + if not inBody and S.hideBareRing and not S.skip[k] then + s = nil + end + if s and S.skip[k] then -- an object stands here; paint its synthesized ground and let the -- prebuilt prism quads (appended below) carry the art @@ -801,6 +813,33 @@ local function buildFlowerMesh(map) return quadsMesh(Structures.forMap(map).flowerQuads) end +-- Authored FIGURES (a person drawn into furniture) as one mesh each, in +-- the card's own local space -- because each one is placed by its own +-- matrix at draw time, leaned back by the camera pitch exactly like a +-- character card (VoxelScene). A figure baked into the terrain mesh could +-- not lean, and a shared mesh could not carry per-figure placement. +-- +-- A list, not a mesh: `{ mesh, wx, wz, y }` per figure. Maps have one or +-- none, so the loop that draws them is shorter than the terrain's. +local function buildFigureMeshes(map) + local out = {} + for _, f in ipairs(Structures.forMap(map).figures or {}) do + local mesh = quadsMesh(f.quads) + if mesh then + out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y } + end + end + return out +end + +-- Figure lists hold their meshes one level down, so the generic slot +-- release cannot reach them. +local function releaseFigures(list) + for _, f in ipairs(type(list) == "table" and list or {}) do + if f.mesh and f.mesh.release then pcall(f.mesh.release, f.mesh) end + end +end + -- Replace a cached slot, releasing whatever mesh it held. local function swapSlot(c, slot, mesh) local old = c[slot] @@ -825,6 +864,8 @@ local function releaseEntry(c) if mesh and mesh.release then pcall(mesh.release, mesh) end c[slot] = nil end + releaseFigures(c.figures) + c.figures = nil c.stale = nil end @@ -863,18 +904,23 @@ end local function runJob(job) local map = job.map local c = entry(job.id) - if c.grass == nil or c.flowers == nil or (c.stale and c.stale.aux) then + if c.grass == nil or c.flowers == nil or c.figures == nil + or (c.stale and c.stale.aux) then local okG, grass = pcall(buildGrassMesh, map) local okF, flowers = pcall(buildFlowerMesh, map) + local okX, figures = pcall(buildFigureMeshes, map) if (gen[job.id] or 0) ~= job.gen then if okG and grass and grass.release then pcall(grass.release, grass) end if okF and flowers and flowers.release then pcall(flowers.release, flowers) end + if okX then releaseFigures(figures) end return end swapSlot(c, "grass", (okG and grass) or false) swapSlot(c, "flowers", (okF and flowers) or false) + releaseFigures(c.figures) + c.figures = (okX and figures) or false if c.stale then c.stale.aux = nil end end local sink = newSink() @@ -1022,6 +1068,14 @@ function ChunkMesher.flowers(map) return c and c.flowers or nil end +-- Authored figures as `{ mesh, wx, wz, y }` records -- each placed by its +-- own leaning matrix at draw time, so they cannot share one mesh. +function ChunkMesher.figures(map) + local c = cache[map.id] + local list = c and c.figures + return (type(list) == "table") and list or nil +end + -- Rebuild a map's meshes IN PLACE: the stale meshes keep drawing while -- replacements cook, and each slot swaps as its build lands. This is -- the block-edit path (a cut tree, a door stamp) -- invalidate() drops diff --git a/lib/DayNight.lua b/lib/DayNight.lua new file mode 100644 index 0000000..086f3ff --- /dev/null +++ b/lib/DayNight.lua @@ -0,0 +1,463 @@ +-- Voxel world mode: the day/night cycle -- one clock, and everything the +-- frame asks it. +-- +-- THE CLOCK is twenty minutes around: ten of day, ten of night. The DAYTIME +-- row either PINS it -- DAY, NIGHT, DUSK and DAWN are fixed times on that +-- dial, not separate looks -- or lets it run (CYCLE), in which case the pin +-- the player left is where the cycle picks up. Everything below is a pure +-- function of the clock, so the pinned settings and the running cycle can +-- never drift apart: DUSK is simply the cycle stopped at sunset. +-- +-- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55), +-- hanging in the southeast about 45 degrees up. That is the DAY setting and +-- the default, so a player who never touches the row sees the mod they +-- already had. From there the arc swings NORTH at both ends -- rising 70 +-- degrees north of east, setting the mirror of that -- because the camera +-- looks north and the northern sky is the only sky it ever frames: a sun +-- that rose due east would light the world for ten minutes without once +-- being seen. Swung north, the disc stands in frame through dawn and dusk +-- (the hours worth looking at) and passes overhead-behind-the-camera +-- through midday, which is where a noon sun belongs. +-- +-- THE MOON arcs entirely through the northern sky -- rising northeast, due +-- north at mid-night, setting northwest -- so it hangs over the diorama all +-- night and the pinned NIGHT setting puts it dead centre. Its shadows fall +-- softly south, away from it, at about two-thirds the sun's weight. +-- +-- SHADOWS are the shear the light throws: direction opposite the body's +-- bearing, length its elevation's cotangent (clamped -- a rising sun throws +-- a long shadow, not an infinite one), strength fading to nothing over the +-- last twelve degrees before the horizon so the handoff between sun and +-- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE) +-- deliberately stays the noon bake: it is a subtle angle term baked into +-- every mesh, and rebaking the world's geometry per phase buys less than +-- the cast shadows, the sky and the tint already say. +-- +-- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky: +-- a cave at midnight is exactly as dark as a cave at noon, which is what a +-- room with no windows looks like. Map.isOutdoor is the same test the sky +-- already rests on; the caller passes its answer in (applyRig/tint). +-- +-- Persistence: the running cycle's clock is written into the mod's own +-- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the +-- engine's save.writing event, and read back on save.loaded/created. A save +-- with no clock in it starts at noon. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local ModSetting = V.require("ModSetting") +local PaletteFX = require("src.render.PaletteFX") + +local DayNight = {} + +-- ------- the dial + +DayNight.CYCLE = 1200 -- seconds around the whole dial +DayNight.DAY_LEN = 600 -- the sun's half; the moon has the rest +DayNight.BLEND = 75 -- seconds of palette blend either side of a twilight + +-- where the pinned settings stop the clock +DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 } + +DayNight.KEY = "daytime" +DayNight.LABEL = "DAYTIME" + +-- "day" first: an unset or unreadable value is DAY, per the row's contract +-- (ModSetting values[1] is the default) +DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL, + { "day", "night", "dusk", "dawn", "cycle" }, + { "DAY", "NIGHT", "DUSK", "DAWN", "CYCLE" }) + +DayNight.clock = DayNight.T.day -- the running cycle's own position + +-- ------- the two arcs +-- +-- Bearings in DEGREES from east toward south (the world's +X is east, +Z +-- south), elevations in degrees up from the ground plane. + +-- noon IS the existing sun: shear (-0.85, -0.55) hangs it at +-- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up +local NOON_KX, NOON_KZ = -0.85, -0.55 +local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX)) +local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX + + NOON_KZ * NOON_KZ))) + +local TH_RISE, TH_SET = -70, 250 -- north of east / north of west +local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160 +local EL_MOON = 40 + +DayNight.K_MAX = 2.0 -- shear clamp: a shadow at most twice its height +DayNight.ALPHA_SUN = 0.40 -- the existing midday shadow weight +DayNight.ALPHA_MOON = 0.26 -- moonlight is a softer press +DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a rise/set + +-- disc PLACEMENT only: the true elevation would put the noon sun far above +-- any frame, so the arc the discs ride is squashed toward the horizon. The +-- shadows always use the true elevation. +DayNight.ELEV_SQUASH = 0.14 + +-- three-point arc: a at s=0, b at s=0.5, c at s=1 +local function arc(a, b, c, s) + if s < 0.5 then return a + (b - a) * 2 * s end + return b + (c - b) * (2 * s - 1) +end + +-- The body lighting the world at clock `t`: bearing and elevation in +-- degrees, and whether it is the moon. The t == DAY_LEN boundary belongs to +-- the SUN, so the pinned DUSK setting is the sun half-set in the northwest, +-- not the moon rising. +function DayNight.bodyAt(t) + t = t % DayNight.CYCLE + if t <= DayNight.DAY_LEN then + local s = t / DayNight.DAY_LEN + return arc(TH_RISE, TH_NOON, TH_SET, s), + EL_NOON * math.sin(math.pi * s), false + end + local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN) + return arc(TH_MRISE, TH_MMID, TH_MSET, s), + EL_MOON * math.sin(math.pi * s), true +end + +-- The shadow shear that body throws: drift per pixel of height, opposite +-- the bearing, cot(elevation) long, clamped. +function DayNight.shearAt(t) + local th, el, moon = DayNight.bodyAt(t) + if el < 0.5 then el = 0.5 end + local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el))) + return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon +end + +-- How much shadow the light can press right now, 0..1 of the body's own +-- weight: full up high, gone at the horizon, so sunset hands off to +-- moonrise through a soft shadowless gap instead of snapping. +function DayNight.strengthAt(t) + local _, el = DayNight.bodyAt(t) + local s = el / DayNight.FADE_DEG + if s < 0 then return 0 end + return s < 1 and s or 1 +end + +-- ------- the palettes +-- +-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands +-- reads. Six bands, not four: twilight is the whole show here, and six rungs +-- of it is what keeps a sunset reading as a gradient rather than as stripes. +-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including +-- after blending, which re-quantises onto it. +-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass +-- through. Day's blue horizon and dusk's gold one are near-complements, and +-- a straight lerp between complements bottoms out in grey: mid-transition +-- the whole sky went the colour of dishwater, and gold-to-navy did the same +-- on the far side of sunset. So the evening bends through a golden hour +-- (horizon warming, zenith still blue -- late afternoon), and both edges of +-- the night bend through a violet civil twilight (rose horizon under a +-- violet sky -- the real colour of that half hour). +DayNight.PALETTES = { + day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 }, + { 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } }, + golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 }, + { 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } }, + dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 }, + { 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } }, + dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 }, + { 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } }, + violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 }, + { 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } }, + night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 }, + { 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } }, +} + +-- what the world's own colours are multiplied by, per phase (0..255) +DayNight.TINTS = { + day = { 255, 255, 255 }, + golden = { 255, 232, 208 }, + dawn = { 255, 216, 192 }, + dusk = { 255, 192, 168 }, + violet = { 184, 160, 200 }, + night = { 120, 136, 192 }, +} + +-- the twilight glow around the low sun, and the discs' own four-shade +-- palettes (lightest first, so a display mode transforms them like any +-- other palette) +DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } } +DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 }, + { 248, 144, 80 }, { 216, 96, 64 } } +DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 }, + { 168, 184, 208 }, { 120, 136, 168 } } + +-- The dial as keyframes: a repeated name is a plateau, a change is a +-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their +-- pinned times, and so the evening approaches dusk THROUGH the golden-hour +-- waypoint rather than straight across the grey between blue and gold. The +-- morning side needs no waypoint of its own: dawn's pinks into day's blues +-- share a family and blend clean. +local DIAL +local function dial() + if DIAL then return DIAL end + local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE + DIAL = { + { 0, "dawn" }, { B, "day" }, + { D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" }, + { D + B / 2, "violet" }, { D + B, "night" }, + { C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" }, + } + return DIAL +end + +-- Phase weights at clock `t`, off the dial above. +function DayNight.mix(t) + t = t % DayNight.CYCLE + local d = dial() + for i = 1, #d - 1 do + local a, b = d[i], d[i + 1] + if t >= a[1] and t < b[1] then + if a[2] == b[2] then return { [a[2]] = 1 } end + local u = (t - a[1]) / (b[1] - a[1]) + return { [a[2]] = 1 - u, [b[2]] = u } + end + end + return { dawn = 1 } +end + +-- back onto the 5-bit lattice after any blend +local function q8(v) + v = math.floor(v / 8 + 0.5) * 8 + if v < 0 then return 0 end + return v > 248 and 248 or v +end + +local function blend3(key, mix, fallback) + local r, g, b = 0, 0, 0 + for name, w in pairs(mix) do + local c = key[name] or fallback + r = r + c[1] * w + g = g + c[2] * w + b = b + c[3] * w + end + return { q8(r), q8(g), q8(b) } +end + +-- The sky palette for clock `t`, blended between the phase palettes and +-- re-quantised to the lattice. Memoised per whole second: the answer only +-- moves as the cycle runs, and the cycle moves it slowly. +local palCache = { key = nil, pal = nil } + +function DayNight.palette(t) + t = t or DayNight.time() + local key = math.floor(t % DayNight.CYCLE) + if palCache.key == key then return palCache.pal end + local mix = DayNight.mix(t) + local pal = {} + for i = 1, #DayNight.PALETTES.day do + local r, g, b = 0, 0, 0 + for name, w in pairs(mix) do + local c = DayNight.PALETTES[name][i] + r = r + c[1] * w + g = g + c[2] * w + b = b + c[3] * w + end + pal[i] = { q8(r), q8(g), q8(b) } + end + palCache.key, palCache.pal = key, pal + return pal +end + +-- The world tint for clock `t`, {r, g, b} in 0..1. Neutral indoors -- the +-- caller answers for where it is standing (see the header). +local tintCache = { key = nil, tint = nil } +local NEUTRAL = { 1, 1, 1 } + +function DayNight.tint(outdoor, t) + if not outdoor then return NEUTRAL end + t = t or DayNight.time() + local key = math.floor(t % DayNight.CYCLE) + if tintCache.key ~= key then + -- NOT re-quantised: this is a light level the shader multiplies by, not + -- a palette colour, and the lattice's 248 ceiling would make even noon + -- fractionally dim + local mix = DayNight.mix(t) + local r, g, b = 0, 0, 0 + for name, w in pairs(mix) do + local c = DayNight.TINTS[name] or DayNight.TINTS.day + r = r + c[1] * w + g = g + c[2] * w + b = b + c[3] * w + end + tintCache.key = key + tintCache.tint = { r / 255, g / 255, b / 255 } + end + return tintCache.tint +end + +-- The twilight glow: how strongly (0..1) and in what colour the sky warms +-- around the low sun. Only the SUN glows -- a moonrise is silver, not gold. +function DayNight.glow(t) + t = t or DayNight.time() + local _, _, moon = DayNight.bodyAt(t) + if moon then return 0, nil end + local mix = DayNight.mix(t) + local amt = (mix.dawn or 0) + (mix.dusk or 0) + if amt <= 0 then return 0, nil end + return amt, blend3(DayNight.GLOWS, mix, DayNight.GLOWS.dusk) +end + +-- ------- the clock itself + +local lastMode = nil + +local function mode() + return DayNight.setting:get() or "day" +end + +-- The effective time: the pin, or the running clock under CYCLE. +function DayNight.time() + local m = mode() + if m == "cycle" then return DayNight.clock end + return DayNight.T[m] or DayNight.T.day +end + +-- Advance the cycle. Runs every frame from the voxel pipeline's update hook +-- (which ticks through battles and menus too, so night falls during a long +-- fight exactly as it does on a walk). Stepping ONTO cycle picks up from +-- the pin the player was just looking at: DUSK then CYCLE rolls on into +-- night rather than teleporting the sky. +function DayNight.update(dt) + local m = mode() + if m ~= lastMode then + if m == "cycle" and DayNight.T[lastMode] then + DayNight.clock = DayNight.T[lastMode] + end + lastMode = m + end + if m == "cycle" and dt and dt > 0 then + DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE + end +end + +-- The clock the RIG runs on: quantised, so the shadow map redraws a few +-- times a minute as the sun crawls rather than every frame. +DayNight.STEP = 2 + +function DayNight.rigTime() + local t = DayNight.time() + return math.floor(t / DayNight.STEP) * DayNight.STEP +end + +-- ------- what the frame reads + +-- Point the shared light rig at the clock -- or at noon, indoors. This +-- writes the same fields everything already reads (ShadowMap.KX/KZ for the +-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the +-- sunDark uniform), so no draw path changes to follow the sun; they follow +-- the rig, and the rig follows the clock. +function DayNight.applyRig(outdoor) + local ShadowMap = V.require("ShadowMap") + local Voxel3D = V.require("Voxel3D") + local t = outdoor and DayNight.rigTime() or DayNight.T.day + local kx, kz, moon = DayNight.shearAt(t) + ShadowMap.KX, ShadowMap.KZ = kx, kz + Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz + local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN + Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t) + return t +end + +-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a +-- caller that sets its own alpha (the battle arena) and should still lose +-- its shadows to a sunset. +function DayNight.shadowScale(outdoor, t) + if not outdoor then return 1 end + t = t or DayNight.rigTime() + local _, _, moon = DayNight.bodyAt(t) + local s = DayNight.strengthAt(t) + return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s +end + +-- The disc to hang in the sky, or nil when the body is set or behind the +-- camera's half of the sky. Returns a direction for the PLACEMENT arc -- +-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it +-- is; the caller projects it through its own camera. +function DayNight.body(t) + t = t or DayNight.time() + local th, el, moon = DayNight.bodyAt(t) + if el < -2 then return nil end + local e = math.rad(el * DayNight.ELEV_SQUASH) + local b = math.rad(th) + return { + dx = math.cos(b) * math.cos(e), + dy = math.sin(e), + dz = math.sin(b) * math.cos(e), + moon = moon, + } +end + +-- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun +-- or moon to see, so the shadow rig stays the mod's fixed noon light, +-- which is all that ever filtered through the leaves -- but not a sealed +-- room either: night still FALLS in them. Of everything the clock does, +-- exactly one thing reaches a canopy map: the hour's tint. +DayNight.CANOPY = { VIRIDIAN_FOREST = true } + +function DayNight.isCanopy(map) + return (map and map.id and DayNight.CANOPY[map.id]) and true or false +end + +-- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky. +-- They come on through dusk (a lit window against a sunset is half the point +-- of having either), burn all night, and are mostly out again by dawn: +-- people wake before it is bright, they do not read at sunrise. +local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 } + +function DayNight.windowLight(t) + local mix = DayNight.mix(t or DayNight.time()) + local lit = 0 + for name, w in pairs(mix) do + lit = lit + (LAMPS[name] or 0) * w + end + return lit +end + +-- The period name for the engine's world.tod hook (map.palette ctx.tod, +-- music.select): the dominant phase, in the vocabulary day/night mods use. +local TOD = { day = "DAY", golden = "DAY", night = "NIGHT", + violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" } + +function DayNight.tod(t) + local mix = DayNight.mix(t or DayNight.time()) + local best, bestW = "day", -1 + for name, w in pairs(mix) do + if w > bestW then best, bestW = name, w end + end + return TOD[best] or "DAY" +end + +-- ------- persistence +-- +-- The clock rides the SAVE SLOT, not the options file: what time it is in +-- Kanto is a fact about that journey, like where the player is standing. +-- mod.save is the loader's per-mod bucket in save.modData, which persists +-- with the slot on its own -- writing the value is all there is to do. + +DayNight.SAVE_KEY = "clock" + +function DayNight.store() + local saveApi = V.mod and V.mod.save + if not (saveApi and saveApi.set) then return end + pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock) +end + +function DayNight.restore() + local saveApi = V.mod and V.mod.save + local stored = nil + if saveApi and saveApi.get then + local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY) + if ok then stored = got end + end + -- no time set: it is day (the requirement, verbatim) + DayNight.clock = type(stored) == "number" + and stored % DayNight.CYCLE or DayNight.T.day +end + +return DayNight diff --git a/lib/GlassMask.lua b/lib/GlassMask.lua new file mode 100644 index 0000000..92f3b95 --- /dev/null +++ b/lib/GlassMask.lua @@ -0,0 +1,174 @@ +-- Voxel world mode: the glass in the windows, found rather than listed. +-- +-- Buildings and doors in the overworld art carry small panes -- six texels +-- wide, framed in black, with a diagonal shine drawn in. This module finds +-- them by SHAPE in the tileset image itself: a border row of six black +-- texels, four or five rows of black-flanked non-black glass under it, and +-- a closing border row. No tile ids are hardcoded, so a total conversion +-- that draws its own windows in the same idiom gets glass for free, and art +-- with no windows gets an empty mask and costs nothing. +-- +-- The scan slides at PIXEL granularity because the art does: the building +-- window sits a row down inside its tile, and the door's pane straddles a +-- 2x2 tile block entirely -- a per-tile matcher finds neither. +-- +-- What the scan yields is a MASK TEXTURE the same size as the tileset +-- atlas: opaque white on glass texels, transparent everywhere else. Terrain +-- meshes sample the atlas by normalized coordinates (ChunkMesher.uvRect), +-- so the scene shader can sample this mask with the SAME coordinates and +-- know, per fragment, whether it is drawing glass -- on any wall, at any +-- angle, in free-roam or a staged battle, with no geometry work anywhere. +-- The recoloured atlases (display modes, RED++) keep the tileset's layout, +-- so the alignment holds under every palette. +-- +-- What the shader does with the answer (Voxel3D): by day a thin glint +-- sweeps across the panes -- a pseudo reflection, view-anchored, preserving +-- the art under it -- and after dark the panes are LIT: the texel's own +-- shine pattern, warmed and brightened, exempt from the sun, the shadow +-- map and the hour's tint, as a window with a lamp behind it is. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Assets = require("src.render.Assets") + +local GlassMask = {} + +-- pane geometry the scan accepts: six glass texels across, and this many +-- rows of them between the two border rows +GlassMask.GLASS_W = 6 +GlassMask.ROWS = { 4, 5 } -- door pane, building pane + +-- Whether a channel triple is the border black. The raw tileset art is the +-- four DMG greys, so black is genuinely zero; the threshold forgives a +-- rescaled asset without accepting the dark grey rung (85/255 = 0.33). +local function isBlack(r, g, b) + return r < 0.12 and g < 0.12 and b < 0.12 +end + +GlassMask._isBlack = isBlack -- named for the suite + +-- Find every pane in an image, through a pure reader so the geometry is +-- testable headless: `getPixel(x, y)` returns r, g, b in 0..1 for 0-based +-- coordinates. Returns { {x=, y=, w=, h=}, ... } rects of GLASS texels +-- (the border is the detector's evidence, not part of the answer). +function GlassMask.scan(getPixel, w, h) + local function black(x, y) + return isBlack(getPixel(x, y)) + end + local function borderRow(x, y) + for c = 1, 6 do + if not black(x + c, y) then return false end + end + return true + end + local function glassRow(x, y) + if not (black(x, y) and black(x + 7, y)) then return false end + for c = 1, 6 do + if black(x + c, y) then return false end + end + return true + end + local want = {} + for _, n in ipairs(GlassMask.ROWS) do want[n] = true end + + local rects = {} + for y = 0, h - 1 do + for x = 0, w - 8 do + if borderRow(x, y) then + local n = 0 + while y + 1 + n < h and glassRow(x, y + 1 + n) do + n = n + 1 + end + if want[n] and y + 1 + n < h and borderRow(x, y + 1 + n) then + rects[#rects + 1] = { x = x + 1, y = y + 1, + w = GlassMask.GLASS_W, h = n } + end + end + end + end + return rects +end + +-- ------- the runtime cache, one entry per tileset image + +local cache = {} -- image path -> { rects, texture (or false) } + +local function entry(tileset) + local path = tileset and tileset.image + if not path then return nil end + local hit = cache[path] + if hit then return hit end + local ok, data = pcall(Assets.imageData, path) + if not (ok and data) then + -- unreadable art is a verdict for the session, not a retry loop + cache[path] = { rects = {}, texture = false } + return cache[path] + end + local w, h = data:getDimensions() + local rects = GlassMask.scan(function(x, y) + return data:getPixel(x, y) + end, w, h) + local texture = false + if #rects > 0 and love.image and love.image.newImageData + and love.graphics and love.graphics.newImage then + local built = pcall(function() + local mask = love.image.newImageData(w, h) + for _, r in ipairs(rects) do + for yy = r.y, r.y + r.h - 1 do + for xx = r.x, r.x + r.w - 1 do + mask:setPixel(xx, yy, 1, 1, 1, 1) + end + end + end + texture = love.graphics.newImage(mask) + texture:setFilter("nearest", "nearest") + end) + if not built then texture = false end + end + cache[path] = { rects = rects, texture = texture } + return cache[path] +end + +-- The panes found in a tileset's art, as glass rects in atlas pixels. +function GlassMask.rects(tileset) + local e = entry(tileset) + return e and e.rects or {} +end + +-- The mask texture for a tileset, or nil when it has no panes (or the art +-- is unreadable, or there is no GPU) -- callers bind the blank instead. +function GlassMask.texture(tileset) + local e = entry(tileset) + return (e and e.texture) or nil +end + +-- A 1x1 transparent stand-in, for the frames (and drivers) with no mask: +-- the scene shader always declares the sampler, and an unbound sampler is +-- a driver-dependent crash rather than a fallback. +local blank = nil + +function GlassMask.blank() + if blank == nil then + local ok, img = pcall(function() + local data = love.image.newImageData(1, 1) + data:setPixel(0, 0, 0, 0, 0, 0) + return love.graphics.newImage(data) + end) + blank = (ok and img) or false + end + return blank or nil +end + +-- Drop the GPU objects (window resize, hot reload). The rects survive -- +-- they are a fact about the art -- but textures are rebuilt on demand. +function GlassMask.invalidate() + for _, e in pairs(cache) do + if e.texture and e.texture.release then pcall(e.texture.release, e.texture) end + e.texture = false + end + cache = {} + blank = nil +end + +return GlassMask diff --git a/lib/OverworldBattle.lua b/lib/OverworldBattle.lua index f3998f0..83e669d 100644 --- a/lib/OverworldBattle.lua +++ b/lib/OverworldBattle.lua @@ -140,6 +140,54 @@ OverworldBattle.HUD_RECT = { player = { 72, 56, 88, 40 }, } +-- ------- the HUDs, out at the window's own edges +-- +-- The battle screen is 160x144 in the MIDDLE of the window and the world is the +-- whole of it. That left both HUD blocks huddled together in the middle of the +-- frame with map showing on either side of them, which reads as a Game Boy +-- screenshot pasted over a diorama rather than as the diorama's own furniture. +-- +-- So each block is snapped to its own side: the foe's to the left edge of the +-- window, the player's to the right. Nothing about either block changes -- same +-- tiles, same size, same rows, drawn by the engine's own DrawEnemyHUDAndHPBar +-- and DrawPlayerHUDAndHPBar -- only where the pair sits. On a window the shape +-- of the GB screen there is nowhere to go and the snap is a no-op. +-- +-- They cannot simply be MOVED there: the engine draws them into the 160x144 UI +-- canvas and everything outside it is clipped away. So the layer is rendered to +-- a texture and composited into the WORLD image instead, which is the one +-- surface in this mode that covers the whole window. + +-- The rows each block is cut out of, full width. Generous on purpose: +-- AnimationShakeEnemyHUD nudges the foe's block sideways, a long name reaches +-- further than the panel does, and the pokeball rows and the safari ball count +-- belong to the block whose rows they sit in. Nothing drawHUDs draws lies +-- outside rows 0-96, and the two bands split that between them. +OverworldBattle.HUD_BAND = { + enemy = { 0, 0, 160, 48 }, + player = { 0, 48, 160, 48 }, +} + +-- Where each block lands, in WORLD-canvas pixels: the panel rect the frosted +-- glass is cut to, plus the x its band is blitted at. +-- +-- The foe's panel starts at the window's left edge and the player's ends at the +-- right one. The vertical is untouched, so both stay on the rows the GB put +-- them on. A band's own origin sits outside the window by the panel's inset -- +-- the couple of pixels a HUD shake can push past the edge are clipped there, +-- which is the whole cost of the snap and is invisible. +function OverworldBattle.snapRects(shot) + local s = shot.scale + local e, p = OverworldBattle.HUD_RECT.enemy, OverworldBattle.HUD_RECT.player + local ex = -e[1] * s -- foe: panel's left edge to 0 + local px = shot.pw - (p[1] + p[3]) * s -- player: right edge to the far side + local rects = { + enemy = { ex + e[1] * s, shot.ly + e[2] * s, e[3] * s, e[4] * s }, + player = { px + p[1] * s, shot.ly + p[2] * s, p[3] * s, p[4] * s }, + } + return rects, { enemy = ex, player = px } +end + -- ------- the live battle -- -- nil when no overworld battle is running. Never more than one: battles do @@ -150,6 +198,13 @@ local function game() return require("src.core.Game") end +-- Whether this frame's HUDs went out to the window's edges instead of being +-- drawn in the GB frame. False whenever the composite could not be made, which +-- is what leaves the in-frame HUD as the fallback rather than no HUD at all. +local function snapped() + return (session and session.snapped) and true or false +end + -- Put the map's cast back. Both lists are handed back by identity, so -- anything that captured one before the battle still sees the same table. local function restoreCast() @@ -174,6 +229,36 @@ local function cullCast(state) state.ghosts = {} end +-- ------- one right battle layout +-- +-- Everything this file composes is measured in the GB's own 160x144 frame: the +-- two ANCHORs the arena camera is solved to put a cell under, the HUD_RECTs +-- the frosted panels are cut to, and the full-frame white intercepted to let +-- the world through. BATTLE LAYOUT's WIDE lays the same battle out on a +-- 304x144 surface (src/battle/WideBattle.lua), which moves every one of those +-- -- the mons would stand where no camera was solved for them, and the panels +-- would land beside the HUDs they are supposed to be under. +-- +-- So while a fight can be staged on the map there is one right answer, and it +-- is SET rather than worked around. The engine reads the option live +-- (BattleState:isWideBattleLayout is asked per frame, and Renderer asks the +-- top state for its surface the same way), so writing it here lands on the +-- battle being pushed as well as every one after it. +-- +-- This is the last line rather than the first: the OPTIONS menu takes the row +-- off the list and pins the value while 3D-BTL is on (see main.lua), so a +-- player is never offered a switch that gets reverted under them. What reaches +-- here is a value that arrived some other way -- a save written before the mod +-- was installed, the mod manager's own page, another mod. +function OverworldBattle.forceOG(g) + g = g or game() + local opts = g and g.save and g.save.options + if not opts or opts.battleLayout ~= "wide" then return false end + opts.battleLayout = "og" + if g.writeOptions then pcall(g.writeOptions, g) end + return true +end + -- Stage a battle triggered from `state`, if this mode can. Returns true when -- a session started -- which is also the only case where anything visible -- changes, so a map with no room for an arena plays exactly the vanilla @@ -189,6 +274,10 @@ function OverworldBattle.begin(state, battle) state.player.surfing) if not (ok and arena) then return false end + -- the fight is staged from here on, so the layout it is composed for is not + -- optional any more (see forceOG) + OverworldBattle.forceOG() + session = { state = state, arena = arena, battle = battle, shot = nil, armed = false, token = 0 } cullCast(state) @@ -276,11 +365,13 @@ function OverworldBattle.update(dt) -- tries again. Rethrowing would hand the whole voxel mode to Pipelines' -- guard, which retires a pipeline for the session. session.shot = nil + session.snapped = false session.broken = true V.mod.log:warn("overworld battle scene failed: %s -- this battle draws " .. "on the plain battle background", tostring(shot)) return end + session.snapped = false if shot and shot.canvas then -- the depth of field is measured off the two marks: the slab in focus is -- the one the mons are standing in, at whatever the drift has done to @@ -295,6 +386,20 @@ function OverworldBattle.update(dt) -- so a panel over a blurred far field is frosted from what is actually -- behind it pcall(BattleHud.build, shot.canvas) + -- and then the HUDs go ON that backdrop, snapped out to the window's own + -- edges (snapHUDs). Here rather than in the battle's draw for the same + -- reason the scene is: it binds a canvas of its own. After the frost, so + -- the glass is frosted from the world alone and never from the glyphs + -- about to sit on it. + local okHud, up = pcall(OverworldBattle.snapHUDs, session.battle, shot) + session.snapped = (okHud and up) and true or false + -- once per battle, not once per frame: a driver that cannot do this cannot + -- do it sixty times a second either, and the fallback is silent and fine + if not okHud and not session.hudWarned then + session.hudWarned = true + V.mod.log:warn("overworld battle HUD snap failed: %s -- the HUDs draw " + .. "in the battle frame this battle", tostring(up)) + end end session.shot = shot end @@ -394,6 +499,7 @@ local texturing = nil local texCanvas = {} local innerPics = nil -- captured by install() +local innerHUDs = nil -- likewise, for the snapped HUD layer local function texCanvasFor(side) local c = texCanvas[side] @@ -695,8 +801,12 @@ function OverworldBattle.install() -- -- The HP bar is untouched: it is drawn in its own greens and reds, and -- only an exactly-black set is remapped. - local innerHUDs = BattleState.drawHUDs + innerHUDs = BattleState.drawHUDs function BattleState:drawHUDs(slide) + -- Normally the HUDs have already been drawn this frame, snapped out to the + -- window's edges and composited into the world image (snapHUDs). Drawing + -- them here as well would show each block twice, once in each place. + if self.dramaticShapeShot and snapped() then return end if not (self.dramaticShapeShot and self.dramaticShapeDark) then return innerHUDs(self, slide) end @@ -726,12 +836,91 @@ function OverworldBattle.hudLive(battle, slide) return enemy and true or false, player and true or false end +-- ------- the snapped composite +-- +-- The engine's own HUD layer, rendered into a texture. +-- +-- One thing is falsified for the render, and it is falsified because this layer +-- never reaches the battle's zone pass -- it is composited into the world image, +-- outside the frame that pass covers. In the colorized pipeline drawHUDs leaves +-- the HP bar's fill as DMG gray for the zone pass to colour by region (#229); +-- answered false, it tints its own greens and reds instead, exactly as it does +-- on the flat path. The glyphs are pure black either way, which is what the +-- flip in BattleHud.layerTexture is measured against. +-- +-- Shadowed on the instance for this call only, the way drawZonePass shadows +-- activeBgp: putting the field back to whatever it was (normally nil) lets the +-- class method be found again. +function OverworldBattle.hudTexture(battle, slide, dark) + if not (innerHUDs and battle) then return nil end + local had = rawget(battle, "colorMode") + battle.colorMode = function() return false end + local ok, layer = pcall(BattleHud.layerTexture, + BattleScene.GB_W, BattleScene.GB_H, dark, + function() innerHUDs(battle, slide) end) + battle.colorMode = had + return ok and layer or nil +end + +-- Draw both HUD blocks into the world image at the window's edges, each on its +-- own frosted panel. Returns true when the frame's HUDs are up there and the +-- in-frame draw must be skipped; false leaves the battle screen's own HUD +-- exactly as it was before any of this existed. +-- +-- Both bands are blitted whether or not that side's HUD is LIVE, because a band +-- carries more than the HUD: the pokeball rows of the intro and of an enemy +-- faint, and the safari ball count, all draw in these rows and belong at the +-- same edge as the block they share it with. The panels are the ones that +-- follow hudLive -- frosted glass under nothing is a slab floating in the arena. +function OverworldBattle.snapHUDs(battle, shot) + if not (battle and shot and shot.canvas and (shot.scale or 0) > 0) then + return false + end + local slide = (battle.introSlide or 0) * 4 + local rects, bandX = OverworldBattle.snapRects(shot) + local enemy, player = OverworldBattle.hudLive(battle, slide) + local live = {} + if enemy then live.enemy = rects.enemy end + if player then live.player = rects.player end + -- measured under the SNAPPED rects: the panels are over whatever the world + -- shows at the window's edges now, which is not what was behind them in the + -- middle of the frame + local dark = BattleHud.verdict(live, shot, true) + local layer = OverworldBattle.hudTexture(battle, slide, dark) + if not layer then return false end + + local g = love.graphics + local prevCanvas = g.getCanvas() + local prevBlend, prevAlpha = g.getBlendMode() + local ok, err = pcall(function() + g.setCanvas(shot.canvas) + g.setBlendMode("alpha") + for _, rect in pairs(live) do BattleHud.panel(rect, shot, dark, true) end + g.setColor(1, 1, 1, 1) + for side, band in pairs(OverworldBattle.HUD_BAND) do + local quad = g.newQuad(band[1], band[2], band[3], band[4], + BattleScene.GB_W, BattleScene.GB_H) + g.draw(layer, quad, bandX[side] + band[1] * shot.scale, + shot.ly + band[2] * shot.scale, 0, shot.scale, shot.scale) + end + end) + if prevCanvas then g.setCanvas(prevCanvas) else g.setCanvas() end + g.setBlendMode(prevBlend or "alpha", prevAlpha) + g.setColor(1, 1, 1, 1) + if not ok then error(err, 0) end + return true +end + -- Lay the frosted glass down under whichever HUD is about to draw, and -- record which way the glyphs have to flip. +-- +-- The fallback path only: with the HUDs snapped out to the window's edges their +-- panels went with them, and there is nothing left inside the GB frame to lay +-- glass under. function OverworldBattle.drawHudPanels(battle) local shot = battle.dramaticShapeShot battle.dramaticShapeDark = nil - if not shot then return end + if not shot or snapped() then return end local slide = (battle.introSlide or 0) * 4 local enemy, player = OverworldBattle.hudLive(battle, slide) if not (enemy or player) then return end diff --git a/lib/ShadowMap.lua b/lib/ShadowMap.lua index d1b43cd..4606d5a 100644 --- a/lib/ShadowMap.lua +++ b/lib/ShadowMap.lua @@ -77,7 +77,44 @@ ShadowMap.HEIGHT = 160 -- surface shadows itself in a moire of acne; too much and a shadow detaches -- from the foot of what casts it. The frustum is ~400 world pixels deep and -- the packed depth resolves under 0.01 of one, so there is room. -ShadowMap.BIAS = 1.0 +-- +-- It cannot be ONE number, because what the comparison has to forgive is +-- not fixed: the map stores one depth for a whole texel, so a lit surface +-- reads its own depth wrong by however far it RAMPS across that texel -- +-- the texel's world size times the surface's slope in the light's frame. +-- The texel swings from a third of a world pixel at the closest zoom to +-- well over one at a maximised window on the widest, so a constant bias is +-- generous at one end of the ladder and short at the other. Short shows up +-- as diagonal bands of acne across big lit surfaces -- diagonal because +-- the moire runs along neither the world grid nor the screen's, but along +-- the depth ramp in the sun's own frame, and the sun sits southeast. +-- +-- So: a floor for what does not scale (the packed depth's quantisation, +-- and the two passes reaching the same world point by different matrices), +-- plus a term in texels for what does. +ShadowMap.BIAS = 0.5 + +-- World pixels of slack per world pixel of texel, for the steepest LIT +-- surface here: a roof pitched 45 degrees and turned away from the sun, +-- whose depth ramps about 3.1 world pixels per texel crossed on EITHER of +-- the light frame's two axes (a vertical wall, by comparison, manages 1.7, +-- flat ground 0.7, and anything steeper than that roof has its back to the +-- sun and never reads the map at all). The 2x2 filter's taps sit half a +-- texel out on both axes at once, so the worst a tap can disagree by is +-- half the ramp along each -- which is where the halving that turns 6.2 +-- into 3.1 comes from, and why it is the SUM of the two components rather +-- than their magnitude. +-- +-- Measured against the artefact rather than trusted: the probe +-- (tests/voxel_acne_probe.lua) counts isolated shadowed pixels on lit +-- surfaces, and the banding stops at slack ~2.4 world px on the widest +-- rung -- where this lands 3.1 * 0.83 + 0.5. +ShadowMap.SLOPE = 3.1 + +-- The slack `fit` last worked out, in world pixels -- BIAS + SLOPE*texel. +-- Read by probes; `ShadowMap.bias` is the same number as the [0,1] depth +-- the map actually stores. +ShadowMap.slack = ShadowMap.BIAS local SHADER = [[ varying float vDepth; @@ -316,9 +353,50 @@ local function fit(cx, cy, vw, vh) -- what the frustum ended up covering, for probes: the lateral extent in -- world pixels divided by RES is how fine a shadow edge can land ShadowMap.extent = { r - l, t - b, far - near } + -- the slack the comparison needs, against the coarser of the two texel + -- axes (the box is asymmetric, and one number has to cover both) + ShadowMap.slack = ShadowMap.BIAS + + ShadowMap.SLOPE * math.max(w, h) / res -- the stored depth spans the frustum, so a world-pixel bias is that -- fraction of it - ShadowMap.bias = ShadowMap.BIAS / math.max(1, far - near) + ShadowMap.bias = ShadowMap.slack / math.max(1, far - near) +end + +-- How much of the compare's forgiveness a snugged caster takes back, 0..1. +-- Short of 1 on purpose: at exactly 1 the card's own fragments compare +-- against their own stored depth on a float-equality knife edge and can +-- speckle. The tenth left over is dozens of times the packed depth's +-- quantization -- ample for that -- and leaves the contact gap around a +-- quarter of a world pixel at any sun, which no zoom resolves. +ShadowMap.SNUG = 0.9 + +-- A CASTER snugged up the sun ray -- moved TOWARD the light -- before it is +-- drawn into the map. +-- +-- The depth compare forgives `slack` world pixels (BIAS + the SLOPE term) +-- so lit surfaces do not acne against their own texels -- but that same +-- forgiveness is what lets the ground right next to a standing figure read +-- as lit: a receiver within `slack` of its blocker along the ray passes the +-- test, so the first stretch of every shadow is forgiven away and on screen +-- it starts that far from the feet, further the lower the sun. The classic +-- peter-panning; unseen while the sun hung at a fixed 45 degrees, plain at +-- a day/night golden hour or under the moon. +-- +-- Moving the card ALONG ITS OWN RAY changes nothing about where its shadow +-- falls -- every point stays on the same light ray -- but moving it toward +-- the sun stores it SHALLOWER, so a ground point right at the foot is +-- already `slack` deeper than the stored blocker and fails the lit test: +-- the root lands back under the feet. Nothing else is touched -- no +-- terrain moved, so the acne margin the slack exists for is intact where +-- it matters. For sprite cards and other thin stand-ins only. +-- +-- Valid between begin() and finish(), which is when `slack` matches the +-- frustum the map is being drawn with. +function ShadowMap.snug(model) + local f = sunDir() + local s = -ShadowMap.slack * ShadowMap.SNUG + return Mat4.mul(Mat4.translate(f[1] * s, f[2] * s, f[3] * s), + model or IDENTITY) end -- Whether the map has to be redrawn for `sig` -- a caller-built stamp of diff --git a/lib/Sky.lua b/lib/Sky.lua new file mode 100644 index 0000000..221e386 --- /dev/null +++ b/lib/Sky.lua @@ -0,0 +1,407 @@ +-- The sky, generated rather than shipped. +-- +-- The overworld's, on every VOXEL rung. Wherever the diorama is drawn the void +-- behind it is sky rather than a black plate: at 75 degrees the horizon is +-- genuinely in frame and the bands run down to meet it, and at the steeper rungs +-- the void that shows is the ground running out past the map edge, which gets +-- the same sky above the same haze. A battle's placed camera keeps the flat fill +-- it has always had -- its horizon is above the frame and its look is not this +-- rung's to change. +-- +-- THE RECIPE is the 8-bit skybox one: a short palette of blues painted as flat +-- horizontal bands, deepest overhead, with a CHECKERBOARD of the next band +-- dithered into the bottom of each one. Alternating two colours on a pixel grid +-- is how a machine with four colours to a palette got a fifth, sixth and seventh +-- out of them, and it is what keeps four bands reading as a gradient rather than +-- as four stripes. No clouds, nothing moving. +-- +-- NOTHING IS RESAMPLED, which is the whole of why it is drawn this way. There is +-- no baked 160x144 picture scaled up to the window, no downsized buffer blown +-- back up, no texture of any kind: one full-region rectangle through a shader +-- that answers every pixel from its own canvas coordinate. A pixel of sky is +-- computed at the size it is displayed at, so there is nothing for a filter to +-- soften and nothing to go stale when the window or the zoom changes. +-- +-- THE PIXEL GRID follows the zoom for the same reason. Bands and dither cells +-- are measured in DIORAMA pixels -- the pass's own pixels-per-world-pixel, handed +-- in fresh every frame -- so a chunky sky at 4x is a chunky sky at 12x, band +-- edges land on the same grid the world's own texels do, and a ZOOM keypress is +-- reflected in the frame that follows it rather than whenever something else +-- happened to rebuild. +-- +-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that +-- is shade order: a display mode transforms a four-colour palette by replacing it +-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are +-- written light to dark. So the sky reads the list backwards -- deepest shade +-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for +-- nothing. +-- +-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight +-- (four phase palettes, blended along the clock and re-quantised to the +-- lattice), and this file paints whatever the clock says: blue at noon, gold +-- and violet through the twilights -- warmed further around the low sun by a +-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves +-- hang here too: cell-art discs on the same grid as the dither, scissored to +-- the sky's own region so a setting body slips below the horizon point and is +-- gone, never wandering under the map. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local DayNight = V.require("DayNight") +local PaletteFX = require("src.render.PaletteFX") + +local unpack = table.unpack or unpack + +local Sky = {} + +-- The shader carries a fixed-size array, because a GLSL uniform array is a +-- fixed size; eight leaves headroom over DayNight's six-band phase palettes +-- without paying for more. +Sky.MAX_BANDS = 8 + +-- The checkerboard between bands. DITHER_START is how far down a band it begins, +-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6 +-- leaves the top of each band flat -- a band dithered all the way through reads +-- as one averaged colour instead of as a step with a soft bottom edge. +Sky.DITHER = true +Sky.DITHER_START = 0.6 + +-- How much of the frame the bands cover when the horizon is NOT in it, as a +-- fraction of the canvas height. +-- +-- At the steeper rungs the camera looks down far enough that the ground plane's +-- vanishing line is above the top edge -- there is no horizon to hang the pale +-- end on, but there is still void up there where the map runs out, and it should +-- read as sky. So the bands take the same slice of the frame the top rung's own +-- horizon gives them, which keeps the sky looking like one sky across the whole +-- ladder instead of changing character rung by rung. +Sky.SPAN = 0.23 + +-- ------- the bands +-- +-- Top first, each a { r, g, b } in 0..1, as the display mode has them. +-- +-- Memoised, because this runs once a frame and the answer only moves when the +-- mode does. +local cache = { bands = nil, key = {} } + +function Sky.bands() + local pal = DayNight.palette() + local shades = PaletteFX.effectiveColors(pal) or pal + local n = math.min(#shades, #pal, Sky.MAX_BANDS) + local key, k = cache.key, 0 + local same = cache.bands ~= nil and #cache.bands == n + for i = 1, n do + local c = shades[i] + for ch = 1, 3 do + k = k + 1 + if key[k] ~= c[ch] then same = false end + key[k] = c[ch] + end + end + if same then return cache.bands end + + local bands = {} + for i = 1, n do + -- backwards: the palette's darkest rung is the top band + local c = shades[n - i + 1] + bands[i] = { c[1] / 255, c[2] / 255, c[3] / 255 } + end + cache.bands = bands + return bands +end + +-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's +-- bottom edge and the right flat fill for any outdoor void that wants to +-- match the clock without painting bands (the battle arena's backdrop). +function Sky.haze() + local bands = Sky.bands() + return bands and bands[#bands] or nil +end + +-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill +-- replaced by the palest of them. That fill is what the caller CLEARS to, so +-- making it the bottom band's own colour means the haze below the sky and the +-- bottom of the sky are one colour -- the join has no seam, and a frame that +-- cannot paint the bands is a hazy sky rather than a wrong one. +-- +-- Mutates the descriptor, which is a fresh table per frame from its caller. +function Sky.dress(sky) + local bands = Sky.bands() + local haze = bands and bands[#bands] + if not (sky and haze) then return sky end + sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] + sky.bands = bands + return sky +end + +-- Where the sky's bottom edge goes, in canvas pixels: the camera's own horizon +-- when that is in frame, and SPAN of the frame when it is not (see SPAN). nil +-- when there is no room for any of it. +function Sky.region(h, horizonY) + if not (h and h > 0) then return nil end + local edge = horizonY + if not (edge and edge > 0) then edge = h * Sky.SPAN end + edge = math.min(edge, h) + if edge < 1 then return nil end + return edge +end + +-- ------- the pass +-- +-- One rectangle, one shader, no texture. Every pixel answers for itself from its +-- canvas coordinate, so the sky is drawn at exactly the resolution it is +-- displayed at -- there is no image being scaled and so nothing to be soft. +-- +-- `cell` quantises BOTH the band edges and the dither: the y a pixel is judged +-- by is the top of its own cell row, so a whole cell row is one colour and every +-- edge in the sky lands on the diorama's pixel grid. +local SHADER_SRC = [[ +#define MAXB %d +uniform vec3 bands[MAXB]; +uniform int count; +uniform float edge; // the sky's bottom, in canvas pixels +uniform float cell; // the diorama's pixel size, in canvas pixels +uniform float start; // where the checker begins inside a band +uniform float alpha; +uniform float glowAmt; // twilight warmth around the low sun; 0 = none +uniform vec2 glowPos; // the sun disc, in canvas pixels +uniform float glowInvR; // 1 / the glow's reach +uniform vec3 glowColor; + +// Indexed through a loop counter, which every GLSL ES compiler accepts for a +// uniform array; a bare bands[idx] is not portable. +vec3 bandAt(int idx) { + vec3 c = bands[0]; + for (int i = 1; i < MAXB; i++) { + if (i == idx) { c = bands[i]; } + } + return c; +} + +vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { + float n = float(count); + float row = floor(sc.y / cell) * cell; // top of this cell row + float pos = clamp(row / max(edge, 1.0), 0.0, 0.999999) * n; + float base = floor(pos); + int idx = int(base); + vec3 c = bandAt(idx); + float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0); + if (idx < count - 1 && (pos - base) > start) { + if (parity < 0.5) { c = bandAt(idx + 1); } + } + // The sunset's warmth, radiating from the disc: posterised to a few rungs + // and checker-dithered between them -- the same 8-bit move as the bands, + // so the glow reads as painted light rather than as a smooth airbrush -- + // and measured cell-to-cell, so its rings ride the diorama's own grid. + if (glowAmt > 0.0) { + vec2 cc = (floor(sc / cell) + 0.5) * cell; + float d = length(cc - glowPos) * glowInvR; + float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0); + float lvl = floor(g * 4.0); + if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; } + c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65); + } + return vec4(c, alpha); +} +]] + +local shader = nil -- nil = untried, false = unavailable + +local function getShader() + if shader == nil then + shader = false + if love.graphics and love.graphics.newShader then + local ok, sh = pcall(love.graphics.newShader, + SHADER_SRC:format(Sky.MAX_BANDS)) + if ok and sh then + shader = sh + elseif V and V.mod and V.mod.log then + -- once, and only where it can be read: the fallback below is a sky + -- without its dither, which is easy to look at and impossible to + -- diagnose without this line + V.mod.log:warn("sky shader did not compile: %s -- the bands draw flat, " + .. "with no dither between them", tostring(sh)) + end + end + end + return shader or nil +end + +Sky._getShader = getShader -- named for the suite + +-- The flat fallback: the same bands as solid rectangles, no checker, on the same +-- quantised edges. For a driver that could not compile the shader -- which is +-- also every headless run. +local function paintFlat(w, h, bands, edge, alpha, cell) + local g = love.graphics + local n = #bands + local prev = 0 + for i = 1, n do + local cut = (i == n) and math.min(h, math.ceil(edge)) + or math.floor(i / n * edge / cell + 0.5) * cell + cut = math.max(prev, math.min(cut, math.min(h, math.ceil(edge)))) + if cut > prev then + local c = bands[i] + g.setColor(c[1], c[2], c[3], alpha) + g.rectangle("fill", 0, prev, w, cut - prev) + end + prev = cut + end +end + +-- ------- the discs +-- +-- The sun and moon, as cell art: a circle of whole diorama cells with a +-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater +-- cells. Drawn as plain rectangles on the same grid as the sky's own dither, +-- through the same display-mode transform as every palette here, and +-- SCISSORED to the sky's region: the horizon point is where a setting body +-- disappears, so it can never hang under the map at a high pitch. +-- +-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is +-- an angle, so zooming the ground in and out must not swell and shrink the +-- sun with it. The radius is a fraction of the frame height, converted to +-- whole cells so the disc still sits on the diorama's grid -- chunky cells +-- up close, fine ones at survey zoom, the same size body either way. +Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height +Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius + +-- crater centres as fractions of the radius, so they ride any disc size +local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 }, + { -0.15, 0.7 }, { 0.05, 0.05 } } + +local function paintDisc(body, edge, cell, w, h) + local g = love.graphics + if not (body and body.y and g.setScissor) then return end + local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS + local shades = PaletteFX.effectiveColors(src) or src + local twilight = (body.glowAmt or 0) > 0.25 and not body.moon + local r = math.max(Sky.DISC_MIN, + math.floor(h * Sky.DISC_FRAC / cell + 0.5)) + -- the low sun looms: the classic sunset exaggeration, and it reads + if twilight then r = r + math.max(1, math.floor(r * 0.4)) end + -- snap the centre to the cell grid, like everything else in this sky + local bx = math.floor(body.x / cell) * cell + cell / 2 + local by = math.floor(body.y / cell) * cell + cell / 2 + if by - r * cell > edge then return end -- wholly below the horizon point + local core = shades[1] + local main = shades[twilight and 3 or 2] + local sx, sy, sw, sh = g.getScissor() + g.setScissor(0, 0, math.ceil(w), math.floor(edge)) + local craterR = math.max(1, math.floor(r / 5)) + for dy = -r, r do + for dx = -r, r do + local d = math.sqrt(dx * dx + dy * dy) + if d <= r + 0.1 then + local c = d <= r * 0.5 and core or main + -- dithered rim: the outer ring keeps only one parity of its cells + local keep = d <= r - 0.9 or (dx + dy) % 2 == 0 + if body.moon then + for _, cr in ipairs(MOON_CRATERS) do + local cdx = dx - math.floor(cr[1] * r + 0.5) + local cdy = dy - math.floor(cr[2] * r + 0.5) + if cdx * cdx + cdy * cdy <= craterR * craterR then + c = shades[3] + end + end + end + if keep then + g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1) + g.rectangle("fill", bx + dx * cell - cell / 2, + by + dy * cell - cell / 2, cell, cell) + end + end + end + end + if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end + g.setColor(1, 1, 1, 1) +end + +-- Paint the sky into the bound canvas, filling it from the top edge down to +-- `horizonY` (or to SPAN of the frame when the horizon is out of it). +-- +-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own +-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately. +-- +-- `body` is the sun or moon to hang, already projected to canvas pixels by +-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding +-- along; nil hangs nothing and warms nothing. +-- +-- Returns false when there is nothing to paint, in which case the caller's flat +-- fill is the whole sky. That fill is the palest band, so a frame that declines +-- this looks like a hazy day rather than like a bug. +function Sky.paint(w, h, sky, horizonY, cell, body) + local bands = sky and sky.bands + if not (bands and bands[1]) then return false end + if not (w and h and w > 0 and h > 0) then return false end + local g = love.graphics + if not (g and g.rectangle) then return false end + local edge = Sky.region(h, horizonY) + if not edge then return false end + local alpha = sky[4] or 1 + cell = math.max(1, math.floor((cell or 1) + 0.5)) + + -- State to put aside. The scene's shader is one, and the blend mode another -- + -- a pass that left "replace" behind would make the fade-in strength meaningless + -- -- but the DEPTH MODE is the one that would break the frame: a rectangle + -- drawn under the pass's own ("lequal", true) stamps itself across the depth + -- buffer at the near plane and hides the entire world behind the sky. + local prevShader = g.getShader and g.getShader() or nil + local cmp, write + if g.getDepthMode then cmp, write = g.getDepthMode() end + if g.setDepthMode then g.setDepthMode("always", false) end + local blend, blendAlpha + if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end + if g.setBlendMode then g.setBlendMode("alpha") end + + local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0 + local sh = getShader() + if sh then + local sent = pcall(function() + -- one send per band would be one uniform lookup per band; the array takes + -- them all at once, and it must be the LAST argument or Lua truncates the + -- unpack to a single value + sh:send("bands", unpack(bands)) + sh:send("count", #bands) + sh:send("edge", edge) + sh:send("cell", cell) + sh:send("start", Sky.DITHER and Sky.DITHER_START or 2) + sh:send("alpha", alpha) + sh:send("glowAmt", glowAmt) + if glowAmt > 0 then + local gc = body.glowColor or { 248, 224, 168 } + sh:send("glowPos", { body.x, body.y }) + sh:send("glowInvR", 1 / math.max(1, w * 0.55)) + sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 }) + end + end) + if sent then + g.setShader(sh) + g.setColor(1, 1, 1, 1) + g.rectangle("fill", 0, 0, w, math.min(h, math.ceil(edge))) + g.setShader() + else + sh = nil + end + end + if not sh then paintFlat(w, h, bands, edge, alpha, cell) end + -- the disc goes over the glow, under nothing: plain rectangles, so it is + -- there whether or not the shader built + paintDisc(body, math.min(h, edge), cell, w, h) + g.setColor(1, 1, 1, 1) + + if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end + if g.setDepthMode then g.setDepthMode(cmp or "always", write or false) end + if prevShader and g.setShader then g.setShader(prevShader) end + return true +end + +-- Drop the compiled shader (window resize, hot reload), so a re-created graphics +-- context builds a new one instead of drawing with a handle from the old. +function Sky.invalidate() + shader = nil +end + +return Sky diff --git a/lib/Structures.lua b/lib/Structures.lua index 84c1ce4..0b5262a 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -55,6 +55,14 @@ local Structures = {} -- must match ChunkMesher's ring (3 border blocks, in tiles) local RING = 12 +-- how far past the map body cells still get the hull. A route's ring is +-- nearly as big as its body; modelling all of it costs hundreds of +-- thousands of quads of border trees nobody walks near. Beyond this, +-- pinned cells simply are not claimed and fall through to the mesher's +-- plain box -- cheap distant scenery. (Declared up here rather than +-- beside buildCylinders because forMap's grid resolve reads it too.) +local ROUND_RING = 4 + -- object-mode gates local OBJECT_MAX_ROWS = 6 -- a prop is at most 48px of drawing local OBJECT_MAX_QUADS = 4096 -- safety cap per cluster @@ -154,12 +162,34 @@ function Structures.forMap(map) local TileRenderer = require("src.render.TileRenderer") local borderId = TileRenderer.borderBlockFor(map) local borderBlk = borderId and tileset.blocks[borderId + 1] or nil + -- TREES fill stops at ROUND_RING instead of running the full RING. + -- Only that far out does a tree cell get carved into a hull; past it + -- the cells fall through to the mesher's plain box, and a slab of + -- flat-topped boxes beside the modelled wall reads as a painted-on + -- plateau -- the wall looking like it was cut off with scissors. So + -- the far ring is simply not built: beyond ROUND_RING tileLookup + -- answers nil, which is the same "nothing out there" BLACK already + -- produces and every pass below already copes with. The cut lands on + -- the carve boundary exactly -- the 2x2-cell canopy scan starts at + -- floor(-RING/2) and RING, ROUND_RING and the body are all multiples + -- of 4 tiles, so no group is left half-resolved at the edge. + -- + -- WATER and the other tilesets' own borders keep the full ring: a flat + -- sheet of water is what water looks like from above anyway, and an + -- interior's border is black already. + local hullRingOnly = borderBlk and def.tileset == "OVERWORLD" + and (TileRenderer.voidFill or "trees") == "trees" local tw2, th2 = tw, th local function tileLookup(tx, ty) if tx >= 0 and ty >= 0 and tx < tw2 and ty < th2 then return map:tileAt(tx, ty) end if not borderBlk then return nil end + if hullRingOnly and (tx < -ROUND_RING or ty < -ROUND_RING + or tx >= tw2 + ROUND_RING + or ty >= th2 + ROUND_RING) then + return nil + end return borderBlk[(ty % 4) * 4 + (tx % 4) + 1] or 0 end local shapeAt, tileAt = {}, {} @@ -192,8 +222,9 @@ function Structures.forMap(map) -- still overdraws a walker's feet even though characters stamp over -- terrain.) S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def), + hideBareRing = hullRingOnly or nil, runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {}, - grassQuads = {}, flowerQuads = {}, roundStamps = {} } + grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} } Buildings.build(S, map, pixels(tileset), perRow) -- Fold doors into their buildings. A door cell is WALKABLE (the player @@ -268,6 +299,14 @@ function Structures.forMap(map) -- ---- bookcases: pinned shelves collapsed to one cell of depth ---- Structures.buildBookcases(S, map, x0, x1, y0, y1) + -- ---- figures: a person drawn INTO furniture, lifted off it ---- + -- Before the region flood and the volume pass, so everything after this + -- reads the tiles the profile says are there once the figure is gone. + -- (Its own tiles are authored furniture or walkable floor either way, so + -- no pass below would have claimed them -- but the repaint is what those + -- passes should see, and this needs no pixel access to do it.) + Structures.buildFigures(S, map, x0, x1, y0, y1) + -- ---- flood-fill regions of structural tiles ---- local seen = {} local regions = {} @@ -880,13 +919,6 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) return quads, bg end --- how far past the map body cells still get the hull. A route's ring is --- nearly as big as its body; modelling all of it costs hundreds of --- thousands of quads of border trees nobody walks near. Beyond this, --- pinned cells simply are not claimed and fall through to the mesher's --- plain box -- cheap distant scenery. -local ROUND_RING = 4 - -- Hull templates dedupe GLOBALLY per (tileset, four tiles, ground set): -- the same four-tile tree repeats for hundreds of cells on a map and -- across every route of its tileset, so the carve runs once per distinct @@ -1214,6 +1246,9 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) end function Structures.buildBookcases(S, map, x0, x1, y0, y1) + -- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill). + -- Read once: it is a property of the tileset, not of the column. + local backfill = TileShape.bookcaseBackfill(map.tileset.id) for tx = x0, x1 do local ty = y1 while ty >= y0 do @@ -1241,10 +1276,24 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1) capTile = S.tileAt[ck] end end + -- The box is one cell deep, so it covers only the run's southmost + -- rows; everything north of that is vacated. By default a vacated + -- row is skipped and painted with synthesized ground -- right for a + -- shelf standing in a room. `bookcase_backfill = "above"` hands it + -- the cell above the run instead, shape and art, so a wall cut into + -- a terrace has more terrace behind it rather than a trench. + local covered = math.min(2, front - top + 1) + local srcK = keyOf(tx, top - 1) + local src = backfill == "above" and S.shapeAt[srcK] or nil for cy = top, front do local tk = keyOf(tx, cy) - S.skip[tk] = true - S.ground[tk] = false + if src and cy <= front - covered then + S.shapeAt[tk] = src + S.tileAt[tk] = S.tileAt[srcK] + else + S.skip[tk] = true + S.ground[tk] = false + end end bookcaseRank(S, map, tx, top, front, capTile) front = top - 1 @@ -1700,18 +1749,44 @@ function Structures.extractObjects(S, map, region, data, perRow, force) local fs = S.shapeAt[keyOf(region.tiles[1][1], region.tiles[1][2])] strict = fs ~= nil and fs.class == "cutout" end + -- The rim vote reads the shades on the DRAWING'S OWN bounding box, so a + -- prop whose body reaches its own edge votes itself out. The Center's + -- potted plants are the case: the pot's olive base is drawn flush on the + -- bottom row of the block, so "dark" came back as background and every + -- dark pixel in the whole plant drained with it -- the pots rendered as + -- hollow black frames while the 2D art has solid olive bodies. + -- + -- Where the vote misreads the art, the profile can name the background + -- shades outright (a tileset entry's prop_bg). Keyed BY TILE rather than + -- per tileset, because the answer is per drawing: the healing consoles' + -- screens really do stand on a dark wall band and really do need dark + -- voted out, and the PC really does need light kept. local bg = {} - for iy = 0, H - 1 do - for ix = 0, W - 1 do - local px, py = ix - 1, iy - 1 - local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1 - local st = state[iy * W + ix] - if edge and (st == "dark" or st == "light" or st == "white") then - bg[st] = true + do + local named = TileShape.propBg(map.tileset.id) + if named then + for _, c in ipairs(region.tiles) do + local rule = named[S.tileAt[keyOf(c[1], c[2])]] + if rule then + for shadeName in pairs(rule) do bg[shadeName] = true end + break + end end end end - if not (bg.dark or bg.light or bg.white) then bg.white = true end + if not next(bg) then + for iy = 0, H - 1 do + for ix = 0, W - 1 do + local px, py = ix - 1, iy - 1 + local edge = px == 0 or px == bw - 1 or py == 0 or py == bh - 1 + local st = state[iy * W + ix] + if edge and (st == "dark" or st == "light" or st == "white") then + bg[st] = true + end + end + end + if not (bg.dark or bg.light or bg.white) then bg.white = true end + end for i, st in pairs(state) do if strict then if st == "dark" or st == "light" then @@ -1943,8 +2018,13 @@ function Structures.buildObject(S, map, region, cluster, local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)] local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2), math.floor(cluster.maxY / 2)) - if blocked and bs and bs.authored and bs.art == "upright" - and (bs.h or 0) > 0 then + -- `bookcase` supports as well as `upright`. A prop drawn above an + -- authored box stands ON it whatever art the box renders with, and a + -- stacked box is still a box: the Plateau's gate pilasters carry a + -- statue on 48 of their tops, and collapsing the pilaster to a stacked + -- run made every one of them fail this test and drop to ground level. + if blocked and bs and bs.authored and (bs.h or 0) > 0 + and (bs.art == "upright" or bs.art == "bookcase") then baseY, support = bs.h, bs end end @@ -2078,7 +2158,8 @@ function Structures.buildObject(S, map, region, cluster, end for _, c in ipairs(cluster.tiles) do local k = keyOf(c[1], c[2]) - if support and support.class == "wall" then + if support and (support.class == "wall" or support.class == "cliff" + or support.art == "bookcase") then -- a figure drawn above a FULL-HEIGHT block (the gym statue on its -- plinth) is a statue on a pillar with ONE cell of footprint: the -- block below already carries the whole base, so the drawn cell @@ -2086,6 +2167,13 @@ function Structures.buildObject(S, map, region, cluster, -- the base backwards. Furniture supports (a monitor on its desk) -- keep the box-extension below -- their drawn cell is the -- furniture's own upper rows, and floor there would amputate it. + -- + -- STRUCTURE, not height, decides which: `cliff` and `bookcase` are + -- full-height blocks like `wall` and belong here, while `desk` is + -- 24px and still furniture. The Plateau's statues on stacked + -- pilasters found this -- taking the furniture branch turned each + -- statue's own two rows into a 32px box wearing the pilaster's art, + -- so every one of them stood inside a slab of its own plinth. S.skip[k] = true S.ground[k] = best elseif support then @@ -2116,6 +2204,136 @@ function Structures.buildObject(S, map, region, cluster, return true end +-- ---- figures: a person drawn INTO furniture, cut out and stood up ---- + +-- One authored figure at one matched position. +-- +-- The mask IS the classification: no flood, no shade segmentation, no +-- validation gate. Every automatic route in this file asks the art where +-- the object ends, and a figure painted into its own furniture has no +-- answer to give -- so the profile answers instead, and this only has to +-- believe it. Which also means figures build HEADLESS: unlike every +-- other standee here, nothing below reads a pixel. +-- +-- A figure is a SPRITE, not a prop. It gets exactly the treatment +-- SpriteBillboards gives a character: one flat plane of the drawing's own +-- pixels, no thickness, standing at its feet and leaned back by the +-- camera's pitch at draw time so it always reads face-on -- because that +-- is what the artwork is. A seated man drawn face-on is a 2D icon like +-- every other Gen 1 figure; extruding him into a slab reconstructs a body +-- nobody drew (the ten-voxel version read as a wedge of furniture, and +-- even one voxel showed an edge the sprites never show). +-- +-- So the quads are emitted in the card's OWN LOCAL SPACE -- x from the +-- mask's west edge, y from his feet, all at z = 0 -- and the placement +-- (`wx`, `wz`, `y`) rides along for VoxelScene to build the lean matrix +-- from. One quad per pixel rather than one alpha-keyed texture: the +-- tileset atlas has no alpha to key on, and per-pixel quads cut the exact +-- same silhouette straight out of the live atlas, so every palette bake +-- (SGB, RED++ per-tile groups, a mod's own art) textures him for free. +local function buildFigure(S, map, fig, tx, ty, perRow) + local bw, bh = fig.w * 8, fig.h * 8 + + local function at(lx, ly) + if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end + return fig.mask[ly * bw + lx] or false + end + + -- his feet and his west edge: the card's own origin + local lowY, minX = 0, bw - 1 + for ly = 0, bh - 1 do + for lx = 0, bw - 1 do + if at(lx, ly) then + if ly > lowY then lowY = ly end + if lx < minX then minX = lx end + end + end + end + + -- He stands ON the furniture he was drawn into -- the same lift a pinned + -- prop above a pinned box takes (see buildObject), and gated the same + -- way: a thing set down on furniture occupies a BLOCKED cell, while a + -- seat you merely walk up to is in a walkable one. + local baseY = 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 + end + + local atlasW = map.tileset.imageWidth or 128 + local atlasH = map.tileset.imageHeight or 48 + local quads = {} + for ly = 0, bh - 1 do + Budget.tick() + for lx = 0, bw - 1 do + if at(lx, ly) then + local tile = fig.tiles[math.floor(ly / 8) * fig.w + + math.floor(lx / 8) + 1] + local u = ((tile % perRow) * 8 + lx % 8 + 0.5) / atlasW + local v = (math.floor(tile / perRow) * 8 + ly % 8 + 0.5) / atlasH + local x, y = lx - minX, lowY - ly + quads[#quads + 1] = { { x, y, 0 }, { x + 1, y, 0 }, + { x + 1, y + 1, 0 }, { x, y + 1, 0 }, + u = u, v = v, shade = 1 } + end + end + end + + -- Where the card stands. `wz` is the MIDDLE of the tile row his feet are + -- drawn in, which is the same convention a character card uses (its feet + -- plane sits at its cell's middle) -- so he sorts against the couch and + -- against a player walking past exactly the way an NPC standing there + -- would. + S.figures[#S.figures + 1] = { + quads = quads, + wx = tx * 8 + minX, + wz = ty * 8 + math.floor(lowY / 8) * 8 + 4, + y = baseY, + } + + -- What each covered tile wears now that he is off it. Only the ART + -- changes: the couch tiles keep their `counter` box (they ARE the + -- couch) and the floor tiles he overhung stay flat floor -- the + -- profile just names the version of each drawing without him in it, + -- so nothing has to be synthesized or repainted from a neighbour vote. + for i = 1, #fig.tiles do + local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w) + S.tileAt[keyOf(tx + dx, ty + dy)] = fig.under[i] + end +end + +-- Every authored figure, wherever the map draws it. +-- +-- Matched by TILE PATTERN rather than by coordinates: one blockset entry +-- places this couch once in each of the eleven Pokemon Centers (and the +-- Celadon Hotel), so the pattern finds all of them without the profile +-- naming a single map or cell. The repaint above replaces the pattern's +-- own tiles, so a match can never fire twice on the same drawing. +function Structures.buildFigures(S, map, x0, x1, y0, y1) + local figures = TileShape.figures(map.tileset.id) + if not figures then return end + local perRow = map.tileset.tilesPerRow or 16 + for _, fig in ipairs(figures) do + for ty = y0, y1 - fig.h + 1 do + for tx = x0, x1 - fig.w + 1 do + Budget.tick() + local hit = true + for i = 1, #fig.tiles do + local dx, dy = (i - 1) % fig.w, math.floor((i - 1) / fig.w) + if S.tileAt[keyOf(tx + dx, ty + dy)] ~= fig.tiles[i] then + hit = false + break + end + end + if hit then buildFigure(S, map, fig, tx, ty, perRow) end + end + end + end +end + -- ---- tall grass ---- -- A tall-grass CELL is four tufts: 2x2 tiles, and each 8x8 tile is one diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 4f0e9d5..6cb65ce 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -54,6 +54,12 @@ local FALLBACK_HEIGHTS = { sign = 12, wall = 16, tree = 16, + -- masonry drawn TWO courses tall: the Indigo Plateau's rim and the + -- badge-check gates down Route 23 are drawn 32px, the same height as a + -- statue on its plinth, and read as a step in the terrain rather than a + -- room's wall. Same fold as `wall`, twice the height -- and its own + -- class because `wall` is 16px for every interior in the game. + cliff = 32, roof = 28, cylinder = 16, -- big round scenery: a 2x2-CELL drawing carved as ONE 32px voxel hull @@ -110,6 +116,7 @@ local ART = { ledge = "top", roof = "top", wall = "upright", + cliff = "upright", tree = "upright", fence = "upright", sign = "upright", @@ -168,6 +175,8 @@ local ART = { local spec = nil -- the loaded data file, or false when absent local cache = {} -- tileset id -> resolved shape list +local figCache = {} -- tileset id -> parsed figure masks, or false +local bgCache = {} -- tileset id -> prop background shades, or false -- The shape profile ships with the mod (data/voxel_heights.lua) and is read -- through the mod's own file loader rather than package.path: a mod's @@ -224,29 +233,42 @@ end -- class = "..." } } }`, evaluated per POSITION in TileShape.at, where -- the map and coordinates are in hand. First match wins; no match keeps -- the tile's ordinary pin. +-- `when_below` is the mirror, and it exists because ABOVE is not always the +-- side that tells the two uses apart. The Plateau's $0D is the case: it is +-- the gate wall's top band AND the base course under a column of rock face, +-- and scanned over both maps the tile above is $03 for 64 of the first and +-- 140 of the second -- no rule on `above` can split them. What is BELOW +-- does, exactly: the wall's own face $0F sits under the top band and under +-- nothing else (336 vs 352, clean). local function authoredConditions(tilesetId, heights) local s = load() local entry = s and s.tilesets and s.tilesets[tilesetId] - local spec = entry and entry.when_above - if type(spec) ~= "table" then return nil end + if type(entry) ~= "table" then return nil end local out, any = {}, false - for tile, rules in pairs(spec) do - if type(tile) == "number" and type(rules) == "table" then - local list = {} - for _, rule in ipairs(rules) do - if type(rule) == "table" and heights[rule.class] - and type(rule.above) == "table" then - local set = {} - for _, t in ipairs(rule.above) do set[t] = true end - list[#list + 1] = { above = set, class = rule.class } + + local function collect(spec, side) + if type(spec) ~= "table" then return end + for tile, rules in pairs(spec) do + if type(tile) == "number" and type(rules) == "table" then + local list = out[tile] or {} + for _, rule in ipairs(rules) do + if type(rule) == "table" and heights[rule.class] + and type(rule[side]) == "table" then + local set = {} + for _, t in ipairs(rule[side]) do set[t] = true end + list[#list + 1] = { side = side, set = set, class = rule.class } + end + end + if #list > 0 then + out[tile] = list + any = true end - end - if #list > 0 then - out[tile] = list - any = true end end end + + collect(entry.when_above, "above") + collect(entry.when_below, "below") return any and out or nil end @@ -348,9 +370,12 @@ function TileShape.at(map, shapes, tile, tx, ty) -- tile and the cell rules below (see authoredConditions) local rules = shapes.cond and shapes.cond[tile] if rules then - local above = map:tileAt(tx, ty - 1) for _, rule in ipairs(rules) do - if above and rule.above[above] then + -- NOTE map:tileAt border-EXTENDS: one row off an edge answers the + -- map's borderBlock, never nil. A rule listing whatever that block + -- draws will fire along that whole edge (it did, on the Marts). + local n = map:tileAt(tx, rule.side == "above" and ty - 1 or ty + 1) + if n and rule.set[n] then -- shapes.condShape, NOT shapes.classes: the canonical class -- shapes are SHARED, and `wall` in particular is the very object -- rule 4 hands every unauthored solid tile. Marking that one @@ -369,11 +394,158 @@ function TileShape.at(map, shapes, tile, tx, ty) return s end +-- Hand-authored FIGURES for one tileset: a drawing painted INTO furniture, +-- cut out by an explicit pixel mask and stood up on top of it. +-- +-- Every other route in this file resolves a whole 8x8 TILE, which is +-- exactly why none of them can reach a figure that shares its tiles with +-- the thing it sits on -- and the detector's segmentation cannot either +-- when the drawing has no background margin to flood from and wears the +-- same shades as its furniture. So the profile authors the silhouette +-- pixel by pixel (see data/voxel_heights.lua): +-- +-- figures = { { w = , +-- tiles = { ...w*h tile ids, row-major... }, +-- under = { ...w*h ids: what each tile wears once the +-- figure is lifted off it... }, +-- pixels = { ...h*8 strings of w*8 chars, "." = not the +-- figure... } } } +-- +-- No class: a figure is always a flat sprite card, drawn the way +-- SpriteBillboards draws a character (see Structures.buildFigures). +-- +-- Returned normalized: `mask` as a set keyed by ly * (w * 8) + lx, so +-- Structures can read it as a bitmap without re-parsing per position. +-- A malformed entry is dropped rather than half-applied -- a typo in a +-- mask should leave the couch alone, not carve a hole in it. +function TileShape.figures(tilesetId) + local hit = figCache[tilesetId] + if hit ~= nil then return hit or nil end + + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local list = entry and entry.figures + local out = {} + if type(list) == "table" then + for _, f in ipairs(list) do + local ok = type(f) == "table" and type(f.w) == "number" + and type(f.tiles) == "table" and type(f.under) == "table" + and type(f.pixels) == "table" + local w = ok and math.floor(f.w) or 0 + local h = (w >= 1) and (#f.tiles / w) or 0 + ok = ok and w >= 1 and h >= 1 and h == math.floor(h) + and #f.under == #f.tiles and #f.pixels == h * 8 + if ok then + for i = 1, h * 8 do + local row = f.pixels[i] + if type(row) ~= "string" or #row ~= w * 8 then + ok = false + break + end + end + end + if ok then + local mask, n = {}, 0 + for ly = 0, h * 8 - 1 do + local row = f.pixels[ly + 1] + for lx = 0, w * 8 - 1 do + if row:sub(lx + 1, lx + 1) ~= "." then + mask[ly * (w * 8) + lx] = true + n = n + 1 + end + end + end + if n > 0 then + out[#out + 1] = { w = w, h = h, n = n, mask = mask, + tiles = f.tiles, under = f.under } + end + end + end + end + + figCache[tilesetId] = (#out > 0) and out or false + return figCache[tilesetId] or nil +end + +-- Which GB shades count as BACKGROUND for a pinned per-pixel prop, per tile +-- (a tileset entry's prop_bg). Returns tile id -> set of shade names, or nil. +-- +-- Structures normally votes on this by reading the shades that touch the +-- drawing's own bounding box, which is right whenever the drawing has a +-- margin of floor around it and wrong when it does not: a prop whose body +-- reaches its own edge votes itself out. Naming the shades is the override, +-- and it is keyed by TILE because the answer is per drawing rather than per +-- tileset -- two props in one atlas can want opposite calls on the same +-- shade (see the POKECENTER entry). +-- +-- prop_bg = { { tiles = { ...ids... }, shades = { "light", "white" } } } +-- +-- Only the four GB shade names exist; anything else is dropped, so a typo +-- degrades to the ordinary vote rather than emptying the background. +local SHADES = { black = true, dark = true, light = true, white = true } + +function TileShape.propBg(tilesetId) + local hit = bgCache[tilesetId] + if hit ~= nil then return hit or nil end + + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local list = entry and entry.prop_bg + local out, any = {}, false + if type(list) == "table" then + for _, rule in ipairs(list) do + if type(rule) == "table" and type(rule.tiles) == "table" + and type(rule.shades) == "table" then + local set, n = {}, 0 + for _, name in ipairs(rule.shades) do + if SHADES[name] then + set[name] = true + n = n + 1 + end + end + if n > 0 then + for _, t in ipairs(rule.tiles) do + if type(t) == "number" then + out[t] = set + any = true + end + end + end + end + end + end + + bgCache[tilesetId] = any and out or false + return bgCache[tilesetId] or nil +end + +-- What a bookcase rank does with the rows it VACATES -- the ones behind the +-- one-cell-deep box it collapses onto (a tileset entry's +-- bookcase_backfill). Returns the mode name, or nil for the default. +-- +-- "above" hand them the cell immediately above the run: its shape and +-- its art. A wall set INTO a terrace wants this -- the ground +-- behind it is more terrace, not a trench. +-- nil skip them and paint the map's commonest ground underneath, +-- which is right for a free-standing shelf against a wall. +-- +-- Per tileset because it is a statement about what the drawing depicts, and +-- the answer differs: the Mart's racks and Red's shelves stand in a room, +-- the Plateau's gate walls are cut into a hillside. +function TileShape.bookcaseBackfill(tilesetId) + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local mode = entry and entry.bookcase_backfill + return mode == "above" and mode or nil +end + -- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset -- record needs the next lookup to re-resolve (hot reload, mod toggle). function TileShape.invalidate() spec = nil cache = {} + figCache = {} + bgCache = {} end return TileShape diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 78679b7..ef7889b 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -30,6 +30,9 @@ local Voxel = V.require("VoxelState") local ShadowMap = V.require("ShadowMap") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") +local Sky = V.require("Sky") +local DayNight = V.require("DayNight") +local GlassMask = V.require("GlassMask") local Voxel3D = {} @@ -200,6 +203,12 @@ local SHADER = [[ uniform vec3 ghostColor; // the flat silhouette colour uniform float ghost; // 0 = shade normally, 1 = flatten to it + uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon + uniform Image glassMask; // opaque where the atlas texel is window glass + uniform float glassNight; // 0 = daylight .. 1 = the lamps are on + uniform float glassPhase; // the glint's phase: advances with TRAVEL + uniform float glassGlint; // and its strength: 0 while standing still + uniform float glassOn; // 0 for sprite-sheet draws (see Voxel3D.glass) vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) { vec4 p = Texel(tex, tc); @@ -207,12 +216,38 @@ local SHADER = [[ // blending keeps those texels out of the depth buffer, so a model never // carves a transparent hole out of whatever stands behind it if (p.a < 0.5) discard; - vec3 rgb = p.rgb * vShade * sunlight(vSun); + // the hour's tint multiplies like the sun terms do: it is LIGHT, the + // same warm or moonlit cast on every surface, not a palette swap + vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint; #ifdef VOXEL_GRID // darken what is there rather than painting a colour, so a seam across // dark grass and one across a white roof each stay in their own palette rgb *= 1.0 - gridDark * voxelSeam(vGrid); #endif + // WINDOW GLASS, marked per atlas texel by the mask (see GlassMask). + // By day a thin diagonal glint crosses the panes WHILE THE VIEW MOVES + // -- the phase is fed by the camera's own travel and the strength dies + // within a beat of standing still, because a reflection is something + // the viewpoint does: still camera, still glass. It lifts the texel + // toward sky-white and leaves the art visible through it. After dark + // the pane is LIT: the texel's own shine pattern carried into a warm + // lamp colour, replacing the shaded answer above -- so a lit window + // ignores the sun, every shadow and the hour's tint, exactly as a + // window with a lamp behind it does. + // glassOn gates the whole thing per DRAW: the mask is shaped like the + // tileset atlas, and only meshes textured FROM that atlas may consult + // it -- a character samples its own sprite sheet, whose coordinates + // land on the mask's pane rectangles by accident and would stripe the + // cast with lamplight at night. + float glass = Texel(glassMask, tc).a * glassOn; + if (glass > 0.0) { + float sweep = sin((sc.x + sc.y) * 0.04 - glassPhase); + float glint = pow(max(sweep, 0.0), 20.0) * 0.55 * glassGlint; + vec3 pane = mix(rgb, vec3(0.93, 0.97, 1.0), glint * glass); + float shine = dot(p.rgb, vec3(0.299, 0.587, 0.114)); + vec3 lamp = vec3(1.0, 0.84, 0.5) * (0.5 + 0.55 * shine); + rgb = mix(pane, lamp, glassNight * glass); + } // The hidden player is a SHAPE, not a dimmed picture of itself. Tinting // through `color` could only multiply the sprite's own pixels, which // darkens each one by its own amount and keeps the character's internal @@ -344,6 +379,9 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) if cam then local eye, focus = cam.eye, cam.focus Voxel3D.eye = eye + -- kept beside the eye for horizonY: where the sky's pale end goes is a + -- question about which way this camera looks, and only these two answer it + Voxel3D.focus = focus local dx = eye[1] - focus[1] local dy = eye[2] - focus[2] local dz = eye[3] - focus[3] @@ -369,6 +407,7 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) local eye = { cx, dist * math.cos(a), cy + dist * math.sin(a) } -- exposed for camera-facing billboards (VoxelScene yaws sprites at it) Voxel3D.eye = eye + Voxel3D.focus = focus -- perpendicular to the view direction in the YZ plane: north is screen-up -- when looking straight down, +Y is screen-up when looking level. Never -- parallel to the view direction, so there is no degenerate a = 0 case. @@ -386,6 +425,92 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) return Mat4.mul(proj, Mat4.lookAt(eye, focus, up)) end +-- ------- the horizon +-- +-- Where the ground plane's vanishing line lands, in canvas pixels down from the +-- top edge, or nil when this camera has no horizon to find. +-- +-- Not a fraction picked by eye. A direction ALONG the ground is a point at +-- infinity, and putting one through the same matrix the geometry is drawn with +-- gives the line every ground plane in the scene converges on -- so the sky's +-- pale end meets the horizon at any pitch, fov, window shape or zoom, and rides +-- the camera tween instead of having to be retuned against it. +-- +-- The world CURVE is not in it, and cannot be: it bends distant ground down in +-- the vertex shader, so the ground's apparent edge sits BELOW this line by +-- however much the bend took. What shows in between is the haze the sky's fill +-- already is, which is what a curved-away horizon should look like. +-- +-- nil in two cases, both meaning "no horizon in this frame": a camera looking +-- straight down, whose forward direction has no horizontal part to send to +-- infinity, and one whose vanishing line is behind it. +function Voxel3D.horizonY(h) + local m, eye, focus = Voxel3D.vp, Voxel3D.eye, Voxel3D.focus + if not (m and eye and focus and h and h > 0) then return nil end + local dx = focus[1] - eye[1] + local dz = focus[3] - eye[3] + local len = math.sqrt(dx * dx + dz * dz) + if len < 1e-6 then return nil end + dx, dz = dx / len, dz / len + -- a DIRECTION, so its w is zero and the matrix's translation column drops + -- out; the clip-space Y flip is already baked into m, so this comes out in + -- canvas coordinates rather than needing one + local y = m[5] * dx + m[7] * dz + local w = m[13] * dx + m[15] * dz + if w <= 1e-6 then return nil end + return (y / w * 0.5 + 0.5) * h +end + +-- ------- the hour's light +-- +-- What the scene shader multiplies every surface by (see dayTint in the +-- shader). Set per pass by whoever knows what map is being drawn -- +-- VoxelScene for free-roam, BattleScene for the arena -- because "is this +-- outdoors" is the map's question, not this pass's. Neutral until somebody +-- answers it, so a caller that never does draws exactly what it always drew. +Voxel3D.tint = { 1, 1, 1 } + +-- The window-glass pass, set the same way and for the same reason: the +-- MASK belongs to the map's tileset (GlassMask.texture) and how lit the +-- panes are belongs to the hour and to being outdoors at all +-- (DayNight.windowLight). nil / 0 -- the defaults -- draw no glass effect. +Voxel3D.glassMask = nil +Voxel3D.glassNight = 0 + +-- the glint, fed by the camera's TRAVEL rather than by a clock (see +-- VoxelScene.glintStep): the phase is radians already wrapped to 2pi, and +-- the strength is 0 whenever the view has been still for a beat +Voxel3D.glassPhase = 0 +Voxel3D.glassGlint = 0 + +-- The sun or moon disc's place on this camera's canvas, or nil when the +-- body is set, on the southern half of the sky, or behind the camera. +-- +-- The direction comes from DayNight (true bearing, squashed elevation) and +-- goes through the SAME matrix the geometry is drawn with, as a point at +-- infinity -- exactly how horizonY finds the vanishing line. So the disc's +-- azimuth is honest: it stands over the point on the horizon its shadows +-- point away from, at every pitch, fov, window shape and zoom. +-- +-- Must run after beginScene has set Voxel3D.vp for this frame's camera. +function Voxel3D.skyBody(w, h) + local m = Voxel3D.vp + local b = m and DayNight.body() + if not b then return nil end + local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz + local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz + local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz + if ww <= 1e-6 then return nil end + local amt, color = DayNight.glow() + return { + x = (x / ww * 0.5 + 0.5) * w, + y = (y / ww * 0.5 + 0.5) * h, + moon = b.moon, + glowAmt = amt, + glowColor = color, + } +end + -- ----------------------------------------------------------------- scene -- -- Begin the 3D pass into a `w` x `h` pixel canvas centred on world @@ -426,8 +551,23 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) pcall(love.graphics.setCanvas) return false end + -- Ahead of the clear, because the sky's bands are placed off the ground + -- plane's vanishing line and that is a property of this matrix. + Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh) if sky then love.graphics.clear(sky[1], sky[2], sky[3], sky[4] or 1, true, true) + -- The sky goes down here, in the one window in this function where a + -- rectangle is just a rectangle: the depth mode and the scene shader are + -- both set below. Sky.paint puts them aside anyway -- beginScene is not the + -- only thing that has ever left a shader bound. + -- + -- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama + -- pixel is on screen: the sky's dither grid is cut to that, so its squares + -- are the same size as the world's own and follow every resize and zoom. + -- The banded sky also hangs the hour's sun or moon (skyBody projects it + -- through this very camera); a flat sky has no bands and hangs nothing. + Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w), + sky.bands and Voxel3D.skyBody(w, h) or nil) else love.graphics.clear(0, 0, 0, 0, true, true) end @@ -438,7 +578,6 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) love.graphics.setMeshCullMode("none") love.graphics.setShader(sh) love.graphics.setColor(1, 1, 1, 1) - Voxel3D.vp = Voxel3D.viewProjection(cx, cy, vw, vh) pcall(sh.send, sh, "vp", "row", Voxel3D.vp) pcall(sh.send, sh, "eye", Voxel3D.eye) -- the sun's frame, filled by ShadowMap just before this pass opened. @@ -462,6 +601,18 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot) -- start out flattening everything it drew. pcall(sh.send, sh, "ghost", 0) pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR) + -- the hour's light, as the caller last set it (see Voxel3D.tint) + pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 }) + -- the window glass: the tileset's mask (or the blank -- the sampler is + -- declared either way, and unbound is a driver-dependent crash), how lit + -- the panes are, and the movement-fed glint as the caller last set it + local mask = Voxel3D.glassMask or GlassMask.blank() + if mask then pcall(sh.send, sh, "glassMask", mask) end + pcall(sh.send, sh, "glassNight", Voxel3D.glassNight or 0) + pcall(sh.send, sh, "glassPhase", Voxel3D.glassPhase or 0) + pcall(sh.send, sh, "glassGlint", Voxel3D.glassGlint or 0) + -- on until a sprite pass says otherwise, reset per frame like `ghost` + pcall(sh.send, sh, "glassOn", 1) -- the curved world bends about the camera's focus, so the horizon keeps -- a fixed distance ahead of the player rather than sitting on the map. -- A placed camera may decline it outright (Voxel3D.camera.curve = 0). @@ -558,6 +709,44 @@ function Voxel3D.flatten(color, amount) end end +-- Whether what is drawn next carries the voxel wireframe. false for the +-- length of a draw, true to put it back. +-- +-- The wireframe reads a mesh's OWN model space and darkens its integer +-- planes (see VoxelGrid), which is only a wireframe because every mesh in +-- this mode is built ONE UNIT PER VOXEL: terrain in world pixels, a +-- character card in the sprite's own pixels. A mesh whose model space does +-- not mean that gets no wireframe out of the same shader -- it gets +-- whichever of its integer planes happen to fall inside it, which is a +-- stray line rather than a seam. +-- +-- So this is not a style switch. It is how a mesh that is not on the voxel +-- grid says so, and the alternative -- rescaling such a mesh until its +-- units happen to be voxels -- would change what it IS to satisfy a +-- shading pass. +-- +-- Sent rather than branched because the plain scene shader has no such +-- uniform, and the send simply does not take there -- which is right: with +-- no wireframe compiled in there is nothing to suppress. +function Voxel3D.seams(on) + if not (active and activeShader) then return end + pcall(activeShader.send, activeShader, "gridDark", + on and VoxelGrid.DARK or 0) +end + +-- Whether what is drawn next may consult the glass mask. false for the +-- length of a sprite-sheet pass, true to put it back. +-- +-- Same shape as seams(), for the same reason: the mask means "this ATLAS +-- texel is window glass", so it is only an answer for meshes textured from +-- the tileset atlas. A sprite sheet's coordinates land wherever they land +-- on it, and at night that painted lamplight stripes down whoever was +-- standing in the wrong part of their own sheet. +function Voxel3D.glass(on) + if not (active and activeShader) then return end + pcall(activeShader.send, activeShader, "glassOn", on and 1 or 0) +end + function Voxel3D.endGhost() if not active then return end pcall(love.graphics.setDepthMode, "lequal", true) @@ -741,6 +930,10 @@ function Voxel3D.invalidate() end canvas, canvasW, canvasH = nil, 0, 0 ShadowMap.invalidate() + -- the sky is part of this pass and holds a shader of its own + Sky.invalidate() + -- and the glass masks are textures of this context too + GlassMask.invalidate() end return Voxel3D diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index b3d4d6c..4d401ea 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -1,4 +1,4 @@ --- Voxel world mode: assemble and draw one frame of the 3D scene. +-- Voxel world mode: assemble and draw one frame of the 3D scene. -- -- World space is world pixels and shares its origin with the 2D paths, so -- the terrain mesh needs no transform at all and a connected map just @@ -20,6 +20,8 @@ local SpriteBillboards = V.require("SpriteBillboards") local TileShape = V.require("TileShape") local TerrainAtlas = V.require("TerrainAtlas") local Voxel = V.require("VoxelState") +local Sky = V.require("Sky") +local DayNight = V.require("DayNight") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -49,12 +51,16 @@ VoxelScene._modeColors = modeColors -- named for the suite -- ------------------------------------------------------------------ sky -- -- --- At the top rung the camera is pitched far enough over that the horizon --- comes into frame and a good part of the picture is void -- so the void --- becomes the sky, and the diorama reads as standing under something --- rather than floating on a black plate. Below that rung the camera looks --- down steeply enough that the horizon is off-screen, and painting the --- void only tints the gaps between meshes, so it stays transparent. +-- The void behind the diorama is SKY, at every rung -- so the world reads as +-- standing under something rather than floating on a black plate. +-- +-- What is up there differs by rung, and the sky follows it rather than being +-- retuned for each. At 75 degrees the camera is pitched far enough over that +-- the horizon is genuinely in frame, and the bands run down to meet it. At the +-- steeper rungs the horizon is above the top edge and the void that shows is +-- where the ground runs OUT -- past the map edge, past the curve -- so the +-- bands take a fixed slice of the frame instead (lib/Sky.lua, Sky.SPAN) and the +-- haze below them fills the rest. -- -- INDOORS THERE IS NO SKY. A house, a cave or a gym is a room with a -- ceiling, and the void past its walls is the outside of a box, not open @@ -67,42 +73,78 @@ VoxelScene._modeColors = modeColors -- named for the suite -- CLASSIC a green one, GBC INV a dark one, and the colour modes the blue. -- A hardcoded blue would sit wrong in every non-colour mode -- the same -- mismatch the terrain bake had. +-- +-- This ramp is the FLAT sky -- what a caller clears the void to. The free-roam +-- camera's banded sky has a palette of its own (lib/Sky.lua), transformed the +-- same way by the same seam; they are separate because the flat one also has to +-- serve an indoor void and a battle's arena, which want a colour rather than a +-- sky. local SKY_SHADES = { { 222, 242, 255 }, { 135, 196, 240 }, { 64, 120, 192 }, { 16, 40, 80 } } local SKY_SHADE = 2 -- the ramp's "sky" proper; 1 is its highlight --- fade across the approach to the top rung, so the sky arrives with the --- camera tween instead of popping in on the keypress +-- the ramp as the display mode has it, which is the only form anything here +-- should be reading it in +local function skyRamp() + return PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES +end + +-- Full strength at every rung: the sky is painted wherever the diorama is. +-- +-- The ramp that is left is for ARRIVAL alone. Switching the mode on eases the +-- camera up from flat, and the sky comes up with it over the first few degrees +-- rather than appearing whole on the keypress -- which is also what keeps a +-- top-down camera, where there is no void worth speaking of, from painting one. +local SKY_FADE_DEG = 8 + local function skyStrength(angleRad) local deg = math.deg(angleRad or 0) - local from = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL] or 50 -- the rung below - local to = Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1] or 75 -- the top rung - if to <= from then return deg >= to and 1 or 0 end - local t = (deg - from) / (to - from) - if t < 0 then return 0 end - if t > 1 then return 1 end - return t + if deg <= 0 then return 0 end + local t = deg / SKY_FADE_DEG + return t < 1 and t or 1 end -- One shade off the sky ramp, transformed by the display mode, as an -- {r, g, b, a} in 0..1. `shade` picks the rung (SKY_SHADE is the sky -- proper; 4 is its darkest, which is what an indoor void wants). function VoxelScene.skyShade(shade, alpha) - local shades = PaletteFX.effectiveColors(SKY_SHADES) or SKY_SHADES + local shades = skyRamp() local c = shades[shade] or SKY_SHADES[shade] or SKY_SHADES[SKY_SHADE] return { c[1] / 255, c[2] / 255, c[3] / 255, alpha or 1 } end -- The sky `map` stands under at strength `t`, or nil where there is no sky -- to paint: indoors, or with the horizon out of frame. +-- +-- One flat colour, which is what a caller that only needs something to clear the +-- void to wants -- the overworld battle's arena shot is one of those. The +-- gradient is added on top of this by skyFor, for the free-roam camera alone. function VoxelScene.skyColor(map, t) if not (map and map.def and Map.isOutdoor(map.def)) then return nil end if not t or t <= 0 then return nil end - return VoxelScene.skyShade(SKY_SHADE, t) + local sky = VoxelScene.skyShade(SKY_SHADE, t) + -- outdoors the flat fill follows the CLOCK: it becomes the hour's haze -- + -- gold at dusk, navy at night -- so a battle staged on the map at + -- midnight is under a midnight void, not a noon one. Free-roam is + -- unchanged by this: Sky.dress overwrites the fill with the same value. + local haze = Sky.haze() + if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end + return sky end +-- The free-roam sky: the flat one above, dressed with the banded gradient +-- (lib/Sky.lua). +-- +-- Only here, and deliberately. This is the sky the walking camera stands under, +-- where the horizon is a quarter of the way down the frame at the top rung and +-- one flat blue reads as a wall of paint. A battle is a staged shot with its own +-- placed camera whose horizon sits above the frame entirely, so it keeps the +-- flat fill it has always had -- there is no gradient to see from down there, +-- and the arena's look is not this rung's to change. local function skyFor(map) - return VoxelScene.skyColor(map, skyStrength(Voxel.angle)) + local sky = VoxelScene.skyColor(map, skyStrength(Voxel.angle)) + if not sky then return nil end + return Sky.dress(sky) end VoxelScene._skyFor = skyFor -- named for the suite @@ -205,6 +247,36 @@ local function billboardPull() return VoxelScene.pull(math.max(Voxel.angle, 0.05)) end +-- An authored FIGURE's card -- a person the tileset draws INTO a piece of +-- furniture, cut out by the profile's mask (Structures.buildFigures). It is +-- a sprite, so it gets the sprite treatment: the mesh arrives in its own +-- local space with its feet on y = 0, and this stands it at its drawn +-- position and tips it back by exactly the camera's pitch -- the same +-- pivot-at-the-feet lean billboardMatrix gives a character, so the man on +-- the Pokemon Center couch reads face-on at every tilt like the NPCs +-- around him. No cell centring: unlike a character he is not standing on a +-- cell, he is standing where he was drawn, which may straddle two. +local function figureMatrix(f, offX, offZ) + local Voxel = V.require("VoxelState") + return Mat4.mul(Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)), + Mat4.rotateX(Voxel.angle - math.pi / 2)) +end + +-- What the sun sees: the same card UNLEANED and flattened, exactly as +-- Voxel3D.casterMatrix does it for a character. +local function figureCaster(f, offX, offZ) + return Mat4.mul( + Mat4.translate(f.wx + (offX or 0), f.y, f.wz + (offZ or 0)), + Mat4.scale(1, 1, 0)) +end + +-- Every figure on `map`, drawn with `draw(mesh, model, caster)`. +local function eachFigure(map, offX, offZ, draw) + for _, f in ipairs(ChunkMesher.figures(map) or {}) do + draw(f.mesh, figureMatrix(f, offX, offZ), figureCaster(f, offX, offZ)) + end +end + -- Draw one posed entity. Returns true if 3D geometry carried it, false -- when nothing could be built and the caller should fall back. -- `colors` is the 4-color world palette the entity stands under in the SGB @@ -385,6 +457,34 @@ local function posesOf(state, spriteColors) return posed, me end +-- ------- the glint's drive +-- +-- A reflection is something the VIEWPOINT does, so the window glint is fed +-- by the camera's own travel rather than by a clock: its phase advances +-- with distance covered and its strength fades in over a few steps of +-- walking and back out within a beat of standing still. Stand still and +-- the glass is still; move and the light crosses it. +VoxelScene.GLINT_RATE = 0.22 -- radians of sweep per world pixel travelled +VoxelScene.GLINT_IN = 0.12 -- strength gained per moving frame +VoxelScene.GLINT_OUT = 0.08 -- and lost per resting frame + +function VoxelScene.glintStep(g, cx, cy) + local dist = 0 + if g.x then + dist = math.abs(cx - g.x) + math.abs(cy - g.y) + end + g.x, g.y = cx, cy + g.phase = ((g.phase or 0) + dist * VoxelScene.GLINT_RATE) % (2 * math.pi) + if dist > 0.05 then + g.amp = math.min(1, (g.amp or 0) + VoxelScene.GLINT_IN) + else + g.amp = math.max(0, (g.amp or 0) - VoxelScene.GLINT_OUT) + end + return g +end + +local glint = {} + -- A stamp of everything the sun pass depends on. Nothing in it moving -- means the shadow map it produced last frame is still exactly right, and -- redrawing the whole world from the sun would buy nothing -- which is @@ -406,6 +506,12 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) -- standing perfectly still put(vw); put(vh) put(math.floor((V.require("VoxelState").angle or 0) * 512)) + -- the sun itself: the cycle swings the shear as the clock runs, and a map + -- lit from somewhere new must be redrawn from there too. Quantised by the + -- rig's own step (DayNight.rigTime), so a running cycle redraws the map a + -- few times a minute rather than every frame. + put(math.floor(ShadowMap.KX * 128)) + put(math.floor(ShadowMap.KZ * 128)) put(tostring(terrain)) for i = 1, #nbMesh do put(tostring(nbMesh[i])) end for _, p in ipairs(posed) do @@ -442,11 +548,25 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, end -- flower billboards live outside the terrain mesh (they draw after the -- characters, pulled -- see render), but the sun still sees them: a - -- handful of cutouts per meadow, unlike the grass left out below - ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), nil) + -- handful of cutouts per meadow, unlike the grass left out below. + -- Every thin card from here down is SNUGGED toward the sun along its own + -- ray (ShadowMap.snug) so its shadow keeps contact with its feet instead + -- of starting a bias-width away. + ShadowMap.draw(ChunkMesher.flowers(state.map), atlasFor(state.map), + ShadowMap.snug(nil)) for _, nb in ipairs(state.neighbors or {}) do ShadowMap.draw(ChunkMesher.flowers(nb.map), atlasFor(nb.map), - Mat4.translate(nb.ox, 0, nb.oy)) + ShadowMap.snug(Mat4.translate(nb.ox, 0, nb.oy))) + end + -- authored figures cast too, for the same reason the flowers do: a + -- handful of cards per map, and a person with no shadow reads as pasted on + eachFigure(state.map, 0, 0, function(mesh, _, caster) + ShadowMap.draw(mesh, atlasFor(state.map), ShadowMap.snug(caster)) + end) + for _, nb in ipairs(state.neighbors or {}) do + eachFigure(nb.map, nb.ox, nb.oy, function(mesh, _, caster) + ShadowMap.draw(mesh, atlasFor(nb.map), ShadowMap.snug(caster)) + end) end for _, p in ipairs(posed) do local def = p.sprite.def @@ -454,8 +574,9 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, local mesh = SpriteBillboards.shadowQuad(def, frame) if mesh then ShadowMap.draw(mesh, p.sprite:resolveImage(), - Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0), - mirror)) + ShadowMap.snug( + Voxel3D.casterMatrix(p.px, p.py, p.gh + (p.lift or 0), + mirror))) end end @@ -473,6 +594,25 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) local cam = state.camera local cx, cy = cam.x + vw / 2, cam.y + vh / 2 + -- the hour's light, before anything is cast or drawn: point the shared + -- rig at the clock (or at noon, indoors -- a cave at midnight is exactly + -- as dark as a cave at noon) and set the tint the scene shader multiplies + -- every surface by. A CANOPY map (Viridian Forest) is the case between: + -- the rig stays at noon and no sky is painted, but the hour's tint still + -- falls through the leaves -- night reaches a forest floor. + local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false + DayNight.applyRig(outdoor) + Voxel3D.tint = DayNight.tint(outdoor or DayNight.isCanopy(state.map)) + -- and the window glass: the tileset's own panes (found in its art -- + -- GlassMask), lit after dark. Outdoors only, like everything the clock + -- touches, which also keeps any pane-shaped art in an interior tileset + -- from picking up a glint. + local GlassMask = V.require("GlassMask") + Voxel3D.glassMask = outdoor and GlassMask.texture(state.map.tileset) or nil + Voxel3D.glassNight = outdoor and DayNight.windowLight() or 0 + local g = VoxelScene.glintStep(glint, cx, cy) + Voxel3D.glassPhase, Voxel3D.glassGlint = g.phase, g.amp + local function atlasFor(map) return TerrainAtlas.forMap(map, modeColors(paletteFor, map)) end @@ -513,6 +653,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Voxel3D.endShadows() end + -- Sprite sheets from here to the figure pass: their texture coordinates + -- mean nothing to the tileset-shaped glass mask, so the glass is off or + -- the panes' atlas positions stripe the cast with lamplight at night + Voxel3D.glass(false) + -- The player's silhouette goes down BEFORE the characters, so the only -- thing it can meet in the depth buffer is the WORLD -- terrain, buildings, -- trees. Drawn after the solid pass it would meet the player's own card @@ -526,14 +671,44 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) Voxel3D.endGhost() end - -- characters, normally depth-tested: the camera-ward pull inside + -- Characters carry no wireframe out here, whatever the V-GRID row says. + -- The seams are what makes the WORLD read as built out of voxels, and + -- the people walking around in it are the one thing that should read as + -- drawn instead -- a grid over a 16x16 sprite lands a line every couple + -- of display pixels and turns a face into a mesh. (The battle pass makes + -- the opposite call for its own combatants, deliberately: that is a + -- staged shot rather than the world being walked around in -- see + -- BattleBillboard.) + -- + -- Characters, normally depth-tested: the camera-ward pull inside -- drawEntity resolves the lean-over-the-wall-in-front case, and a -- character genuinely behind a building is far deeper and loses the -- test, so buildings and trees really occlude. + Voxel3D.seams(false) for _, p in ipairs(posed) do drawEntity(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, p.colors, p.lift) end + -- back on for everything textured from the atlas again -- figures, grass + -- and flowers all sample it, where the mask's coordinates are honest + Voxel3D.glass(true) + -- Authored figures, alongside the characters and with the same lean and + -- the same camera-ward pull -- they ARE characters as far as the artwork + -- is concerned, just ones the tileset draws instead of a sprite sheet. + -- Drawn after the walkers so a player standing in front of the couch + -- wins the overlap, which is the order the flat game draws them in. + local figPull = billboardPull() + eachFigure(state.map, 0, 0, function(mesh, model, caster) + Voxel3D.draw(mesh, atlasFor(state.map), model, figPull, caster) + end) + for _, nb in ipairs(state.neighbors or {}) do + eachFigure(nb.map, nb.ox, nb.oy, function(mesh, model, caster) + Voxel3D.draw(mesh, atlasFor(nb.map), model, figPull, caster) + end) + end + -- and the seams are back on for the terrain art that follows: grass and + -- flowers are the world's own drawing, not people + Voxel3D.seams(true) -- tall grass last, pulled camera-ward exactly as far as the characters -- were (same per-vertex shader bias, so grass never drifts either): -- relative depth between a walker and the tuft row south of their feet diff --git a/main.lua b/main.lua index faf2595..dd28deb 100644 --- a/main.lua +++ b/main.lua @@ -78,6 +78,8 @@ local ChunkMesher = V.require("ChunkMesher") local VoxelGrid = V.require("VoxelGrid") local WorldCurve = V.require("WorldCurve") local OverworldBattle = V.require("OverworldBattle") +local BattleExit = V.require("BattleExit") +local DayNight = V.require("DayNight") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -157,6 +159,11 @@ mod.content.render_pipelines:register("voxel", { -- would fight anyone who changed one deliberately. applyFull(level) Voxel.update(dt, level) + -- the day/night clock, on the same always-running tick: Pipelines.update + -- runs whatever the level, so time passes with the mode off, through + -- battles and menus, and a CYCLE evening falls mid-fight exactly as it + -- would mid-walk + DayNight.update(dt) -- The overworld battle rides this hook rather than owning a pipeline of -- its own, because it owns no pass of the FRAME: it draws under a battle -- screen the engine composites, which is not a stage the registry has. @@ -281,9 +288,30 @@ applyFull = function(level) -- half of it is spent. Set rather than forced -- the row is gone from the -- menu while FULL is on, but a save that already had it off gets it on. OverworldBattle.setting:setIndex(1, Game) + -- and the battle screen the staged fight is composed for. WIDE re-lays that + -- screen out on a 304x144 surface, which moves every anchor the arena camera + -- is solved against (OverworldBattle.forceOG); FULL has just switched staged + -- fights on, so the layout follows them. + OverworldBattle.forceOG(Game) + -- FULL is the whole diorama, and a diorama with a running sky is more of + -- one: the clock is set going (CYCLE is the ladder's last rung). Set, not + -- held, like everything else here -- the player can pin it back afterwards. + DayNight.setting:setIndex(#DayNight.setting.values, Game) if Game.writeOptions then pcall(Game.writeOptions, Game) end end +-- Whether a fight can be staged on the map, as far as the OPTIONS menu is +-- concerned: 3D-BTL is on, or FULL is selected -- which owns that row and +-- switches it on. Deliberately NOT gated on Voxel3D.available(): the engine +-- offers a pipeline's row whether or not the hardware can run it +-- (Pipelines.rows), so this mode's rows say ON on a machine without a depth +-- buffer too, and a menu that claims 3D battles are on must not also offer the +-- layout they cannot be drawn in. +local function stagedBattles() + local Pipelines = require("src.render.Pipelines") + return OverworldBattle.enabled() or Voxel.isFull(Pipelines.level("voxel")) +end + local SETTINGS = { { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, { WorldCurve.setting, @@ -291,6 +319,10 @@ local SETTINGS = { { OverworldBattle.setting, "Fight on the map: the battle draws over the nearest clear ground, " .. "shot over the shoulder with a slow parallax drift." }, + { DayNight.setting, + "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " + .. "or let CYCLE run it -- ten minutes of sun, ten of moon, with the " + .. "shadows, the sky and the light following." }, } local schema = {} @@ -393,6 +425,11 @@ do -- when the fight starts, so flipping it from inside one would be a -- switch that appeared to do nothing. claim:cycle(self) + -- 8 is one of the two ways staged battles get switched on, and they + -- pin BATTLE LAYOUT to OG (see the rows hook). The other two keys + -- parameterise the pass and leave the layout alone; the guard answers + -- for all three, so nothing here has to know which key it was. + if stagedBattles() then OverworldBattle.forceOG(self) end return end end @@ -442,6 +479,17 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) local out = next(game, rows) if type(out) ~= "table" then return out end local Pipelines = require("src.render.Pipelines") + -- BATTLE LAYOUT is the ENGINE's row, and this is the one place the mod takes + -- one away. While a fight can be staged on the map, OG is the only layout it + -- can be composed in (OverworldBattle.forceOG), so the value is pinned there + -- and the row comes off the list on the same reasoning as the rows FULL owns: + -- a row that no longer decides anything is worse than no row. Nothing is + -- lost by switching 3D-BTL off -- the row is back, WIDE and all, on the same + -- keypress. + if stagedBattles() then + OverworldBattle.forceOG(game) + dropRow(out, "battleLayout") + end if Voxel.isFull(Pipelines.level("voxel")) then return dropRow(out, "pipeline:tiltshift") end @@ -457,6 +505,10 @@ mod.events:on("mod.options_changed", function(payload) for _, entry in ipairs(SETTINGS) do if payload.key == entry[1].key then entry[1]:sync(payload.value) end end + -- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as + -- the OPTIONS row does. The manager persists its own value; this is the one + -- that has to follow it. + if stagedBattles() then OverworldBattle.forceOG() end end) -- ------- keeping the geometry in step with the world @@ -539,7 +591,7 @@ mod.events:on("map.reloaded", function(payload) if mapId then ChunkMesher.invalidate(mapId) end end) --- ------- FULL takes rows off the menu, so the menu has to notice +-- ------- rows come and go, so the menu has to notice -- -- OptionsMenu builds its row list ONCE, when it is opened, and then reads -- that list every frame. So stepping the VOXEL row onto or off FULL changed @@ -547,25 +599,40 @@ end) -- settings FULL owns stayed visible until the menu was closed and reopened, -- and a player who stepped off FULL could not see the rows come back. -- --- Rebuilt in place, and only on a step that crosses FULL: every other rung --- returns the same list, and rebuilding on all of them would rerun every --- mod's ui.options.rows hook once per keypress. The cursor is clamped rather --- than reset, so it stays on the VOXEL row it was just used on instead of --- jumping to the top when the list below it shortens. +-- Rebuilt in place, and only on a step that changes the LIST: crossing FULL, +-- or toggling 3D-BTL, which is the other row that owns one (BATTLE LAYOUT). +-- Every other rung returns the same list, and rebuilding on all of them would +-- rerun every mod's ui.options.rows hook once per keypress. The cursor is +-- clamped rather than reset, so it stays on the row it was just used on +-- instead of jumping to the top when the list below it shortens. do local OptionsMenu = require("src.ui.OptionsMenu") if not OptionsMenu.dramaticShapeFullHook then local Pipelines = require("src.render.Pipelines") local inner = OptionsMenu.update + local function idAt(menu, index) + local row = menu.rows and menu.rows[index or 1] + return type(row) == "table" and row.id or nil + end + function OptionsMenu:update(dt) local before = Pipelines.level("voxel") + local hadBattles = OverworldBattle.enabled() + local wasOn = idAt(self, self.index) inner(self, dt) local after = Pipelines.level("voxel") - if after ~= before - and (Voxel.isFull(before) or Voxel.isFull(after)) then + local crossedFull = after ~= before + and (Voxel.isFull(before) or Voxel.isFull(after)) + if crossedFull or OverworldBattle.enabled() ~= hadBattles then local rebuilt = OptionsMenu.new(self.game) self.rows = rebuilt.rows + -- Follow the row the cursor was ON rather than the slot it was in: + -- 3D-BTL takes BATTLE LAYOUT off the list ABOVE itself, which would + -- otherwise slide the cursor onto the row under the one just used. + for i = 1, #self.rows do + if wasOn and idAt(self, i) == wasOn then self.index = i; break end + end local cancel = #self.rows + 1 if (self.index or 1) > cancel then self.index = cancel end end @@ -620,7 +687,55 @@ mod.events:on("battle.ended", function() OverworldBattle.finish() end) -mod.exports.version = "1.1.1" +-- ------- and the way back out +-- +-- The engine wipes INTO a battle with one of the original's eight transitions +-- and cuts straight OUT of it. That cut is between two very different cameras +-- in this mode, so while voxel mode is on the battle fades out, closes behind +-- the black, and the map fades up. The two seams it needs -- BattleState:finish +-- and Renderer:endFrame -- and the reasoning for each live in lib/BattleExit.lua. +-- +-- Declared as a transitions record rather than a constant in that file, so the +-- fade is retunable in data exactly like the eight wipes it answers, and a total +-- conversion can make it as long or as short as its own pacing wants. +mod.content.transitions:register(BattleExit.ID, { + frames = BattleExit.FRAMES, +}) + +BattleExit.install() + +-- ------- what time it is +-- +-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what +-- time it is in Kanto is a fact about that journey, like where the player is +-- standing. Written on the engine's save.writing event -- the moment before +-- the bytes hit disk -- and read back whenever a save is opened or begun. A +-- save with no clock in it starts at day; that is DayNight.restore's +-- fallback, and also the DAYTIME row's own default. +mod.events:on("save.writing", function() + DayNight.store() +end) + +mod.events:on("save.loaded", function() + DayNight.restore() +end) + +mod.events:on("save.created", function() + DayNight.restore() +end) + +-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an +-- eternal "DAY" until a mod answers here; answering it hands the period to +-- the map.palette hook (ctx.tod) and music.select, so a palette or music +-- pack keyed to night works with this mod's clock for free. next() first: a +-- mod loaded before this one that already moved the time keeps its answer. +mod.hooks:wrap("world.tod", function(next, tod, ctx) + local out = next(tod, ctx) + if out ~= tod then return out end + return DayNight.tod() +end) + +mod.exports.version = "1.2.0" -- 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 f5a7633..edf6193 100644 --- a/manifest.json +++ b/manifest.json @@ -1,12 +1,12 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.1.1", + "version": "1.2.0", "api": 2, "entry": "main.lua", "profile": "content", "category": "GRAPHICS", - "game_version": "0.0.0-dev || >=0.1.28 <2.0.0", + "game_version": "0.0.0-dev || >=0.1.37 <2.0.0", "priority": 100, "dependencies": [], "optional_dependencies": [], diff --git a/mod.card b/mod.card index 762df5b..ef13bc1 100644 --- a/mod.card +++ b/mod.card @@ -38,5 +38,5 @@ return { credits = { { who = "pret/pokered", for_ = "the tile and sprite data the geometry is derived from" }, }, - compat = { engine = ">=0.1.28 <2.0.0", modApi = 2 }, + compat = { engine = ">=0.1.37 <2.0.0", modApi = 2 }, } diff --git a/tests/daynight_shots.lua b/tests/daynight_shots.lua new file mode 100644 index 0000000..08c3f83 --- /dev/null +++ b/tests/daynight_shots.lua @@ -0,0 +1,109 @@ +-- Driver: screenshot the day/night cycle. +-- +-- One vantage (Pallet Town, VOXEL 75) through every pinned phase, two +-- mid-blend moments of the running cycle, a battle staged under the night +-- sky, and a room at midnight -- which must look exactly like a room at +-- noon, because indoors the clock does not reach. +-- +-- SHOT_DIR=.scratchpad/daynightcycle POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/daynight_shots.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/daynightcycle" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again") + return + end + local DayNight = lib.require("DayNight") + + local function setTime(value, clock) + DayNight.setting:sync(value) + if clock then DayNight.clock = clock end + DayNight.update(0) + end + + U.teleport(game, "PALLET_TOWN", 12, 10, "up") + -- straight to the top rung, whatever the persisted options say: pressing + -- the key would walk the ladder RELATIVE to wherever a previous run left it + local Pipelines = require("src.render.Pipelines") + Pipelines.setLevel("voxel", 5) + U.wait(150) -- let the camera ease over and the meshes land + + -- ------- the four pins, one vantage + for _, phase in ipairs({ "day", "dawn", "dusk", "night" }) do + setTime(phase) + U.wait(30) -- a couple of rig steps + palette settle + U.shot(game, ("%s/10_pin_%s.png"):format(DIR, phase)) + end + + -- ------- the running cycle, mid-blend + setTime("cycle", 560) -- day leaning into dusk + U.wait(10) + U.shot(game, DIR .. "/20_cycle_day_into_dusk.png") + setTime("cycle", 645) -- dusk falling into night + U.wait(10) + U.shot(game, DIR .. "/21_cycle_dusk_into_night.png") + + -- ------- golden hour on open ground, long shadows + U.teleport(game, "ROUTE_1", 8, 12, "up") + U.wait(120) + setTime("cycle", 555) -- low western sun, shadows long and eastward + U.wait(30) + U.shot(game, DIR .. "/30_route1_low_sun.png") + + -- ------- a fight staged under the night sky + setTime("night") + game.save.party = game.save.party or {} + if #game.save.party == 0 then + game.save.party[1] = Pokemon.new(game.data, "CHARIZARD", 45) + end + -- any real trainer class: the dataset's ids vary by merge, so take the + -- first one in stable order rather than guessing a name + local classes = {} + for id, rec in pairs(game.data.trainers) do + if type(id) == "string" and id:sub(1, 1) ~= "_" + and type(rec) == "table" and rec.parties and rec.parties[1] then + classes[#classes + 1] = id + end + end + table.sort(classes) + local battle = classes[1] and BattleState.newTrainer(game, classes[1], 1) + if battle then + battle.onFinish = function() end + game.overworld:pushBattle(battle) + U.wait(70) + for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end + U.shot(game, DIR .. "/40_night_battle.png") + while game.stack:top() and game.stack:top() ~= game.overworld do + game.stack:pop() + end + U.wait(10) + end + + -- ------- and a room, where midnight must change nothing + setTime("night") + if game.data.maps.REDS_HOUSE_1F then + U.teleport(game, "REDS_HOUSE_1F", 4, 4, "down") + U.wait(90) + U.shot(game, DIR .. "/50_indoor_at_night.png") + end + + -- ------- the forest, where only the TINT of it reaches: night falls + -- through the canopy, but there is no sky and the noon light stays put + setTime("night") + if game.data.maps.VIRIDIAN_FOREST then + U.teleport(game, "VIRIDIAN_FOREST", 17, 20, "up") + U.wait(120) + U.shot(game, DIR .. "/51_forest_night.png") + setTime("day") + U.wait(30) + U.shot(game, DIR .. "/52_forest_day.png") + end + + setTime("day") + U.log("done -- " .. DIR) +end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 582a4d8..e4a0c28 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -135,6 +135,68 @@ T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID") T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE") T.check(not fullIds["DRAMATIC_SHAPE:battles"], "and 3D-BTL") +-- ------- BATTLE LAYOUT is pinned to OG while a fight can be staged on the map +-- +-- The staged battle is composed in the GB's own 160x144 frame: the anchors the +-- arena camera is solved to put a cell under, the HUD rects the frosted panels +-- are cut to, the full-frame white intercepted to let the world through. WIDE +-- lays the same battle out on a 304x144 surface and moves every one of them. So +-- the value is set and the ENGINE's row comes off the menu -- the one row this +-- mod takes away that is not its own. +-- +-- Scoped in a block of its own, like the sections below: this file is one Lua +-- chunk and a chunk has 200 local slots, so a section that wants half a dozen +-- borrows them rather than spending them for the rest of the run. +do +local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +T.eq(Battles.enabled(), true, "3D-BTL is on by default, which is what pins it") + +-- off FULL first: the row on its own has to be enough, and FULL is checked +-- separately below +Pipelines.setLevel("voxel", 2) +local layoutGame = { + data = Data, + save = { options = { battleLayout = "wide", pipelines = {}, modOptions = {} } }, + mods = { modOptions = {} }, + writeOptions = function() end, +} +local pinned = Runtime.call("ui.options.rows", function(_, r) return r end, + layoutGame, + { { id = "battleLayout" }, { id = "tilt" }, + { id = "pipeline:voxel" } }) +local pinnedIds = {} +for _, row in ipairs(pinned) do pinnedIds[row.id] = true end +T.check(not pinnedIds["battleLayout"], + "with staged battles on, BATTLE LAYOUT is off the menu") +T.eq(layoutGame.save.options.battleLayout, "og", + "and a save that had WIDE is set to OG -- the only layout the shot composes in") + +-- switching 3D-BTL off hands the row straight back, WIDE and all +Battles.setting:setIndex(2, layoutGame) +T.eq(Battles.enabled(), false, "3D-BTL off") +local handedBack = Runtime.call("ui.options.rows", function(_, r) return r end, + layoutGame, + { { id = "battleLayout" }, { id = "tilt" }, + { id = "pipeline:voxel" } }) +local backIds = {} +for _, row in ipairs(handedBack) do backIds[row.id] = true end +T.check(backIds["battleLayout"], "the engine's row is back on the menu") +layoutGame.save.options.battleLayout = "wide" +Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame, + { { id = "battleLayout" } }) +T.eq(layoutGame.save.options.battleLayout, "wide", + "and WIDE is left alone once no battle can be staged on the map") + +-- FULL owns the 3D-BTL row, so it pins the layout even with that row switched +-- off underneath it +Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL) +Runtime.call("ui.options.rows", function(_, r) return r end, layoutGame, + { { id = "battleLayout" } }) +T.eq(layoutGame.save.options.battleLayout, "og", + "FULL pins the layout on its own, because it owns the row that would") +Battles.setting:setIndex(1, layoutGame) +end + -- ------- and off FULL, the rows come back, grouped with the mode -- -- The engine splices a pipeline row in beside TILT and lands a mod's own @@ -200,13 +262,43 @@ T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"), "and the rows came straight back without reopening the menu") T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too") +-- ------- 3D-BTL owns BATTLE LAYOUT, and takes it off the OPEN menu too +-- +-- The row this one takes away sits ABOVE it in the list, so the cursor has to +-- follow the row it was ON rather than the slot it was in -- otherwise the very +-- press that switched staged battles on would leave the cursor a row further +-- down than the player left it. +do +local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") +Battles.setting:setIndex(2, menuGame) -- staged battles off +menuGame.save.options.battleLayout = "wide" +Pipelines.setLevel("voxel", 2) +local layoutMenu = OptionsMenu.new(menuGame) +T.check(rowIndex(layoutMenu, "battleLayout"), + "with staged battles off, the engine's BATTLE LAYOUT row is on the menu") +layoutMenu.index = rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles") +pressed = { right = true } +layoutMenu:update(0) +pressed = {} +T.eq(Battles.setting:get(), true, "the step switched staged battles on") +T.check(not rowIndex(layoutMenu, "battleLayout"), + "and BATTLE LAYOUT left the open menu with the same keypress") +T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out") +T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"), + "with the cursor still on the row the player just used") +end + -- level 2 is the "15" rung: any rung that is not FULL, so the settings the -- preset owns are back on the menu Pipelines.setLevel("voxel", 2) local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "text_speed" } }) -T.eq(#hookedRows, 4, "the options hook added a row per setting") +T.eq(#hookedRows, 5, "the options hook added a row per setting") local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4] +local daytime = hookedRows[5] +T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") +T.eq(daytime.value(), "DAY", + "and starts pinned to DAY -- no time set means it is day") T.eq(grid.label, "V-GRID", "the grid row carries its label") T.eq(grid.value(), "OFF", "the grid starts off") T.eq(curve.label, "V-CURVE", "the curve row carries its label") @@ -967,15 +1059,16 @@ local outside = { def = { id = "PALLET_TOWN", tileset = "OVERWORLD" } } local inside = { def = { id = "REDS_HOUSE_1F", tileset = "HOUSE" } } local TOP = math.rad(Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1]) --- the ladder, by angle: only the top rung paints anything +-- the ladder, by angle: every rung that tilts at all paints a sky for level = 0, Voxel.MAX_LEVEL do Voxel.angle = math.rad(Voxel.ANGLES_DEG[level + 1]) local sky = skyFor(outside) - if level == Voxel.MAX_LEVEL then - T.check(sky ~= nil, "the 75-degree rung paints a sky outdoors") - T.eq(sky[4], 1, "and paints it at full strength") + if level == 0 then + T.eq(sky, nil, "rung 0 is the flat camera: no tilt, no void, no sky") else - T.eq(sky, nil, "rung " .. level .. " leaves the void alone") + T.check(sky ~= nil, "rung " .. level .. " paints a sky outdoors") + T.eq(sky[4], 1, "and paints it at full strength") + T.check(sky.bands ~= nil, "with its bands on it") end end @@ -991,14 +1084,18 @@ T.eq(skyFor(inside), nil, "not even at 75 degrees, where outdoors would") T.eq(skyFor(nil), nil, "no map, no sky") T.eq(skyFor({}), nil, "a map with no def is not an outdoor map") --- it fades in with the camera tween rather than popping on the keypress -T.eq(skyStrength(math.rad(50)), 0, "the rung below the top is still skyless") -T.eq(skyStrength(TOP), 1, "the top rung is full sky") -local mid = skyStrength(math.rad(62.5)) -T.check(mid > 0 and mid < 1, "and the tween between them is partial") -T.check(skyStrength(math.rad(70)) > skyStrength(math.rad(60)), - "strengthening as the camera pitches over") -T.eq(skyStrength(math.rad(15)), 0, "a shallow pitch paints nothing at all") +-- full strength at every rung, with the ramp left only for the ARRIVAL: the +-- camera eases up from flat when the mode is switched on, and the sky comes up +-- with it rather than appearing whole on the keypress +T.eq(skyStrength(math.rad(15)), 1, "the shallowest rung is full sky") +T.eq(skyStrength(math.rad(50)), 1, "so is the one below the top") +T.eq(skyStrength(TOP), 1, "and the top rung") +T.eq(skyStrength(0), 0, "a camera that has not tilted at all paints none") +local rising = skyStrength(math.rad(4)) +T.check(rising > 0 and rising < 1, + "and the first few degrees off flat are the fade-in") +T.check(skyStrength(math.rad(6)) > skyStrength(math.rad(3)), + "which strengthens as the camera lifts") -- the colour answers to the display mode, exactly as the terrain does: a -- hardcoded blue would sit wrong in the modes that are not colour modes @@ -1025,6 +1122,227 @@ T.check(green[2] > green[1] and green[2] > green[3], T.check(skyRGB("gbc_inv")[3] ~= blue[3], "GBC INV does not paint the same sky as GBC") +-- ------- and the sky is a banded gradient, with no picture behind it +-- +-- One flat blue was enough while the void was a sliver; with the horizon a +-- quarter of the way down the frame it is a wall of paint. So the sky is the +-- 8-bit skybox recipe: a short palette of blues painted as flat bands, deepest +-- overhead, with a checkerboard of the next band dithered into the bottom of +-- each. Nothing is baked to a fixed size and upscaled -- the bands fill the +-- window and the dither grid is cut to the diorama's own pixel scale. +do +local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") + +local function luma(c) return 0.299 * c[1] + 0.587 * c[2] + 0.114 * c[3] end + +-- the palette is the band list, and it belongs to the CLOCK now: DayNight +-- owns one per phase and blends between them. The row defaults to DAY, so +-- what the sky paints here is the day palette -- still every inch a GBC one. +local dayPal = DayNight.PALETTES.day +for i, c in ipairs(dayPal) do + T.check(c[1] % 8 == 0 and c[2] % 8 == 0 and c[3] % 8 == 0, + "palette entry " .. i .. " is a colour a Game Boy Color could show -- five " + .. "bits a channel, so every one is a multiple of 8") + T.check(c[3] > c[1], "and it is blue: more blue than red") +end +T.eq(#dayPal, 6, "six of them: twilight needs the rungs, and day matches") + +Voxel.angle = TOP +local skyGrad = skyRGB("gbc") +T.eq(#(skyGrad.bands or {}), #dayPal, + "the sky arrives with one band per palette entry") + +for i, band in ipairs(skyGrad.bands) do + if i > 1 then + T.check(luma(band) > luma(skyGrad.bands[i - 1]), + "band " .. i .. " is lighter than the one above it: the sky pales toward " + .. "the horizon") + end +end +-- read backwards out of the palette, which is stored in shade order (lightest +-- first) so a display mode's own four colours drop straight in +local deepest = dayPal[#dayPal] +T.check(math.abs(skyGrad.bands[1][1] - deepest[1] / 255) < 1e-9, + "the top band is the palette's deep rung, unmixed") + +-- the fill a caller clears to IS the palest band, so the haze below the horizon +-- and the bottom of the sky are one colour and the horizon has no seam +local palest = skyGrad.bands[#skyGrad.bands] +T.check(math.abs(skyGrad[1] - palest[1]) < 1e-9 + and math.abs(skyGrad[2] - palest[2]) < 1e-9 + and math.abs(skyGrad[3] - palest[3]) < 1e-9, + "the flat fill is the palest band, so the horizon line has no seam of its own") +T.eq(skyGrad[4], 1, "and the tween strength still rides on the descriptor") + +-- the gradient belongs to the VOXEL 75 rung and the walking camera on it. The +-- arena shot asks for the sky by the flat route (VoxelScene.skyColor) and gets +-- exactly the sky it always had -- its placed camera's horizon is above the +-- frame, so there would be no gradient to see from down there anyway. +local flat = VoxelScene.skyColor(outside, 1) +T.eq(flat.bands, nil, "a battle's arena sky is the flat fill, not the gradient") +T.check(math.abs(flat[1] - palest[1]) < 1e-9 + and math.abs(flat[3] - palest[3]) < 1e-9, + "and it is the hour's haze, so a staged fight stands under the same sky " + .. "free-roam does -- navy at midnight, gold at dusk") + +-- the same call, the same numbers, and the same TABLE: the bands are memoised +-- per display mode, so a frame that paints the sky allocates nothing to do it +local again = Sky.bands() +T.eq(Sky.bands(), again, "the bands are computed once and held, not rebuilt") + +local greyBands = skyRGB("og").bands +for i, band in ipairs(greyBands) do + T.check(math.abs(band[1] - band[2]) < 1e-9 + and math.abs(band[2] - band[3]) < 1e-9, + "GRAY gets four greys, not four blues (band " .. i .. ")") +end +T.check(luma(greyBands[#greyBands]) > luma(greyBands[1]), + "and they still climb toward the horizon") + +-- ------- where the bands go is the camera's own answer +-- +-- The pale end has to meet the horizon at any pitch, fov or window shape, so it +-- is placed on the ground plane's vanishing line -- which is what projecting a +-- direction ALONG the ground through the scene matrix gives. Checked against the +-- limit of projecting real ground points further and further away, so a retuned +-- camera either still agrees with this or says so. +local function groundY(h, dist) + local m = Voxel3D.vp + local y = m[5] * 0 + m[7] * -dist + m[8] + local w = m[13] * 0 + m[15] * -dist + m[16] + return (y / w * 0.5 + 0.5) * h +end + +Voxel.angle = TOP +local vh = 288 +Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, vh) +local horizon = Voxel3D.horizonY(vh) +T.check(horizon and horizon > 0 and horizon < vh, + "at the top rung the horizon is inside the frame, which is why there is a sky") +T.check(math.abs(groundY(vh, 200000) - horizon) < 0.5, + ("ground at infinity converges on it: %.2f vs %.2f") + :format(groundY(vh, 200000), horizon)) +T.check(groundY(vh, 200) > groundY(vh, 2000) + and groundY(vh, 2000) > horizon, + "and nearer ground is always below it, never above") +T.check(horizon < vh / 2, + "the horizon sits in the upper half: the sky is a band across the top, not " + .. "half the picture") + +-- the fraction is a property of the camera, not of the canvas +Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, vh) +local tall = Voxel3D.horizonY(vh * 3) +T.check(math.abs(tall / (vh * 3) - horizon / vh) < 1e-9, + "a taller canvas puts it at the same fraction, so the bands scale with it") + +Voxel.angle = 0 +Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, vh) +T.eq(Voxel3D.horizonY(vh), nil, + "a camera looking straight down has no horizon to find, and paints no bands") + +-- ------- where the sky's bottom edge goes at each rung +-- +-- The camera's own horizon when that is in frame -- which is the top rung -- and +-- otherwise a fixed slice of the frame, because at the steeper rungs the void +-- that shows is where the ground runs OUT rather than what is above the horizon. +-- One sky across the whole ladder either way. +T.check(math.abs(Sky.region(288, 66.83) - 66.83) < 1e-9, + "a horizon in frame is where the sky ends") +T.eq(Sky.region(288, -930), 288 * Sky.SPAN, + "a horizon above the frame falls back to a fixed slice of it") +T.eq(Sky.region(288, nil), 288 * Sky.SPAN, "and so does no horizon at all") +T.eq(Sky.region(288, 4000), 288, "a horizon below the frame fills it") +T.eq(Sky.region(0, 40), nil, "and a canvas with no height paints nothing") +T.check(Sky.SPAN > 0.1 and Sky.SPAN < 0.5, + "the fallback slice is a band across the top, not half the picture") + +-- ------- the pass, as it is actually issued +-- +-- One rectangle through one shader: no texture, no baked image, nothing being +-- resampled -- which is the whole reason it is drawn this way rather than +-- generated once and scaled. Every pixel answers from its own canvas coordinate, +-- so it is computed at the size it is shown at. +-- +-- And the depth mode is put back to what it was, which is the piece that would +-- break the frame: a rectangle drawn under the pass's own ("lequal", true) stamps +-- itself across the depth buffer at the near plane and hides the whole world +-- behind the sky. +local realGraphics = love.graphics +local rects, depthCalls, sent, shaderUses = {}, {}, {}, 0 +local fakeShader = { + send = function(_, name, a, b, c, d) + sent[name] = { a, b, c, d } + end, +} +love.graphics = { + getShader = function() return nil end, + setShader = function(sh) if sh then shaderUses = shaderUses + 1 end end, + getDepthMode = function() return "lequal", true end, + setDepthMode = function(cmp, write) + depthCalls[#depthCalls + 1] = tostring(cmp) .. "/" .. tostring(write) + end, + setColor = function() end, + newShader = function() return fakeShader end, + rectangle = function(_, x, y, w, h) + rects[#rects + 1] = { x = x, y = y, w = w, h = h } + end, +} + +-- 320x288 canvas, horizon at 66.83, diorama pixels 7 canvas pixels square +local painted = Sky.paint(320, 288, skyGrad, 66.83, 7) +love.graphics = realGraphics + +T.eq(painted, true, "the sky paints") +T.eq(shaderUses, 1, "through one shader") +T.eq(#rects, 1, "over one rectangle -- not one per band, and not one per cell") +T.eq(rects[1].x, 0, "from the left edge") +T.eq(rects[1].w, 320, "across the full width of the frame") +T.eq(rects[1].y, 0, "and from the top edge") +T.eq(rects[1].h, 67, "down to the horizon") + +T.eq(sent.count[1], #skyGrad.bands, "the band count goes to the shader") +T.check(math.abs(sent.edge[1] - 66.83) < 1e-9, "with the sky's bottom edge") +T.eq(sent.cell[1], 7, + "and the diorama's pixel size, which is what puts the bands and the dither " + .. "cells on the world's own grid") +T.eq(sent.start[1], Sky.DITHER_START, "and where in a band the checker begins") +T.eq(sent.alpha[1], 1, "and the tween strength") +T.check(sent.bands[1] and sent.bands[1][1] ~= nil, + "the palette goes as one array rather than a send per band") + +T.eq(depthCalls[1], "always/false", "the sky is drawn with depth writes OFF") +T.eq(depthCalls[#depthCalls], "lequal/true", + "and the pass's own depth mode is handed straight back") + +-- ------- and it follows the zoom, in the frame the zoom changed +-- +-- The cell size is handed in every frame rather than cached, so a ZOOM keypress +-- -- which is what changes the diorama's pixels-per-world-pixel -- lands in the +-- next frame with nothing to rebuild and nothing left over at the old scale. +local zoomed = {} +love.graphics = { + getShader = function() return nil end, setShader = function() end, + getDepthMode = function() return "lequal", true end, + setDepthMode = function() end, + setColor = function() end, + newShader = function() return fakeShader end, + rectangle = function(_, _, _, w, h) zoomed.rect = { w = w, h = h } end, +} +sent = {} +Sky.paint(1920, 1080, skyGrad, 250.6, 12) +love.graphics = realGraphics +T.eq(zoomed.rect.w, 1920, "a bigger window is filled to its own width") +T.eq(zoomed.rect.h, 251, "and its own horizon") +T.eq(sent.cell[1], 12, "with the cell size that came in with it, not a cached one") + +T.eq(Sky.paint(320, 288, { 0, 0, 1, 1 }, 40, 7), false, + "a descriptor with no bands on it is the old flat sky, untouched") +T.eq(Sky.paint(320, 0, skyGrad, 40, 7), false, + "and a frame with no height paints nothing at all") +end + Voxel.angle = 0 -- ------- overworld battles: where the fight is staged @@ -1428,6 +1746,484 @@ T.check(range > 0, "with a ramp out of it, so the band edge has no seam") local wide = select(2, BattleDOF.bandFor(120, 30, 144)) T.check(wide > band, "marks further apart hold a deeper slab in focus") +-- ------- the HUDs are snapped to the window's own edges +-- +-- The battle screen is 160x144 in the middle of the window and the world is the +-- whole of it, which left both HUD blocks huddled in the middle of the frame +-- with map on either side of them. Each is snapped to its own side instead: the +-- foe's to the left edge, the player's to the right. Measured in world-canvas +-- pixels, because that is the surface they are composited into -- the GB canvas +-- they are drawn in cannot reach past its own 160 columns. +do +local hudShot = { lx = 100, ly = 12, scale = 3, pw = 1000, ph = 500 } +local hudRects, bandX = Battles.snapRects(hudShot) +local hudRect = Battles.HUD_RECT + +T.eq(hudRects.enemy[1], 0, "the foe's panel starts at the window's left edge") +T.eq(hudRects.player[1] + hudRects.player[3], hudShot.pw, + "and the player's ends at the right one") +T.check(hudRects.enemy[1] < hudShot.lx, + "so the foe's block has left the letterbox it used to sit in") +T.check(hudRects.player[1] > hudShot.lx + hudRect.player[1] * hudShot.scale, + "and the player's has gone the other way") + +-- the vertical is untouched: both blocks stay on the rows the GB put them on +T.eq(hudRects.enemy[2], hudShot.ly + hudRect.enemy[2] * hudShot.scale, + "the foe's block keeps its own rows") +T.eq(hudRects.player[2], hudShot.ly + hudRect.player[2] * hudShot.scale, + "and so does the player's") + +-- and their size: a block is the same GB tiles at the same scale as the rest of +-- the art, moved and not stretched +T.eq(hudRects.enemy[3], hudRect.enemy[3] * hudShot.scale, + "a block is its own width at the frame's scale") +T.eq(hudRects.player[4], hudRect.player[4] * hudShot.scale, + "and its own height") + +-- the band each block is cut out of is placed so the block lands on the rect +-- above; that is what the panel and the glyphs agreeing depends on +T.eq(bandX.enemy + hudRect.enemy[1] * hudShot.scale, hudRects.enemy[1], + "the foe's band is offset so its block lands on its panel") +T.eq(bandX.player + hudRect.player[1] * hudShot.scale, hudRects.player[1], + "and the player's likewise") + +-- a window the shape of the GB screen has nowhere to snap TO, and the player's +-- block already ends at column 160, so it must not move at all +local snug = { lx = 0, ly = 0, scale = 4, pw = 160 * 4, ph = 144 * 4 } +local snugRects = Battles.snapRects(snug) +T.eq(snugRects.player[1], hudRect.player[1] * snug.scale, + "on a GB-shaped window the player's block stays exactly where it was") +T.eq(snugRects.enemy[1], 0, "and the foe's is flush with a left edge it already met") + +-- the bands together cover every row drawHUDs draws into (0-96: the two HUDs, +-- the pokeball rows and the safari ball count) and never overlap, so nothing it +-- draws is dropped or shown twice +local e, p = Battles.HUD_BAND.enemy, Battles.HUD_BAND.player +T.eq(e[2], 0, "the foe's band starts at the top of the frame") +T.eq(e[2] + e[4], p[2], "the player's picks up exactly where it ends") +T.check(p[2] + p[4] >= 96, "and together they reach the bottom of the HUD rows") +T.check(e[2] + e[4] <= hudRect.player[2], + "the split falls between the two blocks, so neither is cut in half") +T.eq(e[1], 0, "the bands are full width") +T.eq(e[3], 160, "so a shaken HUD or a long name is carried out with its block") +end + +-- ------- the way out of a battle is a fade, not a cut +-- +-- The engine wipes INTO a battle and cuts straight out of it. While voxel mode +-- is on that cut is between a placed camera looking across an arena and a +-- diorama looking down on a walking player, so the battle fades out, closes +-- behind the black, and the map fades up. +-- +-- The pop ORDER is the part that has to be right: BattleState:finish pops +-- whatever is on top, which is the fade while it is up, so the fade has to be +-- off the stack before the battle finishes and back on it afterwards. +do +local Exit = run.loader.exports.DRAMATIC_SHAPE.lib.require("BattleExit") + +T.eq(Data.transitions and Data.transitions[Exit.ID] and + Data.transitions[Exit.ID].frames, Exit.FRAMES, + "the fade's timing is a registered transitions record, retunable in data") + +local function fakeStack(...) + local s = { states = { ... } } + function s:top() return self.states[#self.states] end + function s:push(state) self.states[#self.states + 1] = state end + function s:pop() return table.remove(self.states) end + return s +end + +-- headless has no depth buffer, so the real gate answers no on every rung; +-- pin it, which is what the seam is there for +local realModeOn = Exit.modeOn +Exit.modeOn = function() return true end + +T.eq(Exit.wanted(nil), false, "no battle, no fade") +T.eq(Exit.wanted({ game = { stack = {} } }), true, "a battle in voxel mode fades") +T.eq(Exit.wanted({ game = { stack = {} }, payDay = 100, result = "win" }), false, + "but not on an unpaid PAY DAY -- that finish() prints a message and comes " + .. "back, so the fade belongs to the call that really leaves") +Exit.modeOn = function() return false end +T.eq(Exit.wanted({ game = { stack = {} } }), false, + "and with voxel mode off the battle keeps the cut it always had") +Exit.modeOn = function() return true end + +local exitOw = { isOverworld = true } +local exitGame = { data = Data, overworld = exitOw } +local exitBattle = { game = exitGame } +exitGame.stack = fakeStack(exitOw, exitBattle) + +local finished = 0 +local fade = Exit.start(exitBattle, function() + finished = finished + 1 + exitGame.stack:pop() -- what BattleState:finish does: pops itself +end) +T.eq(exitGame.stack:top(), fade, "the fade goes on top of the battle it closes") +T.eq(Exit.veil(), 0, "and starts on the battle's own last live frame") + +for _ = 1, fade.frames - 1 do fade:update() end +T.check(Exit.veil() > 0.5, "the veil climbs while the battle is still up") +T.eq(exitGame.stack:top(), fade, "which is a frozen battle: the fade is on top") +T.eq(finished, 0, "and nothing has finished yet") + +fade:update() -- the frame the cut lands on +T.eq(finished, 1, "at full black the battle finishes for real") +T.eq(Exit.veil(), 1, "with the screen fully black over the swap") +T.eq(#exitGame.stack.states, 2, "the battle left the stack") +T.eq(exitGame.stack.states[1], exitOw, "the map is under it") +T.eq(exitGame.stack:top(), fade, + "and the fade went back on top of the map to bring it up") + +for _ = 1, fade.frames - 1 do fade:update() end +T.check(Exit.veil() < 0.5, "the veil falls away over the map") +fade:update() +T.eq(Exit.veil(), nil, "and the fade is done -- no veil left on the screen") +T.eq(exitGame.stack:top(), exitOw, "with the map back on top, playable") +T.eq(finished, 1, "the battle finished exactly once") + +-- ------- a blackout (or an evolution prompt) owns the way out itself +-- +-- Those push their own transition on the way through onFinish, so this fade +-- stops at the cut rather than fading in over the top of somebody else's. +local other = { isSomeoneElse = true } +local blackout = { game = exitGame } +exitGame.stack = fakeStack(exitOw, blackout) +local warpFade = Exit.start(blackout, function() + exitGame.stack:pop() -- the battle leaves + exitGame.stack:push(other) -- and a warp fade takes the screen +end) +for _ = 1, warpFade.frames do warpFade:update() end +T.eq(exitGame.stack:top(), other, "the state that took over is on top") +T.eq(Exit.veil(), nil, "and this fade let go of the screen at the cut") +T.eq(blackout.dramaticShapeLeaving, nil, + "with the flag cleared, so a finish() that really leaves fades again") + +-- ------- a stack cleared from under a fade cannot black the game out +-- +-- A script (or the shot driver) pops down to the overworld without asking. The +-- fade is gone, so the veil has to go with it -- nothing is left to fade it in. +exitGame.stack = fakeStack(exitOw, exitBattle) +local orphan = Exit.start(exitBattle, function() end) +orphan:update() +T.check(Exit.veil() > 0, "a live fade veils the frame") +while exitGame.stack:top() ~= exitOw do exitGame.stack:pop() end +T.eq(Exit.veil(), nil, "and a fade popped from under itself veils nothing") + +Exit.modeOn = realModeOn +end + +-- ------- the day/night cycle +-- +-- One twenty-minute clock, and everything is a pure function of it: the +-- pinned DAYTIME settings are fixed times on the dial, CYCLE lets it run, +-- and the sun, the moon, the shadows, the sky and the tint all read the same +-- number. What is checked here is the dial itself, the noon-exactness pledge +-- (DAY is the mod's existing sun, to the digit), the arcs' visibility (the +-- camera looks north, so the discs must actually cross the northern sky), +-- and the clock's ride through the save file. +do +local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") +local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") +local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") + +-- the dial and its pins +T.eq(DayNight.setting:get(), "day", "no time set means DAY: the default pin") +T.eq(DayNight.time(), 300, "and DAY is noon on the dial") +local PINS = { day = 300, night = 900, dusk = 600, dawn = 0 } +for name, t in pairs(PINS) do + DayNight.setting:sync(name) + T.eq(DayNight.time(), t, name .. " pins the clock to " .. t) +end + +-- CYCLE picks up from the pin the player was just looking at +DayNight.setting:sync("dusk") +DayNight.update(0) +DayNight.setting:sync("cycle") +DayNight.update(0) +T.eq(DayNight.clock, 600, "stepping onto CYCLE picks up from the pin: dusk") +DayNight.update(30) +T.check(math.abs(DayNight.time() - 630) < 1e-9, "and the clock then runs") +DayNight.clock = 1195 +DayNight.update(10) +T.check(math.abs(DayNight.clock - 5) < 1e-9, + "the dial wraps at twenty minutes, back into dawn") + +-- the sun: noon is the mod's existing sun, exactly +local kx, kz, moon = DayNight.shearAt(300) +T.check(not moon, "noon is the sun's") +T.check(math.abs(kx - (-0.85)) < 1e-9 and math.abs(kz - (-0.55)) < 1e-9, + ("DAY throws the shadows the mod always threw: (%.4f, %.4f)"):format(kx, kz)) +local kx0, kz0 = DayNight.shearAt(0) +T.check(math.abs(math.sqrt(kx0 * kx0 + kz0 * kz0) - DayNight.K_MAX) < 1e-9, + "a rising sun throws a LONG shadow, clamped -- never an infinite one") +T.eq(DayNight.strengthAt(0), 0, "and at the horizon it presses nothing") +T.eq(DayNight.strengthAt(300), 1, "at noon it presses in full") + +-- the moon: due north at mid-night, pressing softly south +local mkx, mkz, mmoon = DayNight.shearAt(900) +T.check(mmoon, "mid-night is the moon's") +T.check(math.abs(mkx) < 1e-9, "due north: no east-west drift at all") +T.check(math.abs(mkz - 1 / math.tan(math.rad(40))) < 1e-9, + "shadows fall south, away from it, cot(40) long") + +-- the palettes: pins land on their phase palette unmixed, blends stay on +-- the 5-bit lattice +local function palEq(a, b) + for i = 1, #a do + for ch = 1, 3 do if a[i][ch] ~= b[i][ch] then return false end end + end + return #a == #b +end +T.check(palEq(DayNight.palette(300), DayNight.PALETTES.day), + "noon paints the day palette, unmixed") +T.check(palEq(DayNight.palette(600), DayNight.PALETTES.dusk), + "the dusk pin paints dusk proper -- the blend is centred on it, not over it") +T.check(palEq(DayNight.palette(0), DayNight.PALETTES.dawn), + "and dawn's pin paints dawn") +local mid = DayNight.palette(562) -- halfway through day -> dusk +for i, c in ipairs(mid) do + T.check(c[1] % 8 == 0 and c[2] % 8 == 0 and c[3] % 8 == 0, + "blended band " .. i .. " is re-quantised onto the GBC lattice") +end +T.check(mid[1][3] < DayNight.PALETTES.day[1][3] + and mid[1][3] > DayNight.PALETTES.dusk[1][3], + "and sits between the two phases it blends") +-- day's blue horizon and dusk's gold one are near-complements, and a +-- straight lerp between complements bottoms out in grey -- so the evening +-- path bends through the golden-hour waypoint, and halfway down the blend +-- the horizon band must already be WARM +T.check(mid[1][1] > mid[1][3], + "mid-evening the horizon is gold, not the grey between blue and gold") +-- and the far side of sunset bends through violet the same way: halfway +-- from dusk to night the horizon is rose, not the taupe between gold and navy +local ev = DayNight.palette(645)[1] +T.check(ev[1] > ev[2] and ev[3] > ev[2], + "mid-fall of night the horizon is violet-rose, not grey") +T.eq(DayNight.palette(300), DayNight.palette(300.4), + "the palette is memoised within the second, not rebuilt per frame") + +-- the tint: noon is neutral, night is dim and blue, indoors is always noon +local tn = DayNight.tint(true, 900) +T.check(tn[1] < 1 and tn[3] > tn[1], "night light is dim and leans blue") +T.eq(DayNight.tint(true, 300)[1], 1, "noon multiplies by one") +T.eq(DayNight.tint(false, 900)[1], 1, + "a cave at midnight is exactly as dark as a cave at noon: neutral indoors") + +-- the twilight glow: gold at the sun's horizons, never for the moon +local amt, gc = DayNight.glow(600) +T.check(math.abs(amt - 1) < 1e-9, "dusk glows in full") +T.check(gc[1] > gc[3], "and warm: more red than blue") +T.eq((DayNight.glow(300)), 0, "noon does not glow") +T.eq((DayNight.glow(900)), 0, "and the moon rises silver, not gold") + +-- the discs cross the sky the camera can actually see +Voxel.angle = math.rad(75) +Voxel3D.camera = nil +Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, 288) +local horizon = Voxel3D.horizonY(288) + +DayNight.setting:sync("night") +local mb = Voxel3D.skyBody(320, 288) +T.check(mb and mb.moon, "at the NIGHT pin the moon is in frame") +T.check(math.abs(mb.x - 160) < 8, + ("due north is screen centre: got x %.1f"):format(mb.x)) +T.check(mb.y > 0 and mb.y < horizon, + ("hanging above the horizon point: y %.1f vs %.1f"):format(mb.y, horizon)) + +DayNight.setting:sync("day") +T.eq(Voxel3D.skyBody(320, 288), nil, + "the noon sun is overhead behind the camera -- correctly not in frame") + +DayNight.setting:sync("dawn") +local db = Voxel3D.skyBody(320, 288) +T.check(db and not db.moon, "at the DAWN pin the rising sun is in frame") +T.check(db.x > 160 and db.x < 320, + ("north of east is screen right: got x %.1f"):format(db.x)) +T.check(math.abs(db.y - horizon) < 2, + "standing on the horizon point, half-risen") +T.check(db.glowAmt > 0.9, "and wrapped in the dawn glow") + +DayNight.setting:sync("dusk") +local sb = Voxel3D.skyBody(320, 288) +T.check(sb and sb.x < 160, "the DUSK sun sets screen LEFT -- north of west") + +-- the rig: outdoor follows the clock, indoor is pinned to noon +DayNight.setting:sync("night") +DayNight.applyRig(true) +T.check(math.abs(ShadowMap.KX - mkx) < 1e-9 + and math.abs(ShadowMap.KZ - mkz) < 1e-9, + "outdoors at night the sun pass is lit by the moon") +T.check(math.abs(Voxel3D.SHADOW_ALPHA - DayNight.ALPHA_MOON) < 1e-9, + "at the moon's own softer weight") +DayNight.applyRig(false) +T.check(math.abs(ShadowMap.KX - (-0.85)) < 1e-9 + and math.abs(ShadowMap.KZ - (-0.55)) < 1e-9, + "indoors the rig stays the mod's noon sun, whatever the clock says") +T.check(math.abs(Voxel3D.SHADOW_ALPHA - DayNight.ALPHA_SUN) < 1e-9, + "at the weight it always had") +T.eq(DayNight.shadowScale(false), 1, "an indoor arena keeps its full shadows") +T.check(math.abs(DayNight.shadowScale(true, 900) + - DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) < 1e-9, + "an outdoor arena under the moon presses at the moon's ratio") +T.check(DayNight.shadowScale(true, 600) < 1e-9, + "and a sunset takes the arena's shadows with it") + +-- the engine's own vocabulary, for map.palette and music.select +T.eq(DayNight.tod(300), "DAY", "noon is DAY") +T.eq(DayNight.tod(900), "NIGHT", "mid-night is NIGHT") +T.eq(DayNight.tod(0), "MORNING", "dawn is MORNING") +T.eq(DayNight.tod(600), "EVENING", "dusk is EVENING") + +-- the clock rides the save slot +local modApi = run.loader.exports.DRAMATIC_SHAPE.lib.mod +DayNight.setting:sync("cycle") +DayNight.clock = 777 +DayNight.store() +DayNight.clock = 5 +DayNight.restore() +T.eq(DayNight.clock, 777, "the clock survives the round trip through mod.save") +modApi.save:set(DayNight.SAVE_KEY, nil) +DayNight.restore() +T.eq(DayNight.clock, 300, "a save with no clock in it starts at day") + +-- arriving at FULL sets the clock going +local Game = require("src.core.Game") +local hadSave = Game.save +Game.save = { options = {} } +DayNight.setting:sync("day") +defs.voxel.update(0, 2) -- any rung that is not FULL +defs.voxel.update(0, 1) -- and the arrival +T.eq(DayNight.setting:get(), "cycle", + "FULL switches DAYTIME to CYCLE -- the diorama gets its running sky") +Game.save = hadSave + +-- put the room back the way it was found +DayNight.setting:sync("day") +DayNight.clock = 300 +DayNight.applyRig(false) +Voxel3D.tint = { 1, 1, 1 } +Voxel3D.vp = nil +end + +-- ------- night falls in the forest +-- +-- Viridian Forest is not outdoor (no sky, and the light through the leaves +-- has no direction to swing) and not a sealed room either. Of everything +-- the clock does, exactly one thing reaches a canopy map: the hour's tint. +-- The scenes wire it as tint(outdoor or isCanopy(map)) over the unchanged +-- noon rig, so what is checked here is the classification itself. +do +local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") +T.check(DayNight.isCanopy({ id = "VIRIDIAN_FOREST" }), + "Viridian Forest stands under a canopy") +T.check(not DayNight.isCanopy({ id = "MT_MOON_1F" }), + "a cave does not -- midnight there is exactly as dark as noon") +T.check(not DayNight.isCanopy({ id = "PALLET_TOWN" }), + "and an outdoor town is already the clock's in full") +T.check(not DayNight.isCanopy(nil), "no map, no canopy") +end + +-- ------- a shadow keeps hold of the feet that throw it +-- +-- The depth compare forgives `slack` world pixels so lit ground does not +-- acne, and that forgiveness detaches a standing card's shadow from its +-- feet by the same amount -- worse the lower the sun. Cards are drawn into +-- the map snugged TOWARD the sun along their own ray -- which moves their +-- stored depth and nothing about where their shadow falls -- taking most of +-- the forgiveness back for the shadow they throw and for nothing else. +do +local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") +local Mat4 = run.loader.exports.DRAMATIC_SHAPE.lib.require("Mat4") +local dir = ShadowMap.sunDir() +local s = -ShadowMap.slack * ShadowMap.SNUG +local m = ShadowMap.snug(nil) +T.check(math.abs(m[4] - dir[1] * s) < 1e-9 + and math.abs(m[8] - dir[2] * s) < 1e-9 + and math.abs(m[12] - dir[3] * s) < 1e-9, + "snug() moves a caster toward the sun -- AGAINST the light's travel") +T.check(m[8] > 0, "which is upward: the sun is above the world it lights") +T.check(ShadowMap.SNUG < 1, + "and takes back less than the whole forgiveness, so a card cannot land " + .. "on the float-equality knife edge against its own stored depth") +local snugged = ShadowMap.snug(Mat4.translate(10, 0, 6)) +T.check(math.abs(snugged[4] - (10 + dir[1] * s)) < 1e-9 + and math.abs(snugged[12] - (6 + dir[3] * s)) < 1e-9, + "and composes over the caster's own transform, not instead of it") +end + +-- ------- the glass in the windows +-- +-- Panes are found by SHAPE in the tileset art -- a black border row, four +-- or five black-flanked glass rows, a closing border -- at pixel +-- granularity, because the door's pane straddles a 2x2 tile block and the +-- building's sits a row down inside its tile. The scan takes a pure reader, +-- so the geometry is checked here without an image in sight. +do +local GlassMask = run.loader.exports.DRAMATIC_SHAPE.lib.require("GlassMask") +local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") + +local W, H = 32, 16 +local blackAt = {} +local function paint(x, y) blackAt[y * W + x] = true end +local function pane(x0, y0, rows, hole) + for c = 1, 6 do paint(x0 + c, y0); paint(x0 + c, y0 + rows + 1) end + for r = 1, rows do + paint(x0, y0 + r); paint(x0 + 7, y0 + r) + if hole and r == 2 then paint(x0 + 3, y0 + r) end + end +end +pane(8, 1, 5) -- a building pane, one row down inside its tile +pane(20, 3, 4) -- a door pane, straddling a tile row boundary +pane(0, 8, 5, true) -- a near-miss: one black texel inside the glass + +local function getPixel(x, y) + if blackAt[y * W + x] then return 0, 0, 0 end + return 0.66, 0.66, 0.66 +end + +local rects = GlassMask.scan(getPixel, W, H) +T.eq(#rects, 2, "the scan finds the two real panes and rejects the near-miss") +T.check(rects[1].x == 9 and rects[1].y == 2 + and rects[1].w == 6 and rects[1].h == 5, + "the building pane's glass is the 6x5 interior, border excluded") +T.check(rects[2].x == 21 and rects[2].y == 4 + and rects[2].w == 6 and rects[2].h == 4, + "the door pane's glass is the 6x4 interior, wherever it sits in the grid") + +-- black is the BORDER black, not the art's dark grey rung +T.check(GlassMask._isBlack(0, 0, 0), "true black is border") +T.check(not GlassMask._isBlack(85 / 255, 85 / 255, 85 / 255), + "the dark grey shade is not") + +-- the lamps behind the glass follow the clock, not the sky's own light +T.eq(DayNight.windowLight(300), 0, "no lamps at noon") +T.eq(DayNight.windowLight(900), 1, "all of them at mid-night") +T.check(math.abs(DayNight.windowLight(600) - 0.7) < 1e-9, + "they come on through dusk -- lit windows against the sunset") +T.check(DayNight.windowLight(645) > 0.9, + "and are fully on by the fall of night") +T.check(math.abs(DayNight.windowLight(0) - 0.25) < 1e-9, + "mostly out again by dawn") + +-- the pass defaults to no glass at all until a scene says otherwise +T.eq(Voxel3D.glassMask, nil, "no mask bound by default") +T.eq(Voxel3D.glassNight, 0, "and the lamps off") + +-- the glint is fed by TRAVEL, not by a clock: still camera, still glass +local g = {} +VoxelScene.glintStep(g, 100, 100) +T.eq(g.amp, 0, "the first frame establishes position and shows no sheen") +for i = 1, 12 do VoxelScene.glintStep(g, 100 + i, 100) end +T.eq(g.amp, 1, "a dozen frames of walking fades the glint fully in") +local held = g.phase +T.check(held > 0 and held < 2 * math.pi, "with the phase advanced by the travel") +for _ = 1, 20 do VoxelScene.glintStep(g, 112, 100) end +T.eq(g.amp, 0, "standing still fades it back out within a beat") +T.eq(g.phase, held, "and the phase does not move while the camera does not") +end + Pipelines.reset() run.release() diff --git a/tests/monline_probe.lua b/tests/monline_probe.lua new file mode 100644 index 0000000..a642202 --- /dev/null +++ b/tests/monline_probe.lua @@ -0,0 +1,50 @@ +-- Probe: one overworld battle, held on the menu beat, shot large. +-- +-- SHOT_DIR=... POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/monline_probe.lua love . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + + love.math.setRandomSeed(20260727) + + game.save.party = { + Pokemon.new(game.data, "CHARIZARD", 45), + Pokemon.new(game.data, "PIKACHU", 40), + } + game.save.player.name = "RED" + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + U.log("DRAMATIC_SHAPE lib:", tostring(lib)) + local Battles = lib and lib.require("OverworldBattle") + U.log("OverworldBattle:", tostring(Battles)) + + U.teleport(game, "ROUTE_1", 5, 8, "down") + U.wait(120) + + local classes = {} + for id, rec in pairs(game.data.trainers) do + if type(id) == "string" and id:sub(1, 1) ~= "_" + and type(rec) == "table" and rec.parties and rec.parties[1] then + classes[#classes + 1] = id + end + end + table.sort(classes) + U.log("class:", classes[1]) + local battle = BattleState.newTrainer(game, classes[1], 1) + battle.onFinish = function() end + game.overworld:pushBattle(battle) + + U.wait(70) + for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end + local arena = Battles and Battles.arena() + U.log("arena:", arena and arena.shape or "none") + U.shot(game, DIR .. "/probe_menu.png") + for _ = 1, 20 do U.tap(game, "a"); U.wait(8) end + U.wait(90) + U.shot(game, DIR .. "/probe_menu2.png") + U.log("done -- " .. DIR) + love.event.quit() +end diff --git a/tests/voxel_acne_probe.lua b/tests/voxel_acne_probe.lua new file mode 100644 index 0000000..31ae673 --- /dev/null +++ b/tests/voxel_acne_probe.lua @@ -0,0 +1,175 @@ +-- Driver: isolate the diagonal banding on flat lit geometry. +-- +-- Shoots the SAME stand point four ways in ONE launch, so palette, camera +-- and framing are identical and the only variable is the sun pass: +-- +-- _on shadows as shipped +-- _off SHADOW_ALPHA = 0 -- the sun pass still runs, nothing reads it +-- _flatN a CONSTANT bias of N world px, i.e. SLOPE switched off +-- +-- and prints, from the frustum the run actually fitted, how much depth +-- slack each face orientation NEEDS against the slack it is given. A face +-- whose need exceeds the slack shadows itself in a moire -- acne -- which +-- reads as diagonal banding, since the ramp it aliases against runs along +-- the light's frame and the sun sits southeast. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_acne_probe.lua lovec . +-- +-- knobs (env): +-- ACNE_MAP map id (default VIRIDIAN_CITY) +-- ACNE_SPOT "x,y[,facing]" (default 32,9,up -- the gym wall) +-- ACNE_LEVELS comma list of voxel levels (default "3") +-- ACNE_FLAT comma list of constant-bias values to compare against +-- (default "1" -- what shipped before SLOPE existed) +-- SHOT_DIR output directory, must exist (default "shots") +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local SPEED = math.max(1, + math.floor(tonumber(os.getenv("POKEPORT_SPEED")) or 1)) + local function wait(n) U.wait(n * SPEED) end + + local DIR = os.getenv("SHOT_DIR") or "shots" + local mapId = os.getenv("ACNE_MAP") or "VIRIDIAN_CITY" + local sx, sy, facing = (os.getenv("ACNE_SPOT") or "32,9,up") + :match("^%s*(%d+)%s*,%s*(%d+)%s*,?%s*(%a*)") + facing = (facing ~= "" and facing) or "up" + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + local V = assert(handle and handle.lib, "DRAMATIC_SHAPE exports missing") + local ShadowMap = V.require("ShadowMap") + local Voxel3D = V.require("Voxel3D") + local Mat4 = V.require("Mat4") + + local levels = {} + for n in (os.getenv("ACNE_LEVELS") or "3"):gmatch("%d+") do + levels[#levels + 1] = tonumber(n) + end + local flats = {} + for n in (os.getenv("ACNE_FLAT") or "1"):gmatch("[%d.]+") do + flats[#flats + 1] = tonumber(n) + end + + -- ---- what the frustum this run fitted asks of the bias, per face + local FACES = { + { "top +Y", 0, 1, 0 }, + { "south +Z", 0, 0, 1 }, + { "east +X", 1, 0, 0 }, + { "west -X", -1, 0, 0 }, + { "north -Z", 0, 0, -1 }, + { "roof 45 N", 0, 0.7071, -0.7071 }, + { "roof 45 S", 0, 0.7071, 0.7071 }, + } + + local function report(tag) + local e = ShadowMap.extent + if not e then print("[acne] " .. tag .. ": no frustum yet") return end + local res = ShadowMap.res + local texX, texY, depth = e[1] / res, e[2] / res, e[3] + local d = ShadowMap.sunDir() + local view = Mat4.lookAt({ 0, 0, 0 }, d, { 0, 0, -1 }) + local R = { view[1], view[2], view[3] } + local Up = { view[5], view[6], view[7] } + local F = { -view[9], -view[10], -view[11] } + print(("[acne] %s: res %d frustum %.0fx%.0f deep %.0f" + .. " texel %.2fx%.2f world px slack %.2f px (stored %.6f)") + :format(tag, res, e[1], e[2], depth, texX, texY, + ShadowMap.slack, ShadowMap.bias)) + for _, fc in ipairs(FACES) do + local n = { fc[2], fc[3], fc[4] } + local nr = n[1]*R[1] + n[2]*R[2] + n[3]*R[3] + local nu = n[1]*Up[1] + n[2]*Up[2] + n[3]*Up[3] + local nf = n[1]*F[1] + n[2]*F[2] + n[3]*F[3] + -- a face the sun cannot see never samples its own depth, so it + -- cannot alias -- FACE_SHADE darkens those and the map is moot + if nf < 0 then + -- the plane's depth gradient in light space, times the half-texel + -- the 2x2 filter reaches out on each axis + local need = 0.5 * (math.abs(nr / nf) * texX + + math.abs(nu / nf) * texY) + print(("[acne] %s cos %.3f slope %.2f needs %.2f px %s") + :format(fc[1], math.abs(nf), + math.sqrt((nr/nf)^2 + (nu/nf)^2), need, + need > ShadowMap.slack and "<-- ACNE" or "ok")) + else + print(("[acne] %s unlit (sun behind it)"):format(fc[1])) + end + end + end + + local Zoom = require("src.render.Zoom") + Zoom.reset() + local steps = math.floor(tonumber(os.getenv("ACNE_ZOOM")) or 0) + for _ = 1, math.abs(steps) do + Zoom.step(steps > 0 and 1 or -1, game.renderer and game.renderer:fitScale()) + end + + Pipelines.setLevel("tiltshift", 0) + U.teleport(game, mapId, tonumber(sx), tonumber(sy), facing) + wait(20) + do + local vw, vh = game.renderer:worldViewSize() + print(("[acne] map %s at (%s,%s,%s) world view %dx%d px zoom %d") + :format(mapId, sx, sy, facing, vw, vh, Zoom.offset)) + end + + local shipped = Voxel3D.SHADOW_ALPHA + local shippedBias, shippedSlope = ShadowMap.BIAS, ShadowMap.SLOPE + + -- ACNE_MATRIX=1: the four corners of (shadows on/off) x (voxel grid + -- on/off), so a band can be attributed to one of them rather than + -- guessed at. The grid is the other thing in this pass that draws + -- regular lines, and at a grazing angle it moires. + local VoxelGrid = V.require("VoxelGrid") + local matrix = os.getenv("ACNE_MATRIX") == "1" + + local function shoot(name) + game.capturePath = ("%s/acne_%s.png"):format(DIR, name) + wait(4) + end + + for _, level in ipairs(levels) do + Pipelines.setLevel("voxel", level) + wait(30) -- outlast the camera tween + local label = Pipelines.levelLabel("voxel", level) or level + print(("[acne] --- level %s shadowsActive=%s") + :format(label, tostring(Voxel3D.shadowsActive()))) + report("as shipped") + shoot(("%s_v%s_on"):format(mapId, label)) + + if matrix then + for _, grid in ipairs({ true, false }) do + VoxelGrid.sync(grid) + for _, sun in ipairs({ true, false }) do + Voxel3D.SHADOW_ALPHA = sun and shipped or 0 + wait(8) + shoot(("%s_v%s_grid%s_sun%s"):format(mapId, label, + grid and "1" or "0", sun and "1" or "0")) + end + end + Voxel3D.SHADOW_ALPHA = shipped + end + + -- the sun pass still runs; the main pass simply stops reading it + Voxel3D.SHADOW_ALPHA = 0 + wait(6) + shoot(("%s_v%s_off"):format(mapId, label)) + Voxel3D.SHADOW_ALPHA = shipped + + for _, b in ipairs(flats) do + ShadowMap.BIAS, ShadowMap.SLOPE = b, 0 + ShadowMap.invalidate() -- force fit() to recompute ShadowMap.bias + wait(8) + report(("flat %.1f"):format(b)) + shoot(("%s_v%s_flat%s"):format(mapId, label, tostring(b):gsub("%.", "p"))) + end + ShadowMap.BIAS, ShadowMap.SLOPE = shippedBias, shippedSlope + ShadowMap.invalidate() + wait(6) + end + + Pipelines.setLevel("voxel", 0) + wait(5) + print("[acne] done") +end diff --git a/tests/voxel_figure_test.lua b/tests/voxel_figure_test.lua new file mode 100644 index 0000000..6f8e529 --- /dev/null +++ b/tests/voxel_figure_test.lua @@ -0,0 +1,245 @@ +-- Figures: a person drawn INTO furniture, cut out by an authored pixel +-- mask and stood up on top of it (TileShape.figures / Structures. +-- buildFigures). The Pokemon Center's seated man is the case the feature +-- exists for, so this drives the real profile entry over a hand-built +-- copy of the couch block he is drawn in -- POKECENTER blockset entry $08, +-- as every Center places it: +-- +-- y=8 36 37 57 11 36/52 west wall strip, 37/53 the MAN, +-- y=9 52 53 60 27 57/60 floor he overhangs east onto, +-- y=10 38 39 54 11 38/39 cushion, 42/43 the couch's base +-- y=11 42 43 26 27 +-- +-- No love, no GPU and no pixel access: a figure is authored rather than +-- detected, which is exactly what lets it build headless. +-- +-- luajit mods/DramaticShapeVoxelMod/tests/voxel_figure_test.lua + +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") + +-- ------- the mod namespace (mirrors main.lua's V, minus the mod loader) + +local ROOT = os.getenv("DS_MOD_PATH") or "mods/DramaticShapeVoxelMod" +local V = { path = ROOT } + +local function chunkFor(rel) + local f = assert(io.open(ROOT .. "/" .. rel, "rb"), rel .. " is missing") + local src = f:read("*a") + f:close() + return assert(load(src, "@" .. ROOT .. "/" .. rel)) +end + +local modules, dataFiles = {}, {} +function V.require(name) + if modules[name] == nil then + modules[name] = chunkFor("lib/" .. name .. ".lua")(V) + end + return modules[name] +end +function V.data(name) + if dataFiles[name] == nil then + dataFiles[name] = chunkFor("data/" .. name .. ".lua")(V) + end + return dataFiles[name] +end + +local TileShape = V.require("TileShape") +local Structures = V.require("Structures") + +-- ------- the authored mask parses + +local figs = TileShape.figures("POKECENTER") +T.check(type(figs) == "table" and #figs == 1, + "POKECENTER carries exactly one figure") + +local fig = figs[1] +T.eq(fig.w, 3, "the figure is three tiles across") +T.eq(fig.h, 2, "the figure is two tiles tall") +T.eq(fig.n, 139, "the mask claims 139 pixels of the 384 it spans") +T.check(fig.class == nil, + "a figure carries no class -- it is always a flat sprite card") + +local W = fig.w * 8 +local function on(lx, ly) return fig.mask[ly * W + lx] == true end + +-- the BACK OF HIS HEAD, in the two rightmost columns of tile 36 (the +-- couch's west arm): rows 0-1 are the arm, row 2 is his hair in column 7 +-- only, rows 3-7 are his hair in both +T.check(not on(6, 0) and not on(7, 0) and not on(6, 1) and not on(7, 1), + "the arm's own top rows stay with the couch") +T.check(not on(6, 2) and on(7, 2), "his hair starts in column 7 at row 2") +for ly = 3, 7 do + T.check(on(6, ly) and on(7, ly), + "the back of his head fills both columns at row " .. ly) +end + +-- no holes in him: 37/53's column 7 (local 15) is the couch's east rule AND +-- the right side of his face, and masking it out by shade slit his cheek +for ly = 5, 8 do + T.check(on(15, ly), + "his cheek is solid at row " .. ly .. " (the column-7 slit)") +end + +T.eq(fig.tiles[1], 36, "it starts at the couch's west arm") +T.eq(fig.tiles[2], 37, "then the tile his head is drawn in") +T.eq(fig.under[1], 52, "the arm wears the plain strip once his hair is off") +T.eq(fig.under[2], 39, "his head tile wears the couch's own cushion") +T.eq(fig.under[3], 1, "and the floor tiles wear the clean art (57 -> 1)") +T.eq(fig.under[6], 26, "and (60 -> 26)") + +-- the mask is one connected figure: an overhang that floats free of him +-- is the failure this feature exists to avoid +local seen, first = {}, nil +for i in pairs(fig.mask) do first = first or i end +local stack, reached = { first }, 0 +seen[first] = true +while #stack > 0 do + local i = table.remove(stack) + reached = reached + 1 + local x, y = i % W, math.floor(i / W) + for dy = -1, 1 do + for dx = -1, 1 do + local nx, ny = x + dx, y + dy + local ni = ny * W + nx + if (dx ~= 0 or dy ~= 0) and nx >= 0 and nx < W + and fig.mask[ni] and not seen[ni] then + seen[ni] = true + stack[#stack + 1] = ni + end + end + end +end +T.eq(reached, fig.n, "the mask is a single connected figure") + +-- ------- the couch block, as the Centers place it + +local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end + +local COUNTER = { class = "counter", h = 8, art = "upright", + flat = false, authored = true } +local GROUND = { class = "ground", h = 0, art = "flat", + flat = true, authored = false } + +local BLOCK = { 36, 37, 57, 11, + 52, 53, 60, 27, + 38, 39, 54, 11, + 42, 43, 26, 27 } +local COUCH = { [36] = true, [37] = true, [38] = true, [39] = true, + [42] = true, [43] = true, [52] = true, [53] = true } + +local function scene() + local S = { shapeAt = {}, tileAt = {}, figures = {}, skip = {}, + ground = {}, runs = {} } + for i = 1, 16 do + local tx, ty = (i - 1) % 4, 8 + math.floor((i - 1) / 4) + local tile = BLOCK[i] + S.tileAt[keyOf(tx, ty)] = tile + S.shapeAt[keyOf(tx, ty)] = COUCH[tile] and COUNTER or GROUND + end + return S +end + +-- collision is per 16x16 cell: the couch's cell is blocked (he is drawn +-- sitting on it), the floor cell east of it is walkable +local map = { + tileset = { id = "POKECENTER", tilesPerRow = 16, + imageWidth = 128, imageHeight = 48 }, + isWalkableCell = function(_, cx) return cx >= 1 end, +} + +-- ------- he comes off the couch + +local S = scene() +Structures.buildFigures(S, map, 0, 3, 8, 11) + +T.eq(#S.figures, 1, "one figure card was built") +local card = S.figures[1] +T.eq(#card.quads, fig.n, "one quad per masked pixel, and nothing else") + +T.eq(S.tileAt[keyOf(0, 8)], 52, "the arm wears the plain strip now") +T.eq(S.tileAt[keyOf(1, 8)], 39, "his head tile now wears the cushion") +T.eq(S.tileAt[keyOf(1, 9)], 39, "his body tile too") +T.eq(S.tileAt[keyOf(2, 8)], 1, "the floor he overhung is clean floor again") +T.eq(S.tileAt[keyOf(2, 9)], 26, "both rows of it") +T.eq(S.tileAt[keyOf(1, 10)], 39, "the couch's own cushion row is untouched") +T.eq(S.tileAt[keyOf(1, 11)], 43, "and so is its drawn base") + +-- the couch keeps its box: a figure changes ART, never class +T.eq(S.shapeAt[keyOf(1, 8)].class, "counter", + "his tiles are still the couch's half-cell box") +T.check(S.skip[keyOf(1, 8)] ~= true, + "and are not skipped -- the couch still renders there") + +-- ------- the card is flat, and stands at its feet + +local minX, maxX, minY, maxY, minZ, maxZ +for _, q in ipairs(card.quads) do + for c = 1, 4 do + local p = q[c] + minX = math.min(minX or p[1], p[1]); maxX = math.max(maxX or p[1], p[1]) + minY = math.min(minY or p[2], p[2]); maxY = math.max(maxY or p[2], p[2]) + minZ = math.min(minZ or p[3], p[3]); maxZ = math.max(maxZ or p[3], p[3]) + end +end + +T.eq(minZ, 0, "the card is a single plane at z = 0 (no thickness)") +T.eq(maxZ, 0, "on both sides -- it is a sprite, not a slab") +T.eq(minY, 0, "local space: his feet are the card's origin") +T.eq(maxY, 16, "and he is his drawn 16px tall") +T.eq(minX, 0, "his west edge is the card's origin too") +T.eq(maxX, 12, "and he is 12px wide -- arm hair to floor overhang") + +-- ------- and where VoxelScene stands it + +T.eq(card.y, 8, "his feet stand on the couch's top face, not the floor") +T.eq(card.wx, 6, "anchored at the back of his head, in tile 36's column 6") +T.eq(card.wz, 76, + "and pivoting at the middle of the tile row his feet are drawn in") + +-- ------- a map that does not draw him builds nothing + +local other = scene() +other.tileAt[keyOf(1, 8)] = 40 +Structures.buildFigures(other, map, 0, 3, 8, 11) +T.eq(#other.figures, 0, "no match, no figure") +T.eq(other.tileAt[keyOf(2, 8)], 57, "and nothing repainted") + +-- ------- and it never fires twice on the same drawing + +local twice = scene() +Structures.buildFigures(twice, map, 0, 3, 8, 11) +Structures.buildFigures(twice, map, 0, 3, 8, 11) +T.eq(#twice.figures, 1, + "the repaint replaces the pattern, so a rescan cannot match it again") + +-- ------- prop_bg: the shades a pinned prop treats as background +-- +-- The potted plants needed this: their pot's olive base is drawn flush on +-- the bottom of the plant block, so the ordinary vote (shades touching the +-- drawing's own bounding box) read "dark" as background and drained every +-- olive pixel in the plant. + +local bgRules = TileShape.propBg("POKECENTER") +T.check(type(bgRules) == "table", "POKECENTER names prop background shades") +for _, tile in ipairs({ 32, 33, 34, 35, 48, 49, 50, 51 }) do + local set = bgRules and bgRules[tile] + T.check(type(set) == "table" and set.light and set.white + and not set.dark and not set.black, + "plant tile " .. tile .. ": light/white are background, dark is the pot") +end + +-- and it is scoped: the healing consoles' screens and the PC keep the +-- ordinary vote, because they want the opposite call on those same shades +for _, tile in ipairs({ 58, 59, 66, 70, 74, 75, 82, 86 }) do + T.check(bgRules[tile] == nil, + "tile " .. tile .. " keeps the ordinary background vote") +end + +-- a tileset with no prop_bg at all answers nil rather than an empty table, +-- so Structures can skip the lookup entirely +T.check(TileShape.propBg("CAVERN") == nil, + "a tileset that names none answers nil") + +T.finish("DRAMATIC_SHAPE figures")