mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:30:59 +02:00
324 lines
13 KiB
Lua
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
|