mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 16:31:05 +02:00
aedc63c40d
Ports from a downstream fork, hand-surgered hunk-by-hunk to exclude the fork's randomizer/pokescript work and to skip a FixedStep jitter-tolerance attempt that never fixed the stutter it targeted. - src/core/Timing.lua: hardware-accurate frame-delay catalog ported from pret/pokered, feeding BattleState:waitNext, EffectRegistry's miss/crit beats, TextBox/ChoiceBox scroll and prompt holds, and the battle silhouette slide/shake/blink/faint timings. - Seamless battle transitions: Renderer:drawBattleWipe replaces the old 160x144-only cascade with one wipe drawn over the whole surface at any zoom or window size; BattleTransition's per-style frame lengths are corrected against pokered-c's derivation; Transition.battleReturn adds the post-battle GBFadeInFromWhite the port never had. - BATTLE SIZE / BATTLE BG options (BattleState:wantsFillScale/bgMode, Game.fillScaleInStack/worldBgBattleDim): battle surface can fill the window instead of the fixed integer letterbox, and the area around it can show white/black/the dimmed overworld instead of only white. - src/core/FaithfulRes.lua: locks the window to an exact 160x144 multiple. - Zoom-aware UI anchoring: Renderer:uiScale steps the UI down with survey zoom (gated to worldActive so the title/intro never shrink); Renderer:setUIAnchor lets TextBox, ChoiceBox, and an opted-in Menu (the START menu) pin themselves to a screen edge instead of the zoomed-out letterbox.
451 lines
18 KiB
Lua
451 lines
18 KiB
Lua
-- The into-battle transition (engine/battle/battle_transitions.asm):
|
|
-- one of the original's eight wipes selected by three bits, trainer
|
|
-- battle (bit 0), enemy at least 3 levels above the lead (bit 1),
|
|
-- dungeon map (bit 2):
|
|
-- %000 DoubleCircle %001 Spiral(in) %010 Circle %011 Spiral(out)
|
|
-- %100 HStripes %101 Shrink %110 VStripes %111 Split
|
|
-- Only the two circle wipes flash the screen first (only they call
|
|
-- BattleTransition_FlashScreen); the spiral runs inward unless the
|
|
-- enemy is stronger (wBattleTransitionSpiralDirection).
|
|
-- Pushed above the overworld; pops itself and runs onDone at the end.
|
|
|
|
local Runtime = require("src.mods.Runtime")
|
|
|
|
local BattleTransition = {}
|
|
BattleTransition.__index = BattleTransition
|
|
BattleTransition.isOpaque = false -- draws over the frozen overworld
|
|
|
|
-- BattleTransition_FlashScreenPalettes: fade to black and back, then to
|
|
-- white and back; each palette held 2 frames, whole sequence played 3
|
|
-- times. Positive = black overlay strength, negative = white.
|
|
local FLASH_STEPS = { 1 / 3, 2 / 3, 1, 2 / 3, 1 / 3, 0,
|
|
-1 / 3, -2 / 3, -1, -2 / 3, -1 / 3, 0 }
|
|
local FLASH_HOLD = 2 -- frames per palette step
|
|
local FLASH_CYCLES = 3
|
|
|
|
-- The screen stays black after the wipe lands. Shrink and Split ask for it
|
|
-- outright (BattleTransition_BlackScreen, then `ld c, 10 / jp DelayFrames`:
|
|
-- battle_transitions.asm:390-392 and 422-424); the other six get the same gap
|
|
-- for free, because BattleTransition_BlackScreen has already set rBGP/rOBP0/
|
|
-- rOBP1 to $ff (:168-174) while DoBattleTransitionAndInitBattleVariables
|
|
-- reloads the HUD tile patterns and clears the screen (core.asm:6152-6185),
|
|
-- InitBattleCommon decompresses the front pic (core.asm:6694-6730), and
|
|
-- SlidePlayerAndEnemySilhouettesOnScreen rebuilds the whole tilemap between
|
|
-- DisableLCD and EnableLCD (core.asm:9-49) before anything moves. This port
|
|
-- has no load to hide behind, so the hold has to be explicit (#315). This is
|
|
-- a frame budget for that work, not a number pokered states.
|
|
--
|
|
-- 60 comes from pokered-c (BTRANS_BLACK_HOLD_FRAMES), which itemized the
|
|
-- derivable floor at ~13 frames -- LoadHpBarAndStatusTilePatterns 4,
|
|
-- LoadHudTilePatterns 2, ClearScreen's Delay3 3, the DisableLCD LY wait 1,
|
|
-- Delay3 after EnableLCD 3 -- and then noted that the two sprite
|
|
-- decompressors (UncompressSpriteFromDE for the 7x7 front pic,
|
|
-- LoadPlayerBackPic's uncompress + ScaleSpriteByTwo) are bit-level RLE/delta
|
|
-- decoders whose cost cannot be cycle-counted from the asm at all. So the
|
|
-- derivation bottoms out around 25-30 with an unbounded remainder, and 60
|
|
-- was set by ear against the real ROM ("a solid second") and confirmed as
|
|
-- ~95% right rather than frame-matched. The credible range is 30-60; do NOT
|
|
-- "correct" this down toward the floor on the strength of the derivation,
|
|
-- because the omitted decompressors are exactly the unbounded part. It
|
|
-- wants a frame-by-frame capture against hardware to pin exactly.
|
|
local BLACK_HOLD = 60
|
|
|
|
local TILE = 8
|
|
local COLS, ROWS = 160 / TILE, 144 / TILE -- 20 x 18 tiles
|
|
|
|
-- outward spiral (%011): BattleTransition_OutwardSpiral_ walks from
|
|
-- (10,10) counterclockwise (right/up/left/down), turning whenever the
|
|
-- tile on its outer side is unfilled; 120 frames x 3 fills = 360 fills
|
|
-- on linear tilemap memory. At the screen edges the walk reads (and
|
|
-- fills) adjacent WRAM, so the left column and part of the top row stay
|
|
-- unfilled until the final blackout, reproduced here by tracking those
|
|
-- cells but not drawing them.
|
|
local function outwardSpiralOrder()
|
|
local order, filled = {}, {}
|
|
local addr = 10 * COLS + 10 -- hlcoord 10,10
|
|
local dir = 3 -- 0 up / 1 left / 2 down / 3 right
|
|
local checkOff = { [0] = -1, [1] = COLS, [2] = 1, [3] = -COLS }
|
|
local moveOff = { [0] = -COLS, [1] = -1, [2] = COLS, [3] = 1 }
|
|
for _ = 1, COLS * ROWS do
|
|
local checked = addr + checkOff[dir]
|
|
if not filled[checked] then
|
|
addr = checked
|
|
dir = (dir + 1) % 4
|
|
else
|
|
addr = addr + moveOff[dir]
|
|
end
|
|
if not filled[addr] then
|
|
filled[addr] = true
|
|
if addr >= 0 and addr < COLS * ROWS then
|
|
order[#order + 1] = { addr % COLS, math.floor(addr / COLS) }
|
|
end
|
|
end
|
|
end
|
|
return order
|
|
end
|
|
|
|
-- inward spiral (%001): BattleTransition_InwardSpiral starts at (0,0)
|
|
-- and walks the perimeter counterclockwise, down the left edge, right
|
|
-- along the bottom, up the right edge, left along the top, spiraling
|
|
-- in; 359 fills, the center tile is left for the final blackout
|
|
local function inwardSpiralOrder()
|
|
local order = {}
|
|
local x, y = 0, 0
|
|
local function run(dx, dy, n)
|
|
for _ = 1, n do
|
|
order[#order + 1] = { x, y }
|
|
x, y = x + dx, y + dy
|
|
end
|
|
end
|
|
run(0, 1, 17) -- SCREEN_HEIGHT - 1
|
|
local c = 18
|
|
while true do
|
|
c = c + 1
|
|
run(1, 0, c) -- right
|
|
c = c - 2
|
|
run(0, -1, c) -- up
|
|
c = c + 1
|
|
run(-1, 0, c) -- left
|
|
c = c - 2
|
|
if c == 0 then break end
|
|
run(0, 1, c) -- down
|
|
end
|
|
return order
|
|
end
|
|
|
|
-- sweep order (the Circle wipes): tiles sorted by angle from the center.
|
|
-- pokered sweeps counterclockwise starting at the right edge middle
|
|
-- (BattleTransition_HalfCircle1 runs (18,6) up over the top to (1,6);
|
|
-- HalfCircle2 continues (1,11) down under the bottom back to (18,11)).
|
|
-- arms = 1 (Circle, halves in sequence) or 2 (DoubleCircle, both halves
|
|
-- at once, so opposite arms)
|
|
local function sweepOrder(arms, cols, rows)
|
|
cols, rows = cols or COLS, rows or ROWS
|
|
local cx, cy = cols / 2, rows / 2
|
|
local tiles = {}
|
|
for y = 0, rows - 1 do
|
|
for x = 0, cols - 1 do
|
|
local a = math.atan2(cy - (y + 0.5), x + 0.5 - cx)
|
|
if a < 0 then a = a + 2 * math.pi end
|
|
if arms == 2 then a = a % math.pi end
|
|
tiles[#tiles + 1] = { x, y, a }
|
|
end
|
|
end
|
|
table.sort(tiles, function(p, q) return p[3] < q[3] end)
|
|
return tiles
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------
|
|
-- Arbitrary-grid wipes, for the surface OUTSIDE the classic letterbox
|
|
-- ---------------------------------------------------------------------
|
|
--
|
|
-- The builders above reproduce the ROM's exact walks on its 20x18 tilemap,
|
|
-- overrun and all, and stay the authority inside the 160x144 box. A zoomed
|
|
-- or windowed surface has more grid than the Game Boy ever had, and no
|
|
-- hardware behaviour to be faithful to out there -- but filling it with a
|
|
-- generic square cascade made a spiral read as "a spiral in a box, with
|
|
-- something else happening around it". These generalise the same shapes to
|
|
-- whatever grid the window works out to so the whole surface wipes as one
|
|
-- figure.
|
|
|
|
-- perimeter inward, counterclockwise, starting down the left edge -- the
|
|
-- direction BattleTransition_InwardSpiral walks
|
|
local function spiralInGrid(cols, rows)
|
|
local order = {}
|
|
local x0, y0, x1, y1 = 0, 0, cols - 1, rows - 1
|
|
while x0 <= x1 and y0 <= y1 do
|
|
for y = y0, y1 do order[#order + 1] = { x0, y } end
|
|
x0 = x0 + 1
|
|
if x0 > x1 then break end
|
|
for x = x0, x1 do order[#order + 1] = { x, y1 } end
|
|
y1 = y1 - 1
|
|
if y0 > y1 then break end
|
|
for y = y1, y0, -1 do order[#order + 1] = { x1, y } end
|
|
x1 = x1 - 1
|
|
if x0 > x1 then break end
|
|
for x = x1, x0, -1 do order[#order + 1] = { x, y0 } end
|
|
y0 = y0 + 1
|
|
end
|
|
return order
|
|
end
|
|
|
|
-- the outward spiral is the same walk read from the middle out
|
|
local function spiralOutGrid(cols, rows)
|
|
local inward = spiralInGrid(cols, rows)
|
|
local order = {}
|
|
for i = #inward, 1, -1 do order[#order + 1] = inward[i] end
|
|
return order
|
|
end
|
|
|
|
local GRID_BUILDERS = {
|
|
spiralin = spiralInGrid,
|
|
spiralout = spiralOutGrid,
|
|
circle = function(c, r) return sweepOrder(1, c, r) end,
|
|
doublecircle = function(c, r) return sweepOrder(2, c, r) end,
|
|
}
|
|
|
|
-- Tile order for `style` on an arbitrary cols x rows grid, or nil for the
|
|
-- styles whose shape is plain geometry (stripes / shrink / split) and which
|
|
-- the caller extends with rectangles instead. Cached per style+size: the
|
|
-- window grid only changes on a resize or a zoom step.
|
|
local orderFor -- defined below; the authentic 20x18 builders
|
|
|
|
local gridCache = {}
|
|
function BattleTransition.gridOrder(style, cols, rows)
|
|
local build = GRID_BUILDERS[style]
|
|
if not build or cols < 1 or rows < 1 then return nil end
|
|
-- At exactly the Game Boy's grid the ROM's own walk is the answer, overrun
|
|
-- and all -- so an unzoomed window is the classic wipe, not a lookalike.
|
|
if cols == COLS and rows == ROWS then return orderFor(style, nil) end
|
|
local key = style .. ":" .. cols .. "x" .. rows
|
|
local hit = gridCache[key]
|
|
if hit == nil then
|
|
hit = build(cols, rows) or false
|
|
gridCache[key] = hit
|
|
end
|
|
return hit or nil
|
|
end
|
|
|
|
-- Wipe lengths, taken from pokered-c's battle_transition.c frame budget --
|
|
-- derived from battle_transitions.asm and then checked on a live
|
|
-- side-by-side against the ROM. Each wipe is `steps x frames-per-step`:
|
|
--
|
|
-- DoubleCircle 10 x 3 = 30 SpiralOut 360 fills / 3 per frame = 120
|
|
-- Circle 20 x 3 = 60 HStripes 20 x 3 = 60
|
|
-- Shrink 9 x 6 = 54 VStripes 18 x 3 = 54
|
|
-- Split 9 x 6 = 54 (asm:386-392 and :418-424)
|
|
--
|
|
-- The port used a flat 40/24 for all eight, which ran every wipe between
|
|
-- 1.5x and 3x too fast -- the single biggest reason a battle used to open
|
|
-- so much more abruptly here than on hardware.
|
|
--
|
|
-- The inward spiral is the one that bites. It writes one tile per
|
|
-- iteration and calls BattleTransition_TransferDelay3 every seventh tile
|
|
-- (wInwardSpiralUpdateScreenCounter counts 7 down to 0), and that helper is
|
|
-- `ld a,1 / ldh [hAutoBGTransferEnabled] / call Delay3 / xor a / ldh [...]`
|
|
-- (battle_transitions.asm:619) -- THREE frames, not a one-frame transfer.
|
|
-- Reading it as one frame runs the whole spiral 3x too fast; pokered-c
|
|
-- caught that against the real ROM. Deriving the length from the path we
|
|
-- actually walk keeps the cadence right if the order ever changes.
|
|
local SPIRAL_IN_TILES_PER_STEP = 7
|
|
local SPIRAL_IN_STEP_FRAMES = 3 -- TransferDelay3
|
|
local SPIRAL_IN_FRAMES = math.ceil(#inwardSpiralOrder()
|
|
/ SPIRAL_IN_TILES_PER_STEP)
|
|
* SPIRAL_IN_STEP_FRAMES
|
|
|
|
-- The eight wipes as records: frames is the wipe length, flash marks the
|
|
-- two circle wipes that call BattleTransition_FlashScreen first. new()
|
|
-- reads them, and the transitions registry serves the same table.
|
|
BattleTransition.STYLES = {
|
|
doublecircle = { kind = "wipe", frames = 30, flash = true },
|
|
spiralin = { kind = "wipe", frames = SPIRAL_IN_FRAMES },
|
|
circle = { kind = "wipe", frames = 60, flash = true },
|
|
spiralout = { kind = "wipe", frames = 120 },
|
|
hstripes = { kind = "wipe", frames = 60 },
|
|
shrink = { kind = "wipe", frames = 54 },
|
|
vstripes = { kind = "wipe", frames = 54 },
|
|
split = { kind = "wipe", frames = 54 },
|
|
}
|
|
|
|
-- the eight wipes plus Transition's two warp fades: one registrant owns
|
|
-- the whole transitions namespace, so Builtins wires it once
|
|
function BattleTransition.registerInto(registry, data, owner)
|
|
for id, record in pairs(BattleTransition.STYLES) do
|
|
registry:register(id, record, owner)
|
|
end
|
|
require("src.render.Transition").registerInto(registry, data, owner)
|
|
end
|
|
|
|
-- the merged record; the built-in table is the fallback for headless
|
|
-- callers and for any state built before Data:load
|
|
local function styleDef(game, style)
|
|
local data = game and game.data
|
|
local record = data and data.transitions and data.transitions[style]
|
|
return record or BattleTransition.STYLES[style]
|
|
end
|
|
|
|
local ORDERS = {} -- cached per style
|
|
|
|
local BUILTIN_ORDERS = {
|
|
spiralout = outwardSpiralOrder,
|
|
spiralin = inwardSpiralOrder,
|
|
circle = function() return sweepOrder(1) end,
|
|
doublecircle = function() return sweepOrder(2) end,
|
|
}
|
|
|
|
-- A registered style may bring its own tile order (a list of {x, y}, or a
|
|
-- function returning one); the four built-in orders are the defaults for
|
|
-- the styles that have always had them.
|
|
function orderFor(style, def)
|
|
if ORDERS[style] == nil then
|
|
local order = def and def.order
|
|
if type(order) == "function" then
|
|
local ok, built = pcall(order)
|
|
order = ok and built or nil
|
|
end
|
|
if type(order) ~= "table" then
|
|
local build = BUILTIN_ORDERS[style]
|
|
order = build and build() or false
|
|
end
|
|
ORDERS[style] = order or false
|
|
end
|
|
return ORDERS[style] or nil
|
|
end
|
|
|
|
-- the vanilla 3-bit select (battle_transitions.asm), and the default of
|
|
-- the transition.style hook a mod wraps to choose its own wipe
|
|
local BIT_STYLES = { [0] = "doublecircle", "spiralin", "circle", "spiralout",
|
|
"hstripes", "shrink", "vstripes", "split" }
|
|
|
|
local function vanillaStyle(ctx)
|
|
return BIT_STYLES[(ctx.trainer and 1 or 0) + (ctx.stronger and 2 or 0)
|
|
+ (ctx.dungeon and 4 or 0)]
|
|
end
|
|
|
|
-- opts: trainer (bool), stronger (bool), dungeon (bool)
|
|
function BattleTransition.new(game, onDone, opts)
|
|
local self = setmetatable({}, BattleTransition)
|
|
self.game = game
|
|
self.onDone = onDone
|
|
self.t = 0
|
|
opts = opts or {}
|
|
local ctx = { trainer = opts.trainer, stronger = opts.stronger,
|
|
dungeon = opts.dungeon, game = game }
|
|
local style = Runtime.call("transition.style", vanillaStyle, ctx)
|
|
local def = styleDef(game, style)
|
|
-- a hook that names an unregistered style falls back to the vanilla bits
|
|
if not def then
|
|
style = vanillaStyle(ctx)
|
|
def = styleDef(game, style)
|
|
end
|
|
self.style = style
|
|
self.def = def
|
|
-- only the circle wipes flash first (battle_transitions.asm:585,628)
|
|
self.phase = def.flash and "flash" or "wipe"
|
|
self.wipeLen = def.frames
|
|
return self
|
|
end
|
|
|
|
function BattleTransition:update(dt)
|
|
self.t = self.t + 1
|
|
if self.phase == "flash" then
|
|
if self.t >= FLASH_CYCLES * #FLASH_STEPS * FLASH_HOLD then
|
|
self.phase = "wipe"
|
|
self.t = 0
|
|
end
|
|
else
|
|
if self.t >= self.wipeLen + BLACK_HOLD then
|
|
self.game.stack:pop()
|
|
if self.onDone then self.onDone() end
|
|
end
|
|
end
|
|
end
|
|
|
|
function BattleTransition:draw()
|
|
if self.phase == "flash" then
|
|
local step = math.floor(self.t / FLASH_HOLD) % #FLASH_STEPS + 1
|
|
local v = FLASH_STEPS[step]
|
|
if v ~= 0 then
|
|
local shade = v > 0 and 0 or 1
|
|
-- The flash is a palette write (rBGP), so on hardware it tints every
|
|
-- pixel the LCD shows. Hand it to the renderer as a screen-space veil
|
|
-- so it covers the whole surface at any zoom; only the headless and
|
|
-- no-renderer paths fall back to filling the 160x144 box.
|
|
local r = self.game and self.game.renderer
|
|
if r then
|
|
r.screenVeil = { shade, math.abs(v) }
|
|
return
|
|
end
|
|
love.graphics.setColor(shade, shade, shade, math.abs(v))
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
return
|
|
end
|
|
|
|
local prog = math.min(1, self.t / self.wipeLen)
|
|
local style = self.style
|
|
|
|
-- a registered style may draw itself; the eight built-ins do not. A custom
|
|
-- draw owns the 160x144 UI canvas as it always has.
|
|
if self.def and self.def.draw then
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
self.def.draw(self, prog)
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
return
|
|
end
|
|
|
|
-- With a renderer the wipe is drawn ONCE over the whole surface in screen
|
|
-- space (Renderer:drawBattleWipe), on a tile grid anchored to the letterbox
|
|
-- and extended outward at the same tile size. That makes it a single
|
|
-- continuous figure: the spiral starts at the outermost edge of the window
|
|
-- and works inward, instead of one spiral inside the letterbox running
|
|
-- alongside a second one outside it. Nothing is stretched -- the pattern is
|
|
-- continued with more tiles, not scaled-up pixels -- so at 1x the grid works
|
|
-- out to exactly 20x18 and this is the classic wipe unchanged.
|
|
local renderer = self.game and self.game.renderer
|
|
if renderer then
|
|
renderer.battleWipe = { style = style, prog = prog }
|
|
return
|
|
end
|
|
|
|
-- headless / no renderer: the classic 160x144 path
|
|
love.graphics.setColor(0, 0, 0, 1)
|
|
|
|
local order = orderFor(style, self.def)
|
|
if order then
|
|
-- tile-order wipes: spiral / circle sweeps
|
|
local n = math.floor(#order * prog)
|
|
for i = 1, n do
|
|
local c = order[i]
|
|
love.graphics.rectangle("fill", c[1] * TILE, c[2] * TILE, TILE, TILE)
|
|
end
|
|
elseif style == "hstripes" then
|
|
-- interlaced rows wipe from alternating sides
|
|
local w = math.floor(160 * prog)
|
|
for row = 0, ROWS - 1 do
|
|
local y = row * TILE
|
|
if row % 2 == 0 then
|
|
love.graphics.rectangle("fill", 0, y, w, TILE)
|
|
else
|
|
love.graphics.rectangle("fill", 160 - w, y, w, TILE)
|
|
end
|
|
end
|
|
elseif style == "vstripes" then
|
|
-- interlaced columns wipe from alternating ends
|
|
local h = math.floor(144 * prog)
|
|
for col = 0, COLS - 1 do
|
|
local x = col * TILE
|
|
if col % 2 == 0 then
|
|
love.graphics.rectangle("fill", x, 0, TILE, h)
|
|
else
|
|
love.graphics.rectangle("fill", x, 144 - h, TILE, h)
|
|
end
|
|
end
|
|
elseif style == "shrink" then
|
|
-- the image squashes toward the middle: the asm shifts rows and
|
|
-- columns inward in the same loop, so bars close from all four
|
|
-- edges at once
|
|
local h = math.floor(72 * prog)
|
|
local w = math.floor(80 * prog)
|
|
love.graphics.rectangle("fill", 0, 0, 160, h)
|
|
love.graphics.rectangle("fill", 0, 144 - h, 160, h)
|
|
love.graphics.rectangle("fill", 0, 0, w, 144)
|
|
love.graphics.rectangle("fill", 160 - w, 0, w, 144)
|
|
else -- split: the quarters tear apart from the middle; the asm shifts
|
|
-- rows and columns outward each loop, so a black cross grows from
|
|
-- the center in both axes at once
|
|
local h = math.floor(72 * prog)
|
|
local w = math.floor(80 * prog)
|
|
love.graphics.rectangle("fill", 0, 72 - h, 160, h * 2)
|
|
love.graphics.rectangle("fill", 80 - w, 0, w * 2, 144)
|
|
end
|
|
|
|
if prog >= 1 then
|
|
love.graphics.rectangle("fill", 0, 0, 160, 144)
|
|
end
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
end
|
|
|
|
return BattleTransition
|