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

Stadium.lua

lib/Stadium.lua36.0 KB10064426bbcabb07
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 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818
-- STADIUM battles: the two Pokemon as real 3D models.
--
-- The 3D-BTL row's two STADIUM rungs. OFF is the engine's own white battle
-- field; the 2D-3D rungs stand the GB's own pics up as quads
-- (BattleBillboard); STADIUM replaces those quads with the Pokemon Stadium
-- battle models -- skinned, animated, and playing the animation the move
-- being used actually calls for. A or B decides whether that happens on the
-- map or on two discs, and is the same choice on either pair of rungs.
--
-- The models come out of the Stadium ROM through model_extract, and are
-- packed into assets/stadium/NNN.dsm by tools/stadium_pack.py. Nothing here
-- knows about the ROM; the pack is the interface.
--
-- ------- what this file is, and is not
--
-- It is the MODE: which species is out on each side, which animation the
-- fight is asking each of them for, whether the model or the flat pic is
-- standing in this frame, and the two draw calls. The arithmetic is
-- StadiumRig's, the file format is StadiumPack's, and one side's own state
-- is StadiumMon's.
--
-- It is not a rewrite of the staged battle. The arena is picked the same
-- way, the camera is solved the same way, the HUDs and the text box and the
-- move animations and the depth of field are all exactly what 2D-3D draws
-- -- because all of those are hung off the arena's CELLS, not off the
-- pics. Swapping what stands on a cell changes nothing about where the cell
-- projects to. That is why this is an option on the mode rather than a
-- second mode.
--
-- ------- declining, per Pokemon
--
-- Every gate here is per SIDE and per FRAME, not per battle:
--
--   no pack for that species, or its meshes would not build -> that side
--   falls back to its flat pic, and the other side keeps its model
--
--   the side is showing a TRAINER (the foe's class before the send-out,
--   the player's own back before "Go!") -> that is not a Pokemon and there
--   is no model for it; the pic stands, exactly as in 2D-3D
--
--   a SUBSTITUTE is up -> the engine replaces the pic with the mini doll,
--   which is the thing the player is being told is there. A model of the
--   Pokemon behind the doll would be a lie about the battle state.
--
-- So `covers` is asked per side per frame, and OverworldBattle renders a
-- billboard texture for exactly the sides it answers false for.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local Voxel3D = V.require("Voxel3D")
local StadiumPack = V.require("StadiumPack")
local StadiumMon = V.require("StadiumMon")
local ShinyBattle = V.require("ShinyBattle")
local ShinyFx = V.require("ShinyFx")

local Stadium = {}

-- The stored values of the two 3D-BTL rungs that select this mode. Strings
-- rather than further booleans so an older save's `true` still means the
-- 2D-3D it was written for (see OverworldBattle.setting).
--
--   A  the models on the MAP -- real ground, the map's own light and sky
--   B  the models on two DISCS against the sky, with no map at all
--
-- Everything below is shared: which species is out, which animation the
-- fight is asking for, the skinning, the draw. The difference is entirely
-- in what the camera is pointed at, which is BattleScene's business and
-- StadiumStage's.
Stadium.VALUE = "stadium"
Stadium.VALUE_B = "stadiumB"

-- ------- the live pair

local session = nil     -- nil when no staged fight is running

local function game()
  return require("src.core.Game")
end

-- Whether the row is on this rung. Deliberately NOT gated on whether the
-- packs are installed: a mod folder without assets/stadium still cycles the
-- row, and each Pokemon declines on its own when its pack does not load --
-- which is one message on the console rather than a row that silently
-- refuses to move.
function Stadium.selected()
  return Stadium.mode() ~= nil
end

-- "A", "B", or nil when the row is on neither stadium rung.
function Stadium.mode()
  local OverworldBattle = V.require("OverworldBattle")
  local value = OverworldBattle.setting:get()
  if value == Stadium.VALUE then return "A" end
  if value == Stadium.VALUE_B then return "B" end
  return nil
end

-- Whether the fight is staged on the DISCS rather than on the map.
--
-- Not this file's question any more: the flat 2D-3D B rung stands the game's
-- own pics on the same two discs with no model anywhere in the frame, so the
-- stage and the actors are chosen separately (see OverworldBattle's ladder).
-- Kept as a forwarder because "are we on discs" is a fair thing to ask the
-- module named after the mode, and because the shot drivers ask it here.
function Stadium.discs()
  return V.require("OverworldBattle").discs()
end

function Stadium.enabled()
  if not Stadium.selected() then return false end
  return Voxel3D.available()
end

-- A staged fight has begun on `arena`. Called from OverworldBattle.begin,
-- which is the one place that knows a fight is being staged at all.
function Stadium.begin(arena)
  Stadium.finish()
  if not Stadium.enabled() then return false end
  -- a new fight gets its own first complaint: `reported` is a one-shot so the
  -- console is not filled sixty times a second, but latched for the whole
  -- process it would swallow every failure after the first one ever
  Stadium.reported = false
  session = {
    arena = arena,
    groundY = 0,
    player = StadiumMon.new("player"),
    enemy = StadiumMon.new("enemy"),
    -- what each side has been TRANSFORMED into, if anything (see install)
    transform = {},
    -- sides that are going to collapse, but whose HP bar has not finished
    -- emptying yet (see faintReady)
    faintPending = {},
    -- who was standing in each slot last frame, so a replacement is noticed
    -- even when it is the same species (see update)
    at = {},
  }
  return true
end

function Stadium.finish()
  if not session then return end
  session.player:release()
  session.enemy:release()
  session = nil
end

function Stadium.active()
  return session ~= nil
end

-- ------- which species each side is showing

-- The National Dex number for a battler, which is the number the Stadium
-- packs are keyed by. The engine's species are string keys ("PIKACHU") and
-- carry their dex number on the definition, so this is one lookup rather
-- than a table of its own.
local function dexOf(species)
  if not species then return nil end
  local data = game() and game().data
  local def = data and data.pokemon and data.pokemon[species]
  return def and def.dex or nil
end

-- Whether this side is showing a TRAINER rather than a Pokemon.
local function showingTrainer(battle, side)
  if side == "enemy" then
    return (battle.showEnemyTrainer and battle.trainerPic) and true or false
  end
  return (battle.showPlayerBack and battle.playerBackPic) and true or false
end

-- Whether this side has anything on the field at all this frame.
--
-- Mirrors BattleState's own guards, the same way OverworldBattle.sideVisible
-- mirrors them for the flat cards: there is no seam that reports "the foe is
-- off screen right now", and a model left standing through a send-out or a
-- damage blink would be the one thing in the frame that ignored the battle.
-- ------- and the collapse gets to finish
--
-- A fainted Pokemon leaves the field when its pic does, which is the end of
-- the engine's slide -- SlideDownFaintedMonPic, seven rows two frames apart,
-- FOURTEEN frames of a 60 Hz clock. Under a quarter of a second.
--
-- The Stadium faint animations are nothing like that short. The briefest in
-- the set is 49 frames of a 30 Hz clock -- a second and two thirds -- the
-- median is 110 and the longest 230, which is nearly eight seconds. Held to
-- the pic's window every one of them was cut off inside its first fifth: the
-- Pokemon began to fall and vanished mid-fall, which is worse than not
-- animating at all, because the eye has been told something is happening and
-- then had it taken away.
--
-- So a model that is COLLAPSING stays until it has finished collapsing, and
-- the two timings stop being tied to each other. That is the whole of the
-- divergence: the slide is how long a flat pic takes to slide off the bottom
-- of a 160x144 frame, and it has nothing to say about how long it takes a
-- Gyarados to fall over.
--
-- Bounded at both ends rather than open-ended. It ends when the animation
-- does (StadiumMon.finished), not when the battle moves on -- so nothing is
-- left lying on the field for the rest of the fight -- and the side is reset
-- outright the moment a different battler stands in that slot (see update),
-- which is what stops the next Pokemon out of the ball arriving face down.
local function onField(battle, side, mon)
  local battler = side == "player" and battle.player or battle.enemy
  if not (battler and battler.sprite) then return false end
  -- A model that is GROWING out of its ball is on the field by definition --
  -- that is what the grow is -- even though the engine still calls the side
  -- "sending out", because the flat pic it wrote that flag for does not
  -- appear until the ball has finished opening and this one comes out with
  -- it (see StadiumMon.GROW_TIME).
  local growing = (mon and mon.grow) and true or false
  if side == "enemy" then
    if battle.enemyHidden then return false end
    if battle.enemySendingOut and not growing then return false end
  else
    if battle.safari or battle.demo then return false end
    if battle.sendingOut and not growing then return false end
    -- ------- and not before the battle has even opened
    --
    -- The player's Pokemon is not out during the INTRO. Every other guard
    -- here is a field the engine sets once the battle is running, and during
    -- the opening none of them is set yet: `showPlayerBack` is still nil
    -- (BattleState assigns it further in, when the back pic is built),
    -- `playerBackPic` is nil with it, and `sendingOut` does not go true until
    -- the ball is actually thrown. So the whole opening read as "this
    -- Pokemon is standing on the field" and the model was drawn through it --
    -- two and a half seconds of it, on its tile, playing its standby loop,
    -- before the trainer sprite it is supposed to be hiding behind had even
    -- appeared. It then vanished when that sprite arrived and came back with
    -- its entrance when the ball opened, so the first Pokemon of a battle
    -- appeared, left and arrived again.
    --
    -- A SWITCH has no intro, which is why a switch always looked right and
    -- was the thing worth comparing against.
    --
    -- Gated on the PHASE rather than on a flag latched at the send-out: a
    -- latch that never fires (a link battle, a script pushing a battle
    -- straight to the menu) would hide the Pokemon for good, and being wrong
    -- in that direction is far worse than the two seconds this fixes.
    if battle.phase == "intro" then return false end
  end
  local ok, hidden = pcall(battle.fxHidden, battle, battler)
  if ok and hidden then return false end
  -- ------- FLY and DIG: the Pokemon that is not there
  --
  -- `fxHidden` above is the damage BLINK and nothing else. The other way a
  -- Pokemon leaves the screen -- the important one -- is the engine's
  -- per-battler pic program, `picFx`, and that is where the two-turn moves
  -- live: FLY runs SE_SLIDE_MON_OFF and DIG SE_SLIDE_MON_DOWN on the charge
  -- turn, each a 19-24 frame slide that ENDS by setting `hidden`, and the
  -- release turn puts the pic back through SE_SLIDE_MON_UP /
  -- SE_SHOW_MON_PIC. Every other vanishing act is the same field: the user
  -- of Explosion, a Pokemon that has been Teleported away.
  --
  -- Without this the model simply stood on its tile while the game said it
  -- was underground -- and said it in the strongest way it has, by making
  -- every attack aimed at it miss. That is the one thing in the frame
  -- contradicting the battle it is part of.
  --
  -- Read as the engine's own answer rather than as a list of moves: this
  -- mode's whole method is to let the battle decide and follow it, and a
  -- table of move ids here would be a second place for the same facts to
  -- live and would go stale against a mod that adds a third one.
  --
  -- The engine's slide is 19-24 frames, so the model plays the opening of
  -- its own FLY or DIG animation while the pic slides and is gone when the
  -- pic is. It is NOT held to the end of that animation the way a collapse
  -- is (see below), and the difference is not an oversight: the Stadium
  -- animations are authored as the WHOLE move -- Charizard's DIG is 3.83
  -- seconds of burrow, emerge and hit -- because Stadium plays it in one
  -- turn. Gen 1 splits it across two, so cutting at the engine's own hide
  -- shows the burrowing and holds the strike back for the turn it lands on,
  -- which is the right half of the animation for the turn being played.
  local pf = battle.picFx and battle.picFx[battler]
  if pf and pf.hidden then return false end
  if battler.fainted then
    local okF, sliding = pcall(battle.fxFaintActive, battle, battler)
    if okF and sliding then return true end
    -- the pic has finished sliding away; the model has not finished falling
    return (mon and mon.state == "faint" and not mon:finished()) and true
           or false
  end
  return true
end

Stadium._onField = onField

-- Whether the 3D model is standing in for this side's pic this frame. The
-- one question OverworldBattle asks, and the answer that decides whether a
-- billboard texture gets rendered for that side at all.
function Stadium.covers(battle, side)
  if not (session and battle) then return false end
  local mon = session[side]
  if not (mon and mon.rig) then return false end
  if showingTrainer(battle, side) then return false end
  local battler = side == "player" and battle.player or battle.enemy
  -- the substitute doll is what the player is being shown is out there
  if battler and battler.substituteHP then return false end
  return true
end

-- ------- the collapse waits for the bar
--
-- `onFaint` runs the instant HP reaches zero, which is NOT when a Pokemon
-- falls over. The engine queues the collapse -- the slide, the cry, the
-- "fainted!" line -- to run after the move animation and the HP-bar drain
-- (BattleState.onFaint's own comment), and the drain takes real frames: a
-- 150 HP mon's bar walks down over some four seconds.
--
-- So asking for the faint animation at `onFaint` played it against a bar
-- that was still emptying: the Pokemon lay down, and then its health went on
-- draining above the corpse. What the player reads as the moment of death is
-- the bar hitting zero, and that is what this waits for.
--
-- `shownHP` is the engine's own bar position (BattleState.stepHPDrain walks
-- it toward mon.hp a point at a time), so this is not a guess at the timing
-- -- it is the same number the bar is drawn from.
local function faintReady(battler)
  if not battler then return false end
  -- nothing is animating the bar for this battler: there is nothing to wait
  -- for, and waiting forever would mean never collapsing at all
  if battler.shownHP == nil then return true end
  return battler.shownHP <= 0
end

-- Whether a pending collapse is still owed. A switch, a revive or a battler
-- that was replaced under us drops it rather than firing late at whoever is
-- standing there now.
local function faintStillDue(battler)
  return (battler and battler.faintQueued
          and battler.mon and (battler.mon.hp or 0) <= 0) and true or false
end

-- named for the suite: these timing rules are the whole of what decides when
-- a Pokemon falls and when it goes, and they are testable without a graphics
-- context where the mode itself is not
Stadium._faintReady = faintReady
Stadium._faintStillDue = faintStillDue

-- ------- per frame
--
-- Runs from OverworldBattle.update, before the pics are rendered and before
-- the scene is drawn: what this decides is exactly which sides need a pic.
function Stadium.update(dt, battle, groundY)
  if not session then return end
  session.groundY = groundY or session.groundY or 0
  if not battle then return end

  local arena = session.arena
  for _, side in ipairs({ "enemy", "player" }) do
    local mon = session[side]
    local battler = side == "player" and battle.player or battle.enemy
    local dex = nil
    if battler and not showingTrainer(battle, side) then
      dex = session.transform[side] or dexOf(battler.mon and battler.mon.species)
    end

    -- A DIFFERENT POKEMON IS IN THIS SLOT. Normally that shows up as a
    -- change of species and setSpecies rebuilds everything -- but a trainer
    -- who leads with two Rattata sends the second one out onto the first
    -- one's dex number, so nothing downstream would notice. What it would
    -- inherit is the state, and the state after a faint is `faint`, which
    -- refuses every request there is (see StadiumMon.request -- a faint is
    -- meant to be final). The new Pokemon would arrive lying on the ground.
    --
    -- The battler TABLE is the identity here rather than the species or the
    -- mon: it is the slot's occupant, and the engine replaces it on a switch,
    -- a send-out and a new battle alike.
    if session.at[side] ~= battler then
      session.at[side] = battler
      -- a fresh arrival: this Pokemon has not grown out of its ball yet
      if mon then mon.grow, mon.grewOwn = nil, nil end
      if mon and mon.rig and mon.state == "faint" then mon:play("idle") end
      -- and if it is shiny, announce it. This edge rather than the grow,
      -- because a WILD foe never grows -- it is on the field from the
      -- first frame -- and that is the encounter a shiny most wants to be
      -- announced on. See the header of ShinyFx.
      if ShinyBattle.battlerIsShiny(battler) then
        ShinyFx.arm(side)
      else
        ShinyFx.clear(side)
      end
    end
    -- the collapse this side is owed, once its bar has finished emptying
    if session.faintPending and session.faintPending[side] then
      if not faintStillDue(battler) then
        session.faintPending[side] = nil
      elseif faintReady(battler) then
        session.faintPending[side] = nil
        if mon and mon.rig then mon:request("faint") end
      end
    end

    -- Shininess is a property of the OCCUPANT, not of the species, so it is
    -- resolved here beside the dex number and passed with it. A shiny
    -- Rattata and an ordinary one are the same dex and different models.
    --
    -- Read off the battler rather than remembered, because Transform makes
    -- the two disagree: a Ditto that copied a shiny Rattata wears the
    -- Rattata's dex (session.transform above) and keeps its OWN shininess,
    -- which is exactly what the games do.
    local shiny = battler ~= nil and not session.transform[side]
                  and ShinyBattle.battlerIsShiny(battler)

    mon:setSpecies(dex, shiny)
    -- and tell the pack cache this one is standing there, every frame. Its
    -- eviction order is keyed on LOADS, and a side only loads when its
    -- species changes -- so without this a Pokemon that has been out for a
    -- few turns is the least recently loaded thing in the cache and gets its
    -- textures released out from under it the moment a fifth species enters
    -- the battle (see StadiumPack.keep). The shiny flag rides along: the
    -- shiny and normal models are separate cache entries.
    if mon.species then StadiumPack.keep(mon.species, mon.shiny) end

    -- how big this Pokemon actually is, so a shiny's sparkle can be sized to
    -- it rather than to a constant that is wrong for most of the dex (see
    -- the header of ShinyFx). Pushed every frame: the model can arrive a
    -- frame or two after the burst is armed, and a send-out is still growing
    -- while it plays.
    if mon.rig and mon.model then
      ShinyFx.setMetrics(side, mon:worldHeight(), mon:worldRadius())
    else
      ShinyFx.setMetrics(side, nil)
    end

    -- and let a waiting sparkle GO, once the fight is actually the thing on
    -- screen. The battle draws underneath the transition wipe for about a
    -- second before that, and a burst released then plays out its whole life
    -- behind it -- armed, drawn, counted, and never seen, which is exactly
    -- how this looked when it was keyed on the scene drawing instead.
    local g = game()
    local top = g and g.stack and g.stack:top()
    if top == battle then ShinyFx.release(side) end
    mon.visible = (mon.rig ~= nil) and onField(battle, side, mon)
                  and not (battler and battler.substituteHP)
    -- LET'S GO capture mode: the player's model is out of the shot the
    -- same way its card and back pic are (the shrink half of the story is
    -- below, AFTER the grow block, which reassigns mon.scale every frame)
    local cap = V.require("BattleScene").capture
    if side == "player" and cap and cap.hidePlayer then
      mon.visible = false
    end
    -- cleared up front, so a side that has just lost its rig cannot leave
    -- last frame's matrix behind it
    mon.model_matrix = nil

    if mon.rig then
      -- ------- the ball is opening: start growing out of it
      --
      -- The POOF is the ball coming apart, and it is where a Pokemon should
      -- begin to exist -- not 27 frames later when the engine starts scaling
      -- up the flat pic it was written for. Only for a side the battle says
      -- is actually sending out, so the same animation played at a thrown
      -- Poke Ball (a capture attempt, which aims it at the FOE) cannot start
      -- the wrong Pokemon growing.
      local poof = (battle.animPlaying
                    and battle.animName == "POOF_ANIM") and true or false
      local sending = (side == "player") and battle.sendingOut
                      or battle.enemySendingOut
      if poof and sending and mon:beginGrow() then
        -- and the arrival animation with it, so the whole thing is one
        -- performance rather than a grow followed by a flourish
        mon:request("entrance")
      end

      -- how big it is drawn. Its own ramp while it is growing (see
      -- StadiumMon.growScale); the engine's three-step one otherwise, which
      -- still covers a send-out that never showed a poof.
      if mon.grow then
        mon.scale = mon:growScale()
      elseif mon.grewOwn then
        mon.scale = 1
      else
        local okG, grow = pcall(battle.growInScale, battle, battler)
        mon.scale = (okG and grow) or 1
      end
      -- LET'S GO capture: the foe drinking into the ball. AFTER the grow
      -- block on purpose -- that block reassigns mon.scale every frame,
      -- and the first cut of this hook sat above it and was silently
      -- clobbered: the model stood at full size over a ball that had
      -- supposedly swallowed it. The session's fraction owns the scale
      -- for as long as it exists; the frame it clears, the grow block
      -- above is already putting the engine's own answer back.
      if side == "enemy" and cap and cap.shrink then
        mon.scale = cap.shrink
      end
      mon:update(dt or 0)
      if mon.visible and arena then
        local cell = arena[side]
        local other = arena[side == "player" and "enemy" or "player"]
        if cell and other then
          -- posed and skinned inside the same guard the draws use: this is
          -- where a bad track or a released texture is first touched, and a
          -- throw here would take the OTHER side's update with it (the
          -- caller wraps this whole function in one pcall)
          Stadium.guard(side, mon, "build", function()
            mon.model_matrix = mon:matrix(cell[1], session.groundY, cell[2],
                                          other[1] - cell[1],
                                          other[2] - cell[2])
            mon:build()
          end)
        else
          mon.model_matrix = nil
        end
      else
        mon.model_matrix = nil
      end
    end
  end
  Stadium.debug(dt)
end

-- ------- the draws
--
-- Both take the pass as they find it: this is called from inside
-- BattleScene's own beginScene/endScene window (and, in a headset, from
-- VoxelScene's), so the camera, the shadow map, the hour's tint and the hit
-- flash are all already set. StadiumRig turns the wireframe and the glass
-- mask off around its own draws and puts them back.

-- ------- one model going wrong is not both
--
-- These two draws used to be a bare loop inside the caller's single pcall,
-- which had two consequences and both were bad. A throw on the FIRST side
-- skipped the second, so one broken Pokemon took its opponent off the screen
-- with it. And nothing recorded that it had happened, so the same throw came
-- back every frame for the rest of the fight -- the mode's own fallback (that
-- side draws its flat pic instead) was sitting right there and never reached,
-- because falling back needs somebody to decide the model is not working.
--
-- So each side is drawn inside its own pcall, and a side that throws is
-- RETIRED: its rig is released, which is exactly the state a species with no
-- pack is in, and OverworldBattle renders a billboard for it from the next
-- frame on. The fight carries on with a flat Pokemon instead of a missing
-- one, which is the difference the player actually sees.
-- On the TABLE rather than a local, because Stadium.update calls it and sits
-- above this line: a local would still be nil there.
function Stadium.guard(side, mon, what, fn)
  local ok, err = pcall(fn)
  if ok then return true end
  Stadium.report(err)
  -- release rather than merely hide: the rig holds meshes and texture
  -- references, and whatever went wrong with them is not going to be better
  -- next frame. setSpecies rebuilds from scratch if this Pokemon is sent out
  -- again later.
  if mon.rig then pcall(mon.release, mon) end
  mon.rig, mon.visible, mon.model_matrix = nil, false, nil
  if session then session.broken = session.broken or {} end
  if session then session.broken[side] = what end
  return false
