mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 09:10:49 +02:00
update 2d shiny sprites and shiny animation
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user