Merge pull request #128 from DramaticShape/stadium_fixes

Stadium fixes
This commit is contained in:
DramaticShape
2026-08-06 18:24:30 -04:00
committed by GitHub
14 changed files with 2268 additions and 30 deletions
@@ -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.
+293 -21
View File
@@ -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
},
},
},
},
}
+90 -1
View File
@@ -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]
+31 -3
View File
@@ -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
+9
View File
@@ -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
+101
View File
@@ -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
+95
View File
@@ -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
+102
View File
@@ -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
+91
View File
@@ -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
+96
View File
@@ -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
+96
View File
@@ -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
+816
View File
@@ -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
+101
View File
@@ -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
+265 -2
View File
@@ -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)