Files
DramaticShapeVoxelMod/lib/StadiumMon.lua
T
2026-08-04 10:43:35 -04:00

351 lines
15 KiB
Lua

-- 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, or
-- one whose meshes would not build, 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.
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
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
-- A species whose animations are corrupt at source stands in its BIND
-- pose and does not move. The state machine still runs -- the fight is
-- still asking for a hit or a faint, and something may want to know --
-- but nothing is ever sampled, so the Pokemon simply stands there
-- looking like itself, which is the one thing the broken data cannot do.
if model.staticPose then self.anim = nil end
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)
else
local nextState = (STATES[self.state] or {}).next or "idle"
self:play(nextState)
end
end
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 for a static species, 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