mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 08:01:09 +02:00
a3fb18a589
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>
199 lines
7.2 KiB
Lua
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
|