mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-23 22:17:19 +02:00
1053 lines
34 KiB
Lua
1053 lines
34 KiB
Lua
-- 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
|