end

-- The foe's body for the capture mode's collision and ring, when a MODEL
-- is standing there instead of a pic: its own measured height and
-- footprint, in world pixels. A model stands on the ground, so the body's
-- centre is half its height up. nil whenever no model covers the foe,
-- which sends CatchThrow to its pic measurement instead.
function Stadium.captureBody()
  if not session then return nil end
  local mon = session.enemy
  if not (mon and mon.rig and mon.visible) then return nil end
  local okH, h = pcall(mon.worldHeight, mon)
  if not (okH and h and h > 0) then return nil end
  -- The POSED body, when there is one: a flying Pokemon is nowhere near
  -- the mark its cell projects to, and only the pose knows where it went
  -- (StadiumMon:bodySpan). The bind-pose figures stand in until the
  -- first skin, which is right for everything that keeps its feet down.
  local okS, centre, half, girth = pcall(mon.bodySpan, mon)
  if okS and centre then
    return { r = math.max(5, math.min(16, math.max(girth or 0, half * 0.8))),
             yOff = centre,
             hh = math.max(4, half) }
  end
  local okR, r = pcall(mon.worldRadius, mon)
  local rr = (okR and r and r > 0) and r or h * 0.4
  return { r = math.max(5, math.min(16, math.max(rr, h * 0.5))),
           yOff = h * 0.5,
           hh = math.max(4, h * 0.55) }
