-- 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