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

StadiumMon.lua

lib/StadiumMon.lua24.7 KB100644d5de5b733a
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
-- STADIUM battles: one Pokemon, standing on its tile.
--
-- The side's live state -- which species is out, the rig posing it, which
-- animation the fight has asked for and how far through it is, and the
-- matrix that puts it on its cell at the right size facing the right way.
-- Stadium owns the pair of these; StadiumRig owns the arithmetic.
--
-- ------- how big a Pokemon is
--
-- The one genuinely invented number in this mode, and it is worth saying
-- why it is invented rather than measured.
--
-- The flat 2D-3D mode has an exact answer: a full-size 56-pixel pic covers
-- one 16-pixel overworld square, so a canvas pixel is a fixed number of
-- world pixels and every species comes out at whatever its own artwork's
-- size implies (see BattleBillboard.FULL_W). The camera is then SOLVED to
-- make one square that big on screen (BattleCam).
--
-- The Stadium models have no such anchor. Their units are the N64's, they
-- run from Caterpie at 9 units to Gyarados at 147 -- a sixteenfold spread,
-- where the Gen 1 pics span barely one and a half -- and the game they come
-- from framed each one with its own camera, which a fight staged on the
-- overworld cannot do because the two mons share a shot.
--
-- Taken literally, that spread puts Caterpie at a couple of pixels on a
-- 144-pixel screen while Gyarados leaves the frame. So the range is
-- COMPRESSED rather than either honoured or discarded: a species is drawn
-- at REF_HEIGHT world pixels scaled by its own height over the set's
-- median, raised to SQUASH. At 1 that would be the raw sixteenfold spread;
-- at 0 every Pokemon would be the same size; at 0.55 the order and the
-- feel of the differences survive -- Onix and Gyarados tower, Diglett and
-- Caterpie are small enough to have to look for -- inside a range a shared
-- frame can hold.
--
-- ------- and where its feet are
--
-- The pack measures each model's lowest point against its own origin
-- (tools/stadium_pack.py's `stance`), and the answer splits the set in
-- three. 119 species sit within 5% of zero: the origin IS the floor, and
-- the game stood them on its field with it. A handful sit ABOVE it --
-- Zubat, Magnemite, Geodude -- which is a hover the model is authored with.
-- The rest hang BELOW it -- Tentacruel, Gastly, Haunter, Weezing, Zapdos --
-- which is a model centred on its origin rather than standing on it.
--
-- So a model is stood on its own lowest point, and then given back as much
-- of its authored hover as the shot can hold -- HOVER_CAP of its own height,
-- no more. The middle group is unaffected either way, which is the check
-- that the rule is reading the data rather than correcting it.
--
-- The cap is not tidiness. Stadium framed one Pokemon per camera and could
-- afford to hang Zubat three body-heights off the floor; this shot has the
-- foe's feet on GB row 56 of 144, so the same hover puts Zubat off the top
-- of the frame entirely -- which is exactly what it did before the cap. The
-- flat 2D-3D mode has the same constraint and answers it by bottom-aligning
-- every pic, hovering species included; this keeps the hover but spends
-- only the room there is.

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

local Mat4 = V.require("Mat4")
local StadiumPack = V.require("StadiumPack")
local StadiumRig = V.require("StadiumRig")

local StadiumMon = {}
StadiumMon.__index = StadiumMon

-- How tall a median Pokemon stands, in world pixels.
--
-- Not picked by eye: it is what the FLAT mode already puts on those cells.
-- A full-size Gen 1 pic is 56 pixels for the foe and 64 for the player's
-- own, drawn with its feet on GB rows 56 and 96 of a 144-row frame -- so a
-- full-size mon covers 39% of the frame at the far cell and 44% at the near
-- one. Against the lens BattleCam solves (about 38 world pixels of frame at
-- the far cell, 30 at the near one, because the near one is closer) both of
-- those work out at roughly fourteen world pixels.
--
-- So this is the number that makes a median Stadium model exactly as big as
-- the artwork it replaces, which is what keeps the composition the camera
-- was solved for.
StadiumMon.REF_HEIGHT = 14

-- The set's own median bind height, in game units (tools/stadium_pack.py
-- --report prints it). Only ever a reference point for the ratio above, so
-- a re-extraction that moved it slightly changes nothing but the middle of
-- the ladder.
StadiumMon.MEDIAN = 52.25

