mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
391 lines
16 KiB
Lua
391 lines
16 KiB
Lua
-- Manual check that BATTLE LAYOUT = WIDE draws move animations intact (#562).
|
|
-- A battle sets rWY to 0 (engine/battle/core.asm), so the window the shake
|
|
-- predefs displace is the whole screen; the wide composition moved only the
|
|
-- two pic regions, which slid the monsters across a nailed-down HUD and text
|
|
-- box. TAIL WHIP is the reported case (wAnimationType 6,
|
|
-- AnimationShakeScreenHorizontallySlow b=3, animations.asm:526). Composition
|
|
-- half: tests/engine/wide_battle_shake_bug562.lua. Never under POKEPORT_SPEED.
|
|
-- POKEPORT_DRIVER=tests/drivers/wide_move_anim_bug562_test.lua POKEPORT_IDENTITY=bug562 POKEPORT_TOUCH=0 SHOT_DIR=/tmp/shots love .
|
|
return function(game)
|
|
local U = dofile("tests/drivers/util.lua")
|
|
local DIR = os.getenv("SHOT_DIR") or "/tmp/shots"
|
|
local Pokemon = require("src.pokemon.Pokemon")
|
|
local BattleState = require("src.battle.BattleState")
|
|
local WideBattle = require("src.battle.WideBattle")
|
|
|
|
local pass, fail = 0, 0
|
|
local function check(label, ok)
|
|
if ok then pass = pass + 1 else fail = fail + 1 end
|
|
U.log(ok and "PASS" or "FAIL", label)
|
|
return ok
|
|
end
|
|
|
|
-- One move per shake family the wide composition has to carry. `hitX` is
|
|
-- the applying-attack amplitude PlayApplyingAttackAnimation plays after the
|
|
-- move (AnimationTypePointerTable, animations.asm:475); `animX` is a shake
|
|
-- the move's own subanimation runs. wAnimationType 4, 5 and 6 are the
|
|
-- player's turn -- the vertical 1 and the wide 2 / 3 are the foe's, and the
|
|
-- foe's rows are reported below rather than driven.
|
|
-- TAIL WHIP type 6, AnimationShakeScreenHorizontallySlow b=3 (silent;
|
|
-- every player status move takes this path)
|
|
-- BUBBLEBEAM type 5, fast horizontal b=2, with the damage thud
|
|
-- EARTHQUAKE SE_SHAKE_SCREEN inside its own animation (fastShakeProg 8),
|
|
-- shared with FISSURE and SEISMIC TOSS
|
|
-- WITHDRAW the control: a stat-UP effect leaves wAnimationType at 0
|
|
-- (effects.asm, PlayCurrentMoveAnimation), so nothing moves.
|
|
-- It also fills the fourth grid slot, which the 2x2 needs.
|
|
local MOVES = {
|
|
{ slot = 1, id = "TAIL_WHIP", type = 6, hitX = 3 },
|
|
{ slot = 2, id = "BUBBLEBEAM", type = 5, hitX = 2 },
|
|
{ slot = 3, id = "EARTHQUAKE", type = 4, animX = 8 },
|
|
{ slot = 4, id = "WITHDRAW", type = 0, hitX = 0, hitY = 0 },
|
|
}
|
|
|
|
-- ---- machine checks ----------------------------------------------------
|
|
-- A move missing from the table, a layout that never switched and a battle
|
|
-- that never reached WIDE all look like "the animation is fine" from a chair.
|
|
U.log("#562 wide-layout move animations: machine checks")
|
|
for _, m in ipairs(MOVES) do
|
|
check(m.id .. " is in the move table", game.data.moves[m.id] ~= nil)
|
|
end
|
|
local anims = game.data.battle_anims and game.data.battle_anims.moveAnims
|
|
check("battle_anims carries TAIL WHIP's own animation",
|
|
anims ~= nil and anims.TAIL_WHIP ~= nil)
|
|
check("...and EARTHQUAKE's, which carries SE_SHAKE_SCREEN itself",
|
|
anims ~= nil and anims.EARTHQUAKE ~= nil)
|
|
check("animations are on in OPTIONS (the shakes are gated on them)",
|
|
game.save.options.animations ~= false)
|
|
|
|
game.save.options.battleLayout = "wide"
|
|
check("BATTLE LAYOUT is WIDE", game.save.options.battleLayout == "wide")
|
|
|
|
local vol = game.save.options and game.save.options.sfxVol
|
|
U.log("sfxVol", tostring(vol),
|
|
vol == 0 and "-- SILENT, raise it in OPTION before judging the sound"
|
|
or "-- BUBBLEBEAM and EARTHQUAKE carry the damage thud; TAIL WHIP is silent")
|
|
|
|
-- ---- fixture -----------------------------------------------------------
|
|
local squirtle = Pokemon.new(game.data, "SQUIRTLE", 40)
|
|
squirtle.moves = {}
|
|
for _, m in ipairs(MOVES) do
|
|
squirtle.moves[m.slot] = { id = m.id, pp = game.data.moves[m.id].pp }
|
|
end
|
|
game.save.party = { squirtle }
|
|
game.save.player.name = "RED"
|
|
|
|
-- data/generated/maps.lua ROUTE_1: (5, 5) is open walkable ground; the
|
|
-- battle is pushed straight in rather than encountered
|
|
U.teleport(game, "ROUTE_1", 5, 5, "down")
|
|
U.wait(10)
|
|
local ow = game.overworld
|
|
if ow and ow.map and not ow.map:isWalkableCell(5, 5) then
|
|
-- a map edit moved the path: take the nearest walkable cell instead
|
|
local sx, sy
|
|
for r = 1, 6 do
|
|
for dy = -r, r do
|
|
for dx = -r, r do
|
|
if not sx and ow.map:isWalkableCell(5 + dx, 5 + dy) then
|
|
sx, sy = 5 + dx, 5 + dy
|
|
end
|
|
end
|
|
end
|
|
if sx then break end
|
|
end
|
|
if sx then
|
|
U.log("(5, 5) is blocked, standing on", sx, sy)
|
|
U.teleport(game, "ROUTE_1", sx, sy, "down")
|
|
U.wait(10)
|
|
ow = game.overworld
|
|
end
|
|
end
|
|
check("the overworld is up on ROUTE_1", ow ~= nil)
|
|
|
|
-- a Normal-type foe: PIDGEY is Flying and would sit out EARTHQUAKE entirely
|
|
local battle = BattleState.newWild(game, "RATTATA", 20)
|
|
battle.onFinish = function() end
|
|
ow:pushBattle(battle)
|
|
for _ = 1, 400 do
|
|
if game.stack:top() == battle and (battle.introSlide or 0) == 0 then break end
|
|
U.wait(1)
|
|
end
|
|
check("the battle reached the screen", game.stack:top() == battle)
|
|
check("the battle is drawing the wide surface", battle:wideLayout() == true)
|
|
local uw = battle:uiSize()
|
|
check(("the surface is %dpx wide (got %s)"):format(WideBattle.WIDTH, tostring(uw)),
|
|
uw == WideBattle.WIDTH)
|
|
|
|
-- a foe that survives the whole run: every family needs its own turn, and
|
|
-- EARTHQUAKE off a level 40 SQUIRTLE takes a large bite
|
|
battle.enemy.mon.stats.hp = 900
|
|
battle.enemy.mon.hp = 900
|
|
battle.enemy.shownHP = 900
|
|
-- WHIRLWIND ends a wild battle outright ("run"), taking the untested
|
|
-- families with it; curMoves aliases mon.moves, so one pass covers both
|
|
for i = #battle.enemy.mon.moves, 1, -1 do
|
|
local id = battle.enemy.mon.moves[i].id
|
|
if id == "WHIRLWIND" or id == "ROAR" or id == "TELEPORT" then
|
|
table.remove(battle.enemy.mon.moves, i)
|
|
end
|
|
end
|
|
|
|
-- ---- sample the offsets the composition is fed -------------------------
|
|
-- One row per performMove, EITHER side: a row that stayed open across the
|
|
-- foe's reply would credit the player's move with the foe's shake. The
|
|
-- split at the first applyHitFx separates a shake the move's own animation
|
|
-- ran (SE_SHAKE_SCREEN) from the applying-attack one that follows it.
|
|
local rows, cur = {}, nil
|
|
local realPerform = battle.performMove
|
|
battle.performMove = function(self, user, target, moveInst, ...)
|
|
cur = { move = moveInst and moveInst.id, isPlayer = user.isPlayer,
|
|
animX = 0, animY = 0, hitX = 0, hitY = 0, hitFrames = 0 }
|
|
rows[#rows + 1] = cur
|
|
return realPerform(self, user, target, moveInst, ...)
|
|
end
|
|
local realFx = battle.applyHitFx
|
|
battle.applyHitFx = function(self, hit)
|
|
if cur and cur.animType == nil then cur.animType = hit.animType or 0 end
|
|
return realFx(self, hit)
|
|
end
|
|
|
|
local function sample()
|
|
local fx = battle.fx
|
|
if not (cur and fx) then return end
|
|
local dx, dy = math.abs(fx.shakeX or 0), math.abs(fx.shakeY or 0)
|
|
if cur.animType == nil then
|
|
cur.animX, cur.animY = math.max(cur.animX, dx), math.max(cur.animY, dy)
|
|
return
|
|
end
|
|
if dx > 0 or dy > 0 then cur.hitFrames = cur.hitFrames + 1 end
|
|
cur.hitX, cur.hitY = math.max(cur.hitX, dx), math.max(cur.hitY, dy)
|
|
end
|
|
|
|
-- has this row finished its applying-attack effect? Type 4 runs a pic
|
|
-- blink and no screen program at all, so it is done as soon as it fires.
|
|
local function settled(row)
|
|
return row ~= nil and row.animType ~= nil
|
|
and (battle.fx == nil or battle.fx.shakeProg == nil)
|
|
end
|
|
|
|
-- step n frames sampling every one, pressing A every `mash` frames but only
|
|
-- while a box is up: an A on the FIGHT menu starts a turn behind our back
|
|
local function pump(n, mash, stop)
|
|
for i = 1, n do
|
|
if mash and i % mash == 0 and battle.phase == "messages" then
|
|
table.insert(game.input.pressQueue, "a")
|
|
end
|
|
U.wait(1)
|
|
game.input.state.a = false
|
|
sample()
|
|
if stop and stop() then return end
|
|
end
|
|
end
|
|
|
|
local function toMenu()
|
|
pump(1500, 6, function() return battle.phase == "menu" and #battle.queue == 0 end)
|
|
return battle.phase == "menu"
|
|
end
|
|
|
|
-- The wide move list is a 2x2 grid, and a press that would land on an
|
|
-- absent fourth move holds the cursor still (WideBattle.moveGridIndex), so
|
|
-- the route is searched through the engine's own navigation rather than
|
|
-- assumed: walking the column never reaches slots 2 and 4, and walking the
|
|
-- row can dead-end on a three-move party.
|
|
local DIRS = { "up", "down", "left", "right" }
|
|
local function routeTo(from, slot, count)
|
|
local seen, queue = { [from] = true }, { { at = from, path = {} } }
|
|
while #queue > 0 do
|
|
local node = table.remove(queue, 1)
|
|
if node.at == slot then return node.path end
|
|
for _, dir in ipairs(DIRS) do
|
|
local nxt = WideBattle.moveGridIndex(node.at, count, dir)
|
|
if nxt and not seen[nxt] then
|
|
seen[nxt] = true
|
|
local path = { unpack(node.path) }
|
|
path[#path + 1] = dir
|
|
queue[#queue + 1] = { at = nxt, path = path }
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- FIGHT is slot 1 of the wide command grid; A opens moveSelect. A press on
|
|
-- a frame the battle is not reading input is simply lost, so retry.
|
|
local function useMove(slot)
|
|
for _ = 1, 80 do
|
|
if battle.phase == "moveSelect" then break end
|
|
if battle.phase == "menu" then
|
|
if battle.menuIndex ~= 1 then
|
|
U.tap(game, battle.menuIndex > 2 and "up" or "left")
|
|
else
|
|
U.tap(game, "a")
|
|
end
|
|
else
|
|
U.tap(game, "a")
|
|
end
|
|
U.wait(4)
|
|
end
|
|
if battle.phase ~= "moveSelect" then return false end
|
|
-- recompute the route every press: a press that lands on a frame the
|
|
-- battle is not reading is lost, and the cursor simply did not move
|
|
local count = #(battle.player.curMoves or {})
|
|
for _ = 1, 30 do
|
|
if battle.moveIndex == slot then break end
|
|
local path = routeTo(battle.moveIndex, slot, count)
|
|
if not path or #path == 0 then break end
|
|
U.tap(game, path[1])
|
|
U.wait(3)
|
|
end
|
|
if battle.moveIndex ~= slot then return false end
|
|
for _ = 1, 20 do
|
|
if battle.phase ~= "moveSelect" then return true end
|
|
U.tap(game, "a")
|
|
U.wait(3)
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function lastPlayerRow(id)
|
|
for i = #rows, 1, -1 do
|
|
if rows[i].move == id and rows[i].isPlayer then return rows[i] end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local SHOTS = { TAIL_WHIP = "bug562_wide_tailwhip.png",
|
|
BUBBLEBEAM = "bug562_wide_bubblebeam.png",
|
|
EARTHQUAKE = "bug562_wide_earthquake.png" }
|
|
-- the foe's vertical drop (type 1) plays on ITS turn, so it is taken
|
|
-- whenever one goes past and chased down after the table below if not
|
|
local vertShot = false
|
|
|
|
check("the battle reached its FIGHT menu", toMenu())
|
|
|
|
for _, m in ipairs(MOVES) do
|
|
local shotAt, sent, r
|
|
-- BUBBLEBEAM whiffs 1 in 6 and the foe's TAIL WHIP stacks accuracy drops,
|
|
-- so give each move a few turns rather than reading a miss (which plays no
|
|
-- applying animation at all) as a regression; hand the accuracy back first
|
|
for _ = 1, 8 do
|
|
battle.player.stages.accuracy = 0
|
|
sent = useMove(m.slot)
|
|
if not sent then
|
|
U.log((" could not reach slot %d for %s (phase %s, move index %s, result %s)")
|
|
:format(m.slot, m.id, tostring(battle.phase),
|
|
tostring(battle.moveIndex), tostring(battle.result)))
|
|
break
|
|
end
|
|
pump(1500, 8, function()
|
|
local fx = battle.fx
|
|
local row = lastPlayerRow(m.id)
|
|
if not shotAt and row == cur and SHOTS[m.id] and fx
|
|
and (fx.shakeX or 0) ~= 0 then
|
|
shotAt = U.shot(game, DIR .. "/" .. SHOTS[m.id])
|
|
end
|
|
if not vertShot and fx and (fx.shakeY or 0) ~= 0
|
|
and cur and not cur.isPlayer then
|
|
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
|
|
end
|
|
if row and battle.phase == "menu" and #battle.queue == 0 then return true end
|
|
return settled(row)
|
|
end)
|
|
toMenu()
|
|
r = lastPlayerRow(m.id)
|
|
-- a type-0 move never queues an applying-attack row, so one clean turn
|
|
-- is all it owes
|
|
if r and (r.animType ~= nil or m.type == 0) then break end
|
|
U.log(" " .. m.id .. " did not connect that turn; using it again")
|
|
end
|
|
check("chose " .. m.id .. " from the wide move grid", sent)
|
|
if sent and check(m.id .. " was performed", r ~= nil) then
|
|
U.log((" %s: type %s, own animation %dpx across / %dpx down, applying "
|
|
.. "%dpx across / %dpx down over %d frames")
|
|
:format(m.id, tostring(r.animType), r.animX, r.animY,
|
|
r.hitX, r.hitY, r.hitFrames))
|
|
check(("%s is wAnimationType %d (got %d)")
|
|
:format(m.id, m.type, r.animType or 0),
|
|
(r.animType or 0) == m.type)
|
|
if m.hitX then
|
|
check(("%s shakes the screen %dpx sideways (got %d)")
|
|
:format(m.id, m.hitX, r.hitX), r.hitX == m.hitX)
|
|
end
|
|
if m.hitY then
|
|
check(("%s leaves the vertical alone (got %d)"):format(m.id, r.hitY),
|
|
r.hitY == m.hitY)
|
|
end
|
|
if m.animX then
|
|
check(("%s's own animation shakes %dpx sideways (got %d)")
|
|
:format(m.id, m.animX, r.animX), r.animX == m.animX)
|
|
end
|
|
if SHOTS[m.id] then
|
|
check("captured " .. m.id .. " mid-shake", shotAt == true)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- The vertical drop (type 1) only plays on the foe's turn, so it is taken by
|
|
-- spending harmless WITHDRAW turns until the RATTATA picks a plain damaging
|
|
-- move. Which move it picks is an AI roll, so a run where it never does is
|
|
-- a thin run, not a regression.
|
|
local function foeTypeOne()
|
|
for _, row in ipairs(rows) do
|
|
if not row.isPlayer and row.animType == 1 then return true end
|
|
end
|
|
return false
|
|
end
|
|
for _ = 1, 6 do
|
|
if vertShot then break end
|
|
if not useMove(4) then break end
|
|
pump(1200, 8, function()
|
|
local fx = battle.fx
|
|
if not vertShot and fx and (fx.shakeY or 0) ~= 0
|
|
and cur and not cur.isPlayer then
|
|
vertShot = U.shot(game, DIR .. "/bug562_wide_foe_vertical.png")
|
|
end
|
|
return battle.phase == "menu" and #battle.queue == 0
|
|
end)
|
|
toMenu()
|
|
end
|
|
|
|
local foe = {}
|
|
for _, row in ipairs(rows) do
|
|
if not row.isPlayer and row.animType then
|
|
foe[#foe + 1] = ("%s type %s (%dpx across, %dpx down)")
|
|
:format(tostring(row.move), tostring(row.animType),
|
|
math.max(row.animX, row.hitX),
|
|
math.max(row.animY, row.hitY))
|
|
end
|
|
end
|
|
U.log("the RATTATA's rows: " .. (#foe > 0 and table.concat(foe, "; ") or "none"))
|
|
if vertShot then
|
|
U.log("captured the foe's 8px vertical drop as well")
|
|
elseif foeTypeOne() then
|
|
check("the foe's vertical drop was captured", false)
|
|
else
|
|
U.log("the RATTATA never used a plain damaging move in these turns, so")
|
|
U.log("there was no vertical drop to capture; watch for one by hand")
|
|
end
|
|
U.log(("machine checks: %d passed, %d failed"):format(pass, fail))
|
|
|
|
-- ---- hand off ----------------------------------------------------------
|
|
U.log("The pad is yours at the wide FIGHT menu, all four panels on screen.")
|
|
U.log("Slot 1 TAIL WHIP creeps the WHOLE screen 1px at a time out to 3px and")
|
|
U.log("back, twice, in silence: foe, both status panels and the message box")
|
|
U.log("move as one sheet. Slot 2 BUBBLEBEAM snaps it 2px across with the")
|
|
U.log("damage thud, slot 3 EARTHQUAKE shakes it 8px from inside its own")
|
|
U.log("animation. Let the RATTATA hit back for the vertical one: a plain")
|
|
U.log("TACKLE or QUICK ATTACK drops the same sheet 8px. The near miss to")
|
|
U.log("watch for is the one #562 reported: the monsters sliding while the HUD")
|
|
U.log("panels and the text box stay nailed down, or a pic sheared off at the")
|
|
U.log("seam between the two side windows on the way.")
|
|
U.log("Slot 4 WITHDRAW is the control: it moves nothing at all, on purpose.")
|
|
U.log("So are the animations' own sprites (the bubbles, the EARTHQUAKE rocks)")
|
|
U.log("-- those are OAM and correctly do NOT ride the shake, same as in OG.")
|
|
U.log("Flip OPTION -> BATTLE LAYOUT to OG and repeat: it should look the same.")
|
|
U.log("Screenshots: " .. DIR .. "/bug562_*.png")
|
|
|
|
while true do
|
|
coroutine.yield()
|
|
end
|
|
end
|