mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 18:20:51 +02:00
6b4dfe6b9b
Four things, all found by shooting the feature rather than by reading it. The flat-pic tint was a no-op for the five colour-table species. tintFor ran synthetic reference colours through the lookup, none of them were IN it, so every ratio came back 1 and the tint was discarded -- a shiny Gyarados drew an ordinary blue pic. Those species now measure the tint from the table's own entries. It was also a no-op for most SLIDE species, for a better-hidden reason: a hue rotation moves red toward cyan and cyan toward red, so averaged over a balanced set of references the ratios cancel and every species reports no tint. Charizard and Ponyta both came back neutral. The tint is now measured against the colour each species is actually MADE of -- a modal body colour, generated into data/shiny_colors.lua as `dom`. 132 of 151 now carry a usable tint; the rest genuinely shift too little for one to mean anything. The sparkle drew sixty quads a frame that nobody could see. It was built to numbers a tenth of the scale of a mon card -- a ring seven units across, inside a Gyarados -- and additive drawing keeps the depth test, so all of it was rejected. Sized against the card now, and pulled toward the camera the way the move-animation card is. And the summary PIC cannot be recoloured by touching pixels: the art is four-shade DMG grey and the colour is applied afterwards by the palette pass. That attempt is reverted, with the reason left where the next person will look for it. The star is the designation that works there. Also: tools/shiny_colors.py now resolves its own paths instead of hardcoding a worktree, and the extract test gained a hard failure when NOTHING recolours -- which is what a missing colour table looks like from the outside, and it passed through it once already. Evidence in .claude/shiny_update, every case beside its own control.
224 lines
8.5 KiB
Lua
224 lines
8.5 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
|
|
|
|
-- Sized against the mon CARD, which is what a Pokemon occupies here: a card
|
|
-- is BattleBillboard.FULL_W / FULL_PIC * GB_W units across, i.e. about 46
|
|
-- wide and 41 tall. The first version of this was built to numbers a tenth
|
|
-- of that and drew sixty quads a frame that nobody could see -- a ring seven
|
|
-- units across, inside a Gyarados.
|
|
ShinyFx.LIFE = 0.75 -- seconds from spring to gone
|
|
ShinyFx.STARS = 10 -- around the ring
|
|
ShinyFx.RISE = 14 -- world units the ring climbs over its life
|
|
ShinyFx.SPREAD = 24 -- how far out the ring opens -- wider than the
|
|
-- body, or the stars are inside the Pokemon
|
|
ShinyFx.CHEST = 20 -- height above the tile the burst starts at
|
|
ShinyFx.SIZE = 7 -- a star's world size at full brightness
|
|
|
|
-- Additive drawing keeps the depth TEST (Voxel3D.blend sets lequal with
|
|
-- writes off), so a star level with the model is rejected by it however
|
|
-- bright it is. The extra pull puts the ring in front of the Pokemon it
|
|
-- belongs to, the same trick the move-animation card uses.
|
|
ShinyFx.PULL_BONUS = 6
|
|
|
|
-- 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.
|
|
-- Why a burst did not draw, for a driver to read back. Rendering faults are
|
|
-- invisible to the test suite and this one has four separate ways to be a
|
|
-- no-op, all of them silent.
|
|
ShinyFx.debug = { calls = 0, noArena = 0, noImage = 0, noMesh = 0,
|
|
noLive = 0, quads = 0 }
|
|
|
|
function ShinyFx.draw(arena, groundY, pull)
|
|
local dbg = ShinyFx.debug
|
|
dbg.calls = dbg.calls + 1
|
|
if not arena then dbg.noArena = dbg.noArena + 1 return end
|
|
local img = starImage()
|
|
if not img then dbg.noImage = dbg.noImage + 1 return end
|
|
local mesh = BattleBillboard.mesh()
|
|
if not mesh then dbg.noMesh = dbg.noMesh + 1 return end
|
|
if not (live.player or live.enemy) then
|
|
dbg.noLive = dbg.noLive + 1
|
|
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 or 0) + ShinyFx.PULL_BONUS)
|
|
dbg.quads = dbg.quads + 1
|
|
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
|