-- 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