-- DoBattleTransition (engine/battle/battle_transition.asm): the wipe that -- takes the overworld off screen before a battle starts. -- -- The cart drives it from a jumptable of 33 slots, four consecutive runs -- through the same five steps with a different outro at the end: -- -- LoadPokeBallGraphics a 16x16 Poke Ball stamped over the map -- TRAINER -- battles only, `and a / jr z` on wOtherTrainerClass -- SetUpBGMap bookkeeping -- Flash x3 the 13-entry rBGP table, twice per entry, run -- three times over -- NextScene -- SetUpFor + the outro itself -- -- and the outro is picked by two bits (StartTrainerBattle_DetermineWhichAnimation): -- -- | player's lead + 3 >= enemy | enemy stronger -- CAVE/DUNGEON/5 | SineWave (a growing wobble)| ZoomToBlack -- anywhere else | SpinToBlack | SpeckleToBlack -- -- Note the cart's own bug, kept here: the level test reads wEnemyMonLevel -- BEFORE the enemy mon is loaded, so "stronger" is decided against whatever -- the previous battle left there. This port has no such stale byte, so it -- compares honestly -- the one place the port is deliberately not bug-exact, -- because the alternative is emulating an uninitialised variable. -- -- Everything that decides WHICH tiles go black is a pure function below and is -- covered by tests; the state at the bottom is the only part that draws. local GbcPalette = require("src.render.GbcPalette") local Palettes = require("src.world.gen2.Palettes") local Runtime = require("src.mods.Runtime") local SpriteAnims = require("src.ui.gen2.SpriteAnims") local Tilt = require("src.render.Tilt") local BattleTransition = {} BattleTransition.__index = BattleTransition BattleTransition.isOpaque = false local COLS, ROWS = 20, 18 -- SCREEN_WIDTH x SCREEN_HEIGHT, in tiles -------------------------------------------------------------------------- -- The flash -------------------------------------------------------------------------- -- StartTrainerBattle_Flash's `.pals`: one packed rBGP per entry, colour 3 -- first, and the run stops at %00000001 (which is why the last row is a -- terminator rather than a palette). On a CGB DmgToCgbBGPals pushes each -- of these through every BG palette, so what the player sees is the whole -- picture darkening to black, coming back, washing out to white, and coming -- back again. -- -- The port draws the overworld into a baked map canvas, so by the time the -- flash runs there is no four-entry palette left in the frame to permute. -- GbcPalette's remap shader puts one back: it matches each pixel to the BG -- palette entry that produced it and substitutes what the byte sends that entry -- to, which is CopyPals exactly. BattleTransition:drawFlash does that and -- falls back to flashVeil below -- the entry's mean shade against the identity -- %11100100 (3,2,1,0), normalised so 3,3,3,3 is solid black and 0,0,0,0 is -- solid white -- only when the exact pass cannot run. BattleTransition.FLASH_PALS = { { 3, 3, 2, 1 }, { 3, 3, 3, 2 }, { 3, 3, 3, 3 }, { 3, 3, 3, 2 }, { 3, 3, 2, 1 }, { 3, 2, 1, 0 }, { 2, 1, 0, 0 }, { 1, 0, 0, 0 }, { 0, 0, 0, 0 }, { 1, 0, 0, 0 }, { 2, 1, 0, 0 }, { 3, 2, 1, 0 }, } -- `ld a, [hl] / inc [hl] / srl a`: the counter advances every frame and the -- index is half of it, so each palette is held for two. BattleTransition.FLASH_HOLD = 2 -- Three StartTrainerBattle_Flash slots in a row, each running the table once. BattleTransition.FLASH_CYCLES = 3 -- One .pals row as the rBGP byte the cart writes. `dc` emits colour 3 first, -- so the row packs high bits to low and `dc 3, 2, 1, 0` comes out $e4. function BattleTransition.flashByte(pal) return pal[1] * 64 + pal[2] * 16 + pal[3] * 4 + pal[4] end -- Signed veil for one palette row: +1 is black, -1 is white, 0 is untouched. -- -- The approximation, kept as the fallback for a boot with no shader (where the -- world is drawn as raw grayscale and there is nothing to re-index anyway) and -- for TILT, whose perspective pass resamples with linear filtering so the frame -- stops holding palette colours. function BattleTransition.flashVeil(pal) local sum = 0 for _, shade in ipairs(pal) do sum = sum + shade end -- identity (3,2,1,0) sums to 6; the extremes are 12 and 0. return (sum - 6) / 6 end BattleTransition.FLASH_FRAMES = #BattleTransition.FLASH_PALS * BattleTransition.FLASH_HOLD * BattleTransition.FLASH_CYCLES -------------------------------------------------------------------------- -- The Poke Ball overlay (trainer battles only) -------------------------------------------------------------------------- -- `.PokeBallTransition`, 16 bigdw rows of 16 bits, stamped from hlcoord 2, 1. -- A set bit becomes BATTLETRANSITION_SQUARE; the drawing loop stops early on a -- byte that has shifted itself empty, which is why the trailing zero columns of -- a byte are never written (and why it cannot be read as a plain 16-wide -- bitmap without care). local POKEBALL_ROWS = { "......XXXX......", "....XXXXXXXX....", "..XXXX....XXXX..", "..XX........XX..", ".XX..........XX.", ".XX...XXXX...XX.", "XX...XX..XX...XX", "XXXXXX....XXXXXX", "XXXXXX....XXXXXX", "XX...XX..XX...XX", ".XX...XXXX...XX.", ".XX..........XX.", "..XX........XX..", "..XXXX....XXXX..", "....XXXXXXXX....", "......XXXX......", } BattleTransition.POKEBALL_X = 2 BattleTransition.POKEBALL_Y = 1 function BattleTransition.pokeballCells() local cells = {} for row, bits in ipairs(POKEBALL_ROWS) do for col = 1, #bits do if bits:sub(col, col) == "X" then cells[#cells + 1] = { BattleTransition.POKEBALL_X + col - 1, BattleTransition.POKEBALL_Y + row - 1, } end end end return cells end -------------------------------------------------------------------------- -- SpinToBlack -------------------------------------------------------------------------- -- Each wedge is a run-length walk away from its own corner: fill `count` -- tiles, drop (or climb) a row, then step `shift` tiles back toward the -- corner. A -1 in the shift slot ends the wedge, so the last pair's fill -- happens and the walk stops. local WEDGES = { wedge1 = { 2, 3, 5, 4, 9, -1 }, wedge2 = { 1, 1, 2, 2, 4, 2, 4, 2, 3, -1 }, wedge3 = { 2, 1, 3, 1, 4, 1, 4, 1, 4, 1, 3, 1, 2, 1, 1, 1, 1, -1 }, wedge4 = { 4, 1, 4, 0, 3, 1, 3, 0, 2, 1, 2, 0, 1, -1 }, wedge5 = { 4, 0, 3, 0, 3, 0, 2, 0, 2, 0, 1, 0, 1, 0, 1, -1 }, } -- `.spin_quadrants`: quadrant, wedge, and the tile the walk starts on. Twenty -- steps, five per quadrant, going clockwise from the left edge. BattleTransition.SPIN_STEPS = { { "UPPER_LEFT", "wedge1", 1, 6 }, { "UPPER_LEFT", "wedge2", 0, 3 }, { "UPPER_LEFT", "wedge3", 1, 0 }, { "UPPER_LEFT", "wedge4", 5, 0 }, { "UPPER_LEFT", "wedge5", 9, 0 }, { "UPPER_RIGHT", "wedge5", 10, 0 }, { "UPPER_RIGHT", "wedge4", 14, 0 }, { "UPPER_RIGHT", "wedge3", 18, 0 }, { "UPPER_RIGHT", "wedge2", 19, 3 }, { "UPPER_RIGHT", "wedge1", 18, 6 }, { "LOWER_RIGHT", "wedge1", 18, 11 }, { "LOWER_RIGHT", "wedge2", 19, 14 }, { "LOWER_RIGHT", "wedge3", 18, 17 }, { "LOWER_RIGHT", "wedge4", 14, 17 }, { "LOWER_RIGHT", "wedge5", 10, 17 }, { "LOWER_LEFT", "wedge5", 9, 17 }, { "LOWER_LEFT", "wedge4", 5, 17 }, { "LOWER_LEFT", "wedge3", 1, 17 }, { "LOWER_LEFT", "wedge2", 0, 14 }, { "LOWER_LEFT", "wedge1", 1, 11 }, } -- Each spin step holds for two frames (`call DelayFrame` twice). BattleTransition.SPIN_HOLD = 2 -- Walk one wedge, marking cells in `black` (a [y * COLS + x] set). The -- quadrant only decides two signs: RIGHT_QUADRANT_F flips the fill direction -- (and the shift, which always runs back the other way), LOWER_QUADRANT_F -- flips the row step. function BattleTransition.spinStep(black, step) local quadrant, wedgeName, x, y = step[1], step[2], step[3], step[4] local wedge = WEDGES[wedgeName] local right = quadrant == "UPPER_RIGHT" or quadrant == "LOWER_RIGHT" local lower = quadrant == "LOWER_LEFT" or quadrant == "LOWER_RIGHT" local dx = right and 1 or -1 local dy = lower and -1 or 1 local i = 1 while i <= #wedge do local count = wedge[i] i = i + 1 local cx = x for _ = 1, count do -- The cart writes straight into the tilemap and lets a run walk off the -- end of a row into the next one; clipping instead keeps the wedge the -- shape the data draws and costs nothing the player can see. if cx >= 0 and cx < COLS and y >= 0 and y < ROWS then black[y * COLS + cx] = true end cx = cx + dx end y = y + dy local shift = wedge[i] i = i + 1 if shift == nil or shift == -1 then return black end x = x - dx * shift end return black end -------------------------------------------------------------------------- -- ZoomToBlack -------------------------------------------------------------------------- -- `.boxes`: width, height, and the top-left corner, growing out of the middle -- until the last one is the whole screen. One box per WaitBGMap, i.e. one a -- frame. BattleTransition.ZOOM_BOXES = { { 4, 2, 8, 8 }, { 6, 4, 7, 7 }, { 8, 6, 6, 6 }, { 10, 8, 5, 5 }, { 12, 10, 4, 4 }, { 14, 12, 3, 3 }, { 16, 14, 2, 2 }, { 18, 16, 1, 1 }, { 20, 18, 0, 0 }, } -- `zoombox width, height, start y, start x` -- the macro's own argument order, -- which is why the third number is the ROW. function BattleTransition.zoomStep(black, box) local width, height, y0, x0 = box[1], box[2], box[3], box[4] for y = y0, math.min(ROWS, y0 + height) - 1 do for x = x0, math.min(COLS, x0 + width) - 1 do black[y * COLS + x] = true end end return black end -------------------------------------------------------------------------- -- SpeckleToBlack -------------------------------------------------------------------------- -- Sixteen passes of twelve tiles each; a tile that is already black is -- resampled, so the count is twelve NEW tiles a frame, not twelve rolls. BattleTransition.SPECKLE_PASSES = 0x10 BattleTransition.SPECKLE_PER_PASS = 12 function BattleTransition.speckleStep(black, random) local roll = random or function(n) return math.random(n) - 1 end for _ = 1, BattleTransition.SPECKLE_PER_PASS do -- The cart rejects an out-of-range Random and rolls again; the modulo a -- port would reach for first is NOT the same distribution, so the reject -- loop stays. local x, y repeat y = roll(256) until y < ROWS repeat x = roll(256) until x < COLS local key = y * COLS + x if black[key] then -- `jr z, .y_loop`: a repeat lands on the same pass, so a late pass -- really does place fewer than twelve tiles. local tries = 0 repeat repeat y = roll(256) until y < ROWS repeat x = roll(256) until x < COLS key = y * COLS + x tries = tries + 1 until not black[key] or tries > COLS * ROWS end black[key] = true end return black end -------------------------------------------------------------------------- -- SineWave (the cave outro) -------------------------------------------------------------------------- -- The amplitude is wBattleTransitionCounter, which grows by the frame index -- every frame (`counter += offset`, `offset++`), so it runs 0, 0, 1, 3, 6, 10 -- ... and the outro ends the frame it reaches $60. The phase does not -- advance: `e` restarts at 0 each frame and steps 2 a scanline, i.e. one full -- period every 32 rows. BattleTransition.SINE_LIMIT = 0x60 function BattleTransition.sineFrames() local frames = {} local counter, offset = 0, 0 while counter < BattleTransition.SINE_LIMIT do local amplitude = counter counter = counter + offset offset = offset + 1 local row = {} for y = 0, 143 do -- The stored byte is signed; DrawSineWave returns it two's complement. local value = SpriteAnims.sine(y * 2, amplitude) row[y] = value >= 128 and value - 256 or value end frames[#frames + 1] = row end return frames end -------------------------------------------------------------------------- -- Choosing the animation -------------------------------------------------------------------------- -- StartTrainerBattle_DetermineWhichAnimation: CAVE, ENVIRONMENT_5 and DUNGEON -- take the cave pair, everything else the other one. BattleTransition.CAVE_ENVIRONMENTS = { CAVE = true, ENVIRONMENT_5 = true, DUNGEON = true, } function BattleTransition.pick(opts) opts = opts or {} local cave = BattleTransition.CAVE_ENVIRONMENTS[opts.environment] == true local stronger = (opts.playerLevel or 1) + 3 < (opts.enemyLevel or 1) if cave then return stronger and "zoom" or "sine" end return stronger and "speckle" or "spin" end -- The four outros the jumptable can reach. A transition.style hook that names -- anything else falls back to the two-bit select, the way the Gen 1 site falls -- back on an unregistered style (src/render/BattleTransition.lua). BattleTransition.STYLES = { spin = true, speckle = true, zoom = true, sine = true, } -- The default of the transition.style hook: the caller's explicit pin if there -- is one, otherwise StartTrainerBattle_DetermineWhichAnimation's own answer. local function vanillaStyle(ctx) return ctx.style or BattleTransition.pick(ctx) end -------------------------------------------------------------------------- -- The state -------------------------------------------------------------------------- function BattleTransition:drawsWidescreen() return true end function BattleTransition:wantsFillScale() return true end -- opts: world, trainer (bool), environment, playerLevel, enemyLevel, -- random(n), onDone function BattleTransition.new(game, opts) opts = opts or {} local self = setmetatable({}, BattleTransition) self.game = game self.world = opts.world or (game and game.world) self.onDone = opts.onDone self.random = opts.random -- transition.style, the same hook name the Gen 1 wipe uses -- (src/render/BattleTransition.lua), and the same context keys: `trainer`, -- `stronger` and `dungeon` are the selection bits a Gen 1 mod already reads, -- `dungeon` being the cave/dungeon environments this generation names -- CAVE / ENVIRONMENT_5 / DUNGEON. `environment`, `playerLevel`, -- `enemyLevel` and `style` are Gen 2's own: the raw inputs the two bits are -- derived from, added rather than swapped in. local ctx = { game = game, trainer = opts.trainer and true or false, stronger = (opts.playerLevel or 1) + 3 < (opts.enemyLevel or 1), dungeon = BattleTransition.CAVE_ENVIRONMENTS[opts.environment] == true, environment = opts.environment, playerLevel = opts.playerLevel, enemyLevel = opts.enemyLevel, style = opts.style, } local style = Runtime.call("transition.style", vanillaStyle, ctx) -- A hook naming an outro that does not exist would freeze on a black screen -- (no phase ever finishes), so it falls back to the vanilla pick. if not BattleTransition.STYLES[style] then style = vanillaStyle(ctx) end self.style = style self.trainer = opts.trainer and true or false self.black = {} self.frame = 0 self.step = 0 self.phase = self.trainer and "pokeball" or "flash" self.sine = nil return self end -- One logic frame. The phases run in the jumptable's order and the state pops -- itself when the last one is done, so the battle screen comes up on the black -- screen the wipe left behind. function BattleTransition:update(_dt) self.frame = self.frame + 1 if self.phase == "pokeball" then -- Two DelayFrames on the DMG path, one CGBOnly_CopyTilemapAtOnce on the -- other; either way the ball is on screen for a moment before the flash. if self.frame >= 2 then self.phase = "flash" self.frame = 0 end return end if self.phase == "flash" then if self.frame >= BattleTransition.FLASH_FRAMES then self.phase = "outro" self.frame = 0 self.step = 0 if self.style == "sine" then self.sine = BattleTransition.sineFrames() end end return end if self.phase == "outro" then self:outroFrame() return end if self.phase == "black" then if self.frame >= BattleTransition.BLACK_HOLD then self:finish() end return end self:finish() end function BattleTransition:outroFrame() local style = self.style if style == "spin" then if self.frame % BattleTransition.SPIN_HOLD ~= 1 then return end self.step = self.step + 1 local step = BattleTransition.SPIN_STEPS[self.step] if not step then self:blackOut() return end BattleTransition.spinStep(self.black, step) elseif style == "zoom" then self.step = self.step + 1 local box = BattleTransition.ZOOM_BOXES[self.step] if not box then self:blackOut() return end BattleTransition.zoomStep(self.black, box) elseif style == "speckle" then self.step = self.step + 1 if self.step > BattleTransition.SPECKLE_PASSES then self:blackOut() return end BattleTransition.speckleStep(self.black, self.random) else -- sine self.step = self.step + 1 if not (self.sine and self.sine[self.step]) then self:blackOut() return end end end -- DoBattleTransition's own `.done`: every BG palette is filled with zero and -- wBGP set to %11111111, i.e. the screen is solid black, and it stays that way -- while the battle screen loads its tiles and decompresses its pics. Two of -- the four outros never black the whole screen out themselves -- the speckle -- only ever reaches about half the tiles, and the sine wave none of them -- so -- without this the map would still be showing under the last frame. -- -- The hold is a frame budget for a load this port does not have, the same -- judgement call src/render/BattleTransition.lua documents for Gen 1. BattleTransition.BLACK_HOLD = 16 function BattleTransition:blackOut() self.phase = "black" self.frame = 0 end function BattleTransition:finish() if self.finished then return end self.finished = true local stack = self.game and self.game.stack if stack then stack:pop() end if self.onDone then self.onDone() end end -- The LY overrides this frame, or nil outside the sine outro. function BattleTransition:lyOverrides() if self.phase ~= "outro" or self.style ~= "sine" then return nil end return self.sine and self.sine[self.step] or nil end -- `black` covers the 20x18 tilemap; the window is bigger than that, so a cell -- outside the map takes its nearest in-range neighbour's state. That is the -- same idea Renderer:drawBattleWipe uses for Gen 1: continue the pattern with -- more tiles rather than scale the tiles up, so at 1x this is the cart's grid -- exactly. function BattleTransition:blackAt(col, row) local x = math.max(0, math.min(COLS - 1, col)) local y = math.max(0, math.min(ROWS - 1, row)) return self.black[y * COLS + x] == true end function BattleTransition:draw() local w, h = love.graphics.getDimensions() self:drawWidescreen(w, h) end -- The .pals row this frame is holding, or nil outside the flash phase. function BattleTransition:flashPal() if self.phase ~= "flash" then return nil end local index = math.floor(self.frame / BattleTransition.FLASH_HOLD) % #BattleTransition.FLASH_PALS + 1 return BattleTransition.FLASH_PALS[index] end -- The palette lists the flash's remap needs: the map's eight BG palettes, which -- DmgToCgbBGPals permutes, and the time of day's eight OBJ palettes, which it -- does not (that is DmgToCgbObjPals, and the flash never calls it -- the player -- and the NPCs really do keep their colours while the map flashes, and -- ClearSprites only runs at StartTrainerBattle_Finish). function BattleTransition:remapPalettes() local world = self.world local def = world and world.map and world.map.def if not (def and world.palettes) then return nil end local bg = Palettes.bgSet(world.palettes, def, world.daytime) if not bg then return nil end return bg, Palettes.objectSet(world.palettes, world.daytime) end -- Draw the map through this frame's rBGP byte, exactly. Returns false when the -- exact pass cannot run, which is the caller's cue to draw the world plainly -- and lay the brightness veil over it instead. function BattleTransition:drawFlash(w, h, pal) local byte = BattleTransition.flashByte(pal) if byte == GbcPalette.BGP_IDENTITY then -- `dc 3, 2, 1, 0` twice in the table: the picture is simply itself. self.world:draw() return true end -- TILT projects the finished frame through a linear-filtered canvas, so its -- pixels are blends of palette colours rather than palette colours; matching -- them back would posterise the warp instead of flashing it. if Tilt.active and Tilt.active() then return false end local bg, obj = self:remapPalettes() if not bg then return false end local canvas = self:capture(w, h) if not canvas then return false end local applied = GbcPalette.useRemap(bg, byte, obj) if not applied then return false end love.graphics.setColor(1, 1, 1, 1) love.graphics.draw(canvas, 0, 0) GbcPalette.clear() return true end function BattleTransition:drawWidescreen(w, h) local G = love.graphics local world = self.world local ly = self:lyOverrides() if self.phase == "black" then G.setColor(0, 0, 0, 1) G.rectangle("fill", 0, 0, w, h) G.setColor(1, 1, 1, 1) return end -- Cleared once the flash has been drawn exactly, so the veil below is only -- ever the fallback and the two can never both land on one frame. local pal = self:flashPal() if world and world.map then if ly then self:drawWavy(w, h, ly) elseif pal and self:drawFlash(w, h, pal) then pal = nil else world:draw() end else G.setColor(0, 0, 0, 1) G.rectangle("fill", 0, 0, w, h) end if self.phase == "pokeball" then self:drawCells(w, h, BattleTransition.pokeballCells()) end if pal then local veil = BattleTransition.flashVeil(pal) if veil ~= 0 then local shade = veil > 0 and 0 or 1 G.setColor(shade, shade, shade, math.abs(veil)) G.rectangle("fill", 0, 0, w, h) end end self:drawBlack(w, h) G.setColor(1, 1, 1, 1) end -- The tile grid, anchored on the letterbox and extended outward: `world` is -- being drawn at the ZOOM scale but the wipe is screen furniture, so it takes -- the plain integer fit the rest of the UI does. function BattleTransition:grid(w, h) local scale = 1 if self.world and self.world.fitScale then scale = self.world:fitScale() else scale = math.max(1, math.floor(math.min(w / 160, h / 144))) end local size = 8 * scale local ox = math.floor((w - 160 * scale) / 2) local oy = math.floor((h - 144 * scale) / 2) return size, ox, oy end function BattleTransition:drawBlack(w, h) local G = love.graphics local size, ox, oy = self:grid(w, h) local first = -math.ceil(ox / size) local last = math.ceil((w - ox) / size) local top = -math.ceil(oy / size) local bottom = math.ceil((h - oy) / size) G.setColor(0, 0, 0, 1) for row = top, bottom - 1 do for col = first, last - 1 do if self:blackAt(col, row) then G.rectangle("fill", ox + col * size, oy + row * size, size, size) end end end end -- BATTLETRANSITION_SQUARE, the Poke Ball's own tile: a filled block in the -- text palette rather than the black the wipe uses, so the ball reads against -- the map behind it. function BattleTransition:drawCells(w, h, cells) local G = love.graphics local size, ox, oy = self:grid(w, h) -- Shade 3 of the text palette, through the COLOR mode like every other -- direct colour read. local color = GbcPalette.color(nil, 4) if color then G.setColor(color[1] / 255, color[2] / 255, color[3] / 255, 1) else G.setColor(0, 0, 0, 1) end for _, cell in ipairs(cells) do G.rectangle("fill", ox + cell[1] * size, oy + cell[2] * size, size, size) end end -- The sine outro shifts whole scanlines, which needs the frame as a texture: -- the world is captured once and re-blitted a row at a time from then on. function BattleTransition:drawWavy(w, h, ly) local G = love.graphics local canvas = self:capture(w, h) if not canvas then self.world:draw() return end local scale = 1 if self.world.fitScale then scale = self.world:fitScale() end G.setColor(0, 0, 0, 1) G.rectangle("fill", 0, 0, w, h) G.setColor(1, 1, 1, 1) local rows = math.ceil(h / scale) if not self.quad then self.quad = love.graphics.newQuad(0, 0, w, scale, w, h) end for y = 0, rows - 1 do -- 144 overrides for however many screen rows the window has; a row past -- the end of the array holds the last value, the way the LCD keeps the -- final rSCX write. local shift = (ly[math.min(143, y)] or 0) * scale self.quad:setViewport(0, y * scale, w, scale, w, h) -- SCX scrolls the BACKGROUND, so a positive override moves the picture -- LEFT -- the same sign the battle BG effects take. The hardware BG map -- WRAPS, so a shifted scanline never shows a hole; the row is drawn again -- a screen over to stand in for that (the cart wraps at the 256-pixel BG -- map, this at the window, but either way there is no black gap). G.draw(canvas, self.quad, -shift, y * scale) if shift > 0 then G.draw(canvas, self.quad, -shift + w, y * scale) elseif shift < 0 then G.draw(canvas, self.quad, -shift - w, y * scale) end end end function BattleTransition:capture(w, h) if self.canvas then local cw, ch = self.canvas:getDimensions() if cw ~= w or ch ~= h then self.canvas = nil end end if not self.canvas then local ok, made = pcall(love.graphics.newCanvas, w, h) if not ok or not made then return nil end made:setFilter("nearest", "nearest") self.canvas = made self.captured = false end if not self.captured then local G = love.graphics local previous = G.getCanvas() -- A canvas does not reset the transform. G.push() G.origin() G.setCanvas(self.canvas) G.clear(0, 0, 0, 1) self.world:draw() G.setCanvas(previous) G.pop() self.captured = true end return self.canvas end return BattleTransition