Files
2026-08-08 18:51:01 -04:00

324 lines
13 KiB
Lua

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