-- How much of the raw size spread survives. See the header.
StadiumMon.SQUASH = 0.5

-- And hard stops either end, because a compression is not a guarantee. The
-- ceiling is what keeps Onix and Gyarados inside a frame whose top edge is
-- only 56 GB rows above the foe's own feet: past about this they stop being
-- imposing and start being cropped.
StadiumMon.MIN_HEIGHT = 5
StadiumMon.MAX_HEIGHT = 18

-- How much of an authored hover survives, as a fraction of the Pokemon's
-- own height. See the header: Stadium could hang a flier three body-heights
-- up because it framed one Pokemon at a time.
StadiumMon.HOVER_CAP = 0.5

-- The animation clock. Every animation in the set is authored at 30 fps
-- (model_extract/README.md), and the eyes run on their own counter at the
-- same rate.
StadiumMon.FPS = StadiumPack.FPS

-- ------- coming out of the ball
--
-- The engine grows its flat pic in the Game Boy's own three steps -- 0, then
-- 3/7, then 5/7, then full -- across the twelve frames after the ball opens
-- (BattleState.growInScale). Two things about that do not carry to a model.
--
-- It is three steps, which on a 56-pixel sprite is a chunky pop and on a
-- smooth 3D model is just a pop. And it starts AFTER the ball: measured, the
-- poof animation runs for 27 frames and `startGrowIn` fires on the frame
-- after it ends, so the Pokemon does not begin to exist until the ball has
-- finished opening -- which reads as the ball opening and then a Pokemon
-- being switched on beside it.
--
-- So the model runs its own ramp, started when the POOF begins rather than
-- when it ends, and continuous rather than stepped: it grows out of nothing
-- while the ball is opening and reaches full size as the engine's own grow
-- finishes. GROW_TIME is measured off that -- 27 frames of poof plus the
-- engine's 12 of grow is 39, which is this.
StadiumMon.GROW_TIME = 0.65

-- How far an animation may carry the Pokemon off its tile, in the Pokemon's
-- own body-heights, before the excess is taken back out (StadiumRig.anchor).
--
-- Measured against the frame rather than chosen by eye. A mon is drawn
-- REF_HEIGHT world pixels tall and the GB frame holds about 38 world pixels
-- at the far cell, with the foe's feet on row 56 of 144 -- so there is
-- roughly one body-height of room above it and about one and a half either
-- side. Three quarters of a height keeps every part of a travelling Pokemon
-- inside that with a margin, and leaves the 83 species that never reach it
-- untouched.
StadiumMon.TRAVEL = 0.75

-- ------- the animation the fight is asking for
--
-- Each entry says which context slot to look up, whether it loops, and
-- what it falls back to when the species has no animation in that slot.
-- ------- there is no hit reaction, and there never was
--
-- This used to carry `hit` and `flinch` states, played when damage landed,
-- resolving through context slots 166 and 178. Both were wrong, and the data
-- says so plainly once the move table is read alongside them:
--
--   Bulbasaur's slot 166 is a 95-frame animation that 66 of its moves play.
--   Pidgey's is 138 frames -- four and a half seconds -- and 111 of its moves
--   play it. Slot 178, and 173, 179, 180 and 181, all point at the same one.
--
-- A four-and-a-half-second animation that most of the move table uses is the
-- species' DEFAULT ATTACK, not a flinch, which is why being hit looked like
-- swinging: it literally was the swing.
--
-- Nor is the reaction hiding elsewhere. Exactly one animation per species is
-- claimed by no slot and no move, and it is the same length as the idle for
-- essentially every one of them -- 48/48, 56/56, 60/60, 84/84 -- so it is a
-- second standby loop, not a recoil. The set has no damage reaction in it.
--
-- So damage plays nothing, and the Pokemon carries on with what it was doing.
-- That is not a gap: the engine flashes the screen, blinks the pic and drains
-- the bar, which is how Gen 1 says "that hurt" and is already in the frame.
local STATES = {
  idle = { slot = "idle", loop = true },
  entrance = { slot = "entrance", loop = false, next = "idle" },
  faint = { slot = "faint", loop = false, hold = true },
  -- A move names its own animation out of the move table. `attack_default`
  -- is the fallback for one the table has nothing for -- which is what slot
  -- 166 actually is, so the generic swing is now a real swing rather than
  -- the standby loop it used to resolve to.
  attack = { slot = "attack_default", loop = false, next = "idle" },
}

