-- 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 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. -- -- ------- 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. 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 self.grow, self.grewOwn = nil, nil 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 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