Compare commits

..

13 Commits

Author SHA1 Message Date
DramaticShape 12a1ab0266 iterate version 2026-08-09 00:02:21 -04:00
DramaticShape 2af88d07cf fix victory road statues 2026-08-09 00:01:47 -04:00
DramaticShape 47369c24fe fixed tops of walls in houses 2026-08-08 22:12:07 -04:00
DramaticShape 787d93a0e4 iterate version 2026-08-08 19:57:49 -04:00
DramaticShape c681167475 add doors to s/w/e sides of gate houses 2026-08-08 19:57:22 -04:00
DramaticShape 99de8c2760 update 2d shiny sprites and shiny animation 2026-08-08 19:55:32 -04:00
DramaticShape 34e7da5c12 v-grid toggle on battles 2026-08-08 19:01:53 -04:00
DramaticShape 9ddb940bc8 update 2d shiny sprites and shiny animation 2026-08-08 18:51:01 -04:00
DramaticShape c05c2cebd9 Merge pull request #125 from Code-Grub/fix/flat-top-rim-repeat
stop a flat top stamping its rim down the plateau
2026-08-08 16:49:29 -04:00
DramaticShape d6a9f10352 x
Removed restriction on redistribution of non-derivative code.
2026-08-08 16:27:30 -04:00
DramaticShape 4aeb0fc675 Update version from 1.7.2 to 1.8.0 2026-08-08 14:32:14 -04:00
DramaticShape 9be6888290 Merge pull request #143 from DramaticShape/shadow-toggle
Shadow toggle
2026-08-08 14:31:11 -04:00
Code-Grub 20f9e19bf9 stop a flat top stamping its rim down the plateau
A cliff mound is drawn as a rim over a body: its top edge, then the same
rock the whole way down. The top face cycles the first two drawn rows to
fill its depth, so it laid that rim again every second tile. The mound the
Diglett's Cave mouth is cut into came out with three rim lines across it
instead of one along its north edge.

Where the drawing says the body is all one tile, lay the rim once and hold
the body after it. Art that genuinely repeats keeps cycling: the Safari
Zone's fence alternates two tiles the whole way down, and there the repeat
is what the drawing says.

Answered per column and per region, because each catches what the other
misses. The columns carrying a mound's cave mouth end in the mouth's own
tiles, so per column alone they kept cycling while their neighbours held,
leaving rim stubs above the doorway. A region vote alone silences a real
rim-over-body column standing in a region of repeating art, of which the
Safari Zone has three. A column holds if either says so.

Geometry is untouched: the silhouette is pixel for pixel what it was, and
only the texel a top face wears changes. Of 3088 flat-topped runs, the
1336 rim-over-body ones change and nothing else does.

tests/flat_top_test.lua walks every map and fails if any rim-over-body run
revisits an earlier drawn row.
2026-08-06 13:04:12 -04:00
24 changed files with 2810 additions and 163 deletions
+129
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+323
View File
@@ -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
View File
@@ -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
+156
View File
@@ -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
View File
@@ -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
View File
@@ -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
+99
View File
@@ -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
View File
@@ -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
View File
@@ -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
+25 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+95
View File
@@ -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
+119
View File
@@ -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
+137
View File
@@ -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
+188
View File
@@ -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 &mdash; 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.
&Delta; is the largest per-channel step across the two middle shades &mdash;
a row in <span class="flat">red</span> barely moved.</p>
]])
w(("<p class=\"lede\">%d species &middot; %d visibly recoloured &middot; "
.. "%d barely moved &middot; %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>&Delta;</th>"
.. "</tr>\n")
for _, row in ipairs(rows) do
local function swatches(cols)
if not cols then return "&mdash;" 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&deg; / %+.1f / %+.1f"):format(s.h or 0, s.s or 0,
s.l or 0) or "&mdash;",
swatches(row.normal), swatches(row.shiny),
(row.delta and row.delta < 8) and " flat" or "",
row.delta and tostring(row.delta) or "&mdash;"))
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))
+75
View File
@@ -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
+155
View File
@@ -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 &mdash; 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 &mdash; 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 " &middot; %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()