function StadiumMon.new(side)
  return setmetatable({
    side = side,               -- "player" or "enemy"
    species = nil,             -- the dex number currently modelled
    shiny = false,             -- and whether it is the recoloured variant
    model = nil,
    rig = nil,
    state = "idle",
    anim = nil,                -- index into model.anims
    time = 0,                  -- seconds into it
    loop = true,
    hold = false,
    aux = nil,                 -- the texture animation running alongside
    visible = false,
    scale = 1,                 -- the send-out grow, 1 the rest of the time
  }, StadiumMon)
end

function StadiumMon:release()
  if self.rig then self.rig:release() end
  self.rig, self.model, self.species = nil, nil, nil
  -- cleared with the species: a stale true here would make the next
  -- setSpecies believe a shiny model was already loaded and early-return
  self.shiny = false
end

-- ------- which species this side is showing
--
-- Returns true when the model is ready to draw. A species with no pack, one
-- whose meshes would not build, or one whose animation data is corrupt at
-- source answers false -- and Stadium then leaves that side to the flat
-- card, which is a per-POKEMON decline rather than a per-battle one: a fight
-- can perfectly well have a model on one side and a pic on the other.
--
-- ------- staticPose: the corrupt-idle escape hatch
--
-- StadiumBuild.idleIsBroken measures whether a species' standby loop throws
-- bones off the body, and the pack carries the verdict as `staticPose`. A
-- species so marked DECLINES here -- the Game Boy's own battle sprite
-- stands on the tile instead, drawn by the same 2D-3D path every species
-- uses when its model is unavailable -- because a bind pose held for a
-- whole fight reads as broken, not as "this one does not animate".
--
-- No species is marked today. Exeggutor, Tangela and Magmar used to be:
-- their animations are hermite keyframes (flags & 8), the extractor misread
-- the flags byte and decoded them as packed streams, and the exploding
-- result tripped the detector (Pidgeot and Dodrio were garbled by the same
-- bug, just not hard enough to trip it). The detector stays, keyed on the
-- DATA rather than a list of dex numbers, so a future extraction bug that
-- corrupts a species' idle falls back to the sprite instead of coming
-- apart on the field -- and nothing here has to be edited when it does.
--
-- `shiny` is part of the IDENTITY, not a flag applied afterwards. The early
-- return below is keyed on it for that reason: a shiny Rattata and an
-- ordinary one share a dex number but are different models, loaded from
-- different packs, and comparing on the dex alone would keep whichever
-- loaded first and colour both sides with it. That is precisely the shape
-- of bug the two-Rattata note above describes, and it is silent -- the
-- model is valid, it is simply the wrong one.
function StadiumMon:setSpecies(dex, shiny)
  shiny = shiny and true or false
  if dex == self.species and shiny == (self.shiny or false) then
    return self.rig ~= nil
  end
  if self.rig then self.rig:release() end
  self.rig, self.model, self.species = nil, nil, dex
  self.shiny = shiny
  self.grow, self.grewOwn = nil, nil
  if not dex then return false end
  local model = StadiumPack.load(dex, shiny)
  if not model then return false end
  -- the pack falls back to the normal model when a species has no shiny
  -- variant, so believe the model rather than the request
  self.shiny = model.shiny and true or false
  if model.staticPose then return false end
  local rig = StadiumRig.new(model)
  if not rig then return false end
  self.model, self.rig = model, rig
  -- a new Pokemon on the field opens on its standby loop; whoever sent it
  -- out asks for the entrance a moment later
  self.state, self.anim, self.time = nil, nil, 0
  self:play("idle")
  return true
