mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 11:00:51 +02:00
a3fb18a589
Gen 1 has no shininess of its own, but it has the four DVs Gen 2 reads to decide it -- and the engine already ships that reading (Stats.isShiny, its own comment calling it "the RBY virtual shiny", allowlisted for mods precisely so an indicator mod can call it). Nothing new is stored on a Pokemon and nothing migrates: every save already contains the answer, and this starts drawing it. Random DVs land on the pattern 1 in 8192, which is the classic rate and the default the odds dial ships at. Deriving rather than storing is what makes it survive a save, a box, a trade and an evolution with no second copy of the truth to drift. mon.shiny is a cache written from the DVs, never read as the source. The roll goes in Pokemon.new -- every wild, gift, starter and traded mon is built there, and it is before the battle bakes its sprite, which battle.started is already too late for. It draws from the mod's own random stream so installing this does not shift the sequence damage rolls and encounter slots come out of. Trainers stay ordinary by themselves: the engine pins their DVs, as the real games do. The models are genuinely recoloured, as part of the extraction. Each species is decoded once, packed as usual, then recoloured and packed again as NNNs.dsm. The colours are Stadium's own HSL slide (hue in degrees, saturation and lightness on a -8..+8 scale at 12.5% a step); five species carry an explicit colour table instead, because Stadium gives them a real alternate texture that no single slide reproduces -- Jigglypuff's body must stay pink while its irises rotate to green. Extraction is the right moment because StadiumFx's generated frames are still marked there and the packer drops the marker: it is the last point a flame is distinguishable from a hide. A shiny Charizard has a shiny hide and an ordinary fire. The normal packs are written BEFORE the recolour, so they come out byte-identical and stadium_extract_test still diffs all 151 against the Python oracle unchanged -- no format change, no DSM4, no second implementation to keep in step. REV goes to 3 so an existing cache rebuilds. Flat art is tinted instead, because the engine bakes a species palette into a cache with no notion of which individual is drawn. The tint comes from that species' own slide rather than a generic gold. A multiply can only darken, so species whose shiny is lighter read quieter there than on the model; the status page's star is the mode-proof mark. Tests: 58 assertions in tests/shiny_test.lua, including the colour transform against 640 real colour pairs lifted from the verified texture set, the DV model, the read side, and the end-to-end through the engine's own constructor. stadium_extract_test gains --mod (worktrees have neither the ROM nor the packs, both gitignored) and now also checks that every shiny pack is the same length as its twin and actually differs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
799 lines
35 KiB
Lua
799 lines
35 KiB
Lua
-- 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
|
|
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
|