mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-18 11:44:42 +02:00
1325 lines
41 KiB
Lua
1325 lines
41 KiB
Lua
-- The Gen 2 battle-animation BACKGROUND effects.
|
|
--
|
|
-- pokegold engine/battle_anims/bg_effects.asm: five concurrent structs, each
|
|
-- of {function, jumptable index, battle turn, param}, and a jumptable of 53
|
|
-- effects that between them do all the shaking, flashing and sinking a Gen 2
|
|
-- battle animation is made of. Objects (src/battle/gen2/AnimObjects.lua) are
|
|
-- OBJs; these are the BG layer.
|
|
--
|
|
-- The one mechanism worth understanding before reading any of it: almost
|
|
-- nothing here moves a sprite. It writes wLYOverridesBackup, a per-scanline
|
|
-- value the LCD STAT interrupt copies into rSCX, rSCY or rBGP as the beam
|
|
-- passes. BattleBGEffect_SetLCDStatCustoms1 picks the window -- scanlines
|
|
-- $00-$36 for the enemy's pic, $2f-$5e for the player's -- so "shake the
|
|
-- attacker" is "write the same SCX to every scanline the attacker occupies".
|
|
--
|
|
-- The port keeps that literally: `lyBackup` is 144 bytes, `lcdc` names the
|
|
-- register they land in, and src/ui/gen2/BattleAnimView.lua draws the BG layer
|
|
-- one scanline band at a time. Modelling it as "move the mon pic" instead
|
|
-- would work for Tackle and then fall apart on Withdraw and Dig, which push a
|
|
-- GROWING number of rows off and leave the rest where they are.
|
|
--
|
|
-- Love-free, like AnimObjects.
|
|
|
|
local bit = require("bit")
|
|
local AnimObjects = require("src.battle.gen2.AnimObjects")
|
|
|
|
local u8, sra = AnimObjects.u8, AnimObjects.sra
|
|
local swap = AnimObjects.swap
|
|
local sine, cosine = AnimObjects.sine, AnimObjects.cosine
|
|
|
|
local BgEffects = {}
|
|
|
|
local NUM_EFFECTS = 5 -- NUM_BG_EFFECTS
|
|
local SCREEN_ROWS = 0x90 -- wLYOverridesBackup is $91 bytes
|
|
|
|
-- `dc a, b, c, d` packs four 2-bit shades into a DMG palette byte, high pair
|
|
-- first: `dc 3, 2, 1, 0` is %11100100 = $e4, the identity ramp.
|
|
local function dc(a, b, c, d) return a * 64 + b * 16 + c * 4 + d end
|
|
|
|
local NORMAL_PAL = dc(3, 2, 1, 0)
|
|
|
|
--------------------------------------------------------------------------
|
|
|
|
local Pool = {}
|
|
Pool.__index = Pool
|
|
|
|
local function newEffect()
|
|
return { func = nil, jt = 0, turn = 0, param = 0 }
|
|
end
|
|
|
|
-- `env` is shared with the object pool: env.battleTurn is hBattleTurn, and
|
|
-- env.flying tells BGEffect_CheckFlyDigStatus whether the battler in question
|
|
-- is mid-Fly or mid-Dig (in which case ShowMon and the battler-pic objects
|
|
-- decline to draw a mon that is not on the field).
|
|
function BgEffects.new(constants, env)
|
|
local self = setmetatable({}, Pool)
|
|
self.env = env or {}
|
|
self.effects = {}
|
|
for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end
|
|
self.order = (constants or {}).battleBgEffectOrder or {}
|
|
self:reset()
|
|
return self
|
|
end
|
|
|
|
function Pool:reset()
|
|
for slot = 1, NUM_EFFECTS do self.effects[slot] = newEffect() end
|
|
-- hSCX / hSCY: a whole-screen scroll, which is what the screen shakes use.
|
|
self.scx, self.scy = 0, 0
|
|
-- hLCDCPointer plus its window, and the per-scanline values themselves.
|
|
self.lcdc, self.lyStart, self.lyEnd = nil, 0, 0
|
|
self.lyBackup = {}
|
|
for row = 0, SCREEN_ROWS do self.lyBackup[row] = 0 end
|
|
-- wBGP / wOBP0 / wOBP1, as DMG palette bytes.
|
|
self.bgp, self.obp0, self.obp1 = NORMAL_PAL, NORMAL_PAL, NORMAL_PAL
|
|
-- Per-battler state the CGB paths write instead of touching wBGP: shade
|
|
-- byte, hidden flag, lifted tile rows, and which BG square it is drawn at.
|
|
self.monShade = { player = NORMAL_PAL, enemy = NORMAL_PAL }
|
|
self.hidden = { player = false, enemy = false }
|
|
self.liftedRows = { player = nil, enemy = nil }
|
|
self.picSize = { player = nil, enemy = nil }
|
|
self.slide = { player = 0, enemy = 0 }
|
|
-- wSurfWaveBGEffect: the $40-byte rolling wave Surf keeps beside the
|
|
-- overrides. nil until InitSurfWaves lays one down.
|
|
self.surfWave = nil
|
|
-- The objects a BG effect asks the object pool to spawn, drained by the
|
|
-- runner after each frame.
|
|
self.spawns = {}
|
|
end
|
|
|
|
function Pool:activeCount()
|
|
local count = 0
|
|
for slot = 1, NUM_EFFECTS do
|
|
if self.effects[slot].func then count = count + 1 end
|
|
end
|
|
return count
|
|
end
|
|
|
|
-- QueueBGEffect: first free struct wins; a full pool silently drops the
|
|
-- request, which is exactly what the carry return means to the caller.
|
|
function Pool:queue(effectId, jumptableIndex, turn, param)
|
|
local name = effectId
|
|
if type(effectId) == "number" then
|
|
name = self.order[effectId + 1] or effectId
|
|
end
|
|
for slot = 1, NUM_EFFECTS do
|
|
local st = self.effects[slot]
|
|
if not st.func then
|
|
st.func = name
|
|
st.jt = u8(jumptableIndex or 0)
|
|
st.turn = u8(turn or 0)
|
|
st.param = u8(param or 0)
|
|
return st
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
-- BattleAnimCmd_IncBGEffect: bump the jumptable index of the first struct
|
|
-- running this effect.
|
|
function Pool:incEffect(effectId)
|
|
local name = effectId
|
|
if type(effectId) == "number" then
|
|
name = self.order[effectId + 1] or effectId
|
|
end
|
|
for slot = 1, NUM_EFFECTS do
|
|
local st = self.effects[slot]
|
|
if st.func == name then
|
|
st.jt = u8(st.jt + 1)
|
|
return st
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- Shared helpers
|
|
--------------------------------------------------------------------------
|
|
|
|
local function endEffect(st) st.func = nil end
|
|
local function incJt(st) st.jt = u8(st.jt + 1) end
|
|
|
|
-- BGEffect_CheckBattleTurn: non-zero means "the side this effect is aimed at
|
|
-- is the player's". A struct's `turn` is BG_EFFECT_USER / BG_EFFECT_TARGET,
|
|
-- so the same effect id follows whichever battler is attacking.
|
|
function Pool:playerSide(st)
|
|
return bit.bxor(bit.band(self.env.battleTurn or 0, 1), st.turn) ~= 0
|
|
end
|
|
|
|
function Pool:sideKey(st)
|
|
return self:playerSide(st) and "player" or "enemy"
|
|
end
|
|
|
|
-- BGEffect_CheckFlyDigStatus: zero means "on the field".
|
|
function Pool:flyDig(st)
|
|
local flying = self.env.flying or {}
|
|
return flying[self:sideKey(st)] and true or false
|
|
end
|
|
|
|
function Pool:clearLYOverrides(value)
|
|
value = value or 0
|
|
for row = 0, SCREEN_ROWS do self.lyBackup[row] = value end
|
|
end
|
|
|
|
-- BattleBGEffect_SetLCDStatCustoms1: the window is the attacker's pic rows.
|
|
function Pool:setLCDStatCustoms1(register, st)
|
|
self.lcdc = register
|
|
if self:playerSide(st) then
|
|
self.lyStart, self.lyEnd = 0x2f, 0x5e
|
|
else
|
|
self.lyStart, self.lyEnd = 0x00, 0x36
|
|
end
|
|
end
|
|
|
|
function Pool:resetLCDStatCustom(st)
|
|
self.lyStart, self.lyEnd = 0, 0
|
|
self:clearLYOverrides(0)
|
|
self.lcdc = nil
|
|
endEffect(st)
|
|
end
|
|
|
|
function Pool:resetVideoHRAM()
|
|
self.lcdc = nil
|
|
self.bgp, self.obp1 = NORMAL_PAL, NORMAL_PAL
|
|
self.lyStart, self.lyEnd = 0, 0
|
|
self:clearLYOverrides(0)
|
|
end
|
|
|
|
-- BGEffect_FillLYOverridesBackup: the same value on every scanline in the
|
|
-- window. `dec d; jr nz` after the first store, so a zero-width window would
|
|
-- run 256 times; the port refuses instead of wrapping the array.
|
|
function Pool:fillLY(value)
|
|
local count = u8(self.lyEnd - self.lyStart)
|
|
if count == 0 then count = 256 end
|
|
for i = 0, count - 1 do
|
|
local row = self.lyStart + i
|
|
if row > SCREEN_ROWS then break end
|
|
self.lyBackup[row] = u8(value)
|
|
end
|
|
end
|
|
|
|
-- BGEffect_DisplaceLYOverridesBackup: the first `a` scanlines of the window
|
|
-- are scrolled to a blank part of the map ($90) and the rest are pushed down
|
|
-- by a + 1. That is what makes Withdraw and Dig look like the mon sinking
|
|
-- rather than sliding.
|
|
function Pool:displaceLY(a)
|
|
a = u8(a)
|
|
local span = u8(self.lyEnd - self.lyStart)
|
|
local rest = u8(span - a)
|
|
local row = self.lyStart
|
|
for _ = 1, (a == 0 and 256 or a) do
|
|
if row > SCREEN_ROWS then return end
|
|
self.lyBackup[row] = 0x90
|
|
row = row + 1
|
|
end
|
|
local pushed = u8(0xff - a)
|
|
for _ = 1, (rest == 0 and 256 or rest) do
|
|
if row > SCREEN_ROWS then return end
|
|
self.lyBackup[row] = pushed
|
|
row = row + 1
|
|
end
|
|
end
|
|
|
|
-- DeformScreen: a standing sine wave down the window. It walks the FIRST
|
|
-- $80 entries of wLYOverridesBackup by their low address byte and writes only
|
|
-- the ones inside the window -- `cp c / jr nc` skips while lyStart >= c and
|
|
-- `cp c / jr c` skips once lyEnd < c, so the row written is strictly
|
|
-- lyStart < row <= lyEnd -- but the phase advances on EVERY iteration, window
|
|
-- or not. So where the window sits decides which part of the wave lands on
|
|
-- it, and two effects with the same amplitude and offset but different
|
|
-- windows do not look alike.
|
|
--
|
|
-- `lb de, d, e` puts the AMPLITUDE in d and the phase step in e.
|
|
function Pool:deformScreen(amplitude, offset)
|
|
local progress = 0
|
|
for row = 0, 0x7f do
|
|
if self.lyStart < row and row <= self.lyEnd and row <= SCREEN_ROWS then
|
|
self.lyBackup[row] = sine(progress, amplitude)
|
|
end
|
|
progress = u8(progress + offset)
|
|
end
|
|
end
|
|
|
|
-- InitSurfWaves: the same wave, into the $40-byte wSurfWaveBGEffect ring
|
|
-- rather than the overrides themselves. Surf rotates that ring a step a frame
|
|
-- and copies it out, which is what makes the water ROLL instead of standing
|
|
-- still the way DeformScreen's does.
|
|
Pool.SURF_WAVE_LENGTH = 0x40
|
|
|
|
function Pool:initSurfWaves(amplitude, offset)
|
|
local progress = 0
|
|
self.surfWave = {}
|
|
for index = 0, Pool.SURF_WAVE_LENGTH - 1 do
|
|
self.surfWave[index] = sine(progress, amplitude)
|
|
progress = u8(progress + offset)
|
|
end
|
|
end
|
|
|
|
-- BattleBGEffect_Surf's `.RotatewSurfWaveBGEffect`: rotate the ring left one,
|
|
-- then paint scanlines $00-$5e from it -- zero at and below lyStart, the ring
|
|
-- (wrapping every $40 rows) above it. The ring index advances on every
|
|
-- scanline including the zeroed ones, so the wave keeps its phase across the
|
|
-- boundary.
|
|
function Pool:rotateSurfWave()
|
|
local wave = self.surfWave
|
|
if not wave then return end
|
|
local first = wave[0]
|
|
for index = 0, Pool.SURF_WAVE_LENGTH - 2 do
|
|
wave[index] = wave[index + 1]
|
|
end
|
|
wave[Pool.SURF_WAVE_LENGTH - 1] = first
|
|
local ring = 0
|
|
for row = 0, 0x5e do
|
|
local value = 0
|
|
if self.lyStart < row then value = wave[ring] end
|
|
if row <= SCREEN_ROWS then self.lyBackup[row] = u8(value) end
|
|
ring = bit.band(ring + 1, Pool.SURF_WAVE_LENGTH - 1)
|
|
end
|
|
end
|
|
|
|
-- DeformWater: `count` PAIRS of scanlines either side of a centre at
|
|
-- lyStart + `progress`, each pair taking the next step of a sine whose angle
|
|
-- climbs by 4 a pair. Both walkers start on the centre row, so it is written
|
|
-- twice and the figure is symmetric about it. The two bounds checks are not
|
|
-- the same test: the downward walker stops once lyEnd < its row, the upward
|
|
-- one once lyStart >= its row.
|
|
function Pool:deformWater(count, amplitude, offset, progress)
|
|
local down = self.lyStart + (progress or 0)
|
|
local up = down
|
|
local angle = u8(offset)
|
|
for _ = 1, u8(count) do
|
|
local value = sine(angle, amplitude)
|
|
if self.lyEnd >= down then
|
|
if down >= 0 and down <= SCREEN_ROWS then self.lyBackup[down] = value end
|
|
down = down + 1
|
|
end
|
|
if self.lyStart < up then
|
|
if up >= 0 and up <= SCREEN_ROWS then self.lyBackup[up] = value end
|
|
up = up - 1
|
|
end
|
|
angle = u8(angle + 4)
|
|
end
|
|
end
|
|
|
|
-- BattleBGEffect_WavyScreenFX: rotate the window's overrides up one row, the
|
|
-- old top row wrapping around to the bottom. Every wobble effect is
|
|
-- DeformScreen once to lay the wave down and then this, once a frame, to make
|
|
-- it travel.
|
|
function Pool:wavyScreenFX()
|
|
local span = u8(self.lyEnd - self.lyStart)
|
|
if span == 0 then return end
|
|
local first = self.lyBackup[self.lyStart] or 0
|
|
for i = 0, span - 1 do
|
|
local row = self.lyStart + i
|
|
if row > SCREEN_ROWS then break end
|
|
self.lyBackup[row] = self.lyBackup[row + 1] or 0
|
|
end
|
|
local last = self.lyStart + span
|
|
if last <= SCREEN_ROWS then self.lyBackup[last] = first end
|
|
end
|
|
|
|
-- BattleBGEffect_GetFirstDMGPal / GetNextDMGPal walking a `dc` list.
|
|
-- $ff ends the effect (returns nil); $fe restarts the list from the top.
|
|
local function nextPal(st, pals)
|
|
local index = st.param
|
|
st.param = u8(st.param + 1)
|
|
local value = pals[index + 1]
|
|
if value == nil or value == 0xff then return nil end
|
|
if value == 0xfe then
|
|
-- Rewind and hand back the list's first entry.
|
|
st.param = 0
|
|
value = pals[1]
|
|
end
|
|
return value
|
|
end
|
|
|
|
-- BattleBGEffect_GetNthDMGPal: JT doubles as a per-step frame counter, and it
|
|
-- is reloaded from the struct's `turn` -- so the SAME field is the flash speed
|
|
-- here and the battler side everywhere else.
|
|
local function nthPal(st, pals)
|
|
if st.jt ~= 0 then
|
|
st.jt = st.jt - 1
|
|
local index = st.param
|
|
local value = pals[index + 1]
|
|
if value == nil or value == 0xff then return nil end
|
|
if value == 0xfe then
|
|
st.param = 0
|
|
value = pals[1]
|
|
end
|
|
return value
|
|
end
|
|
st.jt = st.turn
|
|
return nextPal(st, pals)
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- The effects (BattleBGEffects jumptable)
|
|
--------------------------------------------------------------------------
|
|
|
|
local E = {}
|
|
|
|
E.BATTLE_BG_EFFECT_END = function(_, st) endEffect(st) end
|
|
|
|
-- BattleBGEffect_FlashContinue: `turn` is the flash duration, `param` the
|
|
-- number of flashes left, and the two palettes alternate.
|
|
local function flash(self, st, pals)
|
|
if st.jt ~= 0 then
|
|
st.jt = st.jt - 1
|
|
return
|
|
end
|
|
st.jt = st.turn
|
|
if st.param == 0 then
|
|
endEffect(st)
|
|
return
|
|
end
|
|
st.param = u8(st.param - 1)
|
|
self.bgp = pals[bit.band(st.param, 1) + 1]
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_FLASH_INVERTED = function(self, st)
|
|
flash(self, st, { dc(3, 2, 1, 0), dc(0, 1, 2, 3) })
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_FLASH_WHITE = function(self, st)
|
|
flash(self, st, { dc(3, 2, 1, 0), dc(0, 0, 0, 0) })
|
|
end
|
|
|
|
local WHITE_HUES = { dc(3, 2, 1, 0), dc(3, 2, 0, 0), dc(3, 1, 0, 0), 0xff }
|
|
local BLACK_HUES = { dc(3, 2, 1, 0), dc(3, 3, 1, 0), dc(3, 3, 2, 0), 0xff }
|
|
local ALTERNATE_HUES = {
|
|
dc(3, 2, 1, 0), dc(3, 3, 2, 0), dc(3, 3, 3, 0), dc(3, 3, 2, 0),
|
|
dc(3, 2, 1, 0), dc(2, 1, 0, 0), dc(1, 0, 0, 0), dc(2, 1, 0, 0), 0xfe,
|
|
}
|
|
|
|
E.BATTLE_BG_EFFECT_WHITE_HUES = function(self, st)
|
|
local value = nthPal(st, WHITE_HUES)
|
|
if not value then
|
|
endEffect(st)
|
|
return
|
|
end
|
|
self.bgp = value
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_BLACK_HUES = function(self, st)
|
|
local value = nthPal(st, BLACK_HUES)
|
|
if not value then
|
|
endEffect(st)
|
|
return
|
|
end
|
|
self.bgp = value
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_ALTERNATE_HUES = function(self, st)
|
|
local value = nthPal(st, ALTERNATE_HUES)
|
|
if not value then
|
|
endEffect(st)
|
|
return
|
|
end
|
|
self.bgp, self.obp1 = value, value
|
|
end
|
|
|
|
local OB_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(2, 1, 0, 0), 0xfe }
|
|
local OB_MID_GRAY_YELLOW = { dc(3, 2, 1, 0), dc(3, 1, 2, 0), 0xfe }
|
|
local BG_INVERTED = { dc(0, 1, 2, 3), dc(1, 2, 0, 3), dc(2, 0, 1, 3), 0xfe }
|
|
|
|
E.BATTLE_BG_EFFECT_CYCLE_OBPALS_GRAY_AND_YELLOW = function(self, st)
|
|
local value = nthPal(st, OB_GRAY_YELLOW)
|
|
if value then self.obp0 = value end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_CYCLE_MID_OBPALS_GRAY_AND_YELLOW = function(self, st)
|
|
local value = nthPal(st, OB_MID_GRAY_YELLOW)
|
|
if value then self.obp0 = value end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_CYCLE_BGPALS_INVERTED = function(self, st)
|
|
local value = nthPal(st, BG_INVERTED)
|
|
if value then self.bgp = value end
|
|
end
|
|
|
|
-- The mon's pic box is simply cleared, held for three frames and restored.
|
|
E.BATTLE_BG_EFFECT_HIDE_MON = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self.hidden[self:sideKey(st)] = true
|
|
elseif jt >= 1 and jt <= 3 then
|
|
incJt(st)
|
|
elseif jt == 4 then
|
|
self.hidden[self:sideKey(st)] = false
|
|
endEffect(st)
|
|
end
|
|
end
|
|
|
|
-- BattleBGEffect_RunPicResizeScript: rows of {size, base tile, coord slot},
|
|
-- $ff ending, $fe clearing a box, $fd skipping the draw. The sizes are the
|
|
-- six BG squares (6x6, 4x4, 2x2 for the player; 7x7, 5x5, 3x3 for the enemy),
|
|
-- which is how a mon grows into or shrinks out of the field. Only the size
|
|
-- matters to this port -- the tile ids and the coord slot are the same pic in
|
|
-- the same box -- so the script is followed for its TIMING and its scale.
|
|
local PIC_RESIZE = {
|
|
-- BattleBGEffect_ShowMon
|
|
showPlayer = { 0 },
|
|
showEnemy = { 3 },
|
|
-- BattleBGEffect_EnterMon
|
|
enterPlayer = { 2, 1, 0 },
|
|
enterEnemy = { 5, 4, 3 },
|
|
-- BattleBGEffect_ReturnMon: each step is preceded by a box clear, which is
|
|
-- the -2 row, and the last -3 row leaves the field empty.
|
|
returnPlayer = { 0, 1, 2, false },
|
|
returnEnemy = { 3, 4, 5, false },
|
|
}
|
|
|
|
local function runPicResize(self, st, script)
|
|
local side = self:sideKey(st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
local step = script[st.param + 1]
|
|
st.param = u8(st.param + 1)
|
|
if step == nil then
|
|
self.picSize[side] = nil
|
|
endEffect(st)
|
|
return
|
|
end
|
|
if step == false then
|
|
self.picSize[side] = nil
|
|
self.hidden[side] = true
|
|
else
|
|
self.picSize[side] = step
|
|
self.hidden[side] = false
|
|
end
|
|
self.liftedRows[side] = nil
|
|
incJt(st)
|
|
elseif jt >= 1 and jt <= 2 then
|
|
incJt(st)
|
|
elseif jt == 3 then
|
|
st.jt = 0
|
|
elseif jt == 4 then
|
|
self.picSize[side] = nil
|
|
endEffect(st)
|
|
end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_SHOW_MON = function(self, st)
|
|
if self:flyDig(st) then
|
|
endEffect(st)
|
|
return
|
|
end
|
|
runPicResize(self, st, self:playerSide(st)
|
|
and PIC_RESIZE.showPlayer or PIC_RESIZE.showEnemy)
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_ENTER_MON = function(self, st)
|
|
runPicResize(self, st, self:playerSide(st)
|
|
and PIC_RESIZE.enterPlayer or PIC_RESIZE.enterEnemy)
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_RETURN_MON = function(self, st)
|
|
runPicResize(self, st, self:playerSide(st)
|
|
and PIC_RESIZE.returnPlayer or PIC_RESIZE.returnEnemy)
|
|
end
|
|
|
|
-- BattleBGEffect_RemoveMon slides the pic's tilemap one column a frame
|
|
-- towards the edge it came from, eight or nine columns' worth.
|
|
E.BATTLE_BG_EFFECT_REMOVE_MON = function(self, st)
|
|
local side = self:sideKey(st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
st.param = self:playerSide(st) and 9 or 8
|
|
elseif jt == 1 then
|
|
self.slide[side] = self.slide[side] + (self:playerSide(st) and -8 or 8)
|
|
incJt(st)
|
|
st.param = u8(st.param - 1)
|
|
elseif jt == 2 or jt == 3 then
|
|
incJt(st)
|
|
elseif jt == 4 then
|
|
if st.param == 0 then
|
|
self.slide[side] = 0
|
|
self.hidden[side] = true
|
|
endEffect(st)
|
|
return
|
|
end
|
|
st.jt = 1
|
|
end
|
|
end
|
|
|
|
-- The two battler-pic objects: the animation borrows the mon's own tiles as
|
|
-- an OBJ so it can be moved without touching the tilemap.
|
|
local function battlerObj(self, st, objectPlayer, objectEnemy, rows)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
if self:flyDig(st) then
|
|
endEffect(st)
|
|
return
|
|
end
|
|
incJt(st)
|
|
local player = self:playerSide(st)
|
|
self.spawns[#self.spawns + 1] = {
|
|
object = player and objectPlayer or objectEnemy,
|
|
x = player and (6 * 8) or (16 * 8 + 4),
|
|
y = 8 * 8,
|
|
param = 0,
|
|
}
|
|
elseif jt == 1 then
|
|
incJt(st)
|
|
-- engine/battle_anims/bg_effects.asm:448-465: the rows the OBJ now covers
|
|
-- come out of the tilemap, and .five never puts them back.
|
|
self.liftedRows[self:sideKey(st)] = rows[self:sideKey(st)]
|
|
elseif jt >= 2 and jt <= 4 then
|
|
incJt(st)
|
|
elseif jt == 5 then
|
|
endEffect(st)
|
|
end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_BATTLEROBJ_1ROW = function(self, st)
|
|
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_1ROW",
|
|
"BATTLE_ANIM_OBJ_ENEMYFEET_1ROW",
|
|
{ player = { 0, 1 }, enemy = { 6, 1 } })
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_BATTLEROBJ_2ROW = function(self, st)
|
|
battlerObj(self, st, "BATTLE_ANIM_OBJ_PLAYERHEAD_2ROW",
|
|
"BATTLE_ANIM_OBJ_ENEMYFEET_2ROW",
|
|
{ player = { 0, 2 }, enemy = { 5, 2 } })
|
|
end
|
|
|
|
-- BGEffect_RapidCyclePals. On a CGB the palette is applied to ONE battler
|
|
-- (the struct's side) rather than to the whole background, which is what the
|
|
-- per-mon fades want; the port keeps that and leaves wBGP alone.
|
|
local function rapidCyclePals(self, st, pals)
|
|
local side = self:sideKey(st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
st.turn = st.param
|
|
st.param = 0
|
|
return
|
|
end
|
|
if jt == 1 then
|
|
if bit.band(st.turn, 0xf) ~= 0 then
|
|
st.turn = u8(st.turn - 1)
|
|
return
|
|
end
|
|
-- The low nybble is reloaded from the high one, which is the step delay.
|
|
st.turn = bit.bor(swap(st.turn), st.turn)
|
|
local value = nextPal(st, pals)
|
|
if value == nil then
|
|
st.param = u8(st.param - 1)
|
|
incJt(st)
|
|
return
|
|
end
|
|
self.monShade[side] = value
|
|
return
|
|
end
|
|
self.monShade[side] = NORMAL_PAL
|
|
endEffect(st)
|
|
end
|
|
|
|
local RAPID_PALS = {
|
|
BATTLE_BG_EFFECT_RAPID_FLASH = { 0xe4, 0x6c, 0xfe },
|
|
BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT = { 0xe4, 0x90, 0x40, 0xff },
|
|
BATTLE_BG_EFFECT_FADE_MON_TO_BLACK = { 0xe4, 0xf8, 0xfc, 0xff },
|
|
BATTLE_BG_EFFECT_FADE_MON_TO_LIGHT_REPEATING = { 0xe4, 0x90, 0x40, 0x90, 0xfe },
|
|
BATTLE_BG_EFFECT_FADE_MON_TO_BLACK_REPEATING = { 0xe4, 0xf8, 0xfc, 0xf8, 0xfe },
|
|
BATTLE_BG_EFFECT_CYCLE_MON_LIGHT_DARK_REPEATING =
|
|
{ 0xe4, 0xf8, 0xfc, 0xf8, 0xe4, 0x90, 0x40, 0x90, 0xfe },
|
|
BATTLE_BG_EFFECT_FLASH_MON_REPEATING = { 0xe4, 0xfc, 0xe4, 0x00, 0xfe },
|
|
BATTLE_BG_EFFECT_FADE_MON_TO_WHITE_WAIT_FADE_BACK = {
|
|
0xe4, 0x90, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
0x00, 0x00, 0x40, 0x90, 0xe4, 0xff,
|
|
},
|
|
BATTLE_BG_EFFECT_FADE_MON_FROM_WHITE = { 0x00, 0x40, 0x90, 0xe4, 0xff },
|
|
}
|
|
|
|
for name, pals in pairs(RAPID_PALS) do
|
|
E[name] = function(self, st) rapidCyclePals(self, st, pals) end
|
|
end
|
|
|
|
-- BattleBGEffect_FadeMonsToBlackRepeating fades BOTH battlers, on opposite
|
|
-- halves of the same four-step ramp.
|
|
local FADE_BOTH = { 0xe4, 0xe4, 0xf8, 0x90, 0xfc, 0x40, 0xf8, 0x90 }
|
|
|
|
E.BATTLE_BG_EFFECT_FADE_MONS_TO_BLACK_REPEATING = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
st.param = 0
|
|
return
|
|
end
|
|
if jt == 1 then
|
|
local age = st.param
|
|
st.param = u8(st.param + 1)
|
|
if bit.band(age, 7) ~= 0 then return end
|
|
-- Bits 3-4 pick the pair, doubled into a row index.
|
|
local index = bit.rshift(bit.band(age, 0x18), 3) * 2
|
|
local first = FADE_BOTH[index + 1] or NORMAL_PAL
|
|
local second = FADE_BOTH[index + 2] or NORMAL_PAL
|
|
if self:playerSide(st) then
|
|
self.monShade.player, self.monShade.enemy = first, second
|
|
else
|
|
self.monShade.enemy, self.monShade.player = first, second
|
|
end
|
|
return
|
|
end
|
|
self.monShade.player, self.monShade.enemy = NORMAL_PAL, NORMAL_PAL
|
|
endEffect(st)
|
|
end
|
|
|
|
-- BattleBGEffects_GetShakeAmount. JT is the total frame count, PARAM's low
|
|
-- nybble the countdown to the next flip (reloaded from its high nybble) and
|
|
-- `turn` the amplitude, negated on every flip. Returns nil once it is done.
|
|
local function shakeAmount(self, st)
|
|
if st.jt == 0 then
|
|
endEffect(st)
|
|
return nil
|
|
end
|
|
st.jt = st.jt - 1
|
|
if bit.band(st.param, 0xf) ~= 0 then
|
|
st.param = u8(st.param - 1)
|
|
return st.turn
|
|
end
|
|
st.param = bit.bor(swap(st.param), st.param)
|
|
st.turn = u8(-st.turn)
|
|
return st.turn
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_SHAKE_SCREEN_X = function(self, st)
|
|
self.scx = shakeAmount(self, st) or 0
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_SHAKE_SCREEN_Y = function(self, st)
|
|
self.scy = shakeAmount(self, st) or 0
|
|
end
|
|
|
|
-- Rollout shakes vertically and hands the negated amount to the first anim
|
|
-- object's Y offset, so the boulder rides the shake instead of floating over
|
|
-- it. The cart's extra DelayFrame here is what makes Rollout's animation run
|
|
-- at half speed; RunBattleAnimScript skips its own frame delay to compensate.
|
|
E.BATTLE_BG_EFFECT_ROLLOUT = function(self, st)
|
|
local amount = shakeAmount(self, st)
|
|
if amount == nil or bit.band(amount, 0x80) ~= 0 then amount = 0 end
|
|
self.scy = amount
|
|
self.rolloutYOffset = u8(-amount)
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_WOBBLE_SCREEN = function(self, st)
|
|
if st.param >= 0x40 then
|
|
self.scx = 0
|
|
return
|
|
end
|
|
self.scx = sine(st.param, 6)
|
|
st.param = u8(st.param + 2)
|
|
end
|
|
|
|
-- Withdraw: a growing number of the attacker's scanlines are pushed off, so
|
|
-- the mon appears to pull into its shell. PARAM's low six bits are how far
|
|
-- to go and its top two the step.
|
|
E.BATTLE_BG_EFFECT_WITHDRAW = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCY", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
st.turn = 1
|
|
elseif jt == 1 then
|
|
local limit = bit.band(st.param, 0x3f)
|
|
if st.turn >= limit then return end
|
|
self:displaceLY(st.turn)
|
|
local step = bit.band(bit.rshift(st.param, 6), 3)
|
|
st.turn = u8(st.turn + step)
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Dig: the same displacement, but it pauses and then eats the pic two
|
|
-- scanlines at a time until the whole window is gone.
|
|
E.BATTLE_BG_EFFECT_DIG = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCY", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
st.turn = 2
|
|
st.param = 0
|
|
elseif jt == 1 then
|
|
if st.param ~= 0 then
|
|
st.param = u8(st.param - 1)
|
|
return
|
|
end
|
|
st.param = 0x10
|
|
incJt(st)
|
|
elseif jt == 2 then
|
|
local span = u8(self.lyEnd - self.lyStart) - 1
|
|
if span < st.turn then return end
|
|
-- Every eighth scanline the effect steps back a state, which is the
|
|
-- pause between digs.
|
|
if bit.band(st.turn, 7) == 0 then st.jt = u8(st.jt - 1) end
|
|
self:displaceLY(st.turn)
|
|
st.turn = u8(st.turn + 2)
|
|
elseif jt == 3 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Tackle: the attacker's rows slide eight pixels towards the target and back.
|
|
-- `turn` is the signed step and `param` the distance travelled so far.
|
|
local function tackleMoveForward(self, st)
|
|
if st.param == u8(-8) or st.param == 8 then incJt(st) end
|
|
self:fillLY(st.param)
|
|
st.param = u8(st.param + st.turn)
|
|
end
|
|
|
|
local function tackleReturn(self, st)
|
|
if st.param == 0 then incJt(st) end
|
|
self:fillLY(st.param)
|
|
st.param = u8(st.param + u8(-st.turn))
|
|
end
|
|
|
|
local function tackleInit(self, st, backwards)
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
-- SCX scrolls the BACKGROUND, so a negative value moves the mon RIGHT: the
|
|
-- player's back pic steps towards the enemy on -2, not +2.
|
|
local forward = self:playerSide(st) and u8(-2) or 2
|
|
if backwards then forward = self:playerSide(st) and 2 or u8(-2) end
|
|
st.param = 0
|
|
st.turn = forward
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_TACKLE = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
tackleInit(self, st, false)
|
|
elseif jt == 1 then
|
|
tackleMoveForward(self, st)
|
|
elseif jt == 2 then
|
|
tackleReturn(self, st)
|
|
elseif jt == 3 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_VITAL_THROW = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
tackleInit(self, st, true)
|
|
elseif jt == 1 then
|
|
tackleMoveForward(self, st)
|
|
elseif jt == 3 then
|
|
tackleReturn(self, st)
|
|
elseif jt == 4 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_BETA_PURSUIT = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
tackleInit(self, st, true)
|
|
elseif jt == 1 then
|
|
tackleMoveForward(self, st)
|
|
elseif jt == 2 then
|
|
tackleReturn(self, st)
|
|
elseif jt == 3 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_WOBBLE_MON = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
st.param = 0
|
|
elseif jt == 1 then
|
|
self:fillLY(sine(st.param, 8))
|
|
st.param = u8(st.param + 4)
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Always the player's rows, and the window is written directly rather than
|
|
-- through SetLCDStatCustoms1 -- this is the wobble the player's own mon does
|
|
-- when it is confused, whoever is attacking.
|
|
E.BATTLE_BG_EFFECT_WOBBLE_PLAYER = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self.lcdc = "SCX"
|
|
self.lyStart, self.lyEnd = 0, 0x37
|
|
st.param = 0
|
|
elseif jt == 1 then
|
|
if st.param >= 0x40 then
|
|
self:resetLCDStatCustom(st)
|
|
return
|
|
end
|
|
self:fillLY(sine(st.param, 6))
|
|
st.param = u8(st.param + 2)
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Two sines an octave apart, which is what makes Flail read as thrashing
|
|
-- rather than swaying.
|
|
E.BATTLE_BG_EFFECT_FLAIL = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
st.turn, st.param = 0, 0
|
|
elseif jt == 1 then
|
|
local wide = sine(st.param, 6)
|
|
local narrow = sine(st.turn, 2)
|
|
self:fillLY(u8(wide + narrow))
|
|
st.turn = u8(st.turn + 8)
|
|
st.param = u8(st.param + 2)
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_VIBRATE_MON = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
st.turn = 1
|
|
st.param = 0x20
|
|
elseif jt == 1 then
|
|
if st.param == 0 then
|
|
self:resetLCDStatCustom(st)
|
|
return
|
|
end
|
|
st.param = u8(st.param - 1)
|
|
-- Flips on the even frames only, so it buzzes at 30 Hz rather than 60.
|
|
if bit.band(st.param, 1) ~= 0 then return end
|
|
st.turn = u8(-st.turn)
|
|
self:fillLY(st.turn)
|
|
end
|
|
end
|
|
|
|
-- BounceDown: the attacker drops in on a cosine and settles, using the same
|
|
-- scanline displacement Withdraw does.
|
|
E.BATTLE_BG_EFFECT_BOUNCE_DOWN = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCY", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
st.turn = 1
|
|
st.param = 0x20
|
|
elseif jt == 1 then
|
|
if st.turn >= 0x38 then return end
|
|
local height = u8(cosine(st.param, 0x10) + 0x10)
|
|
self:displaceLY(u8(st.turn + height))
|
|
st.param = u8(st.param + 2)
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
-- The screen-wide deformations
|
|
--------------------------------------------------------------------------
|
|
--
|
|
-- These thirteen are the ones that write a DIFFERENT value to every scanline
|
|
-- rather than the same one to a band, so they all sit on DeformScreen,
|
|
-- DeformWater or the surf ring above. The shape of each is the ASM's; what
|
|
-- the port cannot reproduce is the CGB writing rSCX mid-frame at sub-pixel
|
|
-- timing, and none of these depend on that -- they depend on the ARRAY, which
|
|
-- is modelled exactly.
|
|
|
|
-- Surf. `.zero` lays a 2-amplitude wave into the ring and falls through to
|
|
-- `.one` on the same frame (ASM fallthrough, not a jumptable branch), and
|
|
-- `.one` declines to do anything at all until some OTHER effect has set
|
|
-- hLCDCPointer -- START_WATER is what the Surf animation queues first.
|
|
E.BATTLE_BG_EFFECT_SURF = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:initSurfWaves(2, 2)
|
|
jt = 1
|
|
end
|
|
if jt == 1 then
|
|
if not self.lcdc then return end
|
|
self:rotateSurfWave()
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Whirlpool: the wave covers the WHOLE screen ($00-$5e) rather than one
|
|
-- battler's rows, and it scrolls vertically (rSCY), so the water rolls
|
|
-- top to bottom behind both mons.
|
|
E.BATTLE_BG_EFFECT_WHIRLPOOL = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self.lcdc = "SCY"
|
|
self.lyStart, self.lyEnd = 0, 0x5e
|
|
self:deformScreen(2, 2)
|
|
elseif jt == 1 then
|
|
self:wavyScreenFX()
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- The three water effects, which the Surf animation drives as a set: START
|
|
-- opens the window and ends itself immediately, WATER does the work, END puts
|
|
-- the registers back.
|
|
E.BATTLE_BG_EFFECT_START_WATER = function(self, st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCY", st)
|
|
endEffect(st)
|
|
end
|
|
|
|
-- WATER is the one effect whose three struct fields are all something else:
|
|
-- PARAM is the sine phase (climbing 4 a frame), BATTLE_TURN is a frame
|
|
-- counter that doubles as the amplitude, and JT_INDEX is the Y position the
|
|
-- deformation is centred on.
|
|
--
|
|
-- `ld a, [hl]` then `inc [hl]` twice leaves `a` holding the PRE-increment
|
|
-- turn, and that is the count DeformWater is called with -- so the figure
|
|
-- grows two scanlines a frame from nothing until the counter passes $20.
|
|
E.BATTLE_BG_EFFECT_WATER = function(self, st)
|
|
local offset = st.param
|
|
st.param = u8(st.param + 4)
|
|
-- (0xff XOR the high nibble) + 4: the amplitude SHRINKS as the counter
|
|
-- climbs, so the wave is widest when it first appears.
|
|
local amplitude = u8(bit.bxor(bit.rshift(bit.band(st.turn, 0xf0), 4), 0xff) + 4)
|
|
local progress = st.jt
|
|
local count = st.turn
|
|
if count >= 0x20 then
|
|
self:clearLYOverrides(0)
|
|
endEffect(st)
|
|
return
|
|
end
|
|
st.turn = u8(st.turn + 2)
|
|
self:deformWater(count, amplitude, offset, progress)
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_END_WATER = function(self, st)
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
|
|
-- Psychic is hardcoded to the whole screen ($00-$5f) whichever side used it,
|
|
-- and only travels every FOURTH frame (`and $3 / ret nz`), which is what makes
|
|
-- it a slow ripple rather than Teleport's shimmer.
|
|
E.BATTLE_BG_EFFECT_PSYCHIC = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self.lcdc = "SCX"
|
|
self.lyStart, self.lyEnd = 0, 0x5f
|
|
self:deformScreen(6, 5)
|
|
st.param = 0
|
|
elseif jt == 1 then
|
|
local counter = st.param
|
|
st.param = u8(st.param + 1)
|
|
if bit.band(counter, 3) ~= 0 then return end
|
|
self:wavyScreenFX()
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Teleport: the same wave as Psychic but only over the user's own rows, and
|
|
-- travelling every frame.
|
|
E.BATTLE_BG_EFFECT_TELEPORT = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
self:deformScreen(6, 5)
|
|
elseif jt == 1 then
|
|
self:wavyScreenFX()
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Night Shade takes its phase step from the struct's PARAM, so the same
|
|
-- effect id gives a long slow roll or a tight ripple depending on what the
|
|
-- script queued it with.
|
|
E.BATTLE_BG_EFFECT_NIGHT_SHADE = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCY", st)
|
|
self:deformScreen(2, st.param)
|
|
elseif jt == 1 then
|
|
self:wavyScreenFX()
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Double Team's afterimage: alternate scanlines are pushed +n and -n, so the
|
|
-- pic reads as two copies of itself a few pixels apart. `.UpdateLYOverrides`
|
|
-- writes the pair (e, -e) down the window and, on an odd-height window,
|
|
-- repeats `e` on the last row -- `srl a` leaves the odd bit in carry and
|
|
-- `ret nc` is what skips that store on an even one.
|
|
local function doubleTeamOverrides(self, value)
|
|
local e = u8(value)
|
|
local d = u8(-e)
|
|
local span = u8(self.lyEnd - self.lyStart)
|
|
local pairs_ = bit.rshift(span, 1)
|
|
local odd = bit.band(span, 1) ~= 0
|
|
local row = self.lyStart
|
|
for _ = 1, pairs_ do
|
|
if row > SCREEN_ROWS then return end
|
|
self.lyBackup[row] = e
|
|
row = row + 1
|
|
if row > SCREEN_ROWS then return end
|
|
self.lyBackup[row] = d
|
|
row = row + 1
|
|
end
|
|
if odd and row <= SCREEN_ROWS then self.lyBackup[row] = e end
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_DOUBLE_TEAM = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
st.turn = 0
|
|
elseif jt == 1 then
|
|
-- Split apart, a pixel a frame, to $10.
|
|
if st.param >= 0x10 then
|
|
incJt(st)
|
|
return
|
|
end
|
|
local value = st.param
|
|
st.param = u8(st.param + 1)
|
|
doubleTeamOverrides(self, value)
|
|
elseif jt == 2 then
|
|
-- Hold, wobbling about the current separation. This state never advances
|
|
-- itself; the script's own `incbgeffect` is what moves it on.
|
|
local wobble = u8(sine(st.turn, 2) + st.param)
|
|
doubleTeamOverrides(self, wobble)
|
|
st.turn = u8(st.turn + 4)
|
|
elseif jt == 3 then
|
|
-- Come back together. The test is `cp $ff`, so a PARAM that started at 0
|
|
-- underflows to $ff and stops there rather than at zero.
|
|
if st.param == 0xff then
|
|
incJt(st)
|
|
return
|
|
end
|
|
local value = st.param
|
|
st.param = u8(st.param - 1)
|
|
doubleTeamOverrides(self, value)
|
|
elseif jt == 5 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
-- jt 4 is a bare `ret`: the gap the script sits in between the two halves.
|
|
end
|
|
|
|
-- Acid Armor: the wave is laid down once and then the whole window is scrolled
|
|
-- DOWN one scanline a frame, with a blank row ($90) fed in at the top -- so
|
|
-- the mon melts into the floor instead of wobbling in place. The two
|
|
-- fix-ups at the bottom clear the last two rows once their values are large
|
|
-- enough to be showing the pic's own bottom edge.
|
|
E.BATTLE_BG_EFFECT_ACID_ARMOR = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCY", st)
|
|
self:deformScreen(2, st.param)
|
|
self.lyBackup[self.lyEnd] = 0
|
|
self.lyBackup[self.lyEnd - 1] = 0
|
|
elseif jt == 1 then
|
|
for row = self.lyEnd, self.lyStart + 1, -1 do
|
|
self.lyBackup[row] = self.lyBackup[row - 1] or 0
|
|
end
|
|
self.lyBackup[self.lyStart] = 0x90
|
|
local last = self.lyBackup[self.lyEnd] or 0
|
|
if last >= 1 and last ~= 0x90 then self.lyBackup[self.lyEnd] = 0 end
|
|
local penultimate = self.lyBackup[self.lyEnd - 1] or 0
|
|
if penultimate >= 2 and penultimate ~= 0x90 then
|
|
self.lyBackup[self.lyEnd - 1] = 0
|
|
end
|
|
elseif jt == 2 then
|
|
self:resetLCDStatCustom(st)
|
|
end
|
|
end
|
|
|
|
-- Wave Deform: the amplitude ramps up to $20 in state 1 and back down to 0 in
|
|
-- state 2, at a fixed phase step of 4. Neither ramp advances the state on its
|
|
-- own -- the script does -- so how far it gets is the script's business.
|
|
E.BATTLE_BG_EFFECT_WAVE_DEFORM_MON = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
elseif jt == 1 then
|
|
if st.param >= 0x20 then return end
|
|
local amplitude = st.param
|
|
st.param = u8(st.param + 1)
|
|
self:deformScreen(amplitude, 4)
|
|
elseif jt == 2 then
|
|
if st.param == 0 then
|
|
self:resetLCDStatCustom(st)
|
|
return
|
|
end
|
|
local amplitude = st.param
|
|
st.param = u8(st.param - 1)
|
|
self:deformScreen(amplitude, 4)
|
|
end
|
|
end
|
|
|
|
-- The two beta send-outs are `; unused` on the cart -- nothing queues them --
|
|
-- but they are in the jumptable, so a mod or a hand-written script can, and
|
|
-- an unimplemented entry would sit in the pool forever.
|
|
--
|
|
-- MON1 writes rBGP per scanline rather than a scroll register: every other
|
|
-- row of the window steps through $00 (all white), $40, $90 and $e4 (normal),
|
|
-- eight frames apart, so the pic fades in through a venetian blind.
|
|
local BETA_SEND_OUT_PALS = { 0x00, 0x40, 0x90, 0xe4 }
|
|
|
|
-- `.SetLYOverridesBackup`: every SECOND scanline, (lyEnd - lyStart) / 2 times.
|
|
local function betaBlind(self, value)
|
|
local count = bit.rshift(u8(self.lyEnd - self.lyStart), 1)
|
|
local row = self.lyStart
|
|
for _ = 1, count do
|
|
if row > SCREEN_ROWS then return end
|
|
self.lyBackup[row] = u8(value)
|
|
row = row + 2
|
|
end
|
|
end
|
|
|
|
-- `.GetLYOverride`: PARAM counts up and its top bits index the palette list,
|
|
-- so each entry is held eight frames. Past the end it returns nil, which is
|
|
-- the `cp $ff` the caller branches on.
|
|
local function betaPal(st)
|
|
local index = bit.rshift(st.param, 3)
|
|
st.param = u8(st.param + 1)
|
|
return BETA_SEND_OUT_PALS[index + 1]
|
|
end
|
|
|
|
E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON1 = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0xe4)
|
|
self:setLCDStatCustoms1("BGP", st)
|
|
self.lyEnd = u8(self.lyEnd + 1)
|
|
for row = self.lyStart, self.lyEnd - 1 do
|
|
if row > SCREEN_ROWS then break end
|
|
self.lyBackup[row] = 0
|
|
end
|
|
st.param = 0
|
|
-- `.zero` falls into `.one`, which is a bare ret.
|
|
elseif jt == 2 then
|
|
local value = betaPal(st)
|
|
if value then
|
|
betaBlind(self, value)
|
|
return
|
|
end
|
|
st.param = 0
|
|
self.lyStart = u8(self.lyStart + 1)
|
|
incJt(st)
|
|
elseif jt == 3 then
|
|
local value = betaPal(st)
|
|
if not value then
|
|
incJt(st)
|
|
return
|
|
end
|
|
betaBlind(self, value)
|
|
-- The second pass also fills the row the blind skipped at the bottom.
|
|
self.lyBackup[self.lyEnd - 1] = u8(value)
|
|
elseif jt == 5 then
|
|
self:resetVideoHRAM()
|
|
endEffect(st)
|
|
end
|
|
-- jt 1 and 4 are bare rets.
|
|
end
|
|
|
|
-- MON2 is a plain DeformScreen whose amplitude and phase step are the SAME
|
|
-- value, counted down from $40 in eighths -- so the wobble starts at 8 and
|
|
-- unwinds to nothing.
|
|
E.BATTLE_BG_EFFECT_BETA_SEND_OUT_MON2 = function(self, st)
|
|
local jt = st.jt
|
|
if jt == 0 then
|
|
incJt(st)
|
|
self:clearLYOverrides(0)
|
|
self:setLCDStatCustoms1("SCX", st)
|
|
st.turn = 0x40
|
|
elseif jt == 1 then
|
|
if st.turn == 0 then
|
|
self:resetLCDStatCustom(st)
|
|
return
|
|
end
|
|
local value = st.turn
|
|
st.turn = u8(st.turn - 1)
|
|
-- `ld a, [hl] / dec [hl] / srl a x3`: the PRE-decrement value, shifted.
|
|
local amount = bit.band(bit.rshift(value, 3), 0x0f)
|
|
self:deformScreen(amount, amount)
|
|
end
|
|
end
|
|
|
|
-- Nothing is left unmodelled. The name stays so a caller (and the tests) can
|
|
-- still ask, and so the answer is checkable rather than a claim in a comment.
|
|
local UNMODELLED = {}
|
|
for _, name in ipairs(UNMODELLED) do
|
|
E[name] = function(_, st) endEffect(st) end
|
|
end
|
|
|
|
--------------------------------------------------------------------------
|
|
|
|
-- ExecuteBGEffects: one pass over the five structs.
|
|
function Pool:playFrame()
|
|
for slot = 1, NUM_EFFECTS do
|
|
local st = self.effects[slot]
|
|
if st.func then
|
|
local fn = E[st.func]
|
|
if fn then
|
|
fn(self, st)
|
|
else
|
|
-- An id with no entry would otherwise sit in the pool forever and
|
|
-- keep the animation from ending.
|
|
endEffect(st)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function Pool:takeSpawns()
|
|
local spawns = self.spawns
|
|
self.spawns = {}
|
|
return spawns
|
|
end
|
|
|
|
BgEffects.EFFECTS = E
|
|
BgEffects.NUM_EFFECTS = NUM_EFFECTS
|
|
BgEffects.NORMAL_PAL = NORMAL_PAL
|
|
BgEffects.SCREEN_ROWS = SCREEN_ROWS
|
|
BgEffects.UNMODELLED = UNMODELLED
|
|
|
|
return BgEffects
|