-- The Gold/Silver intro movie (pokegold engine/movie/intro.asm -- GoldSilverIntro), transcribed rather than approximated. -- -- The cart runs the whole thing off one 17-entry jumptable stepped once per -- frame, and almost everything on screen is a side effect of four bytes: -- hSCX, hSCY and the two intro frame counters. So this module keeps those -- bytes, keeps a real 32x32 BG map, and runs the same scene functions over -- them. What that buys is the movement the old timed-fade version had no way -- to express: -- -- 1-5 underwater. Shellders drift up out of frame while bubbles rise, -- then the camera climbs to the surface -- and it climbs by streaming -- one fresh metatile row into the top of the BG map every 16 pixels -- (Intro_UpdateTilemapAndBGMap), which is why the act ships a tilemap -- twice as tall as the map. Magikarp jump, Lapras surfaces, fade. -- 6-9 grass. Scroll left to Jigglypuff with notes rising, Pikachu -- charges in from the right, then the camera drops and fades. -- 10-16 fire. Climb a black field to the Charizard silhouette while the -- three Johto starters flash across it, open its mouth in three -- tilemap redraws, breathe a fireball that spirals outward. -- 17 64 frames of black, then the title screen. -- -- The ocean's wobble is not a sprite effect: hLCDCPointer points at rSCY and -- wLYOverrides holds a per-scanline SCY, so the water bends line by line -- (Intro_InitSineLYOverrides / Intro_UpdateLYOverrides). That is drawn here -- as one quad per scanline. -- -- Any button skips the whole thing, exactly as .PlayFrame does on PAD_BUTTONS. local Assets = require("src.render.Assets") local Chrome = require("src.ui.gen2.Chrome") local GbcPalette = require("src.render.GbcPalette") local Logger = require("src.core.Logger") local Music = require("src.core.Music") local Palettes = require("src.world.gen2.Palettes") local Runtime = require("src.mods.Runtime") local Sound = require("src.core.Sound") local SpriteAnims = require("src.ui.gen2.SpriteAnims") local GoldSilverIntro = {} GoldSilverIntro.__index = GoldSilverIntro GoldSilverIntro.isOpaque = true local SCREEN_W, SCREEN_H = 160, 144 local BG_TILES = 32 -- TILEMAP_WIDTH / TILEMAP_HEIGHT local BG_PIXELS = BG_TILES * 8 -- the BG map wraps every 256 pixels local META_COLS = 16 -- TILEMAP_WIDTH / 2 local TILEMAP_W, TILEMAP_H = 20, 18 -- SCREEN_WIDTH / SCREEN_HEIGHT local INTRO_MUSIC = "Music_GoldSilverOpening" local INTRO_MUSIC_2 = "Music_GoldSilverOpening2" local SFX_FIREBALL = "Sfx_GsIntroCharizardFireball" local SFX_APPEARS = "Sfx_GsIntroPokemonAppears" -- Intro_AnimateOceanWaves' .wave_tiles: four four-tile cycles, each repeated -- across the whole 32-tile BG row. local WAVE_TILES = { { 0x70, 0x71, 0x72, 0x73 }, { 0x74, 0x75, 0x76, 0x77 }, { 0x78, 0x79, 0x7a, 0x7b }, { 0x7c, 0x7d, 0x7e, 0x7f }, } -- `vBGMap0 tile $1e` is 480 bytes in, i.e. the whole of BG row 15. local WAVE_ROW = 15 -- DrawIntroCharizardGraphic .charizard_data: vtile offset, width, height and -- the tilemap coordinate the rectangle of running tile ids starts at. local CHARIZARD_GFX = { { tile = 0x00, width = 8, height = 8, x = 10, y = 6 }, -- mouth closed { tile = 0x40, width = 9, height = 8, x = 9, y = 6 }, -- mouth open { tile = 0x88, width = 9, height = 8, x = 8, y = 6 }, -- breathing fire } -- IntroScene5 / IntroScene9 / IntroScene12 / IntroScene16 palette ladders. -- Each is a DMG palette register that DmgToCgbBGPals reorders the loaded CGB -- colours through, so %11100100 is the identity and %00000000 collapses every -- shade onto colour 0. local WATER_FADE = { 0xe4, 0xe4, 0x90, 0x40, 0x00 } local GRASS_FADE = { 0xe4, 0xe4, 0xe4, 0xe4, 0xe4, 0x90, 0x40, 0x00 } local CHARIZARD_PALS = { 0x6a, 0xa5, 0xe4, 0x00 } local FIRE_FADE = { 0xe4, 0x90, 0x40, 0x00 } -- Intro_CheckSCYEvent .scy_jumptable: the SCY values the fire act's climb -- fires its palette flashes and starter entrances on. local SCY_EVENTS = { [0x86] = "loadChikorita", [0x87] = "chikoritaAppears", [0x88] = "flashMonPalette", [0x98] = "flashSilhouette", [0x99] = "loadCyndaquil", [0xaf] = "cyndaquilAppears", [0xb0] = "flashMonPalette", [0xc0] = "flashSilhouette", [0xc1] = "loadTotodile", [0xd7] = "totodileAppears", [0xd8] = "flashMonPalette", [0xe8] = "flashSilhouette", [0xe9] = "loadCharizard", } local WHITE = { 255, 255, 255 } local BLACK = { 0, 0, 0 } -------------------------------------------------------------------------- -- Palette plumbing -------------------------------------------------------------------------- -- CopyPals: colour i of the displayed palette is colour (reg >> 2i) & 3 of the -- loaded one. DmgToCgbBGPals runs it over every BG palette with rBGP and -- DmgToCgbObjPals over every OBJ palette with rOBP0, which is what makes the -- fades work without touching the artwork. local function remap(palette, register) local out = {} for index = 0, 3 do local slot = math.floor(register / 4 ^ index) % 4 out[index + 1] = (palette and palette[slot + 1]) or BLACK end return out end local function copyPalette(source) local out = {} for index = 1, 4 do local color = source and source[index] out[index] = color and { color[1], color[2], color[3] } or BLACK end return out end -------------------------------------------------------------------------- -- Construction -------------------------------------------------------------------------- function GoldSilverIntro:wantsFillScale() return true end function GoldSilverIntro:drawsWidescreen() return true end function GoldSilverIntro.new(game, opts) opts = opts or {} local self = setmetatable({}, GoldSilverIntro) self.game = game self.onDone = opts.onDone local data = (game and game.data) or {} self.monPalettes = data.gen2Palettes self.assets = opts.intro or data.gen2Intro or (game and game.introData) or nil if not self.assets then -- The scene script runs either way, so a cache with no intro.lua plays -- the movie's full 2335 frames over an empty screen and reads exactly -- like "the intro does not work". Say so instead: the fix is a -- re-import, and nothing else in the boot chain will mention it. Logger.warn("gen2 intro: no intro.lua in the cache -- re-import " .. "this version or the movie plays blank") end self.images = {} self.sheets = {} self.anims = SpriteAnims.new() self.scene = 1 self.done = false self.frames = 0 -- Hardware registers and the movie's own WRAM. self.scx, self.scy = 0, 0 self.counter1, self.counter2 = 0, 0 self.bgp, self.obp0 = 0xe4, 0xe4 self.lyActive = false self.lyOverrides = {} self.lySine = {} for line = 1, SCREEN_H do self.lyOverrides[line] = 0 self.lySine[line] = 0 end self.bgPals = { copyPalette(nil) } self.obPals = { copyPalette(nil), copyPalette(nil) } self.bgmap = {} for index = 1, BG_TILES * BG_TILES do self.bgmap[index] = 0 end self.tilemap = {} for index = 1, TILEMAP_W * TILEMAP_H do self.tilemap[index] = 0 end self.act = nil self.mapDirty = true return self end -------------------------------------------------------------------------- -- BG map -------------------------------------------------------------------------- local function mapGet(self, col, row) return self.bgmap[(row % BG_TILES) * BG_TILES + (col % BG_TILES) + 1] end local function mapSet(self, col, row, tile) self.bgmap[(row % BG_TILES) * BG_TILES + (col % BG_TILES) + 1] = tile self.mapDirty = true end local function actData(self) return self.assets and self.act and self.assets[self.act] or nil end -- Intro_Draw2x2Tiles: metatile `index` of the act's table is four tile ids in -- reading order, laid into the 2x2 block whose top-left corner is (col, row). local function draw2x2(self, source, index, col, row) local meta = source.meta for quad = 0, 3 do local tile = meta[index * 4 + quad + 1] or 0 mapSet(self, col + quad % 2, row + math.floor(quad / 2), tile) end end -- Intro_DrawBackground: 16 metatile rows of 16, starting at tilemap row -- `firstRow`, laid across the whole BG map from BG row 0. local function drawBackground(self, source, firstRow) for metaRow = 0, BG_TILES / 2 - 1 do for metaCol = 0, META_COLS - 1 do local index = source.tilemap[(firstRow + metaRow) * META_COLS + metaCol + 1] draw2x2(self, source, index or 0, metaCol * 2, metaRow * 2) end end end -- Intro_UpdateTilemapAndBGMap: step the tilemap pointer back one metatile row -- and the BG pointer back two tile rows, then draw the new row in at the top -- of the map -- which, because the map wraps, is the row that just scrolled -- off the bottom. It also ticks counter1 down, and that counter is what ends -- the climb. local function updateTilemapAndBGMap(self, source) self.tilemapRow = self.tilemapRow - 1 self.bgRow = (self.bgRow - 2) % BG_TILES for metaCol = 0, META_COLS - 1 do local index = source.tilemap[self.tilemapRow * META_COLS + metaCol + 1] draw2x2(self, source, index or 0, metaCol * 2, self.bgRow) end self.counter1 = (self.counter1 - 1) % 256 end -- Intro_AnimateOceanWaves. Real hardware queues this as a 2bpp request that -- lands in the next VBlank; there is no queue here, so the row changes on the -- frame that asks for it. local function animateOceanWaves(self) if self.counter2 % 4 == 3 then return end local cycle = WAVE_TILES[math.floor(self.counter2 % 0x40 / 0x10) + 1] for col = 0, BG_TILES - 1 do mapSet(self, col, WAVE_ROW, cycle[col % 4 + 1]) end end -------------------------------------------------------------------------- -- LY overrides -------------------------------------------------------------------------- -- Intro_InitSineLYOverrides fills a 144-entry table with sin(line * pi/32) at -- amplitude 4; Intro_UpdateLYOverrides then rotates that table by one entry a -- frame and adds hSCY, so the wave travels down the screen. The top 16 -- scanlines are held flat at hSCY. local function initSineLYOverrides(self) for line = 0, SCREEN_H - 1 do self.lySine[line + 1] = SpriteAnims.sine(line, 4) end end local function resetLYOverrides(self) for line = 1, SCREEN_H do self.lyOverrides[line] = 0 end self.lyActive = false end local function updateLYOverrides(self) for line = 1, 16 do self.lyOverrides[line] = self.scy end local first = self.lySine[1] for offset = 0, 0x7f do local value = self.lySine[offset + 2] self.lySine[offset + 1] = value self.lyOverrides[17 + offset] = (value + self.scy) % 256 end self.lySine[0x81] = first end -------------------------------------------------------------------------- -- Audio -------------------------------------------------------------------------- function GoldSilverIntro:playMusic(song) local data = self.game and self.game.data local audio = data and data.audio if audio and audio.runtime and audio.songs and audio.songs[song] then Music.play(data, song) end end function GoldSilverIntro:playSfx(name) local data = self.game and self.game.data local audio = data and data.audio if audio and audio.runtime and audio.sfx and audio.sfx[name] then Sound.play(data, name) end end -------------------------------------------------------------------------- -- Scenes -------------------------------------------------------------------------- local Scenes = {} -- IntroScene1: set up the water cutscene. Scenes[1] = function(self) self.scene = 2 self.act = "water" local source = actData(self) if source then self.tilemapRow = source.firstRow or 15 self.bgRow = 0 drawBackground(self, source, self.tilemapRow) end self.anims:clear() self.scy = 0 self.anims.globalY, self.anims.globalX = 0, 0 self.scx = 0x58 self.counter2 = 0 self.counter1 = 0x80 self.lyActive = true initSineLYOverrides(self) self.anims.flag = 0 -- GetSGBLayout SCGB_GS_INTRO 0 -> _CGB_GSIntro.ShellderLaprasScene. local palettes = self.assets and self.assets.palettes or {} self.bgPals[1] = copyPalette(palettes.waterBg) self.obPals[1] = copyPalette(palettes.waterOb and palettes.waterOb[1]) self.obPals[2] = copyPalette(palettes.waterOb and palettes.waterOb[2]) self.bgp, self.obp0 = 0xe4, 0xe4 -- Intro_InitShellders. self.anims:init("GS_INTRO_SHELLDER", 7 * 8, 18 * 8) self.anims:init("GS_INTRO_SHELLDER", 10 * 8, 14 * 8) self.anims:init("GS_INTRO_SHELLDER", 15 * 8, 16 * 8) self:playMusic(INTRO_MUSIC) end -- Intro_InitBubble .pixel_table, as {x, y} pairs. The counter picks a slot -- 0-7 out of a six-entry table, so on hardware two of the nine bubbles read -- past its end and surface at whatever the following code bytes say; those two -- are dropped here rather than reproduced. local BUBBLE_SPOTS = { { 6 * 8, 14 * 8 + 4 }, { 14 * 8, 18 * 8 + 4 }, { 10 * 8, 16 * 8 + 4 }, { 12 * 8, 15 * 8 }, { 4 * 8, 13 * 8 }, { 8 * 8, 17 * 8 }, } local function initBubble(self) if self.counter1 % 16 ~= 0 then return end local spot = BUBBLE_SPOTS[math.floor(self.counter1 % 0x80 / 0x10) + 1] if not spot then return end self.anims:init("GS_INTRO_BUBBLE", spot[1], spot[2]) end -- IntroScene2: Shellders drift, bubbles rise, for $80 frames. Scenes[2] = function(self) updateLYOverrides(self) if self.counter1 ~= 0 then self.counter1 = self.counter1 - 1 initBubble(self) return end self.counter1 = 0x10 self.scene = 3 return Scenes[3](self) end -- Intro_InitMagikarps: three at once, alternating between two sets of spots -- every 64 frames. Its rate and phase masks come out of a `depixel` used as a -- pair of constants (8, 7 -> $40 and $3f). local MAGIKARP_SPOTS = { { { 28 * 8, 29 * 8 }, { 0 * 8, 26 * 8 }, { 24 * 8, 0 * 8 } }, { { 30 * 8, 28 * 8 }, { 24 * 8, 31 * 8 }, { 28 * 8, 2 * 8 } }, } local function initMagikarps(self) if self.counter2 % 0x40 ~= 0 then return end local set = MAGIKARP_SPOTS[self.counter2 % 0x80 ~= 0 and 2 or 1] for _, spot in ipairs(set) do self.anims:init("GS_INTRO_MAGIKARP", spot[1], spot[2]) end end local function initLapras(self) if self.counter2 % 0x20 ~= 0 then return end self.anims:init("GS_INTRO_LAPRAS", 24 * 8, 16 * 8) end -- IntroScene3_Jumper's 17 entries, indexed by counter1 -- which counts the -- remaining metatile rows down from $10, so this table reads bottom-up: the -- wobble runs while the camera is still deep, then Magikarp jump, then Lapras -- arrives just before the surface. local SCENE3_STEPS = { [0] = "waves", "waves", "waves", "lapras", "waves", "waves", "magikarp", "magikarp", "magikarp", "palettes", "noLY", "ly", "ly", "ly", "ly", "ly", "ly", } local function scene3Jumper(self) local step = SCENE3_STEPS[self.counter1] if step == "lapras" then initLapras(self) self.obp0 = 0xe4 -- DmgToCgbObjPals with depixel 28, 28, 4, 4 animateOceanWaves(self) elseif step == "waves" then animateOceanWaves(self) elseif step == "magikarp" then initMagikarps(self) animateOceanWaves(self) elseif step == "palettes" then if self.counter2 % 0x20 == 0 then -- Intro_LoadMagikarpPalettes swaps in the school's own colours. local palettes = self.assets and self.assets.palettes or {} self.bgPals[1] = copyPalette(palettes.magikarpBg) self.obPals[1] = copyPalette(palettes.magikarpOb) else initMagikarps(self) end elseif step == "noLY" then self.lyActive = false elseif step == "ly" then updateLYOverrides(self) end end -- IntroScene3_ScrollToSurface: hSCX creeps left a quarter as fast as the climb, -- hSCY steps up every other frame, and a new metatile row streams in every 16 -- pixels. Carry (the act ending) is counter1 reaching zero. local function scrollToSurface(self) self.counter2 = (self.counter2 + 1) % 256 if self.counter2 % 4 == 0 then self.scx = (self.scx - 1) % 256 end if self.counter2 % 2 ~= 0 then return false end self.anims.globalY = (self.anims.globalY + 1) % 256 local before = self.scy self.scy = (self.scy - 1) % 256 if before % 16 == 0 then local source = actData(self) if source then updateTilemapAndBGMap(self, source) end end return self.counter1 == 0 end -- IntroScene3: rise towards the surface. Scenes[3] = function(self) scene3Jumper(self) if not scrollToSurface(self) then return end resetLYOverrides(self) self.scy = (self.scy + 1) % 256 self.scene = 4 return Scenes[4](self) end -- IntroScene4: at the surface; hold until Lapras has swum off to the left. Scenes[4] = function(self) if self.anims.flag == 0 then self.counter2 = (self.counter2 + 1) % 256 if self.counter2 % 16 == 0 then self.scx = (self.scx - 2) % 256 end animateOceanWaves(self) return end self.scene = 5 self.counter1 = 0 return Scenes[5](self) end -- IntroScene5: fade out, one palette step every 16 frames. Scenes[5] = function(self) local step = math.floor(self.counter1 / 16) + 1 self.counter1 = (self.counter1 + 1) % 256 local palette = WATER_FADE[step] if not palette then self.scene = 6 return end self.bgp = palette animateOceanWaves(self) self.scx = (self.scx - 2) % 256 end -- IntroScene6: set up the grass cutscene. Scenes[6] = function(self) self.scene = 7 self.act = "grass" self.anims:clear() resetLYOverrides(self) local source = actData(self) if source then self.tilemapRow = source.firstRow or 0 self.bgRow = 0 drawBackground(self, source, self.tilemapRow) end self.scy = 0 self.anims.globalY = 0 self.scx = 0x60 self.anims.globalX = 0xa0 self.counter2 = 0 local palettes = self.assets and self.assets.palettes or {} self.bgPals[1] = copyPalette(palettes.grassBg) self.obPals[1] = copyPalette(palettes.grassOb) self.obPals[2] = copyPalette(palettes.grassOb) self.bgp, self.obp0 = 0xe4, 0xe4 -- Intro_InitJigglypuff. self.anims:init("GS_INTRO_JIGGLYPUFF", 6 * 8, 14 * 8) self.anims.flag = 0 end -- Intro_InitNote: one note every 64 frames, and every other one is the -- invisible variant that only leaves the little sparkle tile. local function initNote(self) if self.anims.flag ~= 0 then return end if self.counter2 % 0x40 ~= 0 then return end if self.counter2 % 0x80 ~= 0 then self.anims:init("GS_INTRO_NOTE", 6 * 8, 11 * 8 + 4) else self.anims:init("GS_INTRO_INVISIBLE_NOTE", 6 * 8, 10 * 8 + 4) end end -- IntroScene7: scroll left to Jigglypuff. wGlobalAnimXOffset counts up as -- hSCX counts down so the sprites hold still while the field slides. Scenes[7] = function(self) initNote(self) local before = self.counter2 self.counter2 = (self.counter2 + 1) % 256 -- `and 3 / ret z`: the camera holds still one frame in four. if before % 4 == 0 then return end if self.scx ~= 0 then self.scx = self.scx - 1 self.anims.globalX = (self.anims.globalX + 1) % 256 return end self.counter1 = 0xff -- Intro_InitPikachu: body and tail are two objects at the same spot. self.anims:init("GS_INTRO_PIKACHU", 24 * 8, 14 * 8) self.anims:init("GS_INTRO_PIKACHU_TAIL", 24 * 8, 14 * 8) self.scene = 8 end -- IntroScene8: stop scrolling; Pikachu runs in and attacks. Scenes[8] = function(self) if self.counter1 ~= 0 then self.counter1 = self.counter1 - 1 initNote(self) self.counter2 = (self.counter2 + 1) % 256 return end self.counter1 = 0 self.scene = 9 end -- IntroScene9: scroll down and fade, one palette step every 8 frames. Scenes[9] = function(self) local step = math.floor(self.counter1 / 8) + 1 self.counter1 = (self.counter1 + 1) % 256 local palette = GRASS_FADE[step] if not palette then self.scene = 10 return end self.bgp = palette self.scy = (self.scy + 1) % 256 self.anims.globalY = (self.anims.globalY - 1) % 256 end -- DrawIntroCharizardGraphic: wipe tilemap rows 6-13, then fill a rectangle -- with running tile ids and push the tilemap to the BG map. local function drawCharizard(self, stage) for row = 6, 13 do for col = 0, TILEMAP_W - 1 do self.tilemap[row * TILEMAP_W + col + 1] = 0 end end local gfx = CHARIZARD_GFX[stage + 1] local tile = gfx.tile for row = 0, gfx.height - 1 do for col = 0, gfx.width - 1 do self.tilemap[(gfx.y + row) * TILEMAP_W + gfx.x + col + 1] = tile % 256 tile = tile + 1 end end for row = 0, TILEMAP_H - 1 do for col = 0, TILEMAP_W - 1 do mapSet(self, col, row, self.tilemap[row * TILEMAP_W + col + 1]) end end end -- IntroScene10: set up the fireball cutscene. Scenes[10] = function(self) self.scene = 11 self.act = "fire" self.anims:clear() resetLYOverrides(self) for index = 1, BG_TILES * BG_TILES do self.bgmap[index] = 0 end for index = 1, TILEMAP_W * TILEMAP_H do self.tilemap[index] = 0 end self.mapDirty = true drawCharizard(self, 0) self.scy = 0x80 self.scx = 0 self.anims.globalY, self.anims.globalX = 0, 0 self.counter2 = 0 local palettes = self.assets and self.assets.palettes or {} self.bgPals[1] = copyPalette(palettes.fireBg and palettes.fireBg[1]) self.obPals[1] = copyPalette(palettes.startersOb) self.obPals[2] = copyPalette(palettes.startersOb) -- %00111111 paints the silhouette flat: every shade takes colour 3. self.bgp = 0x3f self.obp0 = 0xff self:playMusic(INTRO_MUSIC_2) end local function monColors(self, species) return self.monPalettes and Palettes.monColors(self.monPalettes, species) or nil end -- Intro_LoadMonPalette (CGB): white, the mon's two colours, black, into OBJ -- palette 0. Intro_LoadCharizardPalette deliberately uses Cyndaquil's on a -- CGB and only reaches for Charizard's on a DMG. local function loadMonPalette(self, species) local colors = monColors(self, species) if not colors then return end self.obPals[1] = { WHITE, colors[2] or BLACK, colors[3] or BLACK, BLACK } end local SCY_HANDLERS = { loadChikorita = function(self) loadMonPalette(self, "CHIKORITA") end, loadCyndaquil = function(self) loadMonPalette(self, "CYNDAQUIL") end, loadTotodile = function(self) loadMonPalette(self, "TOTODILE") end, loadCharizard = function(self) loadMonPalette(self, "CYNDAQUIL") end, chikoritaAppears = function(self) self:playSfx(SFX_APPEARS) self.anims:init("GS_INTRO_CHIKORITA", 1 * 8, 22 * 8) end, cyndaquilAppears = function(self) self:playSfx(SFX_APPEARS) self.anims:init("GS_INTRO_CYNDAQUIL", 20 * 8, 22 * 8) end, totodileAppears = function(self) self:playSfx(SFX_APPEARS) self.anims:init("GS_INTRO_TOTODILE", 1 * 8, 22 * 8) end, -- Intro_FlashMonPalette shows the starter and blacks the silhouette out; -- Intro_FlashSilhouette does the reverse. flashMonPalette = function(self) self.obp0 = 0xe4 self.bgp = 0x00 end, flashSilhouette = function(self) self.obp0 = 0xff self.bgp = 0x3f end, } -- IntroScene11: climb to the silhouette, every other frame, firing the events -- above as hSCY passes them. Scenes[11] = function(self) local before = self.counter2 self.counter2 = (self.counter2 + 1) % 256 if before % 2 == 0 then return end local handler = SCY_HANDLERS[SCY_EVENTS[self.scy] or ""] if handler then handler(self) end if self.scy ~= 0 then self.scy = (self.scy + 1) % 256 return end self.scene = 12 self.counter1 = 0 return Scenes[12](self) end -- IntroScene12: four Charizard palettes, four frames apiece, ending on the -- one that reads as $00 and moves the scene on. Scenes[12] = function(self) local step = math.floor(self.counter1 / 4) % 4 + 1 self.counter1 = (self.counter1 + 1) % 256 local palette = CHARIZARD_PALS[step] if palette == 0 then self.scene = 13 self.counter1 = 0x80 return end self.bgp = palette self.obp0 = palette end -- IntroScene13: hold, then open the mouth. Scenes[13] = function(self) if self.counter1 ~= 0 then self.counter1 = self.counter1 - 1 return end self.scene = 14 drawCharizard(self, 1) self.counter1 = 4 end -- IntroScene14: hold four frames, then breathe. Scenes[14] = function(self) if self.counter1 ~= 0 then self.counter1 = self.counter1 - 1 return end self.scene = 15 drawCharizard(self, 2) self.counter1 = 64 self.counter2 = 0 self:playSfx(SFX_FIREBALL) return Scenes[15](self) end -- Intro_AnimateFireball: a new fireball every 4 frames, each one leaving the -- mouth on its own angle, while the field slides left underneath them. local function animateFireball(self) local before = self.counter2 self.counter2 = (self.counter2 + 1) % 256 if before % 4 ~= 0 then return end self.anims:init("GS_INTRO_FIREBALL", 10 * 8 + 4, 12 * 8 + 4) self.scx = (self.scx - 1) % 256 self.anims.globalX = (self.anims.globalX + 1) % 256 end -- IntroScene15: 64 frames of fireball. Scenes[15] = function(self) animateFireball(self) if self.counter1 ~= 0 then self.counter1 = self.counter1 - 1 return end self.scene = 16 self.counter1 = 0 end -- IntroScene16: keep the fireball going while the palettes fade out. Scenes[16] = function(self) animateFireball(self) local step = math.floor(self.counter1 / 16) % 8 + 1 self.counter1 = (self.counter1 + 1) % 256 local palette = FIRE_FADE[step] if not palette then self.scene = 17 self.hold = 0 return end self.bgp = palette self.obp0 = palette end -- IntroScene17: 64 frames of black, then the done flag. The cart spends them -- in a blocking `ld c, 64` loop rather than a counter, so this one gets a -- field of its own. Scenes[17] = function(self) self.hold = (self.hold or 0) + 1 if self.hold >= 64 then self.done = true end end -------------------------------------------------------------------------- -- Frame loop -------------------------------------------------------------------------- -- GoldSilverIntro.PlayFrame, minus the joypad read: sprite animations first, -- then the scene. Returns true once the movie is over. function GoldSilverIntro:step() if self.done then return true end self.frames = self.frames + 1 self.anims:playFrame() local scene = Scenes[self.scene] if scene then scene(self) end return self.done end function GoldSilverIntro:enter() -- IntroScene1 is what starts the music, and it has not run yet. -- -- intro.boot.movie: the attract movie is up, on frame zero of scene 1. Gen 2 -- only, like the rest of intro.boot.* (see src/ui/gen2/CopyrightSplash.lua): -- Red's IntroMovie is a different sequence on a different screen and shares -- no moment with this one. if Runtime.wants("intro.boot.movie") then Runtime.emit("intro.boot.movie", { screen = self, game = self.game }) end end function GoldSilverIntro:finish() if self.finished then return end self.finished = true self.done = true -- intro.boot.movie_ended is the one card end that earns a name of its own. -- The other three hand off to a card that announces itself, but this one has -- a fact nothing downstream carries: whether the player sat through all 2335 -- frames or cut it short (GoldSilverIntro.PlayFrame's PAD_BUTTONS exit). if Runtime.wants("intro.boot.movie_ended") then Runtime.emit("intro.boot.movie_ended", { screen = self, game = self.game, skipped = self.skipped and true or false, frames = self.frames, }) end if self.onDone then self.onDone() end end function GoldSilverIntro:skip() self.skipped = true self:finish() end function GoldSilverIntro:update(_dt) if self.finished then return end local input = self.game and self.game.input if input then for _, button in ipairs({ "a", "b", "start", "select" }) do if input:wasPressed(button) then self:skip() return end end end if self:step() then self:finish() end end -------------------------------------------------------------------------- -- Drawing -------------------------------------------------------------------------- function GoldSilverIntro:image(path) if not path then return nil end local cached = self.images[path] if cached == nil then local ok, img = pcall(Assets.image, path) cached = ok and img or false if cached then cached:setFilter("nearest", "nearest") end self.images[path] = cached end return cached or nil end -- A tile sheet is 16 tiles wide, so tile id N is at (N % 16, N / 16). Quads -- are built once per sheet. function GoldSilverIntro:sheet(path) if not path then return nil end local entry = self.sheets[path] if entry ~= nil then return entry or nil end local image = self:image(path) if not image then self.sheets[path] = false return nil end local width, height = image:getDimensions() local quads = {} for tile = 0, math.floor(width / 8) * math.floor(height / 8) - 1 do quads[tile] = love.graphics.newQuad( tile % 16 * 8, math.floor(tile / 16) * 8, 8, 8, width, height) end entry = { image = image, quads = quads } self.sheets[path] = entry return entry end local function bgPalette(self) return remap(self.bgPals[1], self.bgp) end local function objPalette(self, slot) return remap(self.obPals[slot + 1] or self.obPals[1], self.obp0) end -- The BG map is rendered into a 256x256 canvas and only redrawn when a scene -- edits the map, which is what keeps the per-scanline present cheap. function GoldSilverIntro:bgCanvas() local source = actData(self) local sheet = source and self:sheet(source.tiles) if not sheet then return nil end if self.canvasSheet ~= sheet then self.canvas = nil self.canvasSheet = sheet self.mapDirty = true end if not self.canvas then local ok, canvas = pcall(love.graphics.newCanvas, BG_PIXELS, BG_PIXELS) if not ok then return nil end canvas:setFilter("nearest", "nearest") self.canvas = canvas self.mapDirty = true end if self.mapDirty then local G = love.graphics local previous = G.getCanvas() -- The caller is inside the renderer's letterbox transform; a canvas does -- not reset it, so the map would land scaled and off the edge. G.push() G.origin() G.setCanvas(self.canvas) G.clear(0, 0, 0, 0) G.setColor(1, 1, 1, 1) for row = 0, BG_TILES - 1 do for col = 0, BG_TILES - 1 do local quad = sheet.quads[mapGet(self, col, row)] if quad then G.draw(sheet.image, quad, col * 8, row * 8) end end end G.setCanvas(previous) G.pop() self.mapDirty = false end return self.canvas end -- Present the canvas at (hSCX, hSCY), wrapping both ways. With the LY -- overrides live each scanline gets its own hSCY, which is the water's bend. function GoldSilverIntro:drawBackground() local canvas = self:bgCanvas() if not canvas then return end local G = love.graphics G.setColor(1, 1, 1, 1) local scx = self.scx % BG_PIXELS if not self.lyActive then local scy = self.scy % BG_PIXELS for _, ox in ipairs({ 0, BG_PIXELS }) do for _, oy in ipairs({ 0, BG_PIXELS }) do G.draw(canvas, -scx + ox, -scy + oy) end end return end self.lineQuad = self.lineQuad or love.graphics.newQuad(0, 0, BG_PIXELS, 1, BG_PIXELS, BG_PIXELS) for line = 0, SCREEN_H - 1 do local scy = (self.lyOverrides[line + 1] + line) % BG_PIXELS self.lineQuad:setViewport(0, scy, BG_PIXELS, 1, BG_PIXELS, BG_PIXELS) G.draw(canvas, self.lineQuad, -scx, line) G.draw(canvas, self.lineQuad, -scx + BG_PIXELS, line) end end -- One pass over wShadowOAM. `priority` picks the objects that go behind the -- BG's opaque pixels (OAM_PRIO), which is what half-submerges Lapras. function GoldSilverIntro:drawObjects(priority) local source = actData(self) local sheet = source and self:sheet(source.sprites) if not sheet then return end local G = love.graphics local oam = self.anims.oam local shader = GbcPalette.available() local current = nil for index = #oam, 1, -1 do local entry = oam[index] local behind = entry.attr >= SpriteAnims.OAM_PRIO if behind == priority then local quad = sheet.quads[entry.tile] if quad then local slot = entry.attr % 8 if shader and current ~= slot then GbcPalette.use(objPalette(self, slot)) current = slot end local flipX = math.floor(entry.attr / SpriteAnims.OAM_XFLIP) % 2 == 1 local flipY = math.floor(entry.attr / SpriteAnims.OAM_YFLIP) % 2 == 1 G.setColor(1, 1, 1, 1) G.draw(sheet.image, quad, entry.x - 8 + (flipX and 8 or 0), entry.y - 16 + (flipY and 8 or 0), 0, flipX and -1 or 1, flipY and -1 or 1) end end end if current then GbcPalette.clear() end end -- Compose one 160x144 frame. It goes through a canvas of its own because the -- BG is a 256x256 map drawn at a scroll offset and OBJs hang off both edges: -- on hardware the LCD simply stops at the screen, and this is what stops. function GoldSilverIntro:renderFrame() local G = love.graphics local pal = bgPalette(self) -- Priming the BG canvas first keeps the two setCanvas calls from nesting. self:bgCanvas() if not self.frameCanvas then local ok, canvas = pcall(G.newCanvas, SCREEN_W, SCREEN_H) if not ok then return nil end canvas:setFilter("nearest", "nearest") self.frameCanvas = canvas end local previous = G.getCanvas() G.push() G.origin() G.setCanvas(self.frameCanvas) -- BG colour 0 is the backdrop; the tile sheets are written with shade 0 -- transparent so a priority OBJ shows through exactly where the hardware -- would let it. local backdrop = GbcPalette.color(pal, 1) or BLACK G.clear(backdrop[1] / 255, backdrop[2] / 255, backdrop[3] / 255, 1) G.setColor(1, 1, 1, 1) self:drawObjects(true) if GbcPalette.available() then GbcPalette.with(pal, function() self:drawBackground() end) else self:drawBackground() end self:drawObjects(false) G.setCanvas(previous) G.pop() return self.frameCanvas end function GoldSilverIntro:drawPanel() local G = love.graphics local canvas = self:renderFrame() G.setColor(1, 1, 1, 1) if canvas then G.draw(canvas, 0, 0) return end G.setColor(0, 0, 0, 1) G.rectangle("fill", 0, 0, SCREEN_W, SCREEN_H) G.setColor(1, 1, 1, 1) end function GoldSilverIntro:draw() self:drawPanel() end function GoldSilverIntro:drawWidescreen(winW, winH) local G = love.graphics G.setColor(0, 0, 0, 1) G.rectangle("fill", 0, 0, winW, winH) local scale = Chrome.fitScale(winW, winH) G.push() G.translate(Chrome.fitOrigin(winW, winH, scale)) G.scale(scale, scale) self:drawPanel() G.pop() end GoldSilverIntro.Scenes = Scenes GoldSilverIntro.WATER_FADE = WATER_FADE GoldSilverIntro.GRASS_FADE = GRASS_FADE GoldSilverIntro.CHARIZARD_PALS = CHARIZARD_PALS GoldSilverIntro.FIRE_FADE = FIRE_FADE GoldSilverIntro.SCY_EVENTS = SCY_EVENTS GoldSilverIntro.WAVE_TILES = WAVE_TILES GoldSilverIntro.CHARIZARD_GFX = CHARIZARD_GFX GoldSilverIntro.remap = remap return GoldSilverIntro