end

-- ------- the state machine

-- Which animation a context slot resolves to for this species, or nil.
function StadiumMon:slotAnim(name)
  local model = self.model
  local slot = model and StadiumPack.SLOT[name]
  if not slot then return nil end
  local index = model.ctx[slot]
  if not index or index == StadiumPack.NONE then return nil end
  return index + 1
end

-- Start a state. `animIndex` overrides the state's own slot lookup, which
-- is what an attack uses.
function StadiumMon:play(state, animIndex, auxIndex)
  local model = self.model
  if not model then return false end
  local def = STATES[state] or STATES.idle
  local index = animIndex
  if not index and def.slot then index = self:slotAnim(def.slot) end
  if not index and def.fallback then index = self:slotAnim(def.fallback) end
  if not index then
    -- the species has nothing for this; the standby loop is always there
    if state == "idle" then index = 1 else return self:play("idle") end
  end
  local anim = model.anims[index]
  if not anim then return false end

  self.state, self.anim, self.time = state, index, 0
  self.done = false
  -- (a species whose animations are corrupt at source never gets this far:
  -- setSpecies declines it outright and its flat pic stands instead)
  self.loop = def.loop and true or false
  self.hold = def.hold and true or false
  -- The eyes that go with it. Every skeletal animation carries the texture
  -- animation the battle table most often set alongside it (the pack's own
  -- `aux`), and a move may name a different one -- a hit that leaves the
  -- Pokemon confused swaps the open eye for the dizzy swirl.
  self.aux = auxIndex or anim.aux
  return true
end

-- Ask for a state, but never interrupt one that outranks it. A faint is
-- final, and an entrance cannot be cut short by the standby loop it hands
-- on to.
local RANK = { idle = 0, entrance = 1, attack = 2, faint = 3 }

function StadiumMon:request(state, animIndex, auxIndex)
  if not self.model then return false end
  local now = RANK[self.state] or 0
  local want = RANK[state] or 0
  if self.state == "faint" then return false end
  -- an equal-ranked request RESTARTS: the second move of a two-hit turn
  -- should swing again rather than be swallowed by the first
  if want < now then return false end
  return self:play(state, animIndex, auxIndex)
end