end

function Stadium.draw(pull)
  if not session then return end
  for _, side in ipairs({ "enemy", "player" }) do
    local mon = session[side]
    if mon.rig and mon.visible and mon.model_matrix then
      Stadium.guard(side, mon, "draw", function()
        mon.rig:draw(mon.model_matrix, pull)
      end)
    end
  end
end

-- The same models as the SUN sees them, so a Pokemon throws the shadow of
-- the pose it is actually in -- an outstretched wing puts an outstretched
-- wing on the ground.
function Stadium.cast(shadowMap)
  if not session then return end
  for _, side in ipairs({ "enemy", "player" }) do
    local mon = session[side]
    if mon.rig and mon.visible and mon.model_matrix then
      Stadium.guard(side, mon, "cast", function()
        mon.rig:caster(shadowMap, mon.model_matrix)
      end)
    end
  end
end

-- Which state a side's model is playing, or nil. Named for the shot drivers:
-- checking that an animation starts on the right FRAME is an ordering
-- question, and a screenshot cannot answer one.
function Stadium.animOf(side)
  if not session then return nil end
  local mon = session[side]
  return mon and mon.state or nil
end

-- Whether this side's model is actually being drawn this frame. Named for
-- the shot drivers alongside animOf: "how long does it stay" is a span, and
-- a screenshot taken at one moment has no span in it.
function Stadium.showing(side)
  if not session then return false end
  local mon = session[side]
  return (mon and mon.visible) and true or false
