Files
DramaticShapeVoxelMod/lib/ShinyFx.lua
T
DramaticShape a3fb18a589 shiny Pokemon, on by default
Gen 1 has no shininess of its own, but it has the four DVs Gen 2 reads to
decide it -- and the engine already ships that reading (Stats.isShiny, its
own comment calling it "the RBY virtual shiny", allowlisted for mods
precisely so an indicator mod can call it). Nothing new is stored on a
Pokemon and nothing migrates: every save already contains the answer, and
this starts drawing it. Random DVs land on the pattern 1 in 8192, which is
the classic rate and the default the odds dial ships at.

Deriving rather than storing is what makes it survive a save, a box, a
trade and an evolution with no second copy of the truth to drift. mon.shiny
is a cache written from the DVs, never read as the source.

The roll goes in Pokemon.new -- every wild, gift, starter and traded mon is
built there, and it is before the battle bakes its sprite, which
battle.started is already too late for. It draws from the mod's own random
stream so installing this does not shift the sequence damage rolls and
encounter slots come out of. Trainers stay ordinary by themselves: the
engine pins their DVs, as the real games do.

The models are genuinely recoloured, as part of the extraction. Each
species is decoded once, packed as usual, then recoloured and packed again
as NNNs.dsm. The colours are Stadium's own HSL slide (hue in degrees,
saturation and lightness on a -8..+8 scale at 12.5% a step); five species
carry an explicit colour table instead, because Stadium gives them a real
alternate texture that no single slide reproduces -- Jigglypuff's body must
stay pink while its irises rotate to green.

Extraction is the right moment because StadiumFx's generated frames are
still marked there and the packer drops the marker: it is the last point a
flame is distinguishable from a hide. A shiny Charizard has a shiny hide
and an ordinary fire. The normal packs are written BEFORE the recolour, so
they come out byte-identical and stadium_extract_test still diffs all 151
against the Python oracle unchanged -- no format change, no DSM4, no second
implementation to keep in step. REV goes to 3 so an existing cache rebuilds.

Flat art is tinted instead, because the engine bakes a species palette into
a cache with no notion of which individual is drawn. The tint comes from
that species' own slide rather than a generic gold. A multiply can only
darken, so species whose shiny is lighter read quieter there than on the
model; the status page's star is the mode-proof mark.

Tests: 58 assertions in tests/shiny_test.lua, including the colour
transform against 640 real colour pairs lifted from the verified texture
set, the DV model, the read side, and the end-to-end through the engine's
own constructor. stadium_extract_test gains --mod (worktrees have neither
the ROM nor the packs, both gitignored) and now also checks that every
shiny pack is the same length as its twin and actually differs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 12:12:25 -04:00

199 lines
7.2 KiB
Lua

