diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bb313c..3823961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,259 @@ # Changelog +## 1.5.0 + +### Added + +- **1ST: a first-person camera, played like a modern one.** A seventh + rung on the VOXEL ladder (hotkey 3 walks it; the OPTIONS row carries + it). Stepping onto it dives the camera from wherever the orbit was + into the player's own head over half a second, and stepping off flies + it back out. The rig rides the same placed-camera seam the staged + battle proved out, so the sky's bands meet the horizon, the sun and + moon hang where their shadows say, and the water reflects at eye + level -- all through math that was already there. + + - **Free look.** Relative mouse motion (the cursor is captured while + the rung is on; left click is A, right click is B), the right + stick at a rate with a squared response curve, or a touch dragged + across any open screen -- the overlay's d-pad and buttons still + work, and a second finger can drag the view while the first + walks. Pitch clamps short of straight up and straight down. + + - **Free movement.** While 1ST drives, the grid walk is replaced by + a continuous, camera-relative one: push forward and you go where + you look, at any angle, sliding along whatever you graze. The left + stick's raw deflection, the touch d-pad's true vector, or the held + keys (forward / backpedal / strafe) all steer it. The grid is + still the game: the walk asks the engine's own collision the same + per-cell questions a grid step asks, the logical cell tracks the + body, and every cell crossed runs the engine's own landing + pipeline -- warps, encounters, spinners, gates, poison, repel, the + step counters. Walking off the map edge, into a ledge or into a + boulder hands the push to the engine's own handlers, so + connections cross, ledges hop and boulders shove exactly as + themselves. Speed is the grid walker's own (bike included), so + distance per second and encounters per tile are unchanged. + + - **Billboards seen from inside the world.** Character cards stop + leaning and start turning: upright, yawed about their feet to face + the eye, wearing the frame their pose shows *this* viewer -- walk + behind an NPC and you see their back, circle to a flank and you + get the profile, exactly the four frames Gen 1 drew. The authored + figures (the couch sitters) turn the same way, about their own + middle. The sun pass swaps frames in step, so a card never reads + its own shadow through a mirror-flipped record of itself. The + player's own card is left out of the camera draw -- the eye stands + in it -- but still casts its shadow on the ground ahead. + + - The shadow map's box follows the look (the orbit's fit reaches far + north and barely south, which is wrong for a head facing south); + the world curve is declined outright while the head owns the + camera; and the whole rung falls back to the 75-degree orbit on + hardware without the 3D pass. + +## 1.4.3 + +### Added + +- **The furniture of the whole game goes through the building + pipeline.** 1.4.1 put four drawings through it; this is the rest of + the rooms. Every one of them is the same read -- the drawing's own + bands say what is a top seen from above, what is a face seen head-on, + and where the thing ends on the floor -- and every one of them + replaces a pinned box that wore its drawing as a decal. The pins all + stay as the degradation path, neutralized wherever a template stamps. + + - **The bookcase, the commonest piece of furniture in the game** -- + 58 placements across two drawings on the town-house atlas (books + and a bowl on each shelf at the west end of eighteen homes, books + on both at the east), plus Red's and the Copycat's pair. Pinned + `desk` it was a 24px box with the books painted on its flat front. + Modelled it is 23 voxels of cabinet with its top seen from above, + and every book, bowl and door panel sunk a voxel behind the frame + the drawing seals it in. + - **Celadon's display cabinets** -- the tall one with the trophy + behind its glass and the short one beside it, band for band the + same object as the town house's on another atlas, which is what + makes the pair read as one line of furniture: 23 voxels and 15, + exactly the 8 rows of drawing between them. + - **The dining table, everywhere it is drawn** -- the generic town + house's at 18 placements, Red's and the Copycat's, and the chief's + long table at four cells wide. All of them the lab table's read at + a different width, all of them 6 voxels, all of them standing on + the ground line their legs are drawn stopping at rather than on + the grid's floor. + - **The stool at every one of those tables** -- 94 placements on the + house atlas alone, ten more in Red's and the Copycat's, and the Fan + Club's four members' chairs, a different drawing that is + pixel-identical from the seat down. The first template with no base + piece at all: a stool is drawn mid-cell over its own floor, so it + is a desk-set of exactly one part, seat lid over legs with the + floor showing between them. + - **The Pokemon Center's healing machine** -- two variants, 24 + placements, plus the Indigo Plateau lobby's pair. A wall-height + cabinet with its monitor perched on the front of its top face, + drawn across two map rows because it towers over the 16px band + behind it, which the volume path could only read as more wall. The + hoses leaving its side are modelled as hoses, at the elevation and + the depth the two stacked motifs put them; the west machine's + keyboard is a shelf at counter height wearing its own top-view art. + - **Bill's desk, and the Silph president's** -- the same drawing in + both rooms. Its terminal is drawn in 2:1 isometric, turned 45 + degrees to the map, and builds as a cube rather than the slab a 2:1 + reading gives; the kinked dark run between keyboard and computer is + raised to the keyboard's height and reads as the cable it is. The + desk stops at its own two cells because the artist drew its apron + into the walkable cell in front, sharing tiles with the chair + pushed up to it -- so the chair is modelled as a part of the desk. + - **The Bike Shop's open toolbox.** The drawing looks down INTO the + tray, which is why every solid treatment failed it -- as a + `billboard` the whole cell went up as one 10-voxel slab wearing the + drawing as a decal. `tray` builds four walls, a floor and air + between them, with the lid standing open on its hinge. + + What the template language grew to carry them: `tray`; a `desk` band + that lays its top face flat as a lid; the `box`, `flat` and `iso` + part kinds; `stretch` for a band mapped over a deeper plot than it + was drawn on; `inset` for a pane sunk by hand; `panes = false` where + the global recess pass has the polarity backwards; a `wall` element + so a template can keep the band behind it solid; `plane` for a height + the drawing states elsewhere; and `scrub`/`keep`/`support`, which let + a template model a surface while leaving an object standing on it to + its own standee -- Red's potted plant on the dining table. + +- **Round bins: the `can` class.** Vermilion Gym's switch puzzle stands + fifteen galvanised trash cans in a row, and the S.S. Anne redraws the + same object pixel for pixel as its galley barrels. Left to the thin + standee pool they were flat discs on edge -- fifteen coins standing + in a row; pinned a plain `cylinder` the drawing's base arc revolves + too and they came out as barrels balanced on a three-voxel stem. + `can` is the round hull cut at both ends, hollowed and tapered: the + drawn mouth ellipse projects across the top and down the well so you + look into the bin, the drawn base ellipse is ground contact rather + than body, and the plan narrows toward the floor. The two ellipses + are measured off the pixels; the height, the well and the taper are + authored, and the entry says why. + +- **The rock gyms' boulders are round.** 87 placements over Pewter's + walls and maze and Bruno's clusters, and every one of them was a + square bar wearing a boulder texture in relief -- the repeat-aware + scenery path extruding the whole drawing as one course. Each cell is + now a hull whose plan is its own drawn width profile turned in depth: + a dome full-width from the drawn shoulder down, tapering over the top + five rows exactly where the art tapers, with the floor's corner + diamonds opening between them the way the drawing has them. Still + 16px, so nothing standing on or beside a rock moves. + +- **The potted plant stands as a plant.** The most repeated interior + prop in the game -- 78 placements over 13 maps, six per Pokemon + Center -- and its urn was rendering as a hollow black frame, because + the drawing's foot lies flush on the block's bottom edge and the + background vote took the plant's own darks away with the floor. Named + outright as light and white instead, it stands as one organic + silhouette 32px tall over its two stacked cells, crown overhanging + the stem. `planter` carries the same reading for a round drawing + stacked two cells high on one cell of plot. + +- **Bicycles, in both places the Bike Shop draws them.** The six on the + showroom floor get their own pool at two voxels rather than the thin + pool's five: a bike is a line drawing, and at five voxels every + stroke closes the gap to its neighbour with its own side faces, so + from any angle but dead-on the air inside the frames filled in and + the six came out as one dark lump. And the two against the north wall + get `mounted`, a new authored-mask escape for a thing drawn INTO a + wall band: it holds the wall's plane as a thin per-pixel slab instead + of standing up as a sprite card, and it keeps its drawn elevation, so + a bicycle hung clear of the floor stays hung. Its mask is measured + rather than hand-drawn -- the plain panel tile composited across the + same grid and the background flooded in through the pixels that still + match it, which separates bicycle from stripe exactly. + +- **The Marts' cash register is a machine, not a decal.** An authored + figure may now state a `depth`, which makes it an object rather than + a person: a per-pixel solid standing on the counter instead of the + flat card that turned edge-on with the camera. And the drawing is not + a box -- its black linework packs two facings, an L of base and arm + around a keypad that is the machine's deck seen from above. `flat` + lays that rect horizontal in the notch of the L, and `thin` gives the + receipt curl a paper's thickness where the body's would have made it + a wedge. + +- **Shelf fronts have relief.** Everything the `bookcase` collapse is + used for is a shelf, a rack or a display case, and all of them seal + their contents behind the drawing's own black frame -- so those + regions now sink a voxel, the same rule a facade's window panes are + recessed by, and the books stand in the shelf instead of being + painted on it. A tileset that borrows the collapse for something that + is not a shelf says `bookcase_relief = false`: the League's masonry + and pilasters, whose courses are the wall itself, and Bill's + transporter drums, whose light regions are a lit barrel. + +### Changed + +- **Class heights now follow the models under them.** A tileset's + `heights` gets stools at 5 and tables at 6 in the houses, Bill's desk + at 8, and cans at 9 -- each of them the drawn elevation the new + template or hull stands at, so whoever sits on a stool sits on the + seat, and whatever object sprite stands on a table lands on the + modelled top rather than three voxels over it or under it. +- The healing machines' two flanks leave the `wall` pin for the thin + standee pool. They are equipment standing beside the console -- a + pair of pipes and a keyboard -- and as wall each was boxed into a + solid 16px half-cell wearing its drawing in relief. + +### Fixed + +- **Water no longer hides behind water.** The reflective pass writes no + depth -- the depth canvas is detached for the length of it so the + shader can read it -- so nothing put a lake in the buffer and no lake + could occlude another; the sheets were simply painted in mesh order. + Flat water never showed it, one plane, a farther sheet always landing + farther down the screen. The world curve ends that: it drops the far + side of the map into the near field of view, and a sea a hundred and + fifty tiles away came out rasterised on top of the pond at the + player's feet, tall grass and all -- water and terrain "from the + other side of the map", not reflected but there. The water meshes now + go down flat first, through the ordinary scene shader with depth + writes on, and the reflective pass draws over what survived. The + buffer holds the surface, so the pass's own test throws the far sheet + away; the reflection copy holds it too, so a ray grazing another part + of the lake reads water rather than the void behind it; and a frame + that cannot run the pass at all is unchanged, because the flat draw + is the fallback that was already there. +- **Reflections under the world curve.** The bend tips the world away + and the things standing on it do not lean with it -- and a lake is + one of those things. Reflected off the bowl the bend makes, the far + half of a pond was a mirror tilted twenty degrees: it threw the ray + past the vertical, where the sky ramp's own measure swings from one + end to the other across a single column, and hard-edged patches of + the wrong sky stamped into the water; the same tilt sent the + screen-space march grazing along the bank rather than over it, which + is what smeared the dock and the roofs across the harbour. What the + water reflects is now worked out in the flat world, exactly as it + would be with the curve off, and every marched sample is bent on its + way to the screen by the vertex stage's own displacement -- so the + ray is straight where it should be and lands where the geometry did. + The wave columns are read on the flat sheet too: the relief walk is + built on an even slab over a level plane, and in the curved world + that slab is a bowl, which handed back a column a pixel or three off + per fragment -- a patch of noise in the middle of a pond. +- **Merged runs tore open under the curve.** A quad's interior is the + chord of a parabola its neighbours draw the arc of, so a long run + hangs below the short quads butted against it. Nothing bounded a + run's length, and the ones that ran away were those wearing a + constant texel -- a roof's black eave outline, its fascia, its shaded + underside -- because a flat run has no art to break it. At 102px + across a gym the eave tore off the roof and the slot showed the + building's dark interior through it. Runs now stop at the next 8px + lattice line, which is the lattice buildings are stamped on and the + one every other quad in the scene already ends on, so every join is + vertex-for-vertex and the bend carries them together. It costs quads + whether the curve is on or not -- Cerulean's object stream goes from + 35.7k to 41.6k -- and that is deliberate: the mesh is cached per map + and built over seconds, so meshing for the curve's sake only when the + curve is on would mean rebuilding every live map on a keypress. + ## 1.4.1 ### Added diff --git a/README.md b/README.md index 14e2cb8..2ec0b45 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,26 @@ clear ground — shot over the shoulder, the player's mon low and left and the enemy high and right, with a slow parallax drift behind them and a depth-of-field pass that keeps both of them sharp. -Purely presentational. Nothing here reaches collision, movement, triggers -or scripts — it changes what the world *looks* like and nothing about what -it *is*. The battle arena is where the **camera** goes, not where anybody -goes: no cell, facing, flag or warp is written, so the player is standing -exactly where the fight found them when it ends. +And the whole thing from inside. The ladder's top rung, **1ST**, dives the +camera into the player's own head: free look on the mouse (captured while +the rung is on — left click is A, right click is B), the right stick, or a +touch dragged across open screen; free movement that goes where you look, +at any angle, sliding along walls — the left stick's raw deflection, the +touch d-pad's true vector, or WASD as forward/backpedal/strafe. NPCs turn +to face the eye wearing the frame their pose shows *this* viewer — walk +behind someone and you see their back — and the sky, the shadows and the +water reflections all carry over, because the head rides the same placed +camera the battle shot proved out. + +Presentational, with one deliberate exception. Every rung but 1ST changes +what the world *looks* like and nothing about what it *is*; the battle +arena is where the **camera** goes, not where anybody goes. 1ST replaces +the grid walk with a free one while it is selected — but even there the +game is untouched: the walk asks the engine's own collision the same +questions a grid step asks, keeps the player's cell synced, and runs the +engine's own landing pipeline per cell crossed, so warps, encounters, +ledges, gates and scripts all fire exactly as themselves. Step off the +rung and the grid walk is back. ## Controls diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 97f52f6..55c075c 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -58,12 +58,43 @@ -- collapses onto a one-cell-deep box at its -- full drawn height; back rows become hidden -- floor, and an unpinnable trim row above --- becomes the cap +-- becomes the cap. Its FRONT carries the +-- measured pane relief -- every non-black +-- region the drawing seals behind its own +-- black frame sinks one voxel, the same rule +-- lib/Buildings.lua recesses a facade with, +-- so the books stand in the shelf instead of +-- being painted on it. A tileset that +-- borrows the collapse for something that is +-- not a shelf says `bookcase_relief = false` +-- cylinder / canopy round scenery (tree canopies): a voxel hull +-- / stump cut from the art's own darkest-pixel outline, +-- round in depth -- one 16px cell, a 2x2-cell +-- group, or with the drawn top read as a cut face +-- can that hull cut at both ends, hollowed and +-- tapered: an OPEN bin standing on a floor +-- (Vermilion Gym's trash cans). The drawn mouth +-- ellipse projects across the round top and down +-- the well, the drawn base ellipse is ground +-- contact rather than body, the top rows keep a +-- wall at each end of their chord and lose the +-- middle, and the plan narrows toward the floor +-- (can_cap / can_base in art rows; can_height, +-- can_well, can_taper in voxels) +-- planter the same hull for a round drawing stacked TWO +-- cells high on ONE cell of plot (the Centers' +-- potted plants): it stands in the lower cell and +-- the upper cell is the height it overhangs -- billboard / prop a prop drawn face-on: a standing per-pixel -- cutout, black-outline segmented; drawn above -- a pinned box it stands ON that box (a -- monitor on its desk). prop is a second pool -- so two touching cutouts stay separate +-- bike the same cutout at TWO voxels: a vehicle drawn +-- side-on is a LINE drawing, and the air inside +-- its frame is what makes it read as one -- any +-- thicker and neighbouring strokes close those +-- gaps with their own side faces -- -- A tileset entry may also carry `prop_ground` (not a class): a map of -- prop tile id -> ground tile id naming the tile painted under that @@ -83,7 +114,20 @@ -- 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. +-- the format. A figure entry that states a `depth` is not a person and +-- stands as a per-pixel SOLID instead of a flat card -- the Marts' cash +-- register, a machine set down on a counter (`thin` gives its top rows a +-- thickness of their own, for the part of a drawing that is paper). +-- +-- And `mounted` (not a class either): the same authored-mask escape for a +-- thing painted INTO a WALL band, stood proud of the panel as a thin +-- per-pixel slab instead of standing on its feet as a sprite card. Where +-- a figure is a person and reads face-on from any angle, a mounted object +-- is an object drawn side-on and holds the wall's own plane; and it keeps +-- its DRAWN elevation, so a thing hung clear of the floor stays hung. +-- The Bike Shop's two wall bicycles are the case -- and the mask there is +-- measured rather than drawn, by flooding the panel tile's own stripe out +-- from behind them; see CLUB below. -- -- 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, @@ -117,6 +161,13 @@ return { desk = 24, prop = 16, cutout = 16, + -- a vehicle drawn side-on (the Bike Shop's bicycles): a standee like + -- the pools above, two voxels thin so the air inside its frame stays + -- air (see Structures' PINNED_DEPTH) + bike = 16, + -- a round drawing stacked two cells high on one cell of plot (the + -- Centers' potted plants): 32px of hull standing in its lower cell + planter = 32, relief = 3, bookcase = 32, stair_e = 16, @@ -195,12 +246,8 @@ return { -- black-outline segmented -- backgrounds flood away, the pixels -- the outline encloses stay. Everything stands on the gyms' main -- floor tile ($11), which is also Bruno's floor. - -- (The round boulder drawn beside some statues, $07/$08/$17/$18, - -- is NOT pinned: it also tiles wall-to-wall as Pewter's rock rows, - -- which are scenery for the detector, not a lone prop. Probed: - -- the repeat-aware scenery path already gives every one of them a - -- single 16px course in Pewter's and Bruno's rock walls alike, so - -- a pin would only restate the derived answer.) + -- (The boulders, $07/$08/$17/$18, are pinned round -- see the + -- `cylinder` pool below.) -- -- The rest of the tileset is the ten rooms' own furniture: the six -- badge gyms it serves (Pewter, Cerulean, Vermilion, Celadon, @@ -270,13 +317,100 @@ return { -- from the darkest-pixel outline -- the same treatment the -- overworld's tree canopies take, and per-CELL, so a hedge row -- becomes a row of bushes instead of one boxed monolith. - cylinder = { 44, 45, 46, 47 }, - -- The statues, the cans, and Celadon's three little trees - -- ($40/$41 canopy over $50/$51 trunk). A trunk is not round, so - -- the tree cannot be a ball like the shrubs beside it -- it takes - -- the thin standee pool every interior plant takes, which is also - -- a pool apart from the cylinders it touches. - prop = { 2, 56, 18, 19, 11, 12, 27, 28, + -- + -- The rock gyms' boulder ($07/$08 over $17/$18) is the same + -- reading: one cell is one rock, drawn as a lit dome -- white + -- highlight up the north-west, mid-grey body, black-and-grey + -- rubble dither filling the rest -- with the FLOOR showing + -- through all four corners, which is what makes a run of them + -- read as a pile of separate stones rather than a wall (tile + -- them and the gaps between four rocks draw a grey diamond). + -- Left derived it fell to the repeat-aware scenery path, which + -- extruded the whole drawing as one 16px course: Pewter's and + -- Bruno's rock rows came out as square bars wearing a boulder + -- texture in relief -- the extruded picture. Round-pinned, + -- each cell is a hull whose plan view is its own drawn width + -- profile turned in depth: a dome 16 wide and 16 tall, full + -- width from the drawn shoulder down and tapering over the top + -- five rows exactly where the art tapers, and the corner + -- diamonds open up between them the way the drawing has them. + -- It stays 16px, so nothing that stands on or beside a rock + -- moves. The perimeter rows take the same model as the maze + -- clusters -- one drawing, one rock, everywhere. + -- Scanned: across all 222 maps these four tiles occur 87 times + -- on this atlas and every one of them anchors a whole boulder + -- cell (87 tile-$07 hits, 87 grid matches), in exactly two maps + -- -- PEWTER_GYM's walls and rock maze, and BRUNOS_ROOM's + -- clusters. DOJO shares gym.png but places none of them, so + -- the pin is not copied there. + cylinder = { 44, 45, 46, 47, + 7, 8, 23, 24 }, + -- Vermilion Gym's trash cans ($0B/$0C over $1B/$1C), the switch + -- puzzle's fifteen cans plus the sixteenth beside the leader's + -- platform. An open galvanised bin in the 3/4 view, and its plan is + -- measured: the silhouette runs straight down both flanks for art + -- rows 4-10 and is 11px wide there, so the can is 11 across -- and + -- being round in plan, 11 DEEP. Above row 4 is the MOUTH seen from + -- above (the drawn ring is 9x5, a circle flattened to 55%, which is + -- what fixes it as a top view rather than a face-on dome); below row + -- 10 is the base circle's front arc, ground contact, with the + -- drawing's own $555 halo one pixel outside it as the grounding + -- shadow. + -- + -- Left in the thin standee pool the can was a flat disc on edge -- + -- fifteen coins standing in a row. Pinned a plain `cylinder` it + -- revolves every drawn row, bottom arc included, and comes out a + -- barrel balanced on a three-voxel stem. `can` is the hull cut at + -- both ends and hollowed: the mouth projects across the round top, + -- the base rows are ground rather than body, and the top can_well + -- voxel rows keep a wall at each end of every chord and lose their + -- middle, so the bin is open and you look down into it. + -- + -- can_cap and can_base are the two ellipses in art rows and come off + -- the pixels. The other three do NOT, and are the numbers taste + -- moves: + -- + -- can_height 9 Un-projected strictly the drawing states a squat + -- drum barely two rows of straight side tall, + -- because the GB artist spent most of a 16px cell + -- on the opening -- but a bin is taller than it is + -- wide and the flat game reads as one, the drawing + -- being 14px tall beside a 16px player. The lowest + -- surviving body row (art row 10: black rim, shaded + -- flank, lit face) is repeated up to this, the + -- plainest continuation of the material drawn. + -- `heights` must follow it so anything riding a can + -- lands on the rim. + -- can_well 5 How far down the mouth is hollowed. The drawing + -- paints an opening and cannot say how deep. + -- can_taper 4 Voxels of DIAMETER the base loses -- 11 at the + -- rim to 7 on the floor, stepped twice over the + -- height. The drawing's base arc pulls in to 9px + -- on its own, so the direction is drawn; the amount + -- is not. 2 is one barely-visible step, 4 reads as + -- a cone. + -- + -- Scanned: the four ids occur as this grid 16 times on the GYM atlas + -- and only in VERMILION_GYM -- cells (1/3/5/7/9, 7), the same five + -- on rows 9 and 11, and (6,1). DOJO shares gym.png and places none, + -- so the pin is not copied there. The same four ids form the same + -- grid on eight OTHER atlases (the Bike Shop's crates, the overworld + -- roofs, the Centers' counters, ...) for 142 more hits; those are id + -- collisions between tilesets, and a pin is per tileset id, so they + -- are none of this entry's business. + can = { 11, 12, 27, 28 }, + can_cap = 9, + can_base = 4, + can_height = 9, + can_well = 5, + can_taper = 4, + heights = { can = 9 }, + -- The statues and Celadon's three little trees ($40/$41 canopy over + -- $50/$51 trunk). A trunk is not round, so the tree cannot be a + -- ball like the shrubs beside it -- it takes the thin standee pool + -- every interior plant takes, which is also a pool apart from the + -- cylinders it touches. + prop = { 2, 56, 18, 19, 64, 65, 80, 81 }, -- The Hall of Fame's recording machine, the one piece of real -- furniture in the tileset. It is drawn 32px wide and THREE tile @@ -629,6 +763,11 @@ return { -- the floor above). Bookcases, dining table with its flower pot, -- stools, the TV on the floor, and the staircase up. REDS_HOUSE_1 = { + -- the `house_stool` template below stands 5 voxels and the + -- `reds_house_table` model 6 (both the drawn elevation): whoever + -- sits on a stool cell and whatever object sprite stands on the + -- table rides these heights, not the 8/12px class defaults + heights = { stool = 5, table = 6 }, wall = { 0, 36, 37, 52, 53 }, stool = { 2, 3, 18, 19 }, -- the dining table (38-44/58-60); its top row also caps the @@ -656,6 +795,12 @@ return { -- wall-height volumes and towers the table, so every object is -- pinned to the shape it depicts. HOUSE = { + -- the `house_stool` template below stands 5 voxels and the + -- `house_table` model 6 (both the drawn elevation): whoever sits + -- on a stool cell -- Daisy at her table -- and whatever object + -- sprite stands on a table cell rides these heights, not the + -- 8/12px class defaults + heights = { stool = 5, table = 6 }, -- the wall band stays one 16px face: blank courses, the window, -- the framed picture, and the schoolhouse blackboard (72-75/88-91) wall = { 0, 36, 45, 46, 52, 61, 62, 72, 73, 75, 88, 89, 90, 91 }, @@ -694,14 +839,17 @@ return { -- look-only), the pokeball poster (2/3/18/19), and the pillars -- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback -- trap and would recess into a pond lip). The healing machines' - -- bodies (72/76/77 console face, 6/22 button panel, 73 light edge, - -- 7/13 shadowed edge) are ALSO wall: drawn 16px tall against the - -- band, so wall height is their drawn height and they read as - -- consoles jutting from it; the near-black screen tiles must be - -- pinned or the void rule flattens them, and 72 is the $48 - -- water-fallback trap - wall = { 2, 3, 4, 5, 6, 7, 13, 16, 18, 19, 20, 21, 22, 40, 41, - 72, 73, 76, 77, 92, 93, 94, 95 }, + -- console face (76/77) and button panel (6/22) are ALSO wall: + -- drawn 16px tall against the band, so wall height is their drawn + -- height and they read as equipment jutting from it. (The + -- `center_heal_machine_w`/`_e` templates below claim the full + -- 4x4 machine grids at every placement and model the console + -- properly; these pins are the degradation path when the profile + -- is absent.) The near-black screen tiles must be pinned or the + -- void rule flattens them. What is NOT wall is the machines' + -- two flanks -- see `prop` below. + wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41, + 76, 77, 92, 93, 94, 95 }, -- the counters, half a cell high: top band (8) with the nurse's -- tray (10), front face (24/25, the game's counterTiles), left end -- cap (56) and the Cable Club's light sections (90/91). 8px is @@ -732,6 +880,32 @@ 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 }, + -- The healing machines' two FLANKS, which are not the machine and + -- not the wall: thin equipment standing on the floor beside the + -- console, drawn against a plain light-grey ground with no floor + -- checker in it. `wall` boxed each of them into a solid 16px + -- half-cell wearing its drawing in relief -- the billboard failure, + -- one tile wide. + -- 72 the west flank of both machines, and 73 its mirror on the + -- east machine: a pair of PIPES leaving the console and + -- bending down into the floor in an L, the 8px motif drawn + -- once per pipe. Round and thin, so the THIN pool's 5px + -- slab, per-pixel, is the whole of them; a box is a wall + -- stub with pipework printed on it. + -- 7/13 the west machine's east flank: a KEYBOARD, one 16px panel + -- over two tiles (keys, and the wide bar down its right). + -- Same thin standee; the drawn panel IS its thickness. + -- All four ids are used at these flanks and nowhere else in the + -- game -- 12 maps, the same two cells in each -- so this pin cannot + -- reach anything but the machines. 72 is still the $48 + -- water-fallback trap and still has to carry SOME pin; it just + -- wanted the thin one, not the wall. (The machine templates now + -- claim the flank tiles too and model the hoses and keyboard as + -- attached 3D equipment; these standee pins are the degradation + -- path when the profile is absent.) + prop = { 7, 13, 72, 73, + -- ...and the potted plants -- see THE POTTED PLANTS below + 32, 33, 34, 35, 48, 49, 50, 51 }, -- 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 @@ -745,34 +919,35 @@ return { -- 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, - -- 50 is the $32 water-fallback trap). Standing per-pixel - -- billboards in the THIN pool, like every other interior plant -- - -- and a pool apart from the sofa, which touches the corner pair - -- and must stay a separate object. The bush's light checker - -- highlights share the floor's shades, so the mask drains some of - -- 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. + -- THE POTTED PLANTS: a leaf crown (32/33/48/49) over an urn + -- (34/35/50/51; 50 is the $32 water-fallback trap), the most + -- repeated interior prop in the game -- 78 placements over 13 maps + -- across this id and MART (scan: six per Center, two in the + -- Celadon hotel's lounge rows, four in the Plateau lobby). -- - -- 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" } }, - }, + -- A plant is the THIN pool's textbook case: the drawing is ONE + -- organic silhouette -- crown, 3px stem, urn, all 8-connected, + -- 350 of the block's 512 pixels -- so it stands as a per-pixel + -- cutout 5 voxels deep at its real drawn height, 32px over the + -- two stacked cells. Its lowest drawn row is the block's bottom + -- row, so it stands in the pot cell's south band -- drawn low, + -- near the front of its blocked plot -- and the crown overhangs + -- the stem the way the drawing says, which no box or hull cut to + -- cell granularity can do. Both cells' tiles carry the pin (in + -- `prop` above); the pool clusters them into one drawing, and the + -- two side-by-side placements (every map pairs or quads them) + -- share one cluster whose per-pixel geometry is identical to two + -- separate ones. + -- The plant drawing has no floor margin to vote from: the urn's + -- #555 foot lies flush on the block's bottom edge, so the rim + -- vote reads "dark" as background and drains the urn's own body. + -- Name the background outright instead: the floor showing + -- through the crown's gaps is white+light and nothing else, + -- while the crown's own white/light highlights are sealed inside + -- its black/#555 outline -- a light+white flood from the ring + -- reproduces the extract mask exactly, leaves intact. + 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: @@ -908,6 +1083,21 @@ return { -- 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 }, + -- the same potted plants as POKECENTER above -- MART shares that + -- atlas, so this is the identical drawing tile for tile, and + -- INDIGO_PLATEAU_LOBBY (the only map on this id that places it) + -- puts four of them along the hall. Across POKECENTER and MART + -- all eight of these ids appear exactly 78 times each, which is + -- exactly how often the 2x4 plant grid appears: they are never + -- anything but this plant, so pinning them by tile cannot catch + -- anything else. Same reading as the POKECENTER entry: one + -- organic silhouette in the THIN standee pool, per-pixel, 5 + -- voxels deep at its drawn 32px height, with the background + -- shades named because the urn's foot lies flush on the block's + -- bottom edge and votes the drawing's own darks away. + prop = { 32, 33, 34, 35, 48, 49, 50, 51 }, + prop_bg = { { tiles = { 32, 33, 34, 35, 48, 49, 50, 51 }, + shades = { "light", "white" } } }, -- 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 @@ -915,13 +1105,14 @@ return { -- 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. + -- THE CASH REGISTER: a till drawn across two tile rows in the middle + -- of the counter's east arm, and the same drawing in all nine maps + -- on this id (eight Marts plus the Indigo Plateau lobby), always at + -- cell (1,5). Like the Center's seated man it is drawn INTO the + -- furniture, so it is cut out by the same authored mask, 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 @@ -933,13 +1124,70 @@ return { -- to the top right, and the counter keeps its edging and its light -- work surface. -- + -- But it is a MACHINE, not a person, so unlike the seated man it + -- carries a `depth` and stands as a solid rather than a sprite + -- card. A flat card of a till is exactly the billboard the standee + -- pools exist to avoid -- it read as a decal on the counter, and + -- turned edge-on with the camera. + -- + -- AND IT IS NOT A BOX. The 16x16 packs two facings, and the black + -- linework says which is which: the keypad has its OWN complete + -- border (cols 2-8, rows 4-11) sealed inside the outer silhouette, + -- and what surrounds it is an L -- + -- + -- cols 2-----8 9--12 + -- +-----------+-----+ row 4 + -- | keypad | arm | the L's upright arm: the printer and + -- +-----------+-----+ row 11 display the paper feeds out of + -- | base / drawer | rows 12-15: the L's foot, drawn face-on + -- +------------------+ + -- + -- So the keypad is NOT a face. It is the machine's DECK seen from + -- ABOVE -- the keys lie on it -- and `flat` lays it horizontal in + -- the notch of the L, at the height the base band leaves it and + -- with drawn row = depth row 1:1. Extruding it instead stood that + -- surface on end and painted the keys up the front of a plain box: + -- the extruded-picture failure, and what the first pass shipped. + -- + -- Everything else falls out of the drawing: the base band is 4 rows, + -- so the deck stands 4 above the counter; the arm is 8, so it rises + -- 8 above the deck; the paper reaches 4 more. + -- + -- TWO AUTHORED NUMBERS. + -- + -- `depth` 12, three quarters of the cell. The keypad's own eight + -- drawn rows measure 8, and at 8 the machine read thin on the + -- counter, so the body runs half again deeper and the deck STRETCHES + -- over it -- 8 rows into 12 voxels, centre-sampled, so every second + -- row doubles and the keypad still covers the whole surface. This + -- is the one place in the model where a texel is not 1:1 with a + -- drawn pixel; laying the panel 1:1 instead leaves a strip of the + -- base band's top showing behind the keys. The model is anchored at + -- the counter cell's FRONT and grows north into the bare top behind + -- it, so deepening it can never push the till toward the aisle: at + -- 12 it still stops 4 voxels short of the cell's north edge. This + -- is the number to move if it wants more or less presence. + -- + -- `thin`: 4 rows at 2. Row 4 is the machine's + -- own drawn top edge -- a black rule from column 3 to column 9 -- so + -- everything above it is the receipt strip, and paper is 2 voxels + -- like every other `cutout` here. Centred in the body's band, so + -- the strip leaves the arm's top face by a slot in the middle of it + -- rather than flush with its front. At the body's 8 the curl came + -- out as a chunky wedge on the corner and stopped reading as paper. + -- -- `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. + -- synthesis at all. The `counter` pin on 14/15/30/31 stays: the + -- box under the till is still the counter, and the machine stands + -- on its 8px top plane. figures = { { w = 2, + depth = 12, + thin = { rows = 4, depth = 2 }, + flat = { x = { 2, 8 }, rows = { 4, 11 } }, tiles = { 14, 15, 30, 31 }, under = { 16, 41, @@ -1648,6 +1896,11 @@ return { -- footing. bookcase = { 7, 8, 9, 10, 23, 24, 25, 26, 39, 40, 41, 42, 55, 56, 57, 58 }, + -- ...and the drums are the reason this tileset turns the shelf + -- relief OFF: the collapse here is borrowed for a MACHINE, whose + -- light regions are the barrel's own lit face and not panes + -- behind a frame. Sinking them would dent the drum. + bookcase_relief = false, -- The big OCTAGONAL boardroom table -- the Fan Club's and Silph -- 11F's, the same drawing in both -- half a cell high, and half a -- cell on purpose. A `counter` is ONE band: exactly the drawing's @@ -1712,12 +1965,30 @@ return { -- drawn into the WALKABLE cell in front; 43/44 belong to the stool -- there, not to the desk. table = { 11, 12, 13, 14, 27, 28, 29, 30 }, + -- and those eight ids are the ONLY `table` in this tileset, so the + -- height override is this desk's alone: 8px, the height its apron + -- is drawn and the plane the `bills_desk` template stands its top + -- on (see `buildings` below). The pin is only the degradation + -- path here -- the template claims both placements -- but the two + -- must not disagree about where the desk's top is. + -- + -- `stool` is 5 for the same reason: that is the drawn elevation of + -- BOTH seats in this tileset -- rows 11-15, the seat's front edge + -- over its legs -- and the height the `club_stool` template stands + -- them. Bill's chair and the Fan Club's are different drawings + -- above the seat but share tiles 59/60 below it, so they are drawn + -- at the same height pixel for pixel; the class default of 8 was + -- three voxels over both. Every one of these cells is WALKABLE, + -- so this is also where a character sitting on one ends up. + heights = { table = 8, stool = 5 }, -- the seats: Bill's desk stool (43/44 over 59/60) and the Fan -- Club's four members' chairs (61/62 over 59/60), a whole cell -- each. All of them sit in WALKABLE cells, so they were flat - -- floor until pinned; the 8px standee pool puts them at seat - -- height, which is also where a character standing on the cell - -- ends up. + -- floor until pinned; the standee pool puts them at seat height, + -- which is also where a character standing on the cell ends up. + -- The Fan Club's four are modelled in full by `club_stool` below + -- and these pins are their degradation path; Bill's chair stays a + -- standee, stood up as a part of `bills_desk`. stool = { 43, 44, 59, 60, 61, 62 }, -- flat, and pinned flat on purpose. 31 is the rooms' floor tile -- and needs no pin for its 980 walkable placements -- but the @@ -1765,11 +2036,13 @@ return { -- rounded foot, 53, stands one row lower on the floor and is -- walkable, so it stays flat ground in front of the band). -- Drawn INTO the band and therefore wall as well: the two - -- bicycles hung on the Bike Shop's wall (1/2/3 over 17/18/19, + -- bicycles against the Bike Shop's wall (1/2/3 over 17/18/19, -- exactly two tile rows = exactly the band's height), the Cable -- Club's link poster (48/49/50/51 -- 50 is $32, the shore trap -- that had it recessing to -2), and the Trade Center's pair of -- lit notice boards (63/68/68/69 over 66/67/67/70). + -- The bicycles keep their `wall` pin as the degradation path, but + -- `mounted` below lifts them off the panel -- see there. wall = { 1, 2, 3, 6, 17, 18, 19, 48, 49, 50, 51, 52, 63, 66, 67, 68, 69, 70 }, -- Counters, half a cell high -- the house rule for anything you @@ -1797,28 +2070,114 @@ return { -- character standing on one sits on it. stool = { 38, 39, 42, 43 }, -- The showroom bicycles -- six of them standing on the Bike Shop - -- floor, in two columns, each drawn 3 tiles by 2 side-on. The - -- THIN standee pool, not `bookcase`: a bike is nearly all air - -- (two wheel rings and a frame), and collapsing a rack of them - -- onto a one-cell-deep box at full height would trade six - -- bicycles for one blank 32px panel wearing a bicycle print. As - -- cutouts they segment cleanly -- the drawing has floor margin on - -- three sides and its own black outline seals the wheels -- and - -- the standee builder's connected-component split gives each bike - -- its own feet in its own cell even where a lower bike's - -- handlebar stem touches the wheel of the one drawn above it. + -- floor, in two columns, each drawn 3 tiles by 2 side-on. A THIN + -- standee pool, not `bookcase`: a bike is nearly all air (two + -- wheel rings and a frame), and collapsing a rack of them onto a + -- one-cell-deep box at full height would trade six bicycles for + -- one blank 32px panel wearing a bicycle print. As cutouts they + -- segment cleanly -- the drawing has floor margin on three sides + -- and its own black outline seals the wheels -- and the standee + -- builder's connected-component split gives each bike its own + -- feet in its own cell even where a lower bike's handlebar stem + -- touches the wheel of the one drawn above it. -- 11/12/14 the saddle and bar row, 27/28/9 the frame and wheels. - prop = { 9, 11, 12, 14, 27, 28 }, - -- The crate and the standing pump beside the shop's south wall + -- + -- Their own pool at TWO voxels rather than `prop`'s five, which is + -- the whole reason `bike` exists. The segmentation was never the + -- problem -- the mask the detector cuts is a clean bicycle, wheels, + -- forks, handlebars and all. Depth was: every stroke of a line + -- drawing extruded five voxels closes the gap to its neighbour with + -- its own side faces, so off-axis (which is every camera rung but + -- flat) the air inside the frame filled in and the showroom's six + -- bicycles came out as one dark lump against the wall. At two the + -- negative space survives and they read as bicycles again. + bike = { 9, 11, 12, 14, 27, 28 }, + -- The toolbox and the standing pump beside the shop's south wall -- (29/13 over 21/22), twice. A separate pool from the bicycles - -- on purpose, and the thicker one: a crate is a deliberate object - -- with a body, where a bike is a silhouette. + -- on purpose, and the thicker one: a toolbox is a deliberate + -- object with a body, where a bike is a silhouette. + -- + -- Kept as the DEGRADATION PATH only. The `bike_shop_toolbox` + -- building template (see `buildings.CLUB`) claims these cells and + -- models the drawing properly -- a per-pixel standee is the wrong + -- primitive for a box, and 10 voxels of it turned every black + -- outline pixel into a 10-deep bar. Buildings runs before the + -- standee scan, so where the template stamps, this pin never + -- fires; without a profile it is still better than the volume + -- path. billboard = { 13, 21, 22, 29 }, -- The Bike Shop floor's second checker phase. It is NOT in the -- tileset's walkable list, so the cells it floors resolve by rule -- 4 and it rose as a wall stub -- or, worse, got swept into the -- bicycle beside it and dragged to 8px. It is floor; it is flat. ground = { 30 }, + -- THE TWO BICYCLES AGAINST THE SHOWROOM'S NORTH WALL. Same object + -- as the six on the floor -- 24x16 of bicycle seen side-on, wheels + -- on the floor line -- but drawn INTO the wall band instead of onto + -- the floor, which is a different problem entirely: + -- + -- a class pin resolves a whole 8x8 tile, so `wall` (what these + -- carried) paints the bicycle flat on the panel: a green wall + -- with a bicycle printed on it, which is what the shop looked + -- like; + -- and nothing automatic can cut it out either. The panel behind + -- it is tile 6's stripe -- a #aaa field ruled with #555 every + -- four rows -- and #555 is a flood BOUNDARY, so a silhouette + -- flood comes back with three full-width wall stripes welded + -- across the bike (rows 3, 7 and 15 of the drawing). Naming the + -- background shades instead (prop_bg) cannot work either: the + -- bicycle's own basket is drawn in the very #aaa and white the + -- wall field uses. + -- + -- So the silhouette is authored -- but MEASURED, not drawn by hand. + -- 6 is the plain panel these three tile columns are painted over, + -- and it is perfectly regular, so compositing 6 across the same 3x2 + -- grid and flooding the background in through the pixels that still + -- MATCH it separates bicycle from wall exactly: 247 pixels, one + -- connected component, no stripe left on it and no hole in it. + -- That is also why `under` is six 6s -- the wall wears the panel + -- the artist drew everywhere else along the same run. + -- + -- `depth` 2 for the same reason the `bike` pool above is 2, and + -- because a family shares a silhouette: these are the showroom's + -- bicycles, leaning on the wall rather than standing in the room. + -- The slab juts 2 voxels SOUTH of the band, so they stand in front + -- of the wall and overhang the walkable cell a little -- which is + -- what a bicycle leaning on a wall does. Collision is untouched. + -- + -- Only BIKE_SHOP places this grid on the club atlas, at tile (1,0) + -- and (6,0). The same six tile ids DO pair up in gate.png (both + -- Route gate upper floors and all four Safari rest houses), but + -- that is a different image and `mounted` is keyed per tileset, so + -- the pattern cannot reach them. + mounted = { + { + w = 3, + depth = 2, + tiles = { 1, 2, 3, + 17, 18, 19 }, + under = { 6, 6, 6, + 6, 6, 6 }, + pixels = { + ".........XXX............", + "..XXXXX.XXXX............", + "..XXXXXXXXXX............", + "..XXXXXXXXXX............", + "..XXXXXXXXXXXXX.........", + "..XXXXXXXXXXXXXX........", + "..XXXXXXXXXXXXXX........", + "..XXXXXXXXXXXXXX..XXX...", + ".XXXXXXXXXXXXXXXXXXXXXX.", + "XXXXXXXXX..XXXXXXXXXXXXX", + "XXXXXXXXX.XXXXXXXXXXXXXX", + "XXXXXXXXX.XXXXXXXXXXXXXX", + "XXXXXXXXX.XXXXXXXXXXXXXX", + "XXXXXXXXX..XXX..XXXXXXXX", + ".XXXXXXX....X...XXXXXXX.", + "...XXX.....XXX....XXX...", + }, + }, + }, -- Left to the derived default on purpose: -- $0F $1F the main floor checker -> walkable, ground -- $0A $1A the dark slab under the Bike Shop's exit warp -> a @@ -1920,11 +2279,34 @@ return { -- towered to 24. bookcase = { 43, 54 }, -- The galley barrels -- three down the kitchen's east wall, one in - -- the captain's room, one in each house. A per-pixel cutout in - -- the THIN pool, the treatment Lt. Surge's trash cans get: 72/73 - -- the lid, 88/89 the banded body. 72 is $48, the shore-set trap - -- that had each barrel resolving to water at -2. - prop = { 72, 73, 88, 89 }, + -- the captain's room, one in each house. 72/73 the mouth, 88/89 + -- the banded body. 72 is $48, the shore-set trap that had each + -- barrel resolving to water at -2. + -- + -- This is Lt. Surge's trash can REDRAWN PIXEL FOR PIXEL on the ship + -- atlas: lay the two masks side by side and the only difference is + -- that the gym's floor stripes cross the gym copy's background, + -- which the flood keeps and this one has no need of. So it takes + -- the same treatment and the same numbers as GYM's can -- see there + -- for why the mouth and base ellipses are measured and the height, + -- well and taper are authored. + -- + -- Scanned: six placements on the SHIP atlas and no others -- + -- SS_ANNE_KITCHEN (13,5), (13,7) and (13,9) down the east wall, + -- SS_ANNE_CAPTAINS_ROOM (4,1), and one each in CERULEAN_BADGE_HOUSE + -- and FUCHSIA_GOOD_ROD_HOUSE at (7,7), both of which are ship + -- interiors reusing the tileset. Twenty-one more hits are id + -- collisions on other atlases and no business of this entry; the one + -- worth naming is VERMILION_DOCK, because SHIP_PORT is a near-twin + -- of this image -- but its $48/$49/$58/$59 are a hull panel, not a + -- barrel (diffed pixel for pixel), so the pin is NOT copied there. + can = { 72, 73, 88, 89 }, + can_cap = 9, + can_base = 4, + can_height = 9, + can_well = 5, + can_taper = 4, + heights = { can = 9 }, -- The stairs, both flights, in every map that has them (1F, 2F, -- 3F, B1F and the captain's room). Same two drawings the whole -- game uses, and the warp table says which is which: 41/42/57/58 @@ -2429,6 +2811,11 @@ return { -- PLATEAU, so the wall reads as set INTO the terrace instead of -- standing in front of a trench of synthesized ground. bookcase_backfill = "above", + -- and no shelf relief: what the collapse carries here is MASONRY, + -- whose courses are the wall itself rather than panes set behind + -- a frame. Sinking them cut a stepped bevel into the League's + -- walls and the pilasters (shot before it was turned off). + bookcase_relief = false, -- 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 @@ -3364,6 +3751,156 @@ return { z = 13 }, -- keyboard }, }, + -- F05: the healing machine behind the counter -- two 4x4-tile + -- variants, 24 placements between them (a pair in every Center, + -- cells (1,0):(2,1) and (6,0):(7,1), plus the Indigo Plateau + -- lobby's pair under MART below). The variants differ only in + -- the east flank: the west machine has the control keyboard + -- there (7/13, scan "40,58,59,40;40,74,75,40;72,76,77,7; + -- 72,6,22,13" -- 12 placements), the east machine mirrored + -- hoses (73, scan "...;72,76,77,73;72,6,22,73" -- 12). + -- + -- The FULL drawing is claimed now: the corners are the striped + -- wall band (40), the flanks the machine's own equipment -- + -- hoses and a keyboard ATTACHED to the cabinet, not furniture + -- standing free (their standee pins above stay as the + -- degradation path). What no class can split is a wall-height + -- cabinet with a monitor perched on its front top edge, drawn + -- across two map rows because it towers over the 16px band + -- behind it, which the volume path can only read as more wall. + -- + -- Read off the pixels (absolute grid x0..x31): + -- rows 15-31, x8..x23 the CABINET's front face, 16 wide, 17 + -- tall: black front-top edge with vent notches + -- (15), white top rail (16), black panel in a + -- white frame (17-28), white rail / light plinth / + -- black ground line (29-31). `desk.x` bounds the + -- desk to the middle columns. + -- rows 6-14, x8..x23 the cabinet's TOP FACE seen from above + -- -- not a second-story facade: a white expanse + -- with a lit west strip (x9) and a shaded east + -- strip (x22) wrapping the monitor. Its 9 rows + -- plus the front edge row 15 ARE the cabinet's + -- depth: 10, z 16..25 against the wall. + -- `desk.top` lays the band flat as the lid; where + -- the monitor's drawing occludes it, the lid + -- continues the nearest strip (the drawing's own + -- pixels). + -- rows 1-13, x10..x21 the MONITOR: black back rim (1) and + -- white cap (2-3) seen from above -- depth 4 -- + -- then bezel, screen and control strip (4-13) + -- face-on, 10 tall, at the cabinet top's front + -- (the drawn base edge 13 is one band row up from + -- the front strip 14): z 20..23. The screen + -- interior (x13..x18, rows 6-8) sinks one voxel + -- via `inset` -- the pane rule applied by hand, + -- because `panes = false` blocks the global pass. + -- rows 16-31, x0..x7 the HOSES (tile 72 stacked): one 8-row + -- motif per map row, and the two stacked motifs + -- are two hoses in DEPTH -- the drawn row band is + -- the depth band, and each motif's own rows are + -- its elevation WITHIN that band, exactly the + -- potted plants' stacked-cell rule. So both + -- hoses hug the floor: a horizontal run off the + -- cabinet's side (rows 24-26, x3..x7 -> heights + -- 5..7) over a shaded drop landing ON the floor + -- (rows 27-31, x2..x7 -> heights 0..4) -- all + -- measured, the drawn extent maps 1:1 with no + -- continuation. `box` parts at z 18 and z 24, 3 + -- deep; both wear the LOWER motif's rows (the one + -- drawn at its true elevation; the upper motif is + -- the same atlas tile, pixel-identical). + -- rows 16-31, x24..x31 west variant: the KEYBOARD (7/13), a + -- key grid with its cable at the north end and a + -- bar down the east edge -- TOP-VIEW art, 16 rows + -- = 16 depth rows. A `flat` part mounted on the + -- cabinet's side at COUNTER level (`at` 8, the + -- counters' own 8px band -- the one number + -- top-view art cannot state, pinned to the room's + -- work surface), 3 thick, top face wearing the + -- drawn art. The dark dashes east of it (x30-31) + -- are its cast shadow on the floor: background. + -- East variant: mirrored hoses (runs x24..x28, + -- drops x24..x29), same rows and z slots. + -- rows 0-15 elsewhere wall stripes and the machine's cast + -- shadow -- BACKGROUND, the same standing as the + -- potted plants' floor. The `wall` element keeps + -- the band solid over the back map row, full grid + -- width: a 16-tall, 16-deep block cycling the + -- drawing's own stripe unit (x0, rows 0-3), what + -- the neighbouring cells' wall pins render. + -- + -- `depth = 4` is BOTH map rows: the back row is the wall's plot, + -- the front row the machine's own. `panes = false`: the front + -- panel seals DARK behind LIGHT (a black panel in a white + -- frame), the opposite polarity to the pane rule, so the global + -- recess pass would sink the frame and leave the panel proud. + { + id = "center_heal_machine_w", + tiles = { + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 7 }, + { 72, 6, 22, 13 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- N hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- N hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- S hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- S hose drop + { kind = "flat", x = { 24, 29 }, rows = { 16, 31 }, + z = 16, at = 8, thick = 3 }, -- keyboard shelf + }, + }, + { + id = "center_heal_machine_e", + tiles = { + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 73 }, + { 72, 6, 22, 73 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NW hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SW hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NE hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SE hose drop + }, + }, }, MART = { @@ -3388,6 +3925,78 @@ return { z = 13 }, -- keyboard }, }, + -- F05 again: the Indigo Plateau lobby has the Centers' healing + -- machine pair too -- the west-variant grid at (5,4):(6,5) and + -- the east at (10,4):(11,5), verified by region dump -- and its + -- MART tileset shares the POKECENTER atlas, so these are the + -- same drawings tile for tile. Same part tables as the + -- POKECENTER entries above. + { + id = "center_heal_machine_w", + tiles = { + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 7 }, + { 72, 6, 22, 13 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- N hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- N hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- S hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- S hose drop + { kind = "flat", x = { 24, 29 }, rows = { 16, 31 }, + z = 16, at = 8, thick = 3 }, -- keyboard shelf + }, + }, + { + id = "center_heal_machine_e", + tiles = { + { 40, 58, 59, 40 }, + { 40, 74, 75, 40 }, + { 72, 76, 77, 73 }, + { 72, 6, 22, 73 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + panes = false, + wall = { h = 16, depthPx = 16, x = 0, cycle = { 0, 3 } }, + desk = { x = { 8, 23 }, fascia = { 15, 16 }, base = { 17, 31 }, + z = 16, depthPx = 10, top = { 6, 14 } }, + parts = { + { kind = "upright", x = { 10, 21 }, top = { 1, 3 }, + facade = { 4, 13 }, z = 20, depth = 4, + inset = { x = { 13, 18 }, rows = { 6, 8 } } }, -- the monitor + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NW hose drop + { kind = "box", x = { 3, 7 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SW hose run + { kind = "box", x = { 2, 7 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SW hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 18, depth = 3 }, -- NE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 18, depth = 3 }, -- NE hose drop + { kind = "box", x = { 24, 28 }, rows = { 24, 26 }, + z = 24, depth = 3 }, -- SE hose run + { kind = "box", x = { 24, 29 }, rows = { 27, 31 }, + z = 24, depth = 3 }, -- SE hose drop + }, + }, }, GYM = { @@ -3416,5 +4025,460 @@ return { }, }, }, + + CLUB = { + -- F06: the Bike Shop's OPEN TOOLBOX -- BIKE_SHOP cells (6,6) and + -- (7,7), and nowhere else in the game (2 placements). + -- + -- The drawing looks down INTO the box: what fills its middle is not + -- a face but the inside of the tray, with a wrench lying in it, and + -- the lid standing open on the right. That is why every solid + -- treatment fails it. As a `billboard` (what these tiles carried) + -- the whole 16x16 went up as one 10-voxel per-pixel slab -- the + -- extruded picture in its pure form, a black monolith wearing the + -- drawing as a decal. Reading the middle as a cabinet FRONT and + -- extruding that is the same mistake one level down: it puts a wall + -- where the opening is. An open box has to be hollow, so `tray` + -- builds four walls, a floor slab and AIR between them. + -- + -- The bands, and they measure out exactly (8 depth rows for a + -- one-tile-row plot): + -- row 4 the FAR rim, black across the back wall -> z 0 + -- rows 5-10 the tray's inside, seen from above, with the + -- wrench lying across it -- drawn row = depth row, + -- six rows for six rows -> z 1-6 + -- row 11 the near wall's INNER face, in shadow + -- row 12 the near rim, black right across the box -> z 7 + -- rows 13-15 the near wall's outer face, seen face-on: a light + -- panel between its black rim and its black foot, + -- so the wall stands 4 voxels and the rim is its top + -- + -- `x` is the box's outer span and `inner` the opening's, so the + -- one-column difference on each side IS the wall. x12 is left out + -- of both on purpose: the #555 at x11-x12 on rows 13-15 is the 3/4 + -- shear of the right wall, which un-projecting simply removes -- + -- the wall is where the plan says it is, not where the projection + -- slides it to. `floor = 0` puts the tray's bottom one voxel thick, + -- leaving three voxels of open box above it. + -- + -- The LID is an upright part riding the rim, hinged on the right + -- and standing up: 12 tall as drawn, spanning the box's whole depth + -- (8) and 4 thick, which is the black outline plus the two columns + -- of white body the drawing gives it. It overhangs east of the + -- hinge, which is what a hinged lid standing open does. + { + id = "bike_shop_toolbox", + tiles = { + { 29, 13 }, + { 21, 22 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depthPx = 14, + tray = { top = { 4, 11 }, front = { 12, 15 }, x = { 2, 11 }, + inner = { 3, 9 }, floor = 0 }, + parts = { + { kind = "upright", x = { 11, 14 }, top = { 0, 0 }, + facade = { 0, 11 }, z = 0, depth = 14 }, -- the open lid + }, + }, + }, + + MANSION = { + -- F05: the TALL display cabinet, the one with the trophy behind + -- its glass -- CELADON_CHIEF_HOUSE cells 3,0 and 4,0 and + -- CELADON_MANSION_1F cell 2,2 (3 placements). Its 32 rows read + -- like every band table's: 0-8 the cabinet top seen from above + -- (black rim, white highlight courses along the north and west, + -- grey field, the front corner shaded at row 8); row 9 the top's + -- own black front edge -- which is what the rim treatment paints, + -- so slab = 1 and that row folds into the roof band instead of + -- extruding under it; 10-31 the front face: the trophy in its + -- dark display opening, the nameplate under it, then the two + -- panelled doors of the base. The contents come off the pixels, + -- not the band table: the trophy, the plate and both door panels + -- are non-black regions the drawing seals behind its own black + -- frame, so the measured recess pass sinks each of them a voxel + -- and the frames stay proud. Nine drawn top rows over a 32px + -- plot, so the rims map 1:1 -- the drawn front-corner shading + -- lands one voxel behind the front edge -- and the uniform field + -- cycles between. Both cell rows of the plot are BLOCKED and + -- both are cabinet drawing, so D is the whole grid: the rank is + -- built into the room's north wall, and its front stands flush + -- with the short case's beside it. 23 voxels tall. + { + id = "mansion_trophy_case", + tiles = { + { 38, 41 }, + { 40, 21 }, + { 56, 87 }, + { 50, 51 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F06: the SHORT book case standing beside it -- the same + -- cabinet on a grid one tile row shorter (CELADON_CHIEF_HOUSE + -- cells 2,0 and 5,0, CELADON_MANSION_1F tiles 2,5 and 6,5; 4 + -- placements). Identical top band, identical panelled base; + -- only the display opening is shorter, a shelf of books where + -- the tall one has the trophy. Same band numbers as F05 -- + -- which is what makes the pair read as one line of furniture -- + -- and 15 voxels tall against the tall one's 23, exactly the 8 + -- rows of drawing between them. The grid must carry the 38/41 + -- cap row: {34,35} over {50,51} alone also stands on + -- CELADON_MANSION_2F, where it is the bottom of a taller + -- two-shelf bank and not this drawing at all. The 8px of wall + -- behind the cap is not part of the cabinet and keeps its own + -- `wall` pin. + { + id = "mansion_bookcase", + tiles = { + { 38, 41 }, + { 34, 35 }, + { 50, 51 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F07: the long table in the middle of the chief's house + -- (CELADON_CHIEF_HOUSE cell 2,3), the lab table's read at four + -- cells wide and two deep. Rows 0-23 are the tabletop seen from + -- above; 24-26 are the top slab's own front edge, + -- black/#555/black, and row 27 the #555 shadow that closes it -- + -- exactly what the rim treatment paints, so slab = 3 and rows + -- 24-27 fold into the roof band. That leaves 28-30 as the base, + -- the same three rows the lab table's is, and the two tables + -- stand the same 6 voxels: row 28 the apron running the whole + -- width, 29-30 the end legs under it. The legs stop one row + -- short of the grid, so the measured ground line lands there and + -- the model does not float. Both cell rows of the plot are + -- blocked, so D is the whole grid: the 24 drawn top rows map 1:1 + -- from the north rim and the field's own rows cycle for the last + -- 8. The `table` pin these tiles keep stays 12px -- it is + -- shared with the 2F/3F writing desks and the rooftop shed, and + -- nothing rides this table -- so it is only the degradation + -- path, neutralized wherever this template stamps. + { + id = "mansion_long_table", + tiles = { + { 38, 39, 39, 39, 39, 39, 39, 41 }, + { 54, 55, 55, 55, 55, 55, 55, 57 }, + { 54, 55, 55, 55, 55, 55, 55, 57 }, + { 60, 58, 58, 58, 58, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, + slab = 3, frontEave = 0, ledge = nil, + }, + }, + + HOUSE = { + -- F08: the dining table of the generic town house -- 18 + -- placements, every home's cells (3,3):(4,4), Blue's and the + -- Daycare among them -- the chief's long table (F07 above) at two + -- cells wide, the same read to the row. Rows 0-23 are the rounded + -- tabletop seen from above (black rim, white highlight course, + -- grey field, #555 east rim); 24-26 the slab's own front edge, + -- black/#555/black, and 27 the #555 apron shadow that closes it, + -- folded into the band; 28-30 the base -- the apron's black + -- underrun and the corner legs -- stopping one row short of the + -- grid, so the measured ground line keeps the model on its plot. + -- The same 6 voxels the whole table family stands. The `table` + -- pins stay as the degradation path, neutralized where this + -- stamps; the schoolhouse's half-width table with its book and the + -- trashed house's ransacked corner are different grids and keep + -- theirs. + { + id = "house_table", + tiles = { + { 38, 39, 39, 41 }, + { 54, 47, 47, 57 }, + { 54, 47, 47, 57 }, + { 60, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, + slab = 3, frontEave = 0, ledge = nil, + }, + -- F10: the town house's BOOKCASE -- the commonest piece of + -- furniture in the game at 58 placements across three drawings, + -- and the piece this family was really for: pinned `desk` it was a + -- 24px box with a flat front and the books PAINTED on it. Band for + -- band it is the Celadon display cabinet (F05/F06 under MANSION) + -- on a different atlas, and it takes those numbers unchanged: rows + -- 0-8 the top seen from above (black rim, white highlight courses + -- along the north and west, grey field, the front corner shaded at + -- row 8); row 9 the top's own black front edge -- which is what the + -- rim treatment paints, so slab = 1 and that row folds into the + -- roof band instead of extruding under it; 10-31 the front: two + -- shelves in their dark openings over the cupboard's two panelled + -- doors. Every book, bowl and door panel is a non-black region the + -- drawing seals behind its own black frame, so the measured pane + -- pass sinks each one a voxel and the frames stand proud of them -- + -- the relief the `bookcase` class now carries too, arrived at the + -- same way. + -- + -- Nine drawn top rows over a 32px plot: the rims map 1:1 (the drawn + -- front-corner shading lands one voxel behind the front edge) and + -- the uniform field cycles between. BOTH cell rows of the plot are + -- blocked and both are cabinet drawing, so D is the whole grid -- + -- the case is built into the room's north wall. 23 voxels tall, + -- one under the `desk` pin it replaces and exactly the Celadon + -- trophy case's stand; the `desk` pins stay as the degradation + -- path, neutralized wherever this stamps. + -- + -- This drawing carries books and a bowl on each shelf: 36 + -- placements, the west end of eighteen homes. + { + id = "house_bookcase_bowls", + tiles = { + { 38, 41 }, + { 14, 15 }, + { 14, 15 }, + { 30, 31 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F10 again: its twin at the east end, books on both shelves -- 22 + -- placements, and the only one of the two that Cerulean's trashed + -- house also puts at the west end. + { + id = "house_bookcase_books", + tiles = { + { 38, 41 }, + { 48, 49 }, + { 48, 49 }, + { 30, 31 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F09: the stool at every one of those tables (94 placements on + -- this tileset) -- the first template with NO base piece. The + -- drawing is one object, a round seat on legs, drawn MID-CELL over + -- its own floor (rows 0-4 are the room behind it), and no band + -- split fits that: a roof band starts at the drawing's top row. + -- So it is a desk-set of exactly one upright part anchored to the + -- floor: rows 5-10 the seat seen from above (its lid), row 11 the + -- seat's own front edge, 12-15 the legs with the floor showing + -- between them, which the per-pixel facade cut keeps open. The + -- drawing measures 7 deep (six seat rows plus the front edge at + -- drawn row = depth row); the shipped stool is grown two voxels + -- past that north AND south (z 3..13, developer-tuned against the + -- in-game read), with `stretch` mapping the seat band over the + -- deeper lid instead of smearing its last row. `panes = false`: + -- a stool has no windows, and the pane rule would sink the legs' + -- lit faces behind their own outlines. The 5-voxel stand is the + -- drawn elevation, and the tileset's `heights` above pins the + -- `stool` ride height to it, so whoever sits here sits ON the + -- seat; the old stool standee pins stay as the degradation path. + { + id = "house_stool", + tiles = { + { 2, 3 }, + { 18, 19 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 5, 10 }, + facade = { 11, 15 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, + }, + + REDS_HOUSE_1 = { + -- F08 again: Red's and the Copycat's ground-floor dining table + -- (cells (3,4):(4,5) of both maps) -- the house table on the + -- reds_house atlas, with two differences the fields absorb. + -- + -- Its tabletop field stops at row 22 and row 23 is the top's own + -- #555 south rim, which roofBack = 24 would lay MID-TABLE as a + -- dark stripe: back stops at 23 and the field cycles from there + -- (the drawn rim's place at the south edge is under the rim + -- treatment's own black, like every sibling's). + -- + -- And the POTTED PLANT is drawn standing on the tabletop (rows + -- 5-15, x10..x21, tiles 40/55/56 with the cutout pool's 54/57 + -- beside them). It stays the cutout standee it has always been: + -- `keep` leaves its tiles unclaimed so the pin and its standee + -- survive, `scrub` replaces its pixels with the field shade so the + -- model tops out as the plain surface it sits on, and `support` + -- states the model's 6-voxel top plane so the standee stands ON + -- the modelled tabletop (Structures reads it off the claim; a + -- plain claim's h = 0 supports nothing). + { + id = "reds_house_table", + tiles = { + { 38, 39, 40, 41 }, + { 54, 55, 56, 57 }, + { 44, 42, 42, 43 }, + { 60, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 23, roofFront = 1, roofCycle = { 2, 22 }, + slab = 3, frontEave = 0, ledge = nil, + scrub = { { 10, 5, 21, 15 } }, + keep = { 40, 54, 55, 56, 57 }, + support = 6, + }, + -- F10 again: Red's and the Copycat's bookcase pair, cells (0,0) + -- and (1,0) of both maps (4 placements). The same object as the + -- town house's, one shelf of each of its two drawings -- 48/49 + -- books above, 34/35 below -- and the same band numbers; see HOUSE + -- above for the read. + { + id = "reds_bookcase", + tiles = { + { 38, 41 }, + { 48, 49 }, + { 34, 35 }, + { 50, 51 }, + }, + roofRows = 10, roofBack = 8, roofFront = 2, roofCycle = { 2, 7 }, + slab = 1, frontEave = 0, ledge = nil, + }, + -- F09 again: the stools around it (10 placements across Red's and + -- the Copycat's ground floors), pixel for pixel the house + -- tileset's drawing. Same part table; see HOUSE above. + { + id = "house_stool", + tiles = { + { 2, 3 }, + { 18, 19 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 5, 10 }, + facade = { 11, 15 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, + }, + + INTERIOR = { + -- F0x: Bill's desk, and the Silph president's -- one drawing, two + -- placements (BILLS_HOUSE cell 1,4 and SILPH_CO_11F cell 10,12, + -- both found by the scan and nothing else). The whole 4x4-tile + -- block is one designed unit, but this template deliberately takes + -- only the desk's own two cells. + -- + -- What the 16 drawn rows are: the TABLETOP seen from above, the + -- lab table's pattern tile for tile -- black rim, a white + -- highlight course inside it, grey field -- over a 16px plot, so + -- they map 1:1 and nothing cycles. Standing on it, and drawn INTO + -- that top view the way lab_computers' objects are: a terminal + -- (screen head over a keypad panel, rows 3-13) and the ball + -- (rows 1-13). Drawn row = depth row on a top view, so each part's + -- `z` is where the drawing puts it: the head's front row is 8, the + -- keypad picks up at 9, and the ball's front row is its drawn + -- bottom. Only the two DEPTHS and how much of each blob is lid + -- rather than face are authored -- the projection cannot state + -- either. + -- + -- Why the grid stops at two rows. This drawing's front face is its + -- APRON, and the artist drew the apron into the WALKABLE cell in + -- front (43/44 + 91/92) -- exactly where the lab table's fascia and + -- base are. But 43/44 is also the upper half of the CHAIR pushed + -- up to the desk (43/44 over 59/60, pinned `stool` in the tileset + -- section above), and a template claims whole TILES: reading the + -- apron would take the chair's back with it and leave a backless + -- stub in both rooms. So the apron stays with the chair, `plane` + -- authors the desk's height instead, and the body under the lid is + -- synthesized in the drawing's own shades (the band table's rim + -- treatment -- a shaded box closed by the outline at the floor). + -- 8px is not a taste number: it is the apron's own drawn row count + -- and the `table` height these tiles are already pinned to, so the + -- desk stands where the degradation path stood it. + { + id = "bills_desk", + tiles = { + { 11, 12, 13, 14 }, + { 27, 28, 29, 30 }, + { 43, 44, 91, 92 }, + { 59, 60, 31, 31 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 4, + -- the desk's own plot is its two cells; the grid runs on because + -- its apron and the CHAIR share tiles 43/44 + desk = { fascia = { 16, 18 }, base = { 19, 23 }, depth = 2 }, + parts = { + -- the notes: two sheets lying on the desk, so PAPER -- one + -- voxel proud at drawn row = depth row, and nothing raised + { kind = "flat", x = { 2, 15 }, rows = { 3, 7 } }, + -- the keyboard: a real slab, not a print on the desk. Its + -- keys are drawn from ABOVE so they ride the top face across + -- its depth, and its drawn bottom frame row stands as the + -- front edge + { kind = "upright", x = { 4, 15 }, top = { 8, 12 }, + facade = { 12, 13 }, z = 8, depth = 6 }, + -- the cord: the kinked dark run the drawing puts between the + -- keyboard's top-right corner and the computer's left corner. + -- Raised to the keyboard's own height so it reads as a cable + -- spanning them rather than a scuff on the desk + { kind = "upright", x = { 16, 19 }, top = { 8, 8 }, + facade = { 8, 9 }, z = 8, depth = 2 }, + -- the computer: drawn in 2:1 ISOMETRIC, turned 45 degrees to + -- the map -- see the `iso` branch in buildParts. `plan` = rx + -- makes its footprint a SQUARE turned 45, so from directly + -- above it is the cube the drawing depicts and not the slab + -- the 2:1 would otherwise build + { kind = "iso", x = { 19, 30 }, rows = { 1, 13 }, + plan = 6, z = 9 }, + -- the chair pushed up to the desk, drawn into the walkable + -- cell in front. It stands on the FLOOR, not on the desk, so + -- its rise is the whole plane back down; two voxels deep + -- because it is a chair, and `inside` cuts it per pixel, so + -- this is the thin slab the `stool` standee was -- the desk + -- claiming tiles 43/44 for its apron is what makes the + -- template owe it + { kind = "upright", x = { 2, 13 }, top = { 20, 21 }, + facade = { 22, 31 }, rise = -8, z = 22, depth = 10 }, + }, + }, + -- F09 once more: the Pokemon Fan Club's four members' chairs, + -- drawn round the boardroom table -- POKEMON_FAN_CLUB cells (1,3), + -- (1,4), (6,3) and (6,4), and nowhere else in the game. + -- + -- A DIFFERENT drawing from the house stool -- its seat field + -- carries a one-pixel white margin where the house's carries two + -- -- but the same object band for band: rows 5-10 the seat seen + -- from above (its lid), row 11 the seat's own front edge, 12-15 + -- the legs with the floor showing between them. Its elevation + -- rows are PIXEL-IDENTICAL to the house drawing's, so it takes + -- that part table unchanged, growth and all: z 3..13, `stretch` + -- mapping the seat band over the deeper lid, `panes = false` so + -- the pane rule does not sink the legs' lit faces. + -- + -- Rows 11-15 come from tiles 59/60, which this drawing SHARES + -- with Bill's chair (43/44 over 59/60, modelled as a part of + -- `bills_desk` above). That is why the tileset's `stool = 5` + -- height override is right for both seats rather than a special + -- case for this one: they are drawn at the same height because + -- they are drawn with the same pixels. + -- + -- Listed after `bills_desk` although neither grid is a subgrid of + -- the other (that grid has 43/44 over 59/60, this one 61/62), so + -- the order is free and the bigger, more specific grid keeps + -- first claim. + { + id = "club_stool", + tiles = { + { 61, 62 }, + { 59, 60 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 5, 10 }, + facade = { 11, 15 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, + }, }, } diff --git a/lib/Buildings.lua b/lib/Buildings.lua index b07c913..def6c81 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -68,6 +68,47 @@ local RECESS_MAX = 24 local SHADE = { top = 0.95, south = 1.0, north = 0.68, side = 0.78, bottom = 0.5 } +-- ------- how far a merged run may reach: the tile lattice +-- +-- Merging is what keeps a 90k-voxel house down to ~2k quads, and under a +-- straight projection a run may be as long as it likes -- a straight line +-- is a straight line however finely it is cut. THE WORLD CURVE IS NOT +-- STRAIGHT. It drops every vertex by the square of its distance from the +-- focus (see WorldCurve), so a quad's interior is the CHORD of a parabola +-- its neighbours draw the arc of: a run of length L hangs k*L^2/4 below +-- the short quads butted against it, and the join tears open. +-- +-- Nothing bounded a run's length before, and the runs that ran away were +-- the ones wearing a CONSTANT texel -- the roof's black eave outline, its +-- fascia, the shaded underside -- because a flat run has no art to break +-- it. Those reached 102px across a gym, which at V-CURVE 3 hangs some +-- three world pixels under the roof surface beside it: the eave tore off +-- the roof and the drop showed the building's dark interior through the +-- slot. (Strip runs, the drawing marching along the atlas, break at the +-- tileset's own boundaries and were never the problem.) +-- +-- So a run stops at the next 8px lattice line. Buildings are stamped at +-- tx*8 (see stamp), so the model's lattice IS the map's: every quad in the +-- scene -- terrain, props, this -- now ends on the same lines, every join +-- is vertex-for-vertex, and the bend carries them together. What is left +-- is the sag WITHIN one cell, k*64/4, which is under a twentieth of a +-- world pixel at any rung. +-- +-- It costs quads on a dense city map (Cerulean's object stream goes from +-- 35.7k to 41.6k, and its longest edge from 102px to 8px) and it costs them +-- whether the curve is on or not, which is the deliberate trade: the mesh +-- is cached per map and built asynchronously over seconds, so meshing for +-- the curve's sake only when the curve is on would mean rebuilding every +-- live map on a keypress. +local CELL = 8 + +-- How far a run starting at `a` may go before it crosses the next lattice +-- line. Floor-mod, so the awning's negative z lands on the same lines the +-- positive side does. +local function runCap(a) + return CELL - a % CELL +end + local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end @@ -170,6 +211,39 @@ local function read(t, data, perRow) local inside = {} for i = 0, W * H - 1 do inside[i] = not outside[i] end + + -- `scrub` names pixel rects where the drawing paints an object standing + -- ON the surface (Red's potted plant on the dining tabletop). The object + -- keeps its own standee -- the template's `keep` leaves its tiles + -- unclaimed -- so the band beneath it is the one surface the drawing + -- implies but never paints clear: every rect pixel takes the field + -- shade, sourced from the first field texel outside the rects, and the + -- model's top comes out as the plain surface the object sat on. + if t.scrub then + local function inRect(x, y) + for _, r in ipairs(t.scrub) do + if x >= r[1] and x <= r[3] and y >= r[2] and y <= r[4] then + return true + end + end + return false + end + local donor = nil + for i = 0, W * H - 1 do + if col[i] == GREY and inside[i] + and not inRect(i % W, math.floor(i / W)) then + donor = i + break + end + end + for i = 0, W * H - 1 do + if inRect(i % W, math.floor(i / W)) then + col[i] = GREY + ax[i], ay[i] = ax[donor], ay[donor] + inside[i] = true + end + end + end return { W = W, H = H, col = col, ax = ax, ay = ay, inside = inside } end @@ -276,6 +350,14 @@ local function measure(sp, t) end end + -- The pane rule reads a LIGHT region the drawing seals behind a BLACK + -- frame. A drawing built the other way round -- the healing machine's + -- dark screens sealed behind their own white bezels -- inverts under + -- it: every lit edge sinks and the black panes stand proud, a black + -- lattice a voxel off the face. `panes = false` says the drawing does + -- not carry the rule's polarity, so the facade stays flush. + if t.panes == false then recess = {} end + -- One representative texel per shade, taken from the building's own art: -- the roof's fascia and its undersides are geometry the drawing implies -- but never paints, and they must still wear its palette (and pick up @@ -300,7 +382,12 @@ local function measure(sp, t) -- onto ground the drawing merely stands its legs on: the lab table's -- third row is the walkable cell the player faces it from, and the -- full-grid depth would stand the model in their path. - return { top = top, ytop = ytop, D = (t.depth or #t.tiles) * 8, + -- `depth` names the plot in TILE ROWS, which is the right grain for a + -- building. `depthPx` names it in voxels, for an object whose real + -- depth is not a whole tile row -- the Bike Shop toolbox is a box + -- standing in the middle of its own cell, not a thing that fills a plot. + return { top = top, ytop = ytop, + D = t.depthPx or ((t.depth or #t.tiles) * 8), ground = ground, recess = recess, interior = interior, shadeTexel = shadeTexel } end @@ -343,104 +430,203 @@ local function deskSetModel(sp, pr, t) return sx end - local f0, f1 = t.desk.fascia[1], t.desk.fascia[2] - local b0, b1 = t.desk.base[1], t.desk.base[2] - local plane = (b1 - b0 + 1) + (f1 - f0 + 1) - - -- the base band, extruded exactly like every lab table's - for sy = b0, b1 do - Budget.tick() - local y = ground - 1 - sy - for sx = 0, W - 1 do - if inside[sy * W + sx] then - local ix = interiorAt(sx, sy, 0, W - 1) - for z = 0, D - 1 do - local px = (z == 0 or z == D - 1) and sx or ix - put(sx, y, z, sy * W + px) + -- The parts list, shared by every base piece: a desk plane or an + -- open tray rim alike, `plane` is simply the height they ride. + local ytop = 0 + local function buildParts(plane) + for _, p in ipairs(t.parts) do + Budget.tick() + local x0, x1 = p.x[1], p.x[2] + if p.kind == "flat" then + -- drawn row = depth row by default; `z` renames the origin when + -- the flat sits below the desk's own drawn top span (the Center + -- PC's keyboard). `at` names the sheet's own height when it does + -- not lie on the desk plane (the healing machine's keyboard is a + -- shelf mounted on the cabinet's side); `thick` gives it a body + -- -- layers below the sheet repeating each column's own texel, + -- the same continuation rule every synthesized surface follows. + local r0 = p.rows[1] + local z0 = p.z or r0 + local atY = p.at or plane + local thick = p.thick or 1 + if atY > ytop then ytop = atY end + for sy = r0, p.rows[2] do + local z = z0 + (sy - r0) + if z >= 0 and z < D then + for sx = x0, x1 do + if inside[sy * W + sx] then + for y = math.max(0, atY - thick + 1), atY do + put(sx, y, z, sy * W + sx) + end + end + end + end end - end - end - end - for i in pairs(pr.recess) do - local sy = math.floor(i / W) - if sy >= b0 and sy <= b1 then - vox[key(i % W, ground - 1 - sy, D - 1)] = nil - end - end - - -- the slab: fascia rows wrap every side; the lid continues the - -- sibling tables' top -- black rim, white highlight courses along - -- the north and west, grey field - for sy = f0, f1 do - Budget.tick() - local y = plane - 1 - (sy - f0) - for sx = 0, W - 1 do - for z = 0, D - 1 do put(sx, y, z, sy * W + sx) end - end - end - local field = t.desk.lid == "white" and WHITE or GREY - for sx = 0, W - 1 do - for z = 0, D - 1 do - local shade = field - if sx == 0 or sx == W - 1 or z == 0 or z == D - 1 then - shade = BLACK - elseif sx == 1 or z == 1 then - shade = WHITE - end - put(sx, plane - 1, z, pr.shadeTexel[shade]) - end - end - - local ytop = plane - for _, p in ipairs(t.parts) do - Budget.tick() - local x0, x1 = p.x[1], p.x[2] - if p.kind == "flat" then - -- drawn row = depth row by default; `z` renames the origin when - -- the flat sits below the desk's own drawn top span (the Center - -- PC's keyboard) - local r0 = p.rows[1] - local z0 = p.z or r0 - for sy = r0, p.rows[2] do - local z = z0 + (sy - r0) - if z >= 0 and z < D then + elseif p.kind == "box" then + -- A BOX part is a drawn rect standing at its own drawn + -- elevation -- equipment attached to the machine rather than an + -- object on the desk plane. The rows are face-on art: the top + -- row's drawn height IS the box's top (ground - 1 - r0, + -- measured), and the box runs down to `base` (default the drawn + -- extent; 0 continues it to the floor, the legs-continue rule). + -- Height beyond the drawn rows fills the way a roof band does: + -- rows before `cycle` map 1:1 from the top, rows after it 1:1 + -- from the bottom -- the healing machine hoses' foot lands ON + -- the floor -- and the cycle window repeats between. + local r0, r1 = p.rows[1], p.rows[2] + local c0 = p.cycle and p.cycle[1] or r1 + local c1 = p.cycle and p.cycle[2] or r1 + local pz = p.z or 0 + local pd = p.depth + local top = pr.ground - 1 - r0 + local bot = p.base or (pr.ground - 1 - r1) + local nTop, nBot = c0 - r0, r1 - c1 + if top > ytop then ytop = top end + for y = bot, top do + local k, j = top - y, y - bot + local sy + if k < nTop then + sy = r0 + k + elseif j < nBot then + sy = r1 - j + else + sy = c0 + (k - nTop) % (c1 - c0 + 1) + end for sx = x0, x1 do - if inside[sy * W + sx] then put(sx, plane, z, sy * W + sx) end + local i = sy * W + sx + if inside[i] then + local ix = interiorAt(sx, sy, x0, x1) + for z = pz, pz + pd - 1 do + if z >= 0 and z < D then + local px = (z == pz or z == pz + pd - 1) and sx or ix + put(sx, y, z, sy * W + px) + end + end + end end end - end - else - local tr0, tr1 = p.top[1], p.top[2] - local fr0, fr1 = p.facade[1], p.facade[2] - local pd = p.depth - local ytp = plane + (fr1 - fr0) - if ytp > ytop then ytop = ytp end - for sx = x0, x1 do - -- the lid: the part's drawn top laid across its depth from the - -- back, last row continuing forward; the front lid row is the - -- facade's own top row -- the drawn front-top edge - for z = 0, pd - 1 do - local front = z == pd - 1 - local sy = front and fr0 or math.min(tr0 + z, tr1) - while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end - local ok = sy <= tr1 or (front and inside[fr0 * W + sx]) - if ok then - put(sx, ytp, z, (front and fr0 or sy) * W + sx) + elseif p.kind == "iso" then + -- An ISO part is drawn in 2:1 isometric -- a box TURNED 45 + -- degrees to the map, so one rhombus carries its top, its front + -- and its side at once and no band or facade split can reach + -- them. Un-projecting it is that projection run backwards: the + -- box stands as a real diamond in plan and every voxel wears the + -- texel the drawing paints where that voxel projects TO. The + -- drawn top lands on the top, the screen on the screen-facing + -- side and the flank on the flank, and nothing is segmented by + -- hand -- which is the only way to get this right, because the + -- three faces meet on a diagonal no rectangle can name. + -- + -- Everything but the depth centre falls out of the drawn rect, + -- because the projection fixes it: the half-width is the drawn + -- rhombus's x radius, HALF that again its z radius (2:1 is what + -- makes it isometric), the near corner's drawn row is the base + -- rhombus's front tip, and whatever drawn height is left once + -- that rhombus is accounted for is the box's own height. Bill's + -- computer: rx 6, rz 3, base centre row 10, and 6 voxels tall -- + -- which puts its left corner's vertical edge at drawn rows + -- 4..10, exactly where the drawing paints one. + -- + -- `plan` is the one thing the drawing CANNOT state: 2:1 is the + -- projection, not the object, so reading rz as the plan radius + -- too builds a box half as deep as it is wide -- a slab, not the + -- cube the drawing depicts. `plan` names the real z radius and + -- the drawn row is scaled into it, so a cube is `plan = rx` and + -- the drawing still lands on it pixel for pixel. + local pr0, pr1 = p.rows[1], p.rows[2] + local rx = math.floor((x1 - x0 + 1) / 2) + local rz = math.floor(rx / 2) + local plan = p.plan or rz + local oy = pr1 - rz + local h = oy - rz - pr0 + local ytp = plane + h + if ytp > ytop then ytop = ytp end + for sx = x0, x1 do + -- doubled, so a rect of even width keeps its centre between + -- two columns instead of limping one to the left + local dx2 = 2 * sx - (x0 + x1) + for dz = -plan, plan do + local z = p.z + dz + local d2 = math.abs(dx2) * plan + 2 * math.abs(dz) * rx + if z >= 0 and z < D and d2 <= (2 * rx + 1) * plan then + -- the plan row scaled back into the drawn rhombus + local dzs = math.floor((2 * dz * rz + plan) / (2 * plan)) + for y = 0, h do + local sy = oy + dzs - y + local i = sy * W + sx + if sy >= pr0 and sy <= pr1 and inside[i] then + put(sx, plane + y, z, i) + end + end + end end end - -- the body: facade rows anchored to the desk's top plane - for sy = fr0 + 1, fr1 do - local y = plane + (fr1 - sy) - local i = sy * W + sx - if inside[i] then - local ix = interiorAt(sx, sy, x0, x1) - for z = 0, pd - 1 do - if z == pd - 1 then - if not pr.recess[i] then put(sx, y, z, i) end - elseif z == 0 then - put(sx, y, z, i) - else - put(sx, y, z, sy * W + ix) + else + local tr0, tr1 = p.top[1], p.top[2] + local fr0, fr1 = p.facade[1], p.facade[2] + local pd = p.depth + -- `rise` lifts a part off the desk's top plane and `z` names its + -- back-most depth row (the field a flat part already carries). An + -- object STANDING on a desk needs neither: it starts on the plane + -- at the plot's back. The healing machine's console needs both -- + -- it stands in the FRONT map row of a grid whose back row is the + -- wall band it leans against, and its screen head is MOUNTED on + -- the console's front two voxels above the body's top. Both come + -- off the drawing, not off taste. + local base = plane + (p.rise or 0) + local pz = p.z or 0 + local ytp = base + (fr1 - fr0) + if ytp > ytop then ytop = ytp end + -- `inset` sinks an authored pane one voxel: the pane rule + -- applied by hand, for a part whose screen IS sealed behind its + -- own black frame while the template's `panes = false` (set for + -- the polarity-inverted panel elsewhere in the same drawing) + -- blocks the global pass. Same mechanism as a recess: the front + -- voxel is simply not placed. + local ins = p.inset + for sx = x0, x1 do + -- the lid: the part's drawn top laid across its depth from the + -- back, last row continuing forward; the front lid row is the + -- facade's own top row -- the drawn front-top edge. `stretch` + -- maps the drawn band over the whole depth instead, the tray's + -- rule: for a part authored DEEPER than its drawing (the house + -- stool grown past its drawn seat), clamping would print the + -- last row as a long smear off the back band's edge. + for z = pz, pz + pd - 1 do + local front = z == pz + pd - 1 + local sy + if front then + sy = fr0 + elseif p.stretch then + sy = math.min(tr0 + math.floor((z - pz) * (tr1 - tr0 + 1) + / (pd - 1)), tr1) + else + sy = math.min(tr0 + z - pz, tr1) + end + while sy <= tr1 and not inside[sy * W + sx] do sy = sy + 1 end + local ok = sy <= tr1 or (front and inside[fr0 * W + sx]) + if ok and z >= 0 and z < D then + put(sx, ytp, z, (front and fr0 or sy) * W + sx) + end + end + -- the body: facade rows anchored to the part's own base + for sy = fr0 + 1, fr1 do + local y = base + (fr1 - sy) + local i = sy * W + sx + if inside[i] then + local ix = interiorAt(sx, sy, x0, x1) + for z = pz, pz + pd - 1 do + if z >= 0 and z < D then + if z == pz + pd - 1 then + local sunk = ins and sx >= ins.x[1] and sx <= ins.x[2] + and sy >= ins.rows[1] and sy <= ins.rows[2] + if not sunk and not pr.recess[i] then put(sx, y, z, i) end + elseif z == pz then + put(sx, y, z, i) + else + put(sx, y, z, sy * W + ix) + end + end end end end @@ -449,6 +635,237 @@ local function deskSetModel(sp, pr, t) end end + -- A TRAY is an open container -- the drawing looks down INTO it, so its + -- top-view band is not a lid but the inside of the box, and the model + -- has to be hollow. Bands, all measured 1:1 like any other band table: + -- `top` is the opening (drawn row -> depth row), `front` the near wall + -- seen face-on (drawn row -> elevation), `x` the box's outer span and + -- `inner` the opening's, so the difference between them is the wall. + -- Four walls stand to the rim, the floor slab lies `floor` voxels thick + -- under the opening, and the cavity between them is left as AIR -- which + -- is the whole point, and what an extruded facade can never be. Parts (a + -- standing lid) then ride the rim like any object on a desk's plane. + if t.tray then + local tr = t.tray + local top0 = tr.top[1] + local fr0, fr1 = tr.front[1], tr.front[2] + local bx0, bx1 = tr.x[1], tr.x[2] + local ix0, ix1 = tr.inner[1], tr.inner[2] + local floor = tr.floor or 0 + local plane = fr1 - fr0 + 1 -- the rim: the wall's height + -- Which drawn row lies at depth z. The far rim is the band's first + -- row and the near rim the front wall's own, and the drawn inside + -- STRETCHES over whatever depth is between them: a box deeper than + -- its drawing has rows to spare is the ordinary case once the plot + -- stops being the grid, and the alternative -- running out of rows + -- and repeating the last one -- would print the wrench twice. + local lo, hi = top0 + 1, tr.top[2] - 1 -- the drawn inside + local span = math.max(1, D - 3) -- interior depth rows - 1 + local function trayRow(z) + if z == 0 then return top0 end + if z == D - 1 then return fr0 end + return lo + math.floor((z - 1) * (hi - lo) / span) + end + for sx = bx0, bx1 do + Budget.tick() + for z = 0, D - 1 do + local hollow = sx >= ix0 and sx <= ix1 and z > 0 and z < D - 1 + for y = 0, (hollow and floor or plane - 1) do + if hollow or y == plane - 1 then + -- the opening seen from above: the tray's own floor and + -- whatever lies in it -- and the rim is the same band where + -- the wall meets it + local i = trayRow(z) * W + sx + if inside[i] then put(sx, y, z, i) end + else + -- the wall below the rim: the front band folded up it, the + -- drawn face on the front and back layers and the de-outlined + -- interior between, exactly as a facade extrudes. + -- + -- NO recess pass here, and it must stay that way: a pane sinks + -- by DELETING its front voxel so the one behind becomes the + -- pane, and a container's wall is one voxel thick -- there is + -- nothing behind it, so the front panel simply opened a hole + -- straight into the box and you could see the wrench through it. + local sy = fr1 - y + local i = sy * W + sx + if inside[i] then + local px = (z == 0 or z == D - 1) and sx + or interiorAt(sx, sy, bx0, bx1) + put(sx, y, z, sy * W + px) + end + end + end + end + end + if plane > ytop then ytop = plane end + buildParts(plane) + return { at = function(x, y, z) + if x < 0 or x >= W or y < 0 or z < 0 or z >= D then + return nil + end + return vox[key(x, y, z)] + end, + W = W, ytop = ytop, zmin = 0, zmax = D - 1 } + end + + -- No base piece at all: the drawing IS its parts (the house stool -- a + -- seat and its legs, nothing under them but floor). The plane the parts + -- anchor to is the ground itself. + if not t.desk then + buildParts(0) + return { at = function(x, y, z) + if x < 0 or x >= W or y < 0 or z < 0 or z >= D then + return nil + end + return vox[key(x, y, z)] + end, + W = W, ytop = ytop, zmin = 0, zmax = D - 1 } + end + + -- The desk's top plane. Usually the drawing states it: the fascia and + -- base rows it paints below the objects ARE the front face, and their + -- row count is the height. Bill's desk paints neither inside its grid + -- -- its apron is drawn into the WALKABLE cell in front, and that cell + -- is left out on purpose so the chair standing there keeps its own + -- tiles -- so `plane` names the height directly and the body below the + -- lid is synthesized: the band table's own rim treatment, a shaded box + -- closed by the outline where it meets the floor, in the drawing's + -- shades via shadeTexel. + local f0, f1 = t.desk.fascia[1], t.desk.fascia[2] + local b0, b1 = t.desk.base[1], t.desk.base[2] + local plane = (b1 - b0 + 1) + (f1 - f0 + 1) + + -- The desk's own PLOT, when the grid holds more than the desk. Bill's + -- grid runs on into the walkable cell, because the drawing puts the + -- desk's apron AND the chair pushed up to it in the same tiles -- so + -- the desk box has to stop at its own cell (`depth`) and stand on its + -- own ground line rather than the grid's, which the chair's feet set + -- eight rows lower. The base band's last row IS that ground line by + -- definition, and for every desk drawn inside its own grid it is the + -- measured one to the row (lab table, lab computers, Center PC, the + -- Bike Shop toolbox), so this changes nothing for them. + -- ...and in voxels (`depthPx`) plus a back origin (`z`) when the desk + -- is shallower than a tile row and leans against something: the + -- healing machine's cabinet is 10 deep -- its drawn top band's 9 rows + -- plus the front edge -- standing against the wall band, so its box + -- runs z 16..25 of a 32-deep plot. + local deskD = t.desk.depthPx or (t.desk.depth and t.desk.depth * 8) or D + local dz0 = t.desk.z or 0 + local dz1 = dz0 + deskD - 1 + local deskG = b1 + 1 + -- ...and the desk's COLUMNS (`x`), when the grid is wider than the + -- desk: the healing machine's grid carries its flanking hoses and + -- keyboard, and the cabinet is only the middle 16 columns. + local dx0 = t.desk.x and t.desk.x[1] or 0 + local dx1 = t.desk.x and t.desk.x[2] or W - 1 + + -- The WALL element: the band the machine backs onto, whose tiles this + -- grid claims. The drawing shows it only as the stripe background + -- around the tower (the same standing as the potted plants' floor), + -- so the block cycles the drawing's own stripe unit -- real pixels of + -- column `x`, rows `cycle` -- at wall-band height over the back plot, + -- exactly what the neighbouring cells' `wall` pins render. + if t.wall then + local wl = t.wall + local c0, c1 = wl.cycle[1], wl.cycle[2] + local cn = c1 - c0 + 1 + local wx = wl.x or 0 + for y = 0, wl.h - 1 do + Budget.tick() + local sy = c0 + (wl.h - 1 - y) % cn + for sx = 0, W - 1 do + for z = 0, wl.depthPx - 1 do + put(sx, y, z, sy * W + wx) + end + end + end + end + + -- the base band, extruded exactly like every lab table's + for sy = b0, b1 do + Budget.tick() + local y = deskG - 1 - sy + for sx = dx0, dx1 do + if inside[sy * W + sx] then + local ix = interiorAt(sx, sy, dx0, dx1) + for z = dz0, dz1 do + local px = (z == dz0 or z == dz1) and sx or ix + put(sx, y, z, sy * W + px) + end + end + end + end + for i in pairs(pr.recess) do + local sy = math.floor(i / W) + local sx = i % W + if sy >= b0 and sy <= b1 and sx >= dx0 and sx <= dx1 then + vox[key(sx, deskG - 1 - sy, dz1)] = nil + end + end + + -- the slab: fascia rows wrap every side + for sy = f0, f1 do + Budget.tick() + local y = plane - 1 - (sy - f0) + for sx = dx0, dx1 do + for z = dz0, dz1 do put(sx, y, z, sy * W + sx) end + end + end + + if t.desk.top then + -- The lid wears the desk's own drawn top band -- the drawing DOES + -- paint this tabletop (the healing machine's white top face with + -- its lit west and shaded east strips), so nothing is synthesized + -- where it is visible: band rows map back-to-front, the first + -- fascia row is the drawn front-top edge, same rule as an upright + -- part's lid. Where a part's drawing occludes the band (the monitor + -- standing on it), the lid continues the nearest strip BESIDE the + -- part -- still the drawing's own pixels, the same sibling-pattern + -- rule every synthesized lid follows. + local tr0, tr1 = t.desk.top[1], t.desk.top[2] + for z = dz0, dz1 do + Budget.tick() + local sy = z == dz1 and f0 or math.min(tr0 + (z - dz0), tr1) + for sx = dx0, dx1 do + local px = sx + for _, p in ipairs(t.parts) do + local px0, px1 = p.x[1], p.x[2] + local r0, r1 + if p.kind == "flat" or p.kind == "iso" or p.kind == "box" then + r0, r1 = p.rows[1], p.rows[2] + else + r0, r1 = p.top[1], p.facade[2] + end + if sx >= px0 and sx <= px1 and sy >= r0 and sy <= r1 then + px = (sx - px0 < px1 - sx) and (px0 - 1) or (px1 + 1) + px = math.max(dx0, math.min(dx1, px)) + break + end + end + put(sx, plane - 1, z, sy * W + px) + end + end + else + -- the lid continues the sibling tables' top -- black rim, white + -- highlight courses along the north and west, grey field + local field = t.desk.lid == "white" and WHITE or GREY + for sx = dx0, dx1 do + for z = dz0, dz1 do + local shade = field + if sx == dx0 or sx == dx1 or z == dz0 or z == dz1 then + shade = BLACK + elseif sx == dx0 + 1 or z == dz0 + 1 then + shade = WHITE + end + put(sx, plane - 1, z, pr.shadeTexel[shade]) + end + end + end + + if plane > ytop then ytop = plane end + buildParts(plane) + return { at = function(x, y, z) if x < 0 or x >= W or y < 0 or z < 0 or z >= D then return nil end return vox[key(x, y, z)] @@ -638,7 +1055,8 @@ local function emit(m, sp, atlasW, atlasH) local function runX(y, z, dx, dy, dz, x) local i0 = ci(x, y, z) local strip, n = nil, 1 - while true do + local cap = runCap(x) + while n < cap do local nx = x + n local i = ci(nx, y, z) if not i or ci(nx + dx, y + dy, z + dz) then break end @@ -730,8 +1148,8 @@ local function emit(m, sp, atlasW, atlasH) while z <= zmax do local i = ci(x, y, z) if i and not ci(x + d, y, z) then - local n = 1 - while z + n <= zmax do + local n, cap = 1, runCap(z) + while n < cap and z + n <= zmax do local j = ci(x, y, z + n) if j ~= i or ci(x + d, y, z + n) then break end n = n + 1 @@ -840,7 +1258,7 @@ function Buildings.build(S, map, data, perRow) end built = models[key] end - Buildings.stamp(S, map, built, tx, ty, bw, bh) + Buildings.stamp(S, map, built, tx, ty, bw, bh, t) end end end @@ -850,9 +1268,24 @@ end -- One placement: claim its tiles (so the detector leaves them alone and -- the mesher paints ground under them) and copy the model into place. -function Buildings.stamp(S, map, quads, tx, ty, bw, bh) - local shape = { class = "building", h = 0, art = "building", - flat = false, authored = true } +-- +-- Two template fields alter what a claim means, for a drawing that +-- carries a STANDEE on its surface (Red's potted plant on the dining +-- table). `keep` names tile ids the stamp must NOT claim: their authored +-- pins stay live, so the standee scan still stands the object exactly as +-- it always did. `support` is the model's top plane in voxels: the claim +-- shape carries it as its height, which is what tells that scan the +-- standee's shelf -- a plain claim stays at h = 0, and Structures treats +-- a building claim with height as a full model (skip, never a second +-- box; see its support branches). +function Buildings.stamp(S, map, quads, tx, ty, bw, bh, t) + local shape = { class = "building", h = (t and t.support) or 0, + art = "building", flat = false, authored = true } + local keep = nil + if t and t.keep then + keep = {} + for _, id in ipairs(t.keep) do keep[id] = true end + end -- the ground the building stands on: the commonest flat tile around its -- feet, so a house on a path keeps its path @@ -878,9 +1311,18 @@ function Buildings.stamp(S, map, quads, tx, ty, bw, bh) for r = 0, bh - 1 do for c = 0, bw - 1 do local k = keyOf(tx + c, ty + r) - S.shapeAt[k] = shape - S.skip[k] = true - S.ground[k] = best or false + if keep and keep[S.tileAt[k]] then + -- unclaimed by request: the tile keeps its pin (the plant's + -- cutout pool) and the standee scan finds it there. Only the + -- ground is set now, so the scan's own claim of these tiles has + -- the building's floor to paint when no flat tile touches a + -- cluster ringed by its own furniture. + S.ground[k] = best or false + else + S.shapeAt[k] = shape + S.skip[k] = true + S.ground[k] = best or false + end end end diff --git a/lib/ChunkMesher.lua b/lib/ChunkMesher.lua index 73ed890..eb2dbb9 100644 --- a/lib/ChunkMesher.lua +++ b/lib/ChunkMesher.lua @@ -853,14 +853,24 @@ end -- 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. +-- A list, not a mesh: `{ mesh, wx, wz, y, w }` per figure. Maps have one +-- or none, so the loop that draws them is shorter than the terrain's. +-- `w` is the card's own width in its local space (its quads start at +-- x = 0), measured here because the first-person pass yaws a card about +-- its middle -- a card yawed about its left edge swings off its seat. 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 } + local w = 0 + for _, q in ipairs(f.quads) do + for c = 1, 4 do + local x = q[c] and q[c][1] + if x and x > w then w = x end + end + end + out[#out + 1] = { mesh = mesh, wx = f.wx, wz = f.wz, y = f.y, w = w } end end return out @@ -1132,8 +1142,8 @@ 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. +-- Authored figures as `{ mesh, wx, wz, y, w }` 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 diff --git a/lib/FirstPerson.lua b/lib/FirstPerson.lua new file mode 100644 index 0000000..d1019b4 --- /dev/null +++ b/lib/FirstPerson.lua @@ -0,0 +1,713 @@ +-- Voxel world mode: the first-person camera -- the 1ST rung. +-- +-- Every other rung is the same camera at a different pitch: an orbit over +-- the view centre, described by one number. 1ST is a different rig +-- entirely: the eye stands in the player's own head, the view direction is +-- the player's to steer -- mouse, right stick or a touch drag -- and the +-- rig rides the placed-camera seam (Voxel3D.camera) that the staged battle +-- already proved out. Everything downstream of that seam -- the shader +-- uniforms, project(), the sky's vanishing line, the water's lean -- reads +-- eye and focus the same way it always has. +-- +-- What this module owns: +-- +-- the ATTITUDE yaw and pitch, fed by whichever look input speaks: +-- relative mouse motion, the right stick's rate, or a +-- touch dragged across open screen. All three drive the +-- same two numbers, so they compose instead of fighting. +-- +-- the BLEND easing between the orbit and the head. Stepping onto +-- the rung dives the camera from wherever the orbit was +-- into the player's eyes over half a second; stepping off +-- flies it back out. Mid-blend the rig is a straight lerp +-- of the two cameras -- eye, focus, fov, up -- through +-- the same placed-camera record. +-- +-- the MOVE INTENT the analog vector FreeMove walks the player by, +-- gathered here because it is made of the same devices: +-- the left stick's raw axes, the touch d-pad's true +-- deflection, or the held keys, rotated by this camera's +-- yaw so "forward" means "where I am looking". +-- +-- Deliberately NOT here: movement itself (lib/FreeMove.lua, which owns the +-- collision walk and the grid the game logic still lives on), and the +-- billboard math that faces cards at this eye (VoxelScene, which owns +-- every other card matrix too). +-- +-- Everything the module reaches -- the mouse's relative mode, the wrapped +-- love handlers, the touch overlay's hit test -- is pcall-guarded the same +-- way the 3D pass is: headless runs and drivers without a mouse simply +-- never see the input, and the rung falls back to holding the 75-degree +-- orbit. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local Mat4 = V.require("Mat4") +local Voxel = V.require("VoxelState") +local Voxel3D = V.require("Voxel3D") +local WorldCurve = V.require("WorldCurve") + +local FirstPerson = {} + +-- ------- the rig's numbers +-- +-- EYE_HEIGHT stands the eye near the top of the 16px sprite -- the head, +-- not the hat tip -- above the same ground-plus-lift the character card +-- stands on, so surfing bobs and ledge hops carry the view with them. +-- +-- FOV is wider than the diorama's ~53 degrees: inside the world, the +-- diorama's lens reads as a keyhole. 65 vertical is the modern-shooter +-- middle ground. +-- +-- FOCUS_DIST is short on purpose: the placed-camera branch derives its +-- near plane from |eye - focus| (dist * 0.05), and the eye walks within +-- 2-3 world pixels of a wall face when sliding along it -- a far focus +-- would push the near plane through the wall and clip a hole in it. +FirstPerson.EYE_HEIGHT = 13 +FirstPerson.FOV = math.rad(65) +FirstPerson.FOCUS_DIST = 24 + +-- Pitch limits, in radians below horizontal (positive looks DOWN). The +-- world has no ceiling and the sky's bands sit low, so looking far up +-- shows the void above the gradient; the up-range is clamped tighter than +-- the down-range for that reason, not a technical one. +FirstPerson.PITCH_DOWN = math.rad(70) +FirstPerson.PITCH_UP = -math.rad(50) +FirstPerson.PITCH_DEFAULT = math.rad(10) + +-- how long the dive into (and out of) the head takes, in seconds +FirstPerson.BLEND_TIME = 0.45 + +-- ------- look input tuning +-- +-- MOUSE_SENS is radians per relative-mode count -- about 0.18 degrees per +-- count, the conventional shooter default. STICK rates are radians per +-- second at full deflection, with a squared response curve so small +-- deflections aim and full ones turn. TOUCH_TURN is what one full screen +-- width of drag turns, mobile-shooter convention. +FirstPerson.MOUSE_SENS = 0.0032 +FirstPerson.STICK_YAW = 3.5 +FirstPerson.STICK_PITCH = 2.4 +FirstPerson.STICK_DEAD = 0.18 +FirstPerson.TOUCH_TURN = 2.2 * math.pi +FirstPerson.MOVE_DEAD = 0.25 + +-- ------- state +-- +-- Yaw is a world bearing: 0 faces south (+Z, the way a resting sprite +-- faces), pi/2 east -- the same convention VoxelScene.YAW uses, so a +-- facing converts to a yaw by table lookup. +FirstPerson.yaw = 0 +FirstPerson.pitch = FirstPerson.PITCH_DEFAULT +FirstPerson.blend = 0 + +local wasEngaged = false +local stick = { x = 0, y = 0 } -- right stick, latest event values +local mouseDX, mouseDY = 0, 0 -- relative counts since last update +local lookTouch = nil -- { id, x, y } of the claimed finger +local touchMove = nil -- the touch d-pad's analog deflection +local captured = false -- mouse relative mode engaged by us + +-- the placed-camera record this module last handed to Voxel3D, so passes +-- that key behaviour off "is the first-person rig the one drawing" (the +-- billboard yaw, the frame remap) can ask by identity rather than by mode +-- -- the battle's own placed camera must never read as first person +local rig = nil + +local FACING_ANGLE = { + down = 0, + right = math.pi / 2, + up = math.pi, + left = -math.pi / 2, +} +local FACING_ORDER = { "down", "right", "up", "left" } + +local function wrapPi(a) + return (a + math.pi) % (2 * math.pi) - math.pi +end + +local function ease(t) + return t * t * (3 - 2 * t) +end + +-- ------- gates + +-- Whether the 1ST rung is selected and the 3D pass can carry it. +function FirstPerson.engaged() + return Voxel.isFirstPerson(Voxel.level) and Voxel3D.available() +end + +-- Whether first person should be READING the player's inputs right now: +-- engaged, with the overworld on top of the stack (a menu, a dialog or a +-- battle above it owns the buttons, exactly as it does for grid walking). +function FirstPerson.driving() + if not FirstPerson.engaged() then return false end + local ok, top, ow = pcall(function() + local Game = require("src.core.Game") + return Game.stack and Game.stack:top(), Game.overworld + end) + return ok and top ~= nil and top == ow +end + +-- The eased blend, 0 at the orbit and 1 in the head. +function FirstPerson.blendEased() + return ease(FirstPerson.blend) +end + +-- The blend, but only while the free-roam pass's own rig is the placed +-- camera. The battle scene places a camera of its own through the same +-- seam, and its cards must keep their stage lean rather than yawing at a +-- first-person eye that is not looking at them. +function FirstPerson.cardBlend() + if not rig or Voxel3D.camera ~= rig then return 0 end + return ease(FirstPerson.blend) +end + +-- Whether the player's own card should be left out of the camera draw: +-- deep enough into the blend that the card would fill the lens from +-- inside. The sun pass keeps drawing it either way -- a first-person +-- player still throws a shadow on the ground ahead. +function FirstPerson.hidePlayer() + return FirstPerson.cardBlend() > 0.9 +end + +-- ------- attitude + +-- Apply a look delta, in radians. Everything that turns the head funnels +-- through here, so the clamps live once. +function FirstPerson.lookBy(dyaw, dpitch) + FirstPerson.yaw = wrapPi(FirstPerson.yaw + dyaw) + FirstPerson.pitch = math.max(FirstPerson.PITCH_UP, + math.min(FirstPerson.PITCH_DOWN, + FirstPerson.pitch + dpitch)) +end + +-- The view direction's flat compass facing, for everything that still +-- thinks in the grid's four directions: the cell A interacts with, the +-- sprite the sun sees, the direction a blocked slide bonks in. +function FirstPerson.compassFacing() + local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) + if math.abs(s) > math.abs(c) then + return s > 0 and "right" or "left" + end + return c > 0 and "down" or "up" +end + +-- The unit look direction, and its flat (ground-plane) part. +local function lookDir() + local cp = math.cos(FirstPerson.pitch) + return math.sin(FirstPerson.yaw) * cp, + -math.sin(FirstPerson.pitch), + math.cos(FirstPerson.yaw) * cp +end + +function FirstPerson.lookFlat() + return math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) +end + +-- ------- billboards seen from inside the world +-- +-- The diorama's cards face south and lean back by the camera's pitch -- +-- correct for a camera that always stands south. An eye that can stand +-- ANYWHERE sees a south-facing card edge-on from the east, so in first +-- person every card yaws about its feet to face the eye (cylindrical +-- billboarding: upright, never tipping). VoxelScene blends its matrices +-- between the two by cardBlend. + +-- The yaw that turns a card's south-facing normal toward the eye. +function FirstPerson.cardYaw(wx, wz) + local eye = rig and rig.eye + if not eye then return 0 end + local dx, dz = eye[1] - wx, eye[3] - wz + if dx * dx + dz * dz < 1e-9 then return 0 end + return math.atan2(dx, dz) +end + +-- Which of the four sprite frames an entity shows THIS eye: its facing +-- rotated into the viewer's own frame, quantised. The flat game's frames +-- are "how this pose looks from the south", so the apparent facing is the +-- pose rotated by where the viewer actually stands -- walk behind an NPC +-- and you see their back, circle to their flank and you see the profile, +-- exactly as the four frames Gen 1 drew intend. +function FirstPerson.apparentFacing(facing, wx, wz) + local eye = rig and rig.eye + local phi = FACING_ANGLE[facing] + if not (eye and phi) then return facing end + local dx, dz = eye[1] - wx, eye[3] - wz + if dx * dx + dz * dz < 1e-9 then return facing end + local rel = wrapPi(phi - math.atan2(dx, dz)) + local idx = math.floor((rel + math.pi / 4) / (math.pi / 2)) % 4 + return FACING_ORDER[idx + 1] +end + +-- ------- the move intent +-- +-- The analog vector FreeMove walks by, in CAMERA space: mx strafes (+ +-- right), mz advances (+ forward). Whichever device is actually deflected +-- answers -- the left stick's raw axes first (the engine quantises them to +-- a d-pad; the raw pair is the analog truth), then a touch d-pad finger, +-- then the held keys. Magnitude caps at 1. +function FirstPerson.moveVector() + local ok, Game = pcall(require, "src.core.Game") + local input = ok and Game.input or nil + + local ax = input and input.stickAxis or nil + if ax then + local mag = math.sqrt(ax.x * ax.x + ax.y * ax.y) + if mag > FirstPerson.MOVE_DEAD then + local t = math.min(1, (mag - FirstPerson.MOVE_DEAD) + / (1 - FirstPerson.MOVE_DEAD)) + return ax.x / mag * t, -ax.y / mag * t + end + end + + if touchMove then + local mag = math.sqrt(touchMove.x * touchMove.x + + touchMove.y * touchMove.y) + if mag > FirstPerson.MOVE_DEAD then + local t = math.min(1, mag) + return touchMove.x / mag * t, -touchMove.y / mag * t + end + end + + if input then + local mx = (input:isDown("right") and 1 or 0) + - (input:isDown("left") and 1 or 0) + local mz = (input:isDown("up") and 1 or 0) + - (input:isDown("down") and 1 or 0) + if mx ~= 0 or mz ~= 0 then + local mag = math.sqrt(mx * mx + mz * mz) + return mx / mag, mz / mag + end + end + return 0, 0 +end + +-- Rotate a camera-space move into world space: forward is the flat look +-- direction, strafe-right is its right hand. (cross(forward, up) with +-- forward = (sin y, 0, cos y) and up = +Y lands right on (-cos y, 0, +-- sin y): face south and your right hand points west.) +function FirstPerson.moveWorld(mx, mz) + local s, c = math.sin(FirstPerson.yaw), math.cos(FirstPerson.yaw) + return -c * mx + s * mz, s * mx + c * mz +end + +-- ------- the tick + +-- Runs from the pipeline's update hook, every frame whatever the level -- +-- the same tick VoxelState eases the orbit on. Owns the blend, the mouse +-- capture lifecycle, and the frame's stick-rate look. +function FirstPerson.update(dt) + local engagedNow = FirstPerson.engaged() + + -- entering the rung: the head starts looking the way the sprite faces, + -- pitched gently down -- the reading pose of the flat game + if engagedNow and not wasEngaged then + local ok, facing = pcall(function() + local Game = require("src.core.Game") + return Game.overworld and Game.overworld.player + and Game.overworld.player.facing + end) + FirstPerson.yaw = (ok and FACING_ANGLE[facing]) or 0 + FirstPerson.pitch = FirstPerson.PITCH_DEFAULT + end + wasEngaged = engagedNow + + -- the blend, held at flat until there is terrain to dive into -- the + -- same wait Voxel.update keeps for the orbit tween, for the same reason + local target = engagedNow and 1 or 0 + if target > FirstPerson.blend and FirstPerson.blend == 0 + and not Voxel.ready then + target = 0 + end + local step = dt / FirstPerson.BLEND_TIME + if FirstPerson.blend < target then + FirstPerson.blend = math.min(target, FirstPerson.blend + step) + elseif FirstPerson.blend > target then + FirstPerson.blend = math.max(target, FirstPerson.blend - step) + end + if FirstPerson.blend <= 0 and rig then + -- fully out: let go of the placed camera (unless a battle already + -- swapped its own in, which is not ours to clear) + if Voxel3D.camera == rig then Voxel3D.camera = nil end + rig = nil + end + + -- mouse capture follows engagement: captured whenever the rung is on and + -- the window has focus, released the moment either ends. Checked against + -- the live mode rather than toggled on edges, so a capture lost to the + -- OS (alt-tab) re-arms itself on the next focused frame. + local wantCapture = engagedNow + if wantCapture and love.window and love.window.hasFocus then + local okF, focus = pcall(love.window.hasFocus) + wantCapture = okF and focus or false + end + if love.mouse and love.mouse.setRelativeMode then + local okM, isRel = pcall(love.mouse.getRelativeMode) + if okM and isRel ~= wantCapture then + pcall(love.mouse.setRelativeMode, wantCapture) + end + captured = wantCapture + end + + local driving = FirstPerson.driving() + + -- The mouse's counts, accumulated by the wrapped handler since the last + -- tick; dropped unread while something else owns the screen. + -- + -- The yaw sign is NEGATED, here and in every look input below: yaw grows + -- south -> east -> north (the world runs +X east, +Z south, and the + -- direction is (sin yaw, cos yaw)), which seen from behind the eye is a + -- LEFT turn -- so "move the mouse right, look right" means subtracting. + local dx, dy = mouseDX, mouseDY + mouseDX, mouseDY = 0, 0 + if driving and (dx ~= 0 or dy ~= 0) then + FirstPerson.lookBy(-dx * FirstPerson.MOUSE_SENS, + dy * FirstPerson.MOUSE_SENS) + end + + -- the right stick is a rate: radians per second, squared response so + -- the first half of the throw aims and the rest turns + if driving then + local rx, ry = stick.x, stick.y + local function curve(v) + local a = math.abs(v) + if a < FirstPerson.STICK_DEAD then return 0 end + a = (a - FirstPerson.STICK_DEAD) / (1 - FirstPerson.STICK_DEAD) + return (v < 0 and -1 or 1) * a * a + end + local cy, cp = curve(rx), curve(ry) + if cy ~= 0 or cp ~= 0 then + -- negated yaw for the same reason as the mouse above + FirstPerson.lookBy(-cy * FirstPerson.STICK_YAW * dt, + cp * FirstPerson.STICK_PITCH * dt) + end + end +end + +-- ------- the rig itself + +-- The orbit camera's eye/focus/fov/up for the frame's centre -- the same +-- arithmetic Voxel3D.viewProjection runs, restated here because the blend +-- needs both ends as DATA. Kept textually tiny so the two cannot drift: +-- focus on the centre, eye FOCAL*vh away at the pitch, up perpendicular +-- in the YZ plane. +local function orbitRig(cx, cy, vh) + local a = Voxel.angle + local dist = Voxel.FOCAL * vh + return { cx, dist * math.cos(a), cy + dist * math.sin(a) }, + { cx, 0, cy }, + 2 * math.atan(1 / (2 * Voxel.FOCAL)), + { 0, math.sin(a), -math.cos(a) } +end + +local lastEye = nil -- frozen head pose for player-less frames + +-- Build this frame's placed camera and hand it to Voxel3D, plus the scene +-- centre the curve and the depth reference should use. `me` is the +-- player's posed entry (px, py, gh, lift) or nil (a Fly animation), and +-- (cx, cy) the orbit's own view centre. +-- +-- Returns nil with the blend fully out, which is the caller's signal to +-- leave the orbit in charge. +function FirstPerson.frame(me, cx, cy, vw, vh) + local b = FirstPerson.blend + if b <= 0 then + if rig and Voxel3D.camera == rig then Voxel3D.camera = nil end + rig = nil + return nil + end + local e = ease(b) + + local head + if me then + head = { me.px + 8, + (me.gh or 0) + (me.lift or 0) + FirstPerson.EYE_HEIGHT, + me.py + 8 } + lastEye = head + else + head = lastEye or { cx, FirstPerson.EYE_HEIGHT, cy } + end + local lx, ly, lz = lookDir() + local fpFocus = { head[1] + lx * FirstPerson.FOCUS_DIST, + head[2] + ly * FirstPerson.FOCUS_DIST, + head[3] + lz * FirstPerson.FOCUS_DIST } + + local oEye, oFocus, oFov, oUp = orbitRig(cx, cy, vh) + local function mix(p, q) + return { p[1] + (q[1] - p[1]) * e, + p[2] + (q[2] - p[2]) * e, + p[3] + (q[3] - p[3]) * e } + end + local up = mix(oUp, { 0, 1, 0 }) + local ul = math.sqrt(up[1] * up[1] + up[2] * up[2] + up[3] * up[3]) + if ul > 1e-6 then up[1], up[2], up[3] = up[1] / ul, up[2] / ul, up[3] / ul + else up = { 0, 1, 0 } end + + -- the world curve eases out with the blend: standing inside the world, + -- the bend that sells the diorama reads as the ground falling away. A + -- true zero (curve declined) needs the field present -- nil would let + -- Voxel3D fall back to the setting + local k = WorldCurve.k(vh) * (1 - e) + + rig = { + eye = mix(oEye, head), + focus = mix(oFocus, fpFocus), + fov = oFov + (FirstPerson.FOV - oFov) * e, + up = up, + curve = k, + } + Voxel3D.camera = rig + + -- the scene centre walks from the orbit's view centre to the head, so + -- the curve's focus, the depth reference and the glint's travel follow + -- the camera that is actually in charge + local sx = cx + (head[1] - cx) * e + local sy = cy + (head[3] - cy) * e + return rig, sx, sy +end + +-- Where the shadow pass should centre its box: pushed along the flat look +-- so the fitted frustum -- built for an orbit that always looks north -- +-- covers the ground THIS camera sees. The push is strongest looking +-- south (the direction the orbit's box barely reaches) and scales with +-- the blend. +function FirstPerson.shadowCenter(sx, sy, vh) + local e = FirstPerson.cardBlend() + if e <= 0 then return sx, sy end + local fx, fz = FirstPerson.lookFlat() + local ShadowMap = V.require("ShadowMap") + local cap = (ShadowMap.FAR_CAP or 2.5) * vh + return sx + fx * 0.6 * vh * e, + sy + fz * (fz > 0 and (cap - vh * 0.5) or vh * 0.4) * e +end + +-- The first-person facts a shadow signature has to include: the sun's +-- box is fitted around this camera, so turning the head or walking the +-- blend has to re-fit it even standing still. +function FirstPerson.signature() + local b = FirstPerson.blend + if b <= 0 then return "" end + return table.concat({ + math.floor(b * 64), + math.floor(FirstPerson.yaw * 64), + math.floor(FirstPerson.pitch * 64), + }, ",") +end + +-- ------- input capture +-- +-- The seams: relative mouse motion has no Game handler at all (the +-- engine's love.mousemoved only feeds the mouse-as-touch debug path), the +-- right stick's axes are explicitly ignored by Input, and a touch +-- anywhere off the overlay's controls dies in TouchControls. Each wrap +-- forwards everything it does not claim, and claims only while first +-- person is actually driving -- so with the rung off, every byte flows +-- exactly where it always did. + +local installed = false + +function FirstPerson.install() + if installed then return end + installed = true + + local Game = require("src.core.Game") + + -- ------- right stick + do + local inner = Game.gamepadaxis + function Game:gamepadaxis(joystick, axis, value) + if axis == "rightx" then stick.x = value + elseif axis == "righty" then stick.y = value end + return inner(self, joystick, axis, value) + end + end + -- generic (non-gamepad) sticks: axes 1/2 are the left stick by SDL + -- convention and Input already claims them; 3/4 are the usual right + -- pair on the same class of device. Real gamepads are excluded -- they + -- already spoke through the mapped rightx/righty above, and their RAW + -- axis 3 is as likely a trigger as a stick. + -- + -- Two more exclusions, both learned the hard way on Android, where this + -- wrap runs BEFORE the engine's own generic-joystick guards: + -- + -- the accelerometer arrives as a joystick named for what it is, with + -- gravity pinning an axis well past any deadzone -- the same device + -- Game:joystickaxis refuses for movement (#459), refused here by the + -- same name test, or the view spins on its own the moment 1ST opens. + -- + -- and a raw axis is only BELIEVED after it has been seen near centre + -- once. A stick at rest sits at zero, so a real one earns trust with + -- its first touch; a gravity-pinned sensor axis or a trigger resting + -- at an extreme never centres and so never steers the look. + local function isAccelerometer(joystick) + local ok, name = pcall(function() return joystick:getName() end) + return ok and type(name) == "string" + and name:lower():find("accelerometer", 1, true) ~= nil + end + local rawCentred = {} + do + local inner = Game.joystickaxis + function Game:joystickaxis(joystick, axis, value) + local mapped = joystick and joystick.isGamepad and joystick:isGamepad() + if not mapped and (axis == 3 or axis == 4) + and not isAccelerometer(joystick) then + if math.abs(value) < 0.3 then rawCentred[axis] = true end + if rawCentred[axis] then + if axis == 3 then stick.x = value else stick.y = value end + end + end + return inner(self, joystick, axis, value) + end + end + + -- ------- mouse + -- + -- love.mousemoved rather than a Game method, because the engine has no + -- Game:mousemoved to wrap -- the callback in the project's main.lua is + -- the one place relative counts arrive. Claimed only while captured; + -- pass-through otherwise, including the mouse-as-touch path. + do + local inner = love.mousemoved + love.mousemoved = function(x, y, dx, dy, istouch) + if captured and not istouch then + mouseDX = mouseDX + (dx or 0) + mouseDY = mouseDY + (dy or 0) + return + end + if inner then return inner(x, y, dx, dy, istouch) end + end + end + -- While the mouse is captured there is no cursor to click UI with, so + -- the buttons become GB buttons: left is A, right is B -- through the + -- overlay's own press path, which a rebind can never detach. What WE + -- pressed is remembered per button, so the release always reaches the + -- overlay even if the capture ended while the button was down -- + -- otherwise a click that outlives the rung strands A held forever. + local mouseHeld = {} + local MOUSE_BTN = { [1] = "a", [2] = "b" } + do + local inner = love.mousepressed + love.mousepressed = function(x, y, button, istouch, presses) + if captured and not istouch and MOUSE_BTN[button] then + local Input = require("src.core.Input") + mouseHeld[button] = true + Input:overlayPressed(MOUSE_BTN[button]) + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + do + local inner = love.mousereleased + love.mousereleased = function(x, y, button, istouch, presses) + if mouseHeld[button] then + local Input = require("src.core.Input") + mouseHeld[button] = nil + Input:overlayReleased(MOUSE_BTN[button]) + return + end + if inner then return inner(x, y, button, istouch, presses) end + end + end + + -- ------- touch + -- + -- A finger on open screen -- not on the overlay's d-pad or buttons -- + -- becomes the look drag. One finger owns the look at a time; every + -- other touch flows to TouchControls untouched, so a thumb can drag the + -- view while the other walks the d-pad. That d-pad finger is also read + -- back ANALOG here: TouchControls quantises it to four directions for + -- the grid game, but the deflection it quantised is exactly the move + -- vector a free walk wants. + local TouchControls = require("src.core.TouchControls") + + local function dpadVector(x, y) + local ok, v = pcall(function() + local L = TouchControls:layout() + local dz = L.dpad + local half = dz.w * 0.65 + return { x = math.max(-1, math.min(1, (x - dz.cx) / half)), + y = math.max(-1, math.min(1, (y - dz.cy) / half)) } + end) + return ok and v or nil + end + + do + local inner = Game.touchpressed + function Game:touchpressed(id, x, y) + if FirstPerson.driving() then + local onControl = nil + pcall(function() onControl = TouchControls:hitTest(x, y) end) + if not onControl and not lookTouch then + lookTouch = { id = id, x = x, y = y } + return + end + inner(self, id, x, y) + if onControl == "dpad" and TouchControls.dpadTouch == id then + touchMove = dpadVector(x, y) + end + return + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchmoved + function Game:touchmoved(id, x, y) + if lookTouch and lookTouch.id == id then + local w = 1280 + pcall(function() w = love.graphics.getWidth() end) + local per = FirstPerson.TOUCH_TURN / math.max(320, w) + if FirstPerson.driving() then + -- negated yaw for the same reason as the mouse (see update): + -- drag right, look right, the mobile-shooter convention + FirstPerson.lookBy(-(x - lookTouch.x) * per, + (y - lookTouch.y) * per) + end + lookTouch.x, lookTouch.y = x, y + return + end + if touchMove and TouchControls.dpadTouch == id then + touchMove = dpadVector(x, y) or touchMove + end + return inner(self, id, x, y) + end + end + do + local inner = Game.touchreleased + function Game:touchreleased(id, x, y) + if lookTouch and lookTouch.id == id then + lookTouch = nil + return + end + if TouchControls.dpadTouch == id then touchMove = nil end + return inner(self, id, x, y) + end + end + + -- a reset that drops held input state drops ours with it + do + local inner = Game.focus + function Game:focus(f) + lookTouch, touchMove = nil, nil + stick.x, stick.y = 0, 0 + mouseDX, mouseDY = 0, 0 + return inner(self, f) + end + end + -- a disconnected controller cannot send the centering event for whatever + -- its stick last held -- the engine drops all input state here, and the + -- look rate (plus the raw axes' earned trust) goes with it + do + local inner = Game.joystickremoved + function Game:joystickremoved(joystick) + stick.x, stick.y = 0, 0 + rawCentred[3], rawCentred[4] = nil, nil + return inner(self, joystick) + end + end +end + +return FirstPerson diff --git a/lib/FreeMove.lua b/lib/FreeMove.lua new file mode 100644 index 0000000..eacd26a --- /dev/null +++ b/lib/FreeMove.lua @@ -0,0 +1,345 @@ +-- Voxel world mode: free movement for the first-person rung. +-- +-- The engine walks a grid: sixteen frames per cell, four directions, +-- input locked mid-step. Inside a first-person camera that gait reads as +-- riding a rail, so while 1ST drives, this module replaces the WALK and +-- nothing else: the player's position becomes continuous, steered by the +-- camera's own yaw -- push forward and you go where you look, at any +-- angle, sliding along whatever you graze. +-- +-- THE GRID IS STILL THE GAME. Every fact the world cares about is a fact +-- about cells -- what blocks, what warps, what rustles, what bites -- and +-- this module keeps the player's logical cell synced to wherever the free +-- walk stands, then reuses the engine's own machinery for every one of +-- those questions: +-- +-- passability the same isWalkableCell / water-while-surfing / +-- tile-pair / entity-occupancy verdicts Collision +-- hands the grid walker, asked per cell the player's +-- body overlaps. +-- +-- cell arrival OverworldState:onStepComplete, the same landing +-- pipeline a grid step runs -- warps, spinners, gates, +-- forced currents, poison, repel, encounters, the +-- step counters -- fired once per cell crossed, which +-- is exactly the rate a grid walk fires it. +-- +-- the special pushes walking off the map edge, into a ledge, or into +-- a boulder hands the quantised direction straight to +-- checkEdgeExit / checkLedgeHop / checkBoulderPush, +-- the engine's own handlers, which validate and stage +-- everything themselves (connections, the hop arc, +-- the two-push arm). While any of those animates a +-- scripted grid move, this module stands aside and +-- adopts the result. +-- +-- Nothing here writes save state, rolls encounters, or decides what a +-- warp does -- it moves a point, keeps the cell honest, and lets the +-- engine be the engine. Stepping off the rung snaps the point to its +-- cell and hands the walk back to the grid, and with the rung off this +-- module costs one gate check per frame. + +-- the mod namespace (see main.lua): V.require loads a sibling module +local V = ... + +local FirstPerson = V.require("FirstPerson") + +local FreeMove = {} + +-- The body: a circle in the ground plane. Small enough to walk every +-- one-cell corridor the grid game has (half a cell is 8), big enough to +-- keep the eye's near plane out of wall faces when sliding along them. +FreeMove.RADIUS = 5.5 + +-- World pixels per fixed 60Hz frame -- the grid walker's own speeds (16 +-- frames per 16px cell on foot, 8 on the bike), so distance covered per +-- second is unchanged and the encounter rate per tile crossed stays the +-- game's own. +FreeMove.WALK = 1.0 +FreeMove.BIKE = 2.0 + +local EPS = 0.01 + +-- the free position (player centre, world px) and the px/py we last wrote +-- -- if they differ from the player's, something else (a warp, a script) +-- moved them, and the free walk adopts rather than fights +local pos = nil +local lastPx, lastPy = nil, nil + +local function adopt(p) + pos = { x = p.px + 8, z = p.py + 8 } + lastPx, lastPy = p.px, p.py +end + +function FreeMove.drop() + pos = nil +end + +-- named for the suite: the module's live position, nil while dropped +function FreeMove._pos() + return pos +end + +-- ------- the per-cell verdict +-- +-- The same questions Collision.canMove asks for a grid step, asked of one +-- cell from the player's current standing. The player's OWN cell never +-- blocks -- the body must always be free to leave wherever it stands +-- (a warp mat, the water it is surfing, a cell an NPC just stepped +-- against). + +local function pairBlocked(map, surfing, sx, sy, tx, ty) + local Game = require("src.core.Game") + local tp = Game.data and Game.data.field and Game.data.field.tilePairs + if not tp then return false end + local list = surfing and tp.water or tp.land + if not list or #list == 0 then return false end + local tileset = map.def.tileset + local a = map:cellTile(sx, sy) + local b = map:cellTile(tx, ty) + for _, p in ipairs(list) do + if p.tileset == tileset + and ((p.a == a and p.b == b) or (p.a == b and p.b == a)) then + return true + end + end + return false +end + +-- Why (cx, cy) refuses the player's body, or nil when it may enter: +-- "bounds" | "tile" | "entity", the grid verdict's own names. +local function blockedCell(state, p, cx, cy) + if cx == p.cellX and cy == p.cellY then return nil end + local map = state.map + if not map:inBounds(cx, cy) then return "bounds" end + if not map:isWalkableCell(cx, cy) then + if not (p.surfing and map:isWaterCell(cx, cy)) then return "tile" end + end + if pairBlocked(map, p.surfing, p.cellX, p.cellY, cx, cy) then + return "tile" + end + local Collision = require("src.world.Collision") + if Collision.occupied(state.entities, cx, cy, p) then return "entity" end + return nil +end + +FreeMove._blockedCell = blockedCell -- named for the suite + +-- ------- the slide +-- +-- One axis at a time, clamped at the first refusing cell's face: the +-- classic axis-separated walk, which is where wall-sliding comes from -- +-- the blocked axis stops and the free one keeps going. Returns the +-- refusal ("bounds"/"tile"/"entity") when this axis was clamped. + +local function slideX(state, p, dx) + if dx == 0 then return nil end + local r = FreeMove.RADIUS + local nx = pos.x + dx + local z0 = math.floor((pos.z - r + EPS) / 16) + local z1 = math.floor((pos.z + r - EPS) / 16) + local hit = nil + local edge = dx > 0 and math.floor((nx + r) / 16) + or math.floor((nx - r) / 16) + for zc = z0, z1 do + hit = blockedCell(state, p, edge, zc) + if hit then break end + end + if hit then + if dx > 0 then nx = math.min(nx, edge * 16 - r - EPS) + else nx = math.max(nx, (edge + 1) * 16 + r + EPS) end + end + pos.x = nx + return hit +end + +local function slideZ(state, p, dz) + if dz == 0 then return nil end + local r = FreeMove.RADIUS + local nz = pos.z + dz + local x0 = math.floor((pos.x - r + EPS) / 16) + local x1 = math.floor((pos.x + r - EPS) / 16) + local hit = nil + local edge = dz > 0 and math.floor((nz + r) / 16) + or math.floor((nz - r) / 16) + for xc = x0, x1 do + hit = blockedCell(state, p, xc, edge) + if hit then break end + end + if hit then + if dz > 0 then nz = math.min(nz, edge * 16 - r - EPS) + else nz = math.max(nz, (edge + 1) * 16 + r + EPS) end + end + pos.z = nz + return hit +end + +-- ------- the blocked push +-- +-- The grid game's blocked step is where half its verbs live: the map-edge +-- crossing, the ledge hop, the boulder shove, the route-gate warp fired +-- by collision, and the honest bonk. Hand the engine the quantised +-- direction and let its own handlers decide -- each one validates itself +-- (checkLedgeHop matches the tile pair, checkEdgeExit checks the bounds), +-- so calling them on every firm push is safe. Returns true when one of +-- them took the frame over. +local function pushSpecials(state, dir, why) + local p = state.player + p.facing = dir -- the handlers read the push off the facing + if why == "bounds" and state:checkEdgeExit(dir) then return true end + if state:checkLedgeHop(dir) then return true end + if state:checkBoulderPush(dir) then return true end + if why ~= "entity" and state:canCollisionWarp() then + local Game = require("src.core.Game") + local Warp = require("src.world.Warp") + local w = Warp.onCollision(state.map, Game.data.field.warpCarpets, + p.cellX, p.cellY, dir) + if w then + state:takeWarp(w.def) + return true + end + end + if why ~= "entity" then + if (state.bumpCooldown or 0) <= 0 then + local Game = require("src.core.Game") + require("src.core.Sound").play(Game.data, "Collision") + state.bumpCooldown = 16 + end + end + return false +end + +-- ------- the tick +-- +-- Runs in place of OverworldState:handleInput while first person drives +-- (see install below), which means it inherits every gate the grid walk +-- has: never during scripted moves, transitions, or with anything above +-- the overworld on the stack. + +function FreeMove.tick(state) + local p = state.player + + -- a grid move is animating -- a ledge hop, a spinner slide, a scripted + -- walk -- or a cutscene owns the player: stand aside, adopt the result + if p.moving or p.inputLocked then + FreeMove.drop() + return + end + if not pos or p.px ~= lastPx or p.py ~= lastPy then adopt(p) end + + local Game = require("src.core.Game") + local input = Game.input + + -- the head is the facing: what A talks to, what the sun's card shows, + -- which way a bonk points + p.facing = FirstPerson.compassFacing() + + if input:wasPressed("a") then + state:interact() + return + end + if input:wasPressed("start") then + require("src.core.Sound").play(Game.data, "Start_Menu") + require("src.ui.Screens").push(Game, "StartMenu") + return + end + + local mx, mz = FirstPerson.moveVector() + local wx, wz = FirstPerson.moveWorld(mx, mz) + + -- Cycling Road's downhill pull, the free-walk restatement of the grid + -- path's simulated PAD_DOWN: south drift with nothing held, braked by + -- holding A or B exactly as the Route 17 sign promises + local moving = (mx ~= 0 or mz ~= 0) + if not moving and Game.save and Game.save.onBike then + local fm = Game.data.field.forcedMovement + local braking = input:isDown("a") or input:isDown("b") + if fm and not braking then + for _, m in ipairs(fm.slopeMaps or {}) do + if m == state.map.id then + wx, wz, moving = 0, 1, true + break + end + end + end + end + + if not moving then return end + + state.bumpCooldown = math.max(0, (state.bumpCooldown or 0) - 1) + + local speed = (Game.save and Game.save.onBike) and FreeMove.BIKE + or FreeMove.WALK + local dx, dz = wx * speed, wz * speed + + local hitX = slideX(state, p, dx) + local hitZ = slideZ(state, p, dz) + + -- the walk cycle: the wall-bonk clock animates the legs of a player the + -- grid thinks is standing still, refreshed while the free walk covers + -- ground (Player:update ticks animClock off it; walkPhase reads it) + p.bumpFrames = 2 + + p.px, p.py = pos.x - 8, pos.z - 8 + lastPx, lastPy = p.px, p.py + + -- the cell the body stands in; crossing into a new one IS a step + local ncx = math.floor(pos.x / 16) + local ncy = math.floor(pos.z / 16) + if ncx ~= p.cellX or ncy ~= p.cellY then + p.cellX, p.cellY = ncx, ncy + state:onStepComplete() + -- a warp or a battle may have moved the world out from under the + -- walk; the adopt check on the next tick picks the pieces up + return + end + + -- a firm push into something that refused: the engine's own blocked-step + -- verbs, aimed the way the push leans + local hit, dir + if hitX and (not hitZ or math.abs(dx) >= math.abs(dz)) then + hit, dir = hitX, (dx > 0 and "right" or "left") + elseif hitZ then + hit, dir = hitZ, (dz > 0 and "down" or "up") + end + if hit and math.max(math.abs(dx), math.abs(dz)) > 0.4 * speed then + if pushSpecials(state, dir, hit) then + FreeMove.drop() + return + end + -- the push handlers may have turned the facing; the head still rules + p.facing = FirstPerson.compassFacing() + end +end + +-- ------- the seam +-- +-- OverworldState:handleInput is the one choke point where the grid walk +-- reads the pad -- the same seam the engine's own Cycling Road pull and +-- collision warps live behind -- so replacing the walk means wrapping it +-- and nothing else. Every gate ABOVE the call (scripted moves, trainer +-- engagement, transitions, anything on the stack) still applies to the +-- free walk, because the wrap sits below them all. +function FreeMove.install() + local OverworldState = require("src.world.OverworldController") + if OverworldState.dramaticShapeFreeMoveHook then return end + local inner = OverworldState.handleInput + + function OverworldState:handleInput() + if not FirstPerson.driving() then + if pos then + -- stepping off the rung: back onto the grid, on the cell the + -- free walk stood in + local p = self.player + p.px, p.py = p.cellX * 16, p.cellY * 16 + FreeMove.drop() + end + return inner(self) + end + return FreeMove.tick(self) + end + + OverworldState.dramaticShapeFreeMoveHook = true +end + +return FreeMove diff --git a/lib/Structures.lua b/lib/Structures.lua index 2e3e70c..ad3d5ec 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -75,9 +75,12 @@ local OBJECT_DEPTH = 6 -- voxel thickness of a detected prop -- pure profile; `post` matches the 6px the detector gives the fence -- rows it finds on its own, so pinned and detected fences look alike; -- `signpost` is a plate on a stick -- 2 voxels, the thinnest that still --- shows an edge +-- shows an edge; `bike` is the same 2 for the same reason from the other +-- direction -- a bicycle drawn side-on is a LINE drawing whose negative +-- space is the drawing, and at the 5 voxels `prop` gives, the side faces +-- of neighbouring strokes close every gap in it off-axis local PINNED_DEPTH = { billboard = 10, prop = 5, stool = 10, cutout = 1, - console = 10, post = 6, signpost = 2 } + console = 10, post = 6, signpost = 2, bike = 2 } local MAX_ROWS = 6 -- volume height cap: 48px @@ -297,7 +300,9 @@ function Structures.forMap(map) Structures.buildStairs(S, map, x0, x1, y0, y1) -- ---- bookcases: pinned shelves collapsed to one cell of depth ---- - Structures.buildBookcases(S, map, x0, x1, y0, y1) + -- The atlas comes along so the shelf front can carry its own measured + -- relief: the panes it seals behind its black frames sink a voxel. + Structures.buildBookcases(S, map, x0, x1, y0, y1, pixels(tileset), perRow) -- ---- figures: a person drawn INTO furniture, lifted off it ---- -- Before the region flood and the volume pass, so everything after this @@ -307,6 +312,14 @@ function Structures.forMap(map) -- passes should see, and this needs no pixel access to do it.) Structures.buildFigures(S, map, x0, x1, y0, y1) + -- ---- mounted: a thing drawn INTO a wall band, stood proud of it ---- + -- Here for the same reason and with the same guarantee as the figures + -- above: the repaint hands every pass below the plain panel the profile + -- says is behind the object, so the wall band it was painted into keeps + -- resolving as the wall it is -- without a second copy of the drawing + -- flat on its face. + Structures.buildMounted(S, map, x0, x1, y0, y1) + -- ---- flood-fill regions of structural tiles ---- local seen = {} local regions = {} @@ -558,14 +571,51 @@ end local ROUND_SHADE = { front = 1.0, back = 0.68, side = 0.78, top = 1.0, bottom = 0.55 } -local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) - N = N or 16 -- art canvas: 16 = one cell, 32 = 2x2 cells - local N2 = N / 2 +-- The potted plant's ORGANIC HALF: the leaf crown (16 rows), then the +-- trunk, its root flare and the strands draping over the pot's rim (8 +-- more) -- all of it stands as a slab this many voxels deep instead of +-- revolving. `depth` 5 is the thin standee pool's depth, what every other +-- interior plant already uses. +-- +-- `rows` = 24 puts the slab/revolve boundary AT THE VESSEL'S RIM ROW, and +-- that placement is what makes the pot read as a pot. The first cut put +-- it at the cell seam (16), which let the root and drape rows revolve: +-- their drawn spans are 8-12 wide, so they stacked 8-12-deep discs on top +-- of the rim and the whole base read as one bulbous onion instead of a +-- flat-mouthed planter with a trunk standing out of it. Only rows 24-31 +-- -- black rim edge, gold band, body, foot, the drawn flowerpot profile +-- -- are the vessel, and only they revolve. +local PLANTER_SPRAY = { rows = 24, depth = 5 } + +-- `spray`, when given, caps the chord over the canvas's top `rows` rows to +-- `depth` voxels instead of revolving them. +-- +-- Revolving a row turns its DRAWN WIDTH into depth, which only means +-- something when the drawing states a width to turn -- the pot's rows do +-- (a 3px stem opening to a 12px belly and closing to a 6px foot, an urn's +-- profile), and a tree canopy's do (the ball's outline is drawn). A leaf +-- crown's do NOT: the leaves are a spray that runs off all four sides of +-- its tile, so every row measures the full canvas and the revolve can only +-- produce a solid cylinder -- the "hedge column" a plant must never become, +-- with one row of texels smeared down its whole top face. Where the drawing +-- states no profile, the honest reading is the one the thin standee pools +-- exist for: the foliage stands as a per-pixel slab and keeps the airy +-- silhouette that makes it read as leaves. +local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows, + NYin, spray, baseRows, bodyRows, wellRows, + taperVox) + -- The canvas is NX wide and NX DEEP (a hull is round in plan, so its + -- depth is its width) by NY tall. NX = 16 is one cell, 32 a 2x2-cell + -- group; NY defaults to NX -- a ball -- and NY = 2 * NX is a drawing + -- STACKED two cells high on one cell of plot (the potted plant). + local NX = N or 16 + local NY = NYin or NX + local N2 = NX / 2 local perRow = map.tileset.tilesPerRow or 16 local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 - -- cell-space art access (NxN, row 0 = top), anchored at cell (cx, cy) + -- cell-space art access (NX x NY, row 0 = top), anchored at cell (cx, cy) local function tileOf(px, py) return S.tileAt[keyOf(cx * 2 + math.floor(px / 8), cy * 2 + math.floor(py / 8))] @@ -576,19 +626,19 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) math.floor(tile / perRow) * 8 + py % 8 end - -- shade class of every canvas pixel, indexed py * N + px + -- shade class of every canvas pixel, indexed py * NX + px local cls = {} - for py = 0, N - 1 do - for px = 0, N - 1 do + for py = 0, NY - 1 do + for px = 0, NX - 1 do local ax, ay = texel(px, py) local r, g, b, a = data:getPixel(ax, ay) - cls[py * N + px] = a == 0 and "off" - or Structures.shadeClass(math.min(r, g, b)) + cls[py * NX + px] = a == 0 and "off" + or Structures.shadeClass(math.min(r, g, b)) end end - -- 4-connected flood from the canvas border through `passable` classes - local function floodOutside(passable) + -- 4-connected flood from a row band's border through `passable` classes + local function floodOutside(passable, y0, y1) local out, stack = {}, {} local function seed(i) if not out[i] and passable[cls[i]] then @@ -596,39 +646,56 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) stack[#stack + 1] = i end end - for i = 0, N - 1 do - seed(i); seed(N * (N - 1) + i); seed(i * N); seed(i * N + N - 1) + for px = 0, NX - 1 do + seed(y0 * NX + px); seed(y1 * NX + px) + end + for py = y0, y1 do + seed(py * NX); seed(py * NX + NX - 1) end while #stack > 0 do local i = table.remove(stack) - local px = i % N + local px, py = i % NX, math.floor(i / NX) if px > 0 then seed(i - 1) end - if px < N - 1 then seed(i + 1) end - if i >= N then seed(i - N) end - if i < N * (N - 1) then seed(i + N) end + if px < NX - 1 then seed(i + 1) end + if py > y0 then seed(i - NX) end + if py < y1 then seed(i + NX) end end return out end - -- the mask: darkest-pixel outline plus its enclosure; dither fallback - local out = floodOutside({ off = true, dark = true, - light = true, white = true }) - local mask, enclosed = {}, 0 - for i = 0, N * N - 1 do - if not out[i] then - mask[i] = true - if cls[i] ~= "black" then enclosed = enclosed + 1 end + -- The mask -- darkest-pixel outline plus its enclosure, with the dither + -- rule as fallback -- computed per CELL BAND of NX rows. + -- + -- A square canvas is ONE band, so this is exactly the whole-canvas rule + -- it replaces. A STACKED canvas needs it per band because its two halves + -- want opposite answers: the potted plant's leaf crown is a black-outlined + -- dither drawn over floor (outline enclosure keeps it), while its pot is a + -- solid DARK body whose base runs flush to the band's bottom edge (the + -- enclosure flood walks in through dark and guts it, and the fallback -- + -- which the band's own `enclosed` count asks for -- keeps it). Measured on + -- the Center plant: one flood over both bands keeps 53% of the drawing and + -- leaves the pot a hollow black frame; per band keeps 68% and both read. + local mask = {} + for band = 0, NY / NX - 1 do + local y0, y1 = band * NX, band * NX + NX - 1 + local out = floodOutside({ off = true, dark = true, + light = true, white = true }, y0, y1) + local enclosed = 0 + for i = y0 * NX, (y1 + 1) * NX - 1 do + if not out[i] then + mask[i] = true + if cls[i] ~= "black" then enclosed = enclosed + 1 end + end end - end - if enclosed < N * N / 8 then - out = floodOutside({ off = true, light = true, white = true }) - mask = {} - for i = 0, N * N - 1 do - if not out[i] and cls[i] ~= "off" then mask[i] = true end + if enclosed < NX * NX / 8 then + out = floodOutside({ off = true, light = true, white = true }, y0, y1) + for i = y0 * NX, (y1 + 1) * NX - 1 do + mask[i] = (not out[i] and cls[i] ~= "off") or nil + end end end local any = nil - for i = 0, N * N - 1 do any = any or mask[i] end + for i = 0, NX * NY - 1 do any = any or mask[i] end if not any then return {} end -- a CAPPED hull (the stump): the top capRows rows of the mask are the @@ -638,24 +705,96 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local capY0, capY1 = nil, nil if capRows and capRows > 0 then local top = nil - for iy = 0, N - 1 do - for ix = 0, N - 1 do - if mask[iy * N + ix] then top = iy break end + for iy = 0, NY - 1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then top = iy break end end if top then break end end if top then capY0 = top - capY1 = math.min(top + capRows - 1, N - 2) + capY1 = math.min(top + capRows - 1, NY - 2) for iy = capY0, capY1 do - for ix = 0, N - 1 do mask[iy * N + ix] = nil end + for ix = 0, NX - 1 do mask[iy * NX + ix] = nil end end any = nil - for i = 0, N * N - 1 do any = any or mask[i] end + for i = 0, NX * NY - 1 do any = any or mask[i] end if not any then return {} end end end + -- a FLAT-BASED hull (the can): the bottom baseRows rows of the mask are + -- the BASE circle's front arc -- the drawing's mirror of the cut face + -- above, ground contact seen from above rather than body. A can is only + -- round in the horizontal plane, so the drop those rows make toward the + -- middle is DEPTH, not a narrowing of the plan: left as body they revolve + -- into ever smaller discs and the can ends up balanced on a stem three + -- voxels wide (which is exactly what the first build did). Strip them and + -- the foot rule below runs the last body row's full disc straight to the + -- floor; the rows keep their own texels there, so the front view is still + -- the drawing, base rim and all. + local baseArt = nil + if baseRows and baseRows > 0 then + local bot = nil + for iy = NY - 1, 0, -1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then bot = iy break end + end + if bot then break end + end + if bot then + baseArt = {} + for iy = math.max(bot - baseRows + 1, (capY1 or -1) + 2), bot do + for ix = 0, NX - 1 do + local i = iy * NX + ix + if mask[i] then baseArt[i] = true end + mask[i] = nil + end + end + any = nil + for i = 0, NX * NY - 1 do any = any or mask[i] end + if not any then return {} end + end + end + + -- The can's HEIGHT, and the one place this file departs from the drawing + -- on purpose. Strictly un-projected, the drawing states a squat drum: cut + -- the mouth ellipse off the top and the base circle off the bottom and + -- barely two rows of straight side are left between them, because the GB + -- artist spent most of a 16px cell on the opening. A real bin is TALLER + -- than it is wide, and the flat game reads as one because the drawing is + -- 14px tall next to a 16px player -- so the height is authored (can_height + -- voxels) rather than measured, and the surviving body band is repeated + -- upward to fill it, bottom row first, which continues the drawn rib + -- rhythm instead of inventing a texel. Everything else still comes off + -- the pixels. + local artRow = {} + if bodyRows and bodyRows > 0 then + local body = {} + for iy = 0, NY - 1 do + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then body[#body + 1] = iy break end + end + end + local nb = #body + if nb > 0 then + local top = body[1] + for iy = top - 1, math.max(NY - bodyRows, 0), -1 do + -- the LOWEST surviving body row, repeated: it is the widest and + -- plainest reading of the material (outline, shaded flank, lit + -- face) and stacks into a clean metal cylinder. Cycling the whole + -- surviving band instead stacks the drawn rim arcs into a barcode + -- of hoops, which is detail the drawing never states about the + -- side of the can. + local from = body[nb] + artRow[iy] = from + for ix = 0, NX - 1 do + mask[iy * NX + ix] = mask[from * NX + ix] + end + end + end + end + -- the ground the ball stands on: the drawing's own background names -- it. Score every flat ground tile the map places against the cell's -- unmasked light pixels and keep the closest -- mid-forest trees have @@ -670,11 +809,15 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local ox = (t % perRow) * 8 local oy = math.floor(t / perRow) * 8 local score, n = 0, 0 - for py = 0, N - 1 do - for px = 0, N - 1 do - local i = py * N + px + for py = 0, NY - 1 do + for px = 0, NX - 1 do + local i = py * NX + px local c = cls[i] - if not mask[i] and (c == "light" or c == "white") then + -- a stripped base row is the OBJECT's own rim, not background: + -- scoring its whites against the floor tiles matches paper-white + -- ground under a can whose art stands on the gym's grey + if not mask[i] and not (baseArt and baseArt[i]) + and (c == "light" or c == "white") then local ax, ay = texel(px, py) local r1, g1, b1 = data:getPixel(ax, ay) local r2, g2, b2 = data:getPixel(ox + px % 8, oy + py % 8) @@ -691,14 +834,17 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) end end - -- discs: per mask pixel a z chord [z0, z1), from its row's span circle - local z0, z1, src = {}, {}, {} + -- discs: per mask pixel a z chord [z0, z1), from its row's span circle. + -- z2/z3 is an optional SECOND chord for the same pixel, which only the + -- can's hollow mouth uses: a ring in plan needs a front wall and a back + -- wall at the same column, and one interval cannot say that. + local z0, z1, z2, z3, src, srcX = {}, {}, {}, {}, {}, {} local loRow, hiRow = {}, {} local yBot = nil - for iy = 0, N - 1 do + for iy = 0, NY - 1 do local lo, hi = nil, nil - for ix = 0, N - 1 do - if mask[iy * N + ix] then + for ix = 0, NX - 1 do + if mask[iy * NX + ix] then lo = lo or ix hi = ix end @@ -709,7 +855,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local c = (lo + hi + 1) / 2 local hw = (hi - lo + 1) / 2 for ix = lo, hi do - local i = iy * N + ix + local i = iy * NX + ix if mask[i] then local dx = ix + 0.5 - c local n = 1 @@ -717,23 +863,148 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx) + 0.5)) end + if spray and iy < spray.rows then n = math.min(n, spray.depth) end z0[i] = math.floor(N2 - n / 2 + 0.5) z1[i] = z0[i] + n - src[i] = iy + -- a row the can's body band was repeated into wears the row it + -- was copied from, never a texel of its own + src[i] = artRow[iy] or iy + end + end + end + end + + -- Spray-gap BACKING: the drawing's own gap pixels, one voxel deep at + -- the slab's mid-plane. The flat crown is full of floor showing + -- between leaves; carved as an open slab those gaps became TUNNELS -- + -- the Center couch, the man sitting on it and the void wall all read + -- as pink/orange/black confetti INSIDE the foliage, and the sparse + -- bottom rows (lone drawn leaf tips) floated as disconnected specks + -- against them. The drawing itself backs every gap with its own + -- pixels, so the hull does the same: each in-span gap below drawn + -- foliage takes ITS OWN texel as a plate recessed behind the leaf + -- relief. Coverage is monotone down a column, so the first backed + -- cell always sits directly under a leaf chord -- and every chord + -- spans the mid-plane, so no plate ever caps the crown's top: columns + -- open to the sky stay open and the silhouette keeps its notches. + if spray then + for iy = 1, math.min(spray.rows, NY) - 1 do + if loRow[iy] then + for ix = loRow[iy], hiRow[iy] do + local i = iy * NX + ix + if not z0[i] then + local covered = false + for iy2 = 0, iy - 1 do + if mask[iy2 * NX + ix] then covered = true break end + end + if covered then + z0[i], z1[i], src[i] = N2, N2 + 1, iy + end + end end end end end -- foot: rows under the mask repeat the bottom row's discs, wearing the - -- bottom row's (outline-dark) pixels - for iy = yBot + 1, N - 1 do + -- bottom row's (outline-dark) pixels -- except where a stripped base row + -- DREW something at that pixel, which keeps its own texel, so a can's + -- drawn base rim lands on the model's base instead of being painted over + -- by the body band above it + for iy = yBot + 1, NY - 1 do loRow[iy], hiRow[iy] = loRow[yBot], hiRow[yBot] for ix = loRow[yBot], hiRow[yBot] do - local b = yBot * N + ix + local b = yBot * NX + ix if z0[b] then - local i = iy * N + ix - z0[i], z1[i], src[i] = z0[b], z1[b], yBot + local i = iy * NX + ix + z0[i], z1[i] = z0[b], z1[b] + src[i] = (baseArt and baseArt[i]) and iy or yBot + end + end + end + + -- the TAPER: a bin is a truncated cone, not a tube -- wide at the rim, + -- drawn in a couple of voxels toward the base. The drawing agrees as far + -- as it can (its own base arc pulls in to 9px from the 11px flanks), but + -- it cannot state the whole run, so taperVox is the diameter the base + -- loses and the rows in between interpolate. Every row keeps its plan + -- ROUND: narrow the span, then re-cut the chords from the narrowed span, + -- or the model comes out a cylinder with its corners shaved. + local stepped = {} + if taperVox and taperVox > 0 then + local yTopRow = nil + for iy = 0, NY - 1 do + if loRow[iy] then yTopRow = iy break end + end + local span = NY - 1 - (yTopRow or 0) + if yTopRow and span > 0 then + for iy = yTopRow, NY - 1 do + local inset = math.floor(taperVox / 2 * (iy - yTopRow) / span + 0.5) + if inset > 0 and loRow[iy] then + local lo = loRow[iy] + inset + local hi = hiRow[iy] - inset + if hi - lo < 1 then + lo = math.floor((loRow[iy] + hiRow[iy]) / 2) + hi = lo + 1 + end + for ix = loRow[iy], hiRow[iy] do + if ix < lo or ix > hi then + local i = iy * NX + ix + z0[i], z1[i], z2[i], z3[i] = nil, nil, nil, nil + end + end + -- squeeze the row's ART into the narrowed span rather than + -- clipping its ends off: the drawn outline is the last column + -- either side, and dropping it leaves the taper's new edge + -- wearing an interior texel -- a white chip down the rim + for ix = lo, hi do + srcX[iy * NX + ix] = loRow[iy] + + math.floor((ix - lo) * (hiRow[iy] - loRow[iy]) + / (hi - lo) + 0.5) + end + loRow[iy], hiRow[iy] = lo, hi + stepped[iy] = true + local c = (lo + hi + 1) / 2 + local hw = (hi - lo + 1) / 2 + for ix = lo, hi do + local i = iy * NX + ix + if z0[i] then + local dx = ix + 0.5 - c + local n = 1 + if hw * hw > dx * dx then + n = math.max(1, math.floor(2 * math.sqrt(hw * hw - dx * dx) + + 0.5)) + end + z0[i] = math.floor(N2 - n / 2 + 0.5) + z1[i] = z0[i] + n + end + end + end + end + end + end + + -- the MOUTH: a bin is open, and a solid top wearing the drawn opening + -- only paints one. Hollow the top wellRows voxel rows -- every chord + -- long enough to hold two walls plus a gap keeps a wall at each end and + -- loses its middle, which is a ring in plan, so the model has a real rim + -- to look into. The short chords at the left and right of the row ARE + -- the ring's sides and stay solid on their own. + local wellTop = nil + if wellRows and wellRows > 0 then + for iy = 0, NY - 1 do + if loRow[iy] then wellTop = iy break end + end + local wall = 2 + for iy = wellTop or 0, math.min((wellTop or 0) + wellRows - 1, NY - 1) do + if loRow[iy] then + for ix = loRow[iy], hiRow[iy] do + local i = iy * NX + ix + if z0[i] and z1[i] - z0[i] > wall * 2 then + z2[i], z3[i] = z1[i] - wall, z1[i] + z1[i] = z0[i] + wall + end + end end end end @@ -742,31 +1013,47 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) -- projection below local capTopRow, capZ0, capZ1 = nil, nil, nil if capY0 then - for iy = 0, N - 1 do + for iy = 0, NY - 1 do if loRow[iy] then capTopRow = iy break end end if capTopRow then for ix = loRow[capTopRow], hiRow[capTopRow] do - local i = capTopRow * N + ix + local i = capTopRow * NX + ix if z0[i] then + -- the OUTER extent, so a hollowed row still projects the mouth + -- across the whole opening and not just its front wall + local back = z3[i] or z1[i] capZ0 = math.min(capZ0 or z0[i], z0[i]) - capZ1 = math.max(capZ1 or z1[i], z1[i]) + capZ1 = math.max(capZ1 or back, back) end end end end + -- the art row the mouth projection puts at depth iz -- the drawn + -- opening's north arc at the far side of the hull, its south arc at the + -- near one. The top-face pass below reads the same mapping; this is the + -- vertical faces inside the well asking it the same question. + local function mouthRow(iz) + if not (capY0 and capZ0 and capZ1) then return 0 end + local t = capZ1 - 1 > capZ0 and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0 + t = math.max(0, math.min(1, t)) + return capY0 + math.floor(t * (capY1 - capY0) + 0.5) + end + local function solidAt(ix, iy, iz) - if ix < 0 or ix > N - 1 or iy < 0 or iy > N - 1 then return false end - local i = iy * N + ix - return z0[i] ~= nil and iz >= z0[i] and iz < z1[i] + if ix < 0 or ix > NX - 1 or iy < 0 or iy > NY - 1 then return false end + local i = iy * NX + ix + if z0[i] == nil then return false end + if iz >= z0[i] and iz < z1[i] then return true end + return z2[i] ~= nil and iz >= z2[i] and iz < z3[i] end -- cap interiors sample the canopy a couple of rows below the rim, -- skipping outline-dark pixels local function deepTexel(ix, iy) - for iy2 = iy + 2, math.min(N - 1, iy + 4) do - local i = iy2 * N + ix + for iy2 = iy + 2, math.min(NY - 1, iy + 4) do + local i = iy2 * NX + ix if mask[i] and cls[i] ~= "black" then return texel(ix, iy2) end end return texel(ix, iy) @@ -778,56 +1065,95 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) -- ball paints solid black the moment the camera turns. The foot rows -- stay dark on purpose: their whole source row is outline-black. local function sideTexel(ix, iy) - local r = src[iy * N + ix] + -- A foot row's SIDE keeps the last body row's material even where its + -- FRONT wears a stripped base row (the can). The drawn base rim is + -- front-face art; walking the de-outline inside a row that is no longer + -- in the mask breaks at once and hands back the silhouette's own + -- outline, which painted every flank of the can solid black. + local r = (yBot and iy > yBot) and yBot or src[iy * NX + ix] + -- the walk runs in ART columns, so a tapered row starts from the drawn + -- pixel its squeezed span put here rather than from the model column + local a = srcX[iy * NX + ix] or ix local dir = ix + ix < loRow[iy] + hiRow[iy] and 1 or -1 for step = 0, 3 do - local x2 = ix + dir * step - local i2 = r * N + x2 - if x2 < 0 or x2 > N - 1 or not mask[i2] then break end + local x2 = a + dir * step + local i2 = r * NX + x2 + if x2 < 0 or x2 > NX - 1 or not mask[i2] then break end if cls[i2] ~= "black" then return texel(x2, r) end end - return texel(ix, r) + return texel(a, r) end local quads = {} - for iy = 0, N - 1 do + for iy = 0, NY - 1 do if loRow[iy] then - local yB, yT = N - 1 - iy, N - iy + local yB, yT = NY - 1 - iy, NY - iy -- front and back: the drawing per-pixel, columns merged where they -- share a chord plane; a run never crosses the 8px atlas tile seam -- (its u range must interpolate inside one tile) local ix = loRow[iy] while ix <= hiRow[iy] do - local i = iy * N + ix + local i = iy * NX + ix if z0[i] then local ix2 = ix while ix2 + 1 <= hiRow[iy] do - local j = iy * N + ix2 + 1 - if z0[j] == z0[i] and z1[j] == z1[i] + local j = iy * NX + ix2 + 1 + -- src too: a can's foot row draws part of its span from the + -- stripped base rim and the rest from the body band above it, + -- so a run must not straddle two source rows (the u range is + -- interpolated from one row's texels) + if z0[j] == z0[i] and z1[j] == z1[i] and src[j] == src[i] + and z2[j] == z2[i] and z3[j] == z3[i] and math.floor((ix2 + 1) / 8) == math.floor(ix / 8) then ix2 = ix2 + 1 else break end end - local ax0, ay = texel(ix, src[i]) - local ax1 = (texel(ix2, src[i])) - local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW - local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH local x0, x1 = ix - N2, ix2 - N2 + 1 - local zF, zB = z1[i] - N2, z0[i] - N2 - quads[#quads + 1] = { - { x0, yB, zF }, { x1, yB, zF }, { x1, yT, zF }, { x0, yT, zF }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, - shade = ROUND_SHADE.front, - } - quads[#quads + 1] = { - { x1, yB, zB }, { x0, yB, zB }, { x0, yT, zB }, { x1, yT, zB }, - uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, - shade = ROUND_SHADE.back, - } + -- one facing pair per chord, each face given the art row it + -- should wear. A hollowed mouth row has two chords, and the two + -- faces that look into the well take the drawn OPENING (via the + -- same projection the rim does) rather than the body band: the + -- drawing paints its mouth dark, and an inside-out white wall + -- across the opening is the one thing that stops a bin reading + -- as a bin. + local function facing(za, zb, rowF, rowB) + local zF, zB = zb - N2, za - N2 + local function pair(z, row, shade, back) + local ax0, ay = texel(srcX[i] or ix, row) + local ax1 = (texel(srcX[iy * NX + ix2] or ix2, row)) + local u0, u1 = (ax0 + 0.05) / atlasW, (ax1 + 0.95) / atlasW + local v0, v1 = (ay + 0.05) / atlasH, (ay + 0.95) / atlasH + if back then + quads[#quads + 1] = { + { x1, yB, z }, { x0, yB, z }, { x0, yT, z }, { x1, yT, z }, + uv = { { u1, v1 }, { u0, v1 }, { u0, v0 }, { u1, v0 } }, + shade = shade, + } + else + quads[#quads + 1] = { + { x0, yB, z }, { x1, yB, z }, { x1, yT, z }, { x0, yT, z }, + uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, + shade = shade, + } + end + end + pair(zF, rowF, ROUND_SHADE.front, false) + pair(zB, rowB, ROUND_SHADE.back, true) + end + local body = src[i] + if z2[i] then + -- z grows toward the viewer: the low chord is the can's FAR + -- wall, so its +z face is the inside you look across, and the + -- near chord's -z face is the inside of the wall facing you + facing(z0[i], z1[i], mouthRow(z1[i]), body) + facing(z2[i], z3[i], body, mouthRow(z2[i] - 1)) + else + facing(z0[i], z1[i], body, body) + end ix = ix2 + 1 else ix = ix + 1 @@ -837,19 +1163,20 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) -- sides, steps, undersides: constant-texel quads over the z runs a -- neighbour doesn't cover for ix = loRow[iy], hiRow[iy] do - local i = iy * N + ix + local i = iy * NX + ix if z0[i] then - local ax, ay = texel(ix, src[i]) + local ax, ay = texel(srcX[i] or ix, src[i]) local u, v = (ax + 0.5) / atlasW, (ay + 0.5) / atlasH local x0, x1 = ix - N2, ix - N2 + 1 - -- exposed z pieces against one neighbouring column - local function pieces(nx, ny, emit) - local iz = z0[i] - while iz < z1[i] do + -- exposed z pieces against one neighbouring column, over each of + -- the pixel's chords (a hollowed mouth row has two) + local function chordPieces(nx, ny, emit, zLo, zHi) + local iz = zLo + while iz < zHi do if not solidAt(nx, ny, iz) then local iz2 = iz - while iz2 + 1 < z1[i] and not solidAt(nx, ny, iz2 + 1) do + while iz2 + 1 < zHi and not solidAt(nx, ny, iz2 + 1) do iz2 = iz2 + 1 end emit(iz - N2, iz2 - N2 + 1, iz, iz2) @@ -859,6 +1186,10 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) end end end + local function pieces(nx, ny, emit) + chordPieces(nx, ny, emit, z0[i], z1[i]) + if z2[i] then chordPieces(nx, ny, emit, z2[i], z3[i]) end + end local sax, say = sideTexel(ix, iy) local su, sv = (sax + 0.5) / atlasW, (say + 0.5) / atlasH @@ -881,7 +1212,12 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) u = tu, v = tv, shade = ROUND_SHADE.top, } end - if capTopRow and iy == capTopRow and capZ1 then + -- the whole hollowed band takes the projection, not just its + -- top row: the rim ring gets the mouth's outer arcs and the + -- floor of the well gets its middle, so looking in reads as + -- one opening rather than a lid with a hole punched in it + if capTopRow and capZ1 + and iy >= capTopRow and iy <= capTopRow + (wellRows or 0) then -- the CUT FACE (a capped hull's top): project the drawn -- ellipse across the round cap voxel row by voxel row -- -- its top arc at the cap's north rim, its bottom arc at @@ -890,7 +1226,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) local t = capZ1 - 1 > capZ0 and (iz - capZ0) / (capZ1 - 1 - capZ0) or 0 local ry = capY0 + math.floor(t * (capY1 - capY0) + 0.5) - local cax, cay = texel(ix, ry) + local cax, cay = texel(srcX[i] or ix, ry) top(iz - N2, iz - N2 + 1, (cax + 0.5) / atlasW, (cay + 0.5) / atlasH) end @@ -900,11 +1236,21 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows) top(zA, zA + 1, u, v) top(zA + 1, zB - 1, (du + 0.5) / atlasW, (dv + 0.5) / atlasH) top(zB - 1, zB, u, v) + elseif stepped[iy] then + -- a taper STEP: the chord narrowing leaves a ring facing up + -- at the front of the can, and wearing the lit body band it + -- reads as a bright chip taken out of the wall. The drawing's + -- own rim column is black, so the step wears that and the + -- taper reads as a hoop line -- which is how the reference + -- object is banded anyway. + local rx = srcX[iy * NX + loRow[iy]] or loRow[iy] + local rax, ray = texel(rx, src[i]) + top(zA, zB, (rax + 0.5) / atlasW, (ray + 0.5) / atlasH) else top(zA, zB, u, v) end end) - if iy < N - 1 then + if iy < NY - 1 then pieces(ix, iy + 1, function(zA, zB) quads[#quads + 1] = { { x0, yB, zB }, { x1, yB, zB }, { x1, yB, zA }, { x0, yB, zA }, @@ -946,8 +1292,12 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) local tsid = tostring(map.tileset.id or map.tileset.image or "?") -- the stump class's drawn-ellipse height, hand-authored per tileset - -- (the profile's stump_cap, in art rows) - local stumpCap = 6 + -- (the profile's stump_cap, in art rows), and the can class's three: the + -- mouth ellipse over the top (can_cap) and the base ellipse under the + -- bottom (can_base), both in art rows, plus the authored can_height in + -- voxels the body band is repeated up to + local stumpCap, canCap, canBase, canHeight, canWell, canTaper + = 6, 9, 4, 9, 5, 4 do local okP, prof = pcall(V.data, "voxel_heights") local entry = okP and type(prof) == "table" and prof.tilesets @@ -955,6 +1305,21 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) if entry and type(entry.stump_cap) == "number" then stumpCap = entry.stump_cap end + if entry and type(entry.can_cap) == "number" then + canCap = entry.can_cap + end + if entry and type(entry.can_base) == "number" then + canBase = entry.can_base + end + if entry and type(entry.can_height) == "number" then + canHeight = entry.can_height + end + if entry and type(entry.can_well) == "number" then + canWell = entry.can_well + end + if entry and type(entry.can_taper) == "number" then + canTaper = entry.can_taper + end end -- cells consumed by a 2x2 `canopy` group; the scan runs north to @@ -1015,13 +1380,72 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) grouped[ckey + 8192] = true grouped[ckey + 8193] = true end + elseif s and s.art == "planter" and near then + -- ONE 16x32x16 hull over a drawing stacked TWO CELLS HIGH on one + -- cell of plot: the Pokemon Centers' potted plants (a leaf crown + -- over a flared pot, 78 placements across 13 maps). + -- + -- The anchor is the NORTH cell -- the crown, where the canvas + -- starts -- but the hull stands in the SOUTH cell, because that is + -- where the pot is drawn and an object's ground contact is its + -- plot. The crown is therefore HEIGHT, not depth: the north cell + -- is claimed and left as floor for the crown to overhang, which is + -- what un-projecting the 3/4 view means here. Pinning only one of + -- the two cells leaves the drawing partial (a map edit, a mod's + -- stray tile), so the anchor is left alone rather than carved into + -- half a plant. + local below = S.shapeAt[keyOf(cx * 2, (cy + 1) * 2)] + if below and below.art == "planter" then + local ground = false + if data then + local ids = {} + for dy = 0, 3 do + for dx = 0, 1 do + ids[#ids + 1] = S.tileAt[keyOf(cx * 2 + dx, cy * 2 + dy)] + end + end + local sig = tsid .. "|p32|" .. gsig .. "|" + .. table.concat(ids, ":") + local tpl = roundCache[sig] + if not tpl then + local tq, tbg = roundTemplate(S, map, data, cx, cy, + groundTiles, 16, nil, 32, + PLANTER_SPRAY) + tpl = { quads = tq, bg = tbg } + roundCache[sig] = tpl + end + ground = tpl.bg or false + S.roundStamps[#S.roundStamps + 1] = + { quads = tpl.quads, mx = cx * 16 + 8, + mz = (cy + 1) * 16 + 8 } + end + for dy = 0, 3 do + for dx = 0, 1 do + local tk = keyOf(cx * 2 + dx, cy * 2 + dy) + S.skip[tk] = true + S.ground[tk] = ground + end + end + grouped[ckey + 8192] = true + end elseif s and s.art == "cylinder" and near then -- a `stump`-class cell is the same hull with a cut face: its - -- top capRows of drawing project onto the round top - local cap = s.class == "stump" and stumpCap or nil + -- top capRows of drawing project onto the round top. A `can`-class + -- cell is that hull cut at BOTH ends -- lid on top, base circle on + -- the floor -- which is what a drum standing on a floor is. + local cap = (s.class == "stump" and stumpCap) + or (s.class == "can" and canCap) or nil + local base = s.class == "can" and canBase or nil + local tall = s.class == "can" and canHeight or nil + local well = s.class == "can" and canWell or nil + local taper = s.class == "can" and canTaper or nil local ground = false if data then - local sig = tsid .. (cap and ("|c" .. cap) or "") .. "|" + local sig = tsid .. (cap and ("|c" .. cap) or "") + .. (base and ("|b" .. base) or "") + .. (tall and ("|h" .. tall) or "") + .. (well and ("|w" .. well) or "") + .. (taper and ("|t" .. taper) or "") .. "|" .. gsig .. "|" .. table.concat({ S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)], S.tileAt[keyOf(cx * 2, cy * 2 + 1)], @@ -1029,7 +1453,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) local tpl = roundCache[sig] if not tpl then local tq, tbg = roundTemplate(S, map, data, cx, cy, - groundTiles, 16, cap) + groundTiles, 16, cap, nil, nil, + base, tall, well, taper) tpl = { quads = tq, bg = tbg } roundCache[sig] = tpl end @@ -1179,11 +1604,107 @@ end -- When the row just above a rank is undetected structure (a shared trim -- tile the profile cannot pin), the rank adopts it as its CAP: one more -- band of height and the art its top face wears. -local BOOK_SHADE = { south = 1.0, north = 0.68, flank = 0.8, top = 0.85 } +local BOOK_SHADE = { south = 1.0, north = 0.68, flank = 0.8, top = 0.85, + -- a pane's reveal: the one-voxel side of the frame + -- standing proud of it. The sill catches the light + -- the top face does; the lintel is in shadow. + sill = 0.85, lintel = 0.5 } -local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) +-- A pane is a shelf opening, a glass door or an inset panel: a non-black +-- region the drawing SEALS OFF behind its own black frame. Anything +-- wider or taller than this is a band of the front itself -- a trim +-- course, a plinth -- and stays flush. The same number and the same +-- rule lib/Buildings.lua measures a facade's panes with, so a shelf the +-- band pipeline models and a shelf this class collapses carry the same +-- relief. +local BOOK_RECESS_MAX = 24 + +-- The panes of a BANK of ranks -- every rank of the same height standing +-- side by side -- as a mask over the bank's south face, plus the atlas +-- pixel each face texel comes from. Measured over the whole bank rather +-- than per column, because a door panel drawn across two tiles is one +-- region and not two halves, and because the size test that keeps a +-- broad course flush has to see the course's real width. +-- +-- `fx` runs across the bank and `fy` DOWN from its top, so the grid +-- reads like the drawing: the rank folds its tiles up band by band, the +-- southmost row lowest, and fy = 0 is the topmost drawn row. +local function bookcasePanes(map, data, perRow, run, i, j) + if not data then return nil end + local bands = run[i].bands + local size = run[i].front - run[i].top + 1 + local W, H = (j - i + 1) * 8, bands * 8 + local light, srcU, srcV = {}, {}, {} + for fy = 0, H - 1 do + local band = bands - 1 - math.floor(fy / 8) + local row = fy % 8 + for fx = 0, W - 1 do + local col = run[i + math.floor(fx / 8)] + local tile = band < size and map:tileAt(col.tx, col.front - band) + or col.cap + if tile then + local k = fy * W + fx + local ax = (tile % perRow) * 8 + fx % 8 + local ay = math.floor(tile / perRow) * 8 + row + srcU[k], srcV[k] = ax, ay + local r, g, b, a = data:getPixel(ax, ay) + light[k] = a ~= 0 + and Structures.shadeClass(math.min(r, g, b)) ~= "black" + end + end + end + + -- The drawing's non-black regions, split across its black frames. A + -- region that reaches the face's own border is not sealed by anything + -- -- it is a course of the front running edge to edge, the way a + -- masonry band or a wall of siding does -- and it stays flush. That + -- test is what keeps this rule to shelves: `bookcase` also collapses + -- the League's gate walls and the terraces, and their courses run off + -- the drawing, so nothing there sinks. + local pane, seen = {}, {} + for k0 = 0, W * H - 1 do + if light[k0] and not seen[k0] then + local cells, stack = {}, { k0 } + seen[k0] = true + local ax0, ax1 = k0 % W, k0 % W + local ay0, ay1 = math.floor(k0 / W), math.floor(k0 / W) + local edge = false + while #stack > 0 do + local k = table.remove(stack) + cells[#cells + 1] = k + local cx, cy = k % W, math.floor(k / W) + if cx < ax0 then ax0 = cx end + if cx > ax1 then ax1 = cx end + if cy < ay0 then ay0 = cy end + if cy > ay1 then ay1 = cy end + if cx == 0 or cx == W - 1 or cy == 0 or cy == H - 1 then + edge = true + end + for _, d in ipairs(DIRS4) do + local nx, ny = cx + d[1], cy + d[2] + if nx >= 0 and nx < W and ny >= 0 and ny < H then + local nk = ny * W + nx + if light[nk] and not seen[nk] then + seen[nk] = true + stack[#stack + 1] = nk + end + end + end + end + if not edge and ax1 - ax0 < BOOK_RECESS_MAX + and ay1 - ay0 < BOOK_RECESS_MAX then + for _, k in ipairs(cells) do pane[k] = true end + end + end + end + return pane, srcU, srcV, W, H +end + +local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV, + bankW, bankH) + local r = run[k] + local tx, northTy, frontTy, capTile = r.tx, r.top, r.front, r.cap local quads = S.objectQuads - local perRow = map.tileset.tilesPerRow or 16 local atlasW = map.tileset.imageWidth or 128 local atlasH = map.tileset.imageHeight or 48 local function uvRect(tile) @@ -1194,12 +1715,13 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) end local size = frontTy - northTy + 1 - local bands = size + (capTile and 1 or 0) + local bands = r.bands local h = bands * 8 local depth = math.min(2, size) * 8 local x0, x1 = tx * 8, tx * 8 + 8 local z1 = frontTy * 8 + 8 local z0 = z1 - depth + local fx0 = (k - i) * 8 -- this rank's columns within the bank -- does the neighbouring column continue this shelf? (flanks only cap -- the ends of a run of bookcases standing side by side) @@ -1208,14 +1730,101 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) return ns ~= nil and ns.art == "bookcase" end + local function sunk(fx, fy) + if not pane or fx < 0 or fx >= bankW or fy < 0 or fy >= bankH then + return false + end + return pane[fy * bankW + fx] == true + end + for band = 0, bands - 1 do local tile = band < size and map:tileAt(tx, frontTy - band) or capTile local u0, u1, v0, v1 = uvRect(tile) local y0, y1 = band * 8, band * 8 + 8 - quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 }, - { x1, y1, z1 }, { x0, y1, z1 }, - uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, - shade = BOOK_SHADE.south } + local fyTop = (bands - 1 - band) * 8 + + -- The south face: the drawing folded upright. A band with no pane + -- in it is the single quad it has always been; a band that seals + -- one splits into per-row runs of texels, and the pane's run sinks + -- a voxel behind the frame that stays proud around it. + local relief = false + if pane then + for row = 0, 7 do + for c = 0, 7 do + if sunk(fx0 + c, fyTop + row) then relief = true break end + end + if relief then break end + end + end + if not relief then + quads[#quads + 1] = { { x0, y0, z1 }, { x1, y0, z1 }, + { x1, y1, z1 }, { x0, y1, z1 }, + uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, + shade = BOOK_SHADE.south } + else + local ax = (tile % perRow) * 8 + local ay = math.floor(tile / perRow) * 8 + for row = 0, 7 do + local fy = fyTop + row + local wy = y0 + 7 - row -- the drawing's row 0 is the top + local c = 0 + while c < 8 do + local s = sunk(fx0 + c, fy) + local n = 1 + while c + n < 8 and sunk(fx0 + c + n, fy) == s do n = n + 1 end + local pz = s and z1 - 1 or z1 + local qu0 = (ax + c + 0.05) / atlasW + local qu1 = (ax + c + n - 0.05) / atlasW + local qv0 = (ay + row + 0.05) / atlasH + local qv1 = (ay + row + 1 - 0.05) / atlasH + quads[#quads + 1] = { { x0 + c, wy, pz }, { x0 + c + n, wy, pz }, + { x0 + c + n, wy + 1, pz }, { x0 + c, wy + 1, pz }, + uv = { { qu0, qv1 }, { qu1, qv1 }, { qu1, qv0 }, { qu0, qv0 } }, + shade = BOOK_SHADE.south } + c = c + n + end + end + -- the reveals: where a sunk texel meets a proud one, the frame's + -- own one-voxel side shows. It wears the PROUD neighbour's texel, + -- because that is the block it belongs to. A pane running off the + -- bank, or off the top or bottom of the rank, needs none: the + -- flank and top faces already close it. + for row = 0, 7 do + local fy = fyTop + row + local wy = y0 + 7 - row + for c = 0, 7 do + if sunk(fx0 + c, fy) then + local X = x0 + c + local function reveal(nfx, nfy, verts, shade) + if nfx < 0 or nfx >= bankW or nfy < 0 or nfy >= bankH then + return + end + if sunk(nfx, nfy) then return end + local nk = nfy * bankW + nfx + if not srcU[nk] then return end + quads[#quads + 1] = { verts[1], verts[2], verts[3], verts[4], + u = (srcU[nk] + 0.5) / atlasW, v = (srcV[nk] + 0.5) / atlasH, + shade = shade } + end + reveal(fx0 + c - 1, fy, { + { X, wy, z1 }, { X, wy, z1 - 1 }, + { X, wy + 1, z1 - 1 }, { X, wy + 1, z1 } }, BOOK_SHADE.flank) + reveal(fx0 + c + 1, fy, { + { X + 1, wy, z1 - 1 }, { X + 1, wy, z1 }, + { X + 1, wy + 1, z1 }, { X + 1, wy + 1, z1 - 1 } }, + BOOK_SHADE.flank) + reveal(fx0 + c, fy + 1, { + { X, wy, z1 - 1 }, { X + 1, wy, z1 - 1 }, + { X + 1, wy, z1 }, { X, wy, z1 } }, BOOK_SHADE.sill) + reveal(fx0 + c, fy - 1, { + { X, wy + 1, z1 }, { X + 1, wy + 1, z1 }, + { X + 1, wy + 1, z1 - 1 }, { X, wy + 1, z1 - 1 } }, + BOOK_SHADE.lintel) + end + end + end + end + quads[#quads + 1] = { { x1, y0, z0 }, { x0, y0, z0 }, { x0, y1, z0 }, { x1, y1, z0 }, uv = { { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, @@ -1245,10 +1854,19 @@ local function bookcaseRank(S, map, tx, northTy, frontTy, capTile) end end -function Structures.buildBookcases(S, map, x0, x1, y0, y1) +function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow) + perRow = perRow or map.tileset.tilesPerRow or 16 -- 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) + -- the front's measured relief: on for a shelf, off for the tilesets + -- that borrow the collapse for masonry or machinery + if not TileShape.bookcaseRelief(map.tileset.id) then data = nil end + -- Ranks are collected here and emitted after the sweep: a rank's panes + -- are measured over the whole BANK it stands in (see bookcasePanes), + -- and the bank is only known once every column has been read. Nothing + -- below this loop mutates what the sweep reads, so deferring is free. + local order, banks = {}, {} for tx = x0, x1 do local ty = y1 while ty >= y0 do @@ -1295,7 +1913,17 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1) S.ground[tk] = false end end - bookcaseRank(S, map, tx, top, front, capTile) + -- ranks of the same height standing side by side are one bank + local bands = (front - top + 1) + (capTile and 1 or 0) + local key = top .. ":" .. front .. ":" .. bands + local bank = banks[key] + if not bank then + bank = {} + banks[key] = bank + order[#order + 1] = key + end + bank[#bank + 1] = { tx = tx, top = top, front = front, + cap = capTile, bands = bands } front = top - 1 end ty = north - 1 @@ -1304,6 +1932,24 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1) end end end + + -- tx ascends in the sweep above, so each bank's columns are already in + -- order; split them into the contiguous runs that actually touch + for _, key in ipairs(order) do + local run = banks[key] + local i = 1 + while i <= #run do + local j = i + while j < #run and run[j + 1].tx == run[j].tx + 1 do j = j + 1 end + local pane, srcU, srcV, bankW, bankH = + bookcasePanes(map, data, perRow, run, i, j) + for k = i, j do + bookcaseRank(S, map, perRow, run, i, j, k, + pane, srcU, srcV, bankW, bankH) + end + i = j + 1 + end + end end -- ---- stairs: pinned cells that render as real steps ---- @@ -2023,8 +2669,14 @@ function Structures.buildObject(S, map, region, cluster, -- 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. + -- A `building` claim supports too, when it carries a height: a + -- Buildings template that names `support` is furniture modelled in + -- full with a standee left standing on it (Red's dining table under + -- its potted plant), and the height it states is the model's top + -- plane. A plain claim stays at h = 0 and supports nothing. if blocked and bs and bs.authored and (bs.h or 0) > 0 - and (bs.art == "upright" or bs.art == "bookcase") then + and (bs.art == "upright" or bs.art == "bookcase" + or bs.class == "building") then baseY, support = bs.h, bs end end @@ -2159,7 +2811,8 @@ function Structures.buildObject(S, map, region, cluster, for _, c in ipairs(cluster.tiles) do local k = keyOf(c[1], c[2]) if support and (support.class == "wall" or support.class == "cliff" - or support.art == "bookcase") then + or support.art == "bookcase" + or support.class == "building") 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 @@ -2174,8 +2827,14 @@ function Structures.buildObject(S, map, region, cluster, -- 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. + -- A `building` support belongs here too: the template's stamped + -- model already carries every surface under the standee (that is + -- what its `support` height asserts), so a box here would stand + -- INSIDE the modelled tabletop. Its stamp pre-painted the floor + -- under these tiles, which the `or` keeps when no flat tile + -- touches a cluster ringed by its own furniture. S.skip[k] = true - S.ground[k] = best + S.ground[k] = best or S.ground[k] elseif support then -- the claimed tile keeps rendering as the box the prop stands on, -- wearing the art its own ROW would have without the drawing (the @@ -2204,7 +2863,167 @@ function Structures.buildObject(S, map, region, cluster, return true end --- ---- figures: a person drawn INTO furniture, cut out and stood up ---- +-- ---- authored masks with a body ---- + +-- One authored mask emitted as a per-pixel voxel slab in WORLD space -- +-- the treatment every solid standee in this file gets, driven by a hand +-- drawn silhouette instead of a flood. +-- +-- The caller owns placement entirely, because placement is the whole +-- difference between the two things that use this: `x0` is the world x of +-- the mask's west edge, `yOf(ly)` the world y a drawn row lands at, and +-- `bandOf(ly)` its z span. A bicycle hung on a wall keeps its drawn +-- elevation and juts south of the band; a cash register stands on the +-- counter's top plane and sits inside its own cell. +-- +-- `bandOf` is per ROW rather than per object so one drawing can hold parts +-- of different thickness (the register's receipt curl over its body). +-- Where the band CHANGES between two stacked rows the lower row still gets +-- its top face: without that the body would be open along the strip the +-- thinner part does not cover, and you would see into the machine. +-- +-- `omit` is a rect of the mask this pass does NOT extrude, because it is +-- not a face at all -- maskPlate lays it flat instead. It leaves the mask +-- for good here, neighbours included, so the extrusion closes up around +-- the notch exactly as if the drawing had never filled it. +local function maskSlab(quads, m, perRow, atlasW, atlasH, x0, yOf, bandOf, + yFloor, omit) + local bw, bh = m.w * 8, m.h * 8 + + local function at(lx, ly) + if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end + if omit and lx >= omit.x0 and lx <= omit.x1 + and ly >= omit.r0 and ly <= omit.r1 then return false end + return m.mask[ly * bw + lx] or false + end + + for ly = 0, bh - 1 do + Budget.tick() + local z0, z1 = bandOf(ly) + local pz0, pz1 = bandOf(ly - 1) + local capped = (pz0 ~= z0 or pz1 ~= z1) + for lx = 0, bw - 1 do + if at(lx, ly) then + local tile = m.tiles[math.floor(ly / 8) * m.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 = x0 + lx, yOf(ly) + local function quad(c1, c2, c3, c4, shade) + quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade } + end + quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y + 1, z1 }, + { x, y + 1, z1 }, OBJ_SHADE.front) + quad({ x + 1, y, z0 }, { x, y, z0 }, { x, y + 1, z0 }, + { x + 1, y + 1, z0 }, OBJ_SHADE.back) + if capped or not at(lx, ly - 1) then + quad({ x, y + 1, z0 }, { x + 1, y + 1, z0 }, { x + 1, y + 1, z1 }, + { x, y + 1, z1 }, OBJ_SHADE.top) + end + if y > yFloor and not at(lx, ly + 1) then + quad({ x, y, z1 }, { x + 1, y, z1 }, { x + 1, y, z0 }, + { x, y, z0 }, OBJ_SHADE.bottom) + end + if not at(lx - 1, ly) then + quad({ x, y, z0 }, { x, y, z1 }, { x, y + 1, z1 }, + { x, y + 1, z0 }, OBJ_SHADE.side) + end + if not at(lx + 1, ly) then + quad({ x + 1, y, z1 }, { x + 1, y, z0 }, { x + 1, y + 1, z0 }, + { x + 1, y + 1, z1 }, OBJ_SHADE.side) + end + end + end + end +end + +-- The other half of the same drawing: a rect of the mask that is a +-- TOP-VIEW surface, laid HORIZONTAL instead of extruded. +-- +-- This is the methodology's band classification at rect granularity, and +-- the reason the register is not a box. A GB cell packs several facings, +-- and the register's keypad is drawn from ABOVE -- its keys lie on the +-- machine's deck, sealed behind their own black border inside the outer +-- silhouette. Extruding it stands that surface on end and paints the keys +-- up the machine's face, which is the extruded-picture failure exactly. +-- +-- So the rect lands one voxel proud of what maskSlab left below it, at `y`, +-- one voxel thick, filling the body's whole depth band (`z0`, `D`). +-- +-- The rect STRETCHES over that band rather than laying its rows 1:1: it is +-- the machine's whole deck, so it has to reach the machine's whole depth, +-- and the alternative -- panel at the front, bare deck behind -- leaves a +-- strip of the base band's top showing through where the keys should be. +-- Sampled at the voxel's CENTRE, the same rule Stage 1 samples the atlas +-- with, so a band scales by whole voxels and nothing blurs: at 8 rows over +-- 12 voxels every second drawn row doubles. The one place in the model +-- where a texel is not 1:1 with a drawn pixel, and the reason `depth` is an +-- authored number again. No bottom faces: it rests on the box. +local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D) + local bw, bh = m.w * 8, m.h * 8 + local rows = r.r1 - r.r0 + 1 + + -- depth voxel -> the drawn row it wears + local function rowAt(k) + if k < 0 or k >= D then return nil end + return r.r0 + math.min(rows - 1, math.floor((k + 0.5) * rows / D)) + end + + local function at(lx, k) + local ly = rowAt(k) + if not ly or lx < r.x0 or lx > r.x1 then return false end + return m.mask[ly * bw + lx] or false + end + + -- The plate's rim, in the two directions the drawing treats differently. + -- ACROSS the rows the neighbour is the extrusion standing BESIDE the + -- notch (the register's display unit), which is tall and covers the + -- plate's edge, so that face must not be drawn twice. ALONG them the + -- neighbour is the extrusion BELOW it (the base band, whose own front + -- face stops one voxel short), so the plate's front lip is exposed and + -- is the deck's own front edge. + local function beside(lx, ly) + if lx < 0 or lx >= bw or ly < 0 or ly >= bh then return false end + return m.mask[ly * bw + lx] or false + end + + for k = 0, D - 1 do + Budget.tick() + local ly, z = rowAt(k), z0 + k + for lx = r.x0, r.x1 do + if at(lx, k) then + local tile = m.tiles[math.floor(ly / 8) * m.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 = x0 + lx + local function quad(c1, c2, c3, c4, shade) + quads[#quads + 1] = { c1, c2, c3, c4, u = u, v = v, shade = shade } + end + quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 }, + { x, y + 1, z + 1 }, OBJ_SHADE.top) + if not at(lx, k + 1) then + quad({ x, y, z + 1 }, { x + 1, y, z + 1 }, { x + 1, y + 1, z + 1 }, + { x, y + 1, z + 1 }, OBJ_SHADE.front) + end + if not at(lx, k - 1) then + quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z }, + { x + 1, y + 1, z }, OBJ_SHADE.back) + end + if not beside(lx - 1, ly) then + quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 }, + { x, y + 1, z }, OBJ_SHADE.side) + end + if not beside(lx + 1, ly) then + quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z }, + { x + 1, y + 1, z + 1 }, OBJ_SHADE.side) + end + end + end + end +end + +-- ---- figures: a thing drawn INTO furniture, cut out and stood up ---- -- One authored figure at one matched position. -- @@ -2215,22 +3034,34 @@ end -- 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). +-- A PERSON is a SPRITE, not a prop, and an entry that states no `depth` +-- 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 +-- So the card's quads are emitted in its 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. +-- +-- An entry that DOES state a `depth` is not a person, and takes the other +-- branch: a per-pixel voxel slab in world space (maskSlab above), standing +-- on the same furniture the card would have stood on. The Marts' cash +-- register is why -- a machine set down on a counter is a box seen from +-- the front, and a card of it is the billboard failure the standee pools +-- exist to avoid. It keeps the card's anchoring exactly: its feet on the +-- support's top plane, and its body in the 8px depth band of the tile row +-- its lowest pixel is drawn in, which is where a character card would +-- have pivoted. So the machine sits at the FRONT of the counter cell it +-- is drawn low in, and never leans into the aisle behind it. local function buildFigure(S, map, fig, tx, ty, perRow) local bw, bh = fig.w * 8, fig.h * 8 @@ -2273,34 +3104,76 @@ local function buildFigure(S, map, fig, tx, ty, perRow) 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 } + + if fig.depth then + -- An OBJECT: the standee slab, standing on the FRONT edge of the tile + -- row its feet are drawn in -- the south face of the 8px band a + -- character card would have pivoted in. It is anchored there and + -- grows NORTH rather than being centred, so that `depth` is free to + -- exceed the 8px band without the machine ever creeping toward the + -- aisle: a till drawn low on a counter is at the counter's front, and + -- a deeper one just eats more of the bare top behind it. (At the + -- 8 the band itself is, the two rules agree.) + -- + -- `thin` caps the top rows to their own thickness, centred in the + -- body's depth -- the register's receipt curl leaves the arm's top + -- face by a slot in the middle of it, not flush with its front. + local south = ty * 8 + math.floor(lowY / 8) * 8 + 8 + local function bandOf(ly) + local z0 = south - fig.depth + if fig.thin and ly < fig.thin.rows then + local m = math.floor((fig.depth - fig.thin.depth) / 2) + return z0 + m, z0 + m + fig.thin.depth + end + return z0, south + end + local function yOf(ly) return baseY + lowY - ly end + maskSlab(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8, + yOf, bandOf, baseY, fig.flat) + if fig.flat then + -- The top-view rect lands on the plane its own BOTTOM row would + -- have stood at -- which is the top of whatever the extrusion left + -- under it (the register's base band), so the keys lie on the deck + -- and never float. + -- + -- In depth it fills the body's whole band, STRETCHED to it: the rect + -- is the machine's deck, so it reaches as deep as the machine does, + -- and its last drawn row stays the deck's front edge directly over + -- the fascia below it -- an object drawn LOW on a surface is drawn + -- NEAR its front. + maskPlate(S.objectQuads, fig, perRow, atlasW, atlasH, tx * 8, + fig.flat, yOf(fig.flat.r1), south - fig.depth, fig.depth) + end + else + 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 - 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, - } + -- 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, + } + end -- 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 @@ -2342,6 +3215,78 @@ function Structures.buildFigures(S, map, x0, x1, y0, y1) end end +-- ---- mounted: a thing drawn INTO a wall band, stood proud of it ---- + +-- One authored mounted object at one matched position. +-- +-- Same authoring premise as a figure -- the mask IS the classification, +-- because a drawing painted onto the wall it hangs on has no background +-- margin for a flood to enter by, and here the wall's own #555 stripes +-- are a flood boundary as well, so a silhouette comes back striped. +-- Like a figure it therefore builds HEADLESS: nothing below reads a +-- pixel. +-- +-- But a mounted object is an OBJECT, so it is built the way every other +-- standee here is -- a per-pixel voxel slab wearing the drawing's own +-- texels, quads emitted in world space -- and not as a sprite card: +-- +-- ELEVATION is the drawn one. A figure stands on its own feet; this +-- keeps the row it is painted in, because the band it is painted into +-- is a measured 16px face rising off the floor. So drawn row `ly` +-- becomes world y = (band height - 1) - ly, and a bicycle whose wheels +-- are drawn on the band's bottom row lands on the floor while one hung +-- clear of it stays hung. +-- DEPTH juts SOUTH of the band's own face (z0 at the drawing's south +-- edge), so the object stands in front of the wall rather than inside +-- it. It overhangs the walkable cell in front, which is what a bicycle +-- leaning on a wall does; nothing about collision changes. +local function buildMountedAt(S, map, m, tx, ty, perRow) + local bh = m.h * 8 + local z0 = (ty + m.h) * 8 + local z1 = z0 + (m.depth or 2) + + maskSlab(S.objectQuads, m, perRow, map.tileset.imageWidth or 128, + map.tileset.imageHeight or 48, tx * 8, + function(ly) return (bh - 1) - ly end, + function() return z0, z1 end, 0) + + -- What the band wears now that the object is off it: the plain panel + -- the artist drew everywhere else along the same wall. Only the ART + -- changes -- these tiles keep the `wall` box they always resolved to, + -- because they ARE the wall. + for i = 1, #m.tiles do + local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w) + S.tileAt[keyOf(tx + dx, ty + dy)] = m.under[i] + end +end + +-- Every authored mounted object, wherever the map draws it. Matched by +-- TILE PATTERN like a figure, and for the same reason -- one blockset +-- entry can place the same drawing in several rooms -- and the repaint +-- above replaces the pattern's own tiles, so a match never fires twice +-- on one drawing. +function Structures.buildMounted(S, map, x0, x1, y0, y1) + local list = TileShape.mounted(map.tileset.id) + if not list then return end + local perRow = map.tileset.tilesPerRow or 16 + for _, m in ipairs(list) do + for ty = y0, y1 - m.h + 1 do + for tx = x0, x1 - m.w + 1 do + Budget.tick() + local hit = true + for i = 1, #m.tiles do + local dx, dy = (i - 1) % m.w, math.floor((i - 1) / m.w) + if S.tileAt[keyOf(tx + dx, ty + dy)] ~= m.tiles[i] then + hit = false + break + end + end + if hit then buildMountedAt(S, map, m, 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 31e5916..0ea0ada 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -71,6 +71,21 @@ local FALLBACK_HEIGHTS = { -- body builds from the bark rows and the drawn ellipse projects onto -- the hull's round top stump = 16, + -- the same hull cut at both ends, hollowed and tapered: an OPEN bin + -- standing on a floor (the Vermilion Gym trash cans). The drawn mouth + -- ellipse projects onto the round top and down the well, the drawn base + -- ellipse is ground contact rather than body, and the plan narrows toward + -- the floor. Height is AUTHORED (the profile's can_height, which this + -- pin must be kept equal to so anything riding a can lands on its rim) -- + -- the drawing's own straight run is only a couple of rows, because a GB + -- cell spends most of itself on the opening + can = 9, + -- round scenery drawn ONE cell wide and TWO cells TALL, standing on one + -- cell of plot: the Pokemon Centers' potted plants. Carved as one + -- 16x32x16 hull in the SOUTH (pot) cell -- the drawing's upper cell is + -- the object's height, not its depth. BOTH cells take the class; the + -- group build anchors on the north one (Structures.buildCylinders) + planter = 32, billboard = 16, signpost = 16, post = 16, @@ -91,6 +106,10 @@ local FALLBACK_HEIGHTS = { desk = 24, prop = 16, cutout = 16, + -- a vehicle drawn SIDE-ON: the showroom bicycles. Standee height like + -- every other cutout pool -- what differs is the thickness (see + -- Structures' PINNED_DEPTH) + bike = 16, console = 16, relief = 3, bookcase = 32, @@ -127,6 +146,8 @@ local ART = { cylinder = "cylinder", canopy = "canopy", stump = "cylinder", + can = "cylinder", + planter = "planter", billboard = "billboard", -- signposts share the billboard treatment but as their own pool at a -- 2-voxel depth: a sign is a thin plate on a stick, and the standard @@ -165,6 +186,13 @@ local ART = { desk = "upright", prop = "billboard", cutout = "billboard", + -- a bicycle is a LINE drawing seen side-on, and its negative space -- + -- the air inside the frame, between the wheel and the fork -- is what + -- makes it read as a bicycle at all. Its own pool at two voxels: any + -- thicker and the side faces of neighbouring strokes close those gaps + -- from every angle but dead-on, and six of them in a showroom come out + -- as one dark lump (which is what the 5px `prop` pool gave) + bike = "billboard", -- a machine standing on furniture: the billboard treatment with -- body, plus the one-object contract `cutout` has -- the drawing is -- ringed by the furniture it sits on, and those edges must not be @@ -183,6 +211,7 @@ local ART = { local spec = nil -- the loaded data file, or false when absent local cache = {} -- tileset id -> resolved shape list local figCache = {} -- tileset id -> parsed figure masks, or false +local mntCache = {} -- tileset id -> parsed mounted 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 @@ -430,68 +459,158 @@ end -- pixel by pixel (see data/voxel_heights.lua): -- -- figures = { { w = , +-- depth = , +-- thin = { rows = , depth = }, +-- flat = { x = { , }, rows = { , } }, -- 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). +-- No class -- what the entry carries instead is a `depth`, or does not: +-- +-- WITHOUT one it is a flat sprite card, drawn the way SpriteBillboards +-- draws a character. That is the right reading for a PERSON: a Gen 1 +-- figure is a face-on 2D icon, and extruding one reconstructs a body +-- nobody drew (see Structures.buildFigures). +-- WITH one it is an OBJECT and gets the standee treatment every other +-- solid here gets -- a per-pixel slab in world space, standing on the +-- same furniture the card would have stood on. The Marts' cash +-- register is the case: a machine on a counter is a box, not an icon. +-- +-- Two fields say which parts of such a drawing are NOT the extrusion, +-- because a solid drawn in one 16x16 GB cell still packs more than one +-- facing: +-- +-- `thin` caps the thickness over the mask's top rows, for the part of +-- the drawing that is not the machine (the register's receipt curl). +-- `flat` names a rect of the mask that is a TOP-VIEW surface rather +-- than a face -- the register's keypad, whose keys lie ON its deck. +-- The rect lays horizontal one voxel proud of whatever the extrusion +-- leaves below it, at the elevation its BOTTOM row would have had, +-- with drawn row = depth row 1:1 (the mapping the lab tabletop is +-- drawn with). So a drawing whose front elevation is an L reads as +-- one: body up the side and along the base, keys lying in the notch. -- -- 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. +-- +-- `mounted` (below) carries the same four fields, so the parse is shared, +-- and so are the optional ones that give an authored mask a BODY: `depth`, +-- `thin` and `flat` above. `depth` is left nil when unstated, because +-- absence is meaningful on a figure: no depth means the flat sprite card a +-- person is drawn as. +local function authoredMasks(list) + local out = {} + if type(list) ~= "table" then return out end + 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 + local depth = tonumber(f.depth) + local thin = nil + if type(f.thin) == "table" and tonumber(f.thin.rows) + and tonumber(f.thin.depth) then + thin = { rows = math.floor(tonumber(f.thin.rows)), + depth = math.floor(tonumber(f.thin.depth)) } + end + local flat = nil + if type(f.flat) == "table" and type(f.flat.x) == "table" + and type(f.flat.rows) == "table" then + flat = { x0 = math.floor(f.flat.x[1]), x1 = math.floor(f.flat.x[2]), + r0 = math.floor(f.flat.rows[1]), + r1 = math.floor(f.flat.rows[2]) } + end + if n > 0 then + out[#out + 1] = { w = w, h = h, n = n, mask = mask, + tiles = f.tiles, under = f.under, + depth = depth and math.floor(depth) or nil, + thin = thin, flat = flat } + end + end + end + return out +end + 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 + local out = authoredMasks(entry and entry.figures) figCache[tilesetId] = (#out > 0) and out or false return figCache[tilesetId] or nil end +-- Hand-authored MOUNTED objects for one tileset: a thing drawn INTO the +-- wall band it hangs on, cut out by an explicit pixel mask and stood +-- proud of the wall's face. +-- +-- Same authoring problem as `figures` and the same answer -- a class pin +-- resolves a whole 8x8 tile, and the detector cannot segment a drawing +-- that has no background margin to flood from. The Bike Shop's two wall +-- bicycles are the case: the shop's striped wall panel runs BEHIND them, +-- and its #555 stripes are a flood boundary, so a silhouette flood comes +-- back with the stripes attached to the bike. +-- +-- Two things differ from a figure, and both follow from the object being +-- an object rather than a character: +-- +-- it keeps its DRAWN ELEVATION. A figure stands on its own feet; a +-- mounted thing sits where the wall band draws it, so a bicycle hung +-- clear of the floor stays hung. +-- it has THICKNESS (`depth`, default 2), and it is built in world +-- space as a per-pixel slab jutting south of the band -- not as a +-- camera-facing sprite card. A bicycle drawn side-on is a plane +-- parallel to the wall, not a face-on icon. +-- +-- mounted = { { w = , +-- depth = , +-- tiles = { ...w*h tile ids, row-major... }, +-- under = { ...w*h ids: what each tile wears once the +-- object is lifted off it (the plain panel)... }, +-- pixels = { ...h*8 strings of w*8 chars, "." = wall... } } } +function TileShape.mounted(tilesetId) + local hit = mntCache[tilesetId] + if hit ~= nil then return hit or nil end + + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + local out = authoredMasks(entry and entry.mounted) + + mntCache[tilesetId] = (#out > 0) and out or false + return mntCache[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. -- @@ -564,12 +683,30 @@ function TileShape.bookcaseBackfill(tilesetId) return mode == "above" and mode or nil end +--- Does this tileset's `bookcase` run carry the measured pane RELIEF on +--- its front (a tileset entry's bookcase_relief)? Default yes: the class +--- almost always collapses a shelf, a rack or a display case, and every +--- one of those seals its contents behind a frame that should stand proud +--- of them. +--- +--- A tileset says `bookcase_relief = false` when it borrows the collapse +--- for something that is NOT a shelf -- the League's gate walls and +--- pilasters, Bill's transporter drums -- where the drawing's light +--- regions are the masonry and the barrel, not panes, and sinking them +--- carves the surface instead of describing it. +function TileShape.bookcaseRelief(tilesetId) + local s = load() + local entry = s and s.tilesets and s.tilesets[tilesetId] + return not (entry and entry.bookcase_relief == false) +end + -- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset -- record needs the next lookup to re-resolve (hot reload, mod toggle). function TileShape.invalidate() spec = nil cache = {} figCache = {} + mntCache = {} bgCache = {} end diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua index 54ef847..1749402 100644 --- a/lib/Voxel3D.lua +++ b/lib/Voxel3D.lua @@ -421,7 +421,8 @@ end -- ---------------------------------------------------------------- camera -- -- An explicit camera, replacing the orbit below for as long as it is set: --- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil }. +-- { eye = {x,y,z}, focus = {x,y,z}, fov = radians, curve = k or nil, +-- up = {x,y,z} or nil }. -- -- The orbit is the free-roam camera and it is described entirely by ONE -- number, the pitch, because that is all a camera following the player over @@ -496,9 +497,12 @@ function Voxel3D.viewProjection(cx, cy, vw, vh) -- the same clip-space Y flip the orbit needs, for the same reason: we -- bypass LOVE's transform_projection and canvas coordinates run Y down proj = Mat4.mul(Mat4.scale(1, -1, 1), proj) - -- world up, so the horizon stays level -- a placed camera that rolled - -- with its own pitch would tip the whole arena - return Mat4.mul(proj, Mat4.lookAt(eye, focus, { 0, 1, 0 })) + -- world up by default, so the horizon stays level -- a placed camera + -- that rolled with its own pitch would tip the whole arena. A caller + -- may hand its own up: the first-person BLEND does, because its far + -- end is the orbit, whose up leans with the pitch -- world up at the + -- orbit's steep end degenerates against a straight-down view. + return Mat4.mul(proj, Mat4.lookAt(eye, focus, cam.up or { 0, 1, 0 })) end local a = Voxel.angle diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua index daa4131..48c719d 100644 --- a/lib/VoxelScene.lua +++ b/lib/VoxelScene.lua @@ -24,6 +24,7 @@ local Sky = V.require("Sky") local Water = V.require("Water") local VoxelGrid = V.require("VoxelGrid") local DayNight = V.require("DayNight") +local FirstPerson = V.require("FirstPerson") local PaletteFX = require("src.render.PaletteFX") local Map = require("src.world.Map") @@ -214,6 +215,22 @@ local function frameFor(def, facing, phase, flip) return frame, mirror end +-- The facing a pose SHOWS this camera. The flat frames are "how this pose +-- looks from the south", which is where the orbit always stands; a +-- first-person eye stands anywhere, so deep enough into the blend the +-- facing is remapped to how the pose looks from THERE -- walk behind an +-- NPC and their card wears the back sprite. Used by the camera draw and +-- the sun pass BOTH: the card the sun stored and the transform a lit card +-- reads its own shadowing with must describe the same frame, or the +-- mirror-flip half of the pair asks the map about texels the sun filed +-- under the other cheek. +local function viewFacing(p) + if FirstPerson.cardBlend() > 0.5 then + return FirstPerson.apparentFacing(p.facing, p.px + 8, p.py + 8) + end + return p.facing +end + -- FALLBACK ONLY (see castShadows below). Draw one entity's drop shadow as -- a decal: its current sprite frame as a single quad, flattened onto the -- ground along the sun line (Voxel3D.shadowMatrix). Runs inside @@ -236,10 +253,22 @@ end -- Shared by the solid draw and the silhouette below, so the two can never -- drift apart -- a silhouette standing anywhere but exactly behind the -- figure would read as a second character. +-- +-- IN FIRST PERSON the card stops leaning and starts TURNING: upright, yawed +-- about its feet to face the eye (cylindrical billboarding). A south-facing +-- card is invisible edge-on to an eye standing east of it, which no orbit +-- camera could ever do and a first-person one does constantly. The blend +-- carries one pose into the other -- the lean eases out as the yaw eases in +-- -- and cardBlend is zero for every camera that is not the first-person +-- rig, the battle's placed shot included, so nothing else moves. local function billboardMatrix(px, py, y, mirror) local Voxel = V.require("VoxelState") - local m = Mat4.mul(Mat4.translate(px + 8, y, py + 8), - Mat4.rotateX(Voxel.angle - math.pi / 2)) + local b = FirstPerson.cardBlend() + local m = Mat4.translate(px + 8, y, py + 8) + if b > 0 then + m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(px + 8, py + 8) * b)) + end + m = Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) if mirror then m = Mat4.mul(m, Mat4.scale(-1, 1, 1)) end return Mat4.mul(m, Mat4.translate(-8, 0, 0)) end @@ -258,10 +287,24 @@ end -- 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. +-- +-- First person turns him at the eye like the walkers (see billboardMatrix) +-- -- about his own middle, because unlike a character card his local space +-- starts at x = 0 rather than being anchored by a -8 shift, and a yaw about +-- his edge would swing him off his seat. The width rode in on the record +-- for exactly this (ChunkMesher.buildFigureMeshes). 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)) + local b = FirstPerson.cardBlend() + local wx, wz = f.wx + (offX or 0), f.wz + (offZ or 0) + local m = Mat4.translate(wx, f.y, wz) + if b > 0 and f.w and f.w > 0 then + local half = f.w / 2 + m = Mat4.mul(m, Mat4.translate(half, 0, 0)) + m = Mat4.mul(m, Mat4.rotateY(FirstPerson.cardYaw(wx + half, wz) * b)) + m = Mat4.mul(m, Mat4.translate(-half, 0, 0)) + end + return Mat4.mul(m, Mat4.rotateX((Voxel.angle - math.pi / 2) * (1 - b))) end -- What the sun sees: the same card UNLEANED and flattened, exactly as @@ -333,7 +376,7 @@ VoxelScene.drawEntity = drawEntity -- mesh for it. local function drawGhost(p) local def = p.sprite.def - local frame, mirror = frameFor(def, p.facing, p.phase, p.flip) + local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip) local mesh = SpriteBillboards.shadowQuad(def, frame) if not mesh then return end local tex = p.sprite:resolveImage() @@ -462,7 +505,13 @@ local function posesOf(state, spriteColors) gh = groundAt(state.map, e.cellX, e.cellY), lift = e.py - vy, colors = colors, } - if e == state.player then me = posed[#posed] end + if e == state.player then + me = posed[#posed] + -- marked so the camera draw can leave the card out in first + -- person, where it would fill the lens from inside; the SUN pass + -- reads the same list and deliberately does not check the mark + me.isPlayer = true + end end end return posed, me @@ -529,9 +578,18 @@ local function drawCast(state, posed, atlasFor) -- 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. + -- + -- In first person two of them change: the player's own card is left out + -- (the eye is standing in it), and every other card wears the frame its + -- pose SHOWS this eye (viewFacing) rather than the one it shows the + -- south. Both run through here, so the water's reflection copy -- drawn + -- by this same function -- agrees with the frame to the pixel. + local hideMe = FirstPerson.hidePlayer() 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) + if not (p.isPlayer and hideMe) then + drawEntity(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh, + p.colors, p.lift) + end end -- back on for everything textured from the atlas again -- figures, grass -- and flowers all sample it, where the mask's coordinates are honest @@ -583,8 +641,48 @@ end -- The overworld's alone: the staged battle draws its water plain, always -- -- its placed camera reads this pass wrong, and a stage set wants painted -- water anyway (see BattleScene, where the choice is argued). +-- ------- and why the flat draw happens FIRST while the world is curved +-- +-- The reflective pass writes no depth -- it cannot, the depth canvas is +-- detached for the length of it so the shader can READ it -- and it does its +-- own depth test against that texture instead. That test asks whether +-- something opaque is in front, and it answers correctly for every case but +-- one: WATER IN FRONT OF WATER. Nothing puts water in the depth buffer, so +-- no lake can hide another, and the pass simply paints them in mesh order. +-- +-- On a flat world that never matters: every surface lies in the one plane +-- at its own recessed height, and a farther sheet always lands farther down +-- the screen. THE WORLD CURVE ENDS THAT. The bend drops the world by the +-- square of its distance, so the far side of the map swings down and back +-- up into the near field of view -- and a sheet of sea a hundred and fifty +-- tiles away, drawn later in the same mesh, paints straight over the pond +-- at the player's feet. Not a reflection of the far shore: the far shore +-- itself, rasterised on top of the water in front of you. +-- +-- So WHILE THE CURVE IS ON, the meshes go down flat first, through the +-- ordinary scene shader with depth writes on, and the reflective pass draws +-- over the top of what survived: the depth buffer now holds the water +-- surface, so the pass's own test throws the far sheet away, and the +-- reflection COPY holds it too, so a ray grazing another part of the lake +-- reads water rather than the void behind it. +-- +-- With the curve OFF the prepass is not just unnecessary, it is a LIABILITY, +-- and it stays off -- the reflective pass tests only against terrain, as it +-- always did. Painting the surface into the depth texture turns the pass's +-- test into a comparison of the surface against ITSELF, which asks the two +-- rasterisations to agree to within interpolation error -- and on mobile +-- GPUs they don't reliably (that fight is what put the Android port back on +-- flat water). Confined to the curve there is no regression to reach: the +-- flat world never had the far-shore bug in the first place. function VoxelScene.drawWater(draws, cast) - local plain = true + -- prepass only under the bend; see the header + local curved = (Voxel3D.curveK or 0) > 0 + if curved then + for _, d in ipairs(draws) do + Voxel3D.draw(d[1], d[2], d[3]) + end + end + local plain = not curved if Water.enabled() and Voxel3D.depthReadable() then local mirror, depth = Voxel3D.beginWater(cast) local w, h = Voxel3D.size() @@ -607,10 +705,12 @@ function VoxelScene.drawWater(draws, cast) -- Unconditionally, and OUTSIDE the success branch: beginWater unbinds -- the shader and the depth mode BEFORE it can discover it cannot go on, -- so a frame that bails halfway through has to be put back together - -- exactly like one that succeeded -- otherwise the plain draw below (and - -- every pass after it) runs with no shader and no depth test. + -- exactly like one that succeeded -- otherwise every pass after it runs + -- with no shader and no depth test. Voxel3D.endWater() end + -- the fallback flat draw -- unless the curve's prepass already put the + -- same meshes down, in which case a bailed frame is already whole if plain then for _, d in ipairs(draws) do Voxel3D.draw(d[1], d[2], d[3]) @@ -645,6 +745,10 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh) -- few times a minute rather than every frame. put(math.floor(ShadowMap.KX * 128)) put(math.floor(ShadowMap.KZ * 128)) + -- and the first-person head: the box is fitted around wherever it looks + -- and the sprite cards swap frames as it circles them, so a turn on the + -- spot re-fits and redraws exactly like a camera move ("" outside 1ST) + put(FirstPerson.signature()) put(tostring(terrain)) for i = 1, #nbMesh do put(tostring(nbMesh[i])) end for _, p in ipairs(posed) do @@ -717,7 +821,12 @@ local function castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, end for _, p in ipairs(posed) do local def = p.sprite.def - local frame, mirror = frameFor(def, p.facing, p.phase, p.flip) + -- viewFacing, exactly as the camera draw picks it (see viewFacing for + -- why the two passes must agree): in first person the sun's card + -- swaps frame as the eye circles, which costs a redraw the signature + -- already charges for (FirstPerson.signature) and keeps a card from + -- fringing against a mirror-flipped record of itself + local frame, mirror = frameFor(def, viewFacing(p), p.phase, p.flip) local mesh = SpriteBillboards.shadowQuad(def, frame) if mesh then ShadowMap.draw(mesh, p.sprite:resolveImage(), @@ -773,7 +882,23 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) end local posed, me = posesOf(state, spriteColors) - castShadows(state, terrain, nbMesh, posed, cx, cy, vw, vh, atlasFor, + + -- The first-person rig, built (or blended) for this frame and handed to + -- Voxel3D BEFORE either pass runs: the sun's box is fitted around this + -- camera, and every card matrix asks it which way to turn. With the + -- blend fully out the call clears the placed camera and the orbit is + -- exactly what it always was. The scene centre it returns walks from + -- the orbit's view centre into the head, so the curve's focus and the + -- depth reference follow the camera actually in charge. + local fpRig, fpCx, fpCy = FirstPerson.frame(me, cx, cy, vw, vh) + if fpRig then cx, cy = fpCx, fpCy end + + -- The sun's box, pushed along the first-person look so it covers the + -- ground THIS camera sees (a no-op at blend zero): the orbit's fit + -- reaches far north and barely south, which is right for every rung + -- but a head free to face south. + local shCx, shCy = FirstPerson.shadowCenter(cx, cy, vh) + castShadows(state, terrain, nbMesh, posed, shCx, shCy, vw, vh, atlasFor, water, nbWater) if not Voxel3D.beginScene(w, h, cx, cy, vw, vh, skyFor(state.map)) then @@ -796,7 +921,7 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) if not Voxel3D.shadowsActive() then Voxel3D.beginShadows() for _, p in ipairs(posed) do - drawShadow(p.sprite, p.px, p.py, p.facing, p.phase, p.flip, p.gh, + drawShadow(p.sprite, p.px, p.py, viewFacing(p), p.phase, p.flip, p.gh, p.lift) end Voxel3D.endShadows() @@ -842,7 +967,11 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor) -- wrote it, so the silhouette would paint over the player at all times. -- Every character then draws on top as usual, which leaves the silhouette -- showing in exactly one situation: where the world hides them. - if me then + -- + -- Not in first person: the card it silhouettes is the one the camera is + -- standing inside, and "the world is in front of the player" is every + -- wall the player faces. + if me and not FirstPerson.hidePlayer() then Voxel3D.beginGhost() drawGhost(me) Voxel3D.endGhost() diff --git a/lib/VoxelState.lua b/lib/VoxelState.lua index efad2ec..3e8bb45 100644 --- a/lib/VoxelState.lua +++ b/lib/VoxelState.lua @@ -32,8 +32,18 @@ local Voxel = {} -- Its ANGLE is 35 degrees, the same as the rung of that name. The duplicate -- in the table is deliberate: the ladder is a list of what each rung LOOKS -- like, and two rungs may look the same while meaning different things. -Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75 } -Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75" } +-- +-- 1ST is the other rung that is more than an angle: the camera steps off its +-- orbit entirely and stands in the player's own eyes (lib/FirstPerson.lua), +-- with free look and free movement. Its ANGLE entry is 75 -- the orbit rung +-- it hands over from -- because the tween in and out of first person starts +-- from whatever the orbit shows, and the lowest rung is the one a dive into +-- a head should start from. Everything angle-derived (the sky's fade, the +-- billboard lean the blend eases away) reads that 75 while the first-person +-- rig owns the actual camera. +Voxel.ANGLES_DEG = { 0, 35, 15, 35, 50, 75, 75 } +Voxel.ANGLE_LABELS = { "OFF", "FULL", "15", "35", "50", "75", + "1ST (EXPERIMENTAL)" } Voxel.MAX_LEVEL = #Voxel.ANGLES_DEG - 1 -- the rung FULL sits on, so nothing has to hunt for it by label @@ -43,6 +53,13 @@ function Voxel.isFull(level) return (level or Voxel.level) == Voxel.FULL_LEVEL end +-- the rung the first-person camera sits on, likewise +Voxel.FP_LEVEL = 6 + +function Voxel.isFirstPerson(level) + return (level or Voxel.level) == Voxel.FP_LEVEL +end + -- ------- what the hotkey walks -- -- The ANGLE rungs only, with FULL left out. The key is a display-mode @@ -51,7 +68,12 @@ end -- mid-walk, would silently turn the blur to maximum and flatten the horizon -- with no indication that a keypress had done so. FULL stays on the OPTIONS -- row, which is where a preset that changes other rows belongs. -Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5 } -- OFF, 15, 35, 50, 75 +-- +-- 1ST is on the path: it changes the camera and only the camera, which is +-- exactly what the key promises -- and the key is also the way back OUT of +-- first person on a keyboard, where the mouse is captured and the OPTIONS +-- menu is a trip. +Voxel.HOTKEY_ORDER = { 0, 2, 3, 4, 5, 6 } -- OFF, 15, 35, 50, 75, 1ST -- The rung a press moves to from `level`. -- diff --git a/lib/Water.lua b/lib/Water.lua index db1db8e..45ecd26 100644 --- a/lib/Water.lua +++ b/lib/Water.lua @@ -381,9 +381,32 @@ Water.EDGE_FADE = 0.14 -- reflection eased off over this much of the fra -- -- The scene shader's own vertex path, plus the world position the geometry -- was actually DRAWN at -- after the world curve, because that is the space --- the depth buffer holds and therefore the space the march has to walk in. --- (The curve only ever moves Y, so a fragment's world XZ is the same on both --- sides of it and the ripple can be measured off this one too.) +-- the surface the eye MEETS lives in: which wave column a screen pixel is +-- looking at is a question about the geometry as drawn, and relief() answers +-- it there. (The curve only ever moves Y, so a fragment's world XZ is the +-- same on both sides of it and the ripple can be measured off this one too.) +-- +-- WHAT IT REFLECTS is worked out on the other side of the bend, in the FLAT +-- world, and this is the same rule the rest of the mode keeps: the curve +-- tips the world away and the things standing on it do not lean with it (see +-- WorldCurve -- buildings stay upright, shadows are resolved before the bend +-- and ride along). A lake is one of those things. Reflect off the bowl the +-- bend has made instead and the far half of a pond is a mirror tilted twenty +-- degrees: it throws the ray past the vertical, where the sky ramp's own +-- measure -- a screen row, through the frame's matrix -- swings from one end +-- of the ramp to the other across a single column, and the pond comes out +-- with hard-edged patches of the wrong sky stamped into it -- the overhead +-- band and the horizon band abutting in the middle of a lake, which reads as +-- something other than water showing through. The same tilt sends the +-- screen-space march grazing along the bank instead of over it, which is the +-- other half: the dock and the roofs smeared across the harbour. +-- +-- So the reflection is taken with the flat view ray about the flat normal, +-- exactly as it would be with the curve off -- and the MARCH still has to +-- walk the world as drawn, because that is what the depth buffer holds. Both +-- at once: the ray is straight in the flat world, and project() bends each +-- sample on its way to the screen, which is the same displacement the vertex +-- stage applies and therefore lands in the same place the geometry did. local SHADER_SRC = [[ varying float vShade; varying vec3 vSun; @@ -435,6 +458,20 @@ uniform vec3 eye; uniform vec2 screen; // the canvas, in pixels uniform float cell; // one diorama pixel, in canvas pixels uniform float pxAngle; // radians of view one screen pixel subtends +// The same bend the vertex stage applied. This stage has to undo it to get +// back to the flat world it reflects in, and re-apply it on every marched +// sample to get back to the screen. Declared in both stages, like `vp`, and +// both are highp here. +uniform vec3 curve; // xy = the focus in world XZ, z = k; 0 = off + +// How far the bend has pushed the world down at world XZ `q` -- the vertex +// stage's own displacement, as a number this stage can add and subtract. +// Zero when the curve is off, which is the shader's "skip it" everywhere. +float bendDrop(vec2 q) { + if (curve.z <= 0.0) return 0.0; + vec2 d = q - curve.xy; + return dot(d, d) * curve.z; +} // the sun's own pass, exactly as the scene shader reads it uniform Image sunMap; @@ -658,10 +695,14 @@ vec3 bodyAt(vec3 d, vec3 c, float parity) { // clip-space Y flip is already baked into `vp`, and a canvas texture's v runs // the same way its pixel rows do, so one 0.5x+0.5 answers for both. // -// This is why the march walks in the world as DRAWN rather than as authored: -// the depth buffer holds the curved world, so a straight line in that space -// is the ray, and a straight line in the flat one would bend through it. +// The point arrives in the FLAT world -- the space the ray is straight in -- +// and is bent here, by the same displacement the vertex stage applied, so it +// lands exactly where the geometry it is being compared against landed. That +// split is the whole trick: the reflection is worked out in a world that has +// not been tipped, and every sample of it is tipped on the way to the screen, +// so the march reads the depth buffer it actually has. vec4 project(vec3 p) { + p.y -= bendDrop(p.xz); vec4 c = vp * vec4(p, 1.0); if (c.w <= 1e-6) return vec4(0.0, 0.0, 0.0, 0.0); return vec4(c.xy / c.w * 0.5 + 0.5, c.z / c.w * 0.5 + 0.5, 1.0); @@ -915,8 +956,33 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // the bound canvas's own pixel size, the same units sc is measured in, on // every display. (`screen` stays in units: skyPos reads it against cell // and skyEdge, which are unit-measured with it.) + // + // The buffer now holds THIS SURFACE too (VoxelScene draws the water flat + // before the pass that reflects it), which is what makes one lake able to + // hide another -- and it means every fragment here is testing against its + // own depth. That raises the bar on the fragment's own z: gl_FragCoord is + // allowed to be MEDIUMP on GLES (and is, on Adreno), and fp16 near the far + // end of the range steps by about half a thousandth -- which the old test + // against the terrain far behind the surface never felt, and a comparison + // of the surface against itself loses outright. Every fragment failed, the + // pass discarded the whole lake, and Android showed the flat draw + // underneath. So the depth is recomputed HERE, in highp, from the same + // vBent and vp the vertex stage used -- full precision on every driver. + // + // The slack is sized to what remains after that, which is not rounding: + // the buffer holds depth interpolated LINEARLY IN SCREEN SPACE, while the + // recomputation projects the perspective-interpolated vBent -- the exact + // answer. The two agree at the vertices and drift apart across a quad's + // interior, by more the bigger the quad stands on screen; on a phone + // (fit scale 6, water quads hundreds of pixels tall) the drift crosses + // 1e-5 mid-quad, which discarded the middle of every tile row and looked + // like flat water with reflective seams. Anything GENUINELY in front of a + // water pixel is whole world units nearer -- upward of 1e-3 in depth -- + // so 2e-4 clears the drift with room while still catching every occluder. vec2 uv = sc / love_ScreenSize.xy; - if (gl_FragCoord.z > Texel(depthTex, uv).r) discard; + vec4 selfC = vp * vec4(vBent, 1.0); + float selfZ = selfC.z / selfC.w * 0.5 + 0.5; + if (selfZ > Texel(depthTex, uv).r + 2e-4) discard; // THE COLUMN THIS FRAGMENT IS LOOKING AT. Every water pixel is a bar of // its own standing a whole number of pixels tall, and the ray decides @@ -925,12 +991,23 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { // drawn at, with no smooth shading anywhere across it. (The depth test // above is the one thing that stays per fragment: that is the hardware's // own question and it is asked in screen space.) - vec3 view = normalize(vBent - eye); + // + // Answered on the FLAT sheet, which is where the bars are a slab of even + // thickness over a level plane -- the one thing relief() is built on. The + // bend translates every bar straight down by its own column's drop, so the + // field keeps its shape and only its height moves; undo that here and the + // walk is the walk it was written for. Try it in the world as DRAWN + // instead and the slab is a bowl: the backward step up the ray climbs the + // bowl's near side as fast as it climbs out of the water, the walk starts + // inside the sheet, and it hands back a column a pixel or three off -- per + // fragment, differently, which is a patch of noise rather than parallax. + vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z); + vec3 view = normalize(sheet - eye); vec3 hit; vec2 col; float face; float axis; - relief(vBent, view, hit, col, face, axis); + relief(sheet, view, hit, col, face, axis); // and the bar's centre, so a column is sampled and reflected from one // place rather than from wherever inside it the fragment happened to land vec3 surf = vec3(col.x + 0.5, hit.y, col.y + 0.5); @@ -1001,7 +1078,7 @@ vec4 effect(mediump vec4 color, Image tex, mediump vec2 tc, mediump vec2 sc) { vec3 rgb = mix(base, refl, clamp(f, 0.0, 1.0)); #ifdef VOXEL_GRID - rgb *= 1.0 - gridDark * columnSeam(hit, vBent, axis); + rgb *= 1.0 - gridDark * columnSeam(hit, sheet, axis); #endif return vec4(rgb, 1.0) * color; } diff --git a/main.lua b/main.lua index 01aaf0f..6f50f7c 100644 --- a/main.lua +++ b/main.lua @@ -23,9 +23,16 @@ -- the engine's TILT mode -- is engine plumbing driven by the records -- below. This file declares; lib/ draws. -- --- Nothing here reaches collision, movement, triggers or scripts. Voxel --- mode is purely presentational: it changes what the world LOOKS like and --- nothing about what it IS. +-- Voxel mode is presentational: it changes what the world LOOKS like and +-- nothing about what it IS. ONE rung is the deliberate exception. 1ST -- +-- the first-person camera -- replaces the grid WALK with a free, +-- camera-relative one while it is selected (lib/FreeMove.lua), because a +-- head you can steer with a mouse demands feet that go where it looks. +-- Even there the game is untouched: the walk asks the engine's own +-- collision the same questions a grid step asks, keeps the player's +-- logical cell synced, and fires the engine's own landing pipeline per +-- cell crossed -- warps, encounters, ledges, gates and scripts all run +-- exactly as themselves. Step off the rung and the grid walk is back. local mod = ... @@ -83,6 +90,8 @@ local DayNight = V.require("DayNight") local DayTint = V.require("DayTint") local Water = V.require("Water") local AntiAlias = V.require("AntiAlias") +local FirstPerson = V.require("FirstPerson") +local FreeMove = V.require("FreeMove") -- Forward declaration: the voxel pipeline's update hook (registered below) -- calls this, and it is defined further down with the settings it drives. @@ -162,6 +171,11 @@ mod.content.render_pipelines:register("voxel", { -- would fight anyone who changed one deliberately. applyFull(level) Voxel.update(dt, level) + -- the first-person head, on the same tick: its blend in and out of the + -- orbit, the mouse capture lifecycle, and the frame's stick-rate look. + -- Unconditional like Voxel.update, because the blend has to keep easing + -- OUT after the rung is left + FirstPerson.update(dt) -- 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 @@ -789,6 +803,32 @@ end -- so this file keeps naming every engine seam the mod touches. OverworldBattle.install() +-- ------- the first-person rung's inputs and its walk +-- +-- 1ST needs two things no other rung does, and each is a named seam: +-- +-- FirstPerson.install claims the LOOK inputs the engine ignores: the right +-- stick's axes (Game:gamepadaxis passes them to Input, which returns early +-- on anything but the left pair), relative mouse motion (love.mousemoved -- +-- there is no Game handler to wrap; the engine's own callback only feeds +-- the mouse-as-touch debug path, which stays untouched), the mouse buttons +-- while the cursor is captured (A and B -- there is no cursor to click UI +-- with), and any touch that lands off the overlay's controls (a drag on +-- open screen is the look; the d-pad and buttons still go to +-- TouchControls, whose own d-pad finger is also read back analog as the +-- move vector). Every wrap forwards whatever it does not claim, and claims +-- only while 1ST is actually driving. +-- +-- FreeMove.install wraps OverworldState:handleInput -- the one choke point +-- where the grid walk reads the pad, and the same seam the engine's own +-- Cycling Road pull lives behind. While 1ST drives, the walk is continuous +-- and camera-relative; the player's logical cell stays synced and every +-- per-cell consequence still runs through the engine's own machinery +-- (onStepComplete, checkEdgeExit, checkLedgeHop, checkBoulderPush). The +-- file argues the whole arrangement. +FirstPerson.install() +FreeMove.install() + -- The overworld's own pushBattle is the choke point for a wild encounter or -- a trainer, and it is wrapped. A battle that arrives some other way -- a -- link battle, a script pushing a BattleState directly -- reaches this @@ -890,7 +930,7 @@ mod.hooks:wrap("world.tod", function(next, tod, ctx) return DayNight.tod() end) -mod.exports.version = "1.4.1" +mod.exports.version = "1.5.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 b2382d4..8ea928d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "DRAMATIC_SHAPE", "name": "Dramatic Shape Voxel Mod", - "version": "1.4.1", + "version": "1.5.0", "api": 2, "entry": "main.lua", "profile": "content", diff --git a/mod.card b/mod.card index af77b03..08ec65a 100644 --- a/mod.card +++ b/mod.card @@ -16,9 +16,11 @@ return { "a battle's letterbox voids go black rather than white, because the battle canvas is no longer white", "the engine's TILT and GBC FX rows are taken OFF the OPTIONS menu and held at off for as long as this mod is installed -- TILT is the flat fake of what this mode does for real, GBC FX is a full-screen pass over the top of it; uninstalling puts both rows back", "hotkeys 3 and 5 are taken over from those two, which have no key and no row while this is loaded", + "on the 1ST rung ONLY, the grid walk is replaced by free camera-relative movement: collision, warps, ledges, encounters and scripts still run through the engine's own machinery, and every other rung leaves movement untouched", + "on the 1ST rung the mouse cursor is captured for free look; left click is A, right click is B, and any touch off the overlay's controls drags the view", }, added = { - "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees)", + "VOXEL options row and hotkey 3 (OFF / 15 / 35 / 50 / 75 degrees / 1ST, a first-person camera with free look and free movement)", "T-SHIFT options row and hotkey 6 (OFF / 1 / 2 / 3), the miniature blur", "V-GRID on hotkey 5 and V-CURVE on hotkey 7", "WATER on hotkey 9 (FULL / SKY / OFF, FULL by default): the water surface becomes a field of pixel-tall voxel columns rising and falling as waves, reflecting the sky, the sun, the moon and the cast standing beside it -- and, on FULL, the shoreline, trees and buildings behind it, by a screen-space ray march", @@ -39,6 +41,9 @@ return { "the battle backdrop renders at the GB's 160x144 to match the pics composited over it, so it is chunkier than the free-roam pass", "menus and cutscenes are unaffected -- outside a battle the mode only draws the free-roam overworld", "terrain meshes are cached per map, so the first frame after entering a large map costs a build", + "1ST needs the 3D pass like every rung; without it the level still persists but the world stays 2D and the grid walk stays in charge", + "in 1ST, scripted walks, ledge hops and spinner slides play out as the grid moves they are, with the camera riding along; free control resumes when they land", + "rooms have no ceilings, so a first-person look over an interior wall shows the void the diorama always had behind it", }, }, credits = { diff --git a/tests/bike_shop_shots.lua b/tests/bike_shop_shots.lua new file mode 100644 index 0000000..7a896ed --- /dev/null +++ b/tests/bike_shop_shots.lua @@ -0,0 +1,79 @@ +-- Scratch driver: shots of the Bike Shop showroom, for the bicycle +-- voxelization. Two viewpoints -- the north wall (the two bikes drawn +-- INTO the wall band) and the showroom floor (the six standing bikes). +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bike_shop_shots.lua \ +-- SHOT_DIR=.scratchpad/bikes AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/bikes") + .. "/" .. (os.getenv("AB_TAG") or "before") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[bike] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- cells: wall bikes ride cell row 0 (tile cols 1-3 and 6-8); the six + -- floor bikes stand in cell columns 0 and 2, rows 1-2 and 4-5 + local SCENES = { + { x = 2, y = 2, face = "up", label = "wall" }, + { x = 3, y = 3, face = "up", label = "room" }, + { x = 2, y = 4, face = "left", label = "floor" }, + { x = 3, y = 6, face = "up", label = "wide" }, + -- the two toolboxes, cells (6,6) and (7,7) + { x = 5, y = 6, face = "right", label = "tools" }, + { x = 6, y = 4, face = "down", label = "tools2" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "BIKE_SHOP", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[bike] capture missed: " .. path) end + end + end + print(("[bike] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/bills_desk_shots.lua b/tests/bills_desk_shots.lua new file mode 100644 index 0000000..f6c7898 --- /dev/null +++ b/tests/bills_desk_shots.lua @@ -0,0 +1,77 @@ +-- Scratch driver: shots of Bill's desk, for the `bills_desk` +-- voxelization. The desk fills cells (1,4) and (2,4) of Bill's house +-- with its chair in the walkable cell (1,5) below it, so these are the +-- angles you can actually stand at: head-on from the floor two cells +-- south, and from either flank. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/bills_desk_shots.lua \ +-- SHOT_DIR=.scratchpad/billsdesk AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/billsdesk") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[bills] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { x = 1, y = 6, face = "up", label = "headon" }, + { x = 2, y = 6, face = "up", label = "headon_e" }, + { x = 4, y = 5, face = "left", label = "east" }, + { x = 0, y = 5, face = "right", label = "west" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "BILLS_HOUSE", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[bills] capture missed: " .. path) end + end + end + print(("[bills] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/chief_house_shots.lua b/tests/chief_house_shots.lua new file mode 100644 index 0000000..2fec3a8 --- /dev/null +++ b/tests/chief_house_shots.lua @@ -0,0 +1,81 @@ +-- Scratch driver: shots of the Celadon chief's house, for the display +-- cabinet and long table voxelizations. Three viewpoints -- the +-- cabinet rank along the north wall, the long table in the middle of +-- the room, and a wide shot with both in frame. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/chief_house_shots.lua \ +-- SHOT_DIR=.scratchpad/chief AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/chief") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[chief] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- the cabinets occupy cells 2..5 of rows 0-1; the long table cells + -- 2..5 of rows 3-4; the player walks rows 2 and 5 + local SCENES = { + { x = 3, y = 2, face = "up", label = "cabinets" }, + { x = 5, y = 2, face = "up", label = "bookcase" }, + { x = 3, y = 5, face = "up", label = "table" }, + { x = 1, y = 5, face = "right", label = "wide" }, + -- the same rank on CELADON_MANSION_1F, where it stands against the + -- interior partition and the grids start on an ODD tile row + { map = "CELADON_MANSION_1F", x = 2, y = 4, face = "up", + label = "mansion1f" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map or "CELADON_CHIEF_HOUSE", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[chief] capture missed: " .. path) end + end + end + print(("[chief] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 0ca9283..032b297 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -49,12 +49,14 @@ T.eq(defs._owners and defs._owners.voxel, "DRAMATIC_SHAPE", -- ------- the ladders the engine drives -T.eq(#defs.voxel.levels, 6, "voxel exposes a six-rung ladder") +T.eq(#defs.voxel.levels, 7, "voxel exposes a seven-rung ladder") T.eq(defs.voxel.levels[1], "OFF", "rung 0 is OFF") T.eq(defs.voxel.levels[2], "FULL", "FULL is the first rung after OFF -- the order those two get used in") -T.eq(defs.voxel.levels[6], "75", "the top rung is the 75-degree camera") -T.eq(Pipelines.maxLevel("voxel"), 5, "the engine reads the ladder height") +T.eq(defs.voxel.levels[6], "75", "rung 5 is the 75-degree camera") +T.eq(defs.voxel.levels[7], "1ST (EXPERIMENTAL)", + "the top rung is the first-person camera, labelled as the experiment it is") +T.eq(Pipelines.maxLevel("voxel"), 6, "the engine reads the ladder height") T.eq(Pipelines.levelLabel("voxel", 3), "35", "the engine reads the rung labels") -- ------- gating: inert until switched on, and inert without a GPU @@ -1079,12 +1081,12 @@ local Curve = run.loader.exports.DRAMATIC_SHAPE.lib.require("WorldCurve") -- with nothing on screen saying a keypress had done it. Pipelines.setLevel("voxel", 0) local walk = {} -for _ = 1, 6 do +for _ = 1, 7 do Game.keypressed(keyGame, "3") walk[#walk + 1] = Pipelines.levelLabel("voxel") end -T.eq(table.concat(walk, ","), "15,35,50,75,OFF,15", - "3 walks OFF -> 15 -> 35 -> 50 -> 75 and wraps, never touching FULL") +T.eq(table.concat(walk, ","), "15,35,50,75,1ST (EXPERIMENTAL),OFF,15", + "3 walks OFF -> 15 -> 35 -> 50 -> 75 -> 1ST and wraps, never touching FULL") -- FULL is 35 degrees, so a press from it goes ON to 50 rather than back to -- the rung that shows the same camera -- the key never appears to do nothing. @@ -1143,8 +1145,8 @@ T.eq(GBCFX.level, 0, "and on the live renderer") -- TILT with or without us. Park the ladder on its top rung and turn both -- back on, so the single press under test is the one that wraps to OFF -- -- where nothing else is going to clear them. --- 5 is the "75" rung, the last one the key walks before it wraps to OFF -Pipelines.setLevel("voxel", 5) +-- 6 is the "1ST" rung, the last one the key walks before it wraps to OFF +Pipelines.setLevel("voxel", 6) Tilt.setLevel(3) GBCFX.setLevel(4) keyGame.save.options.tilt = 3 @@ -1800,9 +1802,21 @@ T.check(plain:find("waveNormal(vec2 q, float tilt)", 1, true) ~= nil and plain:find("waveNormal(col,", 1, true) ~= nil, "still one answer per column, so the surface stays pixel-quantised in " .. "space while the value it reflects with is continuous") -T.check(plain:find("relief(vBent, view, hit, col, face, axis)", 1, true) ~= nil, +T.check(plain:find("relief(sheet, view, hit, col, face, axis)", 1, true) ~= nil, "and the visible column is found by walking the view ray through the " .. "slab, which is what makes a tall bar hide the short ones behind it") +-- ...over the FLAT sheet, which is the one thing that walk is built on: an +-- even slab over a level plane. The world curve drops each bar straight down +-- by its own column's drop, so undoing that drop hands relief() the field it +-- was written for. Walked in the world as DRAWN instead, the slab is a bowl: +-- the backward step up the ray climbs the bowl's near side as fast as it +-- climbs out of the water, the walk starts inside the sheet, and it returns a +-- column a pixel or three off -- differently per fragment, which is a +-- hard-edged patch of noise in the middle of a pond. +T.check(plain:find("vec3 sheet = vec3(vBent.x, vBent.y + bendDrop(vBent.xz), vBent.z)", + 1, true) ~= nil, + "and it walks the sheet the mesh was AUTHORED as, the bend taken back off, " + .. "because a slab walk over a bowl starts inside the water") -- the march's reach grows as one over the ray's descent, so a grazing camera -- asks for hundreds of world pixels of it from a fixed number of samples -- -- which stepped over whole crests and smeared the surface into streaks @@ -1830,7 +1844,7 @@ T.check(plain:find("waveUV(tc, col)", 1, true) ~= nil, "and the column is what is handed to it") -- the wireframe is ruled on the COLUMNS, not on the flat sheet they stand on -T.check(gridded:find("columnSeam(hit, vBent, axis)", 1, true) ~= nil, +T.check(gridded:find("columnSeam(hit, sheet, axis)", 1, true) ~= nil, "with V-GRID on, the seams outline the column the ray landed on -- every " .. "voxel of water its own block -- rather than ruling a grid across the " .. "flat quad underneath and ignoring the bars entirely") @@ -1839,6 +1853,25 @@ T.check(gridded:find("vec3 w = fwidth(base);", 1, true) ~= nil, .. "between neighbouring fragments and its own derivative is a step") T.check(plain:find("march(surf, r)", 1, true) ~= nil, "the reflection marches from that column, not from the raw fragment") +-- The two halves of the world curve, and they pull opposite ways. WHAT the +-- lake reflects is worked out FLAT -- the same rule the rest of the mode +-- keeps, that the world tips away and the things standing on it do not lean +-- with it. Reflect off the bowl the bend has made instead and the far half +-- of a pond is a mirror tilted twenty degrees, throwing the ray past the +-- vertical, where the sky ramp's own measure (a screen row, through the +-- frame's matrix) swings from one end of the ramp to the other across a +-- single column and stamps hard-edged patches of the wrong sky into the +-- water. But WHERE it lands has to be found in the world as DRAWN, because +-- that is what the depth buffer holds -- so the ray stays straight in the +-- flat world and every sample of it is bent on the way to the screen, by the +-- vertex stage's own displacement. +T.check(plain:find("p.y -= bendDrop(p.xz);", 1, true) ~= nil, + "and every marched sample is bent into the world as DRAWN before it is " + .. "projected, because that is the world the depth buffer holds") +T.check(plain:find("vec3 r = reflect(view, n);", 1, true) ~= nil + and plain:find("reflect(view, vec3(0.0, 1.0, 0.0))", 1, true) ~= nil, + "while the reflection itself is taken about the FLAT normal, so a curved " + .. "world does not tip the lake the way it does not lean the buildings") T.check(plain:find("mod(col.x + col.y, 2.0)", 1, true) ~= nil, "and the dither's checkerboard is cut from the columns too, so a camera " .. "pan slides the world through nothing") @@ -1884,6 +1917,21 @@ T.check(plain:find("sc / love_ScreenSize.xy", 1, true) ~= nil, .. "size -- `screen` counts canvas UNITS, and on a highdpi phone the two " .. "differ by the density, which clamped the lookup and cut the water " .. "into blocks") +-- ...and it tests against a buffer that now holds the water surface too, +-- because VoxelScene lays the meshes down flat before the reflective pass +-- runs over them. Nothing else can make one lake hide another: the pass +-- writes no depth (the buffer is detached so it can be READ), so before this +-- the sheets were simply painted in mesh order. Flat water never showed it -- +-- one plane, and a farther sheet always lands farther down the screen -- but +-- the world curve drops the far side of the map into the near field of view, +-- and a sea a hundred and fifty tiles away came out rasterised on top of the +-- pond at the player's feet, tall grass and all. +T.check(plain:find("vec4 selfC = vp * vec4(vBent, 1.0)", 1, true) ~= nil + and plain:find("Texel(depthTex, uv).r + 2e-4", 1, true) ~= nil, + "testing a HIGHP recomputed depth (gl_FragCoord.z is mediump on mobile " + .. "GLES -- fp16 loses a self-comparison outright) with slack covering " + .. "the buffer's screen-linear interpolation drift across big quads") +T.check(VoxelScene.drawWater ~= nil, "and the flat draw that puts it there") -- ------- the lift itself -- @@ -3290,7 +3338,10 @@ Commands.start_battle(ctx, "trainer", "RIVAL1", 1) T.check(viaOverworld ~= nil and pushed == nil, "a scripted trainer goes through pushBattle: the flash, the wipe and the " .. "theme, like any fight walked into") -T.eq(viaOverworld.kind, "trainer", "with the battle it was asked to start") +-- guarded index: when the seam above regresses, this must FAIL like any +-- assertion rather than crash the suite half-run +T.eq(viaOverworld and viaOverworld.kind, "trainer", + "with the battle it was asked to start") ctx.overworld = nil Commands.start_battle(ctx, "wild", "PIDGEY", 5) T.check(pushed ~= nil, @@ -3416,6 +3467,182 @@ 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 +-- ------- the first-person rung +-- +-- 1ST rides the same placed-camera seam the battle proved out, so most of +-- what it adds is arithmetic this suite can hold still: the rig built from +-- a pose, the compass the grid game still thinks in, the camera-relative +-- walk vector, and the frame an NPC shows an eye that can stand anywhere. + +do +local FirstPerson = + run.loader.exports.DRAMATIC_SHAPE.lib.require("FirstPerson") +local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +local FreeMove = run.loader.exports.DRAMATIC_SHAPE.lib.require("FreeMove") +local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D") + +T.eq(VoxelState.FP_LEVEL, 6, "1ST is the seventh rung") +T.check(VoxelState.isFirstPerson(6), "and isFirstPerson answers for it") +T.check(not VoxelState.isFirstPerson(5), "but not for the 75 orbit") +T.eq(VoxelState.ANGLE_LABELS[VoxelState.FP_LEVEL + 1], "1ST (EXPERIMENTAL)", + "the rung wears the experimental label") +T.eq(VoxelState.ANGLES_DEG[VoxelState.FP_LEVEL + 1], 75, + "and hands the blend the 75-degree orbit as its far end") + +-- ------- the compass and the walk vector +-- +-- Yaw 0 faces south (+Z), the way a resting sprite faces; the compass is +-- the dominant axis and the walk rotates camera space into world space. +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +T.eq(FirstPerson.compassFacing(), "down", "yaw 0 looks south") +FirstPerson.yaw = math.pi / 2 +T.eq(FirstPerson.compassFacing(), "right", "a quarter turn looks east") +FirstPerson.yaw = math.pi +T.eq(FirstPerson.compassFacing(), "up", "a half turn looks north") +FirstPerson.yaw = -math.pi / 2 +T.eq(FirstPerson.compassFacing(), "left", "and three quarters looks west") + +local function near(a, b) return math.abs(a - b) < 1e-9 end + +FirstPerson.yaw = 0 +local wx, wz = FirstPerson.moveWorld(0, 1) +T.check(near(wx, 0) and near(wz, 1), "facing south, forward walks south") +wx, wz = FirstPerson.moveWorld(1, 0) +T.check(near(wx, -1) and near(wz, 0), + "facing south, the right hand points west") +FirstPerson.yaw = math.pi +wx, wz = FirstPerson.moveWorld(0, 1) +T.check(near(wx, 0) and near(wz, -1), "facing north, forward walks north") +wx, wz = FirstPerson.moveWorld(1, 0) +T.check(near(wx, 1) and near(wz, 0), + "facing north, the right hand points east") + +-- the look clamps: pitch stops at the limits, yaw wraps +FirstPerson.pitch = 0 +FirstPerson.lookBy(0, 100) +T.eq(FirstPerson.pitch, FirstPerson.PITCH_DOWN, "pitch clamps looking down") +FirstPerson.lookBy(0, -100) +T.eq(FirstPerson.pitch, FirstPerson.PITCH_UP, "and looking up") +FirstPerson.yaw = 0 +FirstPerson.lookBy(2 * math.pi, 0) +T.check(math.abs(FirstPerson.yaw) < 1e-9, "a full turn of yaw wraps to zero") + +-- ------- the rig +-- +-- frame() is pure arithmetic over the pose and the orbit, so the suite can +-- stand the blend anywhere and read the record it hands Voxel3D. +FirstPerson.yaw, FirstPerson.pitch = 0, 0 +FirstPerson.blend = 1 +local me = { px = 100, py = 200, gh = 0, lift = 0 } +local rig, sx, sy = FirstPerson.frame(me, 500, 600, 320, 288) +T.check(rig ~= nil, "with the blend in, frame() builds a rig") +T.eq(Voxel3D.camera, rig, "and hands it to Voxel3D") +T.check(near(rig.eye[1], 108) and near(rig.eye[3], 208), + "the eye stands on the player's centre") +T.eq(rig.eye[2], FirstPerson.EYE_HEIGHT, "at head height") +T.check(near(rig.fov, FirstPerson.FOV), "wearing the first-person lens") +T.check(near(sx, 108) and near(sy, 208), + "and the scene centre stands with it") +T.check(rig.curve == 0, + "the world curve is declined outright -- a zero, not a nil the setting " + .. "could override") +T.check(rig.focus[3] > rig.eye[3], + "yaw 0 focuses south of the eye") + +-- surf bob and ledge lift carry the eye with them +local bobbed = FirstPerson.frame({ px = 100, py = 200, gh = 4, lift = 6 }, + 500, 600, 320, 288) +T.eq(bobbed.eye[2], 10 + FirstPerson.EYE_HEIGHT, + "ground height and lift both raise the eye") + +-- mid-blend, the rig is a lerp of the orbit and the head: its eye sits +-- between the two ends, and the curve is only half declined +VoxelState.angle = math.rad(75) +FirstPerson.blend = 0.5 +local mid = FirstPerson.frame(me, 500, 600, 320, 288) +T.check(mid.eye[2] > FirstPerson.EYE_HEIGHT, + "half way out, the eye is higher than the head") +T.check(mid.eye[2] < 288 * VoxelState.FOCAL, + "and lower than the orbit") + +-- ------- the cards ask the rig, and only the rig +FirstPerson.blend = 1 +FirstPerson.frame(me, 500, 600, 320, 288) +T.check(FirstPerson.cardBlend() == 1, "the free-roam rig turns the cards") +T.check(FirstPerson.hidePlayer(), "and hides the player's own card") + +-- an NPC south of the eye: the card yaws to face north, back at the eye +local yaw = FirstPerson.cardYaw(108, 300) +T.check(near(math.sin(yaw), 0) and near(math.cos(yaw), -1), + "a card south of the eye turns its face north") + +-- the frame an entity SHOWS this eye: stand north of someone facing away +-- and you see their back; face to face you see their front; flanks show +-- profiles, named by which flank is toward you +T.eq(FirstPerson.apparentFacing("down", 108, 300), "up", + "an NPC facing south, seen from the north, shows their back") +T.eq(FirstPerson.apparentFacing("up", 108, 300), "down", + "an NPC facing north, seen from the north, shows their face") +T.eq(FirstPerson.apparentFacing("right", 108, 300), "left", + "an NPC facing east, seen from the north, shows their left flank") +T.eq(FirstPerson.apparentFacing("left", 108, 300), "right", + "an NPC facing west, seen from the north, shows their right flank") + +-- another camera on the same seam -- the battle's placed shot -- and the +-- cards stand down: blend still 1, but it is not our rig drawing +local battleCam = { eye = { 0, 40, 120 }, focus = { 0, 8, 0 }, + fov = math.rad(30) } +Voxel3D.camera = battleCam +T.eq(FirstPerson.cardBlend(), 0, + "a battle camera on the seam turns no cards") +T.check(not FirstPerson.hidePlayer(), + "and hides nobody") + +-- blend fully out: frame() clears only a camera that is still ours +FirstPerson.blend = 0 +T.eq(FirstPerson.frame(me, 500, 600, 320, 288), nil, + "with the blend out, frame() answers nil and the orbit rules") +T.eq(Voxel3D.camera, battleCam, + "without touching a camera some other pass placed") +Voxel3D.camera = nil + +-- ------- the free walk's per-cell verdict +-- +-- The same questions Collision asks a grid step, asked per cell the body +-- overlaps -- through a map stub shaped like the engine's own. +local blocked = FreeMove._blockedCell +local stubMap = { + def = { tileset = "OVERWORLD" }, + inBounds = function(self, x, y) + return x >= 0 and y >= 0 and x < 10 and y < 10 + end, + isWalkableCell = function(self, x, y) return x ~= 3 end, + isWaterCell = function(self, x, y) return x == 3 and y == 3 end, + cellTile = function() return 0 end, +} +local p = { cellX = 5, cellY = 5, surfing = false } +local state = { map = stubMap, entities = {} } + +T.eq(blocked(state, p, 5, 5), nil, "the body's own cell never refuses it") +T.eq(blocked(state, p, 6, 5), nil, "an open neighbour admits it") +T.eq(blocked(state, p, 3, 5), "tile", "an unwalkable cell refuses it") +T.eq(blocked(state, p, -1, 5), "bounds", "off the map is the edge's answer") +T.eq(blocked(state, p, 3, 3), "tile", "water refuses a walker") +p.surfing = true +T.eq(blocked(state, p, 3, 3), nil, "and admits a surfer") +p.surfing = false + +state.entities = { { cellX = 6, cellY = 5 } } +T.eq(blocked(state, p, 6, 5), "entity", "an occupied cell refuses the body") +state.entities = { { cellX = 7, cellY = 5, targetX = 6, targetY = 5 } } +T.eq(blocked(state, p, 6, 5), "entity", + "and so does one an NPC is mid-step into") + +-- the ladder's own state, put back the way the suite found it +FirstPerson.blend = 0 +VoxelState.reset() +end + Pipelines.reset() run.release() diff --git a/tests/fp_shots.lua b/tests/fp_shots.lua new file mode 100644 index 0000000..e972bfe --- /dev/null +++ b/tests/fp_shots.lua @@ -0,0 +1,159 @@ +-- Scratch driver: shots of the 1ST (first-person) rung -- the rig standing +-- in the player's head, billboards yawing to face it, the sky meeting the +-- horizon, the shadow box following the look, water seen from eye level, +-- and an interior with its figures. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/fp_shots.lua \ +-- SHOT_DIR=.scratchpad/fpshots lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/fp") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[fp] DRAMATIC_SHAPE mod not loaded") + return love.event.quit() + end + local V = handle.lib + local FirstPerson = V.require("FirstPerson") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local DayNight = V.require("DayNight") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 0 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if FirstPerson.blend >= 1 and Voxel.ready + and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- Teleport to the nearest WALKABLE cell: a guessed coordinate inside a + -- building footprint buries the eye in the geometry, which is what the + -- first cut of every Pallet shot did. + local function place(mapId, x, y) + U.teleport(game, mapId, x, y, "down") + local ow = game.stack:top() + local map = ow and ow.map + if not map or map:isWalkableCell(x, y) then return end + for r = 1, 8 do + for dy = -r, r do + for dx = -r, r do + if math.max(math.abs(dx), math.abs(dy)) == r then + local cx, cy = x + dx, y + dy + if map:inBounds(cx, cy) and map:isWalkableCell(cx, cy) then + U.teleport(game, mapId, cx, cy, "down") + print(("[fp] (%d,%d) not walkable; standing at (%d,%d)") + :format(x, y, cx, cy)) + return + end + end + end + end + end + end + + -- yaw is a world bearing: 0 south, pi/2 east, pi north, -pi/2 west + local SCENES = { + -- Pallet Town, mid-street: houses, the lab, NPCs -- the town seen + -- from inside it, in each compass direction plus a diagonal + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, label = "pallet_north" }, + { map = "PALLET_TOWN", x = 13, y = 14, yaw = 0, label = "pallet_south" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = math.pi / 2, label = "pallet_east" }, + { map = "PALLET_TOWN", x = 9, y = 7, yaw = 3 * math.pi / 4, + label = "pallet_diag" }, + -- the shoreline: water at eye level, which is where the battle pass + -- says a low placed camera reads the reflection wrong -- the shot + -- decides whether 1ST keeps it + { map = "PALLET_TOWN", x = 9, y = 12, yaw = 0, label = "pallet_water" }, + -- looking up: the sky's bands and the horizon line + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = -math.rad(25), label = "pallet_skyward" }, + -- and down: the ground, the feet-level shadow + { map = "PALLET_TOWN", x = 13, y = 14, yaw = math.pi, + pitch = math.rad(45), label = "pallet_down" }, + -- Route 1: grass rows and ledges from inside them + { map = "ROUTE_1", x = 10, y = 28, yaw = math.pi, label = "route1_north" }, + -- an interior: the Center's counter, machines and couch figures + { map = "VIRIDIAN_POKECENTER", x = 3, y = 5, yaw = math.pi, + label = "center_north" }, + { map = "VIRIDIAN_POKECENTER", x = 6, y = 4, yaw = -math.pi / 2, + label = "center_west" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + place(s.map, s.x, s.y) + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + Pipelines.setLevel("tiltshift", 0) + settle() + FirstPerson.yaw = s.yaw + FirstPerson.pitch = s.pitch or FirstPerson.PITCH_DEFAULT + U.wait(20) + if U.shot(game, ("%s/%s.png"):format(ROOT, s.label)) then + shots = shots + 1 + end + end + + -- one mid-blend shot: step the ladder onto 1ST from 75 and catch the + -- dive halfway + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", 5) + settle() + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + for _ = 1, 300 do + if FirstPerson.blend >= 0.5 then break end + U.wait(1) + end + if U.shot(game, ROOT .. "/blend_mid.png") then shots = shots + 1 end + + -- ------- the free walk, exercised + -- + -- Hold forward with the head yawed off-grid and confirm the player + -- GLIDES: the position moves along the look direction, lands off the + -- 16px grid (which no grid step can do), and the logical cell follows. + place("PALLET_TOWN", 13, 14) + Pipelines.setLevel("voxel", Voxel.FP_LEVEL) + settle() + local ow = game.stack:top() + local p = ow.player + FirstPerson.yaw = 3 * math.pi / 4 -- northeast, deliberately off-grid + FirstPerson.pitch = FirstPerson.PITCH_DEFAULT + local x0, y0, c0x, c0y = p.px, p.py, p.cellX, p.cellY + U.hold(game, "up", 90) + U.wait(5) + local moved = math.abs(p.px - x0) + math.abs(p.py - y0) + print(("[fp] walk: (%.1f,%.1f) cell(%d,%d) -> (%.1f,%.1f) cell(%d,%d)") + :format(x0, y0, c0x, c0y, p.px, p.py, p.cellX, p.cellY)) + print(("[fp] walk moved %.1f px; off-grid: %s; diagonal: %s") + :format(moved, + tostring(p.px % 16 ~= 0 or p.py % 16 ~= 0), + tostring(math.abs(p.px - x0) > 8 + and math.abs(p.py - y0) > 8))) + if U.shot(game, ROOT .. "/walked.png") then shots = shots + 1 end + + print(("[fp] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/heal_machine_shots.lua b/tests/heal_machine_shots.lua new file mode 100644 index 0000000..7f148cd --- /dev/null +++ b/tests/heal_machine_shots.lua @@ -0,0 +1,78 @@ +-- Scratch driver: shots of the Pokemon Center healing machines behind +-- the counter, for the center_heal_machine voxelization. The pair +-- stands at cells (1,0):(2,1) and (6,0):(7,1) of every Center; the +-- nurse aisle (row 2) is the row you can actually face them from, and +-- the public floor south of the counter gives the wide view. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/heal_machine_shots.lua \ +-- SHOT_DIR=.scratchpad/healshots AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/healmachine") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[heal] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { x = 1, y = 2, face = "up", label = "west_head" }, + { x = 2, y = 2, face = "up", label = "west_keyboard" }, + { x = 3, y = 2, face = "left", label = "west_side" }, + { x = 6, y = 2, face = "up", label = "east_head" }, + { x = 3, y = 4, face = "up", label = "wide" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "VIRIDIAN_POKECENTER", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[heal] capture missed: " .. path) end + end + end + print(("[heal] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/house_furniture_shots.lua b/tests/house_furniture_shots.lua new file mode 100644 index 0000000..4a3a136 --- /dev/null +++ b/tests/house_furniture_shots.lua @@ -0,0 +1,98 @@ +-- Scratch driver: shots of the house dining tables and stools, for the +-- band-table + no-desk-part voxelization. Every generic home places the +-- table at cells (3,3):(4,4) with four stools around it (Blue's house has +-- Daisy seated at hers); Red's and the Copycat's ground floors place the +-- same furniture one cell lower, with the potted plant CUTOUT standing on +-- the tabletop -- the standee the table template must support, not +-- swallow. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/house_furniture_shots.lua \ +-- SHOT_DIR=.scratchpad/housefurn AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/housefurn") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[housefurn] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + -- head on: below Blue's table looking north over a stool at it, + -- Daisy seated at the left one + { map = "BLUES_HOUSE", x = 3, y = 5, face = "up", label = "blues_front" }, + -- from the east, table and both east stools in profile + { map = "BLUES_HOUSE", x = 6, y = 3, face = "left", label = "blues_side" }, + -- from the north wall looking south down over the tabletop + { map = "BLUES_HOUSE", x = 4, y = 2, face = "down", label = "blues_over" }, + -- close beside a stool: seat top, legs and the gap between them + { map = "BLUES_HOUSE", x = 2, y = 5, face = "up", label = "stool_close" }, + -- Red's table head on from the south: the plant cutout standing on + -- the modelled tabletop + { map = "REDS_HOUSE_1F", x = 4, y = 6, face = "up", label = "reds_front" }, + -- and from the east along the stool row, plant in profile + { map = "REDS_HOUSE_1F", x = 6, y = 4, face = "left", label = "reds_side" }, + -- the Fan Club's four members' chairs round the boardroom table: + -- from the south of the west pair, both stools stacked in profile + { map = "POKEMON_FAN_CLUB", x = 1, y = 5, face = "up", + label = "club_west_pair" }, + -- across the table from the west, both pairs and the octagon between + { map = "POKEMON_FAN_CLUB", x = 0, y = 3, face = "right", + label = "club_across" }, + -- close on the east pair from the north, looking down over the seats + { map = "POKEMON_FAN_CLUB", x = 6, y = 2, face = "down", + label = "club_over" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[housefurn] capture missed: " .. path) end + end + end + print(("[housefurn] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/mart_shots.lua b/tests/mart_shots.lua new file mode 100644 index 0000000..428c4dc --- /dev/null +++ b/tests/mart_shots.lua @@ -0,0 +1,76 @@ +-- Scratch driver: shots of a Poke Mart's clerk counter, for the cash +-- register voxelization. The register is drawn at cell (1,5) of the 4x4 +-- shop layout every Mart shares, in the middle of the counter's east arm, +-- so these are the three angles you can actually stand at: head-on from +-- the aisle, side-on from the east, and over the counter's south arm. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mart_shots.lua \ +-- SHOT_DIR=.scratchpad/register AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/register") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[mart] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { x = 1, y = 7, face = "up", label = "aisle" }, + { x = 2, y = 5, face = "left", label = "side" }, + { x = 2, y = 6, face = "left", label = "over" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "VIRIDIAN_MART", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[mart] capture missed: " .. path) end + end + end + print(("[mart] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/potted_plant_shots.lua b/tests/potted_plant_shots.lua new file mode 100644 index 0000000..533bdba --- /dev/null +++ b/tests/potted_plant_shots.lua @@ -0,0 +1,91 @@ +-- Scratch driver: shots of the potted plants, for the plant standee +-- voxelization. Every Center places three side-by-side pairs on its +-- bottom row -- crowns at cells (0,6)/(1,6), (6,6)/(7,6), (12,6)/(13,6), +-- pots below at y=7 -- and INDIGO_PLATEAU_LOBBY (the MART tileset id, +-- same atlas) lines four of them along its hall at cells (12,10)..(15,10). +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/potted_plant_shots.lua \ +-- SHOT_DIR=.scratchpad/plants AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/plants") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[plant] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + -- east of the left pair, looking west along the bottom row: both + -- plants in profile, crown overhang and pot silhouette side-on + { map = "VIRIDIAN_POKECENTER", x = 3, y = 6, face = "left", + label = "pair_side" }, + -- north of the left pair, looking south down over the crowns + { map = "VIRIDIAN_POKECENTER", x = 1, y = 5, face = "down", + label = "pair_over" }, + -- head on: standing below the middle pair looking north at it + { map = "VIRIDIAN_POKECENTER", x = 6, y = 7, face = "up", + label = "pair_front" }, + -- close up beside the east pair's pot + { map = "VIRIDIAN_POKECENTER", x = 11, y = 7, face = "right", + label = "close" }, + -- the Plateau lobby's row of four (MART tileset id), along the row + { map = "INDIGO_PLATEAU_LOBBY", x = 11, y = 10, face = "right", + label = "lobby_row" }, + -- and head on from the hall below + { map = "INDIGO_PLATEAU_LOBBY", x = 13, y = 12, face = "up", + label = "lobby_front" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[plant] capture missed: " .. path) end + end + end + print(("[plant] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/roof_curve_shots.lua b/tests/roof_curve_shots.lua new file mode 100644 index 0000000..71c06b0 --- /dev/null +++ b/tests/roof_curve_shots.lua @@ -0,0 +1,80 @@ +-- Scratch driver: the Cerulean gym and the houses beside it, shot at +-- several camera rungs with V-CURVE walked OFF..3, to see what the world +-- bend does to a building's roof. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_curve_shots.lua \ +-- SHOT_DIR=.scratchpad/roofcurve AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/roofcurve") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[roof] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local WorldCurve = V.require("WorldCurve") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(30) + end + + -- the gym door is CERULEAN_CITY (30,19); the bike shop and the row of + -- houses along the west side give a second, smaller roof in frame + local SCENES = { + { map = "CERULEAN_CITY", x = 30, y = 20, face = "up", label = "gym" }, + { map = "CERULEAN_CITY", x = 27, y = 21, face = "up", label = "gymwide" }, + { map = "PALLET_TOWN", x = 5, y = 6, face = "up", label = "house" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + for _, curve in ipairs({ 0, 3 }) do + U.teleport(game, s.map, s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + WorldCurve.setting:setIndex(curve + 1, game) + settle() + local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve) + game.capturePath = path + U.wait(8) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[roof] capture missed: " .. path) end + end + end + end + print(("[roof] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/roof_span_probe.lua b/tests/roof_span_probe.lua new file mode 100644 index 0000000..99e9563 --- /dev/null +++ b/tests/roof_span_probe.lua @@ -0,0 +1,47 @@ +-- Scratch probe: how long do a building model's merged quads get? +-- +-- A quad's longest world-space edge is what decides how far its CHORD +-- falls below the world curve's parabola, so this is the number that says +-- whether the bend can crack the mesh open. +-- +-- BUILD_MAP=CERULEAN_CITY POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/roof_span_probe.lua lovec . +return function(game) + local U = dofile("tests/drivers/util.lua") + local mapId = os.getenv("BUILD_MAP") or "CERULEAN_CITY" + U.teleport(game, mapId, tonumber(os.getenv("BUILD_X") or "30"), + tonumber(os.getenv("BUILD_Y") or "20"), "up") + U.wait(30) + + local V = game.mods.exports["DRAMATIC_SHAPE"] + V = V and V.lib + local Structures = V and V.require("Structures") + local ow = game.overworld + if not (Structures and ow and ow.map) then + print("[span] mod or map unavailable") + love.event.quit() + return + end + local S = Structures.forMap(ow.map) + local hist, worst = {}, 0 + for _, q in ipairs(S.objectQuads) do + local dx = math.max(q[1][1], q[2][1], q[3][1], q[4][1]) + - math.min(q[1][1], q[2][1], q[3][1], q[4][1]) + local dz = math.max(q[1][3], q[2][3], q[3][3], q[4][3]) + - math.min(q[1][3], q[2][3], q[3][3], q[4][3]) + local dy = math.max(q[1][2], q[2][2], q[3][2], q[4][2]) + - math.min(q[1][2], q[2][2], q[3][2], q[4][2]) + local span = math.max(dx, dz, dy) + local bucket = span <= 8 and "<=8" or (span <= 16 and "<=16" + or (span <= 32 and "<=32" or (span <= 64 and "<=64" or ">64"))) + bucket = bucket .. (q.own and " bld" or " prop") + hist[bucket] = (hist[bucket] or 0) + 1 + if span > worst then worst = span end + end + print(("[span] %d object quads, longest edge %d px"):format(#S.objectQuads, worst)) + for _, b in ipairs({ "<=8", "<=16", "<=32", "<=64", ">64" }) do + for _, kind in ipairs({ " bld", " prop" }) do + print(("[span] %-10s %d"):format(b .. kind, hist[b .. kind] or 0)) + end + end + love.event.quit() +end diff --git a/tests/round_regress_shots.lua b/tests/round_regress_shots.lua new file mode 100644 index 0000000..246b302 --- /dev/null +++ b/tests/round_regress_shots.lua @@ -0,0 +1,75 @@ +-- Scratch driver: one shot of every OTHER user of the round-hull builder +-- (tree canopies, boulders, hedges, stumps, the Center planter), to check +-- that the `can` class's base cut is the identity it is supposed to be for +-- everything that does not ask for it. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/round_regress_shots.lua \ +-- SHOT_DIR=.scratchpad/round AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/round") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then return end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { map = "VIRIDIAN_FOREST", x = 16, y = 20, face = "up", label = "forest" }, + { map = "PEWTER_GYM", x = 4, y = 10, face = "up", label = "boulders" }, + { map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "hedges" }, + { map = "VIRIDIAN_POKECENTER", x = 6, y = 5, face = "up", label = "planter" }, + { map = "PALLET_TOWN", x = 5, y = 8, face = "up", label = "trees" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face) + if ok then + Pipelines.setLevel("voxel", 5) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s.png"):format(ROOT, s.label) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[round] capture missed: " .. path) end + else + print("[round] teleport failed: " .. s.map) + end + end + print(("[round] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/shelf_relief_shots.lua b/tests/shelf_relief_shots.lua new file mode 100644 index 0000000..633fe37 --- /dev/null +++ b/tests/shelf_relief_shots.lua @@ -0,0 +1,92 @@ +-- Scratch driver: shots of the `bookcase` class across the tilesets that +-- pin it, for the shelf-front relief. Two of them are NOT shelves -- +-- the League's gate walls and the terraces on PLATEAU -- and are here as +-- the control: their courses run edge to edge, so nothing should sink. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shelf_relief_shots.lua \ +-- SHOT_DIR=.scratchpad/shelves AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/shelves") + .. "/" .. (os.getenv("AB_TAG") or "before") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[shelf] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { map = "OAKS_LAB", x = 7, y = 2, face = "up", label = "dojo_lab" }, + { map = "CELADON_MANSION_2F", x = 2, y = 4, face = "up", label = "mansion_2f" }, + { map = "CELADON_MART_2F", x = 5, y = 5, face = "up", label = "lobby_mart" }, + { map = "MUSEUM_1F", x = 2, y = 4, face = "up", label = "museum" }, + { map = "VIRIDIAN_MART", x = 3, y = 5, face = "up", label = "mart" }, + { map = "SS_ANNE_CAPTAINS_ROOM", x = 5, y = 2, face = "up", label = "ship" }, + -- the controls, both of them tilesets that borrow the collapse for + -- something that is NOT a shelf and say so with `bookcase_relief = + -- false`: the League's masonry gate walls, and Bill's transporter + -- drums. Nothing in either may move. + { map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up", label = "plateau" }, + { map = "BILLS_HOUSE", x = 2, y = 3, face = "up", label = "bills" }, + -- the house shelves: pinned `desk`, NOT `bookcase`, so they go + -- through the world mesher's box fold and this relief never reaches + -- them. Here to show the gap. + { map = "REDS_HOUSE_1F", x = 1, y = 2, face = "up", label = "reds" }, + { map = "BLUES_HOUSE", x = 1, y = 2, face = "up", label = "blues" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face) + -- twice: the first load of the session has no mesh to settle against + pcall(U.teleport, game, s.map, s.x, s.y, s.face) + if ok then + Pipelines.setLevel("voxel", 5) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s.png"):format(ROOT, s.label) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[shelf] capture missed: " .. path) end + else + print("[shelf] teleport failed: " .. s.map) + end + end + print(("[shelf] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/ship_can_shots.lua b/tests/ship_can_shots.lua new file mode 100644 index 0000000..6eb535f --- /dev/null +++ b/tests/ship_can_shots.lua @@ -0,0 +1,78 @@ +-- Scratch driver: shots of the SS Anne's galley barrels, which are Lt. +-- Surge's trash can redrawn on the ship atlas. Three down the kitchen's +-- east wall at cells (13,5)/(13,7)/(13,9), one in the captain's room at +-- (4,1), and one each in the two ship-interior houses at (7,7). +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/ship_can_shots.lua \ +-- SHOT_DIR=.scratchpad/ssanne AB_TAG=after lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/ssanne") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[ship] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + { map = "SS_ANNE_KITCHEN", x = 12, y = 11, face = "up", label = "galley_up" }, + { map = "SS_ANNE_KITCHEN", x = 12, y = 3, face = "down", label = "galley_down" }, + { map = "SS_ANNE_KITCHEN", x = 11, y = 7, face = "right", label = "galley_side" }, + { map = "SS_ANNE_CAPTAINS_ROOM", x = 4, y = 4, face = "up", label = "captain" }, + { map = "CERULEAN_BADGE_HOUSE", x = 6, y = 7, face = "right", label = "house" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + local ok = pcall(U.teleport, game, s.map, s.x, s.y, s.face) + if ok then + Pipelines.setLevel("voxel", 5) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s.png"):format(ROOT, s.label) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[ship] capture missed: " .. path) end + else + print("[ship] teleport failed: " .. s.map) + end + end + print(("[ship] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/trash_can_shots.lua b/tests/trash_can_shots.lua new file mode 100644 index 0000000..408165e --- /dev/null +++ b/tests/trash_can_shots.lua @@ -0,0 +1,80 @@ +-- Scratch driver: shots of Vermilion Gym's trash cans, for the trash can +-- voxelization. The fifteen cans stand on odd cell columns 1..9 in cell +-- rows 7, 9 and 11; the sixteenth is up at cell (6,1) beside the leader's +-- platform. Even columns are open floor, so the player can be parked +-- between two cans and look along a row. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/trash_can_shots.lua \ +-- SHOT_DIR=.scratchpad/cans AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/cans") + .. "/" .. (os.getenv("AB_TAG") or "before") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[can] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync("day") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + local SCENES = { + -- head on into the middle of the field, cans left, right and ahead + { x = 4, y = 12, face = "up", label = "field" }, + -- close up: standing between two cans of the bottom row + { x = 2, y = 11, face = "left", label = "close" }, + -- along the row, so the cans line up in depth + { x = 4, y = 13, face = "up", label = "row" }, + -- from the north, looking back down over all three rows + { x = 4, y = 6, face = "down", label = "over" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + for _, rung in ipairs({ 3, 5 }) do + U.teleport(game, "VERMILION_GYM", s.x, s.y, s.face) + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_v%d.png"):format(ROOT, s.label, rung) + game.capturePath = path + U.wait(6) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[can] capture missed: " .. path) end + end + end + print(("[can] %d shots into %s"):format(shots, ROOT)) +end diff --git a/tests/voxel_figure_test.lua b/tests/voxel_figure_test.lua index 6f8e529..66e193e 100644 --- a/tests/voxel_figure_test.lua +++ b/tests/voxel_figure_test.lua @@ -214,6 +214,172 @@ 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") +-- ------- a figure with a DEPTH is an object, not a card +-- +-- The Marts' cash register: the same authored-mask escape, but a machine +-- set down on a counter is a box seen from the front rather than a +-- face-on icon, so it builds as a per-pixel solid. Driven over a +-- synthetic copy of the counter's east arm, as all nine maps on the MART +-- id draw it at cell (1,5): +-- +-- y=9 16 41 the work surface north of it +-- y=10 14 15 the register: keypad and receipt curl +-- y=11 30 31 +-- y=12 16 41 the work surface it stands on + +T.check(TileShape.figures("POKECENTER")[1].depth == nil, + "the seated man states no depth -- he stays a flat sprite card") + +local regs = TileShape.figures("MART") +T.check(type(regs) == "table" and #regs == 1, + "MART carries exactly one figure") +local reg = regs[1] +T.eq(reg.w, 2, "the register is two tiles across") +T.eq(reg.h, 2, "and two tall") +T.eq(reg.n, 150, "the mask claims 150 pixels of the 256 it spans") +T.eq(reg.depth, 12, "its body is 12 voxels deep -- three quarters of the cell") +T.check(reg.thin and reg.thin.rows == 4 and reg.thin.depth == 2, + "the four rows above its drawn top edge are 2-voxel paper") +T.check(reg.flat and reg.flat.x0 == 2 and reg.flat.x1 == 8 + and reg.flat.r0 == 4 and reg.flat.r1 == 11, + "and the keypad is a TOP-VIEW rect, not a face") + +local MART_ROWS = { [9] = { 16, 41 }, [10] = { 14, 15 }, + [11] = { 30, 31 }, [12] = { 16, 41 } } +local martS = { shapeAt = {}, tileAt = {}, figures = {}, skip = {}, + ground = {}, runs = {}, objectQuads = {} } +for ty, row in pairs(MART_ROWS) do + for i, tile in ipairs(row) do + martS.tileAt[keyOf(1 + i, ty)] = tile + martS.shapeAt[keyOf(1 + i, ty)] = COUNTER + end +end +local martMap = { + tileset = { id = "MART", tilesPerRow = 16, + imageWidth = 128, imageHeight = 48 }, + isWalkableCell = function() return false end, +} +Structures.buildFigures(martS, martMap, 2, 3, 9, 12) + +T.eq(#martS.figures, 0, "no card was built -- it is a solid") +T.eq(#martS.objectQuads, 351, + "and it landed in the standee channel as 351 quads") +T.eq(martS.tileAt[keyOf(2, 10)], 16, + "its tiles wear the plain work surface now") +T.eq(martS.tileAt[keyOf(3, 11)], 41, "all four of them") +T.eq(martS.shapeAt[keyOf(2, 10)].class, "counter", + "and keep the counter box the machine stands on") + +local rx0, rx1, ry0, ry1, rz0, rz1 +for _, q in ipairs(martS.objectQuads) do + for c = 1, 4 do + local p = q[c] + rx0 = math.min(rx0 or p[1], p[1]); rx1 = math.max(rx1 or p[1], p[1]) + ry0 = math.min(ry0 or p[2], p[2]); ry1 = math.max(ry1 or p[2], p[2]) + rz0 = math.min(rz0 or p[3], p[3]); rz1 = math.max(rz1 or p[3], p[3]) + end +end +T.eq(ry0, 8, "it stands ON the counter's 8px top plane, not the floor") +T.eq(ry1, 24, "and is its drawn 16px tall") +T.eq(rx0, 18, "west edge at the mask's column 2") +T.eq(rx1, 30, "east edge at column 13, inside its own cell (16..32)") +T.eq(rz1, 96, "its FRONT is the cell's own front edge, where it is drawn") +T.eq(rz0, 84, "and it grows north from there, 4 short of the cell's back") + +-- the two thicknesses: the body at 8, the receipt curl at 2, the curl +-- centred in the body's own band rather than flush with its front +local bands = {} +for _, q in ipairs(martS.objectQuads) do + for c = 1, 4 do bands[q[c][3]] = true end +end +for _, z in ipairs({ 84, 89, 91, 96 }) do + T.check(bands[z], "the model has a face at z = " .. z) +end +local curl = {} +for _, q in ipairs(martS.objectQuads) do + local lo = math.min(q[1][2], q[2][2], q[3][2], q[4][2]) + if lo >= 21 then for c = 1, 4 do curl[q[c][3]] = true end end +end +T.check(curl[89] and curl[91] and not curl[84] and not curl[96], + "clear of the arm's top only the 2-voxel paper band exists") + +-- THE L. The base band (drawn rows 12-15) stands 4 above the counter and +-- the keypad lies on it as a horizontal plate, so the whole machine is +-- exactly three surfaces: a foot, an arm, and a deck in the notch. +local plate, deckTop = {}, 0 +for _, q in ipairs(martS.objectQuads) do + local flatQuad = q[1][2] == q[2][2] and q[2][2] == q[3][2] + and q[3][2] == q[4][2] + if flatQuad and q[1][2] == 13 then + plate[#plate + 1] = q + elseif flatQuad and q[1][2] == 12 then + deckTop = deckTop + 1 + end +end +T.eq(#plate, 83, + "the keypad lies FLAT: one top quad per masked voxel of the deck") +T.eq(deckTop, 7, + "on the base band's own top, which is 4 voxels up (drawn rows 12-15)") +local dz0, dz1 +for _, q in ipairs(martS.objectQuads) do + if q[1][2] == 12 and q[3][2] == 12 then + for c = 1, 4 do + dz0 = math.min(dz0 or q[c][3], q[c][3]) + dz1 = math.max(dz1 or q[c][3], q[c][3]) + end + end +end +T.eq(dz0, 84, "and that deck runs the body's whole depth") +T.eq(dz1, 96, "-- plain behind the panel, covered by it in front") + +local px0, px1, pz0, pz1 +for _, q in ipairs(plate) do + for c = 1, 4 do + px0 = math.min(px0 or q[c][1], q[c][1]); px1 = math.max(px1 or q[c][1], q[c][1]) + pz0 = math.min(pz0 or q[c][3], q[c][3]); pz1 = math.max(pz1 or q[c][3], q[c][3]) + end +end +T.eq(px0, 18, "the deck spans the mask's columns 2..8") +T.eq(px1, 25, "-- the keypad panel and its own black rim") +T.eq(pz1, 96, "the deck reaches the body's front edge") +T.eq(pz0, 84, "and its back -- 8 drawn rows STRETCHED over 12 voxels") + +-- the stretch is by whole voxels, centre-sampled: 8 drawn rows over 12 +-- voxels of deck doubles every second one and blurs nothing +local perRow16, atlasH16 = 16, 48 +local depthRow = {} +for _, q in ipairs(plate) do + local z = math.min(q[1][3], q[2][3], q[3][3], q[4][3]) + depthRow[z] = math.floor(q.v * atlasH16) +end +local seen = {} +for z = 84, 95 do + T.check(depthRow[z] ~= nil, "deck voxel at z = " .. z .. " wears a texel") + seen[depthRow[z]] = (seen[depthRow[z]] or 0) + 1 +end +T.eq(depthRow[95], 11, "the front voxel wears the keypad's own bottom rim") +T.eq(depthRow[84], 4, "the back one wears its top rim") +local doubled = 0 +for _, n in pairs(seen) do + T.check(n == 1 or n == 2, "no drawn row spreads over more than two voxels") + if n == 2 then doubled = doubled + 1 end +end +T.eq(doubled, 4, "exactly four of the eight rows double -- 8 into 12") + + +-- and the arm still stands its drawn 8 rows above that deck, carrying +-- the paper: nothing in the notch reaches higher than the plate +local armTop, notchTop = 0, 0 +for _, q in ipairs(martS.objectQuads) do + for c = 1, 4 do + if q[c][1] >= 25 then armTop = math.max(armTop, q[c][2]) + elseif q[c][1] <= 24 then notchTop = math.max(notchTop, q[c][2]) end + end +end +T.eq(armTop, 24, "the arm and its receipt curl reach the drawn 16px") +T.eq(notchTop, 23, + "and west of it only the keys (13) and the paper overhanging them") + -- ------- prop_bg: the shades a pinned prop treats as background -- -- The potted plants needed this: their pot's olive base is drawn flush on diff --git a/tests/water_curve_shots.lua b/tests/water_curve_shots.lua new file mode 100644 index 0000000..26d8f1f --- /dev/null +++ b/tests/water_curve_shots.lua @@ -0,0 +1,121 @@ +-- Scratch driver: water shot with V-CURVE walked OFF..3, to see what the +-- world bend does to the reflective pass. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/water_curve_shots.lua \ +-- SHOT_DIR=.scratchpad/watercurve AB_TAG=before lovec.exe . +return function(game) + local U = dofile("tests/drivers/util.lua") + local Pipelines = require("src.render.Pipelines") + + local ROOT = (os.getenv("SHOT_DIR") or "shots/watercurve") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[water] DRAMATIC_SHAPE mod not loaded") + return + end + local V = handle.lib + local DayNight = V.require("DayNight") + local ChunkMesher = V.require("ChunkMesher") + local Voxel = V.require("VoxelState") + local WorldCurve = V.require("WorldCurve") + local Water = V.require("Water") + -- WATER_RUNG=sky drops the screen-space march and leaves the sky path, to + -- tell an artefact of the one from an artefact of the other + if os.getenv("WATER_RUNG") then Water.setting:sync(os.getenv("WATER_RUNG")) end + + require("src.world.OverworldController").rollEncounter = function() return nil end + local TileRenderer = require("src.render.TileRenderer") + TileRenderer.tick = function() end + TileRenderer.animFrame = function() return 0 end + DayNight.setting:sync(os.getenv("WATER_TIME") or "night") + + pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul') + + local Zoom = require("src.render.Zoom") + pcall(function() + game.save.options.zoom = 1 + Zoom.applyOptions(game.save.options) + end) + + local function settle() + for _ = 1, 900 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + for _ = 1, 300 do + if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end + U.wait(1) + end + U.wait(40) + end + + -- Stand on the walkable cell just north of the widest run of water on the + -- map, so a scene is picked by where the water actually is rather than by + -- a coordinate guessed off the block list. + local TileShape = V.require("TileShape") + local function shore(map) + local def = map.def + local shapes = TileShape.forMap(map) + local function classAt(cx, cy) + local tx, ty = cx * 2, cy * 2 + local s = TileShape.at(map, shapes, map:tileAt(tx, ty), tx, ty) + return s and s.class + end + local best, bestRun = nil, 0 + for cy = 1, def.height * 2 - 1 do + local run, start = 0, nil + for cx = 0, def.width * 2 - 1 do + if classAt(cx, cy) == "water" then + start = start or cx + run = run + 1 + if run > bestRun and classAt(cx, cy - 1) ~= "water" then + bestRun, best = run, { x = start + math.floor(run / 2), y = cy - 1 } + end + else + run, start = 0, nil + end + end + end + return best + end + + local SCENES = { + { map = "PALLET_TOWN", label = "pallet" }, + { map = "CERULEAN_CITY", label = "cerulean" }, + { map = "VERMILION_CITY", x = 18, y = 27, label = "vermilion" }, + { map = "ROUTE_24", label = "route24" }, + { map = "VIRIDIAN_CITY", label = "viridian" }, + } + + local shots = 0 + for _, s in ipairs(SCENES) do + if not s.x then + -- teleport once so the map is loaded, then let the scan place us + U.teleport(game, s.map, 1, 1, "down") + U.wait(4) + local spot = game.overworld and game.overworld.map and shore(game.overworld.map) + if spot then s.x, s.y = spot.x, spot.y else s.x, s.y = 1, 1 end + print(("[water] %s shore at (%d,%d)"):format(s.label, s.x, s.y)) + end + for _, rung in ipairs({ 3, 4 }) do + for _, curve in ipairs({ 0, 3 }) do + U.teleport(game, s.map, s.x, s.y, s.face or "down") + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + WorldCurve.setting:setIndex(curve + 1, game) + settle() + local path = ("%s/%s_v%d_c%d.png"):format(ROOT, s.label, rung, curve) + game.capturePath = path + U.wait(8) + local f = io.open(path, "rb") + if f then f:close() shots = shots + 1 + else print("[water] capture missed: " .. path) end + end + end + end + print(("[water] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tools/building_voxels.py b/tools/building_voxels.py index 1ca5fd2..c8718c0 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -25,6 +25,7 @@ TILESETS = os.path.join(ROOT, "assets/generated/tilesets") PER_ROW = 16 WHITE, GREY, DARK, BLACK = 0, 1, 2, 3 # by luminance, light first +SHADE_NAMES = dict(white=WHITE, grey=GREY, dark=DARK, black=BLACK) # --- the shape profile, mirroring data/voxel_heights.lua's `buildings` ------ TEMPLATES = { @@ -614,6 +615,244 @@ TEMPLATES = { roof_rows=19, roof_back=16, roof_front=0, roof_cycle=(2, 13), slab=3, front_eave=0, ledge=None, tileset="gym", depth=2, ), + # F05: the tall display cabinet, the one with the trophy behind its + # glass -- CELADON_CHIEF_HOUSE cells 3,0 and 4,0 and + # CELADON_MANSION_1F cell 2,2. Its 32 rows read like every band + # table's: 0-8 the cabinet top seen from above (black rim, white + # highlight along the north and west, grey field, the front corner + # shaded at row 8), row 9 the top's own black front edge -- so + # slab = 1 and that row folds into the rim treatment -- and 10-31 + # the front face: the trophy in its dark display recess, the + # nameplate under it, then the two panelled doors of the base. + # Nine drawn top rows over a 32px plot, so the rims map 1:1 (the + # drawn front-corner shading lands one voxel behind the front edge) + # and the uniform field cycles between. Both cells of the plot are + # blocked and both are cabinet drawing, so D is the whole grid -- + # the rank is built into the room's north wall. + "mansion_trophy_case": dict( + tiles=[ + [38, 41], + [40, 21], + [56, 87], + [50, 51], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="mansion", + ), + # F06: the short book case standing beside it -- the same cabinet on + # a grid one tile row shorter (CELADON_CHIEF_HOUSE cells 2,0 and + # 5,0, CELADON_MANSION_1F tiles 2,5 and 6,5). Identical top band and + # identical base; only the display opening is shorter, a shelf of + # books instead of the trophy. Same band numbers as F05, which is + # what makes the two read as one line of furniture: 15 voxels tall + # against the tall one's 23. The 8px of wall behind it is not part + # of the drawing and keeps its own `wall` pin. + "mansion_bookcase": dict( + tiles=[ + [38, 41], + [34, 35], + [50, 51], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="mansion", + ), + # F07: the long table in the middle of the chief's house + # (CELADON_CHIEF_HOUSE cell 2,3), the lab table's read at four + # cells wide and two deep. Rows 0-23 are the tabletop seen from + # above; 24-26 are the top slab's own front edge, black/#555/black + # -- exactly what the rim treatment paints -- and row 27 is the + # #555 shadow that closes it, so slab = 3 and rows 24-27 fold into + # the roof band instead of extruding under it. That leaves rows + # 28-30 as the base, the same three the lab table's is, and the two + # tables stand the same 6 voxels: row 28 the apron running the whole + # width, 29-30 the end legs under it. The legs stop one row short of + # the grid, so the measured ground line lands there and the model + # does not float. Both cell rows of the plot are blocked, so D is + # the whole grid: 24 drawn top rows map 1:1 from the north rim and + # the field's own rows cycle for the last 8. + "mansion_long_table": dict( + tiles=[ + [38, 39, 39, 39, 39, 39, 39, 41], + [54, 55, 55, 55, 55, 55, 55, 57], + [54, 55, 55, 55, 55, 55, 55, 57], + [60, 58, 58, 58, 58, 58, 58, 59], + ], + roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23), + slab=3, front_eave=0, ledge=None, tileset="mansion", + ), + # F08: the dining table of the generic town house -- 18 placements, + # every home's cells (3,3):(4,4) -- the chief's long table (F07) at + # two cells wide. The same read to the row: 0-23 the rounded + # tabletop seen from above (black rim, white highlight course, grey + # field, #555 east rim), 24-26 the slab's own black/#555/black + # front edge and 27 the #555 apron shadow that closes it, folded + # into the band, then 28-30 the base -- the apron's black underrun + # and the corner legs, stopping one row short of the grid so the + # measured ground line keeps the model on its plot. The same 6 + # voxels the whole table family stands. + "house_table": dict( + tiles=[ + [38, 39, 39, 41], + [54, 47, 47, 57], + [54, 47, 47, 57], + [60, 58, 58, 59], + ], + roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23), + slab=3, front_eave=0, ledge=None, tileset="house", + ), + # F08 again: Red's and the Copycat's ground-floor dining table -- + # the same drawing on the reds_house atlas, with two differences + # the numbers absorb. Its tabletop field stops at row 22 and row 23 + # is the top's own #555 south rim, which back = 24 would lay + # MID-TABLE as a dark stripe: back stops at 23 and the field cycles + # from there (the drawn rim's place at the south edge is under the + # rim treatment's black, like every sibling's). And the POTTED + # PLANT is drawn standing on the tabletop (rows 5-15, x10..x21): it + # stays the cutout standee it has always been -- the template + # scrubs its pixels to the field shade and leaves its tiles + # unclaimed (the Lua entry's `keep`), so the model tops out as the + # plain surface the plant sits on. + "reds_house_table": dict( + tiles=[ + [38, 39, 40, 41], + [54, 55, 56, 57], + [44, 42, 42, 43], + [60, 58, 58, 59], + ], + roof_rows=28, roof_back=23, roof_front=1, roof_cycle=(2, 22), + slab=3, front_eave=0, ledge=None, tileset="reds_house", + scrub=[(10, 5, 21, 15)], + ), + # F10: the town house's bookcase -- the commonest piece of furniture + # in the game at 58 placements across three drawings, and the reason + # this family exists: the `desk` box it used to be had a flat front + # with the books PAINTED on it. Band for band it is the Celadon + # cabinet (F05/F06) on a different atlas, and it takes those numbers + # unchanged: 0-8 the top seen from above (black rim, white highlight + # courses along the north and west, grey field, the front corner + # shaded at row 8), row 9 the top's own black front edge -- so + # slab = 1 and that row folds into the roof band -- and 10-31 the + # front: two shelves in their dark openings over the cupboard's two + # panelled doors. Every one of those is a non-black region the + # drawing seals behind its own black frame, so the measured pane + # pass sinks each a voxel and the frames stand proud of them. + # Nine drawn top rows over a 32px plot: the rims map 1:1 and the + # uniform field cycles between. BOTH cell rows of the plot are + # blocked and both are cabinet drawing, so D is the whole grid -- + # the case is built into the room's north wall. 23 voxels tall, + # one under the `desk` pin it replaces, and the same 23 the Celadon + # trophy case stands. + # + # This one carries books and a bowl on each shelf; 36 placements, + # the west end of eighteen homes. + "house_bookcase_bowls": dict( + tiles=[ + [38, 41], + [14, 15], + [14, 15], + [30, 31], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="house", + ), + # F10 again: its twin at the east end, books on both shelves -- + # 22 placements, and the only drawing of the two that the trashed + # house in Cerulean puts at the west end as well. + "house_bookcase_books": dict( + tiles=[ + [38, 41], + [48, 49], + [48, 49], + [30, 31], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="house", + ), + # F10 once more, on the reds_house atlas: Red's and the Copycat's + # ground-floor pair (4 placements). The same object, one shelf of + # each drawing -- 48/49 books above, 34/35 below -- and the same + # band numbers. + "reds_bookcase": dict( + tiles=[ + [38, 41], + [48, 49], + [34, 35], + [50, 51], + ], + roof_rows=10, roof_back=8, roof_front=2, roof_cycle=(2, 7), + slab=1, front_eave=0, ledge=None, tileset="reds_house", + ), + # F09: the stool at every one of those tables -- the first template + # with NO base piece. The drawing is one object, a round seat on + # legs, drawn MID-CELL over its own floor (rows 0-4 are the room + # behind it), and no band split fits that: a roof band starts at + # the drawing's top row. So it is a desk-set of exactly one upright + # part anchored to the floor: rows 5-10 the seat seen from above + # (its lid), row 11 the seat's own front edge, 12-15 the legs with + # the floor showing between them, which the per-pixel facade cut + # keeps open. The drawing measures 7 deep (six seat rows plus the + # front edge at drawn row = depth row); the shipped stool is grown + # two voxels past that north AND south (z 3..13, developer-tuned + # against the in-game read) with the seat band STRETCHED over the + # deeper lid. `panes = False`: a stool has no windows, and the pane + # rule would sink the legs' lit faces behind their own outlines. + "house_stool": dict( + tiles=[ + [2, 3], + [18, 19], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="house", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), + # F09 again on the reds_house atlas (Red's and the Copycat's ground + # floors), pixel for pixel the same drawing. + "reds_house_stool": dict( + tiles=[ + [2, 3], + [18, 19], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="reds_house", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), + # F09 once more, on the interior atlas: the Pokemon Fan Club's four + # members' chairs, drawn round the boardroom table -- 4 placements + # (cells (1,3), (1,4), (6,3), (6,4)) and nowhere else in the game. + # A DIFFERENT drawing from the house stool -- its seat field carries + # a one-pixel white margin where the house's carries two -- but the + # same object band for band, and its elevation rows (11-15: the + # seat's front edge, then the legs) are pixel-identical to the house + # drawing's. So it takes the same part table unchanged, including + # the two-voxel growth north and south. + # + # Those elevation rows come from tiles 59/60, which this drawing + # SHARES with Bill's chair (43/44 over 59/60, modelled as a part of + # `bills_desk`) -- which is why one `stool = 5` height override in + # the tileset entry is right for both seats rather than a special + # case: they are drawn at the same height because they are drawn + # with the same pixels. + "club_stool": dict( + tiles=[ + [61, 62], + [59, 60], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="interior", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(5, 10), facade=(11, 15), + z=3, depth=11, stretch=True), # the stool + ], + ), # F04: the Pokemon Center's PC -- every Center's northeast corner # (11 placements) plus the Indigo Plateau lobby, whose MART tileset # shares this atlas. The lab desk-set read again: a @@ -639,6 +878,249 @@ TEMPLATES = { dict(kind="flat", x=(2, 13), rows=(17, 19), z=13), # keyboard ], ), + # F05: the healing machine behind every Pokemon Center counter -- + # two 4x4-tile variants, 24 placements between them (a pair in all + # eleven Centers plus the Indigo Plateau lobby's pair, whose MART + # tileset shares this atlas). The variants differ only in the east + # flank: the west machine has the control keyboard there (7/13, + # scan "40,58,59,40;40,74,75,40;72,76,77,7;72,6,22,13" -- 12 + # placements), the east machine mirrored hoses (73, scan + # "...;72,76,77,73;72,6,22,73" -- 12). + # + # The full drawing is claimed: corners are the striped wall band + # (40), flanks the machine's own equipment. What no class can split + # is a wall-height cabinet with a monitor perched on its front top + # edge, drawn over two map rows because it towers over the 16px + # band behind it -- and hoses and a keyboard that are ATTACHED to + # it, not furniture standing on a desk plane. + # + # Read off the pixels (absolute grid x0..x31): + # rows 15-31, x8..x23 the CABINET's front face, 16 wide, 17 + # tall: black front-top edge with vent notches (15), + # white top rail (16), black panel in a white frame + # (17-28), white rail / light plinth / black ground + # (29-31). `desk.x` bounds it to the middle columns. + # rows 6-14, x8..x23 the cabinet's TOP FACE seen from above -- + # not a second-story facade: white expanse, lit west + # strip (x9), shaded east strip (x22), wrapping the + # monitor. Its 9 rows + the front edge row 15 ARE the + # cabinet's depth: 10 (z 16..25 against the wall). + # `desk.top` lays the band flat as the lid; where the + # monitor's drawing occludes it, the lid continues + # the nearest strip (the drawing's own pixels). + # rows 1-13, x10..x21 the MONITOR: black back rim (1) + white + # cap (2-3) seen from above -- depth 4 -- then bezel, + # screen and control strip (4-13) face-on, 10 tall, + # at the cabinet top's front (drawn base edge 13 is + # one band row up from the front strip): z 20..23. + # The screen interior (x13..x18, rows 6-8) sinks one + # voxel via `inset` -- the pane rule by hand, since + # `panes=False` blocks the global pass. + # rows 16-31, x0..x7 the HOSES (tile 72 twice): one 8-row + # motif per map row, and the two stacked motifs are + # two hoses in DEPTH -- the drawn row band is the + # depth band, and each motif's own rows are its + # elevation WITHIN that band, exactly the potted + # plants' stacked-cell rule. So both hoses hug the + # floor: a horizontal run off the cabinet's side + # (rows 24-26, x3..x7 -> heights 5..7) over a shaded + # drop landing ON the floor (rows 27-31, x2..x7 -> + # heights 0..4) -- all measured, the drawn extent + # maps 1:1 with no continuation. `box` parts at z 18 + # and z 24, 3 deep; both wear the LOWER motif's rows + # (the one drawn at its true elevation; the upper + # motif is the same atlas tile, pixel-identical). + # rows 16-31, x24..x31 west variant: the KEYBOARD (7/13), a + # key grid with its cable at the north end and a bar + # down the east edge -- TOP-VIEW art, 16 rows = 16 + # depth rows. A `flat` part mounted on the cabinet's + # side at COUNTER level (`at` 8, the counters' own + # 8px band -- the one number top-view art cannot + # state, pinned to the room's work surface), 3 + # thick, top face wearing the drawn art. The stray + # dark dashes east of it (x30-31) are its cast + # shadow on the floor: background. East variant: + # mirrored hoses (runs x24..x28, drops x24..x29), + # same rows and z slots. + # rows 0-15 elsewhere wall stripes and cast shadow: BACKGROUND. + # The `wall` element keeps the band solid over the + # back map row, full grid width -- a 16-tall, + # 16-deep block cycling the drawing's own stripe + # unit (x0, rows 0-3), exactly what the flanking + # cells' wall pins render. + # depth 4 is both map rows: the back row is the wall's plot, the + # front row the machine's own. `panes=False`: the front panel + # seals DARK behind LIGHT, the opposite polarity to the pane rule. + "center_heal_machine_w": dict( + tiles=[ + [40, 58, 59, 40], + [40, 74, 75, 40], + [72, 76, 77, 7], + [72, 6, 22, 13], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=4, + panes=False, + wall=dict(h=16, depth_px=16, x=0, cycle=(0, 3)), + desk=dict(x=(8, 23), fascia=(15, 16), base=(17, 31), z=16, + depth_px=10, top=(6, 14)), + parts=[ + dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), + z=20, depth=4, + inset=dict(x=(13, 18), rows=(6, 8))), # the monitor + dict(kind="box", x=(3, 7), rows=(24, 26), + z=18, depth=3), # north hose run + dict(kind="box", x=(2, 7), rows=(27, 31), + z=18, depth=3), # north hose drop + dict(kind="box", x=(3, 7), rows=(24, 26), + z=24, depth=3), # south hose run + dict(kind="box", x=(2, 7), rows=(27, 31), + z=24, depth=3), # south hose drop + dict(kind="flat", x=(24, 29), rows=(16, 31), + z=16, at=8, thick=3), # keyboard shelf + ], + ), + "center_heal_machine_e": dict( + tiles=[ + [40, 58, 59, 40], + [40, 74, 75, 40], + [72, 76, 77, 73], + [72, 6, 22, 73], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="pokecenter", depth=4, + panes=False, + wall=dict(h=16, depth_px=16, x=0, cycle=(0, 3)), + desk=dict(x=(8, 23), fascia=(15, 16), base=(17, 31), z=16, + depth_px=10, top=(6, 14)), + parts=[ + dict(kind="upright", x=(10, 21), top=(1, 3), facade=(4, 13), + z=20, depth=4, + inset=dict(x=(13, 18), rows=(6, 8))), # the monitor + dict(kind="box", x=(3, 7), rows=(24, 26), + z=18, depth=3), # NW hose run + dict(kind="box", x=(2, 7), rows=(27, 31), + z=18, depth=3), # NW hose drop + dict(kind="box", x=(3, 7), rows=(24, 26), + z=24, depth=3), # SW hose run + dict(kind="box", x=(2, 7), rows=(27, 31), + z=24, depth=3), # SW hose drop + dict(kind="box", x=(24, 28), rows=(24, 26), + z=18, depth=3), # NE hose run + dict(kind="box", x=(24, 29), rows=(27, 31), + z=18, depth=3), # NE hose drop + dict(kind="box", x=(24, 28), rows=(24, 26), + z=24, depth=3), # SE hose run + dict(kind="box", x=(24, 29), rows=(27, 31), + z=24, depth=3), # SE hose drop + ], + ), + # F06: the Bike Shop's toolbox -- 2 placements, cells (6,6) and (7,7) + # of BIKE_SHOP and nowhere else in the game. + # + # A 2x2-tile drawing that packs two objects standing side by side on + # the floor, which is why no class pin reaches it: a pin resolves a + # whole 8x8 tile and the pump's columns run through both right-hand + # tiles of the toolbox's own grid. Pinned `billboard` the whole + # 16x16 went up as one 10-voxel per-pixel slab -- every black outline + # pixel a 10-deep bar, so it read as a black monolith with the + # drawing decalled on its front, and the pump came out as a wing off + # its corner. + # + # Read off the pixels: + # rows 12-15 x2..x12 the plinth: a black rim course over a light + # drawer front with its own #555 side. The + # `desk` -- 4 tall, and the first one here + # drawn NARROWER than its grid (the pump + # stands on open floor past its right end) + # rows 4-11 x2..x11 the cabinet: a #555 tool tray behind a black + # frame, its right side face (#555) drawn one + # column wide. An upright part on the plinth, + # 8 tall, wearing its drawn top edge as a lid + # rows 0-11 x10..x14 the pump: a white body in a black outline + # with a grey foot. Its own upright part, 4 + # deep and set 2 back so it stands BEHIND the + # cabinet, and `foot` because its drawn bottom + # is where the plinth crosses it, not where it + # meets the floor + # The pump is listed FIRST so the cabinet wins the two columns they + # share (x10/x11 are the cabinet's side face, drawn through it). + # depth 1 is the plot: the toolbox blocks its cell but is drawn a + # half-cell deep, so 8 leaves the walk-up row in front of it clear. + "bike_shop_toolbox": dict( + tiles=[ + [29, 13], + [21, 22], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="club", depth_px=14, + tray=dict(top=(4, 11), front=(12, 15), x=(2, 11), inner=(3, 9), + floor=0), + parts=[ + dict(kind="upright", x=(11, 14), top=(0, 0), facade=(0, 11), + z=0, depth=14), # the open lid + ], + ), + # F0x: Bill's desk, and the Silph president's -- the same drawing in + # both (BILLS_HOUSE cell 1,4 and SILPH_CO_11F cell 10,12). 16 rows of + # TABLETOP seen from above over a 16px plot, 1:1, with a terminal and + # a ball standing on it: the lab table's top surface (black rim, white + # highlight course, grey field) carrying lab-computers' objects. + # + # The grid stops at the desk's own two cells ON PURPOSE. The block + # draws the desk's apron into the WALKABLE cell in front, and the + # left half of that apron shares its tiles with the CHAIR pushed up + # to the desk (43/44 over 59/60, pinned `stool`) -- claiming those + # tiles to read the apron would take the chair's back with them. So + # the front face is the one surface here the drawing does not hand + # us inside the grid, and `plane` authors it at the 8px this desk is + # pinned to stand: the apron's own drawn row count, and table height. + "bills_desk": dict( + tiles=[ + [11, 12, 13, 14], + [27, 28, 29, 30], + [43, 44, 91, 92], + [59, 60, 31, 31], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="interior", depth=4, + # the desk's own plot is its two cells; the grid runs on because + # its apron and the CHAIR share tiles 43/44 + desk=dict(fascia=(16, 18), base=(19, 23), depth=2), + parts=[ + # the notes: two sheets lying on the desk (a plain one and a + # crumpled one), so PAPER -- one voxel proud at drawn row = + # depth row, and nothing raised + dict(kind="flat", x=(2, 15), rows=(3, 7)), # the notes + # the keyboard: a real slab, not a print on the desk. Its + # keys are drawn from ABOVE, so they ride the top face across + # its depth, and its drawn bottom frame row stands as the + # front edge + dict(kind="upright", x=(4, 15), top=(8, 12), facade=(12, 13), + z=8, depth=6), # the keyboard + # the cord: the kinked dark run the drawing puts between the + # keyboard's top-right corner and the computer's left corner. + # Raised to the keyboard's own height so it reads as a cable + # spanning them rather than a scuff on the desk + dict(kind="upright", x=(16, 19), top=(8, 8), facade=(8, 9), + z=8, depth=2), # the cord + # the computer: drawn in 2:1 isometric, turned 45 degrees to + # the map -- see the iso branch in build_parts. `plan` = rx + # makes its footprint a SQUARE turned 45, so from directly + # above it is the cube the drawing depicts and not the slab + # the 2:1 would otherwise build + dict(kind="iso", x=(19, 30), rows=(1, 13), plan=6, z=9), + # the chair pushed up to the desk, drawn into the walkable + # cell in front. It stands on the FLOOR, not on the desk, so + # its rise is the whole plane back down; two voxels deep + # because it is a chair, and `inside` cuts it per pixel, so + # this is the thin slab the `stool` standee was -- the desk + # claiming tiles 43/44 for its apron is what makes the + # template owe it + dict(kind="upright", x=(2, 13), top=(20, 21), facade=(22, 31), + rise=-8, z=22, depth=10), # the chair + ], + ), # B23: the Victory Road entrance on Route 23: a rock face with two # barred doors. The roof band is the pale cliff top seen from above. "victory_road_gate": dict( @@ -662,7 +1144,7 @@ RECESS_MAX = 24 # --------------------------------------------------------------- stage 1 -- -def sprite(tiles, seal="", tileset="overworld"): +def sprite(tiles, seal="", tileset="overworld", scrub=None): """Composite the building and read it the way Structures reads the map: palette index per pixel plus the light-only silhouette flood. @@ -719,6 +1201,26 @@ def sprite(tiles, seal="", tileset="overworld"): src = [[((tiles[y // 8][x // 8] % PER_ROW) * 8 + x % 8, (tiles[y // 8][x // 8] // PER_ROW) * 8 + y % 8) for x in range(W)] for y in range(H)] + + # `scrub` names pixel rects where the drawing paints an object standing + # ON the surface (Red's potted plant on the dining tabletop). The object + # keeps its own standee -- the template leaves its tiles unclaimed -- so + # the band beneath it is the one surface the drawing implies but never + # paints clear: every rect pixel takes the field shade, sourced from the + # first field texel outside the rects, and the model's top comes out as + # the plain surface the object sat on. + if scrub: + in_rect = lambda x, y: any(x0 <= x <= x1 and y0 <= y <= y1 + for x0, y0, x1, y1 in scrub) + donor = next((x, y) for y in range(H) for x in range(W) + if col[y][x] == GREY and not out[y][x] + and not in_rect(x, y)) + for y in range(H): + for x in range(W): + if in_rect(x, y): + col[y][x] = GREY + src[y][x] = src[donor[1]][donor[0]] + out[y][x] = False return dict(W=W, H=H, col=col, out=out, src=src, pal=pal) @@ -773,11 +1275,27 @@ def profile(sp, t): if x1 - x0 + 1 <= RECESS_MAX and y1 - y0 + 1 <= RECESS_MAX: recess.update(cells) + # The pane rule reads a LIGHT region sealed behind a BLACK frame. A + # drawing built the other way round -- the healing machine's dark + # screens sealed behind their own white bezels -- inverts under it: + # every lit edge sinks and the black panes stand proud, a black + # lattice one voxel off the face. `panes = False` says the drawing + # does not carry the rule's polarity, so the facade stays flush. + if not t.get("panes", True): + recess = set() + # Depth is the PLOT. For a whole-drawing building that is the grid # itself; `depth` (in tile rows) names it when the grid runs past the # plot onto ground the drawing merely stands its legs on. return dict(top=top, wall_h=wall_h, ytop=ytop, recess=recess, - inside=inside, D=t.get("depth", len(t["tiles"])) * 8, + inside=inside, + # `depth` names the plot in TILE ROWS, which is the right + # grain for a building. `depth_px` names it in voxels, for + # an object whose real depth is not a whole tile row -- the + # Bike Shop toolbox is a box in the middle of its own cell, + # not a thing that fills a plot. + D=t.get("depth_px") + or t.get("depth", len(t["tiles"])) * 8, ground=ground, W=W, H=H) @@ -813,87 +1331,369 @@ def build_desk_set(sp, pr, t): return nx return sx + def build_parts(plane): + for p in t["parts"]: + x0, x1 = p["x"] + if p["kind"] == "flat": + r0, r1 = p["rows"] + # `at` names the sheet's own height when it does not lie + # on the desk plane (the healing machine's keyboard is a + # shelf mounted on the cabinet's side); `thick` gives it + # a body -- layers below the sheet repeating each + # column's own texel, the same continuation rule every + # synthesized surface follows. + at_y = p.get("at", plane) + thick = p.get("thick", 1) + for sy in range(r0, r1 + 1): + # drawn row = depth row by default; `z` renames the + # origin when the flat sits below the desk's own drawn + # top span (the Center PC's keyboard) + z = p.get("z", r0) + (sy - r0) + if not 0 <= z < D: + continue + for sx in range(x0, x1 + 1): + if inside(sx, sy): + for y in range(max(0, at_y - thick + 1), at_y + 1): + put(sx, y, z, sx, sy) + continue + if p["kind"] == "box": + # A BOX part is a drawn rect standing at its own drawn + # elevation -- equipment attached to the machine rather + # than an object on the desk plane. The rows are face-on + # art: the top row's drawn height IS the box's top + # (ground - 1 - r0, measured), and the box runs down to + # `base` (default the drawn extent; 0 continues it to + # the floor, the legs-continue rule). Height beyond the + # drawn rows fills the way a roof band does: rows before + # `cycle` map 1:1 from the top, rows after it 1:1 from + # the bottom -- the healing machine hoses' foot lands ON + # the floor -- and the cycle window repeats between. + r0, r1 = p["rows"] + c0, c1 = p.get("cycle", (r1, r1)) + pz = p.get("z", 0) + pd = p["depth"] + top = pr["ground"] - 1 - r0 + bot = p.get("base", pr["ground"] - 1 - r1) + n_top, n_bot = c0 - r0, r1 - c1 + for y in range(bot, top + 1): + k, j = top - y, y - bot + if k < n_top: + sy = r0 + k + elif j < n_bot: + sy = r1 - j + else: + sy = c0 + (k - n_top) % (c1 - c0 + 1) + for sx in range(x0, x1 + 1): + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pz, pz + pd): + if 0 <= z < D: + put(sx, y, z, + sx if z in (pz, pz + pd - 1) else ix, sy) + continue + if p["kind"] == "iso": + # An ISO part is drawn in 2:1 isometric -- a box TURNED 45 + # degrees to the map, so one rhombus carries its top, its + # front and its side at once and no band or facade split + # can reach them. Un-projecting it is that projection run + # backwards: the box stands as a real diamond in plan and + # every voxel wears the texel the drawing paints where + # that voxel projects TO, so the drawn top lands on the + # top, the screen on the screen-facing side and the flank + # on the flank -- nothing segmented by hand, which is the + # only way to get this right, because the three faces + # meet on a diagonal no rectangle can name. + # + # Everything but the depth centre falls out of the drawn + # rect, because the projection fixes it: the half-width + # is the drawn rhombus's x radius, HALF that again its z + # radius (2:1 is what makes it isometric), the near + # corner's drawn row is the base rhombus's front tip, and + # whatever drawn height is left once that rhombus is + # accounted for is the box's own height. Bill's computer: + # rx 6, rz 3, base centre row 10, 6 voxels tall -- which + # puts its left corner's vertical edge at drawn rows + # 4..10, exactly where the drawing paints one. + # + # `plan` is the one thing the drawing CANNOT state: 2:1 + # is the projection, not the object, so reading rz as the + # plan radius too builds a box half as deep as it is wide + # -- a slab, not the cube the drawing depicts. `plan` + # names the real z radius and the drawn row is scaled + # into it, so a cube is `plan = rx` and the drawing still + # lands on it pixel for pixel. + pr0, pr1 = p["rows"] + rx = (x1 - x0 + 1) // 2 + rz = rx // 2 + plan = p.get("plan", rz) + oy = pr1 - rz + h = oy - rz - pr0 + for sx in range(x0, x1 + 1): + # doubled, so a rect of even width keeps its centre + # between two columns instead of limping one left + dx2 = 2 * sx - (x0 + x1) + for dz in range(-plan, plan + 1): + z = p["z"] + dz + d2 = abs(dx2) * plan + 2 * abs(dz) * rx + if not (0 <= z < D and d2 <= (2 * rx + 1) * plan): + continue + # the plan row scaled back into the drawn rhombus + dzs = (2 * dz * rz + plan) // (2 * plan) + for y in range(h + 1): + sy = oy + dzs - y + if pr0 <= sy <= pr1 and inside(sx, sy): + put(sx, plane + y, z, sx, sy) + continue + tr0, tr1 = p["top"] + fr0, fr1 = p["facade"] + pd = p["depth"] + # `rise` lifts a part off the desk's top plane. An object STANDING + # on the desk starts at the plane (rise 0, every table-top part); + # the healing machine's screen head is MOUNTED on the console's + # front instead, and the drawing states where -- its bottom row is + # two above the body's top, so rise is measured, not tuned. + base = plane + p.get("rise", 0) + # ...and `z` names its back-most depth row, the same field a flat + # part carries. A part on a desk starts at the plot's back (0); + # the healing machine's console stands in the FRONT map row of a + # grid whose back row is the wall band it leans against, so it + # starts where that wall ends. + pz = p.get("z", 0) + ytp = base + (fr1 - fr0) + # `inset` sinks an authored pane one voxel: the pane rule + # applied by hand, for a part whose screen IS sealed behind + # its own black frame while the template's `panes=False` + # (set for the polarity-inverted panel elsewhere in the same + # drawing) blocks the global pass. Same mechanism as a + # recess: the front voxel is simply not placed. + ins = p.get("inset") + for sx in range(x0, x1 + 1): + # the lid: the part's drawn top laid across its depth from + # the back, last row continuing forward; the front lid row + # is the facade's own top row -- the drawn front-top edge. + # `stretch` maps the drawn band over the whole depth + # instead, the tray's rule: for a part authored DEEPER + # than its drawing (the house stool grown past its drawn + # seat), clamping would print the last row as a long + # smear off the back band's edge. + for z in range(pz, pz + pd): + if z == pz + pd - 1: + sy = fr0 + elif p.get("stretch"): + sy = min(tr0 + ((z - pz) * (tr1 - tr0 + 1)) + // (pd - 1), tr1) + else: + sy = min(tr0 + z - pz, tr1) + while sy <= tr1 and not inside(sx, sy): + sy += 1 + if sy > tr1 and not (z == pz + pd - 1 and inside(sx, fr0)): + continue + if not 0 <= z < D: # a part lives inside the plot + continue + put(sx, ytp, z, sx, fr0 if z == pz + pd - 1 else sy) + # the body: facade rows anchored to the part's own base + for sy in range(fr0 + 1, fr1 + 1): + y = base + (fr1 - sy) + if not inside(sx, sy): + continue + ix = interior(sx, sy, x0, x1) + for z in range(pz, pz + pd): + if not 0 <= z < D: + continue + if z == pz + pd - 1: + if ins and ins["x"][0] <= sx <= ins["x"][1] \ + and ins["rows"][0] <= sy <= ins["rows"][1]: + continue + if (sx, sy) not in pr["recess"]: + put(sx, y, z, sx, sy) + else: + put(sx, y, z, sx if z == pz else ix, sy) + + # A TRAY is an open container -- the drawing looks down INTO it, so + # its top-view band is not a lid but the inside of the box, and the + # model has to be hollow. Bands, all measured 1:1 like any other band + # table: `top` is the opening (drawn row -> depth row), `front` the + # near wall seen face-on (drawn row -> elevation), `x` the box's outer + # span and `inner` the opening's, so the difference between them is + # the wall. Four walls stand to the rim, the floor slab lies `floor` + # voxels thick under the opening, and the cavity between them is left + # as air -- which is the whole point, and what an extruded facade can + # never be. Parts (a standing lid) then ride the rim like any object + # on a desk's plane. + if t.get("tray"): + tr = t["tray"] + top0, top1 = tr["top"] + fr0, fr1 = tr["front"] + bx0, bx1 = tr["x"] + ix0, ix1 = tr["inner"] + floor = tr.get("floor", 0) + plane = fr1 - fr0 + 1 # the rim: the wall's height + # Which drawn row lies at depth z. The far rim is the band's first + # row and the near rim the front wall's own, and the drawn inside + # STRETCHES over whatever depth is between them: a box deeper than + # its drawing has rows to spare is the ordinary case once the plot + # stops being the grid, and the alternative -- running out of rows + # and repeating the last one -- would print the wrench twice. + lo, hi = top0 + 1, top1 - 1 # the drawn inside + span = max(1, D - 3) # interior depth rows - 1 + def tray_row(z): + if z == 0: + return top0 + if z == D - 1: + return fr0 + return lo + ((z - 1) * (hi - lo)) // span + + for sx in range(bx0, bx1 + 1): + for z in range(D): + hollow = ix0 <= sx <= ix1 and 0 < z < D - 1 + for y in range(0, (floor if hollow else plane - 1) + 1): + if hollow or y == plane - 1: + # the opening seen from above: the tray's own + # floor and whatever lies in it -- and the rim is + # the same band where the wall meets it + sy = tray_row(z) + if not inside(sx, sy): + continue + put(sx, y, z, sx, sy) + else: + # the wall below the rim: the front band folded up + # it, the drawn face on the front and back layers + # and the de-outlined interior between, exactly as + # a facade extrudes. + # + # NO recess pass here, and it must stay that way: a + # pane sinks by DELETING its front voxel so the one + # behind becomes the pane, and a container's wall is + # one voxel thick -- there is nothing behind it, so + # the front panel simply opened a hole straight into + # the box and you could see the wrench through it. + sy = fr1 - y + if not inside(sx, sy): + continue + px = sx if z in (0, D - 1) else interior(sx, sy, + bx0, bx1) + put(sx, y, z, px, sy) + build_parts(plane) + return vox + + # No base piece at all: the drawing IS its parts (the house stool -- a + # seat and its legs, nothing under them but floor). The plane the parts + # anchor to is the ground itself. + if not t.get("desk"): + build_parts(0) + return vox + f0, f1 = t["desk"]["fascia"] b0, b1 = t["desk"]["base"] plane = (b1 - b0 + 1) + (f1 - f0 + 1) # the desk's top plane + # The desk's own PLOT, when the grid holds more than the desk. Bill's + # grid runs on into the walkable cell, because the drawing puts the + # desk's apron AND the chair pushed up to it in the same tiles -- so + # the desk box has to stop at its own cell (`depth`) and stand on its + # own ground line rather than the grid's, which the chair's feet set + # eight rows lower. The base band's last row IS that ground line by + # definition, and for every desk drawn inside its own grid it is the + # measured one to the row (lab table, lab computers, Center PC, the + # Bike Shop toolbox), so this changes nothing for them. + # ...and in voxels (`depth_px`) plus a back origin (`z`) when the + # desk is shallower than a tile row and leans against something: the + # healing machine's cabinet is 10 deep -- its drawn top band's 9 + # rows plus the front edge -- standing against the wall band, so its + # box runs z 16..25 of a 32-deep plot. + desk_d = t["desk"].get("depth_px") or t["desk"].get("depth", 0) * 8 or D + dz0 = t["desk"].get("z", 0) + dz1 = dz0 + desk_d - 1 + desk_g = b1 + 1 + # ...and the desk's COLUMNS (`x`), when the grid is wider than the + # desk: the healing machine's grid carries its flanking hoses and + # keyboard, and the cabinet is only the middle 16 columns. + dx0, dx1 = t["desk"].get("x", (0, W - 1)) + + # The WALL element: the band the machine backs onto, whose tiles this + # grid claims. The drawing shows it only as the stripe background + # around the tower (the same standing as the potted plants' floor), + # so the block cycles the drawing's own stripe unit -- real pixels of + # column `x`, rows `cycle` -- at wall-band height over the back plot, + # exactly what the neighbouring cells' `wall` pins render. + if t.get("wall"): + wl = t["wall"] + c0, c1 = wl["cycle"] + cn = c1 - c0 + 1 + wx = wl.get("x", 0) + for y in range(wl["h"]): + sy = c0 + (wl["h"] - 1 - y) % cn + for sx in range(W): + for z in range(wl["depth_px"]): + put(sx, y, z, wx, sy) + # the base band, extruded exactly like every lab table's for sy in range(b0, b1 + 1): - y = ground - 1 - sy - for sx in range(W): + y = desk_g - 1 - sy + for sx in range(dx0, dx1 + 1): if not inside(sx, sy): continue - ix = interior(sx, sy, 0, W - 1) - for z in range(D): - put(sx, y, z, sx if z in (0, D - 1) else ix, sy) + ix = interior(sx, sy, dx0, dx1) + for z in range(dz0, dz1 + 1): + put(sx, y, z, sx if z in (dz0, dz1) else ix, sy) for sx, sy in pr["recess"]: - if b0 <= sy <= b1: - vox.pop((sx, ground - 1 - sy, D - 1), None) + if b0 <= sy <= b1 and dx0 <= sx <= dx1: + vox.pop((sx, desk_g - 1 - sy, dz1), None) - # the slab: the fascia rows wrap every side; the lid is the one - # synthesized surface in the model -- the drawing never paints the - # tabletop (its objects cover it), so the lid continues the sibling - # tables' pattern in the drawing's own shades: black rim, white - # highlight courses along the north and west, grey field + # the slab: the fascia rows wrap every side for i, sy in enumerate(range(f0, f1 + 1)): y = plane - 1 - i - for sx in range(W): - for z in range(D): + for sx in range(dx0, dx1 + 1): + for z in range(dz0, dz1 + 1): put(sx, y, z, sx, sy) - field = WHITE if t["desk"].get("lid") == "white" else GREY - for sx in range(W): - for z in range(D): - if sx in (0, W - 1) or z in (0, D - 1): - shade = BLACK - elif sx == 1 or z == 1: - shade = WHITE - else: - shade = field - px = shade_px.get(shade) or shade_px[BLACK] - put(sx, plane - 1, z, px[0], px[1]) - for p in t["parts"]: - x0, x1 = p["x"] - if p["kind"] == "flat": - r0, r1 = p["rows"] - for sy in range(r0, r1 + 1): - # drawn row = depth row by default; `z` renames the - # origin when the flat sits below the desk's own drawn - # top span (the Center PC's keyboard) - z = p.get("z", r0) + (sy - r0) - if not 0 <= z < D: - continue - for sx in range(x0, x1 + 1): - if inside(sx, sy): - put(sx, plane, z, sx, sy) - continue - tr0, tr1 = p["top"] - fr0, fr1 = p["facade"] - pd = p["depth"] - ytp = plane + (fr1 - fr0) - for sx in range(x0, x1 + 1): - # the lid: the part's drawn top laid across its depth from - # the back, last row continuing forward; the front lid row - # is the facade's own top row -- the drawn front-top edge - for z in range(pd): - sy = fr0 if z == pd - 1 else min(tr0 + z, tr1) - while sy <= tr1 and not inside(sx, sy): - sy += 1 - if sy > tr1 and not (z == pd - 1 and inside(sx, fr0)): - continue - put(sx, ytp, z, sx, fr0 if z == pd - 1 else sy) - # the body: facade rows anchored to the desk's top plane - for sy in range(fr0 + 1, fr1 + 1): - y = plane + (fr1 - sy) - if not inside(sx, sy): - continue - ix = interior(sx, sy, x0, x1) - for z in range(pd): - if z == pd - 1: - if (sx, sy) not in pr["recess"]: - put(sx, y, z, sx, sy) + top_band = t["desk"].get("top") + if top_band: + # The lid wears the desk's own drawn top band -- the drawing DOES + # paint this tabletop (the healing machine's white top face with + # its lit west and shaded east strips), so nothing is synthesized + # where it is visible: band rows map back-to-front, the first + # fascia row is the drawn front-top edge, same rule as an upright + # part's lid. Where a part's drawing occludes the band (the + # monitor standing on it), the lid continues the nearest strip + # BESIDE the part -- still the drawing's own pixels, the same + # sibling-pattern rule every synthesized lid follows. + tr0, tr1 = top_band + for z in range(dz0, dz1 + 1): + sy = f0 if z == dz1 else min(tr0 + (z - dz0), tr1) + for sx in range(dx0, dx1 + 1): + px = sx + for p in t["parts"]: + px0, px1 = p["x"] + if p["kind"] in ("flat", "iso", "box"): + r0, r1 = p["rows"] else: - put(sx, y, z, sx if z == 0 else ix, sy) + r0, r1 = p["top"][0], p["facade"][1] + if px0 <= sx <= px1 and r0 <= sy <= r1: + px = px0 - 1 if sx - px0 < px1 - sx else px1 + 1 + px = max(dx0, min(dx1, px)) + break + put(sx, plane - 1, z, px, sy) + else: + # the lid is the one synthesized surface in the model -- the + # drawing never paints the tabletop clear of its objects, so the + # lid continues the sibling tables' pattern in the drawing's own + # shades: black rim, white highlight courses along the north and + # west, grey field + field = WHITE if t["desk"].get("lid") == "white" else GREY + for sx in range(dx0, dx1 + 1): + for z in range(dz0, dz1 + 1): + if sx in (dx0, dx1) or z in (dz0, dz1): + shade = BLACK + elif sx == dx0 + 1 or z == dz0 + 1: + shade = WHITE + else: + shade = field + px = shade_px.get(shade) or shade_px[BLACK] + put(sx, plane - 1, z, px[0], px[1]) + + build_parts(plane) return vox @@ -1024,16 +1824,22 @@ def verify(vox, sp, pr, t): ytop, top, slab = pr["ytop"], pr["top"], t["slab"] T = lambda x: ytop - top[max(0, min(W - 1, x))] + # A desk set answers its own asserts, and answers this first one + # BETTER -- per part rather than per drawing (nothing may stand above + # the part it belongs to). It also has to: a desk with an authored + # plane legitimately stands taller than its drawing's row count, + # because its height is the one thing the grid does not draw. + # A facade-only template (roof_rows == 0) has no roof band, so there + # is no surface to hold level or slope and no coverage to demand of + # it; everything with a roof band answers the full set. + if t.get("parts"): + verify_desk_set(vox, pr, t) + return shell_of(vox) + for (x, y, z), _ in vox.items(): assert y <= T(x), f"voxel pokes through the roof at {x},{y},{z}" - # A desk set answers its own asserts; a facade-only template (roof_rows - # == 0) has no roof band, so there is no surface to hold level or slope - # and no coverage to demand of it; everything with a roof band answers - # the full set. - if t.get("parts"): - verify_desk_set(vox, pr, t) - elif t["roof_rows"] == 0: + if t["roof_rows"] == 0: # its one geometric intent: a straight extrusion, so each column # of the drawing is solid from its lowest voxel to its top. NOT # from the ground -- the drawing's base band is inset like every @@ -1051,24 +1857,76 @@ def verify(vox, sp, pr, t): else: verify_roof(vox, pr, t) - shell = [k for k in vox if not all( + return shell_of(vox) + + +def shell_of(vox): + return [k for k in vox if not all( (k[0] + d[0], k[1] + d[1], k[2] + d[2]) in vox for d in ((1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1)))] - return shell + + +def verify_tray(vox, pr, t): + """An open container: the walls stand unbroken to the rim, the floor + slab is solid under the whole opening, and -- the assert the whole + thing exists for -- the cavity between them is EMPTY. A tray that + fills in is just the extruded box again.""" + D = pr["D"] + tr = t["tray"] + fr0, fr1 = tr["front"] + bx0, bx1 = tr["x"] + ix0, ix1 = tr["inner"] + floor = tr.get("floor", 0) + plane = fr1 - fr0 + 1 + + for sx in range(bx0, bx1 + 1): + for z in range(D): + hollow = ix0 <= sx <= ix1 and 0 < z < D - 1 + if hollow: + for y in range(floor + 1): + assert (sx, y, z) in vox, f"tray floor hole {sx},{y},{z}" + for y in range(floor + 1, plane): + assert (sx, y, z) not in vox, \ + f"tray is not hollow at {sx},{y},{z}" + else: + for y in range(plane): + if (sx, y, z) not in vox: # a pane sank, or undrawn + continue + assert y < plane, f"wall above the rim at {sx},{y},{z}" + # and the walls actually enclose it: every opening column has a wall + # on all four sides + for sx in range(ix0, ix1 + 1): + assert (sx, plane - 1, 0) in vox and (sx, plane - 1, D - 1) in vox, \ + f"opening column {sx} is not walled front and back" + for z in range(1, D - 1): + assert (bx0, plane - 1, z) in vox and (bx1, plane - 1, z) in vox, \ + f"opening row {z} is not walled left and right" def verify_desk_set(vox, pr, t): W, D = pr["W"], pr["D"] - f0, f1 = t["desk"]["fascia"] - b0, b1 = t["desk"]["base"] - plane = (b1 - b0 + 1) + (f1 - f0 + 1) - - # the slab is a full solid under everything on it - for x in range(W): - for z in range(D): - for y in range(b1 - b0 + 1, plane): - assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" + inside = pr["inside"] + if t.get("tray"): + return verify_tray(vox, pr, t) + # the slab is a full solid under everything on it, over the desk's + # own plot (which is the whole grid unless the grid holds something + # standing in front of the desk as well). A desk-less template (the + # house stool) has no slab to demand: its parts anchor to the floor, + # so the plane is 0 and only the per-part asserts below apply. + plane = 0 + dz0, desk_d = 0, 0 + if t.get("desk"): + f0, f1 = t["desk"]["fascia"] + b0, b1 = t["desk"]["base"] + plane = (b1 - b0 + 1) + (f1 - f0 + 1) + desk_d = t["desk"].get("depth_px") or t["desk"].get("depth", 0) * 8 or D + dz0 = t["desk"].get("z", 0) + dx0, dx1 = t["desk"].get("x", (0, W - 1)) + for x in range(dx0, dx1 + 1): + for z in range(dz0, dz0 + desk_d): + for y in range(b1 - b0 + 1, plane): + assert (x, y, z) in vox, f"slab hole at {x},{y},{z}" # each part stands where authored -- upright bodies solid from the # desk to their own drawn top (bar the front layer, whose panes @@ -1079,21 +1937,73 @@ def verify_desk_set(vox, pr, t): if p["kind"] == "flat": r0, r1 = p["rows"] z0 = p.get("z", r0) + at_y = p.get("at", plane) for x in range(x0, x1 + 1): for z in range(max(z0, 0), min(z0 + r1 - r0, D - 1) + 1): - tops[(x, z)] = max(tops.get((x, z), 0), plane) + tops[(x, z)] = max(tops.get((x, z), 0), at_y) + elif p["kind"] == "box": + # its one geometric intent: a solid column from `base` to the + # drawn top wherever the silhouette is drawn, at its own + # elevation -- never touching the desk plane machinery + r0, r1 = p["rows"] + pz = p.get("z", 0) + top = pr["ground"] - 1 - r0 + bot = p.get("base", pr["ground"] - 1 - r1) + for x in range(x0, x1 + 1): + for z in range(pz, pz + p["depth"]): + if not 0 <= z < D: + continue + tops[(x, z)] = max(tops.get((x, z), 0), top) + if z == pz + p["depth"] - 1: + continue + ys = [y for y in range(bot, top + 1) if (x, y, z) in vox] + assert ys == list(range(ys[0], ys[0] + len(ys))) \ + if ys else True, f"box column hole at {x},{z}" + elif p["kind"] == "iso": + # its one geometric intent: a diamond in plan, solid from the + # plane to a FLAT top -- an isometric box that came out + # stepped would mean the un-projection had drifted + pr0, pr1 = p["rows"] + rx = (x1 - x0 + 1) // 2 + rz = rx // 2 + plan = p.get("plan", rz) + h = (pr1 - rz) - rz - pr0 + for x in range(x0, x1 + 1): + dx2 = 2 * x - (x0 + x1) + for dz in range(-plan, plan + 1): + z = p["z"] + dz + d2 = abs(dx2) * plan + 2 * abs(dz) * rx + if not (0 <= z < D and d2 <= (2 * rx + 1) * plan): + continue + tops[(x, z)] = max(tops.get((x, z), 0), plane + h) + for y in range(plane, plane + h + 1): + assert (x, y, z) in vox, \ + f"iso box hole at {x},{y},{z}" else: fr0, fr1 = p["facade"] - ytp = plane + (fr1 - fr0) + ytp = plane + p.get("rise", 0) + (fr1 - fr0) + pz = p.get("z", 0) for x in range(x0, x1 + 1): - for z in range(p["depth"]): + for z in range(pz, pz + p["depth"]): tops[(x, z)] = max(tops.get((x, z), 0), ytp) - if z == p["depth"] - 1: + if z == pz + p["depth"] - 1: continue ys = [y for y in range(plane, ytp + 1) if (x, y, z) in vox] assert ys == list(range(ys[0], ys[0] + len(ys))) \ if ys else True, f"part column hole at {x},{z}" + # the wall element is a full solid at exactly its own height -- a + # hole is a gap in the room's back wall, and anything above it is + # the tower failure the element exists to prevent + if t.get("wall"): + wl = t["wall"] + for x in range(W): + for z in range(wl["depth_px"]): + for y in range(wl["h"]): + assert (x, y, z) in vox, f"wall hole at {x},{y},{z}" + floor_top = plane - 1 if dz0 <= z < dz0 + desk_d else 0 + tops[(x, z)] = max(tops.get((x, z), 0), wl["h"] - 1, + floor_top) for (x, y, z) in vox: assert y <= tops.get((x, z), plane - 1), \ f"voxel above its part at {x},{y},{z}" @@ -1189,7 +2099,8 @@ def main(): out = sys.argv[1] if len(sys.argv) > 1 else "." os.makedirs(out, exist_ok=True) for name, t in TEMPLATES.items(): - sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld")) + sp = sprite(t["tiles"], t.get("seal", ""), t.get("tileset", "overworld"), + t.get("scrub")) pr = profile(sp, t) vox = build(sp, pr, t) shell = verify(vox, sp, pr, t)