end

-- How big this side's model is being drawn this frame, 0..1 -- the send-out
-- grow. Named for the shot drivers: a ramp is a curve over time and a
-- screenshot has one point of it.
function Stadium.scaleOf(side)
  if not session then return nil end
  local mon = session[side]
  return mon and mon.scale or nil
end

-- How wide the Pokemon on `side` stands, in world pixels, or nil when there
-- is not one. What STADIUM B sizes that side's platform to (StadiumStage).
function Stadium.footprint(side)
  if not session then return nil end
  local mon = session[side]
  if not (mon and mon.model) then return nil end
  local r = mon:worldRadius()
  return (r > 0) and r or nil
end

-- Whether anything at all is standing this frame -- what the shadow
-- signature keys on alongside the pics' own token.
function Stadium.standing()
  if not session then return false end
  return (session.player.visible or session.enemy.visible) and true or false
end

-- ------- what the fight asks for
--
-- The animation state machine is driven from four points in the engine's
-- own battle, and each is a wrap rather than a rewrite: the inner function
-- runs exactly as it always did and this reads what went past.

local function sideOf(battle, battler)
  if not (session and battler) then return nil end
  if battler == battle.player then return "player" end
  if battler == battle.enemy then return "enemy" end
  return nil
end

local function ask(battle, battler, state, animIndex, auxIndex)
  local side = sideOf(battle, battler)
  if not side then return end
  local mon = session[side]
  if mon and mon.rig then mon:request(state, animIndex, auxIndex) end
