-- The egg hatch cutscene: engine/pokemon/breeding.asm -- EggHatch_AnimationSequence, the beat HatchEggs runs between "Huh?" and -- " came out of its EGG!". -- -- The sequence, in the cart's own order: -- -- 1. `ld de, MUSIC_NONE / call PlayMusic`, then BlankScreen and DisableLCD. -- isOpaque is therefore correct: the map really is torn down. -- 2. EggHatchGFX's two tiles to vTiles0, the hatchling's frontpic to -- vTiles2 $00 and the EGG's to $31. -- 3. MUSIC_EVOLUTION, EnableLCD, and the EGG pic laid at hlcoord 7, 4. -- 4. `ld c, 80 / call DelayFrames`: the egg just sits there. -- 5. Eight rounds. Round r wobbles r times -- each wobble is hSCX = +2 for -- two frames then -2 for two frames, with wGlobalAnimXOffset carrying the -- sprites the same way so the cracks stay on the shell -- then sixteen -- still frames, then EggHatch_CrackShell. -- 6. SFX_EGG_HATCH, ten shell fragments, the HATCHLING's pic at hlcoord 6, 3, -- then Hatch_ShellFragmentLoop's 129 frames, WaitSFX and PlayMonCry. -- -- The two sprite objects are built here rather than in -- src/ui/gen2/SpriteAnims.lua because both are one 8x8 tile with a fixed -- frameset (data/sprite_anims/framesets.asm .Frameset_EggCrack and -- .Frameset_EggHatch1..4 are each a single `oamframe ..., 32 / oamend`) and -- only ONE of the two moves at all. Coordinates follow the same convention -- every other caller of _InitSpriteAnimStruct uses (engine/sprite_anims/ -- core.asm:113, "at pixel x=e, y=d"), and OAM's own -8 / -16 bias is applied -- at draw time exactly as src/ui/gen2/GoldSilverIntro.lua does it. -- -- Every asset is optional. A cache built before the extractor grew -- menu_gfx.eggHatch has no egg pic and no shell tiles, and the screen then -- runs the same clock with whatever it does have -- which is the timing beat -- src/world/gen2/World.lua's hatch path used to stand in for on its own. local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") 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 EggHatchAnim = {} EggHatchAnim.__index = EggHatchAnim EggHatchAnim.isOpaque = true local SCREEN_W, SCREEN_H = 160, 144 -- Hatch_UpdateFrontpicBGMapCenter is called twice with different hlcoords: -- the egg sits at (7,4) and the hatchling at (6,3). Both are `lb bc, 7, 7` -- PlaceGraphic boxes, and the pic inside that box has already been padded to -- 7x7 in VRAM, so a 40x40 egg and a 56x56 mon share a ground line. local EGG_TILE_X, EGG_TILE_Y = 7, 4 local MON_TILE_X, MON_TILE_Y = 6, 3 -- PadFrontpic (engine/gfx/load_pics.asm:342) fills the 7x7 box column by -- column: one whole blank column first, then per pic column a fixed run of -- blank tiles above the pic tiles. `.six` fills one tile per column and -- `.five` two, so the pic's top-left tile lands at (1,1) and (1,2) -- respectively and a 7-wide pic fills the box. Same table, for the same -- reason, as src/ui/gen2/SummaryMenu.lua:120. local PIC_PAD = { [7] = { 0, 0 }, [6] = { 1, 1 }, [5] = { 1, 2 } } -- `ld c, 80 / call DelayFrames` between the egg appearing and the first wobble. local HOLD_FRAMES = 80 -- `.outerloop`'s `cp 8`: rounds 0..7, so eight of them, and round r (1-based) -- wobbles r times because `ld e, [hl]` reads the counter AFTER `inc [hl]`. local ROUNDS = 8 -- Each half of a wobble is `ld c, 2 / call DelayFrames`, i.e. two frames. local WOBBLE_HALF = 2 -- `ld a, 2 / ldh [hSCX]` then `ld a, -2`: the screen shifts two pixels. local SHAKE = 2 -- `ld c, 16 / call DelayFrames` after the wobbles of a round. local STILL_FRAMES = 16 -- Hatch_ShellFragmentLoop's `ld c, 129`. local FRAGMENT_FRAMES = 129 -- Hatch_InitShellFragments' .SpriteData, one row per `shell_fragment`. The -- macro emits two coordinate bytes and Hatch_InitShellFragments loads the first -- into e and the second into d, which _InitSpriteAnimStruct reads as x and y -- respectively -- the same pairing `depixel` produces for every other object. -- `frameset` is the offset from SPRITE_ANIM_FRAMESET_EGG_HATCH_1, and the four -- framesets differ only in their OAM flip flags (data/sprite_anims/framesets -- .asm:360-374): 1 plain, 2 X-flipped, 3 Y-flipped, 4 both. local FRAGMENTS = { { x = 10 * 8 + 4, y = 9 * 8, flipX = false, flipY = false, angle = 0x3c }, { x = 11 * 8 + 4, y = 9 * 8, flipX = true, flipY = false, angle = 0x04 }, { x = 10 * 8 + 4, y = 10 * 8, flipX = false, flipY = false, angle = 0x30 }, { x = 11 * 8 + 4, y = 10 * 8, flipX = true, flipY = false, angle = 0x10 }, { x = 10 * 8 + 4, y = 11 * 8, flipX = false, flipY = true, angle = 0x24 }, { x = 11 * 8 + 4, y = 11 * 8, flipX = true, flipY = true, angle = 0x1c }, { x = 10 * 8, y = 9 * 8 + 4, flipX = false, flipY = false, angle = 0x36 }, { x = 12 * 8, y = 9 * 8 + 4, flipX = true, flipY = false, angle = 0x0a }, { x = 10 * 8, y = 10 * 8 + 4, flipX = false, flipY = true, angle = 0x2a }, { x = 12 * 8, y = 10 * 8 + 4, flipX = true, flipY = true, angle = 0x16 }, } -- AnimSeq_RevealNewMon: var1 starts at 0 and grows by 8 a frame until it -- reaches $80, at which point DeinitializeSprite drops the fragment. Sixteen -- frames of flight, then the hatchling stands alone for the rest of the loop. local FRAGMENT_STEP = 8 local FRAGMENT_LIMIT = 0x80 -- Both egg objects use .OAMData_1x1_Palette0, whose single entry is -- `dbsprite -1, -1, 4, 4, $00, 0` (data/sprite_anims/oam.asm:94-95, 112-114). -- dbsprite folds tile and pixel into one byte, so that is a per-object -4 on -- each axis, added by UpdateAnimFrame through AddOrSubtractX/Y on top of the -- struct coordinate (engine/sprite_anims/core.asm:240-266). The flip arm -- computes `-8 - a`, which for a = -4 is -4 again, so the one constant covers -- all four framesets. The remaining -8 / -16 is the hardware's own OAM bias, -- the same one every other Gold screen applies on its way out. local OAM_X = -8 - 4 local OAM_Y = -16 - 4 function EggHatchAnim:wantsFillScale() return true end function EggHatchAnim:drawsWidescreen() return true end -------------------------------------------------------------------------- -- Construction -------------------------------------------------------------------------- -- opts: -- mon the hatchling's party record (species, shiny, nickname) -- species the hatchling's species, when there is no record to hand -- onDone() the beat after PlayMonCry function EggHatchAnim.new(game, opts) opts = opts or {} local self = setmetatable({}, EggHatchAnim) self.game = game self.data = (game and game.data) or {} self.palettes = self.data.gen2Palettes self.mon = opts.mon self.species = opts.species or (self.mon and self.mon.species) self.onDone = opts.onDone local gfx = (opts.menuGfx or self.data.gen2MenuGfx or {}).eggHatch or {} self.eggPath = gfx.egg self.shellPath = gfx.shell self.picCache = {} self.shell = nil self.shellQuads = nil -- The screen starts on the egg and only swaps to the hatchling at `.done`, -- which is the one thing a viewer has to be able to see happen. self.showMon = false self.shakeX = 0 self.sprites = {} -- `ld de, MUSIC_NONE / call PlayMusic` and then MUSIC_EVOLUTION, with the -- map theme handed back by HatchEggs' own RestartMapMusic afterwards. Music.stop() 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 = "hatch" }) end self.beats = self:buildBeats() self.beatIndex = 1 self.beatLeft = self.beats[1] and self.beats[1].frames or 0 self:runBeat(self.beats[1]) return self end -- The whole sequence as a flat list of { frames, enter }. Building it up front -- rather than nesting phase machines is what keeps the frame counts readable -- against the ASM: every number here is one `DelayFrames` operand. function EggHatchAnim:buildBeats() local beats = {} local function beat(frames, enter) beats[#beats + 1] = { frames = frames, enter = enter } end beat(HOLD_FRAMES, function() self.shakeX = 0 end) for round = 1, ROUNDS do for _ = 1, round do beat(WOBBLE_HALF, function() self.shakeX = SHAKE end) beat(WOBBLE_HALF, function() self.shakeX = -SHAKE end) end beat(STILL_FRAMES, function() self.shakeX = 0 end) beat(0, function() self:crackShell(round) end) end -- `.done`: hSCX and wGlobalAnimXOffset are zeroed, ClearSprites drops the -- cracks, the fragments go up and the pic becomes the hatchling's. beat(FRAGMENT_FRAMES, function() self.shakeX = 0 self.sprites = {} self:playSfx("Sfx_EggHatch") self:initFragments() self.showMon = true end) -- WaitSFX then `ld a, [wJumptableIndex] / call PlayMonCry`. The port has no -- channel state to poll, so the cry simply follows the loop. beat(0, function() self:playCry() end) return beats end -------------------------------------------------------------------------- -- Sound -------------------------------------------------------------------------- function EggHatchAnim: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 function EggHatchAnim:playCry() local species = self.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 -------------------------------------------------------------------------- -- The two sprite objects -------------------------------------------------------------------------- -- EggHatch_CrackShell (breeding.asm:756). wFrameCounter has already been -- incremented, so `dec a / and $7` is the round index 0..7; `cp $7 / ret z` -- drops the last round and `srl a / ret nc` drops every even one, leaving -- rounds 2, 4 and 6. The surviving index picks the y coordinate: -- `swap a / srl a` is a multiply by eight, and `add 9 * TILE_WIDTH` puts the -- first crack on tile row 9. x is the fixed `ld e, 11 * TILE_WIDTH`. function EggHatchAnim:crackShell(round) local a = (round - 1) % 8 if a == 7 then return end if a % 2 == 0 then return end local step = math.floor(a / 2) self.sprites[#self.sprites + 1] = { kind = "crack", x = 11 * 8, y = step * 8 + 9 * 8, } self:playSfx("Sfx_EggCrack") end -- Hatch_InitShellFragments, then the SFX_EGG_HATCH it ends on (which the -- caller's `.done` has already played -- the cart plays it twice). function EggHatchAnim:initFragments() for _, row in ipairs(FRAGMENTS) do self.sprites[#self.sprites + 1] = { kind = "fragment", x = row.x, y = row.y, flipX = row.flipX, flipY = row.flipY, -- SPRITEANIMSTRUCT_JUMPTABLE_INDEX carries the fragment's angle, which -- AnimSeq_RevealNewMon flips by $20 every frame. angle = row.angle, var1 = 0, xOffset = 0, yOffset = 0, } end end -- AnimSeq_RevealNewMon (engine/sprite_anims/functions.asm:1270), one frame: -- the amplitude grows by eight, the angle is XORed with $20, and the sine and -- cosine of that pair become the fragment's y and x offsets. So each shard -- alternates between two opposite headings while drifting further out, which -- is what reads as tumbling. local function stepFragment(sprite) if sprite.var1 >= FRAGMENT_LIMIT then return false end local amplitude = sprite.var1 sprite.var1 = sprite.var1 + FRAGMENT_STEP -- `xor $20` toggles bit 5, i.e. half a period of the six-bit angle. Written -- as a bit test rather than an operator because the engine targets LuaJIT's -- 5.1 semantics, where there is none. local angle = sprite.angle % 256 local bit5 = math.floor(angle / 0x20) % 2 sprite.angle = bit5 == 1 and (angle - 0x20) or (angle + 0x20) sprite.yOffset = SpriteAnims.sine(sprite.angle, amplitude) sprite.xOffset = SpriteAnims.cosine(sprite.angle, amplitude) return true end -------------------------------------------------------------------------- -- Update -------------------------------------------------------------------------- function EggHatchAnim:runBeat(beat) if beat and beat.enter then beat.enter() end end function EggHatchAnim:finish() self.done = true local cb = self.onDone self.onDone = nil if cb then cb() end end function EggHatchAnim:update(_dt) if self.done then return end -- EggHatch_DoAnimFrame is PlaySpriteAnimations + DelayFrame, so the sprites -- advance on every frame the sequence spends anywhere. A fragment that has -- run out its amplitude leaves the screen: AnimSeq_RevealNewMon's -- `.finish_EggShell` is a DeinitializeSprite (engine/sprite_anims/functions -- .asm:1303), sixteen frames into a 129-frame loop, so the shards do not -- hang in mid-air for the rest of it. Cracks are never dropped here; the -- ClearSprites at `.done` has already emptied the list by then. local live = {} for _, sprite in ipairs(self.sprites) do if sprite.kind ~= "fragment" or stepFragment(sprite) then live[#live + 1] = sprite end end self.sprites = live while self.beatLeft <= 0 do self.beatIndex = self.beatIndex + 1 local beat = self.beats[self.beatIndex] if not beat then return self:finish() end self.beatLeft = beat.frames self:runBeat(beat) end self.beatLeft = self.beatLeft - 1 end -------------------------------------------------------------------------- -- Draw -------------------------------------------------------------------------- function EggHatchAnim:image(path) if not path then return nil end local cached = self.picCache[path] if cached == nil then -- `and` truncates a multi-return, so the pcall stands alone. local ok, image = pcall(Assets.image, path) cached = (ok and image) or false self.picCache[path] = cached end return cached or nil end function EggHatchAnim:pic() if self.showMon then local def = self.species and self.data.pokemon and self.data.pokemon[self.species] return self:image(def and def.spriteFront) end return self:image(self.eggPath) end -- Hatch_LoadFrontpicPal is SCGB_EVOLUTION with c = 0, i.e. the pic's own -- palette, and the species it is handed is whatever -- Hatch_UpdateFrontpicBGMapCenter was called with: EGG while the shell is up. -- PokemonPalettes carries a real EGG row (data/pokemon/palettes.asm:530) and -- _CGB_Evolution indexes straight into it through GetPlayerOrMonPalettePointer -- (engine/gfx/color.asm:620), so the egg gets its own cream and brown. A -- cache built before the extractor grew that row has no "EGG" key, and -- monColors returning nil then leaves the shader off and the raw shades in -- place, exactly as before. function EggHatchAnim:picColors() local species = self.showMon and self.species or "EGG" return Palettes.monColors(self.palettes, species, self.mon and self.mon.shiny) end function EggHatchAnim:drawPic() local image = self:pic() if not image then return end local G = love.graphics local w = image:getWidth() local tx = self.showMon and MON_TILE_X or EGG_TILE_X local ty = self.showMon and MON_TILE_Y or EGG_TILE_Y -- PadFrontpic's own placement, not a centring rule: the two agree at 7 and -- 5 wide but a 6-wide pic sits a whole tile in, not half of one. local pad = PIC_PAD[math.floor(w / 8)] or PIC_PAD[7] local px = tx * 8 + pad[1] * 8 local py = ty * 8 + pad[2] * 8 G.setColor(1, 1, 1, 1) local colors = self:picColors() local function body() G.draw(image, px, py) end if colors and GbcPalette.available() then GbcPalette.with(colors, body) else body() end end -- EggHatchGFX is one tile wide and two tall: tile $00 is the crack -- (SPRITE_ANIM_OAMSET_EGG_CRACK's `spriteanimoam $00`) and tile $01 the shell -- fragment (`spriteanimoam $01`), both 1x1 on OBJ palette 0. function EggHatchAnim:shellQuad(index) local image = self:image(self.shellPath) if not image then return nil, nil end if not self.shellQuads then self.shellQuads = { love.graphics.newQuad(0, 0, 8, 8, image:getDimensions()), love.graphics.newQuad(0, 8, 8, 8, image:getDimensions()), } end return image, self.shellQuads[index] end function EggHatchAnim:drawSprites() if #self.sprites == 0 then return end local G = love.graphics G.setColor(1, 1, 1, 1) for _, sprite in ipairs(self.sprites) do local slot = sprite.kind == "crack" and 1 or 2 local image, quad = self:shellQuad(slot) if image and quad then -- OAM_X / OAM_Y carry both the hardware bias and .OAMData_1x1_Palette0's -- own -4, and the draw call below adds the flip origin correction. local x = sprite.x + (sprite.xOffset or 0) + OAM_X local y = sprite.y + (sprite.yOffset or 0) + OAM_Y -- The two offsets are the bytes the ASM leaves in a, i.e. two's -- complement, so anything past $7f is a negative drift. if (sprite.xOffset or 0) > 0x7f then x = x - 256 end if (sprite.yOffset or 0) > 0x7f then y = y - 256 end local flipX, flipY = sprite.flipX, sprite.flipY G.draw(image, quad, x + (flipX and 8 or 0), y + (flipY and 8 or 0), 0, flipX and -1 or 1, flipY and -1 or 1) end end end -- BlankScreen leaves the whole tilemap on the palette's colour 0, and nothing -- prints during the sequence: HatchEggs' text boxes are on either side of it. function EggHatchAnim:drawPanel() Chrome.clear() local G = love.graphics G.push() -- Both layers move the SAME way each wobble half (breeding.asm:707-719): -- `ldh [hSCX]` of +2 scrolls the viewport right, i.e. slides the background -- two pixels left on screen, and the paired `ld [wGlobalAnimXOffset], -2` is -- summed into every object's OAM x byte (engine/sprite_anims/core.asm:253 -- -266), sliding the sprites two pixels left as well. So the cracks stay -- put on the shell and the whole picture shakes as one. G.translate(-(self.shakeX or 0), 0) self:drawPic() self:drawSprites() G.pop() G.setColor(1, 1, 1, 1) end function EggHatchAnim:draw() self:drawPanel() end function EggHatchAnim: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 EggHatchAnim