mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 17:50:51 +02:00
380 lines
16 KiB
Lua
380 lines
16 KiB
Lua
-- Driver: screenshot a STADIUM battle -- the 3D-BTL row's third rung, where
|
|
-- the two Pokemon are the Pokemon Stadium battle models rather than the
|
|
-- game's own pics stood up on their tiles.
|
|
--
|
|
-- What has to be looked at is a SPREAD OF SPECIES, because almost everything
|
|
-- that can go wrong here is per-model: how big it comes out (the size ladder
|
|
-- compresses a sixteenfold spread of authored heights -- see StadiumMon),
|
|
-- whether its feet are on the ground (the floor rule has to tell a hovering
|
|
-- Zubat from a Tentacruel centred on its own origin), whether the eyes
|
|
-- animate, and whether the species carries generated flame prims. So the
|
|
-- list below is deliberately awkward: the smallest and the largest in the
|
|
-- set, a hoverer, a floater, and the two species whose idle animation is
|
|
-- known to be wrong in the source data.
|
|
--
|
|
-- SHOT_DIR=.scratchpad DS_STADIUM_DEBUG=1 \
|
|
-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/stadium_shots.lua love .
|
|
--
|
|
-- SHOT_DIR must already exist: the capture writes with io.open, which does
|
|
-- not create directories.
|
|
return function(game)
|
|
local U = dofile("tests/drivers/util.lua")
|
|
local DIR = os.getenv("SHOT_DIR") or ".scratchpad"
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local BattleState = require("src.battle.BattleState")
|
|
|
|
local exports = game.mods and game.mods.exports
|
|
local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
|
|
if not lib then
|
|
U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
|
|
return
|
|
end
|
|
local Battles = lib.require("OverworldBattle")
|
|
local Stadium = lib.require("Stadium")
|
|
local Pack = lib.require("StadiumPack")
|
|
|
|
local Install = lib.require("StadiumInstall")
|
|
|
|
game.save.player.name = "RED"
|
|
|
|
-- foe, and the player's own, chosen to put two different problems in one
|
|
-- frame wherever possible
|
|
local CASES = {
|
|
{ "PIKACHU", "CHARIZARD" }, -- the reference pair; Charizard has flame
|
|
{ "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
|
|
-- 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
|
|
}
|
|
|
|
-- ONTO THE MAP FIRST, before anything below waits on anything.
|
|
--
|
|
-- The models are built out of the player's own ROM on first run, on a
|
|
-- loading screen, and that screen is pushed from the mod's update hook on
|
|
-- the first frame THE OVERWORLD IS THE TOP STATE (StadiumScreen.maybePush).
|
|
-- A driver that waits for the build before it has got the player onto a map
|
|
-- waits forever: the condition that starts the build is the very thing the
|
|
-- waiting is postponing.
|
|
-- DS_MAP puts the fight somewhere else. It exists for STADIUM B, whose
|
|
-- whole claim is that it carries its stage: the shot has to be checked in a
|
|
-- CAVE and in a SHOP as well as on a route, both because those are the
|
|
-- places the map-staged rungs decline, and because the rung is supposed to
|
|
-- take that place's own sky and light with it.
|
|
local MAP = os.getenv("DS_MAP") or "ROUTE_1"
|
|
local MX = tonumber(os.getenv("DS_X") or "") or 5
|
|
local MY = tonumber(os.getenv("DS_Y") or "") or 8
|
|
U.teleport(game, MAP, MX, MY, "down")
|
|
|
|
-- Now wait it out, and say how long it took -- that number is what decides
|
|
-- whether the loading screen is acceptable rather than merely correct.
|
|
local waited, shotLoad = 0, false
|
|
while (Install.pending() or Install.status.state == "building")
|
|
and waited < 5400 do
|
|
if not shotLoad and Install.status.state == "building"
|
|
and (Install.status.done or 0) > 20 then
|
|
U.shot(game, ("%s/00_loading.png"):format(DIR))
|
|
shotLoad = true
|
|
end
|
|
U.wait(10)
|
|
waited = waited + 10
|
|
end
|
|
if waited > 0 then
|
|
U.log(("waited %d frames (%.1fs) for the model build -- %s")
|
|
:format(waited, waited / 60, tostring(Install.status.state)))
|
|
if Install.status.error then
|
|
U.log("build error: " .. tostring(Install.status.error))
|
|
end
|
|
end
|
|
U.log(("rom present: %s, packs ready: %s, stadium available: %s")
|
|
:format(tostring(Install.romPresent()), tostring(Install.ready()),
|
|
tostring(Install.available())))
|
|
|
|
-- 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, 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)
|
|
-- and PIN THE CLOCK. The hour multiplies everything in the shot -- the sky,
|
|
-- the tint, the weight of the shadows -- so a driver left on CYCLE
|
|
-- photographs a different scene every run, and two runs of it cannot be
|
|
-- compared. DS_TOD overrides for a deliberate look at dusk or night.
|
|
lib.require("DayNight").setting:setValue(os.getenv("DS_TOD") or "day", game)
|
|
U.log(("3D-BTL = %s, stadium enabled = %s, discs = %s")
|
|
:format(tostring(Battles.setting:get()), tostring(Stadium.enabled()),
|
|
tostring(Stadium.discs())))
|
|
|
|
-- did the models actually get built? one line rather than 151 silent
|
|
-- declines
|
|
local have, missing = 0, {}
|
|
for dex = 1, 151 do
|
|
if Pack.available(dex) then have = have + 1
|
|
elseif #missing < 6 then missing[#missing + 1] = dex end
|
|
end
|
|
U.log(("stadium packs present: %d/151%s"):format(
|
|
have, #missing > 0 and (" (missing " .. table.concat(missing, ",")
|
|
.. "...)") or ""))
|
|
|
|
-- let the neighbourhood's meshes land before the first fight, so the
|
|
-- opening frame is not the flat fallback
|
|
U.wait(90)
|
|
|
|
-- ------- DS_BLINK: how often does the eye actually change?
|
|
--
|
|
-- The texture animation rides the SKELETAL animation's frame and clamps
|
|
-- past the end of its own stream (StadiumRig.textures). Get either wrong
|
|
-- and a blink becomes a twitch: Rattata's standby loop is forty frames and
|
|
-- its blink is five, so wrapping on the blink's own length plays it six
|
|
-- times a second.
|
|
--
|
|
-- Counted rather than looked at, because "too fast" is a RATE and a
|
|
-- screenshot has no rate in it. This walks the idle loop frame by frame and
|
|
-- reports how many times the eye texture changes per second.
|
|
if os.getenv("DS_BLINK") then
|
|
local Pack = lib.require("StadiumPack")
|
|
local Rig = lib.require("StadiumRig")
|
|
for _, dex in ipairs({ 19, 4, 1, 25 }) do
|
|
local model = Pack.load(dex)
|
|
local rig = model and Rig.new(model)
|
|
if rig then
|
|
local idle = model.ctx[Pack.SLOT.idle]
|
|
local anim = (idle ~= Pack.NONE) and (idle + 1) or nil
|
|
local rec = anim and model.anims[anim]
|
|
-- which prim carries the eye, and what it shows each frame
|
|
local seen, changes, last = {}, 0, nil
|
|
local SECONDS = 4
|
|
for f = 0, SECONDS * Pack.FPS - 1 do
|
|
rig:pose(anim, f, true)
|
|
rig:textures(rec and rec.aux or nil)
|
|
local key = {}
|
|
for i, part in ipairs(rig.parts) do
|
|
if part.prim.texAnim and part.prim.texAnim >= 0 then
|
|
key[#key + 1] = i .. ":" .. tostring(part.texture)
|
|
end
|
|
end
|
|
key = table.concat(key, ",")
|
|
if last and key ~= last then changes = changes + 1 end
|
|
last = key
|
|
seen[key] = true
|
|
end
|
|
local looks = 0
|
|
for _ in pairs(seen) do looks = looks + 1 end
|
|
local period = rec and (rec.frames / Pack.FPS) or 0
|
|
U.log(("BLINK dex %3d: idle %s frames (%.2fs), aux %s frames -- "
|
|
.. "%d texture changes in %ds, %d distinct looks; the idle "
|
|
.. "loop comes round every %.2fs, so that is %.1f changes a "
|
|
.. "cycle")
|
|
:format(dex, tostring(rec and rec.frames), period,
|
|
tostring(rec and rec.aux
|
|
and model.auxAnims[rec.aux].frames),
|
|
changes, SECONDS, looks, period,
|
|
period > 0 and changes / (SECONDS / period) or 0))
|
|
rig:release()
|
|
end
|
|
end
|
|
U.log("done -- " .. DIR)
|
|
return
|
|
end
|
|
|
|
-- ------- DS_FLY: does the Pokemon actually leave?
|
|
--
|
|
-- FLY and DIG take the user off the field for a turn, and the engine says
|
|
-- so through `picFx[battler].hidden` rather than through fxHidden (which is
|
|
-- the damage blink alone). A model that ignores that stands on its tile
|
|
-- while every attack aimed at it misses into empty air.
|
|
--
|
|
-- A TIMELINE again, not a still: what is being checked is that the model
|
|
-- goes when the pic goes and comes back when it comes back, which is three
|
|
-- moments and a screenshot has one.
|
|
if os.getenv("DS_FLY") then
|
|
local Stadium2 = lib.require("Stadium")
|
|
local move = os.getenv("DS_MOVE") or "FLY"
|
|
local mine = Pokemon.new(game.data, "CHARIZARD", 60)
|
|
-- give it the move outright: what is under test is the vanish, not
|
|
-- whether this species learns it by level 60
|
|
mine.moves = { { id = move, pp = 15 } }
|
|
game.save.party = { mine }
|
|
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
|
battle.onFinish = function() end
|
|
game.overworld:pushBattle(battle)
|
|
U.wait(70)
|
|
for _ = 1, 40 do
|
|
if battle.phase == "menu" then break end
|
|
U.tap(game, "a")
|
|
U.wait(10)
|
|
end
|
|
U.tap(game, "a") -- FIGHT
|
|
U.wait(12)
|
|
U.tap(game, "a") -- the first (only) move
|
|
|
|
local goneAt, backAt, wasGone = nil, nil, false
|
|
for f = 1, 400 do
|
|
if f % 20 == 0 then U.tap(game, "a") end
|
|
U.wait(1)
|
|
local me = battle.player
|
|
local pf = battle.picFx and battle.picFx[me]
|
|
local picHidden = (pf and pf.hidden) and true or false
|
|
local showing = Stadium2.showing("player")
|
|
if f % 25 == 0 or (picHidden ~= wasGone) then
|
|
U.log((" f=%3d picHidden=%-5s model=%-5s anim=%s")
|
|
:format(f, tostring(picHidden), tostring(showing),
|
|
tostring(Stadium2.animOf("player"))))
|
|
end
|
|
if picHidden and not goneAt then goneAt = f end
|
|
if picHidden and not showing and not wasGone then
|
|
wasGone = true
|
|
U.shot(game, ("%s/fly_1_gone.png"):format(DIR))
|
|
end
|
|
if wasGone and not picHidden and not backAt then
|
|
backAt = f
|
|
U.shot(game, ("%s/fly_2_back.png"):format(DIR))
|
|
break
|
|
end
|
|
end
|
|
U.log(("%s: the pic hid at frame %s, the model was gone with it (%s), "
|
|
.. "and both came back at %s")
|
|
:format(move, tostring(goneAt), tostring(wasGone), tostring(backAt)))
|
|
U.log(wasGone and " OK -- the model leaves the field with the pic"
|
|
or " WRONG -- the model stood there while the game said it was gone")
|
|
while game.stack:top() and game.stack:top() ~= game.overworld do
|
|
game.stack:pop()
|
|
end
|
|
U.wait(10)
|
|
U.log("done -- " .. DIR)
|
|
return
|
|
end
|
|
|
|
-- ------- DS_FAINT: does the collapse wait for the bar?
|
|
--
|
|
-- The faint animation must not start until the foe's HP bar has finished
|
|
-- emptying: `onFaint` fires the instant HP reaches zero, but the engine
|
|
-- queues the visible collapse behind the move animation and the drain, and
|
|
-- a Pokemon that lies down while its own health is still draining above it
|
|
-- reads as a bug (see Stadium's faintReady).
|
|
--
|
|
-- A TIMELINE rather than a screenshot, because what is being checked is an
|
|
-- ORDER: the frame the bar reaches zero against the frame the animation
|
|
-- changes. One printed line per frame settles that; two stills cannot.
|
|
if os.getenv("DS_FAINT") then
|
|
local Stadium2 = lib.require("Stadium")
|
|
game.save.party = { Pokemon.new(game.data, "CHARIZARD", 60) }
|
|
local battle = BattleState.newWild(game, "PIKACHU", 5)
|
|
battle.onFinish = function() end
|
|
game.overworld:pushBattle(battle)
|
|
U.wait(70)
|
|
for _ = 1, 40 do
|
|
if battle.phase == "menu" then break end
|
|
U.tap(game, "a")
|
|
U.wait(10)
|
|
end
|
|
U.tap(game, "a")
|
|
U.wait(12)
|
|
U.tap(game, "a")
|
|
|
|
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,
|
|
-- and no demonstration whatever that it eventually arrives.
|
|
if f % 20 == 0 then U.tap(game, "a") end
|
|
U.wait(1)
|
|
local foe = battle.enemy
|
|
local shown = foe and foe.shownHP
|
|
local anim = Stadium2.animOf and Stadium2.animOf("enemy") or nil
|
|
if f % 25 == 0 or (shown and shown <= 0 and not barZeroAt) then
|
|
U.log((" f=%3d phase=%s shownHP=%s hp=%s queued=%s anim=%s")
|
|
:format(f, tostring(battle.phase), tostring(shown),
|
|
tostring(foe and foe.mon and foe.mon.hp),
|
|
tostring(foe and foe.faintQueued), tostring(anim)))
|
|
end
|
|
if shown and shown <= 0 and not barZeroAt then barZeroAt = f end
|
|
if anim == "faint" and not animAt then animAt = f end
|
|
-- a still from the middle of the drain: the foe must still be standing
|
|
if shown and shown > 0 and not shot and barZeroAt == nil
|
|
and foe and foe.faintQueued then
|
|
U.shot(game, ("%s/faint_1_draining.png"):format(DIR))
|
|
shot = true
|
|
end
|
|
-- 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
|
|
U.log(("FAINT: bar reached zero at frame %s, faint animation began at %s")
|
|
:format(tostring(barZeroAt), tostring(animAt)))
|
|
if barZeroAt and animAt then
|
|
U.log((animAt >= barZeroAt)
|
|
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
|
|
U.wait(10)
|
|
U.log("done -- " .. DIR)
|
|
return
|
|
end
|
|
|
|
for i, case in ipairs(CASES) do
|
|
local foe, mine = case[1], case[2]
|
|
local tag = ("%02d_%s_vs_%s"):format(i, mine:lower(), foe:lower())
|
|
game.save.party = { Pokemon.new(game.data, mine, 40) }
|
|
|
|
local battle = BattleState.newWild(game, foe, 30)
|
|
battle.onFinish = function() end
|
|
game.overworld:pushBattle(battle)
|
|
|
|
-- The wipe, then the intro chatter, and -- the part that matters --
|
|
-- far enough past it that the player's own Pokemon has been SENT OUT.
|
|
-- Before that the player's side is legitimately the trainer's back
|
|
-- sprite, with no Pokemon on the field to model at all; a shot taken
|
|
-- there looks exactly like a bug and is not one.
|
|
U.wait(70)
|
|
-- tap until the FIGHT menu is up, which is the first moment BOTH
|
|
-- Pokemon are on the field in their standby loop
|
|
for _ = 1, 40 do
|
|
if battle.phase == "menu" then break end
|
|
U.tap(game, "a")
|
|
U.wait(10)
|
|
end
|
|
U.wait(45)
|
|
U.shot(game, ("%s/%s_1_idle.png"):format(DIR, tag))
|
|
|
|
-- FIGHT -> the first move, which is where the attack animation is
|
|
U.tap(game, "a")
|
|
U.wait(12)
|
|
U.tap(game, "a")
|
|
U.wait(22)
|
|
U.shot(game, ("%s/%s_2_attack.png"):format(DIR, tag))
|
|
U.wait(45)
|
|
U.shot(game, ("%s/%s_3_hit.png"):format(DIR, tag))
|
|
|
|
while game.stack:top() and game.stack:top() ~= game.overworld do
|
|
game.stack:pop()
|
|
end
|
|
U.wait(10)
|
|
end
|
|
|
|
U.log("done -- " .. DIR)
|
|
end
|