end

function Stadium.install()
  local BattleState = require("src.battle.BattleState")
  if BattleState.dramaticShapeStadiumHook then return end
  BattleState.dramaticShapeStadiumHook = true

  -- THE ATTACK. performMove is the one place a move is actually used, and
  -- the move's own `index` is the Gen 1 move id the Stadium tables are
  -- keyed by -- so the species' own animation for that move comes straight
  -- out of the pack, with no name mapping and no per-move code.
  local innerMove = BattleState.performMove
  function BattleState:performMove(user, target, moveInst, isCalled)
    if session then
      local side = sideOf(self, user)
      local mon = side and session[side]
      if mon and mon.rig then
        local okDef, def = pcall(self.moveDef, self, moveInst)
        local index = okDef and def and def.index or nil
        if not (index and mon:attack(index)) then
          -- a move the table has nothing for still swings: the generic
          -- attack is what the species' own reaction slot resolves to
          mon:request("attack")
        end
      end
    end
    return innerMove(self, user, target, moveInst, isCalled)
  end

  -- THE HIT is deliberately NOT hooked. There is no damage reaction in this
  -- set to play -- what looked like one is the species' default attack (see
  -- StadiumMon's STATES), which is why being hit used to look like swinging.
  -- The engine's own flash, pic blink and bar drain are what say "that hurt",
  -- and they are already in the frame.

  -- THE FAINT. Held on its last frame rather than looped (see StadiumMon's
  -- STATES), because a Pokemon that collapses and then stands back up
  -- while the message is still on screen is worse than no animation.
  --
  -- RECORDED HERE, PLAYED LATER. This runs the moment HP reaches zero, which
  -- is several seconds before the Pokemon is supposed to fall over -- the
  -- engine queues the collapse behind the move animation and the HP-bar
  -- drain. Marking the side and letting Stadium.update fire it when the bar
  -- empties is what keeps the two together (see faintReady).
  local innerFaint = BattleState.onFaint
  function BattleState:onFaint(battler)
    if session and not (battler and battler.faintQueued) then
      local side = sideOf(self, battler)
      if side and session.faintPending then
        session.faintPending[side] = true
      end
    end
    return innerFaint(self, battler)
  end

  -- THE ENTRANCE. startGrowIn is the send-out: the ball opens, the pic
  -- scales up over twelve frames, and the model plays the animation the
  -- battle system's own entrance slot names.
  local innerGrow = BattleState.startGrowIn
  function BattleState:startGrowIn(battler)
    if session then
      -- unless the model is already on its way out of the ball, in which
      -- case the entrance started with the POOF (see update) and asking
      -- again here would restart it a third of a second in
      local side = sideOf(self, battler)
      local mon = side and session[side]
      if not (mon and mon.grow) then ask(self, battler, "entrance") end
    end
    return innerGrow(self, battler)
  end

  -- TRANSFORM. The engine records a transform by swapping the battler's
  -- sprite and nothing else, so this is the only seam that reports one --
  -- and it reports the side, which is all that is needed to point that
  -- side's model at the copied species. Cleared when a side's own species
  -- changes under it (a switch, or the next battle).
  local innerSpecies = BattleState.speciesSprite
  function BattleState:speciesSprite(species, isPlayerSide)
    if session then
      session.transform[isPlayerSide and "player" or "enemy"] = dexOf(species)
    end
    return innerSpecies(self, species, isPlayerSide)
  end

  -- and a switch or a send-out ends any transform on that side
  local innerSwitch = BattleState.resolveSwitch
  function BattleState:resolveSwitch(newMon)
    if session then session.transform.player = nil end
    return innerSwitch(self, newMon)
  end
