diff --git a/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md index 40bea60..faa8597 100644 --- a/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md +++ b/assets/docs/buidling_to_voxel/sprite_to_voxel_methodology.md @@ -61,6 +61,10 @@ cues generalize: | Band containing window/door frames | Vertical facade | Straight extrusion | | Full-width band with a black underline sitting above an inset band | Ledge / awning overhang | Extrusion + protrusion | | Dark `#555` runs beside a facade under a taper | Shadow on the wall beneath an eave | Leave as wall — the geometry above produces the shadow's meaning | +| Scattered light shapes on a dark field, bracketed by TWO full-width black rims, shallow band below the lower rim | The **inside of an open container** seen from above, with contents lying in it | Hollow tray: walls to the rims, floor slab, air between — never an extrusion | +| Ellipse drawn wider than tall (e.g. 9x5) | A horizontal circle seen from above — a mouth, a lid, a pot rim | Cut face of a round hull; the aspect ratio is the proof of the top view | +| Arcs above/below a round object's straight flanks, lowest point at the centre column, often a 1px #555 halo outside | The SAME circles seen curving — ground contact and mouth back-edge, i.e. depth, not narrowing | Strip them from the revolve; run the last body row's disc to the floor | +| A side band shearing sideways as it descends (¾-view) | The projection sliding a receding wall, not the wall's position | Un-project: the wall goes where the plan says | The band table for Red's house, which Blue's house shares verbatim: @@ -156,9 +160,12 @@ Tooling: `voxel_build_verify.py` (builds, asserts, renders previews). 1. Obtain the sprite; sample to native resolution via block centers. 2. Extract palette + silhouette (light-only flood fill, threshold 130); - review the ASCII mask. -3. Segment rows into bands using the Stage-2 cues; write the band table - before writing any geometry code. + review the ASCII mask — rendered large, not hand-counted. +3. Name the real object first (including whether it is hollow, round or + thin — see "Beyond the house"), then segment rows into bands using the + Stage-2 cues; write the band table as prose, one line per row range + with where each band lands, before writing any geometry code. The + correct reading makes the row arithmetic land exactly. 4. Measure taper rates from the mask; derive `T(x)`, `YTOP`, overhangs, `D`. 5. Build: extrude verticals (de-outlined interiors) → ledges → recesses → flat top (mid-row cycling) → sloped solids (overwrite, then trim) → @@ -214,3 +221,75 @@ right for the raw GB palette but comes out white once the atlas is recoloured, turning every sloped end into a black-and-white zip. The drawing's own eave is black / `#555` / black, and using that reads correctly under every palette. + +## Beyond the house: the forms later objects added + +The house is all solid masses — every band either lies flat or extrudes. +Later objects forced the taxonomy open, and each addition came from the +same root move: **name the real 3D form first, then ask which surfaces the +drawing shows.** The recurring failure at every step was the *extruded +picture* — and it has a second-order form that survives re-segmentation. +The Bike Shop's toolbox was re-read from "a prop" into "a cabinet with a +pump beside it": named parts, correct plot, de-outlined sides, and still +wrong, because the region read as a cabinet *front* was the inside of an +open box seen from above. Naming the parts is not enough; every REGION +must answer "what surface of the real object is this?" The reliable +arbiter is arithmetic: the correct reading makes the drawn row counts land +exactly (the toolbox: 1 back-wall rim + 6 interior rows + 1 front rim = 8 += the one-tile plot depth). Forcing rows to fit means the reading is wrong. + +**Hollow forms.** An open container is the one shape whose model must +contain AIR, which no band table or extrusion can produce. The tray +treatment builds four walls to the drawn rims, lays the top-view band on +the floor of the cavity (its contents — a wrench — come along free, since +they are just pixels of that band), and leaves the space between empty. +Two rules only containers hit: the pane-recess pass must never run on a +one-voxel wall (it deletes the front voxel to expose the one behind, and +there is nothing behind — the wall becomes a hole), and the hollowness +needs its own verification assert, because a later change that refills the +cavity leaves every count looking plausible. + +**Round forms.** A drawn ellipse wider than tall is a horizontal circle +seen from above — that one aspect-ratio measurement settles the whole +reading. Straight flanks give diameter and height at once (round in plan, +so drawn width IS depth — the one depth never authored). The arcs above +and below the straight run are the same top and base circles seen curving: +ground contact and mouth edge, not narrowing — revolving them puts the +object on a stem. The hull's chord representation stores one z-interval +per column/row, so a taper is expressible (re-cut the chords, squeeze the +art into the narrowed span so the rim outline survives) but a hollow ring +needs a second chord. Voxel resolution bounds taste: on an 11-wide object +a one-step taper reads as damage and two steps as a cone; pick the step +count and derive the amount. + +**Thin forms.** A line drawing cannot be thick. The air inside a bicycle's +frame is what makes it read as a bicycle; extrude each stroke 5 voxels and +the side faces of neighbouring strokes close every gap off-axis — six +bikes become one dark mass. Standee thickness is a vocabulary +(`PINNED_DEPTH`: 1 for paper, 2 for plates and side-on vehicles, 5 for +silhouettes, 10 for objects with a body), and when a standee looks wrong +the first move is to dump the detector's mask — if the mask is a clean +object, thickness is the problem, not segmentation. + +**Authored masks.** When a drawing shares its tiles and shades with what +it is painted into, nothing automatic can separate them; the profile +carries a pixel mask instead. A person becomes a `figures` card (flat, +leaning with the camera, standing on its feet — because GB character art +is face-on iconography); an object becomes a `mounted` slab (fixed in the +world, holding the wall's plane, keeping its drawn elevation — because a +side-on drawing is a plane parallel to the wall). And when the backdrop is +a *regular* pattern, the mask should be MEASURED, not hand-drawn: +composite the plain backdrop tile over the same grid and flood from the +border through pixels that still match it — what the flood cannot reach is +the object, sprite-pure and exact. + +**Verification, extended.** Isometric previews miss what only the game +shows: shoot in-game at both the ¾ rung and the low rung (front-face holes +and proportion errors are invisible from above), crop and NEAREST-upscale +before judging, and remember the flat rung renders no model at all. Two +cheap renders beat argument: the front-most voxel per (x, y) laid beside +the composited drawing catches anchoring and texel leaks instantly, and +the same render with sunk voxels flagged turns the recess pass into +something you look at. When shared builder code moves, a saved count +baseline diffed after every edit (mind the line endings) is what proves a +generalization is an identity for every model that already shipped. diff --git a/data/voxel_heights.lua b/data/voxel_heights.lua index 55c075c..367b034 100644 --- a/data/voxel_heights.lua +++ b/data/voxel_heights.lua @@ -168,6 +168,10 @@ return { -- 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, + -- the little trees (Celadon Gym's garden, the overworld's cuttable + -- tree): the round hull squashed front to back, 16px of drawn + -- elevation like every other one-cell tree drawing + sapling = 16, relief = 3, bookcase = 32, stair_e = 16, @@ -197,7 +201,27 @@ return { -- cutouts both read wrong for them; the cylinder archetype carves -- one voxel ball per 16x16 cell from the canopy's darkest-pixel -- outline, round in depth, so tree rows become rows of real canopies + -- + -- The cuttable tree ($2D/$2E/$3D/$3E, the four tiles Cut deletes) + -- takes the same hull SQUASHED: it is Celadon Gym's little tree + -- redrawn pixel-for-pixel on this atlas (same 146/256 silhouette, + -- only canopy highlight texels differ), so the two must stay one + -- model -- the scraggly canopy revolves into a gapped ball, the + -- 2-4px trunk into a thin round column, the root flare into a + -- round mound. It used to sit in the `prop` pool as a 5-voxel + -- standee; see GYM's sapling entry for the reading and for what + -- sapling_squash does. Scanned: the 2x2 grid + -- occurs 32 times on this atlas across the towns and routes, and + -- per-tile counts equal the grid count (32 hits for $2D alone), + -- so no stray occurrence renders as a lone hull. The same four + -- ids form grids on FOREST (8, Safari Zone ground art), HOUSE, + -- MANSION, SHIP_PORT and REDS_HOUSE_2 -- different drawings, id + -- collisions, none of this entry's business. Cut itself only + -- swaps the map block to plain grass, so the pin never sees a + -- cut stump. cylinder = { 42, 43, 58, 59, 64, 65, 80, 81 }, + sapling = { 45, 46, 61, 62 }, + sapling_squash = 50, -- the town sign (blockset 8's SE cell): a standing per-pixel slab -- 2 voxels thin, transparency respected -- never a solid box signpost = { 70, 71, 86, 87 }, @@ -220,15 +244,11 @@ return { -- instead -- see there.) wall = { 2, 36 }, - -- the cuttable bush ($2D/$2E/$3D/$3E, the four tiles Cut deletes - -- -- across the whole tileset they appear only in the five - -- cut-tree blocks): a standing per-pixel cutout 5 voxels deep, - -- black-outline segmented with the pixels the outline encloses - -- kept, its drawn grass dither flooding away as background - prop = { 45, 46, 61, 62 }, - -- the ground painted under those pinned props, by the prop tile's - -- own id: the bush stands on plain grass ($2C) -- the very tile - -- Cut leaves behind (field.cutTreeSwaps' after-blocks) -- rather + -- (the cuttable tree $2D/$2E/$3D/$3E moved to the cylinder hull + -- above) + -- the ground painted under the claimed tree cells, by tile id: + -- the tree stands on plain grass ($2C) -- the very tile Cut + -- leaves behind (field.cutTreeSwaps' after-blocks) -- rather -- than whatever flat tile its neighbours vote prop_ground = { [45] = 44, [46] = 44, [61] = 44, [62] = 44 }, }, @@ -345,6 +365,38 @@ return { -- the pin is not copied there. cylinder = { 44, 45, 46, 47, 7, 8, 23, 24 }, + -- Celadon's three little trees ($40/$41 canopy over $50/$51 + -- trunk): a scraggly canopy over a 2-4px trunk flaring into a + -- round root mound. Every drawn row states its own width, which + -- is exactly what the hull revolves -- the canopy turns into a + -- ball with its drawn gaps kept, the trunk into a thin round + -- column, the mound into a round foot. They used to sit in the + -- `prop` pool ("a trunk is not round"), but the standee rendered + -- as the whole 16x16 cell extruded, background and all -- the + -- extruded picture -- and the trunk IS round; the hull reads it + -- right. The same drawing is the overworld's cuttable tree (see + -- OVERWORLD's sapling entry); one drawing, one model. + -- + -- sapling_squash 50 is the one AUTHORED number and the only knob + -- taste moves: the percent of its revolved depth every chord + -- keeps. A full revolve (100) assumes the drawing's width is + -- also its depth, which is honest for the hedge balls and + -- boulders in the `cylinder` pool above but not for a tree -- + -- the trunk is a stick, the crown is more air than wood, and at + -- full width the tree filled a whole cell of depth and read as a + -- boulder wearing bark. 50 halves it to an ellipse in plan; + -- the model stays round in section and centred on the cell. + -- The height is NOT authored: the class's 16 (see the `heights` + -- table at the top of this file) is the drawn elevation, which + -- is also the model's top plane, so anything riding a tree cell + -- lands right. + -- Scanned: the 2x2 grid occurs 3 times on this atlas and only + -- in CELADON_GYM -- cells (2,4), (7,5), (5,7) -- and per-tile + -- counts equal the grid count (3 hits for $40 alone), so no + -- stray occurrence renders as a lone hull. DOJO shares gym.png + -- and places none, so the pin is not copied there. + sapling = { 64, 65, 80, 81 }, + sapling_squash = 50, -- 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 @@ -405,13 +457,9 @@ return { 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 statues. (Celadon's trees $40/$41/$50/$51 lived here + -- too until they moved to the sapling hull above.) + prop = { 2, 56, 18, 19 }, -- The Hall of Fame's recording machine, the one piece of real -- furniture in the tileset. It is drawn 32px wide and THREE tile -- rows tall against the north band, and the detector made a mess @@ -685,7 +733,15 @@ return { -- per-cell hulls rather than boxes canopy = { 4 }, cylinder = { 5, 6, 7, 21, 22, 23, - 35, 36, 37, 38, 39, 53, 54 }, + 35, 36, 37, 38, 39, 53, 54, + -- the Safari Zone's small round trees ($54/$55/$56/$57, + -- one cell, 376 placements across the four safari maps + -- and nowhere else on this tileset): drawn as a canopy + -- ball like the overworld's lone tree, and the detector + -- was boxing them into 16px dither-textured crates. + -- One voxel ball per cell, the same hull the big trees' + -- quarter tiles degrade to + 84, 85, 86, 87 }, -- the stumps ($02/$03/$12/$13): a hull whose drawn top is a CUT -- FACE. The body builds from the bark rows alone, and the drawn -- ellipse of growth rings projects onto the hull's round flat @@ -1488,9 +1544,14 @@ return { -- terrace: their north rim (9/25 = $09/$19) and the -- pedestal course at the south (85/86/87). -- - -- THE ROUND TABLES in full, because the shape is a compromise. The - -- drawing (block 29, and the same four rows split across blocks 45 - -- and 49 in the diner) is + -- THE ROUND TABLES in full, because the shape is a compromise. + -- (The `diner_round_table` template under `buildings` below now + -- models all four placements in full -- octagonal top on its + -- pedestal -- by matching the whole 4x4 grid, which is what a + -- per-tile pin can never do. Everything here stays as its + -- degradation path and as the record of why the pins look the + -- way they do.) The drawing (block 29, and the same four rows + -- split across blocks 45 and 49 in the diner) is -- $09 $27 $27 $19 an octagonal top seen from above, with -- $36 $37 $37 $39 a pedestal drawn below its southern -- $46 $37 $37 $47 rim @@ -1511,7 +1572,7 @@ return { -- 8px is the FAR rim (9/25, and the shared 39/54/57) and the -- pedestal (85/86/87): the far rim is occluded by the 16px top in -- front of it, and the pedestal is meant to sit low. 16px is also - -- the right height against the 8px `stool` chairs drawn around it + -- the right height against the seat-high `stool` chairs around it -- -- a terrace table you sit at, not a footstool. counter = { 9, 21, 25, 36, 37, 38, 39, 41, 48, 49, 52, 53, 54, 57, 85, 86, 87 }, @@ -1541,7 +1602,15 @@ return { -- drawing carries a full black outline with the floor dither -- showing at all four corners, so the standee segments cleanly -- -- and `stool` keeps its own pool, apart from anything it touches. + -- The `diner_stool` template (see `buildings` below) now models + -- every placement in full, like the house stool it copies; these + -- pins are its degradation path. stool = { 7, 8, 23, 24 }, + -- the `diner_stool` template stands 5 voxels (the drawn + -- elevation: the seat's front edge at row 10 over legs 11-14), + -- as the house stool does: whoever sits on a stool cell rides + -- this height, not the 8px class default + heights = { stool = 5 }, -- Deliberately NOT pinned: -- $37 (55) is three different things -- the light half of the -- checkerboard floor, the interior of the round tables, and @@ -1827,6 +1896,18 @@ return { -- 18/19) sitting in a WALKABLE cell, so flat floor until pinned. -- The 8px standee pool, seat height, as in Red's rooms. stool = { 2, 3, 18, 19 }, + -- ...and the height a figure riding that cell stands at, which is + -- the SEAT and not the backrest: the chair's seat is drawn rows + -- 26-31, six of them, so 6. All three Game Freak developers are + -- placed ON their chair cell (CELADON_MANSION_3F objects at (0,4), + -- (3,4) and (0,7)), and VoxelScene.groundAt reads this pin, not + -- the `mansion_computer_desk` model that now draws the chair -- at + -- the class default 8 they floated two voxels over the seat. The + -- same reading INTERIOR's `stool = 5` carries for Bill's chair, + -- which is this drawing with one white margin column instead of + -- two; these four ids are the mansion desks' chairs and nothing + -- else on this atlas, so the override reaches only them. + heights = { stool = 6 }, -- the potted palms: two cells of drawing (68/69 crown, 8/9 fronds, -- 70/71 stem, 24/25 pot), mostly silhouette, so the THIN standee -- pool -- the same numbers the generic HOUSE entry uses, and the @@ -4169,6 +4250,109 @@ return { roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, slab = 3, frontEave = 0, ledge = nil, }, + -- F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7) + -- (1 placement, scan.lua) -- the long table (F07) at two cells + -- wide, the same drawing to the tile everywhere but the interior + -- column count, and the same read to the row: 0-23 the tabletop + -- seen from above, 24-26 the slab's black/#555/black front edge, + -- 27 the #555 shadow that closes it (slab = 3, folded into the + -- band), 28-30 the base with the legs stopping one row short of + -- the grid. Family numbers unchanged; the `table` pin stays as + -- the degradation path, neutralized where this stamps. + { + id = "mansion_square_table", + tiles = { + { 38, 39, 39, 41 }, + { 54, 55, 55, 57 }, + { 54, 55, 55, 57 }, + { 60, 58, 58, 59 }, + }, + roofRows = 28, roofBack = 24, roofFront = 0, roofCycle = { 2, 23 }, + slab = 3, frontEave = 0, ledge = nil, + }, + -- F07c: the Game Freak office's COMPUTER DESK -- the writing desks + -- of CELADON_MANSION_2F cell (0,5) and CELADON_MANSION_3F cells + -- (0,3), (3,3) and (0,6). scan.lua on the 4x4 grid returns those + -- four and nothing else, and each of the four ids that carry the + -- apron and chair rows (2/3/85/86, 18/19) occurs on this atlas ONLY + -- inside them -- so no stray cell can pick this model up. (The + -- same ids are furniture on the HOUSE and FACILITY atlases; those + -- are different images and none of this entry's business.) + -- + -- Rows 0-15 are BILL'S DESK, byte for byte. A whole-crop diff of + -- these tiles against the interior atlas's 11/12/13/14 over + -- 27/28/29/30 comes back empty for all 512 pixels: the same + -- tabletop seen from above -- black rim, white highlight course, + -- grey field -- with the same terminal, cord and 2:1 isometric + -- computer drawn into it. One drawing is one model, so the four + -- parts below are `bills_desk`'s verbatim; that entry carries the + -- readings (paper is flat, the keyboard's keys ride its top face, + -- `plan` = rx makes the computer a cube turned 45 and not a slab). + -- + -- Only the FRONT is redrawn, and it is 6 rows where Bill's is 7: + -- 15 the top's own black front edge. This is the row the + -- lid replaces, so it opens the fascia exactly the way + -- Bill's row 16 does, and the desk stands 7 voxels to + -- his 8 -- measured, not chosen. + -- 16-17 the #555 edge lip and the black seam under it: the + -- desktop's own rim, which is why they are `fascia` -- + -- that band wraps every side, and a desktop's edge is + -- visible from all four. + -- 18-21 the base: the left leg (the black/#555/black at + -- x0-x2), the open apron between, and the DRAWER + -- PEDESTAL at x20-x31 -- two #555 drawer fronts (rows + -- 16-17 and 19-20) inside a black frame. Each is a + -- non-black region sealed behind its own black outline + -- and under 24px, so the measured recess pass sinks it + -- one voxel and the frame stays proud: the drawer gaps + -- come off the pixels, nothing is authored. + -- Row 21 is the ground line and 22 is the measured one. Rows + -- 22-23 are the desk's cast SHADOW, dithered into the checker + -- floor of the walkable cell in front, so the model builds none of + -- them -- they are floor, not furniture. + -- + -- The chair is Bill's chair REDRAWN rather than the same pixels + -- (two white margin columns round the backrest panel where his + -- has one, and it sits at x4-x15 rather than x2-x13), but the same + -- object band for band -- rows 20-21 the backrest top seen from + -- above, 22-31 its elevation -- so it takes his part table with x + -- and `rise` moved: `rise` is the whole plane back down, because + -- the chair stands on the FLOOR and not on the desk. + -- + -- Why the grid runs two tile rows past the desk: the artist drew + -- the apron into the WALKABLE cell in front (2/3 + 85/86), and + -- 2/3 also carry the chair's back. A template claims whole TILES, + -- so reading the apron takes the chair with it -- which is what + -- makes the template owe it, exactly as at Bill's. `desk.depth` + -- stops the desk box at its own two cells; the chair keeps the + -- front one. The `table` pin on the top tiles and the `stool` pin + -- on 2/3/18/19 both stay as the degradation path, neutralized + -- wherever this stamps. + { + id = "mansion_computer_desk", + tiles = { + { 36, 37, 52, 53 }, + { 64, 65, 66, 67 }, + { 2, 3, 85, 86 }, + { 18, 19, 17, 17 }, + }, + 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 2/3 + desk = { fascia = { 15, 17 }, base = { 18, 21 }, depth = 2 }, + parts = { + { kind = "flat", x = { 2, 15 }, rows = { 3, 7 } }, -- the notes + { kind = "upright", x = { 4, 15 }, top = { 8, 12 }, + facade = { 12, 13 }, z = 8, depth = 6 }, -- keyboard + { kind = "upright", x = { 16, 19 }, top = { 8, 8 }, + facade = { 8, 9 }, z = 8, depth = 2 }, -- the cord + { kind = "iso", x = { 19, 30 }, rows = { 1, 13 }, + plan = 6, z = 9 }, -- the computer + { kind = "upright", x = { 4, 15 }, top = { 20, 21 }, + facade = { 22, 31 }, rise = -7, z = 22, depth = 10 }, -- chair + }, + }, }, HOUSE = { @@ -4480,5 +4664,93 @@ return { }, }, }, + + LOBBY = { + -- F09 on the lobby atlas: the Celadon department store's stools + -- -- the diner's chairs, the roof terrace's, the Game Corner's + -- six rows and the four on 1F (58 placements, the scan's only + -- matches on this atlas). A DIFFERENT drawing from the house + -- stool -- it sits one row HIGHER in the tile (seat top rows 4-9 + -- over its front edge at 10 and the legs at 11-14, with a clear + -- floor row below) and its leg detail differs -- but the same + -- object band for band, so it takes the house part table with + -- the bands shifted up one row. The measured ground line (15 + -- here, 16 in the house) shifts with them, so the stand is the + -- same 5 voxels, and the tileset's `stool = 5` height override + -- keeps whoever sits here ON the seat. The old stool standee + -- pins stay as the degradation path. + { + id = "diner_stool", + tiles = { + { 7, 8 }, + { 23, 24 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, + panes = false, + parts = { + { kind = "upright", x = { 2, 13 }, top = { 4, 9 }, + facade = { 10, 14 }, z = 3, depth = 11, + stretch = true }, -- the stool + }, + }, + -- F11: the ROUND TABLE of the diner and the roof terrace -- 4 + -- placements, all on this atlas (CELADON_DINER cells (0,2) and + -- (0,5), CELADON_MART_ROOF (4,2) and (8,4); scan + -- "9,39,39,25;54,55,55,57;70,55,55,71;85,86,87,55" matches + -- nowhere else). This is the drawing the long `counter` note + -- above calls a compromise -- its four interior tiles are $37, + -- unpinnable, so the flat treatment let the whole top BE a 16px + -- disc. The template matches the exact 4x4 grid instead, which + -- is what a per-tile pin can never do, and un-projects the three + -- facings: rows 0-23 the OCTAGONAL top seen from above (24 + -- top-view rows = 24 depth rows, so the plan is the silhouette + -- itself, a `plan` slab 32x24), rows 24-25 the slab's #555/black + -- fascia folded down its rim, rows 26-31 the PEDESTAL seen under + -- the front edge -- two flattened circles, i.e. horizontal discs: + -- the base (diameter 16, drawn cols 8-23, side rows 30-31, its + -- top wearing the drawn shadow-and-white rows 26-29) and the dark + -- column (diameter 6, cols 13-18, rows 26-27 repeating up the + -- shaft), both on the drawn centre x 16 / plan centre z 12. + -- MEASURED: plan, diameters, centres, slab 3. AUTHORED: tabletop + -- plane 8 -- counter height, developer-tuned (the first cut + -- stood it at the flat compromise's 16px and it read too tall) + -- -- plus base height 2 and the 3-voxel column between. `depth` + -- 3 keeps the plot to the drawn plan; the grid's 4th tile row is + -- the pedestal's own drawing plus one floor tile ($37 again, at + -- the southeast corner), which the claim paints as ground. + -- `scrub` repoints the top's interior field -- the four $37 + -- tiles, ALSO the checkerboard floor's light half, which carry + -- the floor's palette in a colorized atlas -- at the same grey + -- sourced from the rim's own field, so the whole top wears the + -- table's palette (the drawn field there is uniform grey; + -- nothing drawn is lost). The old wall/counter pins on the rim + -- tiles stay as the degradation path, and `support` carries the + -- top plane so anything the standee scan finds on these cells + -- rides the tabletop. + { + id = "diner_round_table", + tiles = { + { 9, 39, 39, 25 }, + { 54, 55, 55, 57 }, + { 70, 55, 55, 71 }, + { 85, 86, 87, 55 }, + }, + roofRows = 0, roofBack = 0, roofFront = 0, roofCycle = { 0, 0 }, + slab = 0, frontEave = 0, ledge = nil, depth = 3, + panes = false, support = 8, + scrub = { { 8, 8, 23, 23 } }, + parts = { + { kind = "disc", cx2 = 32, cz2 = 24, r = 8, rise = 0, h = 2, + side = { rows = { 30, 31 }, x = { 13, 18 } }, + cap = { rows = { 26, 29 }, x = { 9, 22 } } }, -- the base + { kind = "disc", cx2 = 32, cz2 = 24, r = 3, rise = 2, h = 3, + side = { rows = { 26, 27 }, x = { 14, 17 } } }, -- the column + { kind = "plan", x = { 0, 31 }, rows = { 0, 23 }, + fascia = { 24, 25 }, fasciaX = { 8, 23 }, + rise = 5 }, -- the top + }, + }, + }, }, } diff --git a/lib/Buildings.lua b/lib/Buildings.lua index c466965..a21e515 100644 --- a/lib/Buildings.lua +++ b/lib/Buildings.lua @@ -457,7 +457,8 @@ local function deskSetModel(sp, pr, t) local function buildParts(plane) for _, p in ipairs(t.parts) do Budget.tick() - local x0, x1 = p.x[1], p.x[2] + local x0 = p.x and p.x[1] or 0 + local x1 = p.x and p.x[2] or (W - 1) 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 @@ -582,6 +583,94 @@ local function deskSetModel(sp, pr, t) end end end + elseif p.kind == "plan" then + -- A PLAN part is a slab whose plan IS the drawn top view: the + -- band's silhouette becomes the footprint pixel for pixel + -- (drawn row = depth row, the same 1:1 every tabletop is drawn + -- with), so an octagonal top stands as an octagon rather than + -- the box no rectangular band can escape. The top layer wears + -- the band itself, outline and all; the rim layers below wear + -- the drawn fascia rows folded down the edge (x clamped into + -- the drawn fascia's span), and the slab's unseen interior the + -- field's dark texel. + local r0, r1 = p.rows[1], p.rows[2] + local f0, f1 = p.fascia[1], p.fascia[2] + local fx0, fx1 = p.fasciaX[1], p.fasciaX[2] + local rise = p.rise or 0 + local h = (f1 - f0 + 1) + 1 + if rise + h > ytop then ytop = rise + h end + local function drawn(sx, z) + return sx >= x0 and sx <= x1 and z >= 0 and z <= r1 - r0 + and inside[(r0 + z) * W + sx] + end + for z = 0, r1 - r0 do + if z >= 0 and z < D then + local sy = r0 + z + for sx = x0, x1 do + if inside[sy * W + sx] then + put(sx, rise + h - 1, z, sy * W + sx) + local edge = not (drawn(sx - 1, z) and drawn(sx + 1, z) + and drawn(sx, z - 1) and drawn(sx, z + 1)) + for y = rise, rise + h - 2 do + if edge then + local fsx = math.max(fx0, math.min(fx1, sx)) + put(sx, y, z, (f0 + (rise + h - 2 - y)) * W + fsx) + else + put(sx, y, z, pr.shadeTexel[DARK]) + end + end + end + end + end + end + elseif p.kind == "disc" then + -- A DISC part is ROUND IN PLAN -- the pedestal column and base + -- the projection can only draw from the front. Centre and + -- radius are measured off the drawn widths (a flattened arc is + -- a horizontal circle seen from above); the circular footprint + -- is synthesized like any continued geometry, and every voxel + -- still wears the drawing: the side folds the drawn face-on + -- rows around the hull (x clamped into the drawn span, rows + -- repeating up the height), and `cap` lays the drawn top-view + -- rows over the top layer's interior, drawn north rows to the + -- plan's north. `cx2`/`cz2` are DOUBLED plan centres, so an + -- even diameter keeps its centre between two voxels instead of + -- limping one off. + local r, rise, h = p.r, p.rise or 0, p.h + local s0, s1 = p.side.rows[1], p.side.rows[2] + local sa0, sa1 = p.side.x[1], p.side.x[2] + local sn = s1 - s0 + 1 + if rise + h > ytop then ytop = rise + h end + local function inDisc(x, z) + local dx = 2 * x + 1 - p.cx2 + local dz = 2 * z + 1 - p.cz2 + return dx * dx + dz * dz <= 4 * r * r + end + local zlo = math.floor((p.cz2 - 2 * r) / 2) + for x = math.floor((p.cx2 - 2 * r) / 2), + math.floor((p.cx2 + 2 * r) / 2) do + for z = math.max(0, zlo), + math.min(D - 1, math.floor((p.cz2 + 2 * r) / 2)) do + if inDisc(x, z) then + local edge = not (inDisc(x - 1, z) and inDisc(x + 1, z) + and inDisc(x, z - 1) and inDisc(x, z + 1)) + for y = rise, rise + h - 1 do + local sx, sy + if p.cap and y == rise + h - 1 and not edge then + local c0, c1 = p.cap.rows[1], p.cap.rows[2] + sy = math.min(c1, c0 + math.floor((z - zlo) + * (c1 - c0 + 1) + / (2 * r))) + sx = math.max(p.cap.x[1], math.min(p.cap.x[2], x)) + else + sy = s0 + (rise + h - 1 - y) % sn + sx = math.max(sa0, math.min(sa1, x)) + end + put(x, y, z, sy * W + sx) + end + end + end + end else local tr0, tr1 = p.top[1], p.top[2] local fr0, fr1 = p.facade[1], p.facade[2] diff --git a/lib/Structures.lua b/lib/Structures.lua index 2c99c8d..725dbac 100644 --- a/lib/Structures.lua +++ b/lib/Structures.lua @@ -601,9 +601,22 @@ local PLANTER_SPRAY = { rows = 24, depth = 5 } -- 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. +-- `squash`, when given, is the PERCENT of its revolved depth every chord +-- keeps -- 100 (or nil) is the identity, 50 halves the hull front to back. +-- +-- A full revolve assumes the drawing's width is also its depth, which is +-- true of a thing that really is round in plan (a hedge ball, a boulder, +-- a trash can). A TREE is round in its canopy and thin at every other +-- reading: the trunk is a stick, the crown is more air than wood, and the +-- drawing is scenery seen from one side. Revolved at full width the little +-- tree eats a whole cell of depth and reads as a boulder wearing bark, so +-- the plan stays a circle and shrinks toward an ellipse: still round in +-- section, still stepping pixel by pixel, just shallower. The chord is +-- re-centred on the mid-plane, so the model neither slides nor detaches +-- from the cells around it. local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows, NYin, spray, baseRows, bodyRows, wellRows, - taperVox) + taperVox, squash) -- 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 @@ -864,6 +877,7 @@ local function roundTemplate(S, map, data, cx, cy, groundTiles, N, capRows, + 0.5)) end if spray and iy < spray.rows then n = math.min(n, spray.depth) end + if squash then n = math.max(1, math.floor(n * squash / 100 + 0.5)) end z0[i] = math.floor(N2 - n / 2 + 0.5) z1[i] = z0[i] + n -- a row the can's body band was repeated into wears the row it @@ -975,6 +989,9 @@ 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 squash then + n = math.max(1, math.floor(n * squash / 100 + 0.5)) + end z0[i] = math.floor(N2 - n / 2 + 0.5) z1[i] = z0[i] + n end @@ -1298,6 +1315,8 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) -- voxels the body band is repeated up to local stumpCap, canCap, canBase, canHeight, canWell, canTaper = 6, 9, 4, 9, 5, 4 + -- the sapling class's depth, as a PERCENT of the revolved chord + local saplingSquash = 50 do local okP, prof = pcall(V.data, "voxel_heights") local entry = okP and type(prof) == "table" and prof.tilesets @@ -1320,6 +1339,9 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) if entry and type(entry.can_taper) == "number" then canTaper = entry.can_taper end + if entry and type(entry.sapling_squash) == "number" then + saplingSquash = entry.sapling_squash + end end -- cells consumed by a 2x2 `canopy` group; the scan runs north to @@ -1439,13 +1461,19 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) 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 + -- 100% is the full revolve, so it is the identity: never signed + -- into the cache key, and never passed, by a class that has no + -- squash of its own + local squash = (s.class == "sapling" and saplingSquash ~= 100) + and saplingSquash or nil local ground = false if data then 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 "") .. "|" + .. (taper and ("|t" .. taper) or "") + .. (squash and ("|q" .. squash) or "") .. "|" .. gsig .. "|" .. table.concat({ S.tileAt[k], S.tileAt[keyOf(cx * 2 + 1, cy * 2)], S.tileAt[keyOf(cx * 2, cy * 2 + 1)], @@ -1454,7 +1482,7 @@ function Structures.buildCylinders(S, map, x0, x1, y0, y1, groundTiles) if not tpl then local tq, tbg = roundTemplate(S, map, data, cx, cy, groundTiles, 16, cap, nil, nil, - base, tall, well, taper) + base, tall, well, taper, squash) tpl = { quads = tq, bg = tbg } roundCache[sig] = tpl end diff --git a/lib/TileShape.lua b/lib/TileShape.lua index 0ea0ada..f9e041e 100644 --- a/lib/TileShape.lua +++ b/lib/TileShape.lua @@ -80,6 +80,14 @@ local FALLBACK_HEIGHTS = { -- 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, + -- the same hull SQUASHED front to back (the profile's sapling_squash, + -- a percent of the revolved depth): the little trees drawn one cell + -- wide -- Celadon Gym's garden trees and the overworld's cuttable + -- tree, which are the same drawing on two atlases. A tree is round in + -- its canopy but is not a boulder: revolved at full width it fills a + -- whole cell of depth, so the plan keeps its circle and shrinks toward + -- an ellipse + sapling = 16, -- 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 @@ -147,6 +155,7 @@ local ART = { canopy = "canopy", stump = "cylinder", can = "cylinder", + sapling = "cylinder", planter = "planter", billboard = "billboard", -- signposts share the billboard treatment but as their own pool at a diff --git a/tests/cuttree_shots.lua b/tests/cuttree_shots.lua new file mode 100644 index 0000000..0d6f1a0 --- /dev/null +++ b/tests/cuttree_shots.lua @@ -0,0 +1,101 @@ +-- Scratch driver: the overworld cuttable tree ($2D/$2E/$3D/$3E, one +-- cell) at PEWTER_CITY (26,4) -- it sits in a gap of the border tree +-- wall, so shoot from the open grass east/west and the path south. +-- Same spots BEFORE and AFTER the pin change. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/cuttree_shots.lua \ +-- SHOT_DIR=.scratchpad/cuttree AB_TAG=before "/c/Program Files/LOVE/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/cuttree") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[cuttree] DRAMATIC_SHAPE is not loaded") + love.event.quit() + return + end + local V = handle.lib + do -- prove the RUNNING mod sees the pin we think it does + local TS = V.require("TileShape") + local shapes = TS.forMap({ tileset = { id = "OVERWORLD", + imageWidth = 128, + imageHeight = 48 } }) + for _, t in ipairs({ 45, 61 }) do + local s = shapes[t] + print("[cuttree] running-mod OVERWORLD tile " .. t .. ": " + .. (s and (tostring(s.class) .. "/" .. tostring(s.art) + .. " h=" .. tostring(s.h)) or "nil")) + end + end + 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 = { + -- open grass west of the tree, edge-on + { map = "PEWTER_CITY", x = 25, y = 4, face = "right", label = "cut_west" }, + -- open grass east of it + { map = "PEWTER_CITY", x = 27, y = 4, face = "left", label = "cut_east" }, + -- the path south, seeing its front over the tree wall gap + { map = "PEWTER_CITY", x = 26, y = 6, face = "up", label = "cut_front" }, + -- one step closer on the gap's south side + { map = "PEWTER_CITY", x = 26, y = 5, face = "up", label = "cut_near" }, + -- Cerulean's lone cut tree at (19,28): open grass to its south + { map = "CERULEAN_CITY", x = 19, y = 30, face = "up", label = "cer_front" }, + { map = "CERULEAN_CITY", x = 19, y = 29, face = "up", label = "cer_near" }, + { map = "CERULEAN_CITY", x = 20, y = 29, face = "up", label = "cer_diag" }, + } + + 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 + for _, rung in ipairs({ 5, 3 }) do + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_r%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("[cuttree] capture missed: " .. path) end + end + else + print("[cuttree] teleport failed: " .. s.map) + end + end + print(("[cuttree] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/diner_shots.lua b/tests/diner_shots.lua new file mode 100644 index 0000000..0d8f3a1 --- /dev/null +++ b/tests/diner_shots.lua @@ -0,0 +1,95 @@ +-- Scratch driver: the Celadon Diner's stools ($07/$08/$23/$24 on LOBBY, +-- one cell each; the pinned `stool` standee pool). Shot at the voxel +-- rung (5) and the flat rung (3) for orientation, front/back/side of +-- the stool at cell (0,4). +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/diner_shots.lua \ +-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \ +-- "/c/Program Files/LOVE/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 "mods/DramaticShapeVoxelMod/.claude/voxelizations") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[diner] DRAMATIC_SHAPE is not loaded") + return + end + local V = handle.lib + do -- prove the RUNNING mod resolves the stool tiles as pinned + local TS = V.require("TileShape") + local shapes = TS.forMap({ tileset = { id = "LOBBY", + imageWidth = 128, + imageHeight = 48 } }) + for _, t in ipairs({ 7, 8, 23, 24 }) do + local s = shapes[t] + print(("[diner] running-mod tile %d: %s"):format(t, + s and (tostring(s.class) .. "/" .. tostring(s.art) + .. " h=" .. tostring(s.height)) or "nil")) + end + end + 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 = { + -- the stool at (0,4), seen from the south = its front (legs row) + { map = "CELADON_DINER", x = 0, y = 6, face = "up", label = "stool_front" }, + -- the same stool from the north = its back + { map = "CELADON_DINER", x = 0, y = 2, face = "down", label = "stool_back" }, + -- edge-on from the east, with the table beside it in frame + { map = "CELADON_DINER", x = 2, y = 4, face = "left", label = "stool_side" }, + } + + 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 + for _, rung in ipairs({ 5, 3 }) do + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_r%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("[diner] capture missed: " .. path) end + end + else + print("[diner] teleport failed: " .. s.map) + end + end + print(("[diner] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/mansion_desk_shots.lua b/tests/mansion_desk_shots.lua new file mode 100644 index 0000000..181b9e4 --- /dev/null +++ b/tests/mansion_desk_shots.lua @@ -0,0 +1,102 @@ +-- Scratch driver: the Game Freak office computer desks (buildings +-- template mansion_computer_desk). CELADON_MANSION_2F cell (0,5) and +-- CELADON_MANSION_3F cells (0,3)/(3,3)/(0,6). Shot at the voxel rung +-- (5), the mid rung (4) and the flat rung (3) for orientation. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_desk_shots.lua \ +-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \ +-- AB_TAG=before "/c/Program Files/LOVE/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 "mods/DramaticShapeVoxelMod/.claude/voxelizations") + local TAG = os.getenv("AB_TAG") or "shot" + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[deskshot] DRAMATIC_SHAPE is not loaded") + return + end + local V = handle.lib + do -- prove the RUNNING mod's profile carries the new template + local ok, s = pcall(V.data, "voxel_heights") + local found = "NO" + if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then + for _, t in ipairs(s.buildings.MANSION) do + if t.id == "mansion_computer_desk" then + found = ("YES fascia=%d-%d base=%d-%d parts=%d") + :format(t.desk.fascia[1], t.desk.fascia[2], + t.desk.base[1], t.desk.base[2], #t.parts) + end + end + end + print("[deskshot] running-mod mansion_computer_desk: " .. found) + end + 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 = { + -- 2F: the lone desk at (0,5):(1,6). Stand just south of it, facing + -- north, so the camera looks along the desk's front face. + { map = "CELADON_MANSION_2F", x = 1, y = 7, face = "up", label = "desk2f" }, + -- and from the east, to see the drawer pedestal and the chair in + -- profile against the checker floor + { map = "CELADON_MANSION_2F", x = 2, y = 6, face = "left", label = "desk2f_side" }, + -- 3F: the pair at (0,3) and (3,3), both in frame from between them + { map = "CELADON_MANSION_3F", x = 2, y = 5, face = "up", label = "desk3f_pair" }, + -- 3F: the south desk at (0,6), close in + { map = "CELADON_MANSION_3F", x = 1, y = 8, face = "up", label = "desk3f" }, + } + + 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 + for _, rung in ipairs({ 5, 4, 3 }) do + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, 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("[deskshot] capture missed: " .. path) end + end + else + print("[deskshot] teleport failed: " .. s.map) + end + end + print(("[deskshot] %d shots into %s (tag %s)"):format(shots, ROOT, TAG)) + love.event.quit() +end diff --git a/tests/mansion_shots.lua b/tests/mansion_shots.lua new file mode 100644 index 0000000..17637d0 --- /dev/null +++ b/tests/mansion_shots.lua @@ -0,0 +1,91 @@ +-- Scratch driver: the CELADON_MANSION_1F square table (buildings +-- template mansion_square_table, cells (0,6):(1,7)). Shot at the voxel +-- rung (5) and the flat rung (3) for orientation, front/back/side. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/mansion_shots.lua \ +-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \ +-- "/c/Program Files/LOVE/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 "mods/DramaticShapeVoxelMod/.claude/voxelizations") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[mansion] DRAMATIC_SHAPE is not loaded") + return + end + local V = handle.lib + do -- prove the RUNNING mod's profile carries the new template + local ok, s = pcall(V.data, "voxel_heights") + local found = false + if ok and type(s) == "table" and s.buildings and s.buildings.MANSION then + for _, t in ipairs(s.buildings.MANSION) do + if t.id == "mansion_square_table" then found = true end + end + end + print("[mansion] running-mod mansion_square_table: " .. tostring(found)) + end + 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 = { + -- the rung-5 camera looks north from ~4 cells south of the player, so + -- the room's 16-voxel south wall hides the 6-voxel table at the low + -- rung; rungs 4 and 3 pitch over it. Stand just south of the table. + { map = "CELADON_MANSION_1F", x = 1, y = 8, face = "up", label = "sqtable" }, + { map = "CELADON_MANSION_1F", x = 2, y = 6, face = "left", label = "sqtable_beside" }, + } + + 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 + for _, rung in ipairs({ 5, 4, 3 }) do + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_r%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("[mansion] capture missed: " .. path) end + end + else + print("[mansion] teleport failed: " .. s.map) + end + end + print(("[mansion] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/safari_shots.lua b/tests/safari_shots.lua new file mode 100644 index 0000000..97a760c --- /dev/null +++ b/tests/safari_shots.lua @@ -0,0 +1,96 @@ +-- Scratch driver: the Safari Zone's decorations — the "cut"-style round +-- trees ($54/$55/$56/$57, one cell) and the stump/bush rows ($02/$03/ +-- $12/$13) — front and back, at the voxel rung (5) and the flat rung (3) +-- for orientation. Same spots BEFORE and AFTER the pin change. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/safari_shots.lua \ +-- SHOT_DIR=.scratchpad/safari AB_TAG=before "/c/Program Files/LOVE/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/safari") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[safari] DRAMATIC_SHAPE is not loaded") + return + end + local V = handle.lib + do -- what does the RUNNING mod think tile 84 on FOREST is? + local TS = V.require("TileShape") + local shapes = TS.forMap({ tileset = { id = "FOREST", + imageWidth = 128, + imageHeight = 48 } }) + local s = shapes[84] + print("[safari] running-mod tile 84: " + .. (s and (tostring(s.class) .. "/" .. tostring(s.art) + .. " authored=" .. tostring(s.authored)) or "nil")) + end + 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 = { + -- tree row at (2..5, 3), seen from the south = its front + { map = "SAFARI_ZONE_EAST", x = 4, y = 5, face = "up", label = "trees_front" }, + -- the same row from the north = its back + { map = "SAFARI_ZONE_EAST", x = 4, y = 1, face = "down", label = "trees_back" }, + -- the long tree line at (2..9, 6) + { map = "SAFARI_ZONE_EAST", x = 6, y = 8, face = "up", label = "treeline" }, + -- the stump/bush row along the top edge (8..19, 0) + { map = "SAFARI_ZONE_EAST", x = 12, y = 2, face = "up", label = "stumps_front" }, + -- Safari Center's west tree column, edge-on + { map = "SAFARI_ZONE_CENTER", x = 2, y = 4, face = "left", label = "center_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 + for _, rung in ipairs({ 5, 3 }) do + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_r%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("[safari] capture missed: " .. path) end + end + else + print("[safari] teleport failed: " .. s.map) + end + end + print(("[safari] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/table_shots.lua b/tests/table_shots.lua new file mode 100644 index 0000000..f72a574 --- /dev/null +++ b/tests/table_shots.lua @@ -0,0 +1,96 @@ +-- Scratch driver: the round tables of the Celadon diner and the Mart +-- roof terrace (the diner_round_table template's four placements). +-- Shot at the voxel rung (5) and the flat rung (3), front/back/side of +-- the diner table at cells (0,5):(1,6) plus the terrace pair. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/table_shots.lua \ +-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxelizations \ +-- AB_TAG=before "/c/Program Files/LOVE/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 "mods/DramaticShapeVoxelMod/.claude/voxelizations") + local TAG = os.getenv("AB_TAG") or "shot" + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[table] DRAMATIC_SHAPE is not loaded") + return + end + local V = handle.lib + local Buildings = V.require("Buildings") + 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 = { + -- the diner table at (0,5):(1,6), seen from the south = its front + { map = "CELADON_DINER", x = 1, y = 7, face = "up", label = "diner_front" }, + -- the same table from the north = its back (standing between the two) + { map = "CELADON_DINER", x = 0, y = 4, face = "down", label = "diner_back" }, + -- edge-on from the east + { map = "CELADON_DINER", x = 3, y = 5, face = "left", label = "diner_side" }, + -- the roof terrace's table at (4,2):(5,3), from the south + { map = "CELADON_MART_ROOF", x = 4, y = 4, face = "up", label = "roof_front" }, + } + + 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 + for _, rung in ipairs({ 5, 3 }) do + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + -- prove which models the RUNNING mod built for this map + local st = Buildings.stats() + local keys = {} + for k, v in pairs(st) do + keys[#keys + 1] = ("%s(v=%d)"):format(k, v.voxels) + end + table.sort(keys) + print(("[table] %s r%d models: %s"):format(s.label, rung, + #keys > 0 and table.concat(keys, " ") or "none")) + local path = ("%s/%s_%s_r%d.png"):format(ROOT, TAG, 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("[table] capture missed: " .. path) end + end + else + print("[table] teleport failed: " .. s.map) + end + end + print(("[table] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tests/tile_pick.lua b/tests/tile_pick.lua new file mode 100644 index 0000000..2bd95c8 --- /dev/null +++ b/tests/tile_pick.lua @@ -0,0 +1,816 @@ +-- Driver: the tile picker -- point at a tile, get the voxelize prompt. +-- +-- The voxelize-tiles skill is driven per TILE, and its first argument is +-- always the same thing: which map, and which cell on it. Finding that pair +-- by hand means walking the game to the object, counting cells off a corner +-- and hoping the count is right. This is the same window the arena editor is +-- -- the map drawn from its OWN art, the tiles the cartridge holds, through +-- the game's own palette -- with a cursor on it instead of an arena, and one +-- button: COPY PROMPT. +-- +-- What lands on the clipboard is exactly: +-- +-- run voxelize-tile on the tile found in PALLET_TOWN- (5,4). Return +-- screenshots of the voxelization in .claude/voxelizations/ +-- +-- on one line, ready to paste into Claude. Every copy is also appended to +-- SHOT_DIR/prompts.txt and printed to the console, because a session of this +-- is a LIST of jobs -- you walk a map picking out every object worth shape +-- and hand the list over at the end, rather than pasting one and waiting. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tile_pick.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +-- +-- from the PROJECT ROOT, with this mod enabled. lovec rather than love, so +-- the console is attached and the transcript alone is the list. +-- +-- ------- the keys +-- +-- arrows move the cursor (hold shift for 5 cells) +-- click put the cursor on that tile +-- [ ] previous / next map (shift: 10 at a time) +-- c / enter COPY PROMPT for the tile under the cursor +-- z zoom the inset in / out (shift: out) +-- g what the plan shows: the map's own tiles, the walk grid over +-- them, or the walk grid alone +-- m HIDE the plan, so the window is the game -- the panel keeps +-- the coordinates, the inset and the button, so a tile is +-- still copied from here while looking at it standing up in 3D +-- t stand the player on this tile, so the 3D view behind the +-- panel is looking at the thing you are about to voxelize +-- y screenshot (shift: with this panel in it) +-- l re-print every prompt copied so far +-- F1 the key legend on screen +-- escape quit +-- +-- ------- environment +-- +-- TILE_MAPS=ID,ID work this list instead of every map in the game +-- TILE_START=ID open on this map +-- TILE_OUT=path where prompts.txt is written +-- TILE_RUNG=N the voxel angle rung to sit on (default 3) +-- SHOT_DIR=path screenshots, and the default prompt-list directory +return function(game) + local U = dofile("tests/drivers/util.lua") + + local function truthy(v) return v ~= nil and v ~= "" and v ~= "0" end + + local DIR = os.getenv("SHOT_DIR") + if not DIR or DIR == "" then DIR = ".scratchpad/tile_pick" end + local OUT = os.getenv("TILE_OUT") + if not OUT or OUT == "" then OUT = DIR .. "/prompts.txt" end + local RUNG = tonumber(os.getenv("TILE_RUNG") or "") or 3 + + -- U.shot's own mkdir is Unix-flavoured and does nothing on Windows, which + -- is where this tool is driven from -- so the shell is asked in its own + -- language, or the screenshots and the prompt list never reach disk. + local WINDOWS = love.system and love.system.getOS() == "Windows" + local function mkdirp(dir) + if not dir or dir == "" then return end + if WINDOWS then + local d = dir:gsub("/", "\\") + os.execute('if not exist "' .. d .. '" mkdir "' .. d .. '"') + else + os.execute('mkdir -p "' .. dir .. '" 2>/dev/null') + end + end + mkdirp(DIR) + mkdirp(OUT:match("^(.*)[/\\][^/\\]+$")) + + -- ------- the mod + -- + -- Only for the camera rung: the picking itself is engine-only, but the + -- view behind the panel is what `t` is for, and a flat 2D world behind a + -- 3D-tile picker would be looking at the wrong thing. + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + local Pipelines = require("src.render.Pipelines") + local PaletteFX = require("src.render.PaletteFX") + local Pokemon = require("src.pokemon.Pokemon") + + local wasRung + if lib then + local DayNight = lib.require("DayNight") + DayNight.setting:setValue("day") -- one light to look at them all in + wasRung = Pipelines.level("voxel") + Pipelines.setLevel("voxel", RUNG) + else + U.log("DRAMATIC_SHAPE is not loaded -- the picker still works, but the " + .. "view behind it will be the flat 2D map") + end + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) } + game.save.player.name = "RED" + + -- ------- which maps + -- + -- EVERY map, unlike the arena tools: a tile worth voxelizing is as likely + -- to be a shop counter or a bedroom desk as anything on a route, and those + -- are exactly the maps a "where can a fight happen" filter throws away. + local ids = {} + local explicit = os.getenv("TILE_MAPS") + if explicit and explicit ~= "" then + for id in explicit:gmatch("[^,%s]+") do ids[#ids + 1] = id end + else + for id, def in pairs(game.data.maps) do + if type(id) == "string" and type(def) == "table" and def.width then + ids[#ids + 1] = id + end + end + table.sort(ids) + end + if #ids == 0 then + U.log("no maps to work on") + return + end + + local S = { + i = 1, + map = nil, + cx = 0, cy = 0, -- the cursor, in cells + plan = nil, + tiles = nil, -- the map baked from its own tileset + planMode = 1, + showPlan = true, -- is the plan drawn at all + zoom = 5, -- how many cells across the inset shows + msg = "", + legend = true, + copied = {}, -- every prompt this session, in order + shooting = false, + quit = false, + planRect = nil, -- where the plan landed, for the mouse + buttonRect = nil, -- and the button + } + + local queue = {} + local function post(cmd, arg) queue[#queue + 1] = { cmd = cmd, arg = arg } end + local function say(fmt, ...) + S.msg = select("#", ...) > 0 and fmt:format(...) or fmt + end + local function mapId() return ids[S.i] end + + -- ------- the prompt + -- + -- One line, and the ONLY thing this tool exists to produce. Kept in one + -- function so the text that reaches the clipboard, the console and the + -- file is provably the same text. + local function promptFor(id, cx, cy) + return ("run voxelize-tile on the tile found in %s- (%d,%d). " + .. "Return screenshots of the voxelization in " + .. ".claude/voxelizations/"):format(id, cx, cy) + end + + local function copyPrompt() + local text = promptFor(mapId(), S.cx, S.cy) + local ok = false + if love.system and love.system.setClipboardText then + ok = pcall(love.system.setClipboardText, text) + end + S.copied[#S.copied + 1] = text + -- The clipboard holds ONE of these and the session produces many, so the + -- file is not a convenience -- it is where the list actually lives. Open + -- per copy and closed again: a tool driving a game can be killed at any + -- moment, and a buffered list is a lost afternoon. + local f = io.open(OUT, "a") + if f then + f:write(text, "\n") + f:close() + end + U.log("PROMPT " .. text) + say("%s (%d,%d) copied%s -- %d so far", mapId(), S.cx, S.cy, + ok and "" or " to the list only (no clipboard here)", #S.copied) + end + + -- ------- what the map looks like from above + -- + -- Lifted from the arena editor, and for its reason: the plan is the + -- surface a tile is chosen on, and a grid of coloured squares is a map of + -- the walk grid rather than of the place. You are picking out a bookshelf. + -- Baked once per map into a canvas, during UPDATE rather than inside a + -- draw, because it binds a canvas of its own. + local PLAN_MAX = 2048 + + local function paletteFor(m) + local ow = game.overworld + if ow and ow.paletteNameFor then + local okName, name = pcall(ow.paletteNameFor, ow, m) + if okName and name then + local okPal, colours = pcall(PaletteFX.pal, game.data, name) + if okPal and colours then return colours end + end + end + local okOg, og = pcall(PaletteFX.ogBg) + return okOg and og or nil + end + + local function bakeTiles() + if S.tiles then pcall(S.tiles.release, S.tiles) end + S.tiles = nil + local m = S.map + if not (m and m.renderer and love.graphics and love.graphics.newCanvas) then + return + end + local w, h = m.widthCells * 16, m.heightCells * 16 + local scale = math.min(1, PLAN_MAX / math.max(w, h)) + local okNew, canvas = pcall(love.graphics.newCanvas, + math.max(1, math.floor(w * scale)), + math.max(1, math.floor(h * scale))) + if not (okNew and canvas) then return end + -- Nearest, unlike the arena editor's plan: that one is a whole route + -- minified to a panel, where nearest keeps a moire. This one gets + -- magnified in the inset, where a tile has to be READABLE -- linear + -- there is a smear of a bookshelf, and the drawing is the whole point. + pcall(canvas.setFilter, canvas, "nearest", "nearest") + + local g = love.graphics + local prev = g.getCanvas() + local wasTrue = m.renderer.trueColor + m.renderer.trueColor = false + local ok, err = pcall(function() + g.push("all") + g.origin() + g.setCanvas(canvas) + g.clear(0, 0, 0, 0) + g.setBlendMode("alpha") + g.setColor(1, 1, 1, 1) + g.scale(scale, scale) + local shader = PaletteFX.shader() + local colours = paletteFor(m) + if shader and colours then + g.setShader(shader) + PaletteFX.sendColors(shader, colours) + end + m.renderer:drawMapOnly(0, 0, w, h) + g.pop() + end) + m.renderer.trueColor = wasTrue + if prev then pcall(g.setCanvas, prev) else pcall(g.setCanvas) end + if not ok then + pcall(canvas.release, canvas) + U.log("could not bake the map's tiles: " .. tostring(err)) + return + end + S.tiles = canvas + end + + local PLAN_MODES = { "both", "tiles", "grid" } + + local CELL_COLOUR = { + open = { 0.78, 0.74, 0.63 }, + grass = { 0.33, 0.60, 0.30 }, + water = { 0.24, 0.44, 0.78 }, + warp = { 0.85, 0.68, 0.20 }, + solid = { 0.17, 0.17, 0.20 }, + } + + local function buildPlan() + local m = S.map + if not m then S.plan = nil return end + local w, h = m.widthCells, m.heightCells + local plan = { w = w, h = h } + for cy = 0, h - 1 do + for cx = 0, w - 1 do + local kind + if not m:isWalkableCell(cx, cy) then + kind = m:isWaterCell(cx, cy) and "water" or "solid" + elseif m:warpAtCell(cx, cy) or m:isWarpTileCell(cx, cy) then + kind = "warp" + elseif m.isGrassCell and m:isGrassCell(cx, cy) then + kind = "grass" + else + kind = "open" + end + plan[cy * w + cx] = kind + end + end + S.plan = plan + end + + -- ------- moving between maps + + local function enterMap(index) + S.i = ((index - 1) % #ids) + 1 + local id = mapId() + local ok, err = pcall(function() U.teleport(game, id, 1, 1, "down") end) + if not ok then + S.map, S.plan = nil, nil + say("could not enter %s (%s)", id, tostring(err)) + U.log(("SKIP %s -- could not enter (%s)"):format(id, tostring(err))) + return + end + S.map = game.overworld.map + buildPlan() + bakeTiles() + -- the middle of the map, which is where the furniture tends to be + S.cx = math.floor(S.map.widthCells / 2) + S.cy = math.floor(S.map.heightCells / 2) + say("%s -- %dx%d cells", id, S.map.widthCells, S.map.heightCells) + U.log(("MAP %s (%d/%d) %dx%d cells"):format(id, S.i, #ids, + S.map.widthCells, S.map.heightCells)) + end + + -- ------- the frame + -- + -- The driver harness turns the frame cap off (a scripted run is not + -- paced), so each step is held to a 60th of a second by hand. + local lastT = love.timer.getTime() + local function step(n) + for _ = 1, (n or 1) do + local rest = (1 / 60) - (love.timer.getTime() - lastT) + if rest > 0 then love.timer.sleep(rest) end + lastT = love.timer.getTime() + U.wait(1) + end + end + + local function shoot(withHud) + -- the plan being away is part of what a panel shot IS, or the two write + -- one file twice and whichever came last is the only one kept + local tag = "" + if withHud then tag = S.showPlan and "_panel" or "_panel_noplan" end + local name = ("%s_x%d_y%d%s"):format(mapId():lower(), S.cx, S.cy, tag) + local path = ("%s/%s.png"):format(DIR, name) + S.shooting = not withHud + game.capturePath = path + for _ = 1, 120 do + if not game.capturePath then break end + step(1) + end + step(2) + S.shooting = false + local f = io.open(path, "rb") + if f then + f:close() + say("shot %s.png", name) + U.log("SHOT " .. path) + else + say("screenshot did not reach disk: %s", path) + end + end + + -- Stand the player on the tile, so the world behind the panel is looking + -- at the thing about to be voxelized. The tile itself is usually SOLID -- + -- a counter, a shelf, a house -- so the walk of shame outwards: the + -- nearest walkable cell to it, searched in rings, which is where a player + -- would have to stand to see it anyway. + local function standHere() + local m = S.map + if not m then return end + for r = 0, 6 do + for dy = -r, r do + for dx = -r, r do + if math.max(math.abs(dx), math.abs(dy)) == r then + local x, y = S.cx + dx, S.cy + dy + if m:inBounds(x, y) and m:isWalkableCell(x, y) then + game.overworld.player.cellX = x + game.overworld.player.cellY = y + say("standing at %d,%d", x, y) + return + end + end + end + end + end + say("nowhere walkable within 6 cells of %d,%d", S.cx, S.cy) + end + + -- ------- the keys + + local function shifted() + return love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift") + end + + local function moveCursor(dx, dy) + local m = S.map + if not m then return end + local n = shifted() and 5 or 1 + S.cx = math.max(0, math.min(m.widthCells - 1, S.cx + dx * n)) + S.cy = math.max(0, math.min(m.heightCells - 1, S.cy + dy * n)) + end + + local function handle(key) + if key == "left" then moveCursor(-1, 0) return true end + if key == "right" then moveCursor(1, 0) return true end + if key == "up" then moveCursor(0, -1) return true end + if key == "down" then moveCursor(0, 1) return true end + if key == "[" then post("map", shifted() and -10 or -1) return true end + if key == "]" then post("map", shifted() and 10 or 1) return true end + if key == "c" or key == "return" or key == "kpenter" then + copyPrompt() + return true + end + if key == "z" then + S.zoom = math.max(3, math.min(21, S.zoom + (shifted() and 2 or -2))) + return true + end + if key == "g" then + S.planMode = (S.planMode % #PLAN_MODES) + 1 + say("plan: %s", PLAN_MODES[S.planMode]) + return true + end + -- The plan covers most of the window, and the thing UNDER it is the game + -- -- the tile standing up in 3D, which is what the voxelizing is for. + -- With the plan away, the panel still carries the coordinates, the inset + -- and the button, so a tile can still be copied while looking at it. + if key == "m" then + S.showPlan = not S.showPlan + say("plan %s", S.showPlan and "shown" or "hidden -- the game is under it") + return true + end + if key == "t" then standHere() return true end + if key == "y" then post("shot", shifted() and "hud" or nil) return true end + if key == "l" then + if #S.copied == 0 then + U.log("nothing copied yet") + else + for i, t in ipairs(S.copied) do U.log(("%3d %s"):format(i, t)) end + end + say("%d prompt%s printed to the console", #S.copied, + #S.copied == 1 and "" or "s") + return true + end + if key == "f1" then S.legend = not S.legend return true end + if key == "escape" then S.quit = true return true end + return false + end + + local innerKey = love.keypressed + function love.keypressed(key, scancode, isrepeat) + local ok, claimed = pcall(handle, key) + if ok and claimed then return end + if not ok then U.log("key error: " .. tostring(claimed)) end + if innerKey then return innerKey(key, scancode, isrepeat) end + end + + -- The mouse is the reason this tool is faster than counting cells: click + -- the bookshelf, read the coordinates back off the panel, copy. Both hit + -- tests read rectangles the draw left behind, so what is clickable is + -- exactly what was drawn -- no second copy of the layout to drift. + local innerMouse = love.mousepressed + function love.mousepressed(x, y, button, istouch, presses) + if button == 1 then + local b = S.buttonRect + if b and x >= b[1] and y >= b[2] and x <= b[1] + b[3] + and y <= b[2] + b[4] then + copyPrompt() + return + end + local r = S.planRect + if r and S.map and x >= r.x and y >= r.y and x <= r.x + r.w + and y <= r.y + r.h then + S.cx = math.max(0, math.min(S.map.widthCells - 1, + math.floor((x - r.x) / r.cell))) + S.cy = math.max(0, math.min(S.map.heightCells - 1, + math.floor((y - r.y) / r.cell))) + return + end + end + if innerMouse then + return innerMouse(x, y, button, istouch, presses) + end + end + + -- ------- the overlay + + local font = love.graphics.newFont(13) + local smallFont = love.graphics.newFont(11) + local bigFont = love.graphics.newFont(16) + + local function drawPlan(x, y, w, h) + local plan = S.plan + S.planRect = nil + if not plan then return end + local g = love.graphics + local cell = math.min(w / plan.w, h / plan.h) + if cell < 1 then cell = 1 end + local pw, ph = plan.w * cell, plan.h * cell + local ox, oy = x + (w - pw) / 2, y + (h - ph) / 2 + S.planRect = { x = ox, y = oy, w = pw, h = ph, cell = cell } + + g.setColor(0, 0, 0, 0.55) + g.rectangle("fill", ox - 4, oy - 4, pw + 8, ph + 8) + + local mode = PLAN_MODES[S.planMode or 1] + if S.tiles and mode ~= "grid" then + g.setColor(1, 1, 1, 1) + g.draw(S.tiles, ox, oy, 0, pw / S.tiles:getWidth(), + ph / S.tiles:getHeight()) + end + if mode ~= "tiles" then + local alpha = (mode == "grid" or not S.tiles) and 1 or 0.22 + for cy = 0, plan.h - 1 do + for cx = 0, plan.w - 1 do + local kind = plan[cy * plan.w + cx] + local c = CELL_COLOUR[kind] + if c and not (alpha < 1 and kind == "open") then + g.setColor(c[1], c[2], c[3], alpha) + g.rectangle("fill", ox + cx * cell, oy + cy * cell, cell, cell) + end + end + end + end + + local ow = game.overworld + if ow and ow.player then + g.setColor(1, 1, 1, 0.8) + g.circle("fill", ox + (ow.player.cellX + 0.5) * cell, + oy + (ow.player.cellY + 0.5) * cell, math.max(2, cell * 0.4)) + end + + -- The cursor, with crosshair arms out to the edges of the plan. On a + -- route the cell is three pixels across and a box round it is invisible; + -- the arms are what actually says WHICH one is selected. + local cx, cy = ox + S.cx * cell, oy + S.cy * cell + g.setColor(1, 0.85, 0.2, 0.35) + g.setLineWidth(1) + g.line(ox, cy + cell / 2, ox + pw, cy + cell / 2) + g.line(cx + cell / 2, oy, cx + cell / 2, oy + ph) + g.setColor(1, 0.85, 0.2, 1) + g.setLineWidth(2) + g.rectangle("line", cx - 1, cy - 1, cell + 2, cell + 2) + g.setLineWidth(1) + g.setColor(1, 1, 1, 1) + end + + -- The tile itself, magnified. The whole judgement this tool supports is + -- "is THAT the drawing I mean to voxelize", and at plan scale a cell is a + -- few pixels. Drawn as a scissored blow-up of the same baked canvas rather + -- than a second render of the map: one bake, one truth. + local function drawInset(x, y, size) + local g = love.graphics + g.setColor(0, 0, 0, 0.7) + g.rectangle("fill", x - 3, y - 3, size + 6, size + 6) + if not (S.tiles and S.map) then return end + local span = S.zoom -- cells across the inset + local pxPerCell = S.tiles:getWidth() / S.map.widthCells + local mag = size / (span * pxPerCell) + -- the top-left canvas pixel the inset starts at, so the cursor's cell + -- sits in the middle of it + local sx = (S.cx + 0.5 - span / 2) * pxPerCell + local sy = (S.cy + 0.5 - span / 2) * pxPerCell + g.setScissor(x, y, size, size) + g.setColor(1, 1, 1, 1) + g.draw(S.tiles, x - sx * mag, y - sy * mag, 0, mag, mag) + g.setScissor() + local cell = size / span + local mx = x + size / 2 - cell / 2 + local my = y + size / 2 - cell / 2 + g.setColor(1, 0.85, 0.2, 1) + g.setLineWidth(2) + g.rectangle("line", mx, my, cell, cell) + g.setLineWidth(1) + g.setColor(1, 1, 1, 1) + end + + local LEGEND = { + "arrows move the cursor (shift x5)", + "click put the cursor on that tile", + "[ ] previous / next map (shift x10)", + "c COPY PROMPT (also enter, also the button)", + "z zoom the inset in (shift: out)", + "g plan: tiles / walk grid / both", + "m hide the plan (the game is under it)", + "t stand the player on this tile", + "y screenshot (shift: with this panel)", + "l list every prompt copied so far", + "F1 this list escape quit", + } + + local function drawOverlay() + local g = love.graphics + local W, H = g.getDimensions() + local panel = math.min(320, math.floor(W * 0.34)) + + g.setColor(0, 0, 0, 0.72) + g.rectangle("fill", 0, 0, panel, H) + + local y = 10 + g.setFont(bigFont) + g.setColor(1, 1, 1, 1) + g.print(mapId(), 10, y) + y = y + 22 + g.setFont(font) + g.setColor(0.72, 0.78, 0.9, 1) + g.print(("map %d / %d"):format(S.i, #ids), 10, y) + y = y + 20 + + -- the coordinates, big, because they are what the prompt is made of and + -- what gets read back against the game + g.setFont(bigFont) + g.setColor(1, 0.85, 0.2, 1) + g.print(("tile (%d,%d)"):format(S.cx, S.cy), 10, y) + y = y + 24 + g.setFont(smallFont) + g.setColor(0.8, 0.8, 0.85, 1) + local m = S.map + if m then + local okTile, tile = pcall(m.cellTile, m, S.cx, S.cy) + local kind = S.plan and S.plan[S.cy * S.plan.w + S.cx] or "?" + -- tileset and tile id are not in the prompt, and are here anyway: they + -- are what the skill's data files are keyed by, so this is the line + -- that tells you two different-looking cells are the same drawing + g.print(("tileset %s tile %s %s") + :format(tostring(m.def and m.def.tileset), + okTile and tostring(tile) or "?", kind), 10, y) + y = y + 16 + g.print(("block (%d,%d)"):format(math.floor(S.cx / 2), + math.floor(S.cy / 2)), 10, y) + y = y + 18 + end + + -- the inset + local inset = math.min(panel - 20, 180) + drawInset(10, y, inset) + y = y + inset + 14 + + -- THE BUTTON. A real rectangle with a real hit test, not a hint that a + -- key exists: the tool is used with one hand on the mouse walking a map, + -- and reaching for the keyboard per tile is the whole friction. + local bw, bh = panel - 20, 34 + S.buttonRect = { 10, y, bw, bh } + local mx, my = love.mouse.getPosition() + local hot = mx >= 10 and my >= y and mx <= 10 + bw and my <= y + bh + g.setColor(hot and 0.30 or 0.18, hot and 0.62 or 0.42, hot and 0.34 or 0.22, + 1) + g.rectangle("fill", 10, y, bw, bh, 5, 5) + g.setColor(0.45, 0.9, 0.55, 1) + g.rectangle("line", 10, y, bw, bh, 5, 5) + g.setFont(font) + g.setColor(1, 1, 1, 1) + local label = "COPY PROMPT" + g.print(label, 10 + (bw - font:getWidth(label)) / 2, y + 9) + y = y + bh + 12 + + -- what it will copy, shown in full and wrapped: the prompt is the + -- product, so it is never hidden behind the button that makes it + g.setFont(smallFont) + g.setColor(0.65, 0.75, 0.85, 1) + local preview = promptFor(mapId(), S.cx, S.cy) + local _, lines = smallFont:getWrap(preview, panel - 20) + for _, l in ipairs(lines) do + g.print(l, 10, y) + y = y + 13 + end + y = y + 8 + + g.setColor(0.55, 0.9, 0.6, 1) + local _, outLines = smallFont:getWrap( + ("%d copied -> %s"):format(#S.copied, OUT), panel - 20) + for _, l in ipairs(outLines) do + g.print(l, 10, y) + y = y + 13 + end + y = y + 5 + + if S.msg ~= "" then + g.setColor(1, 0.9, 0.6, 1) + local _, msgLines = smallFont:getWrap(S.msg, panel - 20) + for _, l in ipairs(msgLines) do + g.print(l, 10, y) + y = y + 13 + end + y = y + 6 + end + + if S.legend then + y = y + 6 + g.setColor(0.75, 0.8, 0.9, 1) + for _, l in ipairs(LEGEND) do + g.print(l, 10, y) + y = y + 14 + end + end + + if S.showPlan then + local px = panel + 12 + drawPlan(px, 12, W - px - 12, H - 24) + else + -- and with nothing drawn there, nothing there is clickable: the mouse + -- reads the rectangle the draw leaves behind, so clearing it is what + -- stops a click on the game moving the cursor + S.planRect = nil + end + g.setColor(1, 1, 1, 1) + end + + local innerDraw = love.draw + function love.draw() + if innerDraw then innerDraw() end + if S.shooting then return end + local g = love.graphics + g.push("all") + g.origin() + local ok, err = pcall(drawOverlay) + g.pop() + if not ok and not S.drawWarned then + S.drawWarned = true + U.log("overlay draw failed: " .. tostring(err)) + end + end + + -- ------- the session + + U.log("tile picker -- " .. #ids .. " maps") + for _, l in ipairs(LEGEND) do U.log(" " .. l) end + U.log("prompts append to " .. OUT) + + local start = os.getenv("TILE_START") + local at = 1 + if start and start ~= "" then + for i, id in ipairs(ids) do + if id == start then at = i break end + end + end + enterMap(at) + + local function dispatch(job) + if job.cmd == "map" then + enterMap(S.i + job.arg) + elseif job.cmd == "shot" then + shoot(job.arg == "hud") + end + end + + -- One frame of the session: the next thing a key asked for, or a frame of + -- standing still while the game runs underneath. Map changes and captures + -- go through the queue rather than happening inside the key handler -- + -- both of them bind canvases, and doing that from inside love.keypressed + -- is doing it inside the engine's own frame. + local function pump(frames) + for _ = 1, (frames or 1) do + local job = table.remove(queue, 1) + if job then dispatch(job) else step(1) end + if S.quit then return end + end + end + + -- ------- TILE_SELFTEST=1: the same session, with the keys pressed for you + -- + -- Not a substitute for sitting in front of it -- the thing this tool is + -- judged on is whether a bookshelf is recognisable in the inset -- but it + -- proves the session starts, both wraps hold, every key path runs, a map + -- change re-bakes, and a prompt reaches the clipboard and the file. Which + -- is the whole of what can fail without a person noticing. + if truthy(os.getenv("TILE_SELFTEST")) then + OUT = DIR .. "/selftest_prompts.txt" + local function press(key, frames) + local ok, err = pcall(handle, key) + if not ok then U.log("SELFTEST key " .. key .. " failed: " .. tostring(err)) end + pump(frames or 20) + end + press("right") + press("down") + press("left") + press("up") + press("c") -- a prompt, copied + press("z") -- the inset both ways + press("z") + press("g") -- each way of drawing the plan + press("g") + press("g") + press("t") -- stand on it + press("m") -- the plan away, and back -- with a shot of + post("shot", "hud") -- the window without it, which is the only + pump(240) -- thing that proves the panel stands alone + press("m") + press("]", 90) -- the next map, which re-bakes + press("c") -- and a prompt from there + press("[", 90) -- back + press("y", 240) -- a screenshot of the world + post("shot", "hud") -- and one WITH the panel, which is the only + pump(240) -- thing that proves the overlay draws + press("l") -- the list + press("f1") + -- the click paths, which the key presses above never touch + local b = S.buttonRect + if b then + love.mousepressed(b[1] + 4, b[2] + 4, 1) + pump(20) + end + local r = S.planRect + if r then + love.mousepressed(r.x + r.w * 0.5, r.y + r.h * 0.5, 1) + pump(20) + end + U.log(("SELFTEST %d prompts, cursor %d,%d on %s -- %s") + :format(#S.copied, S.cx, S.cy, mapId(), tostring(S.msg))) + press("escape", 5) + S.quit = true + end + + while not S.quit do pump(1) end + + -- Hand the install back the way it was found. + if S.tiles then pcall(S.tiles.release, S.tiles) end + love.draw = innerDraw + love.keypressed = innerKey + love.mousepressed = innerMouse + if wasRung then pcall(Pipelines.setLevel, "voxel", wasRung) end + + if #S.copied > 0 then + U.log(("-- %d prompt%s this session"):format(#S.copied, + #S.copied == 1 and "" or "s")) + for i, t in ipairs(S.copied) do U.log(("%3d %s"):format(i, t)) end + end + U.log("done -- " .. OUT) +end diff --git a/tests/tree_shots.lua b/tests/tree_shots.lua new file mode 100644 index 0000000..8dd320a --- /dev/null +++ b/tests/tree_shots.lua @@ -0,0 +1,101 @@ +-- Scratch driver: Celadon Gym's little tree ($40/$41 over $50/$51, +-- one cell) at CELADON_GYM (5,7), flanked by the hedge cylinders. +-- Front and back, voxel rung (5) and flat rung (3). Same spots BEFORE +-- and AFTER the pin change. +-- +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/tree_shots.lua \ +-- SHOT_DIR=.scratchpad/celtree AB_TAG=before "/c/Program Files/LOVE/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/celtree") + .. "/" .. (os.getenv("AB_TAG") or "after") + + local handle = game.mods.exports["DRAMATIC_SHAPE"] + if not (handle and handle.lib) then + print("[celtree] DRAMATIC_SHAPE is not loaded") + love.event.quit() + return + end + local V = handle.lib + do -- prove the RUNNING mod sees the pin we think it does + local TS = V.require("TileShape") + local shapes = TS.forMap({ tileset = { id = "GYM", + imageWidth = 128, + imageHeight = 48 } }) + for _, t in ipairs({ 64, 80 }) do + local s = shapes[t] + print("[celtree] running-mod GYM tile " .. t .. ": " + .. (s and (tostring(s.class) .. "/" .. tostring(s.art) + .. " h=" .. tostring(s.h)) or "nil")) + end + end + 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 = { + -- the tree at (5,7) seen from the south = its front + { map = "CELADON_GYM", x = 5, y = 8, face = "up", label = "tree_front" }, + -- the same tree from the north = its back + { map = "CELADON_GYM", x = 5, y = 6, face = "down", label = "tree_back" }, + -- a step further back so the whole tree fits over the hedges + { map = "CELADON_GYM", x = 5, y = 9, face = "up", label = "tree_far" }, + -- from the open floor west of it, edge-on + { map = "CELADON_GYM", x = 4, y = 8, face = "up", label = "tree_west" }, + -- the second placement at (7,5), unobstructed in the east garden + { map = "CELADON_GYM", x = 7, y = 7, face = "up", label = "tree2_front" }, + { map = "CELADON_GYM", x = 8, y = 5, face = "left", label = "tree2_east" }, + { map = "CELADON_GYM", x = 7, y = 3, face = "down", label = "tree2_back" }, + } + + 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 + for _, rung in ipairs({ 5, 3 }) do + Pipelines.setLevel("voxel", rung) + Pipelines.setLevel("tiltshift", 0) + settle() + local path = ("%s/%s_r%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("[celtree] capture missed: " .. path) end + end + else + print("[celtree] teleport failed: " .. s.map) + end + end + print(("[celtree] %d shots into %s"):format(shots, ROOT)) + love.event.quit() +end diff --git a/tools/building_voxels.py b/tools/building_voxels.py index bc25643..273eb0b 100644 --- a/tools/building_voxels.py +++ b/tools/building_voxels.py @@ -680,6 +680,80 @@ TEMPLATES = { roof_rows=28, roof_back=24, roof_front=0, roof_cycle=(2, 23), slab=3, front_eave=0, ledge=None, tileset="mansion", ), + # F07b: the SQUARE table of CELADON_MANSION_1F cells (0,6):(1,7) + # (1 placement) -- the long table (F07) at two cells wide, the same + # drawing to the tile everywhere but the interior column count, and + # the same read to the row: 0-23 the tabletop seen from above, 24-26 + # the slab's black/#555/black front edge, 27 the #555 shadow that + # closes it (slab = 3, folded into the band), 28-30 the base with the + # legs stopping one row short of the grid. Family numbers unchanged. + "mansion_square_table": dict( + tiles=[ + [38, 39, 39, 41], + [54, 55, 55, 57], + [54, 55, 55, 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="mansion", + ), + # F07c: the Game Freak office's computer desk -- CELADON_MANSION_2F + # cell (0,5) and CELADON_MANSION_3F cells (0,3), (3,3) and (0,6) + # (4 placements, scan.lua, and the four ids of its apron and chair + # row occur nowhere else on this atlas). + # + # Rows 0-15 are BILL'S DESK, byte for byte: the same tabletop seen + # from above with the same terminal, cord and isometric computer + # drawn into it (a whole-crop diff against the interior atlas's + # 11/12/13/14 + 27/28/29/30 comes back empty for every one of the + # 512 pixels). One drawing = one model, so those four parts are + # bills_desk's verbatim -- see that entry for the readings. + # + # Only the FRONT differs, and it is drawn 6 rows where Bill's is 7: + # 15 the top's own black front edge -- the row the lid + # replaces, so it opens the fascia the way Bill's row 16 + # does and the desk stands 7 voxels rather than 8 + # 16-17 the #555 edge lip and the black seam under it: the + # desktop's own rim, so `fascia` (it wraps every side) + # 18-21 the base -- the left leg (the @#@ at x0-x2), the open + # apron between, and the DRAWER PEDESTAL at x20-x31: two + # #555 drawer fronts (rows 16-17 and 19-20) in a black + # frame, which the measured recess pass sinks a voxel + # each because they are non-black regions sealed behind + # their own black outline. Row 21 is the ground line, and + # the measured 22 is where the drawing puts the cast + # shadow on the floor -- 22-23 are that shadow, dithered + # floor in the walkable cell, and the model builds none + # of it. + # The chair is Bill's chair REDRAWN, not the same pixels (a 2px + # white margin round the backrest panel where Bill's has 1px, and + # it sits at x4-x15 rather than x2-x13), but the same object band + # for band, so it takes that part table with x and rise adjusted: + # rows 20-21 the backrest top seen from above, 22-31 its elevation, + # rise = -7 putting it back on the floor, z 22 depth 10. + "mansion_computer_desk": dict( + tiles=[ + [36, 37, 52, 53], + [64, 65, 66, 67], + [2, 3, 85, 86], + [18, 19, 17, 17], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="mansion", depth=4, + # the desk's own plot is its two cells; the grid runs on because + # its apron and the CHAIR share tiles 2/3 + desk=dict(fascia=(15, 17), base=(18, 21), depth=2), + parts=[ + dict(kind="flat", x=(2, 15), rows=(3, 7)), # the notes + dict(kind="upright", x=(4, 15), top=(8, 12), facade=(12, 13), + z=8, depth=6), # the keyboard + dict(kind="upright", x=(16, 19), top=(8, 8), facade=(8, 9), + z=8, depth=2), # the cord + dict(kind="iso", x=(19, 30), rows=(1, 13), plan=6, z=9), + dict(kind="upright", x=(4, 15), top=(20, 21), facade=(22, 31), + rise=-7, z=22, depth=10), # the chair + ], + ), # 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 @@ -853,6 +927,79 @@ TEMPLATES = { z=3, depth=11, stretch=True), # the stool ], ), + # F09 on the lobby atlas: the Celadon department store's stools -- + # the diner's chairs, the roof terrace's, the Game Corner's six + # rows and the four on 1F (58 placements). A DIFFERENT drawing from + # the house stool -- it sits one row HIGHER in the tile (seat top + # rows 4-9 over front edge 10 and legs 11-14, with a clear floor + # row below) and its leg detail differs -- but the same object band + # for band, so it takes the same part table with the bands shifted + # up one row. The measured ground line (15 here, 16 in the house) + # shifts with them, so the stand is the same 5 voxels. + "diner_stool": dict( + tiles=[ + [7, 8], + [23, 24], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="lobby", + panes=False, + parts=[ + dict(kind="upright", x=(2, 13), top=(4, 9), facade=(10, 14), + z=3, depth=11, stretch=True), # the stool + ], + ), + # F11: the ROUND TABLE of the Celadon diner and the roof terrace -- + # 4 placements, all on the lobby atlas (CELADON_DINER cells (0,2) + # and (0,5), CELADON_MART_ROOF (4,2) and (8,4); scan + # "9,39,39,25;54,55,55,57;70,55,55,71;85,86,87,55", no matches on + # any other atlas). The drawing packs three facings no band split + # can reach: rows 0-23 are the OCTAGONAL top seen from above (24 + # top-view rows = 24 depth rows, the same 1:1 every tabletop is + # drawn with -- so the plan is the silhouette itself, 32 wide by 24 + # deep), rows 24-25 the slab's own fascia (#555 over black, folded + # down the rim under the band's drawn outline -> slab 3), and rows + # 26-31 the PEDESTAL seen under the front edge: the base's top + # surface with the dark column rising from its middle (26-27), its + # lit south half (28-29), and its front arc curving to the floor + # (30-31). The flattened arcs are horizontal CIRCLES seen from + # above -- depth, not narrowing -- so the pedestal is two discs: + # base diameter 16 (drawn cols 8-23), column diameter 6 (the dark + # blob's cols 13-18), both centred on the drawn centre x=16, plan + # centre z=12. MEASURED: plan, diameters, centre, slab 3. AUTHORED: + # tabletop plane 8 -- counter height, developer-tuned (the first + # cut stood it at the flat compromise's 16px and it read too tall) + # -- plus base height 2 and the 3-voxel column between (the + # projection cannot state either; the drawn base arc suggests a + # low disc). depth 3 keeps the plot to the drawn plan's 24 rows; + # the grid's 4th tile row is the pedestal's own drawing and the + # floor tile at its southeast corner, which the claim paints as + # ground. `scrub` repoints the top's interior field -- the four + # $37 tiles, which are ALSO the checkerboard floor's light half + # and carry the floor's palette in a colorized atlas -- at the + # same grey sourced from the rim's own field, so the whole top + # wears the table's palette (the drawn field there is uniform + # grey; nothing drawn is lost). + "diner_round_table": dict( + tiles=[ + [9, 39, 39, 25], + [54, 55, 55, 57], + [70, 55, 55, 71], + [85, 86, 87, 55], + ], + roof_rows=0, roof_back=0, roof_front=0, roof_cycle=(0, 0), + slab=0, front_eave=0, ledge=None, tileset="lobby", depth=3, + panes=False, scrub=[(8, 8, 23, 23)], + parts=[ + dict(kind="disc", cx2=32, cz2=24, r=8, rise=0, h=2, + side=dict(rows=(30, 31), x=(13, 18)), + cap=dict(rows=(26, 29), x=(9, 22))), # the base + dict(kind="disc", cx2=32, cz2=24, r=3, rise=2, h=3, + side=dict(rows=(26, 27), x=(14, 17))), # the column + dict(kind="plan", x=(0, 31), rows=(0, 23), + fascia=(24, 25), fascia_x=(8, 23), rise=5), # the top + ], + ), # 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 @@ -1350,7 +1497,7 @@ def build_desk_set(sp, pr, t): def build_parts(plane): for p in t["parts"]: - x0, x1 = p["x"] + x0, x1 = p.get("x", (0, W - 1)) if p["kind"] == "flat": r0, r1 = p["rows"] # `at` names the sheet's own height when it does not lie @@ -1462,6 +1609,91 @@ def build_desk_set(sp, pr, t): if pr0 <= sy <= pr1 and inside(sx, sy): put(sx, plane + y, z, sx, sy) continue + if p["kind"] == "plan": + # A PLAN part is a slab whose plan IS the drawn top view: + # the band's silhouette becomes the footprint pixel for + # pixel (drawn row = depth row, the same 1:1 every + # tabletop is drawn with), so an octagonal top stands as + # an octagon rather than the box no rectangular band can + # escape. The top layer wears the band itself, outline + # and all; the rim layers below wear the drawn fascia + # rows folded down the edge (x clamped into the drawn + # fascia's span), and the slab's unseen interior the + # field's dark texel. + r0, r1 = p["rows"] + f0, f1 = p["fascia"] + fx0, fx1 = p["fascia_x"] + rise = p.get("rise", 0) + h = (f1 - f0 + 1) + 1 + dark = shade_px.get(DARK) or shade_px[BLACK] + + def drawn(sx, z): + return (x0 <= sx <= x1 and 0 <= z <= r1 - r0 + and inside(sx, r0 + z)) + + for z in range(r1 - r0 + 1): + if not 0 <= z < D: + continue + sy = r0 + z + for sx in range(x0, x1 + 1): + if not inside(sx, sy): + continue + put(sx, rise + h - 1, z, sx, sy) + edge = not (drawn(sx - 1, z) and drawn(sx + 1, z) + and drawn(sx, z - 1) and drawn(sx, z + 1)) + for y in range(rise, rise + h - 1): + if edge: + put(sx, y, z, max(fx0, min(fx1, sx)), + f0 + (rise + h - 2 - y)) + else: + put(sx, y, z, dark[0], dark[1]) + continue + if p["kind"] == "disc": + # A DISC part is ROUND IN PLAN -- the pedestal column + # and base the projection can only draw from the front. + # Centre and radius are measured off the drawn widths + # (a flattened arc is a horizontal circle seen from + # above); the circular footprint is synthesized like any + # continued geometry, and every voxel still wears the + # drawing: the side folds the drawn face-on rows around + # the hull (x clamped into the drawn span, rows + # repeating up the height), and `cap` lays the drawn + # top-view rows over the top layer's interior, drawn + # north rows to the plan's north. `cx2`/`cz2` are + # DOUBLED plan centres, so an even diameter keeps its + # centre between two voxels instead of limping one off. + r, rise, h = p["r"], p.get("rise", 0), p["h"] + s0, s1 = p["side"]["rows"] + sa0, sa1 = p["side"]["x"] + sn = s1 - s0 + 1 + cap = p.get("cap") + + def in_disc(x, z): + dx = 2 * x + 1 - p["cx2"] + dz = 2 * z + 1 - p["cz2"] + return dx * dx + dz * dz <= 4 * r * r + + zlo = (p["cz2"] - 2 * r) // 2 + for x in range((p["cx2"] - 2 * r) // 2, + (p["cx2"] + 2 * r) // 2 + 1): + for z in range(max(0, zlo), + min(D - 1, (p["cz2"] + 2 * r) // 2) + 1): + if not in_disc(x, z): + continue + edge = not (in_disc(x - 1, z) and in_disc(x + 1, z) + and in_disc(x, z - 1) + and in_disc(x, z + 1)) + for y in range(rise, rise + h): + if cap and y == rise + h - 1 and not edge: + c0, c1 = cap["rows"] + sy = min(c1, c0 + ((z - zlo) + * (c1 - c0 + 1)) // (2 * r)) + sx = max(cap["x"][0], min(cap["x"][1], x)) + else: + sy = s0 + (rise + h - 1 - y) % sn + sx = max(sa0, min(sa1, x)) + put(x, y, z, sx, sy) + continue tr0, tr1 = p["top"] fr0, fr1 = p["facade"] pd = p["depth"] @@ -1960,7 +2192,7 @@ def verify_desk_set(vox, pr, t): # sink) -- and nothing stands anywhere else tops = {} for p in t["parts"]: - x0, x1 = p["x"] + x0, x1 = p.get("x", (0, W - 1)) if p["kind"] == "flat": r0, r1 = p["rows"] z0 = p.get("z", r0) @@ -2006,6 +2238,37 @@ def verify_desk_set(vox, pr, t): for y in range(plane, plane + h + 1): assert (x, y, z) in vox, \ f"iso box hole at {x},{y},{z}" + elif p["kind"] == "plan": + # its one geometric intent: the plan IS the drawn band's + # silhouette -- a solid slab column wherever the band draws + r0, r1 = p["rows"] + h = (p["fascia"][1] - p["fascia"][0] + 1) + 1 + rise = p.get("rise", 0) + for x in range(x0, x1 + 1): + for z in range(min(r1 - r0 + 1, D)): + if not pr["inside"](x, r0 + z): + continue + tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1) + for y in range(rise, rise + h): + assert (x, y, z) in vox, \ + f"plan slab hole at {x},{y},{z}" + elif p["kind"] == "disc": + # its one geometric intent: a solid circle in plan at every + # layer, symmetric about the authored centre + r, rise, h = p["r"], p.get("rise", 0), p["h"] + in_disc = lambda x, z: ((2 * x + 1 - p["cx2"]) ** 2 + + (2 * z + 1 - p["cz2"]) ** 2 + <= 4 * r * r) + for x in range((p["cx2"] - 2 * r) // 2, + (p["cx2"] + 2 * r) // 2 + 1): + for z in range(D): + if not in_disc(x, z): + continue + assert in_disc(p["cx2"] - 1 - x, z), \ + f"disc asymmetric at {x},{z}" + tops[(x, z)] = max(tops.get((x, z), 0), rise + h - 1) + for y in range(rise, rise + h): + assert (x, y, z) in vox, f"disc hole at {x},{y},{z}" else: fr0, fr1 = p["facade"] ytp = plane + p.get("rise", 0) + (fr1 - fr0)