Files
gen1recomp/tests/gen2_crystal_anim_test.lua

211 lines
8.4 KiB
Lua

-- Crystal's animated front pics: the bitmask decode and the frame sequencer.
-- luajit tests/gen2_crystal_anim_test.lua
--
-- ROM-free. The fixtures are the shapes the extractor writes into
-- data/generated/pokemon.lua, with the numbers taken from BULBASAUR's own
-- bitmask/frames data so a failure names the bytes it disagrees with.
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("gen2 crystal anim")
local check, eq = S.check, S.eq
local MonAnim = require("src.render.MonAnim")
-- ---- bitmask decode -------------------------------------------------------
-- ../pokecrystal/gfx/pokemon/bulbasaur/bitmask.asm bitmask 0 and frame 1:
-- %01100000 %10101101 %00000001 %00000000, then $19..$20. The tool emits the
-- byte low bit first, so the eight set bits are tile positions 5, 6, 8, 10,
-- 11, 13, 15 and 16 in the pic's own column-major order.
local BULBASAUR = {
tiles = 5,
bitmasks = {
{ 0x60, 0xad, 0x01, 0x00 },
{ 0x20, 0xad, 0x01, 0x00 },
},
frames = {
{ bitmask = 1, tiles = { 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20 } },
{ bitmask = 2, tiles = { 0x21, 0x1b, 0x22, 0x1d, 0x1e, 0x23, 0x24 } },
},
play = { { 1, 10 }, { 2, 10 }, { 0, 5 } },
idle = { { 2, 5 }, { 0, 5 } },
}
local base = MonAnim.tileMap(BULBASAUR, 0)
eq(base and #base, 25, "the base picture is 5x5 tiles")
eq(base and base[1], 0, "position 0 is tile 0")
eq(base and base[25], 24, "position 24 is tile 24")
local first = MonAnim.tileMap(BULBASAUR, 1)
eq(first and #first, 25, "frame 1 is still 5x5 tiles")
local REPLACED = { [6] = 0x19, [7] = 0x1a, [9] = 0x1b, [11] = 0x1c,
[12] = 0x1d, [14] = 0x1e, [16] = 0x1f, [17] = 0x20 }
local wrong = nil
for slot = 1, 25 do
local want = REPLACED[slot] or (slot - 1)
if first[slot] ~= want then wrong = wrong or slot end
end
eq(wrong, nil, "frame 1 replaces exactly the eight bitmask-0 positions")
-- Bitmask 1 drops bit 6 and keeps bit 5, so frame 2 leaves position 6 alone.
local second = MonAnim.tileMap(BULBASAUR, 2)
eq(second and second[6], 0x21, "frame 2 replaces position 5")
eq(second and second[7], 6, "and leaves position 6 at its base tile")
eq(second and #BULBASAUR.frames[2].tiles, 7,
"seven set bits, seven replacement tiles")
eq(MonAnim.tileMap({ frames = {}, bitmasks = {} }, 0), nil,
"no pic size means no tile map")
eq(MonAnim.tileMap(BULBASAUR, 9), nil, "and a frame that does not exist is nil")
-- ---- durations ------------------------------------------------------------
-- PokeAnim_GetDuration: a * (1 + [wPokeAnimSpeed] / 16).
eq(MonAnim.duration(10, 0), 10, "speed 0 leaves a duration alone")
eq(MonAnim.duration(10, 4), 12, "ANIM_MON_SLOW's speed 4 stretches 10 to 12")
eq(MonAnim.duration(5, 4), 6, "and 5 to 6")
eq(MonAnim.duration(200, 4), 250, "the arithmetic stays inside a byte")
-- ---- the sequencer --------------------------------------------------------
-- setrepeat 2 / frame 1, 3 / frame 2, 2 / dorepeat 1 -- the shape most of
-- Crystal's entrance animations have (../pokecrystal/gfx/pokemon/abra/anim.asm).
local LOOPED = {
tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { MonAnim.SETREPEAT, 2 }, { 1, 3 }, { 2, 2 }, { MonAnim.DOREPEAT, 1 } },
idle = { { 2, 2 } },
}
local function timeline(data, scene, ticks)
local anim = MonAnim.new(data, scene)
local out = {}
for _ = 1, ticks do
anim:update()
out[#out + 1] = anim:currentFrame()
end
return out, anim
end
local frames, anim = timeline(LOOPED, "battle", 14)
eq(table.concat(frames, ","), "0,1,1,1,2,2,1,1,1,2,2,2,0,0",
"the looped script plays twice and lands back on the base picture")
check(anim:finished(), "and the scene is over after PokeAnim_Finish")
-- Setup costs a frame of its own before the script starts, so the first
-- animation frame is on tick 2, not tick 1.
eq(frames[1], 0, "PokeAnim_Setup shows the base picture for its own frame")
-- ANIM_MON_SLOW's speed 4 stretches every duration: 8 becomes 10.
local ONESHOT = {
tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { 1, 8 } }, idle = { { 2, 2 } },
}
eq(table.concat(timeline(ONESHOT, "battle", 11), ","),
"0,1,1,1,1,1,1,1,1,0,0", "speed 0 holds frame 1 for eight frames")
eq(table.concat(timeline(ONESHOT, "battleSlow", 13), ","),
"0,1,1,1,1,1,1,1,1,1,1,0,0", "speed 4 holds it for ten")
-- ANIM_MON_MENU: the entrance animation, an 18-frame pause, then the idle.
local menu = MonAnim.new(ONESHOT, "menu")
local seen = {}
for tick = 1, 31 do
menu:update()
seen[tick] = menu:currentFrame()
end
eq(seen[10], 0, "the entrance script ends on the base picture")
local resume
for tick = 11, 31 do
if seen[tick] ~= 0 then resume = tick break end
end
eq(resume, 11 + MonAnim.SCENE_WAIT + 1,
"eighteen wait frames, then PokeAnim_Idle's own frame, then the idle script")
eq(resume and seen[resume], 2, "and the idle script's first frame comes up")
check(not menu:finished(), "the menu scene is longer than the battle one")
local ran = 0
for tick = 1, 60 do
menu:update()
if menu:finished() then ran = tick break end
end
check(ran > 0, "but it does end")
-- ../pokecrystal/engine/gfx/pic_animation.asm:65-71
local hofLine = table.concat(timeline(ONESHOT, "hof", 60), ",")
eq(hofLine, table.concat(timeline(ONESHOT, "menu", 60), ","),
"ANIM_MON_HOF is ANIM_MON_MENU's timeline")
check(MonAnim.new(ONESHOT, "hof") ~= nil, "and the scene exists at all")
-- ../pokecrystal/gfx/pokemon/bulbasaur/anim.asm, anim_idle.asm
-- ../pokecrystal/gfx/pokemon/kadabra/anim.asm, anim_idle.asm
local BULBASAUR_ANIM = {
tiles = 5, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { 1, 10 }, { 2, 10 }, { 1, 8 }, { 2, 6 }, { 4, 20 }, { 3, 6 },
{ 0, 5 }, { 5, 5 } },
idle = { { 5, 5 }, { 0, 5 }, { 5, 5 } },
}
local KADABRA_ANIM = {
tiles = 6, bitmasks = BULBASAUR.bitmasks, frames = BULBASAUR.frames,
play = { { 1, 8 }, { MonAnim.SETREPEAT, 4 }, { 2, 6 }, { 3, 6 },
{ MonAnim.DOREPEAT, 2 }, { 1, 12 } },
idle = { { MonAnim.SETREPEAT, 3 }, { 0, 7 }, { 4, 7 },
{ MonAnim.DOREPEAT, 1 } },
}
local function scene(data, name)
local cries = {}
local anim = MonAnim.new(data, name, function(kind)
cries[#cries + 1] = { kind = kind }
end)
if not anim then return nil, cries end
local out, tick = {}, 0
while not anim:finished() and tick < 600 do
tick = tick + 1
local before = #cries
anim:update()
if #cries > before then cries[#cries].tick = tick end
out[tick] = anim:currentFrame()
end
return out, cries
end
check(MonAnim.new(BULBASAUR_ANIM, "trade") ~= nil, "ANIM_MON_TRADE exists")
check(MonAnim.new(BULBASAUR_ANIM, "evolve") ~= nil, "ANIM_MON_EVOLVE exists")
check(MonAnim.new(BULBASAUR_ANIM, "hatch") ~= nil, "ANIM_MON_HATCH exists")
local bulbaTrade, bulbaCries = scene(BULBASAUR_ANIM, "trade")
eq(#bulbaTrade, 126, "BULBASAUR's trade scene is 126 iterations long")
-- ../pokecrystal/engine/gfx/pic_animation.asm:196-215
eq(bulbaTrade[17], 5, "Play2 leaves the idle script's last frame on screen")
eq(bulbaTrade[34], 0, "the Play after it does put the base picture back")
eq(#bulbaCries, 1, "the trade scene plays exactly one cry")
eq(bulbaCries[1] and bulbaCries[1].kind, "cry",
"and it is PokeAnim_Cry, not CryNoWait")
eq(bulbaCries[1] and bulbaCries[1].tick, 53,
"after Idle, Play2, Idle, Play and the eighteen SetWait frames")
local kadabraTrade, kadabraCries = scene(KADABRA_ANIM, "trade")
eq(#kadabraTrade, 181, "KADABRA's setrepeat/dorepeat idle runs three times")
eq(kadabraTrade[45], 4, "and Play2 ends on `frame 4, 07`, its last one")
eq(kadabraCries[1] and kadabraCries[1].tick, 109, "with the cry after that")
local _, evolveCries = scene(BULBASAUR_ANIM, "evolve")
eq(evolveCries[1] and evolveCries[1].kind, "cryNoWait",
"ANIM_MON_EVOLVE's cry does not wait")
eq(evolveCries[1] and evolveCries[1].tick, 36,
"and lands after Idle, Play and the eighteen frames, before Setup, Play")
local hatchLine, hatchCries = scene(BULBASAUR_ANIM, "hatch")
eq(#hatchLine, 126, "ANIM_MON_HATCH runs the idle script at both ends")
eq(hatchCries[1] and hatchCries[1].tick, 18,
"its CryNoWait comes straight after the first Play")
-- ---- gating ---------------------------------------------------------------
eq(MonAnim.new(nil, "battle"), nil, "no data means no sequencer")
eq(MonAnim.new({ tiles = 5, play = {} }, "battle"), nil,
"and an empty script means no sequencer, which is every Gold species")
eq(MonAnim.new(LOOPED, "nosuchscene"), nil, "an unknown scene is nil too")
S.finish()