-- The shiny sparkle: the flash a Pokemon makes when it first appears.
--
-- The games announce a shiny with a burst of stars over the sprite the
-- instant it lands, before the first text box. This is that moment, in the
-- diorama: a ring of additive stars that springs outward from the mon's
-- chest, rises, and fades over about three quarters of a second.
--
-- ------- where the moment IS
--
-- Harder than it sounds, because the two battle paths arrive differently:
--
-- the model rung a Pokemon grows out of its ball -- Stadium.update
-- already finds that frame (the POOF_ANIM edge) and
-- calls StadiumMon:beginGrow.
-- the pic rung a WILD foe is simply THERE on the first frame, with
-- no poof and no grow at all. There is no animation to
-- hang off.
--
-- So the arming edge is neither of those: it is the frame a side's OCCUPANT
-- changes (Stadium's `session.at[side] ~= battler` test, the same identity
-- the mode already uses because a trainer leading with two Rattata changes
-- occupant without changing species). That edge fires for a send-out, a
-- switch and a wild foe alike, which is exactly the set of moments a shiny
-- should announce itself.
--
-- ------- drawn additively, and why it survives the flash
--
-- Stars are light, so they add rather than cover: `Voxel3D.blend("add")`,
-- the same treatment the Poke Ball's glow gets. They are drawn inside the
-- battle's flash window alongside the cards and models, so a sparkle during
-- a hit flash is lit by it like everything else rather than floating over
-- it as a separate layer.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
local Voxel3D = V.require("Voxel3D")
local Mat4 = V.require("Mat4")
local BattleBillboard = V.require("BattleBillboard")
local ShinyFx = {}
-- ------- shape and timing
ShinyFx.LIFE = 0.75 -- seconds from spring to gone
ShinyFx.STARS = 10 -- around the ring
ShinyFx.RISE = 10 -- world units the ring climbs over its life
ShinyFx.SPREAD = 7 -- how far out the ring opens
ShinyFx.CHEST = 9 -- height above the tile the burst starts at
ShinyFx.SIZE = 3.2 -- a star's world size at full brightness
-- one per side, nil when nothing is playing
local live = { player = nil, enemy = nil }
local star = nil -- the generated star image, built once
-- ------- the star
--
-- Generated rather than shipped: it is a four-pointed twinkle, which is a
-- cheap closed form (a radial falloff times a cross-shaped spike term) and
-- costs nothing next to an asset that would have to be authored, packed,
-- loaded and kept in step with the rest of the mod's art.
local function starImage()
if star ~= nil then return star or nil end
if not (love and love.image and love.graphics) then
star = false
return nil
end
local ok, img = pcall(function()
local N = 32
local data = love.image.newImageData(N, N)
local c = (N - 1) / 2
for y = 0, N - 1 do
for x = 0, N - 1 do
local dx, dy = (x - c) / c, (y - c) / c
local r = math.sqrt(dx * dx + dy * dy)
-- the body: a soft core that is gone by the edge of the square
local core = math.max(0, 1 - r)
core = core * core * core
-- the spikes: bright along the two axes, narrow, and reaching
-- further out than the core does
local ax, ay = math.abs(dx), math.abs(dy)
local spike = math.max(0, 1 - ax * 6) * math.max(0, 1 - ay)
+ math.max(0, 1 - ay * 6) * math.max(0, 1 - ax)
local a = math.min(1, core + spike * 0.55)
-- white with the faintest warm cast, so a sparkle over a cool
-- model still reads as light rather than as a blue smear
data:setPixel(x, y, 1, 1, 0.97, a)
end
end
return love.graphics.newImage(data)
end)
star = (ok and img) or false
return star or nil
end
-- ------- arming
-- Start (or restart) the burst on one side. Restarting rather than ignoring
-- a second call is deliberate: a shiny that faints and is sent back out
-- should sparkle again.
function ShinyFx.arm(side)
if side ~= "player" and side ~= "enemy" then return end
live[side] = { t = 0 }
end
function ShinyFx.clear(side)
if side then live[side] = nil else live.player, live.enemy = nil, nil end
end
function ShinyFx.active(side)
if side then return live[side] ~= nil end
return live.player ~= nil or live.enemy ~= nil
end
function ShinyFx.update(dt)
dt = dt or 0
for _, side in ipairs({ "player", "enemy" }) do
local s = live[side]
if s then
s.t = s.t + dt
if s.t >= ShinyFx.LIFE then live[side] = nil end
end
end
end
-- ------- drawing
-- Eased so the ring leaves fast and settles, which is what a spark does;
-- linear looks like a diagram of a spark.
local function easeOut(u) return 1 - (1 - u) * (1 - u) end
-- Draw whatever is playing. `arena` and `groundY` come from the scene, the
-- same two the mon cards are placed from, so a sparkle lands where its
-- Pokemon is standing rather than where the layout thinks it should be.
function ShinyFx.draw(arena, groundY, pull)
if not arena then return end
local img = starImage()
if not img then return end
local mesh = BattleBillboard.mesh()
if not mesh then return end
local drew = false
for _, side in ipairs({ "player", "enemy" }) do
local s = live[side]
local cell = (side == "player") and arena.player or arena.enemy
if s and cell then
local u = math.min(1, s.t / ShinyFx.LIFE)
local e = easeOut(u)
-- bright immediately, then out: the announcement is the first frame
local alpha = 1 - u * u
local x, z = cell[1], cell[2]
local yaw = BattleBillboard.yawToward(x, z, Voxel3D.eye)
local baseY = groundY + ShinyFx.CHEST + ShinyFx.RISE * e
if not drew then
Voxel3D.blend("add")
Voxel3D.seams(false)
Voxel3D.glass(false)
drew = true
end
for i = 1, ShinyFx.STARS do
-- the ring is offset half a step per side so the two sides do not
-- twinkle in lockstep when both are shiny
local a = (i / ShinyFx.STARS) * math.pi * 2
+ (side == "player" and 0.31 or 0)
local r = ShinyFx.SPREAD * e
-- stars shrink as they fade, and alternate size so the ring reads
-- as scattered rather than as a cog
local k = ShinyFx.SIZE * (1 - u * 0.6) * ((i % 2 == 0) and 0.7 or 1)
local ox = math.cos(a) * r
local oy = math.sin(a) * r * 0.55 -- flattened: we look from low
local m = Mat4.mul(
Mat4.mul(Mat4.translate(x, baseY, z), Mat4.rotateY(yaw)),
Mat4.mul(Mat4.translate(ox, oy, 0), Mat4.scale(k, k, 1)))
love.graphics.setColor(1, 1, 1, alpha)
Voxel3D.draw(mesh, img, m, pull)
end
end
end
if drew then
love.graphics.setColor(1, 1, 1, 1)
Voxel3D.glass(true)
Voxel3D.seams(true)
Voxel3D.blend("alpha")
end
end
-- Drop the generated image (hot reload, or a graphics context that went
-- away) -- the same contract StadiumPack.invalidate honours.
function ShinyFx.invalidate()
if star and star.release then pcall(star.release, star) end
star = nil
ShinyFx.clear()
end
return ShinyFx