-- The animation a move plays for this species, from the battle system's own
-- per-species table (model_extract's moves.json, packed into the .dsm).
-- `moveIndex` is the Gen 1 move id, which the engine's move defs carry as
-- `index` -- the same numbering, so no name mapping is needed.
function StadiumMon:attack(moveIndex)
  local model = self.model
  if not (model and moveIndex and moveIndex >= 1
          and moveIndex <= StadiumPack.N_MOVES) then
    return false
  end
  local index = model.moveAnim[moveIndex]
  if not index or index == StadiumPack.NONE then return false end
  local aux = model.moveAux[moveIndex]
  return self:request("attack", index + 1,
                      (aux and aux >= 0) and (aux + 1) or nil)
end

-- ------- per frame

function StadiumMon:update(dt)
  -- kept for build(), which runs later in the same frame and needs it to
  -- advance the anchor's filter (StadiumRig.anchor). Stashed before the
  -- early-outs below, so a species with nothing to play still has one.
  self.dt = dt or 0
  -- the ball-to-full-size ramp, which runs whether or not there is an
  -- animation to play alongside it
  if self.grow then
    self.grow = self.grow + (dt or 0) / StadiumMon.GROW_TIME
    if self.grow >= 1 then self.grow = nil end
  end
  local model = self.model
  if not (model and self.anim) then return end
  local anim = model.anims[self.anim]
  if not anim then return end
  self.time = self.time + (dt or 0)
  if self.time >= anim.seconds and not self.loop then
    if self.hold then
      -- a faint stays down: hold the last frame rather than snapping back
      -- to a standing pose the moment the animation runs out
      self.time = math.max(0, anim.seconds - 1 / StadiumMon.FPS)
      -- and SAY so, once. The clamp above means the clock can no longer be
      -- asked whether the animation is over -- it stops a frame short of the
      -- end and stays there forever -- and something has to know, because a
      -- collapse that has finished is the moment the Pokemon may leave the
      -- field (see Stadium's onField).
      self.done = true
    else
      local nextState = (STATES[self.state] or {}).next or "idle"
      self:play(nextState)
    end
  end
end

-- ------- the grow
--
-- Begin coming out of the ball. Answers whether it actually started, so the
-- caller can play the entrance alongside it and the engine's own send-out
-- seam a moment later does not restart what is already running.
function StadiumMon:beginGrow()
  if self.grow or not self.model then return false end
  self.grow = 0
  -- and remember that THIS arrival was ours to size, so the engine's own
  -- three-step ramp is not consulted again for it. Ours starts earlier and
  -- finishes a few frames sooner, and in that gap the engine's ramp still
  -- reads 5/7 -- so falling back to it shrank the Pokemon from 0.96 back to
  -- 0.71 and then snapped it to full, a visible hitch at the end of an
  -- animation that exists to not have one.
  self.grewOwn = true
  return true
end

-- How big this Pokemon is drawn this frame, as a fraction of its real size.
--
-- Smoothstep rather than a straight ramp or an ease-out: the ball is opening
-- for the first half of this, so a curve that is already near full size by
-- then would have the Pokemon standing there while the ball is still coming
-- apart. Slow, then quick through the middle, then settling exactly as the
-- engine's own grow ends.
function StadiumMon:growScale()
  local t = self.grow
  if not t then return 1 end
  if t <= 0 then return 0 end
  if t >= 1 then return 1 end
  return t * t * (3 - 2 * t)
end

-- Whether a HELD animation -- which in practice means a faint -- has played
-- all the way through and is now sitting on its last frame. Always false for
-- a looping one, which never finishes, and for one that hands on to another
-- state, which has already stopped being itself by the time anyone can ask.
function StadiumMon:finished()
  return self.done and true or false
end

-- How tall this species stands on the map, in world pixels.
function StadiumMon:worldHeight()
  local model = self.model
  local h = model and model.height or 0
  if not (h > 0) then return StadiumMon.REF_HEIGHT end
  local k = (h / StadiumMon.MEDIAN) ^ StadiumMon.SQUASH
  local out = StadiumMon.REF_HEIGHT * k
  if out < StadiumMon.MIN_HEIGHT then out = StadiumMon.MIN_HEIGHT end
  if out > StadiumMon.MAX_HEIGHT then out = StadiumMon.MAX_HEIGHT end
  return out
end

-- How wide this Pokemon stands, in world pixels -- the same scale
-- worldHeight is in, so a caller can size something to its footprint.
--
-- Only STADIUM B asks: it needs to know how big a platform to put under a
-- mon, and "as tall as it is" is the wrong answer for a Snorlax, which is
-- half as tall as an Onix and three times as wide.
--
-- The send-out grow is deliberately NOT folded in. A Pokemon scaling up out
-- of its ball should arrive on a platform that was already there, not one
-- that inflates under its feet.
function StadiumMon:worldRadius()
  local model = self.model
  if not model then return 0 end
  local h = model.height or 0
  if not (h > 0) then return 0 end
  return (model.radius or 0) * self:worldHeight() / h
end

-- The model matrix: stand this Pokemon on world (x, groundY, z) facing
-- (faceX, faceZ), at whatever the send-out grow has done to its size.
--
-- The vertices the rig writes are in the model's RAW units -- before the
-- model_root scale the game applies -- so the scale here carries that too,
-- and the floor offset is measured in the same raw units on the way in.
function StadiumMon:matrix(x, groundY, z, faceX, faceZ)
  local model = self.model
  if not model then return nil end
  local root = model.rootScale
  if not (root and root > 0) then root = 1 end
  local k = root * self:worldHeight() / math.max(model.height, 1e-6)
  k = k * (self.scale or 1)
  -- stand it on its own lowest point, then give back as much of the
  -- authored hover as the shot can hold (see the header)
  local floor = model.floor or 0
  local hover = math.min(math.max(floor, 0),
                         StadiumMon.HOVER_CAP * math.max(model.height, 0))
  local lift = (floor - hover) / root
  local yaw = 0
  if faceX and faceZ and (faceX ~= 0 or faceZ ~= 0) then
    -- the card and the model share this convention: an unrotated model
    -- faces +Z, which is map SOUTH, which is what "facing down" is in the
    -- flat game (see Voxel3D's axis note)
    yaw = math.atan2(faceX, faceZ)
  end
  self.yaw = yaw
  return Mat4.mul(
    Mat4.mul(Mat4.mul(Mat4.translate(x, groundY, z), Mat4.rotateY(yaw)),
             Mat4.scale(k, k, k)),
    Mat4.translate(0, -lift, 0))
end

-- How far this Pokemon's LOWEST rendered point stands above the ground it
-- is placed on, in world pixels -- the authored hover the matrix above
-- gives back, actually applied.
--
-- Derived by repeating that matrix's own arithmetic rather than by
-- re-deriving it in closed form: root scale, the model's floor and the
-- hover cap interact in a way that is easy to get subtly wrong, and a
-- caller that guessed would place things at the feet of a Pokemon that is
-- flying. Which is exactly what a Pidgey does -- it renders a good third
-- of its own height clear of its tile, and anything aimed at its cell
-- mark lands under it.
function StadiumMon:groundGap()
  local centre, half = self:bodySpan()
  if centre then return math.max(0, centre - half) end
  return 0
end

-- Where this Pokemon's body actually SITS above the ground it is placed
-- on, and how big it is: the centre height, the half height and the
-- girth, all in world pixels.
--
-- Measured off the POSED vertices (StadiumRig:posedBounds) and put
-- through this matrix's own scale and lift, so the answer is the shape
-- the camera is about to see. That matters most for the species it is
-- hardest to guess about: a Pidgey's standby animation flies it well
-- clear of its tile, and anything aimed at its cell mark -- a capture
-- ring, a thrown ball's collision -- lands under an empty patch of grass
-- while the bird hovers above it. Nothing static says so; only the pose
-- does.
--
-- nil before the first skin(), or with no rig: the caller falls back to
-- the bind-pose height, which is right for everything that stands.
function StadiumMon:bodySpan()
  local model, rig = self.model, self.rig
  if not (model and rig and rig.posedBounds) then return nil end
  local okB, lo, hi, girth = pcall(rig.posedBounds, rig)
  if not (okB and lo) then return nil end
  local root = model.rootScale
  if not (root and root > 0) then root = 1 end
  local k = root * self:worldHeight() / math.max(model.height, 1e-6)
  k = k * (self.scale or 1)
  local floor = model.floor or 0
  local hover = math.min(math.max(floor, 0),
                         StadiumMon.HOVER_CAP * math.max(model.height, 0))
  local lift = (floor - hover) / root
  -- the same map the model matrix applies: world = k * (posed - lift)
  return k * ((lo + hi) * 0.5 - lift), k * (hi - lo) * 0.5, k * (girth or 0)
end

-- Pose and skin for this frame. Separate from the draw because both the
-- SUN and the camera -- and, in a headset, both eyes -- want the same
-- skinned mesh, and skinning it once is the whole reason this is worth
-- doing on the CPU.
function StadiumMon:build()
  if not (self.rig and self.model) then return false end
  -- self.anim is nil while a species has nothing to play, and pose() reads
  -- that as "the bind pose", which is exactly what is wanted
  self.rig:pose(self.anim, self.time * StadiumMon.FPS, self.loop)
  -- and then back onto the tile, because these animations were authored for
  -- a camera that followed the Pokemon and this one does not move (see
  -- StadiumRig.anchor)
  self.rig:anchor(StadiumMon.TRAVEL, self.dt)
  self.rig:skin(self.yaw or 0)
  -- no clock of its own: the texture animation rides the frame pose() just
  -- resolved, which is what keeps a blink inside its standby loop and a
  -- fainted Pokemon's eyes shut once it has stopped moving
  self.rig:textures(self.aux)
  return true
end

return StadiumMon