add stadium extraction screen

This commit is contained in:
DramaticShape
2026-08-04 11:30:58 -04:00
parent bddb9de0ba
commit f21b3ee597
16 changed files with 751 additions and 193 deletions
+167 -16
View File
@@ -419,7 +419,7 @@ T.eq(grid.value(), "OFF", "the grid starts off")
T.eq(curve.label, "V-CURVE", "the curve row carries its label")
T.eq(curve.value(), "OFF", "the curve starts off")
T.eq(battles.label, "3D-BTL", "the overworld-battle row carries its label")
T.eq(battles.value(), "2D-3D",
T.eq(battles.value(), "2D-3D A",
"overworld battles are on by default, on the rung that stands the game's "
.. "own pics on the map -- the mode's headline is the world in 3D, and a "
.. "battle is where the player spends half the game")
@@ -1136,18 +1136,25 @@ Game.keypressed(keyGame, "7")
T.neq(Curve.setting:get(), curveBefore, "7 cycles V-CURVE")
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
T.eq(Battles.setting:get(), true, "3D-BTL starts on 2D-3D")
T.eq(Battles.setting:get(), true, "3D-BTL starts on 2D-3D A")
T.eq(Battles.discs(), false, "which stages the fight on the map")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), "flatB",
"8 steps to 2D-3D B -- the same pics, on the discs")
T.eq(Battles.discs(), true,
"and that IS a disc rung, with no Stadium model anywhere in it")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), "stadium",
"8 steps to STADIUM A -- one press off the default is the more elaborate "
.. "staged fight, not the absent one")
"again and it is STADIUM A, the models on the map")
T.eq(Battles.discs(), false, "back on the map")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), "stadiumB",
"again and it is STADIUM B, the discs")
"again and it is STADIUM B, the models on the discs")
T.eq(Battles.discs(), true, "on the discs again")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), false, "again and overworld battles are off")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D")
T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D A")
-- ------- the eyes blink once a loop, not six times a second
--
@@ -1201,6 +1208,101 @@ T.eq(Battles.setting:get(), true, "and the ladder wraps back to 2D-3D")
Pack.image = realImage
end)()
-- ------- the skeleton runs at 60, the textures step at 30
--
-- The animation streams carry one value per frame at 30 Hz, so replayed
-- honestly against a 60 Hz camera every pose holds for two frames and the
-- models visibly stutter against everything around them. StadiumRig blends
-- between consecutive frames instead -- but NOT across a snap, because these
-- are Euler triples and two triples that describe nearly the same rotation
-- can be nowhere near each other component by component. Walking from one to
-- the other is a bone flipping over inside a frame, which is exactly the
-- glitch a naive version of this shipped with.
--
-- Driven on a REAL species, through a rig with no meshes (there is no
-- graphics context here, and pose() only touches the matrix arrays).
;(function()
local lib = run.loader.exports.DRAMATIC_SHAPE.lib
local Pack, Rig = lib.require("StadiumPack"), lib.require("StadiumRig")
local model = Pack.load(25) -- Pikachu
local rig = setmetatable({ model = model, pivotM = {}, drawM = {},
accX = {}, accY = {}, accZ = {}, parts = {} }, Rig)
local slot = model.ctx[Pack.SLOT.idle]
local anim = (slot ~= Pack.NONE) and (slot + 1) or nil
local frames = anim and model.anims[anim].frames or 0
-- every bone's world origin under the pose as it stands
local function origins()
local out = {}
for b = 1, model.boneCount do
local o = (b - 1) * 12
out[b] = { rig.drawM[o + 4], rig.drawM[o + 8], rig.drawM[o + 12] }
end
return out
end
T.check(frames > 8, "Pikachu's standby loop is long enough to sample")
rig:pose(anim, 3, true)
local a = origins()
rig:pose(anim, 4, true)
local c = origins()
rig:pose(anim, 3.5, true)
local b = origins()
T.eq(rig.frameAt, 3,
"half a frame in, the TEXTURE frame is still the whole one -- an eye is "
.. "open or it is shut and there is no halfway swap to draw")
-- the bone that travels furthest between those two frames is the one with
-- something to say about the blend
local best, moved = nil, 0
for i = 1, #a do
local d = ((c[i][1] - a[i][1]) ^ 2 + (c[i][2] - a[i][2]) ^ 2
+ (c[i][3] - a[i][3]) ^ 2) ^ 0.5
if d > moved then best, moved = i, d end
end
T.check(moved > 0, "and some bone actually moves between frames 3 and 4")
-- halfway is HALFWAY: within a twentieth of the step of the midpoint of the
-- two frames it sits between, on every axis. A blend that overshoots, or
-- that walks the long way round a wrapped angle, fails this by miles.
local slack = moved / 20
for axis = 1, 3 do
local mid = (a[best][axis] + c[best][axis]) / 2
T.check(math.abs(b[best][axis] - mid) <= slack,
("the half-frame pose sits between its two frames on axis %d"):format(axis))
end
-- and a whole frame is the frame itself, untouched: the blend has to be
-- exactly nothing at k = 0, or every stepped caller (the blink probe, the
-- oracle diff) is reading a pose the pack does not contain
rig:pose(anim, 4, true)
local again = origins()
T.eq(again[best][1], c[best][1],
"and a whole frame is that frame exactly, with nothing blended into it")
end)()
-- ------- the three species the extraction cannot read stand as PICS
--
-- Exeggutor, Tangela and Magmar come out of the ROM with standby loops that
-- throw bones off the body; the packer measures that and flags them. They
-- used to hold their bind pose for the whole fight, which among a hundred and
-- forty-eight species that breathe reads as broken rather than as still. So
-- they decline the model outright and the Game Boy's own battle pic stands
-- instead -- the same fallback a species with no pack at all takes.
;(function()
local lib = run.loader.exports.DRAMATIC_SHAPE.lib
local Mon = lib.require("StadiumMon")
for _, dex in ipairs({ 103, 114, 126 }) do
local mon = Mon.new("enemy")
T.eq(mon:setSpecies(dex), false,
("dex %d has corrupt animation data, so no model stands for it")
:format(dex))
T.eq(mon.rig, nil, "and there is no rig left behind to draw")
end
end)()
-- ------- the collapse waits for the HP bar
--
-- onFaint fires the moment HP reaches zero, but the engine queues the visible
@@ -1230,32 +1332,81 @@ end)()
T.eq(due({ mon = { hp = 0 } }), false, "and an unqueued battler owes nothing")
end)()
-- ------- and it gets to FINISH
--
-- The engine takes a fainted pic off the field when its slide ends, which is
-- fourteen frames of a 60 Hz clock -- under a quarter of a second. The
-- shortest faint animation in the Stadium set is 49 frames of a 30 Hz one,
-- the median 110 and the longest 230, so held to the pic's window every model
-- was cut off inside the first fifth of its own collapse. It now stays until
-- the animation is done, and not one frame past that.
;(function()
local Stad = run.loader.exports.DRAMATIC_SHAPE.lib.require("Stadium")
local onField = Stad._onField
-- the engine's own guards, mirrored in the shape onField reads them
local battle = { fxHidden = function() return false end,
fxFaintActive = function(_, b) return b.sliding end }
local function mon(state, done)
return { state = state, finished = function() return done end }
end
battle.enemy = { sprite = true }
T.eq(onField(battle, "enemy", mon("idle", false)), true,
"a Pokemon that is standing there is on the field")
battle.enemy = { sprite = true, fainted = true, sliding = true }
T.eq(onField(battle, "enemy", mon("faint", false)), true,
"and one whose pic is still sliding is too, as it always was")
battle.enemy = { sprite = true, fainted = true, sliding = false }
T.eq(onField(battle, "enemy", mon("faint", false)), true,
"the pic has gone but the model has not finished falling -- it STAYS, "
.. "which is the whole of the fix: a quarter-second window was cutting "
.. "off animations that run for one to eight seconds")
T.eq(onField(battle, "enemy", mon("faint", true)), false,
"and the frame its collapse finishes, it goes -- nothing is left lying "
.. "on the field for the rest of the fight")
T.eq(onField(battle, "enemy", mon("idle", false)), false,
"a fainted battler whose model never got as far as the faint goes with "
.. "the pic, exactly as before")
T.eq(onField(battle, "enemy", nil), false,
"and a side with no model at all is not held open by this")
end)()
-- ------- and the two STADIUM rungs are SKIPPED when the models are not there
--
-- The mod ships no Pokemon Stadium data, so on a machine whose owner has not
-- supplied that ROM the row has two stops rather than four. Checked by gating
-- them off by hand rather than by hiding the packs, because what is being
-- tested is the ladder's behaviour and not the installer's.
-- supplied that ROM the row has three stops rather than five. Checked by
-- gating them off by hand rather than by hiding the packs, because what is
-- being tested is the ladder's behaviour and not the installer's.
--
-- 2D-3D B survives that, which is the point of it being its own rung: the
-- discs are generated in Lua and the Pokemon on them are the game's own art,
-- so the disc framing is available to a player who has no Stadium ROM at all.
;(function()
local gate = Battles.setting.gate
Battles.setting:setGate(function(value)
return value ~= "stadium" and value ~= "stadiumB"
end)
T.eq(Battles.setting:rungs(), 2,
"with no models built the 3D-BTL row offers two rungs, not four")
T.eq(Battles.setting:rungs(), 3,
"with no models built the 3D-BTL row offers three rungs, not five")
Battles.setting:setValue(true, Game)
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), false,
"and 8 steps straight past both STADIUM rungs to OFF")
T.eq(Battles.setting:get(), "flatB",
"8 still reaches 2D-3D B, which needs no ROM")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), true, "and back to 2D-3D")
T.eq(Battles.setting:get(), false,
"and the next press steps straight past both STADIUM rungs to OFF")
Game.keypressed(keyGame, "8")
T.eq(Battles.setting:get(), true, "and back to 2D-3D A")
-- a save that CHOSE stadium before the ROM went missing reads as the
-- default, rather than as a mode with nothing behind it -- and the stored
-- value is left alone, so putting the ROM back restores the choice
Battles.setting.index = 2
Battles.setting.index = 3
T.eq(Battles.setting:get(), true,
"a stored STADIUM with no models behind it reads as 2D-3D")
"a stored STADIUM with no models behind it reads as 2D-3D A")
Battles.setting:setGate(gate)
T.eq(Battles.setting:get(), "stadium",
"and comes back the moment the models do")
+23 -6
View File
@@ -44,7 +44,10 @@ return function(game)
{ "ONIX", "DIGLETT" }, -- the tallest against nearly the shortest
{ "ZUBAT", "TENTACRUEL" }, -- hovers above its origin / hangs below it
{ "GASTLY", "CATERPIE" }, -- a floater and the smallest in the set
{ "EXEGGUTOR", "MAGMAR" }, -- both idle animations are wrong at source
-- the two species whose animation data is corrupt at source: BOTH of
-- these must come out as flat battle pics standing on their tiles, not
-- as models (see StadiumMon.setSpecies). Tangela is the third.
{ "EXEGGUTOR", "MAGMAR" },
{ "GYARADOS", "SNORLAX" }, -- the two biggest bodies in the set
}
@@ -93,7 +96,8 @@ return function(game)
-- the rung under test, and the back pic OFF: BACK SPRITES would keep the
-- player's own side on the menu, which is exactly the half of the shot
-- this driver exists to look at
local RUNGS = { cards = true, a = "stadium", b = "stadiumB" }
local RUNGS = { cards = true, cardsb = "flatB", a = "stadium",
b = "stadiumB" }
local rung = RUNGS[os.getenv("DS_RUNG") or "a"] or "stadium"
Battles.setting:setValue(rung, game)
Battles.backSetting:setValue(false, game)
@@ -205,8 +209,8 @@ return function(game)
U.wait(12)
U.tap(game, "a")
local barZeroAt, animAt, shot = nil, nil, false
for f = 1, 400 do
local barZeroAt, animAt, shot, stood = nil, nil, false, 0
for f = 1, 700 do
-- keep advancing the text, as a player would. Without this the queue
-- blocks on the damage message and the HP bar never drains at all --
-- which is a perfectly good demonstration that the collapse now waits,
@@ -230,8 +234,18 @@ return function(game)
U.shot(game, ("%s/faint_1_draining.png"):format(DIR))
shot = true
end
if animAt and f > animAt + 20 then
U.shot(game, ("%s/faint_2_collapsed.png"):format(DIR))
-- and how long the model then STAYS. The engine takes the flat pic off
-- after a fourteen-frame slide; the animation runs for one to eight
-- seconds, and being cut off mid-fall is worse than not falling at all.
if animAt and Stadium2.showing("enemy") then stood = f - animAt end
if animAt and f == animAt + 20 then
U.shot(game, ("%s/faint_2_collapsing.png"):format(DIR))
end
if animAt and f == animAt + 90 then
U.shot(game, ("%s/faint_3_still_falling.png"):format(DIR))
end
if animAt and f > animAt + 20 and not Stadium2.showing("enemy") then
U.shot(game, ("%s/faint_4_gone.png"):format(DIR))
break
end
end
@@ -242,6 +256,9 @@ return function(game)
and " OK -- the collapse waits for the bar"
or " WRONG -- the collapse ran while the bar was still draining")
end
U.log((" the model stayed on the field for %d frames (%.2fs) after the "
.. "collapse began; the engine's own pic slide is 14 (%.2fs)")
:format(stood, stood / 60, 14 / 60))
while game.stack:top() and game.stack:top() ~= game.overworld do
game.stack:pop()
end