-- 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 -- ------- 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. local STATES = { idle = { slot = "idle", loop = true }, entrance = { slot = "entrance", loop = false, next = "idle" }, hit = { slot = "hit", loop = false, next = "idle" }, flinch = { slot = "flinch", loop = false, next = "idle", fallback = "hit" }, faint = { slot = "faint", loop = false, hold = true }, -- an attack names its animation outright (the move table decides), so it -- has no slot of its own attack = { loop = false, next = "idle" }, } function StadiumMon.new(side) return setmetatable({ side = side, -- "player" or "enemy" species = nil, -- the dex number currently modelled 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 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. -- -- ------- the three the extraction cannot read -- -- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that -- throw bones hundreds of units off the body -- the game's own index -- arithmetic evidently reads those channel streams differently from the way -- this does. StadiumBuild.idleIsBroken measures it and the pack carries the -- verdict as `staticPose`. -- -- That flag used to mean "stand this one still in its bind pose", on the -- reasoning that a Pokemon standing there looking like itself beats one -- coming apart. It does -- but only just. A bind pose is a RIGGING pose, not -- a portrait: arms out, neck straight, nothing where the artwork put it. Set -- among a hundred and forty-eight species that breathe, the three that hold -- a T-pose for the whole fight do not read as "these ones do not animate", -- they read as broken -- which they are. -- -- So they decline instead, and the Game Boy's own battle sprite stands on -- the tile in front of the same arena, lit the same way, drawn by the same -- 2D-3D path every species uses when its model is unavailable. The mode -- already had that fallback for a species with no pack at all; this is three -- more species taking it. -- -- Keyed on the DATA rather than on a list of dex numbers on purpose: the -- test that produced the flag is in the packer, so a re-extraction that -- fixes those streams -- or breaks a fourth species -- moves this with it -- and nothing here has to be edited. function StadiumMon:setSpecies(dex) if dex == self.species then return self.rig ~= nil end if self.rig then self.rig:release() end self.rig, self.model, self.species = nil, nil, dex if not dex then return false end local model = StadiumPack.load(dex) if not model then return false end 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 a hit reaction landing on top of an attack the Pokemon is -- halfway through reads as the attack being cancelled -- which, on the -- receiving end of a two-hit turn, it is not. local RANK = { idle = 0, entrance = 1, attack = 2, hit = 3, flinch = 3, faint = 4 } 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: a second hit in a turn should play -- the flinch 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) 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 -- 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 -- 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) 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