-- Plays back the original battle move animations (beams, blobs, rings...) -- from data/generated/battle_anims.lua, reimplementing the subanimation -- player in pokered's engine/battle/animations.asm: -- -- PlayAnimation (:164) walks a move's battle_anim rows: subanimation -- rows (with a tileset + per-frame-block delay) and -- 2-byte special-effect rows (SE_*). -- LoadSubanimation (:270) resolves the subanimation type into a transform: -- on the PLAYER's turn every type but ENEMY plays -- untransformed and ENEMY plays HFLIP'd; on the -- ENEMY's turn the type applies as-is and ENEMY -- plays untransformed (GetSubanimationTransform1/2, -- :333/:346). REVERSE plays the frame-block list -- back to front, untransformed. -- PlaySubanimation (:580) draws each frame block at its base coordinate, -- writing OAM entries from a cursor that resets to -- slot 0 at the start of every subanimation row. -- DrawFrameBlock (:3) per-tile transforms (8-bit math, OAM space): -- HVFLIP: y' = 136-(base+dy), x' = 168-(base+dx), -- toggles both flip bits (flags with -- PRIO/PAL bits become no-flip) -- HFLIP: y' = base+dy+40, x' = 168-(base+dx), -- toggles the x-flip bit -- COORDFLIP: y' = (136-base)+dy, x' = (168-base)+dx -- then applies the frame-block mode: -- 0/1: show for `delay` frames, then clear all -- sprites (one extra frame; GROWL skips the -- clear, :145) and restart at OAM slot 0 -- 2: accumulate; no delay, keep the cursor moving -- 3: show for `delay` frames, keep sprites and -- keep the cursor moving (persistent trails) -- 4: show for `delay` frames, keep sprites but -- rewind the cursor (next block overwrites) -- -- Special-effect rows are timed here (each SE_* gets the frame count its -- routine really blocks for -- see SE_FRAMES) and exposed through -- :pollEffects() so the caller can route them into the screen fx layer -- (palette fades, mon-pic slides, screen shakes; BattleState implements -- the visuals). The sprite-emitter effects (AnimationSpiralBallsInward, -- AnimationShootBallsUpward/ShootManyBallsUpward, -- AnimationWaterDropletsEverywhere, AnimationLeavesFalling/PetalsFalling) -- ARE executed here: their OAM trajectories are compiled into sprite -- steps at start() from the original routines' math. -- -- Per-animation-id frame-block effects (DoSpecialEffectByAnimationId, -- data/battle_anims/special_effects.asm) are also compiled in: the -- screen-flash pulses of Mega Punch/Blizzard/Thunderbolt/Explosion..., -- Rock Slide's 1px rumble, and Explosion's user-pic hide, each timed at -- the wSubAnimCounter values the asm checks. -- -- Events carry the row sounds too ({ sound = }): PlayAnimation -- plays each row's MoveSoundTable entry (with its pitch/tempo modifiers) -- as the row starts, via GetMoveSound. -- -- Usage (one :update() per 60fps frame): -- local player = AnimPlayer.new(require("data.generated.battle_anims")) -- player:start("THUNDERBOLT", true) -- ... player:update(); player:draw(); player:pollEffects() ... -- until player:isDone() -- -- start() takes an optional opts table: { shakes = n } replays each -- subanimation row n times, opening every pass with an SFX_TINK event -- and a 40-frame pause -- DoBallShakeSpecialEffects (:739), which rewinds -- the ball-shake subanimation wNumShakes times. { ball = "" } -- marks a ball-toss row with the thrown ball (wCurItem): a MASTER_BALL -- or ULTRA_BALL toss flickers the OBJ palette every frame block -- -- DoBallTossSpecialEffects (:685) XORs rOBP0 with %00111100. local Logger = require("src.core.Logger") local AnimPlayer = {} AnimPlayer.__index = AnimPlayer local SE_PAUSE_FRAMES = 8 -- fallback pacing for unknown SE rows -- Frames each special-effect routine blocks the animation for -- (engine/battle/animations.asm; delays counted from the routines' -- DelayFrames calls). 0 = a bare register write (palette sets). local SE_FRAMES = { SE_DARK_SCREEN_FLASH = 4, -- AnimationFlashScreen: 2f inverted + 2f white SE_FLASH_SCREEN_LONG = 48, -- 12 palettes x (2f + 1f + 1f) over 3 cycles SE_DARK_SCREEN_PALETTE = 0, -- SetAnimationBGPalette writes SE_LIGHT_SCREEN_PALETTE = 0, SE_DARKEN_MON_PALETTE = 0, SE_RESET_SCREEN_PALETTE = 0, SE_SHAKE_SCREEN = 72, -- PredefShakeScreenHorizontally b=8: sum 9f SE_SHAKE_ENEMY_HUD = 44, -- 8 x (2f + 2f) SCX shake + setup Delay3s SE_DELAY_ANIMATION_10 = 10, SE_SLIDE_MON_OFF = 24, -- 8 tile steps x 3f (wSlideMonDelay) SE_SLIDE_ENEMY_MON_OFF = 24, -- same routine, turn flipped SE_SLIDE_MON_HALF_OFF = 19, -- 4 tile steps x 4f + Delay3 SE_SLIDE_MON_UP = 14, -- 7 row shifts x 2f (cyclic wrap) SE_SLIDE_MON_DOWN = 21, -- 7 rows x Delay3 SE_SLIDE_MON_DOWN_AND_HIDE = 19, -- 2 x 8f + Delay3 SE_MOVE_MON_HORIZONTALLY = 3, SE_RESET_MON_POSITION = 3, SE_SHAKE_BACK_AND_FORTH = 96, -- 16 loops x 2 redraws x Delay3 SE_BOUNCE_UP_AND_DOWN = 108, -- 5 x AnimationSlideMonDown + Delay3 SE_SQUISH_MON_PIC = 26, -- 4 loops x 2 x Delay3 + 2f SE_MINIMIZE_MON = 6, SE_SHOW_MON_PIC = 3, SE_SHOW_ENEMY_MON_PIC = 3, SE_HIDE_MON_PIC = 3, SE_HIDE_ENEMY_MON_PIC = 3, SE_BLINK_MON = 60, SE_BLINK_ENEMY_MON = 60, -- 6 x (5f off + 5f on) SE_FLASH_MON_PIC = 4, SE_FLASH_ENEMY_MON_PIC = 4, SE_TRANSFORM_MON = 4, SE_SUBSTITUTE_MON = 3, SE_WAVY_SCREEN = 255, -- AnimationWavyScreen: ld c, $ff frames } -- data/battle_anims/special_effects.asm AnimationIdSpecialEffects: -- per-frame-block effects keyed on the animation id. "flash" = -- AnimationFlashScreen after every frame block. local ANIM_ID_FX = { MEGA_PUNCH = "flash", GUILLOTINE = "flash", MEGA_KICK = "flash", HEADBUTT = "flash", DISABLE = "flash", BUBBLEBEAM = "flash", REFLECT = "flash", SPORE = "flash", BLIZZARD = "blizzard", -- flash at counters 13/9/5/1 HYPER_BEAM = "every4", -- flash when counter % 4 == 0 THUNDERBOLT = "every8", -- flash when counter % 8 == 0 SELFDESTRUCT = "explode", EXPLOSION = "explode", ROCK_SLIDE = "rockslide", -- 1px shakes at 8-11, flash at 1 } -- Anim tiles load at vSprites tile $31 (LoadMoveAnimationTiles), so the -- raw VRAM tile ids the emitter routines poke into OAM are sheet tile -- (id - $31). local BALL_TILE = 0x7a - 0x31 -- AnimationSpiralBallsInward/ShootBalls local DROPLET_TILE = 0x71 - 0x31 -- AnimationWaterDropletsEverywhere (ts 0) local LEAF_TILE = 0x37 - 0x31 -- AnimationLeavesFalling (ts 1) local PETAL_TILE = 0x71 - 0x31 -- AnimationPetalsFalling (ts 1) function AnimPlayer.new(data) return setmetatable({ data = data, images = {}, -- tileset id -> Image | false (load failed) quads = {}, -- tileset id -> { [tile] = Quad } warned = {}, steps = {}, -- { dur = frames, sprites = { {x,y,tile,ts,xf,yf}... } } events = {}, -- { effect = "SE_*", frame = n } in firing order stepIndex = 1, stepLeft = 0, elapsed = 0, eventCursor = 1, }, AnimPlayer) end -- Release the tilesheet images and quads this player built. They live in -- per-instance caches (a fresh AnimPlayer is made per battle), so unlike a -- shared module cache they are dead the moment the battle ends -- freeing -- them here instead of waiting on a GC finalizer keeps grinding battles -- from piling orphaned VRAM up faster than the (Lua-heap-triggered) GC -- reclaims it. function AnimPlayer:release() for _, img in pairs(self.images) do if img and img.release then pcall(img.release, img) end end self.images = {} for _, byTile in pairs(self.quads) do for _, q in pairs(byTile) do if q and q.release then pcall(q.release, q) end end end self.quads = {} end function AnimPlayer:warnOnce(key, fmt, ...) if not self.warned[key] then self.warned[key] = true Logger.warn(fmt, ...) end end -- engine/battle/animations.asm GetSubanimationTransform1/2 local function resolveTransform(subType, attackerIsPlayer) if subType == "ENEMY" then return attackerIsPlayer and "HFLIP" or "NORMAL" end return attackerIsPlayer and "NORMAL" or subType end local function wrap(v) return v % 256 end -- ------------------------------------------------------------------ -- Sprite-emitter special effects, compiled to per-frame sprite steps. -- Coordinates are OAM space (screen x+8 / y+16) like the frame blocks; -- obp marks which hardware OBJ palette the routine ran under ("e4" = -- ambient rOBP0, "f0" = wAnimPalette on SGB, "obp1" = rOBP1 $6c). -- ------------------------------------------------------------------ -- AnimationSpiralBallsInward (:1480): 3 ball sprites walk the 21-entry -- coordinate spiral, one entry per 5 frames, player-anchored at (0,0) -- and enemy-anchored at (y-40, x+80); ends with AnimationFlashScreen. local SPIRAL_COORDS = { -- y, x pairs (SpiralBallAnimationCoordinates) {0x38,0x28},{0x40,0x18},{0x50,0x10},{0x60,0x18},{0x68,0x28},{0x60,0x38}, {0x50,0x40},{0x40,0x38},{0x40,0x28},{0x46,0x1E},{0x50,0x18},{0x5B,0x1E}, {0x60,0x28},{0x5B,0x32},{0x50,0x38},{0x46,0x32},{0x48,0x28},{0x50,0x20}, {0x58,0x28},{0x50,0x30},{0x50,0x28}, } local function spiralBallSteps(attackerIsPlayer) local by, bx = 0, 0 if not attackerIsPlayer then by, bx = -40, 80 end local steps = {} for k = 1, #SPIRAL_COORDS - 2 do -- the step aborts when a ball reads the -1 local sprites = {} for i = 0, 2 do local c = SPIRAL_COORDS[k + i] sprites[#sprites + 1] = { x = wrap(bx + c[2]), y = wrap(by + c[1]), tile = BALL_TILE, ts = 0, obp = "e4" } end steps[#steps + 1] = { dur = 5, sprites = sprites } end return steps end -- _AnimationShootBallsUpward (:1638): a pillar of `n` balls at x, from -- baseY+8*i, each moving up 4px per frame and vanishing at baseY+8. local function shootPillarSteps(steps, n, x, baseY) local ys = {} for i = 1, n do ys[i] = baseY + 8 * i end local function snapshot() local sprites = {} for i = 1, n do if ys[i] then sprites[#sprites + 1] = { x = x, y = wrap(ys[i]), tile = BALL_TILE, ts = 0, obp = "e4" } end end return sprites end steps[#steps + 1] = { dur = 1, sprites = snapshot() } -- init DelayFrame local alive = n while alive > 0 do for i = 1, n do if ys[i] then if ys[i] == baseY + 8 then ys[i] = nil alive = alive - 1 else ys[i] = ys[i] - 4 end end end steps[#steps + 1] = { dur = 1, sprites = snapshot() } end end -- AnimationShootBallsUpward (:1617): one 5-ball pillar; player at -- (x=5*8, baseY=6*8), enemy at (x=16*8, baseY=0). local function shootBallsSteps(attackerIsPlayer) local steps = {} if attackerIsPlayer then shootPillarSteps(steps, 5, 5 * 8, 6 * 8) else shootPillarSteps(steps, 5, 16 * 8, 0) end return steps end -- AnimationShootManyBallsUpward (:1686): six sequential 4-ball pillars. local function shootManyBallsSteps(attackerIsPlayer) local xs = attackerIsPlayer and { 0x10, 0x40, 0x28, 0x18, 0x38, 0x30 } or { 0x60, 0x90, 0x78, 0x68, 0x88, 0x80 } local baseY = attackerIsPlayer and 0x50 or 0x28 local steps = {} for _, x in ipairs(xs) do shootPillarSteps(steps, 4, x, baseY) end return steps end -- AnimationWaterDropletsEverywhere (:1114): 64 one-frame passes of -- droplet rows; the 8-bit x cursor persists across passes, which is -- what makes the field scroll. local function waterDropletSteps() local steps = {} local baseX = 0xF0 -- ld a, -16 for _ = 1, 32 do for _, startY in ipairs({ 16, 24 }) do local sprites = {} local y = startY while true do baseX = wrap(baseX + 27) sprites[#sprites + 1] = { x = baseX, y = y, tile = DROPLET_TILE, ts = 0, obp = "e4" } if baseX >= 144 then baseX = wrap(baseX - 168) y = y + 16 if y >= 112 then break end end end steps[#steps + 1] = { dur = 1, sprites = sprites } end end return steps end -- AnimationFallingObjects (:2335): n objects fall 2px per 3-frame tick, -- swaying via the delta-X table (index advances each tick, direction -- flips past index 8), until object 1 reaches y=104. local FALLING_X = { 0x38,0x40,0x50,0x60,0x70,0x88,0x90,0x56,0x67,0x4A, 0x77,0x84,0x98,0x32,0x22,0x5C,0x6C,0x7D,0x8E,0x99 } local FALLING_M = { 0x00,0x84,0x06,0x81,0x02,0x88,0x01,0x83,0x05,0x89, 0x09,0x80,0x07,0x87,0x03,0x82,0x04,0x85,0x08,0x86 } local FALLING_DX = { [0]=0, 1, 3, 5, 7, 9, 11, 13, 15 } local function fallingObjectSteps(n, tile, obp) local objs = {} for i = 1, n do objs[i] = { y = (i == 1) and 0 or 8 * i, x = FALLING_X[i], m = FALLING_M[i], xf = false } end local steps = {} while objs[1].y ~= 104 do local sprites = {} for i = 1, n do local o = objs[i] -- FallingObjects_UpdateMovementByte runs before the OAM update local left = o.m >= 0x80 local idx = (o.m % 0x80) + 1 if idx == 9 then left = not left idx = 0 end o.m = (left and 0x80 or 0) + idx o.y = o.y + 2 if o.y >= 112 then o.y = 160 end -- parked off-screen local dx = FALLING_DX[idx] o.x = left and wrap(o.x - dx) or wrap(o.x + dx) o.xf = left sprites[#sprites + 1] = { x = o.x, y = o.y, tile = tile, ts = 1, xf = o.xf, obp = obp } end steps[#steps + 1] = { dur = 3, sprites = sprites } if #steps > 120 then break end -- safety; the asm exits at 52 ticks end return steps end -- effect id -> compiled sprite steps local EMITTERS = { SE_SPIRAL_BALLS_INWARD = function(isPlayer) return spiralBallSteps(isPlayer), "flash" end, SE_SHOOT_BALLS_UPWARD = function(isPlayer) return shootBallsSteps(isPlayer) end, SE_SHOOT_MANY_BALLS_UPWARD = function(isPlayer) return shootManyBallsSteps(isPlayer) end, SE_WATER_DROPLETS_EVERYWHERE = function() return waterDropletSteps() end, -- AnimationLeavesFalling runs under wAnimPalette ($f0 on SGB); -- petals keep the ambient $e4 SE_LEAVES_FALLING = function() return fallingObjectSteps(3, LEAF_TILE, "f0") end, SE_PETALS_FALLING = function() return fallingObjectSteps(20, PETAL_TILE, "e4") end, } -- One OAM entry for tile `t` of a frame block anchored at base coord `bc`, -- with the subanimation transform applied (DrawFrameBlock). local function placeTile(transform, bc, t, tileset) local x, y, xf, yf if transform == "HVFLIP" then y = wrap(136 - wrap(bc.y + t.y)) x = wrap(168 - wrap(bc.x + t.x)) -- the engine compares the whole flags byte: plain/xflip/yflip toggle -- both bits, any other combination (both, PRIO, PAL1) becomes no-flip local plain = not (t.prio or t.pal1) if plain and not t.xflip and not t.yflip then xf, yf = true, true elseif plain and t.xflip and not t.yflip then xf, yf = false, true elseif plain and t.yflip and not t.xflip then xf, yf = true, false else xf, yf = false, false end elseif transform == "HFLIP" then y = wrap(wrap(bc.y + t.y) + 40) x = wrap(168 - wrap(bc.x + t.x)) xf, yf = not t.xflip, t.yflip elseif transform == "COORDFLIP" then y = wrap(wrap(136 - bc.y) + t.y) x = wrap(wrap(168 - bc.x) + t.x) xf, yf = t.xflip, t.yflip else -- NORMAL (and REVERSE, which only reorders the block list) y = wrap(bc.y + t.y) x = wrap(bc.x + t.x) xf, yf = t.xflip, t.yflip end -- OAM_PAL1 tiles render through rOBP1 ($6c); the rest use rOBP0 -- (= wAnimPalette during subanimations: $f0 on SGB, $e4 on DMG) return { x = x, y = y, tile = t.tile, ts = tileset, xf = xf, yf = yf, obp = t.pal1 and "obp1" or "f0" } end -- Compile the move's battle_anim rows into a list of timed steps by -- simulating the OAM buffer, so :update()/:draw() are trivial. function AnimPlayer:start(moveId, attackerIsPlayer, opts) self.steps, self.events = {}, {} self.stepIndex, self.stepLeft = 1, 0 self.elapsed, self.eventCursor = 0, 1 local anim = self.data and self.data.moveAnims and self.data.moveAnims[moveId] if not anim then self:warnOnce("move:" .. tostring(moveId), "AnimPlayer: no animation data for move %s", tostring(moveId)) return end local steps, events = self.steps, self.events local oam, oamMax = {}, 0 local frame = 0 -- DoGrowlSpecialEffects (:928): after every frame block it copies the -- note sprite's 4 OAM entries to a second, untouched slot; since GROWL -- also skips AnimationCleanOAM between blocks (the mode 0/1 branch -- below), that copy from the PREVIOUS block is still on screen -- at -- its old base coordinate -- while the current block draws, so two -- notes are visible each frame, one trailing a step behind the other local growlNoteTrail local function emit(dur, spritesOverride) if dur < 1 then dur = 1 end local sprites = spritesOverride if not sprites then sprites = {} for i = 1, oamMax do local s = oam[i] if s then sprites[#sprites + 1] = s end end end steps[#steps + 1] = { dur = dur, sprites = sprites } frame = frame + dur end -- AnimationFlashScreen (4 blocking frames), reused by the per-block -- animation-id effects; same visual as an SE_DARK_SCREEN_FLASH row local function flashScreen() events[#events + 1] = { effect = "SE_DARK_SCREEN_FLASH", frame = frame } emit(4) end local idFx = ANIM_ID_FX[moveId] -- DoBallTossSpecialEffects (:685): while a Master or Ultra ball is -- tossed (wCurItem <= ULTRA_BALL), the per-block special effect XORs -- rOBP0 with %00111100, complementing colors 1 and 2 -- the ball -- flickers between the $f0 and $cc shade maps block to block. The -- effect runs AFTER each block displays, so block 1 shows normal. -- PlayAnimation pushes rOBP0 around every subanimation row (:246-251 -- / :259-262), so the ambient palette returns when the toss ends. -- opts.ballFlicker carries the ball record's flicker flag; the id -- check covers callers that only pass the ball item. local wantsFlicker if opts and opts.ballFlicker ~= nil then wantsFlicker = opts.ballFlicker else wantsFlicker = opts and (opts.ball == "MASTER_BALL" or opts.ball == "ULTRA_BALL") end local ballFlicker = wantsFlicker and (moveId == "TOSS_ANIM" or moveId == "GREATTOSS_ANIM" or moveId == "ULTRATOSS_ANIM") local obp0Flip = false for _, row in ipairs(anim.seq) do -- PlayAnimation/PlaySubanimation: each row's sound byte is a move id -- whose MoveSoundTable entry (sfx + pitch/tempo modifiers) plays as -- the row starts (GetMoveSound) if row.sound then events[#events + 1] = { sound = row.sound, frame = frame } end if row.effect then local emitter = EMITTERS[row.effect] if emitter then -- the emitter routines write OAM from slot 0 and clean up after oam, oamMax = {}, 0 local emSteps, tailFx = emitter(attackerIsPlayer) events[#events + 1] = { effect = row.effect, frame = frame } for _, st in ipairs(emSteps) do emit(st.dur, st.sprites) end emit(1, {}) -- AnimationCleanOAM / ClearSprites if tailFx == "flash" then flashScreen() end else local dur = SE_FRAMES[row.effect] events[#events + 1] = { effect = row.effect, frame = frame, dur = dur or SE_PAUSE_FRAMES } if dur == nil then dur = SE_PAUSE_FRAMES end if dur > 0 then emit(dur) end end else local sub = self.data.subanims and self.data.subanims[row.subanim] if not (sub and sub.blocks) then self:warnOnce("subanim:" .. tostring(row.subanim), "AnimPlayer: %s references unknown subanimation %s", tostring(moveId), tostring(row.subanim)) else local transform = resolveTransform(sub.type, attackerIsPlayer) local first, last, dir = 1, #sub.blocks, 1 if transform == "REVERSE" then first, last, dir = last, first, -1 end -- DoBallShakeSpecialEffects: each ball shake opens with SFX_TINK -- and a 40-frame pause, then rewinds the same subanimation; the -- mode-4 frame blocks persist, so the resting ball stays visible -- through the pauses between wobbles for _ = 1, (opts and opts.shakes) or 1 do if opts and opts.shakes then events[#events + 1] = { effect = "SFX_TINK", frame = frame } emit(40) end local dest = 1 -- PlaySubanimation resets the OAM cursor per row local nblocks = math.abs(last - first) + 1 local played = 0 for bi = first, last, dir do local entry = sub.blocks[bi] local fb = self.data.frameBlocks and self.data.frameBlocks[entry.block] local bc = self.data.baseCoords and self.data.baseCoords[entry.coord] if not (fb and bc) then self:warnOnce("block:" .. tostring(entry.block) .. ":" .. tostring(entry.coord), "AnimPlayer: %s references missing frame block/coord", tostring(moveId)) else for j = 1, #fb do oam[dest + j - 1] = placeTile(transform, bc, fb[j], row.tileset) end if obp0Flip then -- rOBP0 is complemented right now: this block's rOBP0 -- tiles show with colors 1/2 swapped ($f0 -> $cc) for j = 1, #fb do local t = oam[dest + j - 1] if t.obp == "f0" then t.obp = "f0x" end end end if dest + #fb - 1 > oamMax then oamMax = dest + #fb - 1 end local mode = entry.mode if mode == 2 then -- accumulate, no frame shown yet dest = dest + #fb elseif mode == 3 then -- show and persist emit(row.delay) dest = dest + #fb elseif mode == 4 then -- show; next block overwrites emit(row.delay) else -- 0/1: show, then clean the OAM buffer if moveId == "GROWL" then -- GROWL quirk: sprites persist (no clean), plus the -- previous block's note copy draws alongside this one local current = {} for i = 1, oamMax do if oam[i] then current[#current + 1] = oam[i] end end local shown = current if growlNoteTrail then shown = {} for _, s in ipairs(current) do shown[#shown + 1] = s end for _, s in ipairs(growlNoteTrail) do shown[#shown + 1] = s end end emit(row.delay, shown) growlNoteTrail = current else emit(row.delay + 1) -- AnimationCleanOAM's extra frame oam, oamMax = {}, 0 end dest = 1 end -- DoSpecialEffectByAnimationId runs after every frame -- block with wSubAnimCounter = blocks remaining played = played + 1 if ballFlicker then obp0Flip = not obp0Flip end if idFx then local counter = nblocks - played + 1 if idFx == "flash" or (idFx == "every4" and counter % 4 == 0) or (idFx == "every8" and counter % 8 == 0) or (idFx == "blizzard" and (counter == 13 or counter == 9 or counter == 5 or counter == 1)) then flashScreen() elseif idFx == "explode" then if counter % 4 == 0 then flashScreen() end if counter == 1 then -- DoExplodeSpecialEffects: the user's pic vanishes events[#events + 1] = { effect = "SE_HIDE_ATTACKER_PIC", frame = frame } end elseif idFx == "rockslide" then if counter >= 8 and counter <= 11 then -- 1px horizontal + vertical rumble (15 blocking frames) events[#events + 1] = { effect = "SE_ROCK_SLIDE_SHAKE", frame = frame, dur = 15 } emit(15) elseif counter == 1 then flashScreen() end end end end end end end end end local firstStep = steps[1] self.stepLeft = firstStep and firstStep.dur or 0 end -- Advance one frame (call once per 60fps tick). function AnimPlayer:update() if self:isDone() then return end self.elapsed = self.elapsed + 1 self.stepLeft = self.stepLeft - 1 while self.stepLeft <= 0 do self.stepIndex = self.stepIndex + 1 local st = self.steps[self.stepIndex] if not st then break end self.stepLeft = st.dur end end function AnimPlayer:isDone() return self.steps[self.stepIndex] == nil end -- SE_* rows whose time has come since the last poll: -- returns { { effect = "SE_...", frame = n }, ... } (possibly empty). function AnimPlayer:pollEffects() local fired = {} local events = self.events while self.eventCursor <= #events and events[self.eventCursor].frame <= self.elapsed do fired[#fired + 1] = events[self.eventCursor] self.eventCursor = self.eventCursor + 1 end return fired end function AnimPlayer:sheetImage(ts) local cached = self.images[ts] if cached ~= nil then return cached or nil end local sheet = self.data.tilesheets and self.data.tilesheets[ts] local ok, img = false, nil if sheet and love and love.graphics and love.graphics.newImage then ok, img = pcall(love.graphics.newImage, sheet.path) end if not (ok and img) then self:warnOnce("sheet:" .. tostring(ts), "AnimPlayer: battle anim tilesheet %s unavailable", tostring(sheet and sheet.path or ts)) img = false end self.images[ts] = img or false return self.images[ts] or nil end function AnimPlayer:tileQuad(ts, tile) local sheet = self.data.tilesheets[ts] if not sheet or tile >= sheet.tiles then return nil end local perSheet = self.quads[ts] if not perSheet then perSheet = {} self.quads[ts] = perSheet end local q = perSheet[tile] if q == nil and love and love.graphics and love.graphics.newQuad then local cols = math.floor(sheet.width / 8) q = love.graphics.newQuad((tile % cols) * 8, math.floor(tile / cols) * 8, 8, 8, sheet.width, sheet.height) perSheet[tile] = q end return q end -- Draw the current frame's tiles onto the 160x144 battle canvas. -- colorFn (optional): function(sprite, px, py) -> {c1,c2,c3} SGB colors -- (0-1 RGB triples) for the sprite's three opaque shades at screen -- pixel (px, py), or nil to draw the raw DMG grays. BattleState -- supplies the SGB zone palette + OBJ palette mapping. function AnimPlayer:draw(colorFn) local st = self.steps[self.stepIndex] if not st then return end self:drawSprites(st.sprites, colorFn) end -- The last compiled step's sprites, or nil. After a capture the -- SHAKE_ANIM chain ends on the resting closed ball (its mode-4 frame -- blocks persist), which the GB leaves in OAM through the caught text; -- BattleState keeps drawing it via drawSprites. function AnimPlayer:finalSprites() local last = self.steps[#self.steps] return last and last.sprites or nil end -- two colorFn results (three 0-1 RGB triples) resolve the same palette? local function sameColors(a, b) if a == b then return true end for i = 1, 3 do local p, q = a[i], b[i] if p[1] ~= q[1] or p[2] ~= q[2] or p[3] ~= q[3] then return false end end return true end -- Draw one compiled step's OAM sprites. With colorFn, each sprite is -- drawn through the PaletteFX shade-remap shader. The SGB colorized -- the finished DMG picture per 8x8 screen cell (the ATTR_BLK regions -- know nothing of OAM), so a tile overlapping a palette boundary shows -- each region's colors on the pixels inside it: colorFn is sampled -- once per attribute cell the 8x8 tile touches (up to 4), and cells -- that resolve to a different palette than the first are repainted -- through a scissor clipped to the cell. function AnimPlayer:drawSprites(sprites, colorFn) local g = love and love.graphics local shader if colorFn and g and g.setShader then shader = require("src.render.PaletteFX").shader() end local slices = shader and g.getScissor and g.intersectScissor and g.setScissor for i = 1, #sprites do local s = sprites[i] -- hardware hides sprites at the OAM extremes (y=0/y>=160, x=0/x>=168); -- wrapped offsets rely on this to park tiles offscreen if s.x > 0 and s.x < 168 and s.y > 0 and s.y < 160 then local img = self:sheetImage(s.ts) local quad = img and self:tileQuad(s.ts, s.tile) if quad then local rx, ry = s.x - 8, s.y - 16 -- screen-space rect of the tile local function blit() g.draw(img, quad, rx + (s.xf and 8 or 0), ry + (s.yf and 8 or 0), 0, s.xf and -1 or 1, s.yf and -1 or 1) end -- the attribute cell holding the tile's top-left pixel local cx = math.floor(rx / 8) * 8 local cy = math.floor(ry / 8) * 8 local colors = shader and colorFn(s, cx, cy) if colors then g.setShader(shader) -- c0 is the transparent color-0 slot; send anything shader:send("c0", colors[1]) shader:send("c1", colors[1]) shader:send("c2", colors[2]) shader:send("c3", colors[3]) end blit() if colors and slices and (cx ~= rx or cy ~= ry) then -- unaligned: the tile spills into up to 3 more cells; repaint -- the ones whose zone palette differs (opaque overdraw -- GB -- tiles have binary alpha) local function slice(px, py) if px < 0 or py < 0 or px >= 160 or py >= 144 then return -- fully off-canvas end local cc = colorFn(s, px, py) if not cc or sameColors(cc, colors) then return end local s1, s2, s3, s4 = g.getScissor() g.intersectScissor(px, py, 8, 8) shader:send("c0", cc[1]) shader:send("c1", cc[1]) shader:send("c2", cc[2]) shader:send("c3", cc[3]) blit() if s1 then g.setScissor(s1, s2, s3, s4) else g.setScissor() end end local cx2 = math.floor((rx + 7) / 8) * 8 local cy2 = math.floor((ry + 7) / 8) * 8 if cx2 ~= cx then slice(cx2, cy) end if cy2 ~= cy then slice(cx, cy2) end if cx2 ~= cx and cy2 ~= cy then slice(cx2, cy2) end end if colors then g.setShader() end end end end end return AnimPlayer