mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 08:11:35 +02:00
Merge pull request #627 from spiritsnails/parity-fixes
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
-- Faithful resolution: lock the window to an exact integer multiple of the
|
||||
-- Game Boy's 160x144 screen, 1X through 4X.
|
||||
--
|
||||
-- At any other window size the renderer picks the largest integer scale that
|
||||
-- fits and letterboxes the remainder (Renderer:fitScale), so the game is
|
||||
-- already crisp -- what it is not is *exact*: there are bars, and at a wide
|
||||
-- window a lot of them. Locking the window to 160*N x 144*N removes the
|
||||
-- letterbox entirely, so the surface is the Game Boy screen and nothing else.
|
||||
--
|
||||
-- Persisted as save.options.faithfulRes (0 = OFF). Applied from OptionsMenu
|
||||
-- and on boot via Game:applyOptions. No-ops on mobile and in headless stubs
|
||||
-- that lack love.window.
|
||||
|
||||
local FaithfulRes = {}
|
||||
|
||||
FaithfulRes.WIDTH, FaithfulRes.HEIGHT = 160, 144
|
||||
FaithfulRes.LEVELS = { 0, 1, 2, 3, 4 }
|
||||
FaithfulRes.DEFAULT = 0
|
||||
|
||||
-- conf.lua's floor for the resizable desktop window, restored when the lock
|
||||
-- is released. 1X and 2X are BELOW it, so the lock has to lower the minimum
|
||||
-- as well as set the size or LOVE clamps the window back up.
|
||||
FaithfulRes.MIN_W, FaithfulRes.MIN_H = 480, 360
|
||||
|
||||
-- whether this module currently owns the window size
|
||||
FaithfulRes.locked = false
|
||||
|
||||
function FaithfulRes.normalize(v)
|
||||
v = math.floor(tonumber(v) or FaithfulRes.DEFAULT)
|
||||
if v < 0 then return 0 end
|
||||
if v > 4 then return 4 end
|
||||
return v
|
||||
end
|
||||
|
||||
function FaithfulRes.label(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return "OFF" end
|
||||
return tostring(v) .. "X"
|
||||
end
|
||||
|
||||
function FaithfulRes.cycle(v, dir)
|
||||
local levels = FaithfulRes.LEVELS
|
||||
local cur = 1
|
||||
for i, level in ipairs(levels) do
|
||||
if level == FaithfulRes.normalize(v) then cur = i break end
|
||||
end
|
||||
return levels[(cur - 1 + (dir or 1)) % #levels + 1]
|
||||
end
|
||||
|
||||
function FaithfulRes.isMobile()
|
||||
if not love or not love.system or not love.system.getOS then return false end
|
||||
local osName = love.system.getOS()
|
||||
return osName == "Android" or osName == "iOS"
|
||||
end
|
||||
|
||||
-- Physical pixels per LOVE unit for the CURRENT window.
|
||||
--
|
||||
-- Deliberately NOT love.window.getDPIScale: that reports the display's
|
||||
-- scaling factor even when the window is not high-DPI aware, and conf.lua
|
||||
-- only sets t.window.highdpi on mobile. On a plain desktop window a unit IS
|
||||
-- a pixel, so dividing by the display scale just shrinks the window -- at
|
||||
-- 125% scaling a 2X request became 256x230 pixels, which Renderer:fitScale
|
||||
-- floors to 1, and 4X became 512x461, which floors to 3. That is exactly
|
||||
-- the "2X renders at 1X, 4X renders at 3X" this shipped with.
|
||||
--
|
||||
-- Measuring the ratio the window actually reports is correct in both worlds:
|
||||
-- 1 on a plain desktop window, the real scale on a high-DPI one.
|
||||
local function pixelsPerUnit()
|
||||
local g = love and love.graphics
|
||||
if not (g and g.getDimensions and g.getPixelDimensions) then return 1 end
|
||||
local uw = tonumber((g.getDimensions()))
|
||||
local pw = tonumber((g.getPixelDimensions()))
|
||||
if not uw or not pw or uw <= 0 or pw <= 0 then return 1 end
|
||||
return pw / uw
|
||||
end
|
||||
|
||||
-- The window size in LOVE UNITS that puts 160*v x 144*v PHYSICAL pixels on
|
||||
-- screen.
|
||||
function FaithfulRes.size(v)
|
||||
v = FaithfulRes.normalize(v)
|
||||
if v == 0 then return nil end
|
||||
local ratio = pixelsPerUnit()
|
||||
return math.floor(FaithfulRes.WIDTH * v / ratio + 0.5),
|
||||
math.floor(FaithfulRes.HEIGHT * v / ratio + 0.5)
|
||||
end
|
||||
|
||||
-- Push the lock into the live window. Returns true when the window is
|
||||
-- locked afterwards.
|
||||
function FaithfulRes.apply(v)
|
||||
if FaithfulRes.isMobile() then return false end
|
||||
if not love or not love.window or not love.window.setMode
|
||||
or not love.window.getMode then
|
||||
return false
|
||||
end
|
||||
v = FaithfulRes.normalize(v)
|
||||
local curW, curH, flags = love.window.getMode()
|
||||
flags = flags or {}
|
||||
|
||||
if v == 0 then
|
||||
-- only touch the window if we were the one holding it: an OFF setting on
|
||||
-- boot must not resize a window the player sized themselves
|
||||
if not FaithfulRes.locked then return false end
|
||||
flags.resizable = true
|
||||
flags.minwidth, flags.minheight = FaithfulRes.MIN_W, FaithfulRes.MIN_H
|
||||
love.window.setMode(curW, curH, flags)
|
||||
FaithfulRes.locked = false
|
||||
return false
|
||||
end
|
||||
|
||||
local w, h = FaithfulRes.size(v)
|
||||
-- An exact size and a desktop-fullscreen mode cannot both hold. The lock
|
||||
-- is the more specific request, so it wins and drops fullscreen; VIDEO MODE
|
||||
-- reads BORDERLESS until the player changes it, which then releases this.
|
||||
flags.fullscreen = false
|
||||
-- resizing by hand would silently break the lock, and nothing re-applies it
|
||||
-- (there is no love.resize handler -- the renderer re-reads the size every
|
||||
-- frame), so the window is fixed while locked rather than left draggable.
|
||||
flags.resizable = false
|
||||
flags.minwidth, flags.minheight = w, h
|
||||
love.window.setMode(w, h, flags)
|
||||
FaithfulRes.locked = true
|
||||
return true
|
||||
end
|
||||
|
||||
function FaithfulRes.applyOptions(opts)
|
||||
return FaithfulRes.apply(opts and opts.faithfulRes)
|
||||
end
|
||||
|
||||
return FaithfulRes
|
||||
+17
-4
@@ -10,6 +10,7 @@ local MAX_ACCUM = 0.25 -- avoid spiral of death after a stall
|
||||
function FixedStep:init(callback)
|
||||
self.accum = 0
|
||||
self.callback = callback
|
||||
self.suppressCatchup = false
|
||||
end
|
||||
|
||||
-- The anti-spiral clamp doubles as a steps-per-frame ceiling (0.25s = 15
|
||||
@@ -20,6 +21,16 @@ end
|
||||
FixedStep.maxAccum = MAX_ACCUM
|
||||
|
||||
function FixedStep:update(dt)
|
||||
-- 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.
|
||||
if self.suppressCatchup then
|
||||
self.suppressCatchup = false
|
||||
self.accum = 0
|
||||
self.callback(self.STEP)
|
||||
return
|
||||
end
|
||||
self.accum = math.min(self.accum + dt, self.maxAccum or MAX_ACCUM)
|
||||
while self.accum >= self.STEP do
|
||||
self.accum = self.accum - self.STEP
|
||||
@@ -27,12 +38,14 @@ function FixedStep:update(dt)
|
||||
end
|
||||
end
|
||||
|
||||
-- Drop any pending catch-up steps. 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).
|
||||
-- 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
|
||||
|
||||
@@ -265,6 +265,38 @@ end
|
||||
-- exactly as the owning state computed it
|
||||
local function sameZones(_, zones) return zones end
|
||||
|
||||
-- Dim alpha for a BATTLE BG "world" battle anywhere in the stack, or nil.
|
||||
-- Same whole-stack rule as fillScaleInStack: a party menu or text box opened
|
||||
-- during the battle must not drop the dim for a frame.
|
||||
function Game.worldBgBattleDim(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.bgMode and state:bgMode() == "world" then
|
||||
return state.BG_WORLD_DIM or 0.55
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Does anything on the stack want the surface scaled to FILL the window
|
||||
-- (aspect preserved, bars on the long axis) rather than sit at the fixed
|
||||
-- integer scale?
|
||||
--
|
||||
-- Asked of the WHOLE stack, not just the top. For BATTLE SIZE "fill" that is
|
||||
-- because the party menu, bag and text boxes a battle opens must not snap the
|
||||
-- surface back to the fixed scale for a frame; the title screen and intro want
|
||||
-- it unconditionally, since neither has a world behind it and neither has any
|
||||
-- reason to sit in a small box in the middle of a large window.
|
||||
function Game.fillScaleInStack(stack)
|
||||
for i = #(stack and stack.states or {}), 1, -1 do
|
||||
local state = stack.states[i]
|
||||
if state and state.wantsFillScale and state:wantsFillScale() then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- A wide battle owns the surface until it leaves the stack. The party,
|
||||
-- bag, choice and text states it opens still draw their original 160px UI,
|
||||
-- but the canvas must not snap to 160px between those states.
|
||||
@@ -319,6 +351,14 @@ function Game:draw()
|
||||
else
|
||||
Renderer:setUISize(Renderer.WIDTH, Renderer.HEIGHT)
|
||||
end
|
||||
-- BATTLE SIZE: scale the battle surface to the window instead of the
|
||||
-- classic integer letterbox. Read from the whole stack, not just the top,
|
||||
-- so a party menu or text box opened mid-battle keeps the same surface.
|
||||
Renderer.uiFill = Game.fillScaleInStack(self.stack)
|
||||
-- BATTLE BG "world": dim the overworld the battle is drawn over. Read off
|
||||
-- the stack for the same reason as uiFill above -- a prompt opened during
|
||||
-- the battle must not drop the dim for a frame.
|
||||
Renderer.battleDim = Game.worldBgBattleDim(self.stack)
|
||||
Renderer:beginFrame(worldBelow)
|
||||
for i = self.stack:visibleBase(), #self.stack.states do
|
||||
local state = self.stack.states[i]
|
||||
@@ -658,6 +698,9 @@ function Game:applyOptions(opts)
|
||||
-- returns true when a persisted GBC FX level was cleared on mobile
|
||||
local gbcCleared = require("src.render.GBCFX").applyOptions(opts)
|
||||
require("src.core.VideoMode").applyOptions(opts)
|
||||
-- after VideoMode: a faithful-resolution lock is an exact window size, so
|
||||
-- it has to be the last word on the window (it drops fullscreen to hold)
|
||||
require("src.core.FaithfulRes").applyOptions(opts)
|
||||
-- normalizes a nil/garbage cap to the 60 default, so old saves with no
|
||||
-- fpsCap key pace at the standard rate (issue #88)
|
||||
require("src.core.FrameCap").applyOptions(opts)
|
||||
|
||||
@@ -219,6 +219,15 @@ function SaveData.defaultOptions()
|
||||
-- battle screen composition: og (the 160x144 original) | wide
|
||||
-- (304x144, src/battle/WideBattle.lua)
|
||||
battleLayout = "og",
|
||||
-- BATTLE SIZE: "fixed" = the classic integer-scaled letterbox; "fill" =
|
||||
-- scale the battle surface to the window so it fills vertically. See
|
||||
-- BattleState:wantsFillScale.
|
||||
battleFit = "fixed",
|
||||
-- BATTLE BG: what fills the screen behind and around the battle.
|
||||
-- "white" = the display mode's paper shade (the classic look),
|
||||
-- "black" = plain black bars, "world" = the frozen overworld showing
|
||||
-- through, dimmed. See BattleState:bgMode.
|
||||
battleBg = "white",
|
||||
ruleset = "gen1_faithful",
|
||||
-- 0-7 like the GB's NR50 master volume
|
||||
musicVol = 7,
|
||||
@@ -239,6 +248,9 @@ function SaveData.defaultOptions()
|
||||
voidFill = "trees",
|
||||
-- windowed | borderless (desktop fullscreen); ignored on mobile
|
||||
videoMode = "windowed",
|
||||
-- lock the window to an exact 160x144 multiple, 1..4 (0 = OFF); see
|
||||
-- src/core/FaithfulRes.lua. Ignored on mobile.
|
||||
faithfulRes = 0,
|
||||
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
|
||||
fpsCap = 60,
|
||||
-- graphics performance tier: auto | high | balanced | low. "auto"
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Hardware frame budgets, in fixed 60Hz logic steps.
|
||||
--
|
||||
-- The original spends a large share of its running time inside DelayFrames
|
||||
-- calls that produce no visible change -- the pause after a page break, the
|
||||
-- beat before a status move resolves, the one-HP-at-a-time drain of an HP
|
||||
-- bar. Porting the visible half of a sequence and dropping the wait is what
|
||||
-- makes a port read as snappier than hardware, so every one of those waits
|
||||
-- lives here with its asm citation instead of as a file-local constant.
|
||||
--
|
||||
-- See docs/timing-parity.md for the full catalog and the measurement method;
|
||||
-- tools/scan_pokered_delays.ps1 regenerates the hardware side from a
|
||||
-- disassembly checkout.
|
||||
|
||||
local Timing = {}
|
||||
|
||||
-- home/palettes.asm:14 -- three frames to let the bg map fully update
|
||||
Timing.DELAY3 = 3
|
||||
|
||||
-- home/fade.asm: each fade is a loop of `ld c, 8 / call DelayFrames`
|
||||
Timing.FADE_IN_FROM_BLACK = 32 -- fade.asm:21, b = 4
|
||||
Timing.FADE_OUT_TO_BLACK = 32 -- fade.asm:43, b = 4
|
||||
Timing.FADE_OUT_TO_WHITE = 24 -- fade.asm:26, b = 3
|
||||
Timing.FADE_IN_FROM_WHITE = 24 -- fade.asm:48, b = 3
|
||||
|
||||
-- Overworld -----------------------------------------------------------------
|
||||
|
||||
-- home/overworld.asm:703 PlayMapChangeSound tail-calls GBFadeOutToBlack on
|
||||
-- every map change. There is no matching fade in: the new map is drawn while
|
||||
-- the palettes are still blacked out and LoadGBPal restores them in one write,
|
||||
-- so the map appears instantly.
|
||||
Timing.WARP_FADE_OUT = Timing.FADE_OUT_TO_BLACK
|
||||
Timing.WARP_FADE_IN = 0
|
||||
|
||||
-- home/overworld.asm:351-352 -- after a battle, before EnterMap
|
||||
Timing.POST_BATTLE_RETURN = 10
|
||||
|
||||
-- engine/overworld/player_animations.asm:5-7 -- EnterMapAnim, the fly /
|
||||
-- teleport / dungeon-warp arrival: Delay3 then GBFadeInFromWhite
|
||||
Timing.SPECIAL_WARP_ENTRY = Timing.DELAY3 + Timing.FADE_IN_FROM_WHITE
|
||||
-- player_animations.asm:43 -- dungeon warp holds before handing back control
|
||||
Timing.DUNGEON_WARP_ARRIVAL = 50
|
||||
|
||||
-- Text ----------------------------------------------------------------------
|
||||
|
||||
-- home/text.asm:283-307 ScrollTextUpOneLine is `ld b, 5` of DelayFrame, and
|
||||
-- its own comment notes it is "always called twice in a row"
|
||||
Timing.TEXT_SCROLL_LINE = 5
|
||||
Timing.TEXT_SCROLL_PAIR = Timing.TEXT_SCROLL_LINE * 2
|
||||
|
||||
-- Both _ContText (home/text.asm:262-277) and Paragraph (:230-243) print the
|
||||
-- â–¼ and call ProtectedDelay3 *before* ManualTextScroll starts watching the
|
||||
-- joypad, so three frames pass with the arrow up and the button ignored.
|
||||
Timing.TEXT_PRE_ADVANCE = Timing.DELAY3
|
||||
|
||||
-- Paragraph / PageChar clear the box and then hold (home/text.asm:239-240,
|
||||
-- :254-255) before the next page starts typing.
|
||||
Timing.TEXT_PAGE_CLEAR = 20
|
||||
|
||||
-- Totals, for the catalog and the parity tests.
|
||||
Timing.TEXT_CONT = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_SCROLL_PAIR
|
||||
Timing.TEXT_PARAGRAPH = Timing.TEXT_PRE_ADVANCE + Timing.TEXT_PAGE_CLEAR
|
||||
Timing.TEXT_PAGE = Timing.TEXT_PARAGRAPH
|
||||
|
||||
Timing.TEXT_PAUSE = 30 -- home/text.asm:500 TextCommand_PAUSE
|
||||
Timing.TEXT_DOT = 10 -- home/text.asm:576 TextCommand_DOTS, per dot
|
||||
|
||||
-- Menus ---------------------------------------------------------------------
|
||||
|
||||
-- engine/menus/text_box.asm:322-323 / :333-334 -- both branches of a
|
||||
-- two-option (yes/no) menu hold before restoring the screen tiles
|
||||
Timing.YES_NO_ANSWER = 15
|
||||
|
||||
Timing.LIST_MENU_OPEN = 10 -- home/list_menu.asm:55-56
|
||||
Timing.LIST_MENU_REDRAW = Timing.DELAY3 -- home/list_menu.asm:64
|
||||
|
||||
-- engine/menus/start_sub_menus.asm:224-225
|
||||
Timing.FIELD_TELEPORT = 60 + Timing.DELAY3
|
||||
|
||||
-- Battle --------------------------------------------------------------------
|
||||
|
||||
-- SlidePlayerAndEnemySilhouettesOnScreen (engine/battle/core.asm:9-49):
|
||||
-- the enemy comes in on BG SCX $90 -> $00 and the player's back pic on
|
||||
-- decrementing OAM x, both 2 px per frame -- so 144 px over 72 frames. The
|
||||
-- port ran 160 px at 4 px/frame (40 frames), a little under twice too fast.
|
||||
Timing.BATTLE_SLIDE_IN_FRAMES = 72
|
||||
Timing.BATTLE_SLIDE_PX_PER_FRAME = 2
|
||||
|
||||
-- PrintBeginningBattleText .trainerBattle (engine/battle/common_text.asm):
|
||||
-- SFX_SILPH_SCOPE plays into a clear window (PlaySound then
|
||||
-- WaitForSoundToFinish, which blocks), and only after `ld c, 20 /
|
||||
-- DelayFrames` do DrawAllPokeballs and the "wants to fight!" text run.
|
||||
Timing.TRAINER_INTRO_SFX_GAP = 20
|
||||
|
||||
Timing.BATTLE_START_SENDOUT = 40 -- engine/battle/core.asm:155-156
|
||||
Timing.MOVE_ANIM_PRE = Timing.DELAY3 -- core.asm:6638 PlayMoveAnimation
|
||||
|
||||
-- core.asm:3185-3186 (player) / :5587-5588 (enemy). Reached when the move
|
||||
-- has 0 BP (core.asm:3145 -- every status move) or missed (:3158), so this
|
||||
-- beat is paid on a large fraction of all turns.
|
||||
Timing.MOVE_STATUS_OR_MISS = 30
|
||||
|
||||
-- PlayApplyingAttackAnimation's six types (AnimationTypePointerTable,
|
||||
-- engine/battle/animations.asm:490-524). The two shake families are
|
||||
-- `AnimationShakeScreenHorizontallySlow`, whose double push/pop makes each
|
||||
-- outer pass cost 4b frames and run c times -- so c * 4b.
|
||||
Timing.SHAKE_VERTICAL = 48 -- type 1, b=8: 8 x 6
|
||||
Timing.SHAKE_HORIZ_HEAVY = 72 -- type 2, b=8: 8 x 9
|
||||
Timing.SHAKE_HORIZ_SLOW = 48 -- type 3, lb bc, 6, 2: 2 x 4x6
|
||||
Timing.SHAKE_HORIZ_LIGHT = 18 -- type 5, b=2: 2 x 9
|
||||
Timing.SHAKE_HORIZ_SLOW2 = 24 -- type 6, lb bc, 3, 2: 2 x 4x3
|
||||
|
||||
-- Type 4 -- the player's damaging move with no added effect, and so the
|
||||
-- single most common animation in the game -- is AnimationBlinkMon
|
||||
-- (animations.asm:1360-1376): `ld c, 6` iterations of hide + DelayFrames 5
|
||||
-- + show + DelayFrames 5. The asm's own comment calls it "a second or
|
||||
-- two"; the port ran it in 20 frames, three times too fast, which is a
|
||||
-- large part of why trading blows felt hurried.
|
||||
Timing.BLINK_MON = 60
|
||||
|
||||
-- SlideDownFaintedMonPic (engine/battle/core.asm:1181-1222): b = PIC_HEIGHT
|
||||
-- (7) outer iterations, each closing with `ld c, 2 / call DelayFrames`.
|
||||
-- This one the port ran SLOWER than hardware, at 30.
|
||||
Timing.FAINT_SLIDE = 14
|
||||
|
||||
Timing.RESIDUAL_TICK = 20 -- core.asm:529-530 poison/burn/leech seed
|
||||
Timing.CRIT_OHKO_TEXT = 20 -- core.asm:3813-3814
|
||||
Timing.SWITCH_PLAYER_MON = 50 -- core.asm:2421-2422
|
||||
Timing.NO_MOVES_LEFT = 60 -- core.asm:2753-2754
|
||||
Timing.TRAINER_VICTORY = 40 -- core.asm:940-941
|
||||
Timing.PLAYER_BLACKOUT = 40 -- core.asm:1143-1144
|
||||
Timing.FAINT_SLIDE_ROW = 2 -- core.asm:1216-1217, per row
|
||||
Timing.TRAINER_SLIDE_COL = 2 -- core.asm:1267-1268, per column
|
||||
|
||||
-- HP bar (engine/gfx/hp_bar.asm) ---------------------------------------------
|
||||
--
|
||||
-- UpdateHPBar steps ONE HP point per loop iteration (:81-120). Each
|
||||
-- iteration pays:
|
||||
-- * 1 frame in UpdateHPBar_PrintHPNumber's DelayFrame (:234) -- but only
|
||||
-- when wHPBarType is nonzero (:207-209), i.e. the player's own HUD and
|
||||
-- the party menu, never the enemy HUD; and
|
||||
-- * 2 frames per pixel the bar actually moved, from
|
||||
-- UpdateHPBar_AnimateHPBar's `ld c, 2 / call DelayFrames` (:147-148).
|
||||
-- The drain closes with one more pixel step and a Delay3 (:133-135).
|
||||
--
|
||||
-- So a player-side drain of D HP across P pixels costs D + 2P + 6 frames,
|
||||
-- while the same drain on the enemy HUD costs only 2P + 5. A 150 HP mon
|
||||
-- losing everything takes 150 + 96 + 6 = 252 frames on hardware.
|
||||
|
||||
Timing.HP_BAR_PIXELS = 48 -- the bar is 48 px wide (GetHPBarLength)
|
||||
Timing.HP_BAR_PIXEL_STEP = 2 -- frames per pixel of bar movement
|
||||
Timing.HP_BAR_HP_STEP = 1 -- frames per HP point, player-side HUD only
|
||||
|
||||
-- Pixels the bar shows for `hp` out of `maxHP`. GetHPBarLength floors the
|
||||
-- 48ths and clamps the result to at least 1 for any nonzero HP
|
||||
-- (engine/gfx/hp_bar.asm:42-45); an empty bar is 0.
|
||||
function Timing.hpBarPixels(hp, maxHP)
|
||||
if not maxHP or maxHP <= 0 then return 0 end
|
||||
if hp <= 0 then return 0 end
|
||||
local px = math.floor(hp * Timing.HP_BAR_PIXELS / maxHP)
|
||||
if px < 1 then px = 1 end
|
||||
return px
|
||||
end
|
||||
|
||||
-- Frames one single-HP step of the drain costs: the per-HP number print
|
||||
-- (player side only) plus two frames for every pixel that step moved.
|
||||
function Timing.hpDrainStepFrames(fromHP, toHP, maxHP, playerSide)
|
||||
local pixels = math.abs(Timing.hpBarPixels(toHP, maxHP)
|
||||
- Timing.hpBarPixels(fromHP, maxHP))
|
||||
local frames = pixels * Timing.HP_BAR_PIXEL_STEP
|
||||
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- After the loop, .animateHPBarDone prints the number one last time, runs
|
||||
-- AnimateHPBar for a single pixel and falls into Delay3 (hp_bar.asm:132-135)
|
||||
-- -- so the tail costs 6 frames on the player's HUD and 5 on the enemy's.
|
||||
function Timing.hpDrainClosingFrames(playerSide)
|
||||
local frames = Timing.HP_BAR_PIXEL_STEP + Timing.DELAY3
|
||||
if playerSide then frames = frames + Timing.HP_BAR_HP_STEP end
|
||||
return frames
|
||||
end
|
||||
|
||||
-- Total cost of draining `fromHP` to `toHP`, for tests and for anything that
|
||||
-- needs to budget the whole animation up front.
|
||||
function Timing.hpDrainFrames(fromHP, toHP, maxHP, playerSide)
|
||||
local total = 0
|
||||
local hp = fromHP
|
||||
local dir = (toHP < fromHP) and -1 or 1
|
||||
while hp ~= toHP do
|
||||
local nextHP = hp + dir
|
||||
total = total + Timing.hpDrainStepFrames(hp, nextHP, maxHP, playerSide)
|
||||
hp = nextHP
|
||||
end
|
||||
return total + Timing.hpDrainClosingFrames(playerSide)
|
||||
end
|
||||
|
||||
return Timing
|
||||
Reference in New Issue
Block a user