Merge pull request #147 from DramaticShape/dev

1.8.1
This commit is contained in:
DramaticShape
2026-08-08 19:59:27 -04:00
committed by GitHub
23 changed files with 2116 additions and 152 deletions
+44
View File
@@ -87,6 +87,50 @@
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.
## 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).
+131 -9
View File
@@ -890,9 +890,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 +904,37 @@ 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 },
-- THE CABLE CLUB STEPS, cut into the back wall at cells (10,0) and
-- (12,0) of every Center -- a flight going DOWN, away from the room,
-- and the reason `stair_down_n` exists 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), a
-- black nosing, 3 grey, a nosing, 3 checker, then 4 black rows -- the
-- dark the flight leaves into, which is also what the far end wall
-- wears. Its first and last COLUMNS are the well'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_down_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 +1059,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,
@@ -3002,6 +3094,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]
+53 -5
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
@@ -403,8 +423,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 +585,11 @@ 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))
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 +616,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
@@ -610,7 +658,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
+214 -2
View File
@@ -2004,7 +2004,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_down_n"
local down = north 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 +2055,90 @@ 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.
--
-- 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 = -(i + 1) * rise
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, STAIR_SHADE.wellTread)
-- the riser under that lip. It faces NORTH -- a flight descending
-- away from you turns its risers away with it, and they close the
-- steps from below rather than being looked at. One art row tall,
-- so it needs none of `banded`'s row splitting; written straight
-- keeps the geometry flush at the seam while the art stays inside
-- its tile
local ry = -i * rise
face({ wx1, yTop, z1b }, { wx0, yTop, z1b },
{ wx0, ry, z1b }, { wx1, ry, z1b },
ax1, a1 - 1, ax0, a1, STAIR_SHADE.riser)
-- 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
if 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 well's side walls above this tread, wearing the drawing's own
-- black edge columns -- the excavation is walled in its own texels
local function sideWall(px, sx0, sx1, inward)
local c
if inward then -- west wall, faces E
c = { { px, yTop, z1b }, { px, yTop, z0b },
{ px, 0, z0b }, { px, 0, z1b } }
else -- east wall, faces W
c = { { px, yTop, z0b }, { px, yTop, z1b },
{ px, 0, z1b }, { px, 0, z0b } }
end
face(c[1], c[2], c[3], c[4], sx0, a1, sx1, a0, STAIR_SHADE.wellN)
end
sideWall(mx, 0.1, 1.3, true)
sideWall(mx + 16, 14.7, 15.9, false)
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 +2238,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 +2340,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 +2424,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
@@ -3051,6 +3176,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 +3332,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
+52
View File
@@ -125,6 +125,12 @@ local FALLBACK_HEIGHTS = {
stair_w = 16,
stair_down_e = 16,
stair_down_w = 16,
-- a stairwell descending 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 down the well
stair_down_n = 16,
}
-- class -> how the mesher draws it (see the header). The last three are
@@ -215,6 +221,7 @@ local ART = {
stair_w = "stair",
stair_down_e = "stair",
stair_down_w = "stair",
stair_down_n = "stair",
}
local spec = nil -- the loaded data file, or false when absent
@@ -469,6 +476,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 +497,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 +576,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
+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.8.0",
"version": "1.8.1",
"api": 2,
"entry": "main.lua",
"profile": "content",
+12 -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
--
+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
+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()