mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 15:00:54 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12a1ab0266 | |||
| 2af88d07cf | |||
| 47369c24fe | |||
| 787d93a0e4 | |||
| c681167475 | |||
| 99de8c2760 | |||
| 34e7da5c12 | |||
| 9ddb940bc8 | |||
| c05c2cebd9 | |||
| d6a9f10352 | |||
| 4aeb0fc675 | |||
| 9be6888290 | |||
| 20f9e19bf9 |
+129
@@ -87,6 +87,135 @@
|
||||
on the pattern by luck -- without that, every setting would be itself
|
||||
and 1/8192 in parallel, and no setting could ever be rarer than 8192.
|
||||
|
||||
- **DOORS ON A GATE HOUSE'S OTHER SIDES.** A route gate is walked
|
||||
through, so it opens on two opposite faces -- and the overworld drawing
|
||||
can only show one. The sprite is a facade seen face-on under a roof
|
||||
seen from above, so a SOUTH entrance is drawn (a doorway block in the
|
||||
facade's last rows, which `Structures` folds up into the front face)
|
||||
and a north, east or west one is drawn as nothing at all: the warp
|
||||
sits on the ground cell outside, the art beside it is plain wall.
|
||||
Top-down that reads fine, because the wall is never seen. In 3D you
|
||||
walked into a blank slab -- 33 of them, on eleven gates.
|
||||
|
||||
Each is now a real doorway, one cell of the tileset's own door art
|
||||
standing on the ground of the face you walk into, hung by the SAME
|
||||
rule the drawn facade hangs its own door by: the black frame stays
|
||||
flush with the wall and what it seals sinks a voxel behind it, so a
|
||||
side door and a front door are the same opening at the same depth, and
|
||||
the jambs the recess exposes fall out of the mesher wearing the
|
||||
frame's own texels. The art's outer ring is left alone -- a doorway
|
||||
cell is a cell OF a facade, its border is the wall beside and above
|
||||
the frame, and painting all 16x16 would stamp a one-pixel strip of
|
||||
front-wall art around every door. The same flood the facade tells wall
|
||||
from pane with, bounded to the cell, tells them apart here.
|
||||
|
||||
**Nothing is authored but the art.** `data/voxel_heights.lua` names
|
||||
one door cell per tileset and no coordinates: where the doors go is
|
||||
read off the map, from the warps that land in a gate house and the
|
||||
building standing against them. A warp is an entrance when it leads
|
||||
into a GATE-tileset map, is not already ON a door tile (a drawn south
|
||||
door would be fought over), and stands on a WALKABLE cell -- the ROM
|
||||
gives several gates an unreachable twin warp on the fence or tree
|
||||
beside the real opening, and a door behind a fence is a door into
|
||||
nothing. What is left is the entrance, and the two cells side by side
|
||||
that most gates do have come out as the double door they always were.
|
||||
So no hand list to drift out of step with a map edit, and every other
|
||||
building in the game is untouched: exactly the eleven gate placements
|
||||
get doors.
|
||||
|
||||
Doors belong to the PLACEMENT, not the drawing -- the same 6x4 block
|
||||
is the gate on four routes and the warps sit at different rows of it
|
||||
on each -- so the model cache is keyed by the openings as well, and
|
||||
only placements that agree share a model. It costs about 290 quads a
|
||||
door (a flank quad carries one texel, so the art cannot merge into
|
||||
strips the way a facade's does) and no voxels: the recess removes as
|
||||
many faces as it exposes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The Pokemon Center's steps climb.** The Cable Club stairs, cut into
|
||||
the back wall of all eleven Centers, were built as a stairwell sunk
|
||||
into the floor -- they lead UP to the Center's second floor. The
|
||||
head-on stair reading was right and stays: a drawn ROW is a step and
|
||||
drawn row is depth row, 1:1 into the opening, the drawing's own band
|
||||
table landing exactly on four steps and its black edge columns walling
|
||||
the opening. Only the sign of the rise was wrong, and two things follow
|
||||
from it. The risers turn around -- a flight descending away from you
|
||||
closes its steps from below and shows you their backs, one climbing
|
||||
away shows you their fronts -- and the black edge columns become the
|
||||
walls of the opening the flight climbs into, running from each tread up
|
||||
to the top of the wall band rather than down from the floor to it. The
|
||||
top step lands level with that band, in the dark rows the artist drew
|
||||
there, so the flight fills the opening it leaves by. The descending
|
||||
class (`stair_down_n`) is unchanged and still available; the Centers
|
||||
now pin `stair_n`.
|
||||
|
||||
- **Nothing is hung on the TOP of an interior wall.** A wall band is 16px
|
||||
of art folded upright over a run two drawn rows deep, so it folds
|
||||
entirely onto its south face and has no drawn row left to lay flat on
|
||||
top -- and the top then repeated the face. The town house's town-map
|
||||
poster (cell (3,0)) and its window ((5,0)), and the Pokemon Center's
|
||||
pokeball poster ((3,0) and (4,0)), each came out lying across the top
|
||||
of the wall as well as hanging on it: a picture you look DOWN on.
|
||||
|
||||
What is really up there is the wall's own capping course, which is
|
||||
exactly the plain panel the decorated column's neighbours draw --
|
||||
`wall_top` in `data/voxel_heights.lua` names it per tileset (HOUSE caps
|
||||
with the blank course, POKECENTER with the striped panel cell (9,0)
|
||||
draws). Per tileset rather than per tile because one room caps with one
|
||||
course, and because "plain" is a fact about the drawing that no
|
||||
measurement of the geometry can recover. Only the top face is
|
||||
redirected; the poster still faces the room.
|
||||
|
||||
Five rooms take it. `HOUSE`, `POKECENTER`, `REDS_HOUSE_1` and
|
||||
`REDS_HOUSE_2` name one course for every wall in the atlas -- each of
|
||||
those dresses one kind of room, and a list keyed by the decorated tiles
|
||||
would need extending every time a map hung something new on the same
|
||||
wall. `LOBBY` names the tiles instead (`{ [40] = 93, [56] = 93 }`): the
|
||||
Rocket lift's car doors cap with the cabin frame, and the department
|
||||
store, the Game Corner, Silph's floors and the roof -- all on that one
|
||||
atlas -- keep exactly the tops they had. The doors are also the reason
|
||||
the cap is applied in the mesher's DETECTED-run branch as well as its
|
||||
pinned one; Structures finds them rather than a pin naming them.
|
||||
|
||||
- **Lance's room is furnished with the badge gyms' bird statue.** It is
|
||||
the gyms' statue tile for tile on the DOJO atlas -- one cell of figure
|
||||
($02/$38/$12/$13) over one cell of plinth ($22/$23/$32/$33) -- and left
|
||||
derived the pair merged into one 32px volume wearing the statue folded
|
||||
onto its face. The extruded picture, the same failure the gyms' statues
|
||||
and the Plateau's avenue had, and it takes the same answer: the plinth a
|
||||
solid 16px block, the bird a per-pixel cutout 5 voxels deep riding its
|
||||
top face. Every placement of those eight tiles in the game is a statue
|
||||
-- 18 in LANCES_ROOM and 2 in FIGHTING_DOJO -- and Oak's Lab, the third
|
||||
map on the atlas, places none of them.
|
||||
|
||||
- **A wall cut into a terrace inherits terrace, never the statue standing
|
||||
on it.** `bookcase_backfill = "above"` hands a collapsed rank's vacated
|
||||
rows the cell above the run, so the League's gate walls have more
|
||||
hillside behind them rather than a trench. Indigo Plateau's avenue
|
||||
statues stand directly on the pilasters that collapse that way, so what
|
||||
every one of them inherited was the BIRD: the figure's shape and art
|
||||
copied onto two more rows down the shaft, and each statue came out two
|
||||
deep behind itself. Only bodies backfill now -- flat, top and upright.
|
||||
A per-pixel standee above (a statue, a sign, a bush) is an object
|
||||
standing ON the terrace rather than terrace, so the row has nothing to
|
||||
inherit and takes the default synthesized ground.
|
||||
|
||||
- **...and a statue on a collapsed pilaster stands ON it.** The duplicate
|
||||
above was masking a second fault. A standee finds its support by reading
|
||||
the cell below its own drawing, and the bookcase collapse MOVES the box
|
||||
it finds: the whole four-row pilaster walks onto its southmost cell,
|
||||
which on the Plateau is a full cell south of where the test looked. So
|
||||
the bird was lifted to the right HEIGHT and left standing over open
|
||||
ground with its pillar behind it -- invisible while the vacated rows
|
||||
were being filled with copies of the bird itself, obvious the moment
|
||||
they were not. Every row of a collapsed rank now records the row its box
|
||||
actually stands on (`S.bookcaseBox`), and a standee supported by one is
|
||||
placed there instead of at its drawn position. Supports that do not move
|
||||
-- the gyms' plinths, furniture, `building` claims -- are unaffected.
|
||||
The plinth keeps its elevation and the statue extends exactly one cell
|
||||
above it.
|
||||
|
||||
## 1.8.0
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# Dramatic Shape Voxel Mod
|
||||
|
||||
Redistribution of non-derivative code is expressly prohibited after v1.6.0 without permission.
|
||||
|
||||
A mod for the [Pokémon Gen 1 Recompilation
|
||||
Project](https://github.com/bryanthaboi/pokemon-gen1-recomp-project).
|
||||
|
||||
|
||||
+212
-14
@@ -50,6 +50,9 @@
|
||||
-- stair_down_e / _down_w a sunken stairwell: the cell opens into a
|
||||
-- hole with steps descending toward the named
|
||||
-- side -- stairs that lead DOWN a floor
|
||||
-- stair_n / stair_down_n the same two flights running into the map
|
||||
-- rather than across it, for a staircase drawn
|
||||
-- HEAD-ON: a drawn row is a step, not a column
|
||||
-- relief a prop drawn from above (a console on the
|
||||
-- floor): the drawing stays flat and the
|
||||
-- pixels inside its black outline extrude a
|
||||
@@ -129,6 +132,24 @@
|
||||
-- measured rather than drawn, by flooding the panel tile's own stripe out
|
||||
-- from behind them; see CLUB below.
|
||||
--
|
||||
-- And `wall_top` (not a class either): what a `wall` cell's TOP face
|
||||
-- wears, whatever the cell itself draws. An interior wall band is 16px of
|
||||
-- art folded upright, and a fully folded run has no drawn row left over to
|
||||
-- lay flat on top -- so the top repeated the FACE, and the town-map poster
|
||||
-- and the window of a house came out lying across the top of the wall as
|
||||
-- well as hanging on it. What is really up there is the wall's own
|
||||
-- capping course, which is exactly the plain panel the decorated column's
|
||||
-- neighbours draw. The face is untouched -- the poster still faces the
|
||||
-- room. Two forms:
|
||||
--
|
||||
-- wall_top = <id> every wall in the tileset caps with this,
|
||||
-- for an atlas that dresses one kind of room
|
||||
-- (the houses, the Centers, Red's floors)
|
||||
-- wall_top = { [tile] = id } only the named tiles are redirected, for
|
||||
-- an atlas that dresses several (LOBBY is
|
||||
-- the department store, the Game Corner,
|
||||
-- Silph, the roof AND the Rocket lift)
|
||||
--
|
||||
-- Whole BUILDINGS are not tile pins -- one drawing packs a roof seen from
|
||||
-- above, a facade seen face-on and sloped ends as diagonal silhouettes,
|
||||
-- and no single class covers that. They live in the `buildings` list at
|
||||
@@ -758,12 +779,38 @@ return {
|
||||
signpost = { 33, 34, 49, 50 },
|
||||
},
|
||||
|
||||
-- Oak's Lab (the tileset also serves the Fighting Dojo and Lance's
|
||||
-- room, which use none of these tiles). The free-standing shelf
|
||||
-- ranks: book rows and base pinned; the shared trim tiles above
|
||||
-- (41/42, also the lab tables' corners) are adopted as caps by the
|
||||
-- bookcase builder rather than pinned.
|
||||
-- Oak's Lab, the Fighting Dojo and Lance's room (one tileset). The
|
||||
-- lab's free-standing shelf ranks: book rows and base pinned; the
|
||||
-- shared trim tiles above (41/42, also the lab tables' corners) are
|
||||
-- adopted as caps by the bookcase builder rather than pinned. The
|
||||
-- other two rooms are furnished with one thing between them -- the
|
||||
-- BIRD STATUE, and it is the badge gyms' statue exactly.
|
||||
DOJO = {
|
||||
-- THE STATUES. The same drawing as GYM's, tile for tile on this
|
||||
-- atlas: one cell of figure ($02/$38/$12/$13) over one cell of
|
||||
-- plinth ($22/$23/$32/$33), so it takes the same treatment -- the
|
||||
-- plinth a SOLID 16px block, the figure a per-pixel cutout 5 voxels
|
||||
-- deep (the thin `prop` pool) riding the plinth's top face through
|
||||
-- the authored-box support rule and collapsing to the plinth's
|
||||
-- single cell of footprint.
|
||||
--
|
||||
-- Left derived the pair merged into ONE 32px volume wearing the
|
||||
-- statue art folded onto its face -- the extruded picture, the same
|
||||
-- failure the gyms' statues and the Plateau's avenue had.
|
||||
--
|
||||
-- Every placement of these eight tiles in the game is a statue:
|
||||
-- blocks 49/50/114/115 pack figure over plinth in one 2x2-cell
|
||||
-- block, and they are placed 18 times in LANCES_ROOM (the pairs
|
||||
-- lining his aisle, and the two flanking his dais at cells (6,12)
|
||||
-- and (7,12) over (6,13)/(7,13)) and twice in FIGHTING_DOJO. Oak's
|
||||
-- Lab, the third map on this atlas, places none of them -- and the
|
||||
-- four figure-only and plinth-only blocks are never placed at all.
|
||||
wall = { 34, 35, 50, 51 },
|
||||
prop = { 2, 18, 19, 56 },
|
||||
-- and each stands on the room's main floor ($11) rather than on
|
||||
-- whatever its neighbours vote -- the gyms' rule, for the gyms'
|
||||
-- reason: a statue against a wall would otherwise take the wall.
|
||||
prop_ground = { [2] = 17, [18] = 17, [19] = 17, [56] = 17 },
|
||||
bookcase = { 13, 14, 29, 30 },
|
||||
-- the lab tables (the starter-ball display and the north tables):
|
||||
-- 41/42 are also the shelf trim the bookcase builder adopts as
|
||||
@@ -787,6 +834,11 @@ return {
|
||||
REDS_HOUSE_2 = {
|
||||
-- the wall band with its windows stays one 16px face
|
||||
wall = { 0, 36, 37, 52, 53 },
|
||||
-- and caps with the blank course. The windows of Red's 2F sit at
|
||||
-- cells (5,0) and (7,0); without this the panes came out lying
|
||||
-- across the top of the wall as well as glazing its face, where
|
||||
-- (6,0) between them draws the plain panel that belongs up there
|
||||
wall_top = 0,
|
||||
-- the bed: a mattress drawn from above, half a block high
|
||||
bed = { 45, 46, 47, 61, 62, 63 },
|
||||
-- stools: a seat-high box, seat art on top, legs on the front
|
||||
@@ -825,6 +877,9 @@ return {
|
||||
-- table rides these heights, not the 8/12px class defaults
|
||||
heights = { stool = 5, table = 6 },
|
||||
wall = { 0, 36, 37, 52, 53 },
|
||||
-- the same blank course caps 1F, whose windows are cells (3,0),
|
||||
-- (5,0) and (7,0) -- (6,0) between the last two is the panel
|
||||
wall_top = 0,
|
||||
stool = { 2, 3, 18, 19 },
|
||||
-- the dining table (38-44/58-60); its top row also caps the
|
||||
-- bookcases below
|
||||
@@ -860,6 +915,12 @@ return {
|
||||
-- the wall band stays one 16px face: blank courses, the window,
|
||||
-- the framed picture, and the schoolhouse blackboard (72-75/88-91)
|
||||
wall = { 0, 36, 45, 46, 52, 61, 62, 72, 73, 75, 88, 89, 90, 91 },
|
||||
-- and it caps with the blank course. Cells (3,0) and (5,0) of the
|
||||
-- town house are the town-map poster (45/46 over 61/62) and the
|
||||
-- window (36 over 52); without this the top of the wall wore the
|
||||
-- poster and the glass, laid flat, instead of the plain panel their
|
||||
-- own left-hand neighbour draws
|
||||
wall_top = 0,
|
||||
-- stools: a seat-high box that also seats Daisy
|
||||
stool = { 2, 3, 18, 19 },
|
||||
-- the dining table (top edge 38/41 also caps the bookcases);
|
||||
@@ -890,9 +951,8 @@ return {
|
||||
-- which has a PERSON drawn into the tile art -- becomes a monolith
|
||||
-- wearing his face.
|
||||
POKECENTER = {
|
||||
-- the wall band stays one 16px face: striped panels (40), the high
|
||||
-- windows (92-95; 94 doubles as the map's warp tile, and pins are
|
||||
-- look-only), the pokeball poster (2/3/18/19), and the pillars
|
||||
-- the wall band stays one 16px face: striped panels (40), the
|
||||
-- pokeball poster (2/3/18/19), and the pillars
|
||||
-- (16/41) with their bases (4/5/20/21; 20 is the $14 water-fallback
|
||||
-- trap and would recess into a pond lip). The healing machines'
|
||||
-- console face (76/77) and button panel (6/22) are ALSO wall:
|
||||
@@ -905,12 +965,44 @@ return {
|
||||
-- void rule flattens them. What is NOT wall is the machines'
|
||||
-- two flanks -- see `prop` below.
|
||||
wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41,
|
||||
76, 77, 92, 93, 94, 95 },
|
||||
-- the counters, half a cell high: top band (8) with the nurse's
|
||||
-- tray (10), front face (24/25, the game's counterTiles), left end
|
||||
-- cap (56) and the Cable Club's light sections (90/91). 8px is
|
||||
-- one clean band, so the drawn front panel stands up and the
|
||||
-- counter top stays on top; at 12 they read as wall stubs
|
||||
76, 77 },
|
||||
-- and it caps with the striped panel, the tile cell (9,0) draws.
|
||||
-- The pokeball poster spans cells (3,0) and (4,0) (2/3 over 18/19),
|
||||
-- and without this the top of the wall wore the poster lying flat as
|
||||
-- well as hanging it on the face
|
||||
wall_top = 40,
|
||||
-- THE CABLE CLUB STEPS, cut into the back wall at cells (10,0) and
|
||||
-- (12,0) of every Center -- a flight going UP, away from the room, to
|
||||
-- the Center's second floor, and the reason the head-on stair classes
|
||||
-- exist at all: the profile's other stairs run east or west and are
|
||||
-- drawn from the SIDE, where a drawn column is a step; these are drawn
|
||||
-- HEAD-ON, where a drawn row is, and no rotation of the east/west
|
||||
-- reading produces that.
|
||||
--
|
||||
-- The drawing is its own band table, and it lands exactly on an even
|
||||
-- four-step division of the cell: 4 white rows (the near tread, the
|
||||
-- one at floor level), a black nosing, 3 grey, a nosing, 3 checker,
|
||||
-- then 4 black rows -- the dark the flight climbs into, which is the
|
||||
-- top step, level with the wall band it is cut through. Its first and
|
||||
-- last COLUMNS are the opening's black side walls. Drawn row = depth
|
||||
-- row throughout; the rise is the only number the head-on view cannot
|
||||
-- state, and it takes the class height over the four steps like every
|
||||
-- other flight here.
|
||||
--
|
||||
-- Pinned as one cell (the class resolves off the top-left tile) but
|
||||
-- all four ids carry it, and the scan says they cannot reach anything
|
||||
-- else: 22 placements, exactly the two cells in each of the eleven
|
||||
-- Centers, and the Celadon Hotel on the same id places none of them.
|
||||
-- 94 is also the map's warp tile; pins are look-only, so the warp is
|
||||
-- untouched and the steps stay walk-through.
|
||||
stair_n = { 92, 93, 94, 95 },
|
||||
-- the counters, half a cell high: top band (8) and the one cell of
|
||||
-- it that carries the push bell (10, lifted off as a figure below --
|
||||
-- the pin stays as the degradation path), front face (24/25, the
|
||||
-- game's counterTiles), left end cap (56) and the Cable Club's light
|
||||
-- sections (90/91). 8px is one clean band, so the drawn front panel
|
||||
-- stands up and the counter top stays on top; at 12 they read as
|
||||
-- wall stubs
|
||||
counter = { 8, 10, 24, 25, 56, 90, 91,
|
||||
-- and the lounge couch's SEAT column with the man
|
||||
-- sitting on it. Same half-cell box: its bottom row
|
||||
@@ -1035,6 +1127,74 @@ return {
|
||||
-- on the arm. The background corners around his head and the
|
||||
-- cushion wedge under his legs are the only pixels given back.
|
||||
figures = {
|
||||
-- THE PUSH BELL on the reception counter. One tile, $0A, drawn in
|
||||
-- the counter's TOP tile row at cell (3,2) -- the same cell in all
|
||||
-- eleven Centers and nowhere else on this id (scan: 11 hits, all
|
||||
-- tile (7,4)). Every other counter cell in the game runs 8 over
|
||||
-- 24/25; this one runs 8/10 over 24/25, and 10 is 8 with the bell
|
||||
-- painted into its east half.
|
||||
--
|
||||
-- It could not be a class pin: a pin resolves a whole 8x8 tile, and
|
||||
-- the tile is three quarters counter top. Pinned with the counter
|
||||
-- (which is what it was) the bell was just ink lying on the
|
||||
-- surface -- and lying on it TWICE, because the counter's one top
|
||||
-- row had to cover a 16px-deep plot and the mesher repeated it (see
|
||||
-- the half-cell rule in ChunkMesher: fixed, and the two stacked
|
||||
-- bells were what showed it).
|
||||
--
|
||||
-- So it is lifted off by mask, exactly like the Marts' till, and
|
||||
-- `under` puts plain 8 back -- the counter top the artist drew for
|
||||
-- every other cell of the same run, so nothing is synthesized and
|
||||
-- the surface closes up seamlessly.
|
||||
--
|
||||
-- Unlike the till it is NOT an extrusion of its drawing. Seven
|
||||
-- pixels by six of ¾-view dome state a round object and nothing
|
||||
-- else usable: every reading that turns six rows into geometry
|
||||
-- invents more than it measures. So the solid is AUTHORED (see
|
||||
-- TileShape's `model`) -- a 5x3 puck with its corners taken off,
|
||||
-- one voxel proud of the counter, with a single button voxel at
|
||||
-- its centre. `pixels` stays as the segmentation: it is what says
|
||||
-- where on the tile the bell is, and the model centres on it.
|
||||
--
|
||||
-- COLOUR is still not authored. Each layer names the texel its
|
||||
-- faces wear, and all four come off tile 8 -- the counter's own
|
||||
-- plain top, whose first rows are one flat shade each: row 0 its
|
||||
-- black back edge, row 1 its white highlight, row 5 its light
|
||||
-- band. So the puck's sides are the desk's own light shade, its
|
||||
-- top the desk's own white, and the button's sides the desk's own
|
||||
-- black, and all four follow every palette bake with it.
|
||||
--
|
||||
-- It stands at the FRONT of the counter cell: a service bell is on
|
||||
-- the customer's side of the desk, and this is the only object in
|
||||
-- the profile whose depth its drawing does not state. `inset` 2
|
||||
-- backs it off the counter's own front lip -- flush read as balanced
|
||||
-- on the edge; this is the number to move to slide it either way.
|
||||
{
|
||||
w = 1,
|
||||
inset = 2,
|
||||
tiles = { 10 },
|
||||
under = { 8 },
|
||||
model = {
|
||||
{ plan = { "0xxx0",
|
||||
"xxxxx",
|
||||
"0xxx0" },
|
||||
top = { 8, 1 }, side = { 8, 5 } },
|
||||
{ plan = { "00000",
|
||||
"00x00",
|
||||
"00000" },
|
||||
top = { 8, 1 }, side = { 8, 0 } },
|
||||
},
|
||||
pixels = {
|
||||
"........",
|
||||
"........",
|
||||
"...XXX..",
|
||||
"..XXXXX.",
|
||||
".XXXXXXX",
|
||||
".XXXXXXX",
|
||||
"..XXXXX.",
|
||||
"...XXX..",
|
||||
},
|
||||
},
|
||||
{
|
||||
w = 3,
|
||||
tiles = { 36, 37, 57,
|
||||
@@ -1497,6 +1657,14 @@ return {
|
||||
wall = { 1, 2, 3, 6, 18, 19, 22, 33, 46, 47,
|
||||
62, 63, 68, 70, 71, 72, 73, 75, 76, 77, 78, 79, 84,
|
||||
88, 89, 91, 92, 93 },
|
||||
-- The Rocket lift's CAR DOORS (40 over 56, cells (2,1) and (3,1) of
|
||||
-- ROCKET_HIDEOUT_ELEVATOR) cap with the cabin frame's lower course
|
||||
-- -- the tile cell (2,0) draws beneath its own top band, and what
|
||||
-- every other column of that wall already caps with. Keyed by tile
|
||||
-- rather than blanket, unlike the houses: this one atlas dresses the
|
||||
-- department store, the Game Corner, Silph's floors and the roof
|
||||
-- too, and none of those wall tops is a lift frame.
|
||||
wall_top = { [40] = 93, [56] = 93 },
|
||||
-- 3F's television sets ($0E/$0F/$1E/$1F): the one drawing in this
|
||||
-- tileset that is a deliberate object with a body -- a black-framed
|
||||
-- cabinet, a bezel and a lit screen, drawn face-on -- and the same
|
||||
@@ -3002,6 +3170,36 @@ return {
|
||||
FOREST = { 42, 43, 58, 59 },
|
||||
},
|
||||
|
||||
-- THE DOOR A GATE HOUSE IS ENTERED BY FROM ANY SIDE BUT THE SOUTH.
|
||||
--
|
||||
-- A route gate is walked THROUGH, so it has an opening on two opposite
|
||||
-- sides -- and the drawing can only show one of them. The overworld
|
||||
-- sprite is a facade seen face-on with a roof laid over it, so a south
|
||||
-- entrance is drawn (a doorway block in the facade's last rows, folded up
|
||||
-- by lib/Structures.lua) and a north, east or west one is drawn as
|
||||
-- NOTHING: the warp sits on the ground cell outside, the art beside it is
|
||||
-- plain wall, and top-down that reads fine because you never see the
|
||||
-- wall. In 3D you walk straight into a blank slab.
|
||||
--
|
||||
-- So the door is put back, on the face the player walks into. This names
|
||||
-- only the ART -- one 16x16 cell of the tileset's own doorway block, rows
|
||||
-- north-first, the same ids `frontOnly` above lists as facade-only.
|
||||
-- WHERE it goes is not authored at all: lib/Buildings.lua reads it off
|
||||
-- the map, from the warps that land in a gate and the building standing
|
||||
-- against them (see `sideDoors` there), because the map already states it
|
||||
-- and a hand list of thirty-odd coordinates would only be a chance to get
|
||||
-- one wrong.
|
||||
--
|
||||
-- OVERWORLD is the whole table because every such entrance in the game
|
||||
-- stands against an OVERWORLD building: the Safari Zone's north gate is a
|
||||
-- gap between two fence stubs with no drawing to carve, and the Route 22
|
||||
-- league gate on ROUTE_23 puts its warps on the road THROUGH the arch
|
||||
-- rather than against a wall. Both come out with no door, which is what
|
||||
-- they always had.
|
||||
sideDoors = {
|
||||
OVERWORLD = { { 11, 12 }, { 27, 28 } },
|
||||
},
|
||||
|
||||
-- Buildings whose whole sprite is voxelized band by band (lib/Buildings.lua,
|
||||
-- the pipeline in assets/docs/buidling_to_voxel/). A building is matched by its
|
||||
-- exact tile grid -- the drawings are catalogued in assets/docs/buildings/ -- so
|
||||
|
||||
+4
-8
@@ -40,7 +40,6 @@ local TerrainAtlas = V.require("TerrainAtlas")
|
||||
local VoxelScene = V.require("VoxelScene")
|
||||
local BattleCam = V.require("BattleCam")
|
||||
local BattleBillboard = V.require("BattleBillboard")
|
||||
local VoxelGrid = V.require("VoxelGrid")
|
||||
local DayNight = V.require("DayNight")
|
||||
local AntiAlias = V.require("AntiAlias")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
@@ -640,12 +639,10 @@ function BattleScene.render(state, arena, textures, token)
|
||||
local sunWas = Voxel3D.SHADOW_ALPHA
|
||||
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
|
||||
* DayNight.shadowScale(outdoor)
|
||||
-- and the wireframe is ON for a battle whatever the V-GRID row says. The
|
||||
-- arena is a staged shot rather than the world being walked through, and
|
||||
-- the seams are what make it read as built rather than photographed. Forced
|
||||
-- through the override so the player's own row is never written to.
|
||||
local gridWas = VoxelGrid.override
|
||||
VoxelGrid.override = true
|
||||
-- The wireframe is whatever the V-GRID row says, exactly as it is out in
|
||||
-- the world (see VoxelGrid): the arena is drawn a unit per voxel like
|
||||
-- everything else, so the seams follow the one toggle and a player who
|
||||
-- turned them off does not get them back for the length of a fight.
|
||||
local out = nil
|
||||
local ok, err = pcall(function()
|
||||
-- its own canvas slot: this renders at the window's pixel size and the
|
||||
@@ -845,7 +842,6 @@ function BattleScene.render(state, arena, textures, token)
|
||||
-- renders (the free-roam pipeline, next frame) must find the orbit back
|
||||
Voxel3D.camera = nil
|
||||
Voxel3D.SHADOW_ALPHA = sunWas
|
||||
VoxelGrid.override = gridWas
|
||||
if not ok then
|
||||
-- endScene never ran, so the canvas is still bound and the shader still
|
||||
-- set; put the frame back the way it was found before rethrowing
|
||||
|
||||
+281
-5
@@ -136,6 +136,13 @@ end
|
||||
local models = {} -- "<tileset>:<index>" -> prebuilt local quads
|
||||
local frontSets = {} -- tileset id -> { [tile] = true } or false
|
||||
|
||||
-- The tileset's side-door art: the 2x2 tile grid of one doorway cell
|
||||
-- (data/voxel_heights.lua `sideDoors`), or nil when the tileset names none.
|
||||
function Buildings.sideDoorCell(tilesetId)
|
||||
local s = profile()
|
||||
return s and s.sideDoors and s.sideDoors[tilesetId] or nil
|
||||
end
|
||||
|
||||
-- The tileset's front-only tiles as a set (data/voxel_heights.lua
|
||||
-- `frontOnly`): the doorways, shop signs and painted lettering that belong
|
||||
-- on a facade and on no other face of the same building. nil when the
|
||||
@@ -252,6 +259,82 @@ end
|
||||
-- topRows (placement is still by `tiles` alone); they exist so the MODEL
|
||||
-- is built from the complete drawing and the tower rises to its real
|
||||
-- height instead of folding as two half-buildings.
|
||||
-- Composite one doorway cell PAST the end of the sprite, at indices
|
||||
-- W*H .. W*H+255, and hand back its base. The side-door pass paints with
|
||||
-- sprite indices like everything else -- `emit` resolves a voxel's colour
|
||||
-- through sp.ax/sp.ay and knows nothing about where the index came from --
|
||||
-- so the door only has to BE in the sprite arrays to travel the rest of
|
||||
-- the pipeline untouched. Appending rather than drawing into the grid is
|
||||
-- the point: the drawing itself must not change, or the silhouette flood,
|
||||
-- the taper and every measured band would be read off art the tileset
|
||||
-- never placed here.
|
||||
--
|
||||
-- Nothing else walks past W*H (measure's shadeTexel scan and the pane
|
||||
-- flood both stop there), so the block is invisible to measurement and
|
||||
-- visible only to the code that asks for it by index.
|
||||
--
|
||||
-- WHICH OF THE 256 TEXELS ARE THE DOOR. A doorway cell is not a doorway
|
||||
-- edge to edge: the tileset draws it as a cell OF A FACADE, so its outer
|
||||
-- ring is the wall beside and above the frame, and its last row is the
|
||||
-- black threshold the building stands on with the door's own step cut into
|
||||
-- it. Painting all 16x16 onto a flank would stamp a one-pixel border of
|
||||
-- front-wall art around every door.
|
||||
--
|
||||
-- The front facade tells the ring from the door by flooding: the wall
|
||||
-- around the door is one region with the whole facade, far too big to be a
|
||||
-- pane, and only what the black frame SEALS sinks. The same test, bounded
|
||||
-- to the block: flood the left, right and top edges through their own
|
||||
-- shade class, and what the flood reaches is context -- left unpainted, so
|
||||
-- the flank keeps the texel it already had. Not the bottom edge, because
|
||||
-- the bottom edge is the ground: the step under the door is sealed there
|
||||
-- on the drawn facade too, and it recesses with the rest of the doorway.
|
||||
--
|
||||
-- What is painted then splits the way a facade's does: black is frame and
|
||||
-- stays flush with the wall, everything it seals sinks a voxel behind it.
|
||||
local function readDoor(sp, data, perRow, cell)
|
||||
local base = sp.W * sp.H
|
||||
local black = {}
|
||||
for dy = 0, 15 do
|
||||
local row = cell[math.floor(dy / 8) + 1]
|
||||
for dx = 0, 15 do
|
||||
local tile = row[math.floor(dx / 8) + 1]
|
||||
local px = (tile % perRow) * 8 + dx % 8
|
||||
local py = math.floor(tile / perRow) * 8 + dy % 8
|
||||
local k = dy * 16 + dx
|
||||
local i = base + k
|
||||
sp.ax[i], sp.ay[i] = px, py
|
||||
local r, g, b, a = data:getPixel(px, py)
|
||||
sp.col[i] = shadeOf(r, g, b, a)
|
||||
sp.inside[i] = true
|
||||
black[k] = sp.col[i] == BLACK
|
||||
end
|
||||
end
|
||||
|
||||
local context, stack = {}, {}
|
||||
local function seed(dx, dy, cls)
|
||||
if dx < 0 or dx > 15 or dy < 0 or dy > 15 then return end
|
||||
local k = dy * 16 + dx
|
||||
if context[k] or black[k] ~= cls then return end
|
||||
context[k] = true
|
||||
stack[#stack + 1] = k
|
||||
end
|
||||
for dy = 0, 15 do
|
||||
seed(0, dy, black[dy * 16])
|
||||
seed(15, dy, black[dy * 16 + 15])
|
||||
end
|
||||
for dx = 0, 15 do seed(dx, 0, black[dx]) end
|
||||
while #stack > 0 do
|
||||
local k = table.remove(stack)
|
||||
local dx, dy, cls = k % 16, math.floor(k / 16), black[k]
|
||||
seed(dx + 1, dy, cls)
|
||||
seed(dx - 1, dy, cls)
|
||||
seed(dx, dy + 1, cls)
|
||||
seed(dx, dy - 1, cls)
|
||||
end
|
||||
|
||||
sp.door = { base = base, black = black, context = context }
|
||||
end
|
||||
|
||||
local function read(t, data, perRow, frontOnly)
|
||||
local tiles = t.tiles
|
||||
if t.topRows then
|
||||
@@ -1087,11 +1170,113 @@ local function deskSetModel(sp, pr, t)
|
||||
W = W, ytop = ytop, zmin = 0, zmax = D - 1 }
|
||||
end
|
||||
|
||||
-- ------- the doorway a gate house is entered by from a side the drawing
|
||||
-- never shows it on (data/voxel_heights.lua `sideDoors`, and `sideDoorsAt`
|
||||
-- below for how the placements are found).
|
||||
--
|
||||
-- One cell of the tileset's own doorway art, standing on the ground of the
|
||||
-- face the player walks into, and hung by the SAME rule the drawn facade
|
||||
-- hangs its own door by: the art's black frame stays flush with the wall
|
||||
-- and everything it seals sinks a voxel behind it (`measure`'s pane pass,
|
||||
-- applied here by hand because the art is not in the drawing to be flooded
|
||||
-- with it). So a side door and a front door are the same depth of the same
|
||||
-- opening, and the jamb faces the recess exposes come out of the mesher for
|
||||
-- free, wearing the frame's own texels.
|
||||
--
|
||||
-- ORIENTATION IS NOT FREE. A flank quad carries one texel and the mesher
|
||||
-- picks it per voxel, so which art column lands at which world coordinate
|
||||
-- is decided HERE and nowhere else -- and a face is read from outside, so
|
||||
-- the art's own left-to-right runs with the viewer's, not with the world's:
|
||||
-- facing east at a west wall, south is to your right (+z); facing west at
|
||||
-- an east wall, north is (-z); facing south at a north wall, west is (-x).
|
||||
-- Two of the three are mirrored against the axis, which is the same reason
|
||||
-- `backMap` exists -- a wall seen from behind IS the drawing mirrored.
|
||||
local DOOR = 16
|
||||
|
||||
local function sideDoors(at, sp, doors, W)
|
||||
if not (doors and #doors > 0 and sp.door) then return at end
|
||||
local art = sp.door
|
||||
|
||||
-- The face's OUTER surface, walked in from the box edge until the wall
|
||||
-- answers. A drawing inset from its own grid (B03's outer columns are
|
||||
-- terrain, not building) stands its flank a column or two in, and a door
|
||||
-- pinned to the box edge would hang in the air beside it.
|
||||
local function faceX(from, step, off)
|
||||
for k = 0, W - 1 do
|
||||
local x = from + step * k
|
||||
for y = 0, DOOR - 1 do
|
||||
for z = off, off + DOOR - 1 do
|
||||
if at(x, y, z) then return x end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local list = {}
|
||||
for _, d in ipairs(doors) do
|
||||
local face = 0 -- north: the facade's own z origin
|
||||
if d.side == "w" then face = faceX(0, 1, d.at)
|
||||
elseif d.side == "e" then face = faceX(W - 1, -1, d.at) end
|
||||
if face then
|
||||
list[#list + 1] = { side = d.side, off = d.at, face = face }
|
||||
end
|
||||
end
|
||||
if #list == 0 then return at end
|
||||
|
||||
-- art column at (x, z) for door `e`, or nil when the voxel is not in it
|
||||
local function column(e, x, z)
|
||||
if e.side == "n" then
|
||||
if (z == 0 or z == 1) and x >= e.off and x < e.off + DOOR then
|
||||
return e.off + DOOR - 1 - x, z == 0
|
||||
end
|
||||
elseif z >= e.off and z < e.off + DOOR then
|
||||
if e.side == "w" then
|
||||
if x == e.face then return z - e.off, true end
|
||||
if x == e.face + 1 then return z - e.off, false end
|
||||
else
|
||||
if x == e.face then return e.off + DOOR - 1 - z, true end
|
||||
if x == e.face - 1 then return e.off + DOOR - 1 - z, false end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return function(x, y, z)
|
||||
local v = at(x, y, z)
|
||||
-- no wall here is the end of it: a door is hung ON the building, and
|
||||
-- nothing about it may add geometry the drawing does not stand up
|
||||
if v == nil or y >= DOOR then return v end
|
||||
for _, e in ipairs(list) do
|
||||
local c, outer = column(e, x, z)
|
||||
if c then
|
||||
-- art row 0 is the door's head, so the ground row is its last
|
||||
local k = (DOOR - 1 - y) * DOOR + c
|
||||
-- the art's own ring: wall, not door. The flank keeps its texel.
|
||||
if art.context[k] then return v end
|
||||
if art.black[k] then
|
||||
-- the frame, flush with the wall; behind it the wall stands on
|
||||
if outer then return art.base + k end
|
||||
return v
|
||||
end
|
||||
-- and what the frame seals sinks: the face voxel goes, the one
|
||||
-- behind it wears the art. (Written long: `outer and nil or i`
|
||||
-- returns i for BOTH, nil being false to `and`.)
|
||||
if outer then return nil end
|
||||
return art.base + k
|
||||
end
|
||||
end
|
||||
return v
|
||||
end
|
||||
end
|
||||
|
||||
-- The voxel model as a lookup: `at(x, y, z)` is the index of the sprite
|
||||
-- pixel that voxel wears, or nil. Build ORDER is expressed as lookup
|
||||
-- order -- roof first, so it overwrites the walls it intersects, and walls
|
||||
-- are trimmed to its underside so nothing pokes through the surface.
|
||||
local function model(sp, pr, t)
|
||||
-- are trimmed to its underside so nothing pokes through the surface. A
|
||||
-- gate's side doors are hung on the finished lookup, last of all, because
|
||||
-- they answer to the FACE rather than to any band of the drawing.
|
||||
local function model(sp, pr, t, doors)
|
||||
if t.parts then return deskSetModel(sp, pr, t) end
|
||||
local W, H, D = sp.W, sp.H, pr.D
|
||||
local slab, roofRows = t.slab, t.roofRows
|
||||
@@ -1209,6 +1394,8 @@ local function model(sp, pr, t)
|
||||
return pr.interior[i]
|
||||
end
|
||||
|
||||
at = sideDoors(at, sp, doors, W)
|
||||
|
||||
return { at = at, W = W, ytop = ytop,
|
||||
zmin = ledge0 and -2 or 0,
|
||||
zmax = math.max(rz1, ledge0 and (D + 1) or 0) }
|
||||
@@ -1420,6 +1607,82 @@ local function matches(S, t, tx, ty)
|
||||
return true
|
||||
end
|
||||
|
||||
-- ------- which of a placement's faces a gate is entered by
|
||||
--
|
||||
-- Read off the MAP, not authored: a gate entrance is a warp that lands in a
|
||||
-- gate house, and the face is whichever one of this placement the warp cell
|
||||
-- stands against. Thirty-odd doors fall out of two lines of geometry, and
|
||||
-- none of them can drift out of step with a map edit the way a hand list
|
||||
-- would. Three tests, each of them load-bearing:
|
||||
--
|
||||
-- the destination is a GATE tileset -- what makes a building a gate house
|
||||
-- rather than a house with a back door. GATE and FOREST_GATE both, so
|
||||
-- the Viridian Forest pair count; the Safari rest houses are on GATE
|
||||
-- too and are excluded by the next test, their warps being drawn doors
|
||||
-- already.
|
||||
-- the cell is not already a door tile -- a south entrance IS drawn, as a
|
||||
-- doorway block in the facade, and lib/Structures.lua folds it up into
|
||||
-- the front face. Adding a second one there would fight it.
|
||||
-- the cell is WALKABLE -- the ROM gives an unreachable twin warp to
|
||||
-- several gates (a fence cell beside the real opening on Route 7 west
|
||||
-- and Route 16 east, a tree beside Route 6's), and a door on the wall
|
||||
-- behind a fence is a door into nothing. The reachable cells are the
|
||||
-- entrance, and two of them side by side are the gate's real two-cell
|
||||
-- opening, which comes out as the double door it always was.
|
||||
--
|
||||
-- The south face is skipped whether or not it is drawn: it is the one face
|
||||
-- the drawing states in full, so anything it needs it already has.
|
||||
local function sideDoorsAt(map, tileset, tx, ty, bw, bh)
|
||||
-- through the module, NOT a global: `Game` is a local everywhere in the
|
||||
-- engine (`local Game = require("src.core.Game")` in a dozen files) and
|
||||
-- reading `_G.Game` came back nil every time -- which fails silently and
|
||||
-- exactly like the feature being off, because a nil map table is also
|
||||
-- what a headless build legitimately has.
|
||||
local ok, G = pcall(require, "src.core.Game")
|
||||
local defs = ok and G and G.data and G.data.maps
|
||||
local warps = map.def and map.def.warps
|
||||
if not (defs and warps and warps[1]) then return nil end
|
||||
if not Buildings.sideDoorCell(tileset.id) then return nil end
|
||||
|
||||
local out = nil
|
||||
for _, w in ipairs(warps) do
|
||||
local dest = defs[w.destMap]
|
||||
if dest and dest.tileset and dest.tileset:find("GATE", 1, true)
|
||||
and map:isWalkableCell(w.x, w.y)
|
||||
and not map:isDoorTileCell(w.x, w.y) then
|
||||
-- the cell in the model's own pixels: a cell is two tiles, a tile
|
||||
-- eight pixels, and the placement's origin is (tx, ty) in tiles
|
||||
local lx, lz = w.x * 16 - tx * 8, w.y * 16 - ty * 8
|
||||
local side = nil
|
||||
if lz == -16 and lx >= 0 and lx < bw * 8 then side = "n"
|
||||
elseif lx == -16 and lz >= 0 and lz < bh * 8 then side = "w"
|
||||
elseif lx == bw * 8 and lz >= 0 and lz < bh * 8 then side = "e" end
|
||||
if side then
|
||||
out = out or {}
|
||||
out[#out + 1] = { side = side, at = side == "n" and lx or lz }
|
||||
end
|
||||
end
|
||||
end
|
||||
if out then
|
||||
table.sort(out, function(a, b)
|
||||
if a.side ~= b.side then return a.side < b.side end
|
||||
return a.at < b.at
|
||||
end)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- The model cache key's door half. A template's doors belong to the
|
||||
-- PLACEMENT -- the same 6x4 block is the gate on four routes and the warps
|
||||
-- sit at different rows of it on each -- so two placements of one drawing
|
||||
-- are two models, and only placements that agree share one.
|
||||
local function doorKey(doors)
|
||||
if not doors then return "" end
|
||||
local parts = {}
|
||||
for i, d in ipairs(doors) do parts[i] = d.side .. d.at end
|
||||
return "#" .. table.concat(parts, ",")
|
||||
end
|
||||
|
||||
-- Find every placement of every template for this map's tileset, build one
|
||||
-- model per template, and stamp it. Returns nothing; the quads land in
|
||||
-- S.objectQuads and the tiles are claimed so the volume path never boxes a
|
||||
@@ -1440,6 +1703,9 @@ function Buildings.build(S, map, data, perRow)
|
||||
if type(t.tiles) == "table" and #t.tiles > 0 then
|
||||
local bh, bw = #t.tiles, #t.tiles[1]
|
||||
local first = t.tiles[1][1]
|
||||
-- the model this placement stamps. Not hoisted out of the loops any
|
||||
-- more: a template's doors belong to the placement, so two hits of
|
||||
-- one drawing on the same map can be two models (see doorKey).
|
||||
local built = nil
|
||||
for ty = 0, th - bh do
|
||||
Budget.tick()
|
||||
@@ -1465,8 +1731,13 @@ function Buildings.build(S, map, data, perRow)
|
||||
end
|
||||
end
|
||||
if free and matches(S, t, tx, ty) then
|
||||
if not built then
|
||||
local key = tileset.id .. ":" .. index
|
||||
do
|
||||
-- keyed per PLACEMENT once a gate's doors are in play (see
|
||||
-- doorKey): the drawing is shared, the openings are not
|
||||
local doors = not t.claimOnly
|
||||
and sideDoorsAt(map, tileset, tx, ty, bw, bh)
|
||||
or nil
|
||||
local key = tileset.id .. ":" .. index .. doorKey(doors)
|
||||
if not models[key] then
|
||||
if t.claimOnly then
|
||||
-- claim the cells, stamp nothing: the drawing here is
|
||||
@@ -1478,8 +1749,13 @@ function Buildings.build(S, map, data, perRow)
|
||||
else
|
||||
local sp = read(t, data, perRow,
|
||||
Buildings.frontOnly(tileset.id))
|
||||
if doors then
|
||||
readDoor(sp, data, perRow,
|
||||
Buildings.sideDoorCell(tileset.id))
|
||||
end
|
||||
local pr = measure(sp, t)
|
||||
models[key] = emit(model(sp, pr, t), sp, atlasW, atlasH)
|
||||
models[key] = emit(model(sp, pr, t, doors), sp,
|
||||
atlasW, atlasH)
|
||||
end
|
||||
end
|
||||
built = models[key]
|
||||
|
||||
+72
-6
@@ -65,6 +65,26 @@ end
|
||||
|
||||
local ChunkMesher = {}
|
||||
|
||||
-- Which drawn row a FLAT-topped volume's top face wears at depth `ty`.
|
||||
--
|
||||
-- A structure is usually deeper than the art that draws it, so the rows
|
||||
-- cycle and the drawing repeats down the top. That is right for art which
|
||||
-- genuinely repeats -- the Safari Zone's fence alternates two tiles the
|
||||
-- whole way down -- and wrong for a RIM over a uniform body: a cliff
|
||||
-- mound's first row is its top edge, and cycling lays that edge again
|
||||
-- every second tile, striping a plateau with rims it should not have.
|
||||
--
|
||||
-- Where Structures found the body uniform, the rim is laid once at the
|
||||
-- north edge and the body held after it. Everything else cycles as before.
|
||||
function ChunkMesher.flatTopRow(run, ty)
|
||||
local m = math.min(2, run.extent)
|
||||
local d = ty - run.north
|
||||
if run.topUniform then
|
||||
return run.north + math.min(d, m - 1)
|
||||
end
|
||||
return run.north + (d % m)
|
||||
end
|
||||
|
||||
-- Ring of border blocks meshed around the body, matching the width
|
||||
-- TileRenderer draws so the two modes end at the same place.
|
||||
local RING = 3
|
||||
@@ -281,6 +301,19 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
return tile
|
||||
end
|
||||
|
||||
-- The capping course an interior wall wears on its TOP face (see
|
||||
-- TileShape.wallTop). A wall band folds entirely onto its own face, so
|
||||
-- the top had nothing left to lay flat and repeated the face -- a house's
|
||||
-- town-map poster and window, a Center's pokeball poster and the Rocket
|
||||
-- lift's doors came out lying across the top of the wall as well as
|
||||
-- standing in it. Only the top is redirected: the face still draws what
|
||||
-- the map draws.
|
||||
local wallTop = TileShape.wallTop(tileset.id)
|
||||
local function capOf(s, tile)
|
||||
if not (wallTop and s.class == "wall") then return nil end
|
||||
return wallTop(tile)
|
||||
end
|
||||
|
||||
-- one atlas-rect UV, optionally cropped to art rows [vTop, vBot] of 8
|
||||
local function uvRect(tile, vTop, vBot)
|
||||
local ax = (tile % perRow) * 8
|
||||
@@ -403,8 +436,12 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
|
||||
-- `to` routes the quad somewhere other than the main sink -- the water
|
||||
-- surface is the only caller that ever does (see runGeometry's header).
|
||||
local function topQuad(x0, z0, h, tile, shade, to)
|
||||
local u0, u1, v0, v1 = uvRect(tile, 0, 8)
|
||||
-- `vTop`/`vBot` crop the art to a row range of the tile, which only the
|
||||
-- half-cell furniture rule below ever asks for: a top band that has to
|
||||
-- cover more depth than it was drawn with hands each 8px cell its own
|
||||
-- slice of the band instead of the whole of it.
|
||||
local function topQuad(x0, z0, h, tile, shade, to, vTop, vBot)
|
||||
local u0, u1, v0, v1 = uvRect(tile, vTop or 0, vBot or 8)
|
||||
;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
|
||||
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
|
||||
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
|
||||
@@ -561,11 +598,16 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
{ x0 + 8, neY, z0 }, { x0, nwY, z0 } },
|
||||
{ { u0, v1 }, { u1, v1 }, { u1, v0 }, { u0, v0 } }, 0.95)
|
||||
elseif run then
|
||||
local m = math.min(2, run.extent)
|
||||
local topTile = map:tileAt(tx, run.north + ((ty - run.north) % m))
|
||||
local topTile = map:tileAt(tx, ChunkMesher.flatTopRow(run, ty))
|
||||
-- a DETECTED wall volume caps the same way a pinned one does:
|
||||
-- the Rocket lift's cabin doors are found rather than pinned,
|
||||
-- and their drawing lay across the top of the wall they are set
|
||||
-- into (see wallTop)
|
||||
topTile = capOf(s, tile) or topTile
|
||||
topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE)
|
||||
else
|
||||
local topTile = tile
|
||||
local vTop, vBot = nil, nil
|
||||
if s.art == "upright" and s.authored then
|
||||
-- Top art for a pinned box. A furniture drawing is top-view
|
||||
-- rows over floor(h/8) face-on rows the fold stands upright;
|
||||
@@ -592,6 +634,30 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
end
|
||||
end
|
||||
local row = math.min(ty, front - math.floor(h / 8))
|
||||
-- HALF-CELL FURNITURE, one cell of plot: the drawing gives ONE
|
||||
-- tile row of top view (the counter's surface) over one that
|
||||
-- folds up as the face (its front panel), and the plot under it
|
||||
-- is 16px deep. Repeating the top row over both depth rows --
|
||||
-- what `row` above resolves to, since the face row has no top
|
||||
-- art of its own to wear -- draws the surface TWICE: the
|
||||
-- Centers' counters ran a black back edge and its white
|
||||
-- highlight down the middle of every counter, and the push bell
|
||||
-- drawn on one of them came out as two bells stacked front to
|
||||
-- back. The band is foreshortened, not tiled, so each depth row
|
||||
-- takes HALF of it and the one drawing covers the whole top.
|
||||
--
|
||||
-- Deliberately narrow: only a run that is exactly one cell deep
|
||||
-- with exactly one top row. A deeper run states its own depth
|
||||
-- 1:1 already (the lounge couch is four tile rows over two
|
||||
-- cells, and its cushions must stay cushion-sized), and only the
|
||||
-- last of its rows repeats -- which is the drawing tiling, not
|
||||
-- a surface drawn once and stretched.
|
||||
local face = math.floor(h / 8)
|
||||
if front - face - north == 0 and front - north == 1 then
|
||||
local k = ty - north
|
||||
row = north
|
||||
vTop, vBot = k * 4, k * 4 + 4
|
||||
end
|
||||
if row < north then
|
||||
-- the whole run folded onto the face: top with the drawn
|
||||
-- row just above it when that row is furniture too (a
|
||||
@@ -601,7 +667,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
row = (above and above.authored and above.art == "upright")
|
||||
and (north - 1) or north
|
||||
end
|
||||
topTile = S.tileAt[keyOf(tx, row)]
|
||||
topTile = capOf(s, tile) or S.tileAt[keyOf(tx, row)]
|
||||
end
|
||||
-- water's surface, and only water's: the recessed sheet itself,
|
||||
-- never the ground's shoreline bands around it. A cell an object
|
||||
@@ -610,7 +676,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
|
||||
-- on the pond.
|
||||
topQuad(x0, z0, h, topTile,
|
||||
s.art == "upright" and VOLUME_TOP_SHADE or 1,
|
||||
(s.class == "water") and waterPush or nil)
|
||||
(s.class == "water") and waterPush or nil, vTop, vBot)
|
||||
end
|
||||
|
||||
-- sides: 8px bands wherever the neighbour is lower. Band k spans
|
||||
|
||||
+29
-33
@@ -1101,43 +1101,20 @@ function OverworldBattle.sideTexture(battle, side)
|
||||
for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
|
||||
texturing = side
|
||||
|
||||
-- A SHINY on this side, tinted here rather than in ShinyUI's flat-path
|
||||
-- wrap. This is the one place a pic is rendered for ONE side at a time,
|
||||
-- so it is the only place the two sides can be tinted differently -- a
|
||||
-- shiny facing an ordinary mon gets its own colour and leaves the other
|
||||
-- alone, which the engine's both-sides-at-once pic layer cannot do.
|
||||
local shinyTint = nil
|
||||
do
|
||||
-- NOT when this side is showing a PERSON. Both sides can be holding a
|
||||
-- trainer pic rather than a Pokemon -- the foe's portrait before the
|
||||
-- send-out, and the player's own back until "Go!" -- and a shiny is a
|
||||
-- fact about a Pokemon, not about its owner. Tinting through it turned
|
||||
-- the player's trainer sprite a different colour for the whole intro,
|
||||
-- which is what a shiny Pokemon in the party looks like if you do not
|
||||
-- ask this question. The two tests are the same ones sideTexture already
|
||||
-- uses to label the finished texture, asked here instead of after.
|
||||
local person = (side == "enemy"
|
||||
and battle.showEnemyTrainer and battle.trainerPic)
|
||||
or (side == "player"
|
||||
and battle.showPlayerBack and battle.playerBackPic)
|
||||
if not person then
|
||||
local battler = (side == "player") and battle.player or battle.enemy
|
||||
local g2 = game()
|
||||
shinyTint = battler and V.require("ShinyUI")
|
||||
.tintFor(battler.mon, g2 and g2.data) or nil
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- no shiny tint here any more
|
||||
--
|
||||
-- This used to bracket the draw below with that side's shiny tint, on the
|
||||
-- grounds that rendering one side at a time is the only place the two can
|
||||
-- be coloured differently. True, and no longer needed: the PIC itself is
|
||||
-- now built from a shiny palette (lib/ShinyPics.lua), which is per-mon
|
||||
-- rather than per-side and gets the colour right instead of approximating
|
||||
-- it with a multiply. Tinting on top of that would apply the shift twice.
|
||||
local ok, err = pcall(function()
|
||||
g.setCanvas(canvas)
|
||||
g.clear(0, 0, 0, 0)
|
||||
g.setBlendMode("alpha")
|
||||
g.setColor(1, 1, 1, 1)
|
||||
if shinyTint then
|
||||
V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0)
|
||||
else
|
||||
innerPics(battle, 0, 0, 0)
|
||||
end
|
||||
innerPics(battle, 0, 0, 0)
|
||||
end)
|
||||
|
||||
texturing = nil
|
||||
@@ -1288,6 +1265,22 @@ function OverworldBattle.install()
|
||||
return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
|
||||
end
|
||||
|
||||
-- ------- the shiny arrival sparkle, on every rung this file draws
|
||||
--
|
||||
-- Called from BOTH branches below, because both are a complete battle
|
||||
-- frame: the `not shot` branch is the engine's own screen (3D-BTL OFF, and
|
||||
-- any battle the mod does not stage), and the other is the staged shot.
|
||||
--
|
||||
-- It lives here rather than on a hook or a monkeypatch of its own because
|
||||
-- this override IS the battle's draw -- every rung, every frame. The two
|
||||
-- other seams were tried and measured at zero calls: BattleState:update is
|
||||
-- never reached (the battle is not the top of the stack during its own
|
||||
-- intro), and the engine's `battle.overlay` hook is only reached through
|
||||
-- the tail of the engine's draw. See lib/ShinyFlash.lua.
|
||||
local function shinyFlash(battle)
|
||||
pcall(function() V.require("ShinyFlash").render(battle) end)
|
||||
end
|
||||
|
||||
local innerDraw = BattleState.draw
|
||||
function BattleState:draw()
|
||||
local shot = OverworldBattle.shot()
|
||||
@@ -1298,7 +1291,9 @@ function OverworldBattle.install()
|
||||
-- that loses its arena mid-fight goes back to white voids
|
||||
self.letterboxWhite = nil
|
||||
self.dramaticShapeShot = nil
|
||||
return innerDraw(self)
|
||||
local out = innerDraw(self)
|
||||
shinyFlash(self)
|
||||
return out
|
||||
end
|
||||
self.dramaticShapeShot = shot
|
||||
-- The world reaches the screen through the seam a render pipeline's
|
||||
@@ -1325,6 +1320,7 @@ function OverworldBattle.install()
|
||||
-- own HUD drew in, so it letterboxes and chunks identically
|
||||
local cap = BattleScene.capture
|
||||
if cap and cap.drawGB then pcall(cap.drawGB, self) end
|
||||
shinyFlash(self)
|
||||
end
|
||||
|
||||
-- The mons are geometry standing on the map now, drawn in the 3D pass
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
-- The arrival sparkle, on the FLAT battle screen.
|
||||
--
|
||||
-- ------- why ShinyFx could not be reused
|
||||
--
|
||||
-- lib/ShinyFx.lua is the sparkle for the STADIUM rungs, and every line of it
|
||||
-- is about the 3D arena: it is armed from Stadium.update on the frame a
|
||||
-- side's model changes, it is sized from the model's own world height and
|
||||
-- radius, and it draws additive quads into the voxel scene through
|
||||
-- Voxel3D.blend. None of that exists on the other rungs -- 3D-BTL OFF has no
|
||||
-- arena at all, and the two 2D-3D rungs stand flat PICS up as billboards
|
||||
-- rather than building a model to measure.
|
||||
--
|
||||
-- So the effect was Stadium-only, and had been since it was written: ShinyFx
|
||||
-- .arm is called from exactly one file. On every other rung a shiny simply
|
||||
-- appeared, with no announcement. This is the announcement, in the one
|
||||
-- coordinate space those rungs share -- the Game Boy's own 160x144 grid,
|
||||
-- where the pic itself is drawn.
|
||||
--
|
||||
-- ------- the two slots
|
||||
--
|
||||
-- Both are the engine's, and neither moves: the enemy's front pic lives in
|
||||
-- the 7x7 tile slot at hlcoord 12,0 (x 96..152, y 0..56) and the player's
|
||||
-- back pic stands at x=8 with its feet on the text box at y=96, two-times
|
||||
-- scaled, so it fills y 32..96. The burst springs from a point inside each,
|
||||
-- a little above centre, which is roughly where a Pokemon's chest is in art
|
||||
-- drawn to fill its box.
|
||||
--
|
||||
-- Deliberately NOT measured off the drawn image. resolveBattleScale can
|
||||
-- rescale a pic per species, the send-out grow animates the scale from zero,
|
||||
-- and following either would make the burst jump around during exactly the
|
||||
-- moment it is playing. The slot is fixed; the sparkle uses the slot.
|
||||
--
|
||||
-- ------- black AND white, both
|
||||
--
|
||||
-- Each spark is drawn twice: a wider near-black cross, then a white one
|
||||
-- inside it. One colour alone would be invisible half the time -- the battle
|
||||
-- screen's field is white, so a white spark vanishes on OFF, and the 2D-3D
|
||||
-- rungs composite the same pic over a sky or a map, where a black one does.
|
||||
-- The pair reads on both, and costs ten extra rectangles.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
|
||||
local ShinyFlash = {}
|
||||
|
||||
ShinyFlash.LIFE = 0.75 -- seconds, matching ShinyFx
|
||||
ShinyFlash.SPARKS = 9
|
||||
|
||||
-- The two slots, in GB pixels: where the burst starts and how far it travels.
|
||||
ShinyFlash.SLOTS = {
|
||||
enemy = { x = 124, y = 24, rx = 34, ry = 26 },
|
||||
player = { x = 40, y = 62, rx = 34, ry = 30 },
|
||||
}
|
||||
|
||||
-- Where each spark sits on the ring, as a fraction of a turn. Spread by hand
|
||||
-- rather than randomly: nine sparks on an even ring reads as a ring, and nine
|
||||
-- random ones read as a mess at this size. The half-step offset on alternate
|
||||
-- sparks keeps it from looking like a clock face.
|
||||
local ANGLES = {}
|
||||
for i = 1, ShinyFlash.SPARKS do
|
||||
ANGLES[i] = (i - 1) / ShinyFlash.SPARKS + (i % 2 == 0 and 0.5 or 0)
|
||||
/ ShinyFlash.SPARKS
|
||||
end
|
||||
|
||||
-- ------- why the clock is the WALL clock
|
||||
--
|
||||
-- Everything here happens on the DRAW side (see install), and a draw is
|
||||
-- handed no dt. Rather than accumulate one nobody offers, a burst records
|
||||
-- the time it started and its age is read back off love.timer.
|
||||
--
|
||||
-- That also makes it immune to being asked to draw more than once in a
|
||||
-- frame, which the wide layout does -- once per side -- and which a
|
||||
-- per-call dt accumulator would age at double speed.
|
||||
local function now()
|
||||
return (love.timer and love.timer.getTime and love.timer.getTime()) or 0
|
||||
end
|
||||
|
||||
-- live bursts: side -> the time it started
|
||||
local live = {}
|
||||
|
||||
-- what each side's pic was showing last frame, so an arrival is an EDGE
|
||||
local showing = {}
|
||||
|
||||
-- for the tests and the shot drivers, the way ShinyFx.debug is
|
||||
ShinyFlash.debug = { renders = 0, follows = 0, occupied = 0,
|
||||
armed = 0, draws = 0, sparks = 0, err = "" }
|
||||
|
||||
function ShinyFlash.arm(side)
|
||||
live[side] = now()
|
||||
ShinyFlash.debug.armed = ShinyFlash.debug.armed + 1
|
||||
end
|
||||
|
||||
function ShinyFlash.clear(side)
|
||||
live[side] = nil
|
||||
end
|
||||
|
||||
function ShinyFlash.reset()
|
||||
live, showing = {}, {}
|
||||
end
|
||||
|
||||
-- How far through its life this side's burst is, 0..1, or nil when there
|
||||
-- isn't one (or it has finished, which retires it on the way past).
|
||||
function ShinyFlash.age(side)
|
||||
local started = live[side]
|
||||
if not started then return nil end
|
||||
local u = (now() - started) / ShinyFlash.LIFE
|
||||
if u >= 1 then
|
||||
live[side] = nil
|
||||
return nil
|
||||
end
|
||||
return u
|
||||
end
|
||||
|
||||
function ShinyFlash.active(side)
|
||||
return ShinyFlash.age(side) ~= nil
|
||||
end
|
||||
|
||||
-- ------- is a Pokemon's own pic on screen for this side
|
||||
--
|
||||
-- The conditions are the engine's, read off drawPicsLayer rather than
|
||||
-- guessed: a side showing a TRAINER is showing a person and not a Pokemon,
|
||||
-- and the send-out, the faint fade and the safari/demo cases each have their
|
||||
-- own reason for the slot to be empty.
|
||||
--
|
||||
-- Returns the mon whose pic is up, or nil.
|
||||
function ShinyFlash.occupant(battle, side)
|
||||
if type(battle) ~= "table" then return nil end
|
||||
if side == "enemy" then
|
||||
if battle.showEnemyTrainer and battle.trainerPic then return nil end
|
||||
local b = battle.enemy
|
||||
if not (b and b.sprite) then return nil end
|
||||
if battle.enemyHidden or battle.enemySendingOut then return nil end
|
||||
if battle.fxHidden and battle:fxHidden(b) then return nil end
|
||||
return b.mon
|
||||
end
|
||||
if battle.showPlayerBack and battle.playerBackPic then return nil end
|
||||
if battle.safari or battle.demo then return nil end
|
||||
local b = battle.player
|
||||
if not (b and b.sprite) then return nil end
|
||||
if battle.sendingOut then return nil end
|
||||
if battle.fxHidden and battle:fxHidden(b) then return nil end
|
||||
return b.mon
|
||||
end
|
||||
|
||||
-- Arm on the frame a side's occupant CHANGES to a shiny -- a send-out, a
|
||||
-- switch and a wild foe's first appearance alike, which is the same edge
|
||||
-- ShinyFx picks for the models.
|
||||
function ShinyFlash.follow(battle)
|
||||
ShinyFlash.debug.follows = ShinyFlash.debug.follows + 1
|
||||
for _, side in ipairs({ "enemy", "player" }) do
|
||||
local mon = ShinyFlash.occupant(battle, side)
|
||||
if mon then ShinyFlash.debug.occupied = ShinyFlash.debug.occupied + 1 end
|
||||
if mon ~= showing[side] then
|
||||
showing[side] = mon
|
||||
if mon and Shiny.isShiny(mon) then
|
||||
ShinyFlash.arm(side)
|
||||
else
|
||||
ShinyFlash.clear(side)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- drawing
|
||||
--
|
||||
-- Whole pixels. The screen this lands on is 160x144 and everything else in
|
||||
-- it is on the pixel grid, so a spark at x=41.37 would be the one soft thing
|
||||
-- on a hard-edged frame.
|
||||
local function spark(px, py, arm)
|
||||
local g = love.graphics
|
||||
px, py = math.floor(px + 0.5), math.floor(py + 0.5)
|
||||
-- the dark cross first, one pixel proud of the light one on every side
|
||||
g.setColor(0, 0, 0, 1)
|
||||
g.rectangle("fill", px - arm - 1, py - 1, arm * 2 + 3, 3)
|
||||
g.rectangle("fill", px - 1, py - arm - 1, 3, arm * 2 + 3)
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.rectangle("fill", px - arm, py, arm * 2 + 1, 1)
|
||||
g.rectangle("fill", px, py - arm, 1, arm * 2 + 1)
|
||||
end
|
||||
|
||||
-- One side's burst, if it has one.
|
||||
function ShinyFlash.draw(side, sx, sy)
|
||||
local u = ShinyFlash.age(side)
|
||||
if not u then return end
|
||||
local slot = ShinyFlash.SLOTS[side]
|
||||
if not slot then return end
|
||||
local g = love.graphics
|
||||
local r, gg, b, a = g.getColor()
|
||||
|
||||
-- Out and fading. The ring eases OUT rather than travelling at a constant
|
||||
-- speed -- fast off the mark, slow at the edge -- because a burst that
|
||||
-- decelerates reads as thrown and one that does not reads as a wipe.
|
||||
local ease = 1 - (1 - u) * (1 - u)
|
||||
local fade = 1 - u
|
||||
g.setColor(1, 1, 1, 1)
|
||||
ShinyFlash.debug.draws = ShinyFlash.debug.draws + 1
|
||||
|
||||
for i = 1, ShinyFlash.SPARKS do
|
||||
-- every third spark is held back a little, so the ring has some depth
|
||||
-- rather than nine points on one circle
|
||||
local lag = (i % 3 == 0) and 0.78 or 1
|
||||
local ang = ANGLES[i] * math.pi * 2
|
||||
local px = (sx or 0) + slot.x + math.cos(ang) * slot.rx * ease * lag
|
||||
local py = (sy or 0) + slot.y - math.sin(ang) * slot.ry * ease * lag
|
||||
-- arms shrink as the spark fades, so it goes out rather than vanishing
|
||||
local arm = 1 + math.floor(fade * 2.5)
|
||||
spark(px, py, arm)
|
||||
ShinyFlash.debug.sparks = ShinyFlash.debug.sparks + 1
|
||||
end
|
||||
|
||||
g.setColor(r, gg, b, a)
|
||||
end
|
||||
|
||||
-- ------- BEHIND the Pokemon, not over it
|
||||
--
|
||||
-- The burst springs from inside the mon and flies outward, so the frames that
|
||||
-- matter most are the ones where the ring is still small and sitting ON the
|
||||
-- body. Drawn from the overlay hook -- the end of the battle draw -- every one
|
||||
-- of those lands in FRONT of the pic, and the sparkle reads as stuck to the
|
||||
-- glass rather than as coming from the Pokemon.
|
||||
--
|
||||
-- So it is drawn from the PICS LAYER instead, before the engine's own pics go
|
||||
-- down. That is the only place in the frame that is behind the mon and in
|
||||
-- front of the field.
|
||||
--
|
||||
-- The overlay hook stays, and is still the only seam the 3D rungs have:
|
||||
-- OverworldBattle captured drawPicsLayer at install time and its battle draw
|
||||
-- calls the captured copy, so the wrap below never runs there. Whichever seam
|
||||
-- fires first draws; the other one sees the side already spent and leaves it
|
||||
-- alone. `spent` is cleared by the overlay, which is the one call guaranteed
|
||||
-- to happen exactly once per battle draw.
|
||||
local spent = {}
|
||||
|
||||
-- One side, unless it has already been drawn this frame.
|
||||
local function once(side, sx, sy)
|
||||
if spent[side] then return end
|
||||
spent[side] = true
|
||||
ShinyFlash.draw(side, sx, sy)
|
||||
end
|
||||
|
||||
-- The pics layer, BEFORE the engine's pics. `onlySide` is the wide layout
|
||||
-- drawing one side per call, and is honoured so the burst lands in the same
|
||||
-- pass its Pokemon does.
|
||||
--
|
||||
-- Skipped while the layer is SLIDING (the intro walks the whole battle in
|
||||
-- from the side): the slot this draws to is fixed, so a burst during the
|
||||
-- slide would sit still while the mon travelled past it. Nothing is lost --
|
||||
-- the arrival edge that arms it is after the slide is over.
|
||||
function ShinyFlash.renderBehind(battle, slide, sx, sy, onlySide)
|
||||
ShinyFlash.debug.behinds = (ShinyFlash.debug.behinds or 0) + 1
|
||||
ShinyFlash.follow(battle)
|
||||
if (slide or 0) ~= 0 then return end
|
||||
if onlySide ~= "player" then once("enemy", sx, sy) end
|
||||
if onlySide ~= "enemy" then once("player", sx, sy) end
|
||||
end
|
||||
|
||||
-- Follow the occupants and draw whatever the pics layer did not, in one call.
|
||||
function ShinyFlash.render(battle)
|
||||
ShinyFlash.debug.renders = ShinyFlash.debug.renders + 1
|
||||
ShinyFlash.follow(battle)
|
||||
once("enemy", 0, 0)
|
||||
once("player", 0, 0)
|
||||
spent = {} -- one battle draw ends here; the next is new
|
||||
end
|
||||
|
||||
-- ------- install
|
||||
--
|
||||
-- Through the engine's own `battle.overlay` hook, whose comment at the call
|
||||
-- site names this exact use ("shiny sparkles, custom HUD chrome"). It fires
|
||||
-- at the very end of BattleState:draw, in the Game Boy's own 160x144 space,
|
||||
-- with the battle as its argument -- which is all this needs.
|
||||
--
|
||||
-- A MONKEYPATCH ON UPDATE WAS TRIED FIRST AND DOES NOT WORK, which is worth
|
||||
-- recording so it is not tried again: BattleState:update never fires during
|
||||
-- the intro, because the battle is not the top of the stack there and
|
||||
-- StateStack:update only calls the top. Measured -- installed, confirmed live
|
||||
-- on the class, zero calls -- rather than reasoned about.
|
||||
--
|
||||
-- The hook has no shake offset to give, and does not need one: it is called
|
||||
-- after the screen-shake translate has been popped, so nominal coordinates
|
||||
-- are the right ones.
|
||||
--
|
||||
-- ------- and the second seam, for depth
|
||||
--
|
||||
-- The overlay alone draws the burst OVER the Pokemon. The pics layer is
|
||||
-- wrapped as well so it can go down BEHIND it (see renderBehind), on every
|
||||
-- rung where the engine's own method is the one called. On the 3D rungs it is
|
||||
-- not -- OverworldBattle captured drawPicsLayer at install time and calls the
|
||||
-- captured copy -- and there the overlay is still the seam, which is why both
|
||||
-- are installed rather than one replacing the other.
|
||||
function ShinyFlash.install()
|
||||
local mod = V.mod
|
||||
if not (mod and mod.hooks and mod.hooks.wrap) then return false end
|
||||
if ShinyFlash.installed then return true end
|
||||
mod.hooks:wrap("battle.overlay", function(next, battle)
|
||||
local out = next(battle)
|
||||
local ok, err = pcall(ShinyFlash.render, battle)
|
||||
if not ok then ShinyFlash.debug.err = tostring(err) end
|
||||
return out
|
||||
end)
|
||||
|
||||
local okBS, BattleState = pcall(require, "src.battle.BattleState")
|
||||
if okBS and type(BattleState) == "table"
|
||||
and type(BattleState.drawPicsLayer) == "function"
|
||||
and not BattleState.dramaticShapeShinyFlash then
|
||||
local inner = BattleState.drawPicsLayer
|
||||
function BattleState:drawPicsLayer(slide, sx, sy, onlySide, ...)
|
||||
ShinyFlash.debug.picsCalls = (ShinyFlash.debug.picsCalls or 0) + 1
|
||||
local ok, err = pcall(ShinyFlash.renderBehind, self, slide, sx, sy,
|
||||
onlySide)
|
||||
if not ok then ShinyFlash.debug.err = tostring(err) end
|
||||
return inner(self, slide, sx, sy, onlySide, ...)
|
||||
end
|
||||
BattleState.dramaticShapeShinyFlash = true
|
||||
end
|
||||
|
||||
ShinyFlash.installed = true
|
||||
return true
|
||||
end
|
||||
|
||||
return ShinyFlash
|
||||
+118
-7
@@ -368,17 +368,128 @@ end
|
||||
-- So: slide species use the slide, which is defined on all colours. Table
|
||||
-- species fall back to their tint multiplier, which IS derived from the
|
||||
-- table and does carry its direction.
|
||||
-- ------- reading a SLIDE back out of a lookup table
|
||||
--
|
||||
-- A multiply was the first answer here and it is not good enough. Gyarados is
|
||||
-- the whole argument: its shiny is BLUE TURNING RED, and no multiply reaches
|
||||
-- red from blue -- it can only darken what is already there, so the most
|
||||
-- dramatic shiny in the game came out a dull mauve. That is the same ceiling
|
||||
-- the flat tint hit (see lib/ShinyPics.lua), reached from the other side.
|
||||
--
|
||||
-- But the table is not just a direction, it is the ANSWER: 1857 exact
|
||||
-- (normal -> shiny) pairs lifted from Stadium's own alternate textures. Read
|
||||
-- as HSL, each pair is a hue rotation, a saturation scale and a lightness
|
||||
-- step -- which is precisely the shape of a slide. So the five table species
|
||||
-- get a slide MEASURED from their own table rather than declared, and the one
|
||||
-- transform serves all 151.
|
||||
--
|
||||
-- Averaged over the pairs because a real alternate texture is not a perfect
|
||||
-- slide -- that is why it is a texture -- but it is close enough to one that
|
||||
-- the mean carries the change a player actually sees.
|
||||
--
|
||||
-- hue circularly (sum the unit vectors), or opposite rotations
|
||||
-- would cancel to "no change"
|
||||
-- saturation as GIMP's k, s2 = s1 * (1 + k), skipping near-grey pairs
|
||||
-- where the ratio is noise
|
||||
-- lightness as GIMP's two-sided k, matching shiftLight
|
||||
local slideCache = {}
|
||||
|
||||
local function slideFromLut(lut)
|
||||
local sx, sy, hueN = 0, 0, 0
|
||||
local sk, sn, lk, ln = 0, 0, 0, 0
|
||||
for key, val in pairs(lut) do
|
||||
local r1 = floor(key / 65536) % 256
|
||||
local g1 = floor(key / 256) % 256
|
||||
local b1 = key % 256
|
||||
local r2 = floor(val / 65536) % 256
|
||||
local g2 = floor(val / 256) % 256
|
||||
local b2 = val % 256
|
||||
local h1, s1, l1 = rgbToHsl(r1, g1, b1)
|
||||
local h2, s2, l2 = rgbToHsl(r2, g2, b2)
|
||||
-- an achromatic end has no hue, so the pair says nothing about rotation
|
||||
if s1 > 0.08 and s2 > 0.08 then
|
||||
-- DEGREES, both of them: rgbToHsl returns h*60 and hslToRgb takes
|
||||
-- `h % 360`, so the declared slides are in degrees too (-136 for
|
||||
-- Charizard) and a measured one has to come out in the same unit. It
|
||||
-- did not at first, and a rotation of 0.13 TURNS read as 0.13 degrees:
|
||||
-- Gyarados stayed blue and the whole point of measuring was lost.
|
||||
local d = math.rad(h2 - h1)
|
||||
sx, sy = sx + math.cos(d), sy + math.sin(d)
|
||||
hueN = hueN + 1
|
||||
sk, sn = sk + (s2 / s1 - 1), sn + 1
|
||||
end
|
||||
if l1 > 0.02 and l1 < 0.98 then
|
||||
lk = lk + (l2 < l1 and (l2 / l1 - 1) or ((l2 - l1) / (1 - l1)))
|
||||
ln = ln + 1
|
||||
end
|
||||
end
|
||||
local dh = 0
|
||||
if hueN > 0 and (sx * sx + sy * sy) > 1e-9 then
|
||||
dh = math.deg(math.atan2(sy, sx))
|
||||
end
|
||||
-- back into the -8..+8 STEPS the slide fields are in, so the value that
|
||||
-- comes out of here is the same kind of number as the 146 declared ones
|
||||
return {
|
||||
h = dh,
|
||||
s = sn > 0 and (sk / sn) / 0.125 or 0,
|
||||
l = ln > 0 and (lk / ln) / 0.125 or 0,
|
||||
}
|
||||
end
|
||||
|
||||
-- A transform for PALETTE colours rather than texture texels.
|
||||
--
|
||||
-- The two are not the same job. A lookup table answers only the colours that
|
||||
-- are IN it -- the ones its model is painted with -- and the engine's palettes
|
||||
-- are a different set entirely (BLUEMON's blue is not any blue on the
|
||||
-- Gyarados model), so the table asked to shift a palette returns it unchanged
|
||||
-- and the most dramatic shiny in the game comes out identical.
|
||||
--
|
||||
-- So: slide species use their declared slide, and table species use one
|
||||
-- measured out of their table by slideFromLut above. Both end up in the same
|
||||
-- HSL transform, which is the only kind that can rotate a hue.
|
||||
-- ------- and why the LIGHTNESS step is damped on a palette
|
||||
--
|
||||
-- A slide's l is authored against a TEXTURE: thousands of texels spread
|
||||
-- across the middle of the range, where "six steps darker" reads as a shadow
|
||||
-- falling over the animal. A Game Boy palette is not that. It is a four-shade
|
||||
-- RAMP from paper to ink, and only the middle two shades are the Pokemon --
|
||||
-- both already dark relative to the white they sit on, and both needing to
|
||||
-- stay clear of the fixed ink below them.
|
||||
--
|
||||
-- Applied whole, Golbat's -6 took its two shades to 27,42,37 and 34,58,52:
|
||||
-- correct green, and a green nobody can see against a 25,16,16 outline. Half
|
||||
-- the step keeps the direction and keeps the pic readable, which is the trade
|
||||
-- the ramp forces. Hue and saturation are untouched -- they are what makes a
|
||||
-- shiny recognisable as one, and neither collides with the paper or the ink.
|
||||
ShinyPalette.PALETTE_LIGHT_DAMP = 0.5
|
||||
|
||||
function ShinyPalette.paletteTransform(dex)
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
local slide = spec and spec.slide
|
||||
if not spec then return nil end
|
||||
if not spec.lut then return ShinyPalette.transform(spec) end
|
||||
local t = ShinyPalette.tintFor(dex)
|
||||
if not t then return nil end
|
||||
return function(r, g, b)
|
||||
return floor(min(255, r * t[1]) + 0.5),
|
||||
floor(min(255, g * t[2]) + 0.5),
|
||||
floor(min(255, b * t[3]) + 0.5)
|
||||
if spec.lut then
|
||||
if slideCache[dex] == nil then
|
||||
slideCache[dex] = slideFromLut(spec.lut) or false
|
||||
end
|
||||
slide = slideCache[dex] or nil
|
||||
end
|
||||
if not slide then return nil end
|
||||
return slideFn({
|
||||
h = slide.h or 0,
|
||||
s = slide.s or 0,
|
||||
l = (slide.l or 0) * ShinyPalette.PALETTE_LIGHT_DAMP,
|
||||
})
|
||||
end
|
||||
|
||||
-- The measured slide itself, for the tests and for anyone checking the five
|
||||
-- against Stadium's own textures.
|
||||
function ShinyPalette.lutSlide(dex)
|
||||
local spec = ShinyPalette.forDex(dex)
|
||||
if not (spec and spec.lut) then return nil end
|
||||
if slideCache[dex] == nil then
|
||||
slideCache[dex] = slideFromLut(spec.lut) or false
|
||||
end
|
||||
return slideCache[dex] or nil
|
||||
end
|
||||
|
||||
-- ------- the pass over one species' whole texture array
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
-- A shiny's battle pic, genuinely recoloured.
|
||||
--
|
||||
-- ------- why the tint had to go
|
||||
--
|
||||
-- The first answer to "a shiny on the flat art" was a MULTIPLY at draw time,
|
||||
-- and it was the wrong shape twice over:
|
||||
--
|
||||
-- * A multiply can only DARKEN. Shiny Gyarados is blue turning RED; the
|
||||
-- nearest a multiply gets is a dimmer blue. Every species whose shiny is
|
||||
-- lighter, or is a hue rotation rather than a dimming, came out looking
|
||||
-- like the ordinary one with the brightness down -- which is exactly what
|
||||
-- "shinies don't work in 2D" describes.
|
||||
-- * It tinted the whole PICS LAYER, both sides at once, because that is the
|
||||
-- granularity the engine's own draw has. A shiny facing an ordinary mon
|
||||
-- dimmed its opponent too.
|
||||
--
|
||||
-- ------- where the colour actually lives
|
||||
--
|
||||
-- The battle pic is not drawn from four-shade art at play time. getImage
|
||||
-- (src/battle/BattleState.lua:147) snaps the four DMG shades to the species'
|
||||
-- palette ONCE, with mapPixel, and caches the finished image under
|
||||
-- `path .. "#" .. pal.name`. By the time anything is drawn the colour is
|
||||
-- already baked in, and the only way to change it is to hand that bake a
|
||||
-- different palette -- which also means a different cache NAME, or the shiny
|
||||
-- and the ordinary pic fight over one cache slot.
|
||||
--
|
||||
-- That is the whole of this file. It is the same conclusion ShinyUI reached
|
||||
-- for the status screen ("the palette is what has to move"), applied to the
|
||||
-- one other place a Pokemon is drawn flat.
|
||||
--
|
||||
-- ------- the seam
|
||||
--
|
||||
-- monPalette (BattleState.lua:216) is a local, so it cannot be wrapped. What
|
||||
-- it calls -- PaletteFX.monPal and PaletteFX.monPalName -- are not, and they
|
||||
-- are asked in that order for every battle pic the game builds.
|
||||
--
|
||||
-- Neither is told WHICH Pokemon is being drawn; both take a species. The
|
||||
-- individual arrives one call earlier, at the engine's own `pokemon.sprite`
|
||||
-- hook, which carries ctx.mon -- so the hook notes "the pic about to be built
|
||||
-- is this shiny mon's" and the two palette wraps consume that note. A flag
|
||||
-- rather than an argument, because the argument does not exist.
|
||||
--
|
||||
-- It is consumed ONCE, and matched on species as well, so a leak (monPalette
|
||||
-- returns early when a species has no palette at all, and then never asks for
|
||||
-- the name) cannot recolour somebody else's pic -- the worst case is one
|
||||
-- extra ordinary pic built under a shiny cache key, which the next call
|
||||
-- corrects.
|
||||
|
||||
-- the mod namespace (see main.lua): V.require loads a sibling module
|
||||
local V = ...
|
||||
|
||||
local Shiny = V.require("Shiny")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
|
||||
local ShinyPics = {}
|
||||
|
||||
-- { species = <name>, dex = <n> } while a shiny's pic is being built
|
||||
local pending = nil
|
||||
|
||||
-- The suffix that makes the shiny pic its own cache entry. Part of the
|
||||
-- palette NAME rather than the path, because the name is what getImage keys
|
||||
-- on and the path is real art on disk that this mod does not add to.
|
||||
ShinyPics.SUFFIX = "-SHINY"
|
||||
|
||||
-- ------- what the sprite hook notices
|
||||
--
|
||||
-- Called for every battle pic the engine resolves. Returns nothing: the point
|
||||
-- is the note it leaves.
|
||||
function ShinyPics.note(ctx)
|
||||
pending = nil
|
||||
if type(ctx) ~= "table" or ctx.kind ~= "battle" then return end
|
||||
local mon = ctx.mon
|
||||
if not (mon and Shiny.isShiny(mon)) then return end
|
||||
local def = ctx.data and ctx.data.pokemon and ctx.data.pokemon[ctx.species]
|
||||
local dex = def and def.dex
|
||||
if not dex then return end
|
||||
pending = { species = ctx.species, dex = dex }
|
||||
end
|
||||
|
||||
-- Whether the pic currently being built is a shiny's -- for a test, and for
|
||||
-- the palette wraps below.
|
||||
function ShinyPics.pendingDex(species)
|
||||
if pending and pending.species == species then return pending.dex end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ------- the palette wraps
|
||||
--
|
||||
-- Idempotent by sentinel, the pattern every wrap in this mod uses.
|
||||
function ShinyPics.install()
|
||||
local ok, PaletteFX = pcall(require, "src.render.PaletteFX")
|
||||
if not ok or type(PaletteFX) ~= "table" then return false end
|
||||
if PaletteFX.dramaticShapeShiny then return true end
|
||||
local innerPal = PaletteFX.monPal
|
||||
local innerName = PaletteFX.monPalName
|
||||
if type(innerPal) ~= "function" or type(innerName) ~= "function" then
|
||||
return false
|
||||
end
|
||||
|
||||
function PaletteFX.monPal(data, species, transformed, ...)
|
||||
local cols = innerPal(data, species, transformed, ...)
|
||||
local dex = ShinyPics.pendingDex(species)
|
||||
if not (cols and dex) then
|
||||
-- nothing to recolour, and monPalette's early return means the name
|
||||
-- wrap below may never run: drop the note here rather than leave it
|
||||
-- for whoever asks next
|
||||
if not cols then pending = nil end
|
||||
return cols
|
||||
end
|
||||
local fn = ShinyPalette.paletteTransform(dex)
|
||||
if not fn then return cols end
|
||||
-- ------- the first and last shades DO NOT MOVE
|
||||
--
|
||||
-- A Game Boy mon palette is four shades and only the middle two are the
|
||||
-- Pokemon. The first is the PAPER -- 255,239,255 in every species'
|
||||
-- palette in the dataset, the white the pic sits on -- and the last is
|
||||
-- the INK, 25,16,16, the outline every pic is drawn with. Both are shared
|
||||
-- constants, not colours anybody chose for this animal.
|
||||
--
|
||||
-- Sliding them is what a shiny looks like when it is broken: shiny Golbat
|
||||
-- rotates far enough that its white became NAVY (31,34,93) and the pic
|
||||
-- read as a mon on a blue card rather than a green Golbat. Stadium's
|
||||
-- slides were authored for model textures, which have no paper and no
|
||||
-- outline in them, so there was nothing there to warn against it.
|
||||
--
|
||||
-- COPIED, never written through, for the rest. monPal hands back the
|
||||
-- dataset's own palette table, and mutating it would recolour every
|
||||
-- Pokemon of the species everywhere for the rest of the process -- the
|
||||
-- same trap ShinyUI's summary wrap documents.
|
||||
local last = #cols
|
||||
local out = {}
|
||||
for i, c in ipairs(cols) do
|
||||
if type(c) == "table" and c[1] and i > 1 and i < last then
|
||||
local r, g, b = fn(c[1], c[2], c[3])
|
||||
out[i] = { r, g, b }
|
||||
else
|
||||
out[i] = c
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function PaletteFX.monPalName(data, species, ...)
|
||||
local name = innerName(data, species, ...)
|
||||
local dex = ShinyPics.pendingDex(species)
|
||||
pending = nil -- consumed: one pic, one note
|
||||
if not (name and dex) then return name end
|
||||
if not ShinyPalette.paletteTransform(dex) then return name end
|
||||
return name .. ShinyPics.SUFFIX
|
||||
end
|
||||
|
||||
PaletteFX.dramaticShapeShiny = true
|
||||
return true
|
||||
end
|
||||
|
||||
return ShinyPics
|
||||
+20
-47
@@ -97,7 +97,6 @@ end
|
||||
|
||||
function ShinyUI.install()
|
||||
ShinyUI.installSummary()
|
||||
ShinyUI.installBattlePics()
|
||||
end
|
||||
|
||||
-- The status page. Wraps the draw and adds the star afterwards, so the
|
||||
@@ -177,51 +176,25 @@ function ShinyUI.installSummary()
|
||||
SummaryMenu.dramaticShapeShiny = true
|
||||
end
|
||||
|
||||
-- The battle pics. The engine's pic layer is reached through
|
||||
-- BattleState:drawPicsLayer, which draws BOTH sides in one call -- so a
|
||||
-- per-side tint has to bracket each side separately, which is exactly what
|
||||
-- OverworldBattle.sideTexture already does when it renders one side at a
|
||||
-- time into its own canvas. That is where the tint belongs on the 3D path;
|
||||
-- this wrap covers the FLAT path, where the engine draws the battle itself.
|
||||
function ShinyUI.installBattlePics()
|
||||
local ok, BattleState = pcall(require, "src.battle.BattleState")
|
||||
if not ok or type(BattleState) ~= "table" then return end
|
||||
if BattleState.dramaticShapeShinyPics then return end
|
||||
local inner = BattleState.drawPicsLayer
|
||||
if type(inner) ~= "function" then return end
|
||||
|
||||
function BattleState:drawPicsLayer(...)
|
||||
-- THE 3D PATH HAS ALREADY DONE THIS, per side and better: when the mod
|
||||
-- is rendering one side into its own canvas it brackets that draw with
|
||||
-- that side's own tint (OverworldBattle.sideTexture). Tinting again here
|
||||
-- would square it. Asked as a question rather than left to install
|
||||
-- order, because both wraps are installed from main.lua and whichever
|
||||
-- ran first would otherwise silently decide the outcome.
|
||||
local okOw, Ow = pcall(V.require, "OverworldBattle")
|
||||
if okOw and Ow and Ow.texturingSide and Ow.texturingSide() then
|
||||
return inner(self, ...)
|
||||
end
|
||||
|
||||
-- Both sides at once, so when they disagree the tint cannot be applied
|
||||
-- per-side here without splitting the engine's own draw. When only ONE
|
||||
-- side is shiny we tint the whole layer by it: the other side's pic is
|
||||
-- dimmed slightly, which is far less wrong than a shiny drawn in its
|
||||
-- ordinary colours -- and when both are shiny each gets the mean.
|
||||
local data = self.game and self.game.data
|
||||
local a = self.player and ShinyUI.tintFor(self.player.mon, data)
|
||||
local b = self.enemy and ShinyUI.tintFor(self.enemy.mon, data)
|
||||
local tint = a or b
|
||||
if a and b then
|
||||
tint = { (a[1] + b[1]) / 2, (a[2] + b[2]) / 2, (a[3] + b[3]) / 2 }
|
||||
end
|
||||
if not tint then return inner(self, ...) end
|
||||
local args = { ... }
|
||||
local out
|
||||
ShinyUI.withTint(tint, function() out = { inner(self, unpack(args)) } end)
|
||||
return unpack(out or {})
|
||||
end
|
||||
|
||||
BattleState.dramaticShapeShinyPics = true
|
||||
end
|
||||
-- ------- the battle pics are NOT tinted here any more
|
||||
--
|
||||
-- There used to be a third wrap in this file: a multiply over
|
||||
-- BattleState:drawPicsLayer, with the tint above. It is gone, and the reason
|
||||
-- is worth keeping so it is not put back.
|
||||
--
|
||||
-- A multiply can only DARKEN. Shiny Gyarados is blue turning red, and the
|
||||
-- nearest a multiply gets to that is a dimmer blue -- so every species whose
|
||||
-- shiny is lighter, or is a rotation rather than a dimming, read as the
|
||||
-- ordinary one with the brightness down. And the engine's pic layer draws
|
||||
-- BOTH sides in one call, so a shiny also dimmed the ordinary mon opposite it.
|
||||
--
|
||||
-- lib/ShinyPics.lua replaces it by moving the PALETTE instead, which is where
|
||||
-- a battle pic's colour actually lives: getImage bakes the four DMG shades
|
||||
-- into the species palette once and caches the result, so handing that bake a
|
||||
-- shiny palette (under a cache name of its own) gives a genuinely recoloured
|
||||
-- pic -- brightening included -- for one side alone.
|
||||
--
|
||||
-- ShinyUI.withTint and ShinyUI.tintFor stay: the 3D path still uses them for
|
||||
-- the per-side canvas, and they are the only tint left in the mod.
|
||||
|
||||
return ShinyUI
|
||||
|
||||
+287
-7
@@ -227,7 +227,11 @@ function Structures.forMap(map)
|
||||
S = { shapeAt = shapeAt, tileAt = tileAt, outdoor = Map.isOutdoor(def),
|
||||
hideBareRing = hullRingOnly or nil,
|
||||
runs = {}, skip = {}, ground = {}, doorFold = {}, objectQuads = {},
|
||||
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {} }
|
||||
grassQuads = {}, flowerQuads = {}, roundStamps = {}, figures = {},
|
||||
-- tile key -> the row a collapsed bookcase rank's box actually
|
||||
-- stands on, so a standee supported by one lands on it rather than
|
||||
-- where the drawing put it (see buildBookcases)
|
||||
bookcaseBox = {} }
|
||||
Buildings.build(S, map, pixels(tileset), perRow)
|
||||
|
||||
-- Fold doors into their buildings. A door cell is WALKABLE (the player
|
||||
@@ -1882,6 +1886,12 @@ local function bookcaseRank(S, map, perRow, run, i, j, k, pane, srcU, srcV,
|
||||
end
|
||||
end
|
||||
|
||||
-- The arts a `bookcase_backfill = "above"` row may inherit: terrain and
|
||||
-- solid bodies only (see the note at the backfill itself). Everything
|
||||
-- absent here -- billboard, post, cylinder, grass, flower -- is a per-pixel
|
||||
-- object STANDING on terrain rather than terrain.
|
||||
local BACKFILL_ART = { flat = true, top = true, upright = true }
|
||||
|
||||
function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow)
|
||||
perRow = perRow or map.tileset.tilesPerRow or 16
|
||||
-- What to do with the rows a rank VACATES (see TileShape.bookcaseBackfill).
|
||||
@@ -1928,11 +1938,32 @@ function Structures.buildBookcases(S, map, x0, x1, y0, y1, data, perRow)
|
||||
-- shelf standing in a room. `bookcase_backfill = "above"` hands it
|
||||
-- the cell above the run instead, shape and art, so a wall cut into
|
||||
-- a terrace has more terrace behind it rather than a trench.
|
||||
--
|
||||
-- Only BODY above backfills: a vacated row wants more of the
|
||||
-- terrace the wall is cut into, and the terrace is whatever lies
|
||||
-- flat, tops out or stands as a solid face. A per-pixel STANDEE
|
||||
-- above -- a statue, a sign, a bush -- is an object standing ON
|
||||
-- that terrace, and copying it northward builds a second and a
|
||||
-- third of it: Indigo Plateau's avenue statues sit directly on
|
||||
-- the pilasters that collapse here, so every bird came out
|
||||
-- duplicated twice down the shaft behind itself. A standee
|
||||
-- above means the row has no terrace to inherit, so it takes the
|
||||
-- default and is painted with synthesized ground.
|
||||
local covered = math.min(2, front - top + 1)
|
||||
local srcK = keyOf(tx, top - 1)
|
||||
local src = backfill == "above" and S.shapeAt[srcK] or nil
|
||||
if src and not BACKFILL_ART[src.art] then src = nil end
|
||||
-- Where the box ACTUALLY ends up, remembered for every row of the
|
||||
-- rank: the collapse walks the whole drawn run onto its southmost
|
||||
-- cell, so anything that has to stand ON the box has to be told
|
||||
-- where the box went. A statue keys off the cell below its own
|
||||
-- drawing, which is the run's NORTH end -- two rows away from the
|
||||
-- box on a two-cell pilaster, which is exactly the distance the
|
||||
-- Plateau's birds floated by.
|
||||
local boxTop = front - covered + 1
|
||||
for cy = top, front do
|
||||
local tk = keyOf(tx, cy)
|
||||
S.bookcaseBox[tk] = boxTop
|
||||
if src and cy <= front - covered then
|
||||
S.shapeAt[tk] = src
|
||||
S.tileAt[tk] = S.tileAt[srcK]
|
||||
@@ -1992,6 +2023,10 @@ end
|
||||
-- walls) wear the matching slice of that drawing -- the railing's
|
||||
-- diagonal lands along the stepped silhouette -- while treads sample the
|
||||
-- art band drawn at their own height.
|
||||
--
|
||||
-- stair_n / stair_down_n are the same pair of flights running INTO the
|
||||
-- map rather than across it, for a staircase drawn head-on; that changes
|
||||
-- the art reading enough to need its own branch below.
|
||||
local STAIR_STEPS = 4
|
||||
|
||||
local STAIR_SHADE = { south = 1.0, north = 0.68, tread = 1.0,
|
||||
@@ -2004,7 +2039,9 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
local quads = S.objectQuads
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
local north = s.class == "stair_n" or s.class == "stair_down_n"
|
||||
local down = s.class == "stair_down_n" or s.class == "stair_down_e"
|
||||
or s.class == "stair_down_w"
|
||||
local east = s.class == "stair_e" or s.class == "stair_down_e"
|
||||
local mx, mz = cx * 16, cy * 16
|
||||
local h = s.h or 16
|
||||
@@ -2053,6 +2090,114 @@ local function stairCell(S, map, data, cx, cy, s)
|
||||
end
|
||||
end
|
||||
|
||||
-- A flight running INTO the map instead of across it. The drawing is
|
||||
-- the same staircase seen head-on rather than from the side, and that
|
||||
-- changes which axis of the art means what: a drawn ROW is a step here,
|
||||
-- and -- because looking down a well is looking along its depth -- drawn
|
||||
-- row IS depth row, 1:1 across the cell's 16.
|
||||
--
|
||||
-- The Centers' steps state their own band table and it lands exactly:
|
||||
-- 4 white rows, 1 black, 3 grey, 1 black, 3 checker, 4 black = 16. So
|
||||
-- an even four-step division puts a black NOSING on the southmost row of
|
||||
-- every band (15, 11, 7, 3) and leaves the rows behind it as that step's
|
||||
-- tread. Nothing is authored but the RISE, which no head-on drawing can
|
||||
-- state; the depths, the treads and the nosings are all measured.
|
||||
--
|
||||
-- A nosing is drawn as one row because it is seen nearly edge-on, so
|
||||
-- un-projected it has real height and no depth: its row lies flat as the
|
||||
-- tread's front lip AND stands as the riser under it. That is the one
|
||||
-- texel in the flight used twice, and using it twice is what a nosing is.
|
||||
--
|
||||
-- The well's own walls come free as well: the drawing's first and last
|
||||
-- COLUMNS are its black side walls, and its top band is the darkness the
|
||||
-- flight leaves by, which is what the far end wants to wear.
|
||||
--
|
||||
-- A flight CLIMBING away (`stair_n`) is the same reading with the sign of
|
||||
-- the rise flipped -- bands still run south to north, drawn row is still
|
||||
-- depth row, the nosing still serves twice. Two things follow from the
|
||||
-- sign. The risers turn around: a flight descending away from you closes
|
||||
-- its steps from below and shows you their backs, one climbing away shows
|
||||
-- you their FRONTS, so they face south. And the drawing's black side
|
||||
-- columns stop being a well's walls and become the walls of the opening
|
||||
-- the flight climbs into: they run from each tread UP to the top of the
|
||||
-- wall band rather than down from the floor. At the last step the flight
|
||||
-- has reached that top and there is no opening left to wall.
|
||||
--
|
||||
-- Every quad here is split at the cell's own 8px seam, in x and in rows
|
||||
-- both: `uv` resolves ONE tile per corner, and these four tiles are not
|
||||
-- neighbours in the atlas, so a quad that spans a seam interpolates
|
||||
-- between two unrelated corners of the sheet.
|
||||
if north then
|
||||
local runD = 16 / STAIR_STEPS
|
||||
local HALVES = { { 0.2, 7.9, 0, 8 }, { 8.1, 15.8, 8, 16 } }
|
||||
for i = 0, STAIR_STEPS - 1 do
|
||||
local a0 = 16 - (i + 1) * runD -- band i, in art rows
|
||||
local a1 = a0 + runD
|
||||
local yTop = (down and -1 or 1) * (i + 1) * rise
|
||||
local ry = (down and -1 or 1) * i * rise -- the step behind it
|
||||
local z0b, z1b = mz + a0, mz + a1
|
||||
|
||||
for _, H in ipairs(HALVES) do
|
||||
local ax0, ax1, wx0, wx1 = H[1], H[2], mx + H[3], mx + H[4]
|
||||
|
||||
-- the tread: the whole band, drawn row = depth row, so the nosing
|
||||
-- lies on its front lip exactly where the artist drew it
|
||||
face({ wx0, yTop, z0b }, { wx1, yTop, z0b },
|
||||
{ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
ax0, a1, ax1, a0,
|
||||
down and STAIR_SHADE.wellTread or STAIR_SHADE.tread)
|
||||
|
||||
-- the riser at that lip, one art row tall -- so it needs none of
|
||||
-- `banded`'s row splitting, and written straight keeps the geometry
|
||||
-- flush at the seam while the art stays inside its tile. Facing
|
||||
-- north when the flight descends (the steps are closed from below,
|
||||
-- not looked at) and south when it climbs
|
||||
if down then
|
||||
face({ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
{ wx0, ry, z1b }, { wx1, ry, z1b },
|
||||
ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser)
|
||||
else
|
||||
face({ wx0, ry, z1b }, { wx1, ry, z1b },
|
||||
{ wx1, yTop, z1b }, { wx0, yTop, z1b },
|
||||
ax0, a1 - 1, ax1, a1, STAIR_SHADE.riser)
|
||||
end
|
||||
|
||||
-- the deep end, closing the opening this flight is cut into: from
|
||||
-- the floor of the well up to the top of the wall band beside it,
|
||||
-- in the drawing's own black top rows. A climbing flight has no
|
||||
-- such end -- its top tread stands at the wall's own height and
|
||||
-- fills the opening
|
||||
if down and i == STAIR_STEPS - 1 then
|
||||
face({ wx1, -h, mz }, { wx0, -h, mz },
|
||||
{ wx0, h, mz }, { wx1, h, mz },
|
||||
ax1, 3.9, ax0, 0.1, STAIR_SHADE.wellEnd)
|
||||
end
|
||||
end
|
||||
|
||||
-- the opening's side walls beside this tread, wearing the drawing's
|
||||
-- own black edge columns -- excavation or recess, it is walled in its
|
||||
-- own texels. Descending they run from the tread up to the floor,
|
||||
-- climbing from the tread up to the top of the wall band
|
||||
local wallTop = down and 0 or h
|
||||
local function sideWall(px, sx0, sx1, inward)
|
||||
local c
|
||||
if inward then -- west wall, faces E
|
||||
c = { { px, yTop, z1b }, { px, yTop, z0b },
|
||||
{ px, wallTop, z0b }, { px, wallTop, z1b } }
|
||||
else -- east wall, faces W
|
||||
c = { { px, yTop, z0b }, { px, yTop, z1b },
|
||||
{ px, wallTop, z1b }, { px, wallTop, z0b } }
|
||||
end
|
||||
face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN)
|
||||
end
|
||||
if wallTop > yTop then
|
||||
sideWall(mx, 0.1, 1.3, true)
|
||||
sideWall(mx + 16, 14.7, 15.9, false)
|
||||
end
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
for i = 0, STAIR_STEPS - 1 do
|
||||
local sx0 = east and (i * runW) or (16 - (i + 1) * runW)
|
||||
local sx1 = sx0 + runW
|
||||
@@ -2152,6 +2297,7 @@ function Structures.buildStairs(S, map, x0, x1, y0, y1)
|
||||
-- box or floor it. A rising flight stands on the map's common
|
||||
-- floor; a stairwell IS the hole, so nothing is painted under it
|
||||
local down = s.class == "stair_down_e" or s.class == "stair_down_w"
|
||||
or s.class == "stair_down_n"
|
||||
for dy = 0, 1 do
|
||||
for dx = 0, 1 do
|
||||
local tk = keyOf(cx * 2 + dx, cy * 2 + dy)
|
||||
@@ -2253,6 +2399,43 @@ function Structures.buildVolume(S, map, tiles)
|
||||
-- whether the region's dominant columns are flat repeats (a cliff
|
||||
-- mound's plateau) rather than drawn facades (a house's front)
|
||||
local modeRepeat = (repeatVotes[modeH] or 0) * 2 > modeN
|
||||
|
||||
-- Whether this REGION's tops are a rim over a uniform body -- what every
|
||||
-- cliff mound is drawn as: a top edge, then the same rock the whole way
|
||||
-- down. The top face may then lay that rim once along its north edge and
|
||||
-- hold the body after it, instead of cycling the rim back every second
|
||||
-- tile and striping a plateau with edges it should not have.
|
||||
--
|
||||
-- Answered per column AND per region, because each catches what the
|
||||
-- other misses. A mound is one structure many columns wide, and the
|
||||
-- columns carrying its cave mouth read differently from their neighbours
|
||||
-- (their drawing ends in the mouth's own tiles): per column alone, those
|
||||
-- kept cycling while the rest held, leaving rim stubs above the doorway.
|
||||
-- But a region vote alone silences a genuine rim-over-body column that
|
||||
-- happens to stand in a region of repeating art -- three of them in the
|
||||
-- Safari Zone. A column holds if EITHER says so.
|
||||
--
|
||||
-- Art that genuinely repeats is not uniform and keeps cycling: the
|
||||
-- Safari Zone's fence alternates two tiles the whole way down, and there
|
||||
-- the repeat IS what the drawing says.
|
||||
local uniformVotes, uniformTotal = 0, 0
|
||||
for _, r in ipairs(runs) do
|
||||
local run = r.run
|
||||
if run.extent > 2 then
|
||||
uniformTotal = uniformTotal + 1
|
||||
local body = map:tileAt(r.tx, run.north + 1)
|
||||
local uniform = true
|
||||
for d = 2, run.extent - 1 do
|
||||
if map:tileAt(r.tx, run.north + d) ~= body then
|
||||
uniform = false
|
||||
break
|
||||
end
|
||||
end
|
||||
run.ownUniform = uniform
|
||||
if uniform then uniformVotes = uniformVotes + 1 end
|
||||
end
|
||||
end
|
||||
local regionUniform = uniformTotal > 0 and uniformVotes * 2 > uniformTotal
|
||||
for _, r in ipairs(runs) do
|
||||
local run = r.run
|
||||
local h = run.unit * 8
|
||||
@@ -2300,6 +2483,7 @@ function Structures.buildVolume(S, map, tiles)
|
||||
run.rise = roofRows * 8
|
||||
run.peak = h
|
||||
run.h = h - run.rise -- facade height: what sides build to
|
||||
run.topUniform = run.ownUniform or regionUniform
|
||||
for ty = run.north, run.front do
|
||||
S.runs[keyOf(r.tx, ty)] = run
|
||||
end
|
||||
@@ -2687,9 +2871,10 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
-- Town is where it showed: pinning the cliff's slope chain gave the
|
||||
-- posts along the cliff edge an authored 16px box to their south, and
|
||||
-- they were hoisted to stand on the clifftop instead of the path.
|
||||
local baseY, support = 0, nil
|
||||
local baseY, support, supportRow = 0, nil, nil
|
||||
if force and force ~= "opaque" then
|
||||
local bs = S.shapeAt[keyOf(cluster.minX, cluster.maxY + 1)]
|
||||
local belowK = keyOf(cluster.minX, cluster.maxY + 1)
|
||||
local bs = S.shapeAt[belowK]
|
||||
local blocked = not map:isWalkableCell(math.floor(cluster.minX / 2),
|
||||
math.floor(cluster.maxY / 2))
|
||||
-- `bookcase` supports as well as `upright`. A prop drawn above an
|
||||
@@ -2706,6 +2891,13 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
and (bs.art == "upright" or bs.art == "bookcase"
|
||||
or bs.class == "building") then
|
||||
baseY, support = bs.h, bs
|
||||
-- A bookcase support has MOVED: the collapse walks the whole drawn
|
||||
-- run onto its southmost cell, and the cell tested above is the run's
|
||||
-- north end. On the Plateau's two-cell pilasters that is a full cell
|
||||
-- away, and the bird stood at the right HEIGHT over open ground with
|
||||
-- its pillar behind it -- floating. Stand it on the box's own north
|
||||
-- row instead of one row south of its drawing.
|
||||
supportRow = S.bookcaseBox[belowK]
|
||||
end
|
||||
end
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
@@ -2757,8 +2949,9 @@ function Structures.buildObject(S, map, region, cluster,
|
||||
end
|
||||
end
|
||||
for _, c in ipairs(comps) do
|
||||
c.z0 = cluster.minY * 8 + math.floor(c.lowY / 8) * 8
|
||||
+ (support and 8 or 0) + (8 - depth) / 2
|
||||
c.z0 = supportRow and (supportRow * 8 + (8 - depth) / 2)
|
||||
or (cluster.minY * 8 + math.floor(c.lowY / 8) * 8
|
||||
+ (support and 8 or 0) + (8 - depth) / 2)
|
||||
c.z1 = c.z0 + depth
|
||||
end
|
||||
|
||||
@@ -3051,6 +3244,80 @@ local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D)
|
||||
end
|
||||
end
|
||||
|
||||
-- An AUTHORED solid standing on furniture, given as plan layers instead of
|
||||
-- extruded from the drawing (see TileShape's `model`). The one thing it
|
||||
-- shares with the mask paths is that nothing here is a colour: each layer
|
||||
-- names the atlas texels its top and its sides wear, and every quad below
|
||||
-- samples one of them, so the Centers' bell is painted out of the counter's
|
||||
-- own pixels and recolours with it.
|
||||
--
|
||||
-- Placement is by CELL, not by drawn row. A model exists because the
|
||||
-- drawing was too small to un-project, so its drawn row says nothing about
|
||||
-- depth worth keeping -- what says something is which piece of furniture it
|
||||
-- is on and which end of it a person reaches: the solid is centred on the
|
||||
-- mask's own columns and pushed to the SOUTH edge of the support cell, the
|
||||
-- face the aisle is on, less the entry's `inset` -- the one number here
|
||||
-- taste can move, because flush against the counter's own front lip is a
|
||||
-- real position and so is a couple of voxels back from it.
|
||||
local function maskModel(quads, m, perRow, atlasW, atlasH, xMid, zSouth, y0)
|
||||
local function uvOf(t)
|
||||
local tile, row, col = t[1], t[2], t[3] or 0
|
||||
return ((tile % perRow) * 8 + col + 0.5) / atlasW,
|
||||
(math.floor(tile / perRow) * 8 + row + 0.5) / atlasH
|
||||
end
|
||||
|
||||
for k, L in ipairs(m) do
|
||||
local u, v = uvOf(L.side)
|
||||
local ut, vt = uvOf(L.top)
|
||||
local above = m[k + 1]
|
||||
local x0 = xMid - math.floor(L.w / 2)
|
||||
local z0 = zSouth - L.d
|
||||
local function solid(layer, dx, dz)
|
||||
if not layer or dx < 0 or dx >= layer.w or dz < 0 or dz >= layer.d then
|
||||
return false
|
||||
end
|
||||
return layer.cells[dz * layer.w + dx] or false
|
||||
end
|
||||
for dz = 0, L.d - 1 do
|
||||
for dx = 0, L.w - 1 do
|
||||
if solid(L, dx, dz) then
|
||||
local x, y, z = x0 + dx, y0 + k - 1, z0 + dz
|
||||
local function quad(c1, c2, c3, c4, uu, vv, shade)
|
||||
quads[#quads + 1] = { c1, c2, c3, c4, u = uu, v = vv,
|
||||
shade = shade }
|
||||
end
|
||||
-- a layer's own plan is what closes it: a face is drawn wherever
|
||||
-- the neighbouring cell of this layer is empty, and the top
|
||||
-- wherever the layer ABOVE does not stand on it. Nothing needs a
|
||||
-- bottom -- layer 1 rests on the furniture and the rest rest on
|
||||
-- each other.
|
||||
if not solid(above, dx, dz) then
|
||||
quad({ x, y + 1, z }, { x + 1, y + 1, z }, { x + 1, y + 1, z + 1 },
|
||||
{ x, y + 1, z + 1 }, ut, vt, OBJ_SHADE.top)
|
||||
end
|
||||
if not solid(L, dx, dz + 1) then
|
||||
quad({ x, y, z + 1 }, { x + 1, y, z + 1 },
|
||||
{ x + 1, y + 1, z + 1 }, { x, y + 1, z + 1 }, u, v,
|
||||
OBJ_SHADE.front)
|
||||
end
|
||||
if not solid(L, dx, dz - 1) then
|
||||
quad({ x + 1, y, z }, { x, y, z }, { x, y + 1, z },
|
||||
{ x + 1, y + 1, z }, u, v, OBJ_SHADE.back)
|
||||
end
|
||||
if not solid(L, dx - 1, dz) then
|
||||
quad({ x, y, z }, { x, y, z + 1 }, { x, y + 1, z + 1 },
|
||||
{ x, y + 1, z }, u, v, OBJ_SHADE.side)
|
||||
end
|
||||
if not solid(L, dx + 1, dz) then
|
||||
quad({ x + 1, y, z + 1 }, { x + 1, y, z }, { x + 1, y + 1, z },
|
||||
{ x + 1, y + 1, z + 1 }, u, v, OBJ_SHADE.side)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---- figures: a thing drawn INTO furniture, cut out and stood up ----
|
||||
|
||||
-- One authored figure at one matched position.
|
||||
@@ -3133,7 +3400,20 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
|
||||
local atlasW = map.tileset.imageWidth or 128
|
||||
local atlasH = map.tileset.imageHeight or 48
|
||||
|
||||
if fig.depth then
|
||||
if fig.model then
|
||||
-- An authored solid: centred on the mask's own columns, standing on
|
||||
-- the furniture's top plane at the front of its cell.
|
||||
local maxX = minX
|
||||
for ly = 0, bh - 1 do
|
||||
for lx = 0, bw - 1 do
|
||||
if at(lx, ly) and lx > maxX then maxX = lx end
|
||||
end
|
||||
end
|
||||
local xMid = tx * 8 + math.floor((minX + maxX + 1) / 2)
|
||||
local zSouth = (math.floor((ty + fig.h - 1) / 2) + 1) * 16 - (fig.inset or 0)
|
||||
maskModel(S.objectQuads, fig.model, perRow, atlasW, atlasH,
|
||||
xMid, zSouth, baseY)
|
||||
elseif fig.depth then
|
||||
-- An OBJECT: the standee slab, standing on the FRONT edge of the tile
|
||||
-- row its feet are drawn in -- the south face of the 8px band a
|
||||
-- character card would have pivoted in. It is anchored there and
|
||||
|
||||
@@ -125,6 +125,14 @@ local FALLBACK_HEIGHTS = {
|
||||
stair_w = 16,
|
||||
stair_down_e = 16,
|
||||
stair_down_w = 16,
|
||||
-- a flight running toward the BACK of the map, drawn head-on instead of
|
||||
-- from the side (the Centers' Cable Club steps). Its own class because
|
||||
-- the art reading is not the east/west one turned: there a drawn COLUMN
|
||||
-- is a step and a drawn row is height, here a drawn ROW is a step and
|
||||
-- drawn row = depth row, 1:1 into the opening. `stair_n` climbs away
|
||||
-- from the room, `stair_down_n` descends into a well
|
||||
stair_n = 16,
|
||||
stair_down_n = 16,
|
||||
}
|
||||
|
||||
-- class -> how the mesher draws it (see the header). The last three are
|
||||
@@ -215,6 +223,8 @@ local ART = {
|
||||
stair_w = "stair",
|
||||
stair_down_e = "stair",
|
||||
stair_down_w = "stair",
|
||||
stair_n = "stair",
|
||||
stair_down_n = "stair",
|
||||
}
|
||||
|
||||
local spec = nil -- the loaded data file, or false when absent
|
||||
@@ -469,6 +479,8 @@ end
|
||||
--
|
||||
-- figures = { { w = <tiles across>,
|
||||
-- depth = <voxels of body; ABSENT for a person>,
|
||||
-- model = { ...authored plan layers, bottom first... },
|
||||
-- inset = <voxels back from the support cell's front>,
|
||||
-- thin = { rows = <top rows>, depth = <voxels> },
|
||||
-- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
|
||||
-- tiles = { ...w*h tile ids, row-major... },
|
||||
@@ -488,6 +500,16 @@ end
|
||||
-- same furniture the card would have stood on. The Marts' cash
|
||||
-- register is the case: a machine on a counter is a box, not an icon.
|
||||
--
|
||||
-- `model` is the third answer, and the only one that is not an extrusion
|
||||
-- of the drawing at all: an AUTHORED solid, given as plan layers bottom
|
||||
-- first, standing at the FRONT of the support cell. It exists for a
|
||||
-- drawing too small to un-project -- the Centers' push bell is 7x6 pixels
|
||||
-- of ¾-view dome, and no reading of six rows produces a shape a mask can
|
||||
-- extrude without inventing more than it measures. What it still may not
|
||||
-- invent is COLOUR: each layer names the atlas texel its top and its
|
||||
-- sides wear, so the solid is painted out of the drawing it replaces and
|
||||
-- follows every palette bake exactly like the rest of this file.
|
||||
--
|
||||
-- Two fields say which parts of such a drawing are NOT the extrusion,
|
||||
-- because a solid drawn in one 16x16 GB cell still packs more than one
|
||||
-- facing:
|
||||
@@ -557,10 +579,43 @@ local function authoredMasks(list)
|
||||
r0 = math.floor(f.flat.rows[1]),
|
||||
r1 = math.floor(f.flat.rows[2]) }
|
||||
end
|
||||
-- an AUTHORED model: plan layers bottom-first, each with the atlas
|
||||
-- texel its top and its sides wear. Dropped whole on any malformed
|
||||
-- layer, like every other field here -- a typo should leave the
|
||||
-- drawing lying flat, not build half a solid.
|
||||
local model = nil
|
||||
if type(f.model) == "table" and #f.model > 0 then
|
||||
model = {}
|
||||
for _, L in ipairs(f.model) do
|
||||
local plan = type(L) == "table" and L.plan
|
||||
local mw = (type(plan) == "table" and type(plan[1]) == "string")
|
||||
and #plan[1] or 0
|
||||
local okL = mw > 0 and type(L.top) == "table"
|
||||
and type(L.side) == "table"
|
||||
if okL then
|
||||
for _, r in ipairs(plan) do
|
||||
if type(r) ~= "string" or #r ~= mw then okL = false break end
|
||||
end
|
||||
end
|
||||
if not okL then model = nil break end
|
||||
local cells = {}
|
||||
for dz = 0, #plan - 1 do
|
||||
local r = plan[dz + 1]
|
||||
for dx = 0, mw - 1 do
|
||||
if r:sub(dx + 1, dx + 1) ~= "0" then cells[dz * mw + dx] = true end
|
||||
end
|
||||
end
|
||||
model[#model + 1] = { w = mw, d = #plan, cells = cells,
|
||||
top = L.top, side = L.side }
|
||||
end
|
||||
end
|
||||
if n > 0 then
|
||||
out[#out + 1] = { w = w, h = h, n = n, mask = mask,
|
||||
tiles = f.tiles, under = f.under,
|
||||
depth = depth and math.floor(depth) or nil,
|
||||
model = model,
|
||||
inset = model and math.floor(tonumber(f.inset) or 0)
|
||||
or nil,
|
||||
thin = thin, flat = flat }
|
||||
end
|
||||
end
|
||||
@@ -709,6 +764,50 @@ function TileShape.bookcaseRelief(tilesetId)
|
||||
return not (entry and entry.bookcase_relief == false)
|
||||
end
|
||||
|
||||
--- What a `wall` cell's TOP face wears in this tileset (a tileset entry's
|
||||
--- wall_top). Returns a function tile -> cap tile id (nil for "leave it
|
||||
--- alone"), or nil when the tileset says nothing at all.
|
||||
---
|
||||
--- A wall band is 16px of art folded upright over a run two drawn rows
|
||||
--- deep, so it folds ENTIRELY onto its face and has no row left to lay
|
||||
--- flat on top -- the top then repeats the face, and a house's town-map
|
||||
--- poster and window came out lying across the top of the wall as well as
|
||||
--- hanging on it. What is up there is the wall's capping course, which is
|
||||
--- the plain panel the decorated column's own neighbours draw; naming it
|
||||
--- is the whole fix, because "plain" is a fact about the drawing that
|
||||
--- nothing in the geometry can measure.
|
||||
---
|
||||
--- Two forms, because tilesets differ in how far one answer reaches:
|
||||
---
|
||||
--- wall_top = <id> EVERY wall cell caps with this course.
|
||||
--- Right where one atlas dresses one kind of
|
||||
--- room -- the town house, the Centers, Red's
|
||||
--- two floors all cap with their own blank
|
||||
--- panel, and a list keyed by the decorated
|
||||
--- tiles would need extending every time a
|
||||
--- map hung something new on the same wall.
|
||||
--- wall_top = { [tile] = id } only these tiles are redirected. Right
|
||||
--- where one atlas dresses several rooms:
|
||||
--- LOBBY is the department store, the Game
|
||||
--- Corner, Silph's floors, the roof AND the
|
||||
--- Rocket lift, and the lift's cabin frame is
|
||||
--- not what a shop wall caps with.
|
||||
function TileShape.wallTop(tilesetId)
|
||||
local s = load()
|
||||
local entry = s and s.tilesets and s.tilesets[tilesetId]
|
||||
local spec = entry and entry.wall_top
|
||||
if type(spec) == "number" then
|
||||
return function() return spec end
|
||||
end
|
||||
if type(spec) == "table" then
|
||||
return function(tile)
|
||||
local cap = spec[tile]
|
||||
return type(cap) == "number" and cap or nil
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Drop the cache: a mod that shadows data/voxel_heights.lua or a tileset
|
||||
-- record needs the next lookup to re-resolve (hot reload, mod toggle).
|
||||
function TileShape.invalidate()
|
||||
|
||||
+15
-2
@@ -503,12 +503,22 @@ local active = false
|
||||
-- which is exactly the old behaviour minus the reflections.
|
||||
local DEPTH_FORMATS = { "depth24", "depth24stencil8", "depth32f", "depth16" }
|
||||
|
||||
-- dpiscale = 1, for the same reason PixelCanvas pins it and for one more:
|
||||
-- newCanvas otherwise takes the WINDOW's scale, and every canvas bound
|
||||
-- together must agree on PIXEL dimensions. The colour canvas beside this one
|
||||
-- comes from PixelCanvas at scale 1, so on any surface whose scale is not 1
|
||||
-- -- Android's density is routinely 2.625, and a retina Mac's is 2 -- this
|
||||
-- one came back 2.625x larger and the pair would not bind. beginScene then
|
||||
-- dropped the readable depth for the session (see below), depthReadable()
|
||||
-- went false, and the water pass never ran at all: the reflections were
|
||||
-- missing on every high-density display, with nothing in the log to say so,
|
||||
-- because a canvas that will not BIND is not a canvas the driver refused.
|
||||
local function newDepth(w, h)
|
||||
if not (love.graphics and love.graphics.newCanvas) then return nil end
|
||||
local c = nil
|
||||
for _, format in ipairs(DEPTH_FORMATS) do
|
||||
local ok, made = pcall(love.graphics.newCanvas, w, h,
|
||||
{ format = format, readable = true })
|
||||
{ format = format, readable = true, dpiscale = 1 })
|
||||
if ok and made then c = made break end
|
||||
end
|
||||
if not c then return nil end
|
||||
@@ -1344,7 +1354,10 @@ end
|
||||
function Voxel3D.beginWater(paint)
|
||||
if not (active and canvas and held and held.depth) then return nil end
|
||||
if not held.mirror then
|
||||
local ok, c = pcall(love.graphics.newCanvas, held.w, held.h)
|
||||
-- through PixelCanvas, because this one is bound WITH held.depth a few
|
||||
-- lines down and the two must agree on pixel dimensions -- the same
|
||||
-- scale trap newDepth documents
|
||||
local ok, c = PixelCanvas.new(held.w, held.h)
|
||||
if not (ok and c) then return nil end
|
||||
pcall(c.setFilter, c, "nearest", "nearest")
|
||||
pcall(c.setWrap, c, "clamp", "clamp")
|
||||
|
||||
+6
-12
@@ -61,19 +61,13 @@ end
|
||||
VoxelGrid.setting = ModSetting.new(VoxelGrid.KEY, VoxelGrid.LABEL,
|
||||
{ false, true }, { "OFF", "ON" })
|
||||
|
||||
-- A pass that needs the wireframe whatever the player left the row on sets
|
||||
-- this for the length of its own draw and puts it back after. nil means
|
||||
-- "follow the setting", which is every frame outside such a pass.
|
||||
--
|
||||
-- The overworld battle is the one user: a fight is a STAGED shot, not the
|
||||
-- world being walked around in, and the seams are what make it read as
|
||||
-- constructed rather than as a photograph of somewhere. The row still owns
|
||||
-- what free-roam looks like, and is not written to -- switching the mode off
|
||||
-- mid-battle would silently rewrite the player's own setting.
|
||||
VoxelGrid.override = nil
|
||||
|
||||
-- The row is the whole answer, everywhere: free-roam and the battle arena
|
||||
-- alike. The battle used to force the seams on regardless -- a fight is a
|
||||
-- STAGED shot, and the seams are what make it read as constructed rather
|
||||
-- than photographed -- but a player who turns the wireframe off means the
|
||||
-- whole mod, and a mode that came back for every fight read as the row not
|
||||
-- working rather than as a deliberate framing.
|
||||
function VoxelGrid.enabled()
|
||||
if VoxelGrid.override ~= nil then return VoxelGrid.override end
|
||||
return VoxelGrid.setting:get() and true or false
|
||||
end
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ local BattleExit = V.require("BattleExit")
|
||||
local Shiny = V.require("Shiny")
|
||||
local ShinyBattle = V.require("ShinyBattle")
|
||||
local ShinyUI = V.require("ShinyUI")
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyFlash = V.require("ShinyFlash")
|
||||
local DayNight = V.require("DayNight")
|
||||
local DayTint = V.require("DayTint")
|
||||
local Water = V.require("Water")
|
||||
@@ -1185,14 +1187,35 @@ OverworldBattle.install()
|
||||
-- ShinyBattle wraps Pokemon.new, which is where every wild, gift,
|
||||
-- starter and traded mon is built, so the roll lands before
|
||||
-- the sprite is baked
|
||||
-- ShinyUI the battle pics' tint and the status page's mark
|
||||
-- ShinyFx the arrival sparkle (armed from Stadium.update)
|
||||
-- ShinyUI the status page's mark, and the summary pic's palette
|
||||
-- ShinyPics the battle pic's palette -- a real recolour, baked into the
|
||||
-- image cache under a shiny key, on every rung that draws a
|
||||
-- pic (OFF, both 2D-3D rungs, and the cards a STADIUM battle
|
||||
-- still uses for a species with no model)
|
||||
-- ShinyFx the arrival sparkle for the STADIUM rungs (3D, armed from
|
||||
-- Stadium.update)
|
||||
-- ShinyFlash the same announcement for every OTHER rung, drawn in the
|
||||
-- Game Boy's own pixel grid over the pic
|
||||
--
|
||||
-- The Stadium models need no seam here at all: their recolour happens at
|
||||
-- extraction (lib/StadiumBuild.lua), and the battle simply asks for the
|
||||
-- shiny pack.
|
||||
ShinyBattle.install()
|
||||
ShinyUI.install()
|
||||
ShinyPics.install()
|
||||
ShinyFlash.install()
|
||||
|
||||
-- ShinyPics needs to know WHICH Pokemon a pic is being built for, and the
|
||||
-- two palette functions it wraps are told only the species. The individual
|
||||
-- passes through here one call earlier: `pokemon.sprite` carries ctx.mon.
|
||||
--
|
||||
-- next() first and the return value untouched -- this reads the context and
|
||||
-- changes nothing about which art is chosen.
|
||||
mod.hooks:wrap("pokemon.sprite", function(next, path, ctx)
|
||||
local out = next(path, ctx)
|
||||
pcall(ShinyPics.note, ctx)
|
||||
return out
|
||||
end)
|
||||
|
||||
-- A save opened for the first time under this mod has shiny Pokemon in it
|
||||
-- already -- they always did -- so refresh the cached flag across the party
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "DRAMATIC_SHAPE",
|
||||
"name": "Dramatic Shape Voxel Mod",
|
||||
"version": "1.7.2",
|
||||
"version": "1.8.2",
|
||||
"api": 2,
|
||||
"entry": "main.lua",
|
||||
"profile": "content",
|
||||
|
||||
+264
-17
@@ -3631,25 +3631,20 @@ T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 2 }), false,
|
||||
T.eq(Battles.flashing({ fx = { flash = 16 }, frame = 5 }), true,
|
||||
"on a four-frame cycle")
|
||||
|
||||
-- ------- the wireframe is forced on in a battle
|
||||
-- ------- the wireframe follows the V-GRID row, battles included
|
||||
--
|
||||
-- A fight is a staged shot rather than the world being walked through, so it
|
||||
-- always wears the seams. The player's own V-GRID row must not be touched by
|
||||
-- that -- an override, not a write, or switching the mode off mid-battle
|
||||
-- would quietly rewrite a setting they chose.
|
||||
-- The row is the whole answer. A battle used to force the seams on whatever
|
||||
-- it said -- so nothing may override enabled(), and the battle pass must not
|
||||
-- write the row either: a fight that flipped it would rewrite a setting the
|
||||
-- player chose.
|
||||
local Grid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
|
||||
Grid.override = nil
|
||||
local rowWas = Grid.setting:get()
|
||||
T.eq(Grid.enabled(), rowWas and true or false,
|
||||
"with no override the wireframe follows the row")
|
||||
Grid.override = true
|
||||
T.eq(Grid.enabled(), true, "an override forces it on")
|
||||
T.eq(Grid.setting:get(), rowWas, "and leaves the player's row alone")
|
||||
Grid.override = false
|
||||
T.eq(Grid.enabled(), false, "an override can force it off too")
|
||||
Grid.override = nil
|
||||
T.eq(Grid.enabled(), rowWas and true or false,
|
||||
"and clearing it hands the answer back to the row")
|
||||
local idxWas = Grid.setting.index
|
||||
Grid.setting:sync(true)
|
||||
T.eq(Grid.enabled(), true, "the wireframe is on when the row is on")
|
||||
Grid.setting:sync(false)
|
||||
T.eq(Grid.enabled(), false, "and off when the row is off -- nothing overrides it")
|
||||
T.eq(Grid.override, nil, "and there is no override left to force a battle on")
|
||||
Grid.setting.index = idxWas
|
||||
|
||||
-- ------- the depth of field is measured off the two marks
|
||||
--
|
||||
@@ -7007,6 +7002,258 @@ end)()
|
||||
T.check(ball.stars == nil, "and the stars burn out on their own")
|
||||
end)()
|
||||
|
||||
-- ------- an interior wall caps with its own plain course
|
||||
--
|
||||
-- A wall band is 16px of art over a run two drawn rows deep, so it folds
|
||||
-- ENTIRELY onto its south face and has no drawn row left to lay flat on
|
||||
-- top. The top therefore repeated the face: the town house's town-map
|
||||
-- poster and window, and the Pokemon Center's pokeball poster, came out
|
||||
-- lying across the top of the wall as well as hanging on it -- a picture
|
||||
-- you look DOWN on from the voxel camera.
|
||||
--
|
||||
-- `wall_top` names the capping course per tileset (the plain panel the
|
||||
-- decorated column's own neighbours draw), and it is a fact about the
|
||||
-- drawing that no measurement of the geometry can recover -- nothing
|
||||
-- distinguishes "poster" from "panel" but knowing which is which.
|
||||
;(function()
|
||||
local Mesher = run.loader.exports.DRAMATIC_SHAPE.lib.require("ChunkMesher")
|
||||
local Struct = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures")
|
||||
local Shape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape")
|
||||
|
||||
-- the blanket form: whatever a wall cell draws, it caps with the one
|
||||
-- course the atlas's rooms are panelled in
|
||||
T.eq(Shape.wallTop("HOUSE")(45), 0,
|
||||
"the town house caps its walls with the blank course")
|
||||
T.eq(Shape.wallTop("POKECENTER")(2), 40,
|
||||
"and a Center with its striped panel -- the tile cell (9,0) draws")
|
||||
T.eq(Shape.wallTop("REDS_HOUSE_2")(36), 0,
|
||||
"Red's bedroom with its own blank panel, over the window")
|
||||
T.eq(Shape.wallTop("REDS_HOUSE_1")(36), 0, "and so does the floor below")
|
||||
-- the keyed form, for an atlas that dresses more than one kind of room
|
||||
T.eq(Shape.wallTop("LOBBY")(40), 93,
|
||||
"the Rocket lift's car door caps with the cabin frame")
|
||||
T.eq(Shape.wallTop("LOBBY")(1), nil,
|
||||
"but the department store's own panel is left exactly as it was -- "
|
||||
.. "one atlas, several rooms, and only the lift is a lift")
|
||||
T.eq(Shape.wallTop("DS_NO_SUCH_TILESET"), nil,
|
||||
"a tileset that says nothing keeps the top it always had")
|
||||
|
||||
local FLOOR = 1
|
||||
local function topTiles(tilesetId, rows)
|
||||
local map = {
|
||||
id = "DS_TEST_WALLTOP_" .. tilesetId,
|
||||
tileset = { id = tilesetId, image = "gfx/tilesets/ds_test.png",
|
||||
tilesPerRow = 16, imageWidth = 128, imageHeight = 48,
|
||||
blocks = {}, grassTile = -1 },
|
||||
def = { width = 4, height = 2, tileset = tilesetId },
|
||||
walkable = { [FLOOR] = true },
|
||||
waterTiles = {},
|
||||
doorTiles = {},
|
||||
tileAt = function(_, tx, ty)
|
||||
local r = rows[ty + 1]
|
||||
return (r and r[tx + 1]) or FLOOR
|
||||
end,
|
||||
cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end,
|
||||
isWaterCell = function() return false end,
|
||||
isWalkableCell = function(self, cx, cy)
|
||||
return self:cellTile(cx, cy) == FLOOR
|
||||
end,
|
||||
inBounds = function(_, cx, cy)
|
||||
return cx >= 0 and cy >= 0 and cx < 4 and cy < 2
|
||||
end,
|
||||
}
|
||||
Struct.invalidate(map.id)
|
||||
local verts = Mesher.geometry(map, true, nil)
|
||||
-- the flat quads standing at wall height, keyed by the column they cap
|
||||
local out = {}
|
||||
for i = 1, #verts, 4 do
|
||||
local a, b, c, d = verts[i], verts[i + 1], verts[i + 2], verts[i + 3]
|
||||
if a[2] == b[2] and b[2] == c[2] and c[2] == d[2] and a[2] == 16 then
|
||||
local ax = math.floor(math.min(a[4], b[4], c[4], d[4]) * 128 + 0.5)
|
||||
local ay = math.floor(math.min(a[5], b[5], c[5], d[5]) * 48 + 0.5)
|
||||
local tx = math.floor(math.min(a[1], b[1], c[1], d[1]) / 8)
|
||||
local ty = math.floor(math.min(a[3], b[3], c[3], d[3]) / 8)
|
||||
-- keyed by the TILE ROW too: a wall band is two drawn rows deep
|
||||
-- and they can wear different art
|
||||
out[ty * 100 + tx] = out[ty * 100 + tx]
|
||||
or (math.floor(ay / 8) * 16 + math.floor(ax / 8))
|
||||
end
|
||||
end
|
||||
return out, verts
|
||||
end
|
||||
|
||||
-- BLUES_HOUSE's back wall: blank panel, the town-map poster (45/46 over
|
||||
-- 61/62) at cell (3,0)'s block, the window (36 over 52) at (5,0)'s
|
||||
local houseTops, houseVerts = topTiles("HOUSE", {
|
||||
{ 0, 0, 45, 46, 36, 36, 0, 0 },
|
||||
{ 0, 0, 61, 62, 52, 52, 0, 0 },
|
||||
})
|
||||
for ty = 0, 1 do
|
||||
for tx = 0, 7 do
|
||||
T.eq(houseTops[ty * 100 + tx], 0,
|
||||
("(%d,%d) of the house's back wall caps with the blank course")
|
||||
:format(tx, ty))
|
||||
end
|
||||
end
|
||||
|
||||
-- and the FACE is untouched: the poster still hangs in the room, which is
|
||||
-- the half of this the fix must not take with it
|
||||
local hung = {}
|
||||
for _, v in ipairs(houseVerts) do
|
||||
hung[math.floor(math.floor(v[5] * 48 + 0.5) / 8) * 16
|
||||
+ math.floor(math.floor(v[4] * 128 + 0.5) / 8)] = true
|
||||
end
|
||||
T.check(hung[45] and hung[46] and hung[61] and hung[62],
|
||||
"the town-map poster is still drawn on the wall it hangs on")
|
||||
T.check(hung[36] and hung[52], "and so is the window")
|
||||
|
||||
-- VIRIDIAN_POKECENTER's back wall: the striped panel with the pokeball
|
||||
-- poster (2/3 over 18/19) spanning cells (3,0) and (4,0)
|
||||
local pcTops = topTiles("POKECENTER", {
|
||||
{ 40, 40, 40, 2, 3, 40, 40, 40 },
|
||||
{ 40, 40, 40, 18, 19, 40, 40, 40 },
|
||||
})
|
||||
for ty = 0, 1 do
|
||||
for tx = 0, 7 do
|
||||
T.eq(pcTops[ty * 100 + tx], 40,
|
||||
("(%d,%d) of the Center's back wall caps with the striped panel")
|
||||
:format(tx, ty))
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the Rocket lift's car doors
|
||||
--
|
||||
-- Detected rather than pinned -- the doors are a volume Structures finds
|
||||
-- -- so the cap has to reach the RUN branch too, not only the pinned one.
|
||||
local liftTops = topTiles("LOBBY", {
|
||||
{ 92, 92, 92, 92, 92, 92, 92, 92 },
|
||||
{ 93, 93, 93, 93, 93, 93, 93, 93 },
|
||||
{ 1, 1, 40, 40, 40, 40, 1, 1 },
|
||||
{ 33, 33, 56, 56, 56, 56, 33, 33 },
|
||||
})
|
||||
-- rows 2 and 3 are the doors' own band; the doors span columns 2-5
|
||||
for ty = 2, 3 do
|
||||
for tx = 2, 5 do
|
||||
T.eq(liftTops[ty * 100 + tx], 93,
|
||||
("(%d,%d) of the lift's door caps with the cabin frame"):format(tx, ty))
|
||||
end
|
||||
end
|
||||
-- and the panelling either side of them is left alone, which is what the
|
||||
-- keyed form buys: the same atlas panels the department store
|
||||
T.eq(liftTops[2 * 100], 1, "the blank course beside the door is untouched")
|
||||
T.eq(liftTops[3 * 100], 33, "and so is its skirting")
|
||||
end)()
|
||||
|
||||
-- ------- Lance's room is furnished with the badge gyms' statue
|
||||
--
|
||||
-- The same drawing on a different atlas: one cell of bird over one cell of
|
||||
-- plinth. Left derived the pair merged into one 32px volume wearing the
|
||||
-- statue folded onto its face -- the extruded picture. The gyms' reading
|
||||
-- (solid plinth, per-pixel standee on top) is the right one here too, and
|
||||
-- these eight tiles draw nothing else anywhere in the game.
|
||||
;(function()
|
||||
local Shape = run.loader.exports.DRAMATIC_SHAPE.lib.require("TileShape")
|
||||
local shapes = Shape.forMap({
|
||||
tileset = { id = "DOJO", tilesPerRow = 16,
|
||||
imageWidth = 128, imageHeight = 48, grassTile = -1 },
|
||||
walkable = { [17] = true },
|
||||
})
|
||||
for _, t in ipairs({ 2, 18, 19, 56 }) do
|
||||
T.eq(shapes[t].class, "prop",
|
||||
("the statue's figure tile %d stands as a per-pixel cutout"):format(t))
|
||||
T.eq(shapes[t].authored, true, "and it is an authored answer, not derived")
|
||||
end
|
||||
for _, t in ipairs({ 34, 35, 50, 51 }) do
|
||||
T.eq(shapes[t].class, "wall",
|
||||
("the plinth tile %d stays a solid 16px block"):format(t))
|
||||
T.eq(shapes[t].authored, true,
|
||||
"authored, which is what the standee's support rule tests -- and "
|
||||
.. "what keeps tile 50 ($32) out of the water-fallback trap")
|
||||
end
|
||||
T.eq(shapes[13].class, "bookcase",
|
||||
"and Oak's shelf ranks on the same atlas are untouched")
|
||||
end)()
|
||||
|
||||
-- ------- a wall cut into a terrace inherits TERRACE, never a statue
|
||||
--
|
||||
-- `bookcase_backfill = "above"` hands a vacated row the cell above the run,
|
||||
-- so masonry set into a hillside has more hillside behind it rather than a
|
||||
-- trench. Indigo Plateau's avenue statues stand directly on the pilasters
|
||||
-- that collapse this way, so what every one of them inherited was the BIRD:
|
||||
-- the figure's shape and art copied onto two more rows down the shaft, and
|
||||
-- the statue came out two deep behind itself. A standee above is an object
|
||||
-- standing ON the terrace, not terrace -- so there is nothing to inherit.
|
||||
;(function()
|
||||
local Struct = run.loader.exports.DRAMATIC_SHAPE.lib.require("Structures")
|
||||
-- the real west-edge statue of INDIGO_PLATEAU, tile for tile: the bird
|
||||
-- (37/38 over 40/41) on the pilaster (cap 21/22, shaft 5/6 twice, foot
|
||||
-- 21/22), standing on the plateau's paving (35)
|
||||
local rows = {
|
||||
{ 46, 47 }, { 46, 47 },
|
||||
{ 37, 38 }, { 40, 41 },
|
||||
{ 21, 22 }, { 5, 6 },
|
||||
{ 5, 6 }, { 21, 22 },
|
||||
{ 35, 35 }, { 35, 35 },
|
||||
{ 35, 35 }, { 35, 35 },
|
||||
}
|
||||
local map = {
|
||||
id = "DS_TEST_PLATEAU_STATUE",
|
||||
tileset = { id = "PLATEAU", image = "gfx/tilesets/ds_test.png",
|
||||
tilesPerRow = 16, imageWidth = 128, imageHeight = 40,
|
||||
blocks = {}, grassTile = -1 },
|
||||
def = { width = 1, height = 3, tileset = "PLATEAU" },
|
||||
walkable = { [35] = true },
|
||||
waterTiles = {},
|
||||
doorTiles = {},
|
||||
tileAt = function(_, tx, ty)
|
||||
local r = rows[math.max(1, math.min(#rows, ty + 1))]
|
||||
return r[(tx % 2) + 1]
|
||||
end,
|
||||
cellTile = function(self, cx, cy) return self:tileAt(cx * 2, cy * 2 + 1) end,
|
||||
isWaterCell = function() return false end,
|
||||
isWalkableCell = function(self, cx, cy)
|
||||
return self:cellTile(cx, cy) == 35
|
||||
end,
|
||||
isDoorTileCell = function() return false end,
|
||||
isGrassCell = function() return false end,
|
||||
inBounds = function(_, cx, cy)
|
||||
return cx >= 0 and cy >= 0 and cx < 2 and cy < 6
|
||||
end,
|
||||
}
|
||||
Struct.invalidate(map.id)
|
||||
local S = Struct.forMap(map)
|
||||
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
|
||||
|
||||
-- the bird stands, once, where it is drawn
|
||||
for _, ty in ipairs({ 2, 3 }) do
|
||||
T.eq(S.shapeAt[keyOf(0, ty)].art, "billboard",
|
||||
"the statue's own rows carry the standee")
|
||||
end
|
||||
-- and the pilaster rows it vacates carry NOTHING -- not a second bird
|
||||
for _, ty in ipairs({ 4, 5, 6, 7 }) do
|
||||
local s = S.shapeAt[keyOf(0, ty)]
|
||||
T.check(s == nil or s.art ~= "billboard",
|
||||
("pilaster row %d did not inherit the statue standing on it"):format(ty))
|
||||
end
|
||||
|
||||
-- ...and the bird stands on the box rather than where it is DRAWN. The
|
||||
-- collapse walks the whole four-row pilaster onto its southmost cell, so
|
||||
-- a standee that trusted its own drawn position ended up a full cell
|
||||
-- north of the pillar holding it up -- at the right height, over open
|
||||
-- ground. Every row of the rank records where the box actually went.
|
||||
for _, ty in ipairs({ 4, 5, 6, 7 }) do
|
||||
T.eq(S.bookcaseBox[keyOf(0, ty)], 6,
|
||||
("rank row %d knows the box stands on row 6"):format(ty))
|
||||
end
|
||||
local zlo = math.huge
|
||||
for _, q in ipairs(S.objectQuads) do
|
||||
for i = 1, 4 do zlo = math.min(zlo, q[i][3]) end
|
||||
end
|
||||
T.eq(math.floor(zlo / 8), 6,
|
||||
"and the statue's own geometry starts on that row -- standing on the "
|
||||
.. "pillar, not hanging in the air two cells in front of it")
|
||||
end)()
|
||||
|
||||
Pipelines.reset()
|
||||
run.release()
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
-- A flat top must not stamp its rim twice.
|
||||
--
|
||||
-- ChunkMesher.flatTopRow decides which drawn row a flat-topped volume's top
|
||||
-- face wears at each depth. Where the drawing is a RIM over a uniform body
|
||||
-- -- every cliff mound in the game, and the mound the Diglett's Cave mouth
|
||||
-- is cut into -- the rim belongs at the plateau's north edge and nowhere
|
||||
-- else. Cycling the first two rows lays it again every second tile.
|
||||
--
|
||||
-- The invariant: on such a run the sampled row never goes BACKWARDS as ty
|
||||
-- moves south. Art that genuinely repeats (the Safari Zone's fence
|
||||
-- alternates two tiles the whole way down) is exempt: there the repeat is
|
||||
-- what the drawing says, and the run is not rim-over-body.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/flat_top_test.lua lovec .
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
|
||||
local V = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
V = V and V.lib
|
||||
local Structures = V and V.require("Structures")
|
||||
local ChunkMesher = V and V.require("ChunkMesher")
|
||||
if not (Structures and ChunkMesher and ChunkMesher.flatTopRow) then
|
||||
print("[flattop] FAIL mod, Structures or ChunkMesher.flatTopRow missing")
|
||||
love.event.quit(1)
|
||||
return
|
||||
end
|
||||
local function keyOf(tx, ty) return (ty + 64) * 4096 + (tx + 64) end
|
||||
|
||||
local MAPS = {}
|
||||
for id in pairs((game.data and game.data.maps) or {}) do
|
||||
MAPS[#MAPS + 1] = id
|
||||
end
|
||||
table.sort(MAPS)
|
||||
|
||||
local checked, offenders, examples = 0, 0, {}
|
||||
for _, mapId in ipairs(MAPS) do
|
||||
U.teleport(game, mapId, 5, 5, "up")
|
||||
U.wait(6)
|
||||
local ow = game.overworld
|
||||
if ow and ow.map and ow.map.def and ow.map.def.id == mapId then
|
||||
local map = ow.map
|
||||
local S = Structures.forMap(map)
|
||||
local seen = {}
|
||||
for tx = 0, map.def.width * 4 - 1 do
|
||||
for ty = 0, map.def.height * 4 - 1 do
|
||||
local run = S.runs[keyOf(tx, ty)]
|
||||
local sig = run and (tostring(run) .. ":" .. tx)
|
||||
if run and not seen[sig] and (run.rise or 0) == 0 then
|
||||
seen[sig] = true
|
||||
local ext = run.front - run.north + 1
|
||||
-- rim over a uniform body: the shape the rim must not repeat on
|
||||
local uniform = ext > 2
|
||||
if uniform then
|
||||
local body = map:tileAt(tx, run.north + 1)
|
||||
for d = 2, ext - 1 do
|
||||
if map:tileAt(tx, run.north + d) ~= body then
|
||||
uniform = false
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if uniform then
|
||||
checked = checked + 1
|
||||
local prev = -1
|
||||
for ty2 = run.north, run.front do
|
||||
local row = ChunkMesher.flatTopRow(run, ty2)
|
||||
if row < prev then
|
||||
offenders = offenders + 1
|
||||
if #examples < 5 then
|
||||
examples[#examples + 1] = ("%s tx=%d north=%d ext=%d "
|
||||
.. "went back to row %d at ty %d")
|
||||
:format(mapId, tx, run.north, ext, row, ty2)
|
||||
end
|
||||
break
|
||||
end
|
||||
prev = row
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
print(("[flattop] %d rim-over-body runs checked, %d repeat their rim")
|
||||
:format(checked, offenders))
|
||||
for _, e in ipairs(examples) do print("[flattop] " .. e) end
|
||||
if offenders > 0 then
|
||||
print("[flattop] FAIL")
|
||||
love.event.quit(1)
|
||||
else
|
||||
print("[flattop] PASS")
|
||||
love.event.quit(0)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
-- Driver: is a shiny visible on the FLAT paths -- the engine's own battle
|
||||
-- screen (3D-BTL OFF) and the cards rung (2D-3D A)?
|
||||
--
|
||||
-- DS_SHOTS=mods/DramaticShapeVoxelMod/.claude/shiny_update/flat \
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/shiny_flat.lua \
|
||||
-- "/c/Program Files/LOVE/lovec.exe" .
|
||||
--
|
||||
-- The other shot driver covers the cards rung and the STADIUM rungs and NOT
|
||||
-- 3D-BTL OFF -- which is the rung a player who has never touched the mod's
|
||||
-- battle row is on, and so the one place a regression can sit unseen. It sat
|
||||
-- there: the pic tint was a multiply that could only darken, and the arrival
|
||||
-- sparkle was armed from Stadium.update and therefore never played here.
|
||||
--
|
||||
-- Each rung is shot TWICE, shiny and ordinary, from the same species at the
|
||||
-- same spot, as a STRIP -- the intro flashes the pic through palette variants
|
||||
-- on its way in, so a single frame lands wherever the pacing put it.
|
||||
--
|
||||
-- Reports, per rung: whether the roll landed, the PALETTE the pic was baked
|
||||
-- under (which is where the recolour now lives), and whether the flat-path
|
||||
-- sparkle armed and drew.
|
||||
return function(game)
|
||||
local U = dofile("tests/drivers/util.lua")
|
||||
local BattleState = require("src.battle.BattleState")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
|
||||
local SPECIES = os.getenv("DS_SPECIES") or "GYARADOS"
|
||||
local LEVEL = tonumber(os.getenv("DS_LEVEL") or "") or 40
|
||||
local DIR = os.getenv("DS_SHOTS") or ".claude/shiny_update/flat"
|
||||
|
||||
local exports = game.mods and game.mods.exports
|
||||
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
||||
if not lib then U.log("DRAMATIC_SHAPE is not loaded") return end
|
||||
local Shiny = lib.require("Shiny")
|
||||
local ShinyPics = lib.require("ShinyPics")
|
||||
local ShinyFlash = lib.require("ShinyFlash")
|
||||
local OverworldBattle = lib.require("OverworldBattle")
|
||||
|
||||
U.log(("wraps: pics=%s flash=%s"):format(
|
||||
tostring(PaletteFX.dramaticShapeShiny == true),
|
||||
tostring(ShinyFlash.installed == true)))
|
||||
|
||||
-- ------- what the palette wrap hands the image cache
|
||||
--
|
||||
-- The recolour is baked ONCE, at build time, so counting draws says nothing
|
||||
-- about it. What matters is the cache key and the colours behind it: ask
|
||||
-- PaletteFX the same two questions monPalette asks, with the note the
|
||||
-- sprite hook would have left a moment earlier.
|
||||
local function palReport(mon)
|
||||
ShinyPics.note({ kind = "battle", species = SPECIES, mon = mon,
|
||||
data = game.data })
|
||||
local cols = PaletteFX.monPal(game.data, SPECIES)
|
||||
local name = PaletteFX.monPalName(game.data, SPECIES)
|
||||
local out = { "pal=" .. tostring(name) }
|
||||
for i = 1, math.min(3, cols and #cols or 0) do
|
||||
local c = cols[i]
|
||||
if type(c) == "table" and c[1] then
|
||||
out[#out + 1] = ("c%d=%d,%d,%d"):format(i, c[1], c[2], c[3])
|
||||
end
|
||||
end
|
||||
return table.concat(out, " ")
|
||||
end
|
||||
|
||||
-- The party is built at ORDINARY odds and pinned afterwards, so the
|
||||
-- player's own Pikachu stays common: this run is about the foe.
|
||||
game.save.player.name = "RED"
|
||||
game.save.party = { Pokemon.new(game.data, "PIKACHU", 50) }
|
||||
|
||||
local function leave()
|
||||
while game.stack:top() and game.stack:top() ~= game.overworld do
|
||||
game.stack:pop()
|
||||
end
|
||||
U.wait(10)
|
||||
end
|
||||
|
||||
local function shoot(rung, label, shiny)
|
||||
OverworldBattle.setting:setValue(rung, game)
|
||||
Shiny.setOdds(shiny and 1 or 100000000)
|
||||
for k in pairs(ShinyFlash.debug) do ShinyFlash.debug[k] = 0 end
|
||||
|
||||
U.teleport(game, "ROUTE_1", 5, 8, "down")
|
||||
U.wait(60)
|
||||
|
||||
local battle = BattleState.newWild(game, SPECIES, LEVEL)
|
||||
battle.onFinish = function() end
|
||||
game.overworld:pushBattle(battle)
|
||||
|
||||
local mon = battle.enemy and battle.enemy.mon
|
||||
U.log(("%s %s: 3D-BTL=%s isShiny=%s %s"):format(
|
||||
label, shiny and "shiny" or "normal",
|
||||
tostring(OverworldBattle.setting:get()), tostring(Shiny.isShiny(mon)),
|
||||
palReport(mon)))
|
||||
|
||||
-- The WIPE has to be walked through first. A driver run with no input at
|
||||
-- all sits on BattleTransition forever -- the battle is never pushed, so
|
||||
-- nothing about it draws and every counter below reads zero, which is
|
||||
-- exactly the false negative this probe produced before the taps went in.
|
||||
for _ = 1, 8 do U.tap(game, "a") U.wait(10) end
|
||||
|
||||
-- the sparkle is three quarters of a second long and starts on the frame
|
||||
-- the pic appears, so the strip is TIGHT
|
||||
for k = 1, 10 do
|
||||
U.shot(game, ("%s/%s_%s_%02d.png"):format(DIR, label,
|
||||
shiny and "shiny" or "normal",
|
||||
k))
|
||||
U.wait(9)
|
||||
end
|
||||
local d = ShinyFlash.debug
|
||||
U.log((" flash: renders=%s armed=%d draws=%d sparks=%d follows=%d occ=%d %s")
|
||||
:format(tostring(d.renders), d.armed, d.draws, d.sparks,
|
||||
d.follows, d.occupied, tostring(d.err)))
|
||||
leave()
|
||||
end
|
||||
|
||||
shoot(false, "off", false)
|
||||
shoot(false, "off", true)
|
||||
shoot(true, "cards", false)
|
||||
shoot(true, "cards", true)
|
||||
end
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Scratch driver: one shot of each fixture the wall-top / statue pass
|
||||
-- touched, so the change can be looked at rather than reasoned about.
|
||||
--
|
||||
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/voxel_fix_shots.lua \
|
||||
-- SHOT_DIR=mods/DramaticShapeVoxelMod/.claude/voxel_fix AB_TAG=. lovec.exe .
|
||||
--
|
||||
-- SHOT_DIR is relative to the PROJECT ROOT, which is where lovec runs from --
|
||||
-- the mod's own .claude/ is where these belong, so it has to be spelt out.
|
||||
--
|
||||
-- Every scene stands the player on the nearest WALKABLE cell to the vantage
|
||||
-- named below (teleporting into a wall puts the close cameras inside the
|
||||
-- geometry), faces the fixture, and shoots at voxel rung 3 -- the higher
|
||||
-- top-down camera, which is the one that shows what a wall wears on TOP.
|
||||
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/voxel_fix")
|
||||
.. "/" .. (os.getenv("AB_TAG") or ".")
|
||||
|
||||
local handle = game.mods.exports["DRAMATIC_SHAPE"]
|
||||
if not (handle and handle.lib) then
|
||||
print("[voxfix] DRAMATIC_SHAPE mod not loaded")
|
||||
return
|
||||
end
|
||||
local V = handle.lib
|
||||
local DayNight = V.require("DayNight")
|
||||
local ChunkMesher = V.require("ChunkMesher")
|
||||
local Voxel = V.require("VoxelState")
|
||||
local TileShape = V.require("TileShape")
|
||||
|
||||
-- prove the game is reading THIS tree, not a stale installed copy
|
||||
local ht = TileShape.wallTop and TileShape.wallTop("HOUSE")
|
||||
print(("[voxfix] wallTop present=%s HOUSE(45)=%s")
|
||||
:format(tostring(TileShape.wallTop ~= nil),
|
||||
tostring(ht and ht(45))))
|
||||
|
||||
require("src.world.OverworldController").rollEncounter = function() return nil end
|
||||
local TileRenderer = require("src.render.TileRenderer")
|
||||
TileRenderer.tick = function() end
|
||||
TileRenderer.animFrame = function() return 0 end
|
||||
DayNight.setting:sync("day")
|
||||
|
||||
pcall(os.execute, 'mkdir -p "' .. ROOT .. '" 2>/dev/null')
|
||||
pcall(os.execute, 'mkdir "' .. ROOT:gsub("/", "\\") .. '" 2>nul')
|
||||
|
||||
local Zoom = require("src.render.Zoom")
|
||||
pcall(function()
|
||||
game.save.options.zoom = 1
|
||||
Zoom.applyOptions(game.save.options)
|
||||
end)
|
||||
|
||||
local function settle()
|
||||
for _ = 1, 900 do
|
||||
if ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
for _ = 1, 300 do
|
||||
if Voxel.t >= 1 and Voxel.ready and ChunkMesher.pending() == 0 then break end
|
||||
U.wait(1)
|
||||
end
|
||||
U.wait(40)
|
||||
end
|
||||
|
||||
-- the nearest walkable cell to the vantage, searched in rings -- a
|
||||
-- vantage picked off a map dump is often the fixture itself
|
||||
local function place(x, y, face)
|
||||
local m = game.overworld.map
|
||||
for r = 0, 8 do
|
||||
for dy = -r, r do
|
||||
for dx = -r, r do
|
||||
if math.max(math.abs(dx), math.abs(dy)) == r then
|
||||
local px, py = x + dx, y + dy
|
||||
if m:inBounds(px, py) and m:isWalkableCell(px, py) then
|
||||
game.overworld.player.cellX = px
|
||||
game.overworld.player.cellY = py
|
||||
game.overworld.player.facing = face
|
||||
return px, py
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return x, y
|
||||
end
|
||||
|
||||
local SCENES = {
|
||||
{ map = "BLUES_HOUSE", x = 4, y = 3, face = "up",
|
||||
label = "house_wall_top" },
|
||||
{ map = "VIRIDIAN_POKECENTER", x = 4, y = 3, face = "up",
|
||||
label = "pokecenter_wall_top" },
|
||||
{ map = "ROCKET_HIDEOUT_ELEVATOR", x = 2, y = 3, face = "up",
|
||||
label = "rocket_lift_wall_top" },
|
||||
{ map = "REDS_HOUSE_2F", x = 6, y = 3, face = "up",
|
||||
label = "reds_2f_wall_top" },
|
||||
{ map = "REDS_HOUSE_1F", x = 5, y = 3, face = "up",
|
||||
label = "reds_1f_wall_top" },
|
||||
{ map = "LANCES_ROOM", x = 6, y = 15, face = "up",
|
||||
label = "lances_room_statues" },
|
||||
{ map = "INDIGO_PLATEAU", x = 2, y = 5, face = "up",
|
||||
label = "indigo_plateau_statue" },
|
||||
-- the west-edge statue itself, close enough to count the birds on it
|
||||
{ map = "INDIGO_PLATEAU", x = 1, y = 4, face = "up",
|
||||
label = "indigo_plateau_statue_close", rung = 5 },
|
||||
{ map = "INDIGO_PLATEAU", x = 2, y = 4, face = "up",
|
||||
label = "indigo_plateau_statue_near" },
|
||||
}
|
||||
|
||||
local only = os.getenv("VF_ONLY")
|
||||
local shots = 0
|
||||
for _, s in ipairs(SCENES) do
|
||||
if not (only and only ~= "" and not only:find(s.label, 1, true)) then
|
||||
local ok, err = pcall(function()
|
||||
U.teleport(game, s.map, s.x, s.y, s.face)
|
||||
local px, py = place(s.x, s.y, s.face)
|
||||
Pipelines.setLevel("voxel", s.rung or 3)
|
||||
Pipelines.setLevel("tiltshift", 0)
|
||||
settle()
|
||||
local path = ("%s/%s.png"):format(ROOT, s.label)
|
||||
game.capturePath = path
|
||||
U.wait(8)
|
||||
local f = io.open(path, "rb")
|
||||
if f then
|
||||
f:close()
|
||||
shots = shots + 1
|
||||
print(("[voxfix] %s -> %s (stood %d,%d)"):format(s.map, path, px, py))
|
||||
else
|
||||
print("[voxfix] capture missed: " .. path)
|
||||
end
|
||||
end)
|
||||
if not ok then print("[voxfix] " .. s.map .. " failed: " .. tostring(err)) end
|
||||
end
|
||||
end
|
||||
print(("[voxfix] %d/%d shots into %s"):format(shots, #SCENES, ROOT))
|
||||
love.event.quit()
|
||||
end
|
||||
@@ -0,0 +1,188 @@
|
||||
-- Every species' battle palette, normal beside shiny, as one HTML page.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_palette_sheet.lua
|
||||
--
|
||||
-- Run from the PROJECT ROOT. Writes
|
||||
-- mods/DramaticShapeVoxelMod/.claude/shiny_update/palettes.html
|
||||
--
|
||||
-- ------- what it is actually showing
|
||||
--
|
||||
-- Not the shiny COLOURS table (data/shiny_colors.lua) -- that is Stadium's
|
||||
-- values for a model's texels, and it is already checked against the Python
|
||||
-- that produced it. This is the other end: what those values become after
|
||||
-- ShinyPics puts them through the engine's four-shade battle palette, which
|
||||
-- is where the flat art gets its colour and the only place a mistake there
|
||||
-- shows up.
|
||||
--
|
||||
-- Two rules are visible in the output and both were bugs first:
|
||||
--
|
||||
-- * shade 1 and shade 4 never move. They are the shared paper (255,239,255)
|
||||
-- and the shared ink (25,16,16), not colours anybody chose for this
|
||||
-- animal, and sliding them turned shiny Golbat's white navy.
|
||||
-- * the five TABLE species rotate hue like everyone else, because their
|
||||
-- slide is measured back out of their lookup table rather than falling
|
||||
-- back to a multiply that can only darken.
|
||||
--
|
||||
-- The COLORS pack is whatever PaletteFX defaults to in a headless process
|
||||
-- (the GBC pack). The RED++ pack is a different set of four colours per
|
||||
-- species and would want its own sheet.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
local OUT = MOD .. "/.claude/shiny_update/palettes.html"
|
||||
|
||||
-- ------- the mod namespace, enough of it (see tests/shiny_test.lua)
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
-- Def/Spd/Spc all 10 and Atk 10 is the Gen 2 pattern src/pokemon/Stats.lua
|
||||
-- reads; any mon carrying it is shiny as far as the engine is concerned.
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
-- ------- collect, in dex order
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151 then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function hex(c)
|
||||
return ("#%02x%02x%02x"):format(c[1] or 0, c[2] or 0, c[3] or 0)
|
||||
end
|
||||
|
||||
local moved, still, missing = 0, 0, 0
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
row.normal = PaletteFX.monPal(Data, row.name)
|
||||
row.palName = PaletteFX.monPalName(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
row.shiny = PaletteFX.monPal(Data, row.name)
|
||||
row.shinyName = PaletteFX.monPalName(Data, row.name)
|
||||
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
row.kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
local slide = spec and (spec.lut and ShinyPalette.lutSlide(row.dex)
|
||||
or spec.slide)
|
||||
row.slide = slide
|
||||
|
||||
if not (row.normal and row.shiny) then
|
||||
missing = missing + 1
|
||||
else
|
||||
-- how far the two middle shades actually travelled, as the largest
|
||||
-- per-channel step: a row that reads 0 is a shiny nobody can see
|
||||
local d = 0
|
||||
for i = 2, #row.normal - 1 do
|
||||
local a, b = row.normal[i], row.shiny[i]
|
||||
if type(a) == "table" and type(b) == "table" then
|
||||
for k = 1, 3 do d = math.max(d, math.abs((a[k] or 0) - (b[k] or 0))) end
|
||||
end
|
||||
end
|
||||
row.delta = d
|
||||
if d >= 8 then moved = moved + 1 else still = still + 1 end
|
||||
end
|
||||
end
|
||||
|
||||
-- ------- the page
|
||||
local out = {}
|
||||
local function w(s) out[#out + 1] = s end
|
||||
|
||||
w([[<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<title>Shiny battle palettes</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 62em; margin: 0 0 20px; }
|
||||
table { border-collapse: collapse; }
|
||||
th, td { padding: 3px 10px 3px 0; text-align: left; vertical-align: middle;
|
||||
white-space: nowrap; }
|
||||
th { color: #9aa0aa; font-weight: 400; border-bottom: 1px solid #2a2c34; }
|
||||
tr:hover { background: #1c1e26; }
|
||||
.sw { display: inline-block; width: 26px; height: 20px;
|
||||
border: 1px solid #000; vertical-align: middle; }
|
||||
.fixed { opacity: .45; }
|
||||
.kind-table { color: #ffc46b; }
|
||||
.kind-slide { color: #7fb2ff; }
|
||||
.flat { color: #ff8a8a; }
|
||||
td.n { text-align: right; color: #9aa0aa; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle palettes — normal beside shiny</h1>
|
||||
<p class="lede">What <code>ShinyPics</code> hands the battle pic cache, per
|
||||
species. The first and last shades are the shared paper and ink and are held
|
||||
still on purpose (shown faded); only the two middle shades are the Pokemon.
|
||||
<span class="kind-slide">slide</span> species use Stadium's declared values;
|
||||
the five <span class="kind-table">table</span> species use a slide measured
|
||||
back out of their own lookup table, which is what lets Gyarados reach red.
|
||||
Δ is the largest per-channel step across the two middle shades —
|
||||
a row in <span class="flat">red</span> barely moved.</p>
|
||||
]])
|
||||
|
||||
w(("<p class=\"lede\">%d species · %d visibly recoloured · "
|
||||
.. "%d barely moved · %d with no palette</p>\n")
|
||||
:format(#rows, moved, still, missing))
|
||||
|
||||
w("<table><tr><th>#</th><th>species</th><th>pal</th><th>kind</th>"
|
||||
.. "<th>slide h / s / l</th><th>normal</th><th>shiny</th><th>Δ</th>"
|
||||
.. "</tr>\n")
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local function swatches(cols)
|
||||
if not cols then return "—" end
|
||||
local o = {}
|
||||
for i, c in ipairs(cols) do
|
||||
local fixed = (i == 1 or i == #cols) and " fixed" or ""
|
||||
if type(c) == "table" and c[1] then
|
||||
o[#o + 1] = ("<span class=\"sw%s\" style=\"background:%s\" "
|
||||
.. "title=\"%d,%d,%d\"></span>")
|
||||
:format(fixed, hex(c), c[1], c[2], c[3])
|
||||
end
|
||||
end
|
||||
return table.concat(o)
|
||||
end
|
||||
local s = row.slide
|
||||
w(("<tr><td class=\"n\">%03d</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"kind-%s\">%s</td><td>%s</td><td>%s</td><td>%s</td>"
|
||||
.. "<td class=\"n%s\">%s</td></tr>\n")
|
||||
:format(row.dex, row.name, tostring(row.palName), row.kind, row.kind,
|
||||
s and ("%.0f° / %+.1f / %+.1f"):format(s.h or 0, s.s or 0,
|
||||
s.l or 0) or "—",
|
||||
swatches(row.normal), swatches(row.shiny),
|
||||
(row.delta and row.delta < 8) and " flat" or "",
|
||||
row.delta and tostring(row.delta) or "—"))
|
||||
end
|
||||
|
||||
w("</table></body></html>\n")
|
||||
|
||||
local f = assert(io.open(OUT, "wb"))
|
||||
f:write(table.concat(out))
|
||||
f:close()
|
||||
|
||||
print(("%s -- %d species, %d recoloured, %d barely moved, %d no palette")
|
||||
:format(OUT, #rows, moved, still, missing))
|
||||
@@ -0,0 +1,75 @@
|
||||
-- Emit what tools/shiny_pic_sheet.py needs to bake the battle pics.
|
||||
--
|
||||
-- luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > pics.tsv
|
||||
--
|
||||
-- Run from the PROJECT ROOT. One species per line, tab separated:
|
||||
--
|
||||
-- dex name spriteFront kind n1 n2 n3 n4 s1 s2 s3 s4
|
||||
--
|
||||
-- where each colour is r,g,b. TSV rather than JSON because there is no JSON
|
||||
-- encoder in this tree and the payload is eight colours and a path.
|
||||
--
|
||||
-- The COLOURS are the point: they come from the real wrap (ShinyPics over
|
||||
-- PaletteFX.monPal), not from a second implementation of it, so what the
|
||||
-- sheet shows is what the game bakes.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local MOD = "mods/DramaticShapeVoxelMod"
|
||||
|
||||
local loaded, V = {}, {}
|
||||
function V.require(n)
|
||||
if loaded[n] == nil then
|
||||
loaded[n] = assert(loadfile(MOD .. "/lib/" .. n .. ".lua"))(V)
|
||||
end
|
||||
return loaded[n]
|
||||
end
|
||||
function V.data(n) return assert(loadfile(MOD .. "/data/" .. n .. ".lua"))(V) end
|
||||
V.path = MOD
|
||||
V.mod = { id = "DRAMATIC_SHAPE",
|
||||
log = { warn = function() end, info = function() end } }
|
||||
|
||||
local ShinyPics = V.require("ShinyPics")
|
||||
local ShinyPalette = V.require("ShinyPalette")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Data = {
|
||||
pokemon = dofile("data/generated/pokemon.lua"),
|
||||
palettes = dofile("data/generated/palettes.lua"),
|
||||
}
|
||||
|
||||
assert(ShinyPics.install(), "the palette wrap did not install")
|
||||
|
||||
local SHINY = { dvs = { attack = 10, defense = 10, speed = 10,
|
||||
special = 10, hp = 15 } }
|
||||
|
||||
local rows = {}
|
||||
for name, def in pairs(Data.pokemon) do
|
||||
if type(def) == "table" and def.dex and def.dex >= 1 and def.dex <= 151
|
||||
and def.spriteFront then
|
||||
rows[#rows + 1] = { name = name, dex = def.dex, path = def.spriteFront }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b) return a.dex < b.dex end)
|
||||
|
||||
local function cols(t)
|
||||
local o = {}
|
||||
for i = 1, 4 do
|
||||
local c = t and t[i]
|
||||
o[i] = (type(c) == "table" and c[1])
|
||||
and ("%d,%d,%d"):format(c[1], c[2], c[3]) or "0,0,0"
|
||||
end
|
||||
return table.concat(o, "\t")
|
||||
end
|
||||
|
||||
for _, row in ipairs(rows) do
|
||||
local normal = PaletteFX.monPal(Data, row.name)
|
||||
ShinyPics.note({ kind = "battle", species = row.name, mon = SHINY,
|
||||
data = Data })
|
||||
local shiny = PaletteFX.monPal(Data, row.name)
|
||||
local spec = ShinyPalette.forDex(row.dex)
|
||||
local kind = spec and (spec.lut and "table" or "slide") or "none"
|
||||
io.write(("%d\t%s\t%s\t%s\t%s\t%s\n")
|
||||
:format(row.dex, row.name, row.path, kind, cols(normal),
|
||||
cols(shiny)))
|
||||
end
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Every battle pic, baked normal beside baked shiny, as one HTML page.
|
||||
|
||||
luajit mods/DramaticShapeVoxelMod/tools/shiny_pic_dump.lua > /tmp/pics.tsv
|
||||
python mods/DramaticShapeVoxelMod/tools/shiny_pic_sheet.py /tmp/pics.tsv
|
||||
|
||||
Run from the PROJECT ROOT. Writes
|
||||
mods/DramaticShapeVoxelMod/.claude/shiny_update/sprites.html, self-contained
|
||||
(every pic is a data: URI), so it can be opened or sent on its own.
|
||||
|
||||
------- the bake is the engine's, exactly
|
||||
|
||||
src/battle/BattleState.lua:147 getImage() is the only place a battle pic gets
|
||||
its colour, and it does it ONCE at load with mapPixel:
|
||||
|
||||
col = r > 0.83 and c[1] or r > 0.5 and c[2] or r > 0.17 and c[3] or c[4]
|
||||
|
||||
Four-shade DMG art, keyed on the RED channel alone, snapped to the species
|
||||
palette. That line is reproduced below rather than approximated, because the
|
||||
whole question this sheet answers is what the player will actually see -- an
|
||||
approximation of the bake would be answering a different one.
|
||||
|
||||
The colours come from tools/shiny_pic_dump.lua, which runs the real
|
||||
ShinyPics wrap over the real PaletteFX, so nothing here re-derives them.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.getcwd()
|
||||
MOD = "mods/DramaticShapeVoxelMod"
|
||||
OUT = os.path.join(MOD, ".claude/shiny_update/sprites.html")
|
||||
SCALE = 3 # nearest-neighbour, so the pixels stay pixels
|
||||
|
||||
|
||||
def parse_color(s):
|
||||
r, g, b = (int(v) for v in s.split(","))
|
||||
return (r, g, b)
|
||||
|
||||
|
||||
def bake(img, pal):
|
||||
"""getImage's mapPixel: red channel picks the shade, alpha is kept."""
|
||||
img = img.convert("RGBA")
|
||||
px = img.load()
|
||||
w, h = img.size
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
r, g, b, a = px[x, y]
|
||||
if a == 0:
|
||||
continue
|
||||
f = r / 255.0
|
||||
if f > 0.83:
|
||||
c = pal[0]
|
||||
elif f > 0.5:
|
||||
c = pal[1]
|
||||
elif f > 0.17:
|
||||
c = pal[2]
|
||||
else:
|
||||
c = pal[3]
|
||||
px[x, y] = (c[0], c[1], c[2], a)
|
||||
return img
|
||||
|
||||
|
||||
def data_uri(img):
|
||||
img = img.resize((img.width * SCALE, img.height * SCALE), Image.NEAREST)
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
|
||||
|
||||
def main():
|
||||
src = sys.argv[1] if len(sys.argv) > 1 else "-"
|
||||
stream = sys.stdin if src == "-" else open(src, encoding="utf-8")
|
||||
rows = []
|
||||
with stream:
|
||||
for line in stream:
|
||||
line = line.rstrip("\n")
|
||||
if not line:
|
||||
continue
|
||||
f = line.split("\t")
|
||||
rows.append({
|
||||
"dex": int(f[0]),
|
||||
"name": f[1],
|
||||
"path": f[2],
|
||||
"kind": f[3],
|
||||
"normal": [parse_color(c) for c in f[4:8]],
|
||||
"shiny": [parse_color(c) for c in f[8:12]],
|
||||
})
|
||||
|
||||
cards, missing = [], 0
|
||||
for row in rows:
|
||||
path = os.path.join(ROOT, row["path"])
|
||||
if not os.path.exists(path):
|
||||
missing += 1
|
||||
continue
|
||||
art = Image.open(path)
|
||||
n = data_uri(bake(art.copy(), row["normal"]))
|
||||
s = data_uri(bake(art.copy(), row["shiny"]))
|
||||
cards.append(
|
||||
'<figure class="k-{kind}">'
|
||||
'<div class="pair"><img src="{n}" alt="{name} normal">'
|
||||
'<img src="{s}" alt="{name} shiny"></div>'
|
||||
'<figcaption>{dex:03d} {name}<span>{kind}</span></figcaption>'
|
||||
"</figure>".format(kind=row["kind"], n=n, s=s,
|
||||
name=row["name"], dex=row["dex"])
|
||||
)
|
||||
|
||||
html = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><title>Shiny battle pics</title>
|
||||
<style>
|
||||
body { font: 13px/1.5 ui-monospace, Menlo, Consolas, monospace;
|
||||
margin: 24px; background: #14151a; color: #e6e6ea; }
|
||||
h1 { font-size: 18px; margin: 0 0 4px; }
|
||||
p.lede { color: #9aa0aa; max-width: 64em; margin: 0 0 20px; }
|
||||
.grid { display: flex; flex-wrap: wrap; gap: 14px; }
|
||||
figure { margin: 0; background: #1c1e26; border: 1px solid #2a2c34;
|
||||
border-radius: 6px; padding: 8px; }
|
||||
.k-table { border-color: #7a5a1e; }
|
||||
.pair { display: flex; gap: 6px; background: #fff; border-radius: 3px;
|
||||
padding: 2px; }
|
||||
img { display: block; image-rendering: pixelated; }
|
||||
figcaption { margin-top: 6px; color: #9aa0aa; display: flex;
|
||||
justify-content: space-between; gap: 10px; }
|
||||
figcaption span { color: #6f757f; }
|
||||
.k-table figcaption span { color: #ffc46b; }
|
||||
</style></head><body>
|
||||
<h1>Shiny battle pics — normal on the left, shiny on the right</h1>
|
||||
<p class="lede">Each pair is the same four-shade art baked twice, through the
|
||||
palette the game itself would use: <code>getImage</code> keys on the red
|
||||
channel alone and snaps to the species palette, once, at load. The colours
|
||||
come from the live <code>ShinyPics</code> wrap, so this is what the flat
|
||||
battle screen draws — not a preview of it. Bordered cards are the five
|
||||
species Stadium gives a real alternate texture, whose slide is measured back
|
||||
out of that texture rather than declared.</p>
|
||||
<p class="lede">%d species%s</p>
|
||||
<div class="grid">%s</div>
|
||||
</body></html>
|
||||
""" % (len(cards),
|
||||
"" if not missing else " · %d with no art on disk" % missing,
|
||||
"\n".join(cards))
|
||||
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
with open(OUT, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
size = os.path.getsize(OUT) / 1024.0
|
||||
print("%s -- %d pairs, %d missing, %.0f KB" % (OUT, len(cards), missing,
|
||||
size))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user