update 2d shiny sprites and shiny animation

This commit is contained in:
DramaticShape
2026-08-08 18:51:01 -04:00
parent c05c2cebd9
commit 9ddb940bc8
14 changed files with 1448 additions and 98 deletions
+75 -5
View File
@@ -906,11 +906,13 @@ return {
-- two flanks -- see `prop` below. -- two flanks -- see `prop` below.
wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41, wall = { 2, 3, 4, 5, 6, 16, 18, 19, 20, 21, 22, 40, 41,
76, 77, 92, 93, 94, 95 }, 76, 77, 92, 93, 94, 95 },
-- the counters, half a cell high: top band (8) with the nurse's -- the counters, half a cell high: top band (8) and the one cell of
-- tray (10), front face (24/25, the game's counterTiles), left end -- it that carries the push bell (10, lifted off as a figure below --
-- cap (56) and the Cable Club's light sections (90/91). 8px is -- the pin stays as the degradation path), front face (24/25, the
-- one clean band, so the drawn front panel stands up and the -- game's counterTiles), left end cap (56) and the Cable Club's light
-- counter top stays on top; at 12 they read as wall stubs -- 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, counter = { 8, 10, 24, 25, 56, 90, 91,
-- and the lounge couch's SEAT column with the man -- and the lounge couch's SEAT column with the man
-- sitting on it. Same half-cell box: its bottom row -- sitting on it. Same half-cell box: its bottom row
@@ -1035,6 +1037,74 @@ return {
-- on the arm. The background corners around his head and the -- on the arm. The background corners around his head and the
-- cushion wedge under his legs are the only pixels given back. -- cushion wedge under his legs are the only pixels given back.
figures = { 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, w = 3,
tiles = { 36, 37, 57, tiles = { 36, 37, 57,
+32 -3
View File
@@ -423,8 +423,12 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- `to` routes the quad somewhere other than the main sink -- the water -- `to` routes the quad somewhere other than the main sink -- the water
-- surface is the only caller that ever does (see runGeometry's header). -- surface is the only caller that ever does (see runGeometry's header).
local function topQuad(x0, z0, h, tile, shade, to) -- `vTop`/`vBot` crop the art to a row range of the tile, which only the
local u0, u1, v0, v1 = uvRect(tile, 0, 8) -- 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 }, ;(to or push)({ { x0, h, z0 }, { x0 + 8, h, z0 },
{ x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } }, { x0 + 8, h, z0 + 8 }, { x0, h, z0 + 8 } },
{ { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } }, { { u0, v0 }, { u1, v0 }, { u1, v1 }, { u0, v1 } },
@@ -585,6 +589,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE) topQuad(x0, z0, h, topTile, VOLUME_TOP_SHADE)
else else
local topTile = tile local topTile = tile
local vTop, vBot = nil, nil
if s.art == "upright" and s.authored then if s.art == "upright" and s.authored then
-- Top art for a pinned box. A furniture drawing is top-view -- Top art for a pinned box. A furniture drawing is top-view
-- rows over floor(h/8) face-on rows the fold stands upright; -- rows over floor(h/8) face-on rows the fold stands upright;
@@ -611,6 +616,30 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
end end
end end
local row = math.min(ty, front - math.floor(h / 8)) 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 if row < north then
-- the whole run folded onto the face: top with the drawn -- the whole run folded onto the face: top with the drawn
-- row just above it when that row is furniture too (a -- row just above it when that row is furniture too (a
@@ -629,7 +658,7 @@ local function runGeometry(map, bodyOnly, masks, sink, waterSink)
-- on the pond. -- on the pond.
topQuad(x0, z0, h, topTile, topQuad(x0, z0, h, topTile,
s.art == "upright" and VOLUME_TOP_SHADE or 1, 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 end
-- sides: 8px bands wherever the neighbour is lower. Band k spans -- 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 for k, v in pairs(OFF[side]) do saved[k] = battle[k]; battle[k] = v end
texturing = side texturing = side
-- A SHINY on this side, tinted here rather than in ShinyUI's flat-path -- ------- no shiny tint here any more
-- 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 -- This used to bracket the draw below with that side's shiny tint, on the
-- shiny facing an ordinary mon gets its own colour and leaves the other -- grounds that rendering one side at a time is the only place the two can
-- alone, which the engine's both-sides-at-once pic layer cannot do. -- be coloured differently. True, and no longer needed: the PIC itself is
local shinyTint = nil -- now built from a shiny palette (lib/ShinyPics.lua), which is per-mon
do -- rather than per-side and gets the colour right instead of approximating
-- NOT when this side is showing a PERSON. Both sides can be holding a -- it with a multiply. Tinting on top of that would apply the shift twice.
-- 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
local ok, err = pcall(function() local ok, err = pcall(function()
g.setCanvas(canvas) g.setCanvas(canvas)
g.clear(0, 0, 0, 0) g.clear(0, 0, 0, 0)
g.setBlendMode("alpha") g.setBlendMode("alpha")
g.setColor(1, 1, 1, 1) g.setColor(1, 1, 1, 1)
if shinyTint then innerPics(battle, 0, 0, 0)
V.require("ShinyUI").withTint(shinyTint, innerPics, battle, 0, 0, 0)
else
innerPics(battle, 0, 0, 0)
end
end) end)
texturing = nil texturing = nil
@@ -1288,6 +1265,22 @@ function OverworldBattle.install()
return TEX_AX - w * scale / 2, TEX_AY - h * scale, s return TEX_AX - w * scale / 2, TEX_AY - h * scale, s
end 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 local innerDraw = BattleState.draw
function BattleState:draw() function BattleState:draw()
local shot = OverworldBattle.shot() local shot = OverworldBattle.shot()
@@ -1298,7 +1291,9 @@ function OverworldBattle.install()
-- that loses its arena mid-fight goes back to white voids -- that loses its arena mid-fight goes back to white voids
self.letterboxWhite = nil self.letterboxWhite = nil
self.dramaticShapeShot = nil self.dramaticShapeShot = nil
return innerDraw(self) local out = innerDraw(self)
shinyFlash(self)
return out
end end
self.dramaticShapeShot = shot self.dramaticShapeShot = shot
-- The world reaches the screen through the seam a render pipeline's -- 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 -- own HUD drew in, so it letterboxes and chunks identically
local cap = BattleScene.capture local cap = BattleScene.capture
if cap and cap.drawGB then pcall(cap.drawGB, self) end if cap and cap.drawGB then pcall(cap.drawGB, self) end
shinyFlash(self)
end end
-- The mons are geometry standing on the map now, drawn in the 3D pass -- 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 -- 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 -- species fall back to their tint multiplier, which IS derived from the
-- table and does carry its direction. -- 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) function ShinyPalette.paletteTransform(dex)
local spec = ShinyPalette.forDex(dex) local spec = ShinyPalette.forDex(dex)
local slide = spec and spec.slide
if not spec then return nil end if not spec then return nil end
if not spec.lut then return ShinyPalette.transform(spec) end if spec.lut then
local t = ShinyPalette.tintFor(dex) if slideCache[dex] == nil then
if not t then return nil end slideCache[dex] = slideFromLut(spec.lut) or false
return function(r, g, b) end
return floor(min(255, r * t[1]) + 0.5), slide = slideCache[dex] or nil
floor(min(255, g * t[2]) + 0.5),
floor(min(255, b * t[3]) + 0.5)
end 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 end
-- ------- the pass over one species' whole texture array -- ------- 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() function ShinyUI.install()
ShinyUI.installSummary() ShinyUI.installSummary()
ShinyUI.installBattlePics()
end end
-- The status page. Wraps the draw and adds the star afterwards, so the -- The status page. Wraps the draw and adds the star afterwards, so the
@@ -177,51 +176,25 @@ function ShinyUI.installSummary()
SummaryMenu.dramaticShapeShiny = true SummaryMenu.dramaticShapeShiny = true
end end
-- The battle pics. The engine's pic layer is reached through -- ------- the battle pics are NOT tinted here any more
-- BattleState:drawPicsLayer, which draws BOTH sides in one call -- so a --
-- per-side tint has to bracket each side separately, which is exactly what -- There used to be a third wrap in this file: a multiply over
-- OverworldBattle.sideTexture already does when it renders one side at a -- BattleState:drawPicsLayer, with the tint above. It is gone, and the reason
-- time into its own canvas. That is where the tint belongs on the 3D path; -- is worth keeping so it is not put back.
-- this wrap covers the FLAT path, where the engine draws the battle itself. --
function ShinyUI.installBattlePics() -- A multiply can only DARKEN. Shiny Gyarados is blue turning red, and the
local ok, BattleState = pcall(require, "src.battle.BattleState") -- nearest a multiply gets to that is a dimmer blue -- so every species whose
if not ok or type(BattleState) ~= "table" then return end -- shiny is lighter, or is a rotation rather than a dimming, read as the
if BattleState.dramaticShapeShinyPics then return end -- ordinary one with the brightness down. And the engine's pic layer draws
local inner = BattleState.drawPicsLayer -- BOTH sides in one call, so a shiny also dimmed the ordinary mon opposite it.
if type(inner) ~= "function" then return end --
-- lib/ShinyPics.lua replaces it by moving the PALETTE instead, which is where
function BattleState:drawPicsLayer(...) -- a battle pic's colour actually lives: getImage bakes the four DMG shades
-- THE 3D PATH HAS ALREADY DONE THIS, per side and better: when the mod -- into the species palette once and caches the result, so handing that bake a
-- is rendering one side into its own canvas it brackets that draw with -- shiny palette (under a cache name of its own) gives a genuinely recoloured
-- that side's own tint (OverworldBattle.sideTexture). Tinting again here -- pic -- brightening included -- for one side alone.
-- would square it. Asked as a question rather than left to install --
-- order, because both wraps are installed from main.lua and whichever -- ShinyUI.withTint and ShinyUI.tintFor stay: the 3D path still uses them for
-- ran first would otherwise silently decide the outcome. -- the per-side canvas, and they are the only tint left in the mod.
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
return ShinyUI return ShinyUI
+88 -1
View File
@@ -3089,6 +3089,80 @@ local function maskPlate(quads, m, perRow, atlasW, atlasH, x0, r, y, z0, D)
end end
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 ---- -- ---- figures: a thing drawn INTO furniture, cut out and stood up ----
-- One authored figure at one matched position. -- One authored figure at one matched position.
@@ -3171,7 +3245,20 @@ local function buildFigure(S, map, fig, tx, ty, perRow)
local atlasW = map.tileset.imageWidth or 128 local atlasW = map.tileset.imageWidth or 128
local atlasH = map.tileset.imageHeight or 48 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 -- 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 -- 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 -- character card would have pivoted in. It is anchored there and
+45
View File
@@ -469,6 +469,8 @@ end
-- --
-- figures = { { w = <tiles across>, -- figures = { { w = <tiles across>,
-- depth = <voxels of body; ABSENT for a person>, -- 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> }, -- thin = { rows = <top rows>, depth = <voxels> },
-- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } }, -- flat = { x = { <lx0>, <lx1> }, rows = { <r0>, <r1> } },
-- tiles = { ...w*h tile ids, row-major... }, -- tiles = { ...w*h tile ids, row-major... },
@@ -488,6 +490,16 @@ end
-- same furniture the card would have stood on. The Marts' cash -- 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. -- 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, -- 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 -- because a solid drawn in one 16x16 GB cell still packs more than one
-- facing: -- facing:
@@ -557,10 +569,43 @@ local function authoredMasks(list)
r0 = math.floor(f.flat.rows[1]), r0 = math.floor(f.flat.rows[1]),
r1 = math.floor(f.flat.rows[2]) } r1 = math.floor(f.flat.rows[2]) }
end 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 if n > 0 then
out[#out + 1] = { w = w, h = h, n = n, mask = mask, out[#out + 1] = { w = w, h = h, n = n, mask = mask,
tiles = f.tiles, under = f.under, tiles = f.tiles, under = f.under,
depth = depth and math.floor(depth) or nil, 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 } thin = thin, flat = flat }
end end
end end
+25 -2
View File
@@ -91,6 +91,8 @@ local BattleExit = V.require("BattleExit")
local Shiny = V.require("Shiny") local Shiny = V.require("Shiny")
local ShinyBattle = V.require("ShinyBattle") local ShinyBattle = V.require("ShinyBattle")
local ShinyUI = V.require("ShinyUI") local ShinyUI = V.require("ShinyUI")
local ShinyPics = V.require("ShinyPics")
local ShinyFlash = V.require("ShinyFlash")
local DayNight = V.require("DayNight") local DayNight = V.require("DayNight")
local DayTint = V.require("DayTint") local DayTint = V.require("DayTint")
local Water = V.require("Water") local Water = V.require("Water")
@@ -1185,14 +1187,35 @@ OverworldBattle.install()
-- ShinyBattle wraps Pokemon.new, which is where every wild, gift, -- ShinyBattle wraps Pokemon.new, which is where every wild, gift,
-- starter and traded mon is built, so the roll lands before -- starter and traded mon is built, so the roll lands before
-- the sprite is baked -- the sprite is baked
-- ShinyUI the battle pics' tint and the status page's mark -- ShinyUI the status page's mark, and the summary pic's palette
-- ShinyFx the arrival sparkle (armed from Stadium.update) -- 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 -- The Stadium models need no seam here at all: their recolour happens at
-- extraction (lib/StadiumBuild.lua), and the battle simply asks for the -- extraction (lib/StadiumBuild.lua), and the battle simply asks for the
-- shiny pack. -- shiny pack.
ShinyBattle.install() ShinyBattle.install()
ShinyUI.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 -- 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 -- already -- they always did -- so refresh the cached flag across the party
+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()