-- The Gen 2 evolution screen: engine/movie/evolution_animation.asm plus the -- text beats around it in engine/pokemon/evolve.asm's EvolveAfterBattle. -- -- All of the arithmetic (which species, whether the condition is met, what the -- party record becomes, and every frame count below) is -- src/core/gen2/Evolution.lua's; this file is the only half that draws, so an -- evolution can be asserted end to end with no window. -- -- The animation is NOT a cross fade. The cart loads BOTH frontpics into -- vTiles2 -- the new species' 7x7 = 49 tiles at tile $31, the old one's at -- tile $00 -- and then adds or subtracts 49 to every tilemap entry of the -- 7x7 box at hlcoord 7, 2. That is a hard swap between two pics, once per -- WaitBGMap, run in accelerating bursts: -- -- `lb bc, 1, 16` then, per round, `inc b / dec c / dec c` -- round 1: hold the old pic 16 frames, then flash 1 time -- round 2: hold 14, flash 2 times -- ... eight rounds, ending 2 / 8, and each "flash" is new-then-old. -- -- The whole flashing half runs under PREDEFPAL_BLACKOUT (_CGB_Evolution's -- `ld c` = TRUE branch), which is what makes both pics read as one silhouette; -- the new species' real colours only arrive on the last swap. -- -- Layout: hlcoord 7, 2 with `lb bc, 7, 7` (PrepMonFrontpic), i.e. a 7x7 tile -- box at (56, 16). The text box below it is never cleared -- EvolveAfterBattle -- clears rows 0..11 only -- so "What? is evolving!" stays under the pic -- for the whole animation. local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") local Evolution = require("src.core.gen2.Evolution") local GbcPalette = require("src.render.GbcPalette") local Mon = require("src.battle.gen2.Mon") local Music = require("src.core.Music") local Palettes = require("src.world.gen2.Palettes") local Sound = require("src.core.Sound") local SpriteAnims = require("src.ui.gen2.SpriteAnims") local EvolutionAnim = {} EvolutionAnim.__index = EvolutionAnim EvolutionAnim.isOpaque = true local SCREEN_W, SCREEN_H = 160, 144 -- PrepMonFrontpic's box: hlcoord 7, 2, `lb bc, 7, 7`. local PIC_TILE_X, PIC_TILE_Y, PIC_TILES = 7, 2, 7 -- TEXTBOX_Y is SCREEN_HEIGHT - TEXTBOX_HEIGHT and TEXTBOX_INNERY is -- TEXTBOX_Y + 2; `line` ($4f) targets the box's absolute second line, which is -- two rows below the first. local BOX_X, BOX_Y, BOX_W, BOX_H = 0, 12, 20, 6 local TEXT_X, TEXT_Y, TEXT_LINE = 1, 14, 2 -- PromptButton pages: the port holds a prompt for this long and takes A or B -- as the press, the same deal src/ui/gen2/BattleState.lua's MESSAGE_FRAMES -- gives every other Gold text box. local PROMPT_FRAMES = 48 -- Paragraph's own `ld c, 20 / call DelayFrames` between clearing the box and -- printing the next page. local PARAGRAPH_FRAMES = 20 -- gfx/sgb/predef.pal PREDEFPAL_BLACKOUT, through the extractor's own 5-bit to -- 8-bit scale (floor(v * 255 / 31 + 0.5)). local BLACKOUT = { { 255, 255, 255 }, { 58, 58, 58 }, { 16, 25, 25 }, { 0, 0, 0 }, } -- NUM_SPRITE_ANIM_STRUCTS. InitSpriteAnimStruct refuses once the pool is -- full, so the balls of light cap out at ten on screen no matter how many -- .GenerateBallOfLight would like to make. local BALL_LIMIT = 10 -------------------------------------------------------------------------- -- Construction -------------------------------------------------------------------------- function EvolutionAnim:wantsFillScale() return true end function EvolutionAnim:drawsWidescreen() return true end -- opts: -- mon the party record as it stands BEFORE evolving -- entry the EvosAttacks row Evolution.check picked -- index its party slot, so the new record lands in the right one -- party the party table to write back into (defaults to save.party) -- save for SetSeenAndCaughtMon -- force wForceEvolution: a stone evolution cannot be cancelled with B -- onDone(result) result = { canceled, evolved, learned, full } function EvolutionAnim.new(game, opts) opts = opts or {} local self = setmetatable({}, EvolutionAnim) self.game = game self.data = (game and game.data) or {} self.palettes = self.data.gen2Palettes self.save = opts.save or (game and game.save) self.party = opts.party or (self.save and self.save.party) or {} self.index = opts.index or 1 self.mon = opts.mon or self.party[self.index] self.entry = opts.entry self.force = opts.force or false self.onDone = opts.onDone self.oldSpecies = self.mon and self.mon.species self.newSpecies = self.entry and self.entry.into -- wStringBuffer2 is filled with GetNickname BEFORE the animation, so every -- line below (including "stopped evolving!") names the mon by the name it -- had going in. self.nick = (self.mon and (self.mon.nickname or self.mon.name)) or self.oldSpecies or "?" self.newName = Evolution.speciesName(self.data, self.newSpecies) self.picCache = {} self.rounds = Evolution.flashRounds() self.canceled = false self.learned = {} self.full = {} self.balls = {} self.ballFrame = 0 -- Which of the two pics the 7x7 box is showing. The box starts on the OLD -- one and every round of flashing ends back on it, which is why a B press -- during a hold always leaves the old mon on screen. self.showNew = false self.blackout = false self:setPhase("evolving") return self end -------------------------------------------------------------------------- -- Phases -------------------------------------------------------------------------- function EvolutionAnim:playCry(species) if not species then return end local cries = self.data.audio and self.data.audio.cries if cries and cries[species] then Sound.playCry(self.data, species) end end function EvolutionAnim:playSfx(name) local sfx = self.data.audio and self.data.audio.sfx if sfx and sfx[Sound.resolve(self.data, name)] then Sound.play(self.data, name) end end -- NOT `enter`. `enter` is the stack's own lifecycle hook -- StateStack:push -- calls `state:enter(...)` (src/core/StateStack.lua:18), and Gold runs that -- same stack (src/core/Game2.lua:makeStack) -- so a screen that also used -- `enter` for its internal state machine had that machine reset the instant -- it was pushed: -- Screens.push passes no extra arguments, so the hook arrived as `enter(nil)`, -- phase became nil and every branch of update() then missed. The screen sat -- there decrementing its timer forever with the world still marked busy, which -- made a post-battle evolution an unrecoverable hang -- the Gold route bot hit -- it the first time its starter reached level 14. function EvolutionAnim:setPhase(phase) self.phase = phase self.timer = 0 if phase == "evolving" then -- PrintText EvolvingText, then `ld c, 50 / call DelayFrames`. The pics are -- not placed yet: on the cart the battle screen is still up behind this -- line and ClearBox only wipes rows 0..11 once the delay is over. self.lines = { "What? " .. self.nick, "is evolving!" } self.timer = Evolution.EVOLVING_FRAMES return end if phase == "cry" then -- PlayMonCry of the OLD species, then MUSIC_EVOLUTION, then 80 frames. -- PlayMusic MUSIC_NONE ran first, so nothing else is sounding. Music.stop() self:playCry(self.oldSpecies) local songs = self.data.audio and self.data.audio.songs if songs and songs.Music_Evolution then Music.play(self.data, "Music_Evolution", true, { reason = "evolution" }) end self.timer = Evolution.MUSIC_FRAMES return end if phase == "flash" then -- GetSGBLayout with c = TRUE: PREDEFPAL_BLACKOUT for the whole burst. self.blackout = true self.round = 1 self.step = "wait" self.timer = self.rounds[1].wait self.swapsLeft = 0 return end if phase == "reveal" then -- The final `ld a, 7 * 7 / call .ReplaceFrontpic` commits the new pic, then -- wPlayerHPPal takes the new species and GetSGBLayout comes back with -- c = FALSE, so the colours arrive on the same beat. self.blackout = false self.showNew = not self.canceled if self.canceled then -- .PlayEvolvedSFX returns immediately once wEvolutionCanceled is set: -- no SFX_EVOLVED and no balls of light, straight to the cry. self:playCry(self.oldSpecies) return self:setPhase("stopped") end self:playSfx("Sfx_Evolved") self.timer = Evolution.BALL_SPAWN_FRAMES + Evolution.BALL_TAIL_FRAMES return end if phase == "stopped" then -- CancelEvolution: StoppedEvolvingText over the pic, then ClearTilemap. self.lines = { "Huh? " .. self.nick, "stopped evolving!" } self.timer = PROMPT_FRAMES return end if phase == "congrats" then self.lines = { "Congratulations!", "Your " .. self.nick } self.timer = PROMPT_FRAMES return end if phase == "paragraph" then -- Paragraph clears the box and waits 20 frames before the next page. self.lines = nil self.timer = PARAGRAPH_FRAMES return end if phase == "evolved" then -- EvolvedIntoText, then MUSIC_NONE / SFX_CAUGHT_MON / WaitSFX and -- `ld c, 40 / call DelayFrames`. self.lines = { "evolved into", self.newName .. "!" } Music.stop() self:playSfx("Sfx_CaughtMon") self.timer = Evolution.CONGRATS_FRAMES return end if phase == "learn" then -- ClearTilemap, then the party slot is rewritten and LearnLevelMoves runs. self:commit() self.learnIndex = 0 return self:nextLearn() end if phase == "done" then if self.onDone then self.onDone({ canceled = self.canceled, evolved = self.evolved, learned = self.learned, full = self.full, }) end return end end -- The writeback: `pop de / pop hl / ld a, [wTempMonSpecies] / ld [hl], a` -- after CalcMonStats has already rebuilt the struct. Everything the record -- becomes is Evolution.apply's, which builds it through -- src/battle/gen2/Mon.lua so there is still exactly one builder. function EvolutionAnim:commit() local evolved = Evolution.apply(self.data, self.mon, self.entry) if not evolved then return end self.evolved = evolved self.party[self.index] = evolved Evolution.markPokedex(self.save, evolved.species) -- LearnLevelMoves at wCurPartyLevel, which the .proceed block set to the -- level the mon already had. self.pending = Evolution.learnedOnEvolve(self.data, evolved.species, evolved.level, evolved) end -- One LearnMove call per move the new species picks up at this level. There -- is no forget screen in the Gold port yet, so a full moveset reports the way -- src/battle/gen2/Battle.lua's own level-up path does instead of prompting. function EvolutionAnim:nextLearn() self.learnIndex = (self.learnIndex or 0) + 1 local moveId = self.pending and self.pending[self.learnIndex] if not (moveId and self.evolved) then self.lines = nil return self:setPhase("done") end local moveDef = self.data.moves and self.data.moves[moveId] local moveName = (moveDef and moveDef.name) or moveId local ok, reason = Mon.learnMove(self.evolved, moveId, self.data) if ok then self.learned[#self.learned + 1] = moveId self.lines = { self.nick .. " learned", moveName .. "!" } elseif reason == "full" then self.full[#self.full + 1] = moveId self.lines = { self.nick .. " wants to", "learn " .. moveName .. "!" } else return self:nextLearn() end self.phase = "learn" self.timer = PROMPT_FRAMES end -------------------------------------------------------------------------- -- Update -------------------------------------------------------------------------- -- .WaitFrames_CheckPressedB reads the joypad every frame of a hold, and -- .pressed_b honours it only while wForceEvolution is clear: a stone -- evolution cannot be stopped. function EvolutionAnim:cancelPressed(input) if self.force then return false end return input and input:wasPressed("b") or false end function EvolutionAnim:update(_dt) local input = self.game and self.game.input local phase = self.phase -- onDone has already fired; the caller pops this state on its own beat. if phase == "done" then return end if phase == "flash" then return self:updateFlash(input) end if phase == "reveal" then self:updateBalls() self.timer = self.timer - 1 if self.timer <= 0 then -- ClearSpriteAnims, then PlayMonCry of the species the screen is now -- showing. self:playCry(self.newSpecies) self:setPhase("congrats") end return end self.timer = (self.timer or 0) - 1 local prompt = phase == "stopped" or phase == "congrats" or phase == "learn" if prompt and input and (input:wasPressed("a") or input:wasPressed("b")) then self.timer = 0 end if self.timer > 0 then return end if phase == "evolving" then return self:setPhase("cry") end if phase == "cry" then return self:setPhase("flash") end if phase == "stopped" then return self:setPhase("done") end if phase == "congrats" then return self:setPhase("paragraph") end if phase == "paragraph" then return self:setPhase("evolved") end if phase == "evolved" then return self:setPhase("learn") end if phase == "learn" then return self:nextLearn() end end -- One frame of the flash loop. A round is `.WaitFrames_CheckPressedB` for its -- own `c` frames and then `.Flash` b times, each flash being two -- .ReplaceFrontpic calls (new, then back to old) a WaitBGMap apart. function EvolutionAnim:updateFlash(input) local round = self.rounds[self.round] if not round then return self:setPhase("reveal") end if self.step == "wait" then if self:cancelPressed(input) then -- .cancel_evo: wEvolutionCanceled, and the pic stays on the old stage. self.canceled = true self.showNew = false return self:setPhase("reveal") end self.timer = self.timer - 1 if self.timer <= 0 then self.step = "flash" -- Two swaps per flash: to the new pic and back. self.swapsLeft = round.flashes * 2 self.timer = Evolution.SWAP_FRAMES self.showNew = true end return end -- Each swap holds for one WaitBGMap. self.timer = self.timer - 1 if self.timer > 0 then return end self.swapsLeft = self.swapsLeft - 1 if self.swapsLeft <= 0 then self.showNew = false self.round = self.round + 1 local nextRound = self.rounds[self.round] if not nextRound then return self:setPhase("reveal") end self.step = "wait" self.timer = nextRound.wait return end self.showNew = not self.showNew self.timer = Evolution.SWAP_FRAMES end -------------------------------------------------------------------------- -- Balls of light -------------------------------------------------------------------------- -- .balls_of_light spawns two balls on every EVEN wJumptableIndex over the 32 -- spawn frames. The angle it writes is read AFTER `inc [hl]`, so it is the -- POST-increment index masked to %1110 and shifted left; the second ball of a -- pair is a further $10 round the table. -- -- AnimSeq_RevealNewMon then walks each ball out: VAR1 is the radius, starting -- at $10 and stepping $08 a frame until it passes $80, and the angle is xor'd -- with $20 every frame so a ball alternates between the two ends of its own -- diameter. y comes from Sine and x from Cosine. function EvolutionAnim:updateBalls() local frame = self.ballFrame if frame < Evolution.BALL_SPAWN_FRAMES and frame % 2 == 0 then -- `ld a, [hl] / inc [hl]` leaves the PRE-increment index in a, which is -- what the `and $1` even/odd test sees; .GenerateBallOfLight then re-reads -- wJumptableIndex and gets the POST-increment one. So the angle is -- (post & %1110) << 1, with post always odd here. local post = frame + 1 local base = (((post % 16) - (post % 2)) * 2) % 256 for _, offset in ipairs({ 0x00, 0x10 }) do if #self.balls < BALL_LIMIT then self.balls[#self.balls + 1] = { angle = (base + offset) % 256, radius = Evolution.BALL_RADIUS_START, age = 0, } end end end self.ballFrame = frame + 1 local alive = {} for _, ball in ipairs(self.balls) do if ball.radius < Evolution.BALL_RADIUS_END then local radius = ball.radius ball.radius = radius + Evolution.BALL_RADIUS_STEP -- `xor $20` on an angle the sine table masks to six bits is the same as -- half a period, and Lua 5.1 has no bitwise xor to spell it with. ball.angle = (ball.angle + 0x20) % 0x40 ball.y = EvolutionAnim.signed(SpriteAnims.sine(ball.angle, radius)) ball.x = EvolutionAnim.signed(SpriteAnims.cosine(ball.angle, radius)) ball.age = ball.age + 1 alive[#alive + 1] = ball end end self.balls = alive end -- The sine helpers hand back the byte the ASM leaves in a, so a negative -- offset arrives in two's complement. function EvolutionAnim.signed(value) value = value % 256 if value >= 128 then return value - 256 end return value end -------------------------------------------------------------------------- -- Draw -------------------------------------------------------------------------- function EvolutionAnim:pic(species) local def = species and self.data.pokemon and self.data.pokemon[species] local path = def and def.spriteFront if not path then return nil end local cached = self.picCache[path] if cached == nil then -- "and" would truncate the pcall's second return, so the call stands on -- its own line. local ok, image = pcall(Assets.image, path) cached = ok and image or false self.picCache[path] = cached end return cached or nil end -- The four colours the box draws through right now: the mon's own while the -- SGB layout is c = FALSE, PREDEFPAL_BLACKOUT while it is TRUE. -- Shininess is a DV pattern, and the DVs survive the evolution, so both stages -- read the same flag off the record that walked in. function EvolutionAnim:picColors(species) if self.blackout then return BLACKOUT end return Palettes.monColors(self.palettes, species, self.mon and self.mon.shiny) end function EvolutionAnim:drawPic() local species = self.showNew and self.newSpecies or self.oldSpecies local image = self:pic(species) if not image then return end local G = love.graphics local w, h = image:getDimensions() -- PlaceGraphic pads the pic into the 7x7 box bottom-first, so a 40x40 -- Cyndaquil stands on the same ground line a 56x56 Onix does. local box = PIC_TILES * 8 local px = PIC_TILE_X * 8 + math.floor((box - w) / 2) local py = PIC_TILE_Y * 8 + (box - h) G.setColor(1, 1, 1, 1) local colors = self:picColors(species) local function body() G.draw(image, px, py) end if colors and GbcPalette.available() then GbcPalette.with(colors, body) else body() end end -- gfx/evo/bubble_large.2bpp and gfx/evo/bubble.2bpp are not in the Gold cache -- (the extractor does not pull gfx/evo/), so the ball is drawn as the disc -- those two 8x8 tiles are, at the two sizes .Frameset_EvolutionBallOfLight -- alternates between every two frames. Its colour goes through -- GbcPalette.color so the COLOR option still reaches it. function EvolutionAnim:drawBalls() if #self.balls == 0 then return end local G = love.graphics local colors = Palettes.monColors(self.palettes, self.newSpecies, self.evolved and self.evolved.shiny) local rgb = GbcPalette.color(colors, 3) or { 0, 0, 0 } G.setColor(rgb[1] / 255, rgb[2] / 255, rgb[3] / 255, 1) for _, ball in ipairs(self.balls) do local radius = (math.floor(ball.age / 2) % 2 == 0) and 4 or 3 G.circle("fill", Evolution.BALL_ORIGIN_X + (ball.x or 0) + 4, Evolution.BALL_ORIGIN_Y + (ball.y or 0) + 4, radius) end G.setColor(1, 1, 1, 1) end -- The pic is on screen from the moment .PlaceFrontpic runs until ClearTilemap, -- which is only reached once the "evolved into" page has had its 40 frames -- -- so the congratulation text prints OVER the new mon, and CancelEvolution's -- "stopped evolving!" prints over the old one. The two phases without a pic -- are the 50 frames before the animation starts and the LearnLevelMoves run -- after the tilemap is cleared. function EvolutionAnim:drawPanel() Chrome.clear() if self.phase ~= "evolving" and self.phase ~= "learn" then self:drawPic() self:drawBalls() end if self.lines then Chrome.box(BOX_X, BOX_Y, BOX_W, BOX_H) for index, line in ipairs(self.lines) do Chrome.print(line, TEXT_X, TEXT_Y + (index - 1) * TEXT_LINE) end end love.graphics.setColor(1, 1, 1, 1) end function EvolutionAnim:draw() self:drawPanel() end function EvolutionAnim:drawWidescreen(winW, winH) local G = love.graphics G.setColor(1, 1, 1, 1) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(math.floor((winW - SCREEN_W * scale) / 2), math.floor((winH - SCREEN_H * scale) / 2)) G.scale(scale, scale) self:drawPanel() G.pop() end return EvolutionAnim