end

-- ------- when a draw goes wrong
--
-- The draw and the shadow cast are both called through a pcall, because a
-- throw inside the scene pass would hand the whole voxel mode to Pipelines'
-- guard and retire it for the session. Swallowed silently, though, a broken
-- model is indistinguishable from an invisible one -- so the first failure
-- of a battle says so, once, and the rest of the fight carries on without
-- it.
Stadium.reported = false

function Stadium.report(err)
  if Stadium.reported then return end
  Stadium.reported = true
  V.mod.log:warn("stadium: a model failed and was retired for this battle: "
                 .. "%s -- that Pokemon falls back to its flat battle pic, "
                 .. "and its opponent is unaffected", tostring(err))
end

-- DS_STADIUM_DEBUG=1 prints what each side resolved to once a second, which
-- is how "nothing is on screen" gets told apart from "nothing was asked
-- for". Read through pcall: the loader's sandbox does not hand a mod `os`,
-- and a diagnostic must never be why the mod fails to load.
local DEBUG = select(2, pcall(function() return os.getenv("DS_STADIUM_DEBUG") end))
if DEBUG == nil or DEBUG == false then DEBUG = nil end

local debugAt = 0

function Stadium.debug(dt)
  if not (DEBUG and session) then return end
  debugAt = debugAt + (dt or 0)
  if debugAt < 1 then return end
  debugAt = 0
  for _, side in ipairs({ "enemy", "player" }) do
    local mon = session[side]
    local m = mon.model_matrix
    V.mod.log:info("stadium %s: dex=%s rig=%s visible=%s anim=%s t=%.2f "
                   .. "height=%.1f at=%s",
                   side, tostring(mon.species), tostring(mon.rig ~= nil),
                   tostring(mon.visible), tostring(mon.anim), mon.time or 0,
                   mon.model and mon:worldHeight() or 0,
                   m and ("%.0f,%.0f,%.0f"):format(m[4], m[8], m[12]) or "-")
  end
end

function Stadium.invalidate()
  if session then
    session.player:release()
    session.enemy:release()
  end
  StadiumPack.invalidate()
  -- the discs are a mesh and a texture like anything else, and a graphics
  -- context that went away took them with it
  pcall(function() V.require("StadiumStage").invalidate() end)
end

return Stadium