DramaticShapeVoxelMod archival mirror · read-only
lib/ShinyFlash.lua

ShinyFlash.lua

lib/ShinyFlash.lua12.8 KB1006445b45b21eeb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 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