mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
228 lines
8.5 KiB
Lua
228 lines
8.5 KiB
Lua
-- Screen fade used for warps: fade out, run a callback (map switch), fade in.
|
|
-- Pushed on the state stack above the overworld.
|
|
|
|
local Timing = require("src.core.Timing")
|
|
|
|
local Transition = {}
|
|
Transition.__index = Transition
|
|
|
|
-- PlayMapChangeSound tail-calls GBFadeOutToBlack on every map change
|
|
-- (home/overworld.asm:703), which is four palette steps of DelayFrames 8 --
|
|
-- 32 frames. There is no matching fade in: the new map is built while the
|
|
-- palettes are still blacked out and OverworldLoop's LoadGBPal
|
|
-- (home/overworld.asm:45) restores them in a single write, so the map pops
|
|
-- in. The old symmetric 12/12 fade was both too fast and a shape the
|
|
-- hardware never had.
|
|
local FRAMES = Timing.WARP_FADE_OUT
|
|
local FRAMES_IN = Timing.WARP_FADE_IN
|
|
local FLASH_FRAMES = 7
|
|
|
|
-- Every fade in home/fade.asm holds each palette write with
|
|
-- `ld c, 8 / call DelayFrames`, so eight frames is the step of all of them.
|
|
local FADE_STEP_FRAMES = 8
|
|
|
|
-- Veil alpha `t` frames into a `len`-frame fade out. GBFadeOutToBlack
|
|
-- (home/fade.asm:43-46) walks FadePal4 -> FadePal3 -> FadePal2 -> FadePal1
|
|
-- with b = 4, and FadePal4 IS the normal palette while FadePal1 is solid
|
|
-- black (:64-67) -- so the screen sits untouched through the first eight
|
|
-- frames and then drops a whole shade per step, reaching black for the last
|
|
-- hold. The port tweened the veil instead, which reads as a dissolve rather
|
|
-- than a Game Boy fade (#607). A record retimed shorter than two steps
|
|
-- (mods) keeps the old ramp so it still reaches black.
|
|
local function fadeAlpha(t, len)
|
|
if not len or len <= 0 then return 1 end
|
|
local steps = math.floor(len / FADE_STEP_FRAMES)
|
|
if steps < 2 then return t / len end
|
|
local step = math.min(steps - 1, math.floor(t / (len / steps)))
|
|
return step / (steps - 1)
|
|
end
|
|
|
|
-- The two fades as transitions records, so a mod retimes a warp fade the
|
|
-- same way it retimes a battle wipe. BattleTransition.registerInto pulls
|
|
-- these in with its eight wipes -- one registrant owns the registry.
|
|
Transition.STYLES = {
|
|
warp_fade = { kind = "fade", frames = FRAMES, framesIn = FRAMES_IN },
|
|
white_flash = { kind = "fade", frames = FLASH_FRAMES },
|
|
}
|
|
|
|
function Transition.registerInto(registry, _, owner)
|
|
for id, record in pairs(Transition.STYLES) do
|
|
registry:register(id, record, owner)
|
|
end
|
|
end
|
|
|
|
-- the merged record, falling back to the built-in when no data is around
|
|
-- (headless callers, and any state built before Data:load)
|
|
local function styleOf(game, id)
|
|
local data = game and game.data
|
|
local record = data and data.transitions and data.transitions[id]
|
|
return record or Transition.STYLES[id]
|
|
end
|
|
|
|
-- `warp` marks the map-change fade: PlayMapChangeSound's GBFadeOutToBlack
|
|
-- has no matching fade in (LoadGBPal restores the palettes in one write), so
|
|
-- warps land with framesIn 0. Script fades that bracket a HideObject
|
|
-- (ViridianGym.asm .afterBeat, RocketHideoutB4F BeatGiovanniScript) call
|
|
-- GBFadeOutToBlack -> GBFadeInFromBlack instead, so the default keeps the
|
|
-- symmetric 32-frame fade back in (home/fade.asm:21, b = 4).
|
|
function Transition.new(game, onMidpoint, onDone, warp)
|
|
local self = setmetatable({}, Transition)
|
|
self.game = game
|
|
self.onMidpoint = onMidpoint
|
|
self.onDone = onDone
|
|
self.t = 0
|
|
self.phase = "out"
|
|
local style = styleOf(game, "warp_fade")
|
|
self.frames = style.frames or FRAMES
|
|
if warp then
|
|
-- a style may still ask for a fade in (mods, and the record is
|
|
-- data-driven); the built-in warp is 0, matching hardware
|
|
self.framesIn = style.framesIn or FRAMES_IN
|
|
else
|
|
self.framesIn = Timing.FADE_IN_FROM_BLACK
|
|
end
|
|
return self
|
|
end
|
|
|
|
function Transition:finish()
|
|
self.game.stack:pop()
|
|
if self.onDone then self.onDone() end
|
|
end
|
|
|
|
function Transition:update(dt)
|
|
self.t = self.t + 1
|
|
local len = (self.phase == "out") and self.frames or self.framesIn
|
|
if self.t >= len then
|
|
self.t = 0
|
|
if self.phase == "out" then
|
|
self.phase = "in"
|
|
if self.onMidpoint then self.onMidpoint() end
|
|
-- LoadGBPal restores the palettes in one write, so with no fade in the
|
|
-- map is simply there on the next frame
|
|
if (self.framesIn or 0) <= 0 then self:finish() end
|
|
else
|
|
self:finish()
|
|
end
|
|
end
|
|
end
|
|
|
|
-- The veil alpha for this frame, exposed the way BattleReturn:alpha is so
|
|
-- the staircase can be asserted without stubbing love.graphics.
|
|
function Transition:alpha()
|
|
local len = (self.phase == "out") and self.frames or self.framesIn
|
|
local a = fadeAlpha(self.t, len)
|
|
if self.phase == "in" then a = 1 - a end
|
|
return a
|
|
end
|
|
|
|
function Transition:draw()
|
|
local alpha = self:alpha()
|
|
-- Survey zoom draws the overworld into a window-filling world canvas
|
|
-- while the UI pass stays the classic 160x144 letterbox. A rect on the
|
|
-- UI canvas only darkens that center box (issue #121); when the world
|
|
-- pass ran this frame, hand the alpha to Renderer:endFrame so it paints
|
|
-- a screen-space overlay over the full composite instead.
|
|
local r = self.game and self.game.renderer
|
|
if r and r.worldActive then
|
|
r.worldFadeAlpha = alpha
|
|
return
|
|
end
|
|
love.graphics.setColor(0, 0, 0, alpha)
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
-- GBPalWhiteOutWithDelay3 (home/palettes.asm): the field moves that close
|
|
-- the party menu (start_sub_menus.asm .goBackToMap paths) white out the
|
|
-- palettes, and they stay white through Delay3 + the screen-tile restore
|
|
-- until CloseTextDisplay's LoadGBPal -- a ~7-frame solid-white blink.
|
|
-- Instant white, hold, instant restore (a palette write, not a fade).
|
|
local WhiteFlash = {}
|
|
WhiteFlash.__index = WhiteFlash
|
|
WhiteFlash.isOpaque = true
|
|
|
|
function Transition.whiteFlash(game, frames, onDone)
|
|
return setmetatable({ game = game,
|
|
frames = frames or styleOf(game, "white_flash").frames
|
|
or FLASH_FRAMES,
|
|
onDone = onDone, t = 0 }, WhiteFlash)
|
|
end
|
|
|
|
function WhiteFlash:update(dt)
|
|
self.t = self.t + 1
|
|
if self.t >= self.frames then
|
|
self.game.stack:pop()
|
|
if self.onDone then self.onDone() end
|
|
end
|
|
end
|
|
|
|
function WhiteFlash:draw()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
end
|
|
|
|
-- Coming back to the overworld after a battle.
|
|
--
|
|
-- The battle screen is torn down with the palettes still whited out, the
|
|
-- caller spends `ld c, 10 / call DelayFrames` (home/overworld.asm:351-352),
|
|
-- and then EnterMap sees BIT_BATTLE_OVER_OR_BLACKOUT set and runs
|
|
-- MapEntryAfterBattle (:22, :749-753), which is GBFadeInFromWhite -- three
|
|
-- palette steps of DelayFrames 8, so 24 frames. The port had none of it and
|
|
-- simply cut from the battle to the map.
|
|
--
|
|
-- A dark map takes the other branch: wMapPalOffset is nonzero there, so
|
|
-- MapEntryAfterBattle does a plain LoadGBPal and the map is just there. Pass
|
|
-- opts.instant for that case.
|
|
local BattleReturn = {}
|
|
BattleReturn.__index = BattleReturn
|
|
BattleReturn.isOpaque = false -- the overworld draws underneath
|
|
|
|
function Transition.battleReturn(game, onDone, opts)
|
|
opts = opts or {}
|
|
return setmetatable({
|
|
game = game, onDone = onDone, t = 0,
|
|
hold = opts.hold or Timing.POST_BATTLE_RETURN,
|
|
frames = opts.instant and 0 or (opts.frames or Timing.FADE_IN_FROM_WHITE),
|
|
}, BattleReturn)
|
|
end
|
|
|
|
function BattleReturn:update(dt)
|
|
self.t = self.t + 1
|
|
if self.t >= self.hold + self.frames then
|
|
self.game.stack:pop()
|
|
if self.onDone then self.onDone() end
|
|
end
|
|
end
|
|
|
|
-- GBFadeInFromWhite is a palette staircase, not a smooth ramp: GBFadeIncCommon
|
|
-- writes one palette then holds it with `ld c, 8 / call DelayFrames`
|
|
-- (home/fade.asm:30-41), three times over. Stepping the veil the same way
|
|
-- keeps the fade reading like a Game Boy palette fade rather than a tween.
|
|
-- The hold is the same eight frames the warp fade steps on, so both share
|
|
-- FADE_STEP_FRAMES at the top of the file.
|
|
|
|
function BattleReturn:alpha()
|
|
if self.t < self.hold then return 1 end
|
|
if self.frames <= 0 then return 0 end
|
|
local steps = math.max(1, math.floor(self.frames / FADE_STEP_FRAMES))
|
|
local step = math.floor((self.t - self.hold) / FADE_STEP_FRAMES)
|
|
if step >= steps then return 0 end
|
|
return (steps - step - 1) / steps
|
|
end
|
|
|
|
function BattleReturn:draw()
|
|
local a = self:alpha()
|
|
-- the fade is a palette write, so it covers the whole surface; the
|
|
-- renderer paints it in screen space (see Renderer.screenVeil)
|
|
local r = self.game and self.game.renderer
|
|
if r then
|
|
r.screenVeil = { 1, a }
|
|
return
|
|
end
|
|
love.graphics.setColor(1, 1, 1, a)
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
return Transition
|