-- Drawing for the Gen 2 battle-animation runtime. -- -- src/battle/gen2/AnimRunner.lua produces two things a frame: a list of OAM -- entries (the OBJ layer) and a set of BG register writes. This turns them -- into draw calls, and it is the only half of the runtime that touches love. -- -- The BG half is the interesting one. A Gen 2 battle animation shakes and -- sinks its mons through wLYOverridesBackup -- a per-scanline value the LCD -- STAT interrupt writes into rSCX or rSCY as the beam passes -- so the port -- draws the battle panel into a canvas and then blits it back one scanline at -- a time at that scanline's own offset. 144 quads a frame is nothing, and it -- is the only model that gets Tackle (every row of the attacker moves the same -- way) and Withdraw (a growing number of rows are pushed off while the rest -- stay) both right out of the same data. -- -- OBJs are NOT affected by SCX/SCY, so they are drawn after the blit, at their -- own coordinates. local bit = require("bit") local Assets = require("src.render.Assets") local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") local BattleAnimView = {} BattleAnimView.__index = BattleAnimView local SCREEN_W, SCREEN_H = 160, 144 -- An OBJ at OAM (x, y) draws at (x - 8, y - 16). local OAM_X_BIAS, OAM_Y_BIAS = 8, 16 local OAM_YFLIP, OAM_XFLIP = 0x40, 0x20 -- Every coordinate the runtime produces is a byte, and the ones that mean -- "left of / above the origin" arrive as two's complement. local function signed(value) value = (value or 0) % 256 return value < 0x80 and value or value - 256 end -- data: the cache's battle_anims.lua; palettes: the cache's palettes.lua function BattleAnimView.new(data, palettes) local self = setmetatable({}, BattleAnimView) self.data = data or {} self.palettes = palettes self.images = {} self.quads = {} self.canvas = nil self.blitQuad = nil return self end function BattleAnimView:image(path) if not path then return nil end local cached = self.images[path] if cached == nil then -- `and` would truncate pcall's second return, so this cannot fold into a -- one-liner: every sheet would come back "unavailable". local ok, image = pcall(Assets.image, path) self.images[path] = (ok and image) or false cached = self.images[path] end return cached or nil end function BattleAnimView:quad(sheetName, index, wide, image) local key = sheetName .. ":" .. index local quad = self.quads[key] if not quad then local w, h = image:getDimensions() quad = love.graphics.newQuad( (index % wide) * 8, math.floor(index / wide) * 8, 8, 8, w, h) self.quads[key] = quad end return quad end -- Which loaded sheet a tile id falls in. The runner's `loaded` list is in -- load order and each entry knows its base tile and its length, which is the -- same walk GetBattleAnimTileOffset does in reverse. local function sheetForTile(runner, tile) for i = #runner.loaded, 1, -1 do local entry = runner.loaded[i] if tile >= entry.tile and tile < entry.tile + math.max(entry.tiles, 1) then return entry, tile - entry.tile end end return nil end -- PAL_BATTLE_OB_ENEMY and PAL_BATTLE_OB_PLAYER are the two battlers' own -- colours; the other six are the fixed block from -- gfx/battle_anims/battle_anims.pal, which the extractor reads into -- palettes.battleObjects. function BattleAnimView:objPalette(name, battle) if name == "PAL_BATTLE_OB_ENEMY" then local enemy = battle and battle.enemy return enemy and Palettes.monColors(self.palettes, enemy.species, enemy.shiny) end if name == "PAL_BATTLE_OB_PLAYER" then local player = battle and battle.player return player and Palettes.monColors(self.palettes, player.species, player.shiny) end local set = self.palettes and self.palettes.battleObjects return set and set[name] or nil end -- One frame's OBJ layer. function BattleAnimView:drawObjects(runner, battle) local G = love.graphics G.setColor(1, 1, 1, 1) for _, obj in ipairs(runner:oam()) do local entry, index = sheetForTile(runner, obj.tile) -- The two battler-pic pseudo-sheets are the mons' own tiles; nothing in -- the cache holds them as a sheet, so they are simply not drawn rather -- than drawn from the wrong image. if entry and not entry.battler then local sheet = (self.data.gfx or {})[entry.gfx] local image = sheet and self:image(sheet.image) if image then local wide = sheet.wide or 8 local quad = self:quad(entry.gfx, index, wide, image) local _, sy = quad:getViewport() local _, ih = image:getDimensions() if sy < ih then local x = obj.x - OAM_X_BIAS local y = obj.y - OAM_Y_BIAS -- love flips about the draw origin, so a flipped 8x8 sprite has to -- be pushed one cell back along that axis. local sxScale = bit.band(obj.attr, OAM_XFLIP) ~= 0 and -1 or 1 local syScale = bit.band(obj.attr, OAM_YFLIP) ~= 0 and -1 or 1 local ox = sxScale < 0 and 8 or 0 local oy = syScale < 0 and 8 or 0 local colors = self:objPalette(obj.palette, battle) local function body() G.draw(image, quad, x + ox, y + oy, 0, sxScale, syScale) end if colors and GbcPalette.available() then GbcPalette.with(colors, body) else body() end end end end end end -- True when the BG layer needs the scanline treatment at all; a plain -- animation (most of them) skips the canvas entirely. local function needsCanvas(runner) local bg = runner.bg -- engine/battle_anims/bg_effects.asm:448-465: a lifted battler row stays -- out of the BG until the next pic redraw, so those frames stay baked too. local lifted = bg.liftedRows if lifted and (lifted.player or lifted.enemy) then return true end if bg.scx ~= 0 or bg.scy ~= 0 then return true end if not bg.lcdc then return false end if bg.lyEnd <= bg.lyStart then return false end for row = bg.lyStart, math.min(bg.lyEnd, SCREEN_H) - 1 do if (bg.lyBackup[row] or 0) ~= 0 then return true end end return false end -- The battle background is BG colour 0 everywhere the two pic boxes and the -- text box are not, which Chrome.clear draws as plain white; anything the -- scanline blit exposes has to be that colour and not a hole. function BattleAnimView:fillBackground() local G = love.graphics G.setColor(1, 1, 1, 1) G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) end -- One reusable quad, re-aimed per scanline. A row shifted by `dx` is drawn -- CLIPPED to the 160-pixel screen rather than allowed to hang over the edge: -- the cart's BG map wraps, so a scrolled scanline never spills past the LCD. function BattleAnimView:blitRow(row, dx, dy) local canvas = self.canvas if not self.blitQuad then self.blitQuad = love.graphics.newQuad(0, 0, SCREEN_W, 1, SCREEN_W, SCREEN_H) end local srcX, width, destX = 0, SCREEN_W, dx if dx > 0 then width = SCREEN_W - dx elseif dx < 0 then srcX, width, destX = -dx, SCREEN_W + dx, 0 end if width <= 0 then return end self.blitQuad:setViewport(srcX, row, width, 1, SCREEN_W, SCREEN_H) love.graphics.draw(canvas, self.blitQuad, destX, row + dy) end -- Draw the battle panel into the blit canvas, optionally with an rBGP byte -- folded into every palette on the way in. -- -- The byte is set on GbcPalette rather than applied afterwards, which is what -- makes it exact: the panel is still being DRAWN, so its palettes can take -- CopyPals' permutation before they ever reach the shader. That is bit for bit -- what DmgToCgbBGPals does, and it is why nothing here has to guess which -- palette entry produced a finished pixel. -- -- pcall so a drawBg that throws cannot leave the byte standing on GbcPalette -- for the rest of the frame. function BattleAnimView:bake(drawBg, palByte) local G = love.graphics if not self.canvas then self.canvas = G.newCanvas(SCREEN_W, SCREEN_H) self.canvas:setFilter("nearest", "nearest") end local previousCanvas = G.getCanvas() local previousBgp = GbcPalette.setBgp(palByte) G.setCanvas(self.canvas) G.clear(0, 0, 0, 0) -- A love canvas does NOT reset the transform: without this the panel is -- drawn at whatever scale and offset the caller was already under, and then -- scaled again on the way back out. G.push() G.origin() local ok, err = pcall(drawBg) G.pop() G.setCanvas(previousCanvas) GbcPalette.setBgp(previousBgp) if not ok then error(err, 0) end end -- The rBGP window's scanlines grouped by the byte they hold, identity first. -- -- `.SetLYOverridesBackup` writes one value on every SECOND row and leaves the -- rest on whatever ClearLYOverrides put there, so a beta send-out frame holds -- two bytes and FadeMonsToBlackRepeating's DMG path holds two bands of one -- each. Grouping means the panel is re-baked twice a frame rather than 144 -- times, and the grouping is by VALUE so a table that happens to repeat costs -- nothing extra. local function bgpBands(bg) local base = bg.bgp or GbcPalette.BGP_IDENTITY local order, bands = {}, {} for row = 0, SCREEN_H - 1 do local inWindow = row >= bg.lyStart and row < bg.lyEnd -- Outside the window the register still reads whatever wBGP holds. local byte = inWindow and (bg.lyBackup[row] or base) or base local band = bands[byte] if not band then band = { byte = byte, rows = {} } bands[byte] = band order[#order + 1] = band end band.rows[#band.rows + 1] = row end -- The base band first so the fillBackground below it happens before any blit -- and the common band is the one drawn from the first bake. table.sort(order, function(a, b) if a.byte == b.byte then return false end if a.byte == base then return true end if b.byte == base then return false end return a.rows[1] < b.rows[1] end) return order end -- engine/battle_anims/anim_commands.asm:1293 BattleAnim_SetBGPals function BattleAnimView:panelPalettes(battle) local list = {} local shades = {} for index = 1, 4 do shades[index] = GbcPalette.color(nil, index) end list[#list + 1] = shades local function bracket(pair) if not (pair and pair[1] and pair[2]) then return end list[#list + 1] = { { 255, 255, 255 }, { pair[1][1], pair[1][2], pair[1][3] }, { pair[2][1], pair[2][2], pair[2][3] }, { 0, 0, 0 }, } end for _, side in ipairs({ "player", "enemy" }) do local mon = battle and battle[side] local colors = mon and Palettes.monColors(self.palettes, mon.species, mon.shiny) if colors then list[#list + 1] = colors end end local hpBar = self.palettes and self.palettes.hpBar if hpBar then bracket(hpBar.green) bracket(hpBar.yellow) bracket(hpBar.red) end bracket(self.palettes and self.palettes.expBar) return list end -- Runs `drawBg` (the battle panel) and puts it on screen through the -- animation's BG registers; skips the canvas when nothing needs one. function BattleAnimView:present(runner, drawBg, battle) if not (love and love.graphics) then return end local bg = runner.bg local invert = bg.bgp and bg.bgp ~= GbcPalette.BGP_IDENTITY and bg.lcdc ~= "BGP" and GbcPalette.remapShader() ~= nil if not invert and not needsCanvas(runner) then drawBg() return end local G = love.graphics -- hLCDCPointer can also aim at rBGP, in which case each scanline gets its own -- PALETTE rather than its own scroll (the beta send-out effects, and -- FadeMonsToBlackRepeating on the DMG path). Nothing scrolls on that path, -- so the whole of it is: bake the panel once per distinct byte and blit that -- byte's rows out of it. if bg.lcdc == "BGP" and GbcPalette.available() then local baseX, baseY = -signed(bg.scx), -signed(bg.scy) local bands = bgpBands(bg) local filled = false for _, band in ipairs(bands) do self:bake(drawBg, band.byte) if not filled then self:fillBackground() filled = true end G.setColor(1, 1, 1, 1) for _, row in ipairs(band.rows) do self:blitRow(row, baseX, baseY) end end return end self:bake(drawBg, nil) local remapped = invert and GbcPalette.useRemap(self:panelPalettes(battle), bg.bgp) -- A shifted scanline exposes the blank tile beside the pic boxes; without -- this the exposed strip is the canvas's own transparency. self:fillBackground() G.setColor(1, 1, 1, 1) -- hSCX / hSCY move the whole background; the per-scanline overrides only -- apply inside the effect's own window. local baseX, baseY = -signed(bg.scx), -signed(bg.scy) for row = 0, SCREEN_H - 1 do local dx, dy = baseX, baseY local inWindow = bg.lcdc and row >= bg.lyStart and row < bg.lyEnd if inWindow and bg.lcdc ~= "BGP" then local value = signed(bg.lyBackup[row] or 0) if bg.lcdc == "SCX" then dx = -value else dy = -value end end -- A row scrolled to $90 is showing a blank part of the map: skip it, which -- is what makes Withdraw and Dig look like the mon sinking out of sight. if (bg.lyBackup[row] or 0) ~= 0x90 or not bg.lcdc or bg.lcdc == "BGP" or not inWindow then self:blitRow(row, dx, dy) end end if remapped then GbcPalette.clear() end -- Shaderless boot: the panel is raw grayscale, so there are no palettes to -- permute and the entry's BRIGHTNESS is the only thing left to reproduce. if bg.lcdc == "BGP" then for row = math.max(0, bg.lyStart), math.min(bg.lyEnd, SCREEN_H) - 1 do local veil = BattleAnimView.palVeil(bg.lyBackup[row]) if veil ~= 0 then local shade = veil > 0 and 0 or 1 G.setColor(shade, shade, shade, math.min(1, math.abs(veil))) G.rectangle("fill", 0, row, SCREEN_W, 1) end end G.setColor(1, 1, 1, 1) end end -- A DMG palette byte's mean shade against the identity %11100100, signed: -- +1 is solid black ($ff), -1 solid white ($00), 0 the identity. The -- approximation, kept for the shaderless path in `present` above. function BattleAnimView.palVeil(palByte) if not palByte then return 0 end local sum = 0 for index = 0, 3 do sum = sum + math.floor(palByte / (4 ^ index)) % 4 end return (sum - 6) / 6 end -------------------------------------------------------------------------- -- The battle intro slide (engine/battle/sliding_intro.asm) -------------------------------------------------------------------------- -- BattleIntroSlidingPics runs 72 frames. It holds three SCX values at once: -- scanlines 0-$3f take `c`, which starts at $90 and falls by 2 a frame, so -- the enemy's half enters from the RIGHT; $40-$5f take `b`, which starts at -- $70 and RISES by 2, wrapping to 0 on the last frame, so the player's half -- enters from the LEFT; everything from $60 down is already in place. BattleAnimView.SLIDE_FRAMES = 72 function BattleAnimView.slideOffsets(frame) local step = math.max(0, math.min(BattleAnimView.SLIDE_FRAMES, frame)) local top = (0x90 - step * 2) % 256 local middle = (0x70 + step * 2) % 256 return top, middle end -- The pixels the cart's OAM back-pic copy still has to travel at `frame`: -- CopyBackpic parks 18 sprites just off the RIGHT edge (x = 168 in OAM -- terms) and the slide's `.subfunction1` walks every one left 2px a frame, -- so the whole pic crosses 144px to its resting column in the 72 frames. function BattleAnimView.slideBackpicOffset(frame) local step = math.max(0, math.min(BattleAnimView.SLIDE_FRAMES, frame)) return (BattleAnimView.SLIDE_FRAMES - step) * 2 end -- The same band-at-a-time blit `present` uses, with the intro's own offsets. -- -- `drawBackpic(offset)`, when given, draws the player's back pic OVER the -- bands the way the cart's OAM copy rides over them: InitBattleDisplay clears -- the pic's top rows out of the BG before the slide (the hlcoord 1, 5 -- ClearBox) precisely because the pic straddles the $40 scanline where the -- two bands part ways -- baked into the bands it tears in half there, its top -- rows riding the enemy's offset and its bottom rows the player's. The -- caller must leave the pic OUT of drawBg and hand it here instead. function BattleAnimView:presentSlide(frame, drawBg, drawBackpic) if not (love and love.graphics) then return end local top, middle = BattleAnimView.slideOffsets(frame) if top == 0 and middle == 0 then drawBg() if drawBackpic then drawBackpic(0) end return end local G = love.graphics self:bake(drawBg, nil) self:fillBackground() G.setColor(1, 1, 1, 1) for row = 0, SCREEN_H - 1 do local scx = 0 if row < 0x40 then scx = top elseif row < 0x60 then scx = middle end -- SCX is unsigned and the map wraps at 256, so a value over half the -- screen reads as "coming in from the other side" rather than as a jump. local dx = -scx if scx > 128 then dx = 256 - scx end self:blitRow(row, dx, 0) end if drawBackpic then drawBackpic(BattleAnimView.slideBackpicOffset(frame)) end end -- A DMG palette byte read as a shade remap, so a BG effect that fades a mon -- through $f8/$fc can be applied to that mon's four CGB colours. The same -- CopyPals permutation `present` now folds into the whole panel, which is why -- it lives in GbcPalette and this is a name for it rather than a second copy. function BattleAnimView.shadeColors(colors, palByte) return GbcPalette.remap(colors, palByte) end BattleAnimView.SCREEN_W = SCREEN_W BattleAnimView.SCREEN_H = SCREEN_H BattleAnimView.needsCanvas = needsCanvas return BattleAnimView