mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-09 01:59:15 -04:00
188 lines
6.3 KiB
Lua
188 lines
6.3 KiB
Lua
-- Fixed-step update loop at the Game Boy's ~60Hz. Game logic advances in
|
||
-- whole steps regardless of the display refresh rate, which keeps movement,
|
||
-- text speed and battle timing deterministic.
|
||
|
||
local FixedStep = {}
|
||
|
||
FixedStep.GB_HZ = 4194304 / 70224
|
||
FixedStep.STEP = 1 / 60
|
||
FixedStep.MAX_ACCUM = 0.25
|
||
FixedStep.WORK_FRACTION = 0.75
|
||
FixedStep.clock = nil
|
||
local MAX_ACCUM = FixedStep.MAX_ACCUM
|
||
local SMOOTH_FRAMES = 4
|
||
local SMOOTH_MAX = 1 / 60 * 2.5
|
||
local STEP_EPS = 1 / 60 * 0.02
|
||
|
||
function FixedStep.setHz(hz)
|
||
hz = tonumber(hz)
|
||
if not hz or hz <= 0 then hz = 60 end
|
||
FixedStep.STEP = 1 / hz
|
||
SMOOTH_MAX = FixedStep.STEP * 2.5
|
||
STEP_EPS = FixedStep.STEP * 0.02
|
||
FixedStep.phasedFor = nil
|
||
return hz
|
||
end
|
||
|
||
-- Phase the accumulator is re-seeded with once an absorbed hitch frame has
|
||
-- been paid for. Half a step is the balanced point: a frame has to come in
|
||
-- ~8ms short before it drops a step and ~8ms long before it doubles one up.
|
||
-- Zero -- what this used to leave behind -- has no margin at all on the long
|
||
-- side, so the accumulator settles a hair BELOW one step and parks there, and
|
||
-- ordinary sub-millisecond vsync wobble then flips it between 0 and 2 steps
|
||
-- every few frames for the rest of the session. That is why pacing stayed
|
||
-- visibly broken after a route/city seam and nowhere else: crossConnection in
|
||
-- src/world/OverworldController.lua is the only caller of discardCatchup, and
|
||
-- warps go through Transition instead (issue #487).
|
||
local RESEED_PHASE = 0.5
|
||
|
||
FixedStep.refreshPeriod = nil
|
||
|
||
local PHASE_PROBE = 600
|
||
|
||
local function phaseOffset(period, step)
|
||
local residuals, seen, accum = {}, {}, 0
|
||
for _ = 1, PHASE_PROBE do
|
||
accum = accum + period
|
||
while accum >= step - STEP_EPS do accum = accum - step end
|
||
local key = math.floor(accum / step * 1e6 + 0.5)
|
||
if seen[key] then break end
|
||
seen[key] = true
|
||
residuals[#residuals + 1] = accum
|
||
end
|
||
if #residuals == 0 then return 0 end
|
||
table.sort(residuals)
|
||
local bestGap, bestCentre = -1, 0
|
||
for i = 1, #residuals do
|
||
local a = residuals[i]
|
||
local b = (i < #residuals) and residuals[i + 1] or (residuals[1] + step)
|
||
if b - a > bestGap then bestGap, bestCentre = b - a, (a + b) * 0.5 end
|
||
end
|
||
return (step - bestCentre) % step
|
||
end
|
||
|
||
function FixedStep:init(callback)
|
||
self.accum = 0
|
||
self.callback = callback
|
||
self.suppressCatchup = false
|
||
self.dtHistory, self.dtSum = nil, 0
|
||
self.phasedFor = nil
|
||
self.frameBreak = false
|
||
end
|
||
|
||
FixedStep.maxAccum = MAX_ACCUM
|
||
|
||
function FixedStep.catchupLimit(speed, dt)
|
||
speed = tonumber(speed) or 1
|
||
if speed < 1 then speed = 1 end
|
||
local step = FixedStep.STEP
|
||
local frame = tonumber(dt) or step
|
||
if frame ~= frame or frame < step or frame > SMOOTH_MAX then frame = step end
|
||
local target = speed * frame * 1.5
|
||
local floor = step * 2
|
||
if target < floor then target = floor end
|
||
return target
|
||
end
|
||
|
||
local function workBudget(self, dt)
|
||
local base = self.refreshPeriod or self.STEP
|
||
local frame = dt
|
||
if frame < base then frame = base end
|
||
if frame > self.STEP * 2 then frame = math.max(base, self.STEP * 2) end
|
||
return frame * FixedStep.WORK_FRACTION
|
||
end
|
||
|
||
local function workClock()
|
||
if FixedStep.clock then return FixedStep.clock end
|
||
local timer = love and love.timer
|
||
return timer and timer.getTime or nil
|
||
end
|
||
|
||
function FixedStep:update(dt, speed)
|
||
-- A hitch's oversized dt lands on the frame AFTER discardCatchup was
|
||
-- called (the hitch itself already ran inside the current step); absorb
|
||
-- that one frame as a single step instead of the normal accumulator so
|
||
-- the burst it would otherwise release doesn't play out as a slide.
|
||
self.frameBreak = false
|
||
if self.suppressCatchup then
|
||
self.suppressCatchup = false
|
||
self.dtHistory, self.dtSum = nil, 0
|
||
self.accum = self.STEP * RESEED_PHASE
|
||
self.callback(self.STEP)
|
||
self.frameBreak = false
|
||
return
|
||
end
|
||
-- Snap/smooth against the wall-clock frame dt first. Game speed is a
|
||
-- multiplier on how many logic steps that real frame buys — applying it
|
||
-- before refresh-period snap made 2–4X look like multi-period frames and
|
||
-- destabilized pacing under DISPLAY sync.
|
||
speed = tonumber(speed) or 1
|
||
if speed < 1 then speed = 1 end
|
||
-- Pathological wall-clock stalls: do not let dt alone exceed the catch-up
|
||
-- ceiling before speed is applied (speed amplify would just hit the clamp).
|
||
if dt > MAX_ACCUM then dt = MAX_ACCUM end
|
||
local frameDt = dt
|
||
local period = self.refreshPeriod
|
||
local snapped = false
|
||
if period and dt > 0 then
|
||
local frames = math.floor(dt / period + 0.5)
|
||
if frames >= 1 and frames * period <= SMOOTH_MAX
|
||
and math.abs(frames * period - dt) < period * 0.25 then
|
||
dt = frames * period
|
||
snapped = true
|
||
end
|
||
end
|
||
if snapped then
|
||
self.dtHistory, self.dtSum = nil, 0
|
||
elseif dt > 0 and dt <= SMOOTH_MAX then
|
||
local hist = self.dtHistory
|
||
if not hist then hist = {}; self.dtHistory = hist; self.dtSum = 0 end
|
||
hist[#hist + 1] = dt
|
||
self.dtSum = self.dtSum + dt
|
||
if #hist > SMOOTH_FRAMES then
|
||
self.dtSum = self.dtSum - hist[1]
|
||
table.remove(hist, 1)
|
||
end
|
||
dt = self.dtSum / #hist
|
||
else
|
||
self.dtHistory, self.dtSum = nil, 0
|
||
end
|
||
if period and self.phasedFor ~= period then
|
||
self.phasedFor = period
|
||
local target = phaseOffset(period, self.STEP)
|
||
self.accum = self.accum - self.accum % self.STEP + target
|
||
end
|
||
self.accum = math.min(self.accum + dt * speed, self.maxAccum or MAX_ACCUM)
|
||
local clock = speed > 1 and workClock() or nil
|
||
local deadline = clock and (clock() + workBudget(self, frameDt)) or nil
|
||
while self.accum >= self.STEP - STEP_EPS do
|
||
self.accum = self.accum - self.STEP
|
||
self.callback(self.STEP)
|
||
if self.frameBreak then
|
||
self.frameBreak = false
|
||
self.accum = self.accum % self.STEP
|
||
break
|
||
end
|
||
if deadline and self.accum >= self.STEP - STEP_EPS and clock() >= deadline then
|
||
self.accum = self.accum % self.STEP
|
||
break
|
||
end
|
||
end
|
||
end
|
||
|
||
function FixedStep:endFrame()
|
||
self.frameBreak = true
|
||
end
|
||
|
||
-- Drop any pending catch-up steps and arm the one-frame clamp above. A
|
||
-- hitch inside one logic step (map seam setMap / song start) makes the
|
||
-- next real-time dt huge; without this the while-loop above would advance
|
||
-- many walk frames before the next draw, which looks like a slide with no
|
||
-- leg animation (issue #93).
|
||
function FixedStep:discardCatchup()
|
||
self.accum = 0
|
||
self.suppressCatchup = true
|
||
end
|
||
|
||
return FixedStep
|