mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
402 lines
15 KiB
Lua
402 lines
15 KiB
Lua
-- Boot splash + attract movie, a faithful port of PlayIntro
|
|
-- (engine/movie/intro.asm) and AnimateShootingStar (engine/movie/splash.asm)
|
|
-- using the real extracted art (data/generated/field.lua `intro` manifest).
|
|
--
|
|
-- Three frame-counted phases:
|
|
-- 1. copyright card, 180 frames (intro.asm:311-312).
|
|
-- 2. shooting star: 64 frames of empty letterbox (intro.asm:323-324), then
|
|
-- the big star streaks down-left for 40 frames while the studio logo
|
|
-- sits centered in the letterbox band (the GAME FREAK logo + letter
|
|
-- row it replaces sat at (72,56)/(40,80); splash.asm:27-60, 211-228),
|
|
-- the logo flashes 3x10 frames (splash.asm:72-82), 4 waves of small
|
|
-- stars rain from the logo -- 6x24 frames, +1px every 3 frames, lower
|
|
-- star blinking (splash.asm:97-146, 163-209) -- and a 40 frame hold
|
|
-- (intro.asm:329-331).
|
|
-- 3. the Gengar/Nidorino fight (PlayIntroScene, intro.asm:23-141), played
|
|
-- from FIGHT_SCRIPT below: Music_IntroBattle starts, Gengar (56x56 BG
|
|
-- pose from a gengar_N.tilemap, at tile 13,7 = x104,y56) scrolls left
|
|
-- while Nidorino (48x48 OAM at x-8,y72) walks right, then the scripted
|
|
-- hip/hop hops, Gengar's raise + slash lunge, Nidorino's dodge leap,
|
|
-- retreat, crouch and final lunge, ending in a 24-frame fade to white
|
|
-- (GBFadeOutToWhite, home/fade.asm:26-40).
|
|
--
|
|
-- Any of A/B/START skips the whole movie (CheckForUserInterruption).
|
|
-- Pops itself and calls onDone() when finished or skipped. All art loads
|
|
-- through pcall and every missing graphic degrades to a text/rect
|
|
-- fallback, so the movie stays headless-safe.
|
|
|
|
local Font = require("src.render.Font")
|
|
local Music = require("src.core.Music")
|
|
local Sound = require("src.core.Sound")
|
|
local Strings = require("src.core.Strings")
|
|
|
|
local IntroMovie = {}
|
|
IntroMovie.__index = IntroMovie
|
|
IntroMovie.isOpaque = true
|
|
|
|
-- Same as the title screen: full-bleed art, no world behind it, no player zoom
|
|
-- to respect, so fill the window rather than sit at the fixed integer scale.
|
|
function IntroMovie:wantsFillScale() return true end
|
|
|
|
-- SGB intro palettes: the splash uses PalPacket_GameFreakIntro (logo
|
|
-- GAMEFREAK, falling star columns RED/VIRIDIAN/BLUEMON), the attract
|
|
-- fight PalPacket_NidorinoIntro (PURPLEMON letterbox, BLACK bars)
|
|
function IntroMovie:sgbPalettes(game)
|
|
local P = require("src.render.PaletteFX")
|
|
if self.phase == 2 then
|
|
local logo = P.pal(game.data, "GAMEFREAK")
|
|
if not logo then return nil end
|
|
return {
|
|
P.whole(logo),
|
|
P.zone(P.pal(game.data, "REDMON"), 5, 11, 7, 13),
|
|
P.zone(P.pal(game.data, "VIRIDIAN"), 8, 11, 9, 13),
|
|
P.zone(P.pal(game.data, "BLUEMON"), 12, 11, 14, 13),
|
|
}
|
|
elseif self.phase == 3 then
|
|
local purple = P.pal(game.data, "PURPLEMON")
|
|
if not purple then return nil end
|
|
return {
|
|
P.zone(P.pal(game.data, "BLACK"), 0, 0, 19, 3),
|
|
P.zone(purple, 0, 4, 19, 13),
|
|
P.zone(P.pal(game.data, "BLACK"), 0, 14, 19, 17),
|
|
}
|
|
end
|
|
return nil -- the copyright card stays plain
|
|
end
|
|
|
|
local COPYRIGHT_FRAMES = 180 -- ld c, 180 (intro.asm:311-312)
|
|
|
|
-- phase 2 (shooting star) timeline, in frames from phase start
|
|
local STAR_START = 64 -- ld c, 64 (intro.asm:323-324)
|
|
local STAR_FRAMES = 40 -- OAM Y 0->160 in +4 steps (splash.asm:32-60)
|
|
local FLASH_START = STAR_START + STAR_FRAMES
|
|
local FLASH_FRAMES = 30 -- 3 loops x 10 frames (splash.asm:72-82)
|
|
local WAVES_START = FLASH_START + FLASH_FRAMES
|
|
local WAVE_FRAMES = 24 -- 8 substeps x 3 frames (splash.asm:186-209)
|
|
local WAVES_END = WAVES_START + 6 * WAVE_FRAMES -- 4 waves + 2 empty
|
|
local SPLASH_FRAMES = WAVES_END + 40 -- ld c, 40 (intro.asm:329-331)
|
|
|
|
-- logo 16x24 at grid (10,9), letters row at grid y=12 cols 6..15
|
|
-- (GameFreakLogoOAMData, splash.asm:211-228; screen = grid*8, OAM offsets
|
|
-- cancel)
|
|
local LOGO_X, LOGO_Y = 72, 56
|
|
|
|
-- The studio logo (assets/logo/minilogo.png) stands in for both the
|
|
-- GAME FREAK logo and its letter row, so it gets the whole band between
|
|
-- the letterbox bars (y 32..112) down to where the star waves spawn
|
|
-- (y=88): fit it inside this box, centered, aspect preserved.
|
|
local STUDIO_BOX = { w = 128, h = 52, cx = 80, cy = 60 }
|
|
|
|
-- the 4 waves of small stars: screen X positions, all spawning at y=88
|
|
-- (OAM $68; SmallStarsWave*Coords, splash.asm:160-183)
|
|
local STAR_WAVES = {
|
|
{ 40, 56, 80, 112 },
|
|
{ 48, 64, 88, 104 },
|
|
{ 44, 68, 76, 92 },
|
|
{ 52, 84, 100, 108 },
|
|
}
|
|
|
|
-- Nidorino movement lists: {dy, dx} applied every 5 frames
|
|
-- (AnimateIntroNidorino, intro.asm:143-158)
|
|
local ANIM = {
|
|
-- IntroNidorinoAnimation1..7 (intro.asm:370-437)
|
|
{ {0,0}, {-2,2}, {-1,2}, {1,2}, {2,2} }, -- 1: hop arc, +8 right
|
|
{ {0,0}, {-2,-2}, {-1,-2}, {1,-2}, {2,-2} }, -- 2: hop arc, -8 left
|
|
{ {0,0}, {-12,6}, {-8,6}, {8,6}, {12,6} }, -- 3: dodge leap, +24 right
|
|
{ {0,0}, {-8,-4}, {-4,-4}, {4,-4}, {8,-4} }, -- 4: high hop, -16 left
|
|
{ {0,0}, {-8,4}, {-4,4}, {4,4}, {8,4} }, -- 5: high hop, +16 right
|
|
{ {0,0}, {2,0}, {2,0}, {0,0} }, -- 6: crouch, +4 down
|
|
{ {-8,-16}, {-7,-14}, {-6,-12}, {-4,-10} }, -- 7: lunge, -52/-25 up-left
|
|
}
|
|
|
|
-- PlayIntroScene, in source order (intro.asm:23-141). `move` ops shift
|
|
-- 2px per 2 frames (IntroMoveMon, intro.asm:235-269): "scrollIn" moves
|
|
-- Nidorino right AND Gengar left together (the fallthrough at :247-259),
|
|
-- gengar dx<0 = MOVE_GENGAR_LEFT (SCX+2), dx>0 = MOVE_GENGAR_RIGHT.
|
|
local FIGHT_SCRIPT = {
|
|
{ move = "scrollIn", px = 80 }, -- intro.asm:40-41
|
|
{ sfx = "Intro_Hip" }, { anim = 1 }, -- :44-50
|
|
{ sfx = "Intro_Hop" }, { anim = 2 }, { wait = 10 }, -- :51-57
|
|
{ sfx = "Intro_Hip" }, { anim = 1 }, -- :60-64
|
|
{ sfx = "Intro_Hop" }, { anim = 2 }, { wait = 30 }, -- :65-71
|
|
{ pose = 2 }, { sfx = "Intro_Raise" }, -- :74-78
|
|
{ move = "gengar", dx = -8 }, { wait = 30 }, -- :79-82
|
|
{ pose = 3 }, { sfx = "Intro_Crash" }, -- :85-89
|
|
{ move = "gengar", dx = 16 }, -- :90-91
|
|
{ sfx = "Intro_Hip" }, { frame = 2 }, { anim = 3 }, -- :92-98
|
|
{ wait = 30 }, -- :99-100
|
|
{ move = "gengar", dx = -8 }, { pose = 1 }, -- :103-106
|
|
{ wait = 60 }, -- :107-108
|
|
{ sfx = "Intro_Hip" }, { frame = 1 }, { anim = 4 }, -- :111-117
|
|
{ sfx = "Intro_Hop" }, { anim = 5 }, { wait = 20 }, -- :118-124
|
|
{ frame = 2 }, { anim = 6 }, { wait = 30 }, -- :127-132
|
|
{ sfx = "Intro_Lunge" }, { frame = 3 }, { anim = 7 }, -- :135-141
|
|
{ fade = 24 }, -- GBFadeOutToWhite: 3 pals x 8 frames (home/fade.asm:26-40)
|
|
}
|
|
|
|
local function tryImage(path)
|
|
if not path then return nil end
|
|
local ok, img = pcall(love.graphics.newImage, path)
|
|
return ok and img or nil
|
|
end
|
|
|
|
function IntroMovie.new(game, onDone)
|
|
local self = setmetatable({}, IntroMovie)
|
|
self.game = game
|
|
self.onDone = onDone
|
|
self.phase = 1
|
|
self.timer = 0
|
|
self.finished = false
|
|
|
|
local intro = game.data.field and game.data.field.intro or {}
|
|
self.introCfg = intro
|
|
-- brand-level knobs (12 4.7): studio strings and the skip a total
|
|
-- conversion or dev profile sets to jump straight to the title
|
|
self.studio = intro.studio or {}
|
|
self.skipAll = intro.skip and true or false
|
|
local function img(e) return tryImage(e and e.path) end
|
|
self.copyright = tryImage("assets/generated/title/copyright.png")
|
|
-- studio mark replaces the GAME FREAK logo + splash text entirely; the
|
|
-- extracted logo stays as the fallback if the asset is missing
|
|
self.studioLogo = tryImage(self.studio.logo or "assets/logo/minilogo.png")
|
|
if self.studioLogo then
|
|
self.studioLogo:setFilter("nearest", "nearest")
|
|
local iw, ih = self.studioLogo:getDimensions()
|
|
self.studioScale = math.min(STUDIO_BOX.w / iw, STUDIO_BOX.h / ih)
|
|
self.studioX = STUDIO_BOX.cx - iw * self.studioScale / 2
|
|
self.studioY = STUDIO_BOX.cy - ih * self.studioScale / 2
|
|
end
|
|
self.logo = img(intro.gamefreakLogo)
|
|
self.gfText = img(intro.gamefreakText)
|
|
self.bigStar = img(intro.bigStar)
|
|
self.smallStar = img(intro.fallingStar)
|
|
self.smallStarBlink = img(intro.fallingStarBlink)
|
|
self.gengarFrames, self.nidoFrames = {}, {}
|
|
for i = 1, 3 do
|
|
self.gengarFrames[i] = img(intro.gengar and intro.gengar["frame" .. i])
|
|
self.nidoFrames[i] = img(intro.nidorino and intro.nidorino["frame" .. i])
|
|
end
|
|
|
|
-- fight state (PlayIntroScene entry, intro.asm:30-39): Gengar BG pose at
|
|
-- tile (13,7) = screen (104,56); Nidorino OAM base (0,80) = screen
|
|
-- (-8,72) after the OAM +8 offsets
|
|
self.gengarX, self.gengarY = 104, 56
|
|
self.nidoX, self.nidoY = -8, 72
|
|
self.gengarPose, self.nidoFrame = 1, 1
|
|
self.opIndex, self.opTimer = 1, 0
|
|
self.fade = 0
|
|
return self
|
|
end
|
|
|
|
function IntroMovie:finish()
|
|
if self.finished then return end
|
|
self.finished = true
|
|
pcall(Music.stop)
|
|
self.game.stack:pop()
|
|
if self.onDone then self.onDone() end
|
|
end
|
|
|
|
function IntroMovie:startPhase(phase)
|
|
self.phase = phase
|
|
self.timer = 0
|
|
if phase == 3 then
|
|
-- intro.asm:333-338
|
|
local data = self.game.data
|
|
local songs = data.audio and data.audio.songs
|
|
local song = self.introCfg.music or "Music_IntroBattle"
|
|
if songs and songs[song] then
|
|
pcall(Music.play, data, song, false)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- one frame of the fight script (see FIGHT_SCRIPT)
|
|
function IntroMovie:fightStep()
|
|
while true do
|
|
local op = FIGHT_SCRIPT[self.opIndex]
|
|
if not op then
|
|
self:finish()
|
|
return
|
|
end
|
|
if op.sfx then
|
|
Sound.play(self.game.data, op.sfx)
|
|
elseif op.pose then
|
|
self.gengarPose = op.pose
|
|
elseif op.frame then
|
|
self.nidoFrame = op.frame
|
|
elseif op.move then
|
|
-- 2px per 2 frames (IntroMoveMon: CheckForUserInterruption c=2)
|
|
if self.opTimer % 2 == 0 then
|
|
if op.move == "scrollIn" then
|
|
self.gengarX = self.gengarX - 2
|
|
self.nidoX = self.nidoX + 2
|
|
else
|
|
self.gengarX = self.gengarX + (op.dx > 0 and 2 or -2)
|
|
end
|
|
end
|
|
self.opTimer = self.opTimer + 1
|
|
if self.opTimer < (op.px or math.abs(op.dx)) then return end
|
|
elseif op.anim then
|
|
-- one {dy,dx} delta per 5 frames (AnimateIntroNidorino: DelayFrames 5)
|
|
if self.opTimer % 5 == 0 then
|
|
local d = ANIM[op.anim][self.opTimer / 5 + 1]
|
|
self.nidoY = self.nidoY + d[1]
|
|
self.nidoX = self.nidoX + d[2]
|
|
end
|
|
self.opTimer = self.opTimer + 1
|
|
if self.opTimer < #ANIM[op.anim] * 5 then return end
|
|
elseif op.wait then
|
|
self.opTimer = self.opTimer + 1
|
|
if self.opTimer < op.wait then return end
|
|
elseif op.fade then
|
|
self.opTimer = self.opTimer + 1
|
|
self.fade = self.opTimer / op.fade
|
|
if self.opTimer >= op.fade then self:finish() end
|
|
return
|
|
end
|
|
self.opIndex = self.opIndex + 1
|
|
self.opTimer = 0
|
|
end
|
|
end
|
|
|
|
function IntroMovie:update(dt)
|
|
if self.skipAll then
|
|
self:finish()
|
|
return
|
|
end
|
|
local input = self.game.input
|
|
if input:wasPressed("a") or input:wasPressed("b")
|
|
or input:wasPressed("start") then
|
|
self:finish()
|
|
return
|
|
end
|
|
self.timer = self.timer + 1
|
|
if self.phase == 1 then
|
|
if self.timer >= COPYRIGHT_FRAMES then self:startPhase(2) end
|
|
elseif self.phase == 2 then
|
|
if self.timer == STAR_START then
|
|
Sound.play(self.game.data, "Shooting_Star") -- splash.asm:29-30
|
|
end
|
|
if self.timer >= SPLASH_FRAMES then self:startPhase(3) end
|
|
else
|
|
self:fightStep()
|
|
end
|
|
end
|
|
|
|
-- the letterbox bars: 4 black tile rows top and bottom
|
|
-- (IntroDrawBlackBars, intro.asm:343-357); drawn AFTER the sprites since
|
|
-- both Nidorino and the small stars carry OAM_PRIO (intro.asm:195,
|
|
-- splash.asm:149) so the bars cover them.
|
|
local function drawBars()
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
love.graphics.rectangle("fill", 0, 0, 160, 32)
|
|
love.graphics.rectangle("fill", 0, 112, 160, 32)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
function IntroMovie:drawSplash()
|
|
local t = self.timer
|
|
if t >= STAR_START then
|
|
-- logo + GAME FREAK letters appear with the star OAM
|
|
-- (LoadShootingStarGraphics, splash.asm:18-25); the logo palette
|
|
-- rotates during the 3-flash loop (splash.asm:72-82)
|
|
local flashing = t >= FLASH_START and t < FLASH_START + FLASH_FRAMES
|
|
local dim = flashing and math.floor((t - FLASH_START) / 5) % 2 == 0
|
|
love.graphics.setColor(1, 1, 1, dim and 0.35 or 1)
|
|
if self.studioLogo then
|
|
love.graphics.draw(self.studioLogo, self.studioX, self.studioY,
|
|
0, self.studioScale, self.studioScale)
|
|
elseif self.logo then
|
|
love.graphics.draw(self.logo, LOGO_X, LOGO_Y)
|
|
end
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
if t >= STAR_START and t < FLASH_START then
|
|
-- big star: from OAM (160,0) moving +4Y/-4X per frame
|
|
-- (GameFreakShootingStarOAMData + .bigStarLoop, splash.asm:32-60)
|
|
local n = t - STAR_START + 1
|
|
local sx, sy = 152 - 4 * n, -16 + 4 * n
|
|
if self.bigStar then
|
|
love.graphics.draw(self.bigStar, sx, sy)
|
|
else
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
love.graphics.rectangle("fill", sx + 6, sy + 6, 4, 4)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
end
|
|
if t >= WAVES_START then
|
|
-- small stars: wave w spawns at y=88 every 24 frames, everything falls
|
|
-- +1px per 3-frame substep until the wave loop ends; the lower star in
|
|
-- the tile blinks every substep (splash.asm:97-146, 186-209)
|
|
local substep = math.floor((math.min(t, WAVES_END) - WAVES_START) / 3)
|
|
local blink = substep % 2 == 0
|
|
for w, xs in ipairs(STAR_WAVES) do
|
|
local spawn = (w - 1) * 8 -- in substeps
|
|
if substep >= spawn then
|
|
local y = 88 + (substep - spawn)
|
|
if y < 144 then
|
|
local img = blink and self.smallStar
|
|
or (self.smallStarBlink or self.smallStar)
|
|
for _, x in ipairs(xs) do
|
|
if img then
|
|
love.graphics.draw(img, x, y)
|
|
else
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
love.graphics.rectangle("fill", x + 3, y + 1, 2, 2)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
drawBars()
|
|
end
|
|
|
|
function IntroMovie:drawFight()
|
|
-- Gengar: a 56x56 BG-tile pose recomposed from gengar_N.tilemap, moved
|
|
-- by scrolling SCX (intro.asm:32-33, 235-269)
|
|
-- Nidorino: 6x6 OAM sprite, one of the three red_nidorino poses
|
|
local nido = self.nidoFrames[self.nidoFrame]
|
|
if nido then
|
|
love.graphics.draw(nido, self.nidoX, self.nidoY)
|
|
end
|
|
local gengar = self.gengarFrames[self.gengarPose]
|
|
if gengar then
|
|
love.graphics.draw(gengar, self.gengarX, self.gengarY)
|
|
end
|
|
|
|
if not gengar and not nido then
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
Font.draw(Strings("GENGAR VS NIDORINO"), (160 - 18 * 8) / 2, 64)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
drawBars()
|
|
if self.fade > 0 then
|
|
love.graphics.setColor(1, 1, 1, math.min(1, self.fade))
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
end
|
|
|
|
function IntroMovie:draw()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
if self.phase == 1 then
|
|
-- custom boot card (replaces the Nintendo / GAME FREAK copyright
|
|
-- card; no (c) glyph in the charmap, keep it ASCII-safe)
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
local credit = self.studio.credit or Strings("bois club")
|
|
Font.draw("2026", (160 - 4 * 8) / 2, 48)
|
|
Font.draw(credit, (160 - #credit * 8) / 2, 64)
|
|
Font.draw("bryanthaboi", (160 - 11 * 8) / 2, 80)
|
|
elseif self.phase == 2 then
|
|
self:drawSplash()
|
|
else
|
|
self:drawFight()
|
|
end
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
return IntroMovie
|