Compare commits

...

2 Commits

Author SHA1 Message Date
bryanthaboi aad86fb351 pokecenter fix 2026-07-23 15:15:35 -04:00
bryanthaboi 2c9970a643 Frame Cap (#124) 2026-07-23 14:35:26 -04:00
12 changed files with 311 additions and 30 deletions
+7 -3
View File
@@ -42,8 +42,10 @@ Game Boy equivalent:
rows above the player recede and rows below come toward the viewer. Only rows above the player recede and rows below come toward the viewer. Only
things that actually *stand* on the ground draw as upright billboards, things that actually *stand* on the ground draw as upright billboards,
unscaled and pixel-identical to flat mode: the player, NPCs, item balls, unscaled and pixel-identical to flat mode: the player, NPCs, item balls,
and the screen-anchored FX attached to them (heal machine glow, emote and the standing FX attached to them (emote bubbles, the fishing rod,
bubbles, the fishing rod, the FLY bird). An earlier revision tried the FLY bird). The Poké Center heal-machine overlay stays on the ground
plane with the machine tiles (it is OAM glued to a BG graphic, not a
standing sprite). An earlier revision tried
billboarding buildings/trees/signs too (cutting them out of the ground billboarding buildings/trees/signs too (cutting them out of the ground
per hand-curated per-tileset tables); that chased an endless tail of per hand-curated per-tileset tables); that chased an endless tail of
special cases, dense tree canopy, fences fused into grass, building special cases, dense tree canopy, fences fused into grass, building
@@ -148,4 +150,6 @@ migrated once into `options.lua` on load.
hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc hotkey `2` (OG RED = GBC boot-ROM look; RED++ uses pokered-gbc
SuperPalettes + per-species mon colors) SuperPalettes + per-species mon colors)
- TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming - TILT (OFF / 15 / 35 / 50), also hotkey `3` while free-roaming
- GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5` - GBC FX (OFF / 1 / 2 / 3 / 4), also hotkey `5`
- MAX FPS (30 / 40 / 50 / 60 / 75 / 90 / 100 / 120 / 144 / 160, default 60),
a hard render frame-rate cap (`save.options.fpsCap`).
+75
View File
@@ -299,3 +299,78 @@ function love.filedropped(file)
end end
if Importer then Importer:filedropped(file) end if Importer then Importer:filedropped(file) end
end end
local function pacingEnabled()
if os.getenv("POKEPORT_AUTOPILOT") then return false end
if os.getenv("POKEPORT_DRIVER") then return false end
if os.getenv("POKEPORT_IMPORT_ONLY") == "1" then return false end
return true
end
function love.run()
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
-- don't let love.load's cost land in the first frame's dt
if love.timer then love.timer.step() end
local FrameCap = require("src.core.FrameCap")
local paced = pacingEnabled()
-- The deadline the next present() should not beat. Carried forward one
-- budget per frame so pacing stays even instead of drifting with the
-- per-frame sleep-granularity jitter.
local nextFrame = love.timer and love.timer.getTime() or 0
local dt = 0
return function()
-- process events
if love.event then
love.event.pump()
for name, a, b, c, d, e, f in love.event.poll() do
if name == "quit" then
if not love.quit or not love.quit() then
return a or 0
end
end
love.handlers[name](a, b, c, d, e, f)
end
end
-- update dt
if love.timer then dt = love.timer.step() end
-- call update and draw
if love.update then love.update(dt) end
if love.graphics and love.graphics.isActive() then
love.graphics.origin()
love.graphics.clear(love.graphics.getBackgroundColor())
if love.draw then love.draw() end
love.graphics.present()
end
if love.timer then
if paced then
-- Sleep out the remainder of the frame budget, measured from the
-- carried deadline, in small chunks so the OS timer stays
-- responsive. vsync is untouched: when it already paces slower
-- than the cap the remainder is <= 0 and this rounds to a no-op.
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
local now = love.timer.getTime()
-- A stall (alt-tab, a GC pause, a blocked import) can leave the
-- deadline more than a full budget in the past; re-anchor to now so
-- we pace the next frame rather than burst uncapped to catch up.
if now - nextFrame > budget then
nextFrame = now
end
while true do
local remaining = nextFrame - love.timer.getTime()
if remaining <= 0 then break end
love.timer.sleep(remaining < 0.001 and remaining or 0.001)
end
else
love.timer.sleep(0.001)
end
end
end
end
+34
View File
@@ -230,6 +230,25 @@ function ChipAudio.ensureMusicPlaying()
end end
end end
-- Threaded playMusic returns an empty QueueableSource and only calls
-- Source:play once the first worker buffer lands (~1 frame later). Until
-- then Source:isPlaying is false -- callers that treat that as "song over"
-- (Music.oneShotPlaying / pendingRestore) must wait here instead, or a
-- playOnce jingle like Music_PkmnHealed is cut off before it starts.
local forceAwaitingFirstBuffer -- test-only override (see _simulate*)
function ChipAudio.awaitingFirstBuffer()
if forceAwaitingFirstBuffer then return true end
local m = currentMusic
if not (m and m.threaded and not m.started and not m.finished) then
return false
end
-- a dead worker will never deliver the first buffer
if workerReady == false then return false end
if worker and worker.getError and worker:getError() then return false end
return true
end
function ChipAudio.stopMusic() function ChipAudio.stopMusic()
if currentMusic and currentMusic.source then if currentMusic and currentMusic.source then
pcall(currentMusic.source.stop, currentMusic.source) pcall(currentMusic.source.stop, currentMusic.source)
@@ -240,6 +259,7 @@ function ChipAudio.stopMusic()
end end
pendingBuf = nil pendingBuf = nil
currentMusic = nil currentMusic = nil
forceAwaitingFirstBuffer = nil
end end
-- hot reload: the next play re-reads programs.bin (a mod may have swapped the -- hot reload: the next play re-reads programs.bin (a mod may have swapped the
@@ -302,6 +322,20 @@ end
-- test hooks (headless): synchronous synthesis straight through ChipSynth -- test hooks (headless): synchronous synthesis straight through ChipSynth
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Force the "threaded, first buffer not yet queued" window so Music's
-- playOnce / pendingRestore race can be asserted without love.thread.
-- Returns a clear() that drops the override (call after the assertion).
function ChipAudio._simulateAwaitingFirstBufferForTest()
local m = currentMusic
if not m or not m.source then return nil end
m.threaded = true
m.started = false
m.finished = false
pcall(function() m.source.playing = false end)
forceAwaitingFirstBuffer = true
return function() forceAwaitingFirstBuffer = nil end
end
function ChipAudio._renderMusicForTest(data, header, seconds) function ChipAudio._renderMusicForTest(data, header, seconds)
local engine = ChipSynth.newEngine(data, header, { allowLoops = true }) local engine = ChipSynth.newEngine(data, header, { allowLoops = true })
return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2) return ChipSynth.soundData(engine, math.floor(seconds * SAMPLE_RATE), 2)
+72
View File
@@ -0,0 +1,72 @@
-- Render frame-rate cap. With a driver control panel forcing
-- vsync off, the 160x144 game is trivially cheap and love.run will present
-- thousands of frames a second; over hours that cooks the graphics driver
-- until a restart, and it wastes power whenever the window is left open in
-- the background. A hard cap bounds the present rate. Render-only: game
-- logic is fixed-step off dt (src/core/FixedStep.lua), so pacing present()
-- changes nothing about timing, audio, or determinism.
--
-- Persisted as save.options.fpsCap; applied from OptionsMenu and on boot
-- via Game:applyOptions. main.lua's love.run reads FrameCap.current each
-- frame for its sleep budget. The module never touches love.timer itself,
-- so it stays safe under the headless test stub.
local FrameCap = {}
-- Selectable steps: the normal framerate stops between the floor and the
-- ceiling. STEPS[1] == MIN and STEPS[#STEPS] == MAX, so the nearest-step
-- snap in normalize doubles as the clamp. Cycling past the last wraps.
FrameCap.STEPS = { 30, 40, 50, 60, 75, 90, 100, 120, 144, 160 }
FrameCap.MIN = 30
FrameCap.MAX = 160
FrameCap.DEFAULT = 60
-- The live cap the run loop paces to. Defaults so the launcher and the
-- save editor are paced before any save applies its stored option.
FrameCap.current = FrameCap.DEFAULT
-- Nearest valid step for an arbitrary value (a hand-edited options.lua or
-- an old save with no fpsCap key), so a bad number degrades to something
-- sane; nil / non-numbers fall back to the default. A value below MIN or
-- above MAX snaps to that end, since MIN/MAX are the first/last steps.
function FrameCap.normalize(value)
value = tonumber(value)
if not value then return FrameCap.DEFAULT end
local best, bestDiff = FrameCap.DEFAULT, math.huge
for _, step in ipairs(FrameCap.STEPS) do
local diff = math.abs(step - value)
if diff < bestDiff then best, bestDiff = step, diff end
end
return best
end
-- plain numeric text for the options row (e.g. "60")
function FrameCap.label(value)
return tostring(FrameCap.normalize(value))
end
-- cycle to the next/previous step, wrapping (the options row idiom)
function FrameCap.cycle(value, dir)
local steps = FrameCap.STEPS
local snapped = FrameCap.normalize(value)
local cur = 1
for i, step in ipairs(steps) do
if step == snapped then cur = i break end
end
local nextIdx = (cur - 1 + (dir or 1)) % #steps + 1
return steps[nextIdx]
end
-- Store the chosen cap as the live value the run loop paces to. Never
-- touches love.timer, so it is safe headless -- the loop just reads the
-- number back. Returns the normalized value it stored.
function FrameCap.apply(value)
FrameCap.current = FrameCap.normalize(value)
return FrameCap.current
end
function FrameCap.applyOptions(opts)
FrameCap.apply(opts and opts.fpsCap)
end
return FrameCap
+3
View File
@@ -445,6 +445,9 @@ function Game:applyOptions(opts)
require("src.render.Tilt").applyOptions(opts) require("src.render.Tilt").applyOptions(opts)
require("src.render.GBCFX").applyOptions(opts) require("src.render.GBCFX").applyOptions(opts)
require("src.core.VideoMode").applyOptions(opts) require("src.core.VideoMode").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)
Input:applyBindings(opts.bindings) Input:applyBindings(opts.bindings)
end end
+14 -1
View File
@@ -254,6 +254,7 @@ function Music.stop()
require("src.core.ChipAudio").stopMusic() require("src.core.ChipAudio").stopMusic()
state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil state.current, state.source, state.loopSource, state.fade = nil, nil, nil, nil
state.chip = false state.chip = false
state.pendingRestore = nil
if previous and Runtime.wants("music.stopped") then if previous and Runtime.wants("music.stopped") then
Runtime.emit("music.stopped", { song = previous }) Runtime.emit("music.stopped", { song = previous })
end end
@@ -347,14 +348,24 @@ end
function Music.playOnce(data, song) function Music.playOnce(data, song)
if not songDef(data, song) then return false end if not songDef(data, song) then return false end
Music.play(data, song, false, { reason = "once" }) Music.play(data, song, false, { reason = "once" })
-- play() can no-op (hook silence, failed def); only arm restore when
-- the jingle actually became current
if state.current ~= song then return false end
state.pendingRestore = true state.pendingRestore = true
return true return true
end end
local function chipAwaitingFirstBuffer()
return state.chip
and require("src.core.ChipAudio").awaitingFirstBuffer()
end
-- is a playOnce jingle still sounding? (AnimateHealingMachine's -- is a playOnce jingle still sounding? (AnimateHealingMachine's
-- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends) -- .waitLoop2 holds the healing machine until MUSIC_PKMN_HEALED ends)
function Music.oneShotPlaying() function Music.oneShotPlaying()
if not state.pendingRestore then return false end if not state.pendingRestore then return false end
-- threaded chip songs start silent for ~1 frame; that gap is not "over"
if chipAwaitingFirstBuffer() then return true end
local src = state.source local src = state.source
if not src then return false end if not src then return false end
local ok, playing = pcall(src.isPlaying, src) local ok, playing = pcall(src.isPlaying, src)
@@ -441,8 +452,10 @@ function Music.update(data)
state.source = loopSrc state.source = loopSrc
pcall(loopSrc.play, loopSrc) pcall(loopSrc.play, loopSrc)
end end
-- do not treat "threaded source still waiting on its first buffer" as
-- ended, or playOnce jingles get restored over before they can sound
if state.pendingRestore and sourceStopped(state.source) if state.pendingRestore and sourceStopped(state.source)
and not state.loopSource then and not state.loopSource and not chipAwaitingFirstBuffer() then
Music.restoreMap(data) Music.restoreMap(data)
end end
end end
+2
View File
@@ -192,6 +192,8 @@ function SaveData.defaultOptions()
gbcfx = 0, gbcfx = 0,
-- windowed | borderless (desktop fullscreen); ignored on mobile -- windowed | borderless (desktop fullscreen); ignored on mobile
videoMode = "windowed", videoMode = "windowed",
-- hard render frame-rate cap; render-only pacing (issue #88, FrameCap.lua)
fpsCap = 60,
-- Native mod enablement is an installation option, not save-slot data. -- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default. -- Missing entries mean enabled so newly installed mods work by default.
mods = {}, mods = {},
+14
View File
@@ -13,6 +13,7 @@ local Tilt = require("src.render.Tilt")
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode") local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local Logger = require("src.core.Logger") local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime") local Runtime = require("src.mods.Runtime")
local OptionRows = require("src.ui.OptionRows") local OptionRows = require("src.ui.OptionRows")
@@ -202,6 +203,19 @@ local function buildRows(game)
VideoMode.apply(o.videoMode) VideoMode.apply(o.videoMode)
return true return true
end }, end },
-- hard render cap (issue #88): bounds the present rate so a
-- driver-forced vsync-off run cannot spin at thousands of FPS. Logic
-- is fixed-step off dt, so this touches presentation only.
{ id = "fpsCap", label = "MAX FPS",
value = function(g)
return FrameCap.label(g.save.options.fpsCap)
end,
step = function(g, dir)
local o = g.save.options
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
FrameCap.apply(o.fpsCap)
return true
end },
-- fast-forward the logic clock only; music and sfx keep their tempo -- fast-forward the logic clock only; music and sfx keep their tempo
-- (src/core/GameSpeed.lua), so this is safe to leave on -- (src/core/GameSpeed.lua), so this is safe to leave on
{ id = "speed", label = "GAME SPEED", { id = "speed", label = "GAME SPEED",
+14 -16
View File
@@ -3415,8 +3415,11 @@ function OverworldState:drawWorld()
love.graphics.setShader(shader) love.graphics.setShader(shader)
end end
end end
local ox = ha.px - 64 - cam.x -- TileRenderer windows with -floor(cam), so the overlay must use the
local oy = ha.py - 64 - cam.y -- same snap or a fractional camera (odd fill/tilt view sizes) parks
-- the balls a pixel off the machine tiles
local ox = ha.px - 64 - math.floor(cam.x)
local oy = ha.py - 64 - math.floor(cam.y)
love.graphics.setColor(1, 1, 1, 1) love.graphics.setColor(1, 1, 1, 1)
love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20) love.graphics.draw(img, self.healMachineQuads[1], ox + 44, oy + 20)
for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do for i = 1, math.min(ha.lit, #HEAL_BALL_XY) do
@@ -3632,11 +3635,13 @@ function OverworldState:drawWorld()
else else
-- === TILT PATH: ground-hugging FX stay on the projected ground, all -- === TILT PATH: ground-hugging FX stay on the projected ground, all
-- standing things billboard upright over it in a separate pass. ====== -- standing things billboard upright over it in a separate pass. ======
-- Dust is ground-hugging smoke -> ground canvas (puts it -- Dust / cut / the Poké Center heal overlay hug the BG (the heal
-- with the flat layer, so it projects with the ground). Flat mode -- machine is a tileset graphic; its OAM balls must ride that plane or
-- draws it last, over the sprites, in the same canvas; here the two -- they float off the machine once the ground foreshortens). Flat mode
-- draws them last, over the sprites, in the same canvas; here the two
-- layers are separate and composited ground-under-upright, so drawing -- layers are separate and composited ground-under-upright, so drawing
-- it now into the still-active ground canvas is order-equivalent. -- them now into the still-active ground canvas is order-equivalent.
fxHeal()
fxDust() fxDust()
fxCutTree() fxCutTree()
@@ -3692,18 +3697,11 @@ function OverworldState:drawWorld()
end end
end end
-- Screen-anchored world FX : each billboards at the -- Standing world FX: each billboards at the ground foot of the
-- ground foot of the character it belongs to, so it stands upright and -- character it belongs to, so it stays upright over the tilted ground.
-- scales with that character's depth.
-- heal machine -> the healed player's foot (the machine stands on
-- the ground in front of where the player was)
-- emote bubble -> the spotting NPC's foot (rides above its head) -- emote bubble -> the spotting NPC's foot (rides above its head)
-- fly bird, rod -> the player's foot -- fly bird, rod -> the player's foot
if self.healAnim then -- (heal machine is ground-hugging -- drawn above with dust/cut)
local fx = self.healAnim.px - cam.x + 8
local fy = self.healAnim.py - cam.y + 16
self:billboard(fx, fy, vw, vh, zoneColorsAt(zones, fx, fy), false, fxHeal)
end
if self.emote and self.emote.npc then if self.emote and self.emote.npc then
local fx = self.emote.npc.px - cam.x + 8 local fx = self.emote.npc.px - cam.x + 8
local fy = self.emote.npc.py - cam.y + 16 local fy = self.emote.npc.py - cam.y + 16
+18
View File
@@ -344,6 +344,24 @@ check(lastSource().queueable and lastSource().playing,
"a chip song still plays after a file song") "a chip song still plays after a file song")
check(not body.playing, "the outgoing file song was stopped") check(not body.playing, "the outgoing file song was stopped")
-- playOnce must survive the threaded "empty QueueableSource" window:
-- Source:isPlaying is false until the first worker buffer lands, and that
-- gap must not look like the jingle already ended (Poké Center heal).
data = reset(fixtureData())
Music.playMap(data, "PALLET_TOWN", false, false)
check(Music.playOnce(data, "Music_Chip"), "playOnce starts a chip jingle")
local jingle = lastSource()
local clearAwait = ChipAudio._simulateAwaitingFirstBufferForTest()
check(clearAwait ~= nil, "test can force the awaiting-first-buffer window")
check(Music.oneShotPlaying(),
"oneShotPlaying stays true while the first buffer is still in flight")
Music.update(data)
check(jingle == lastSource() and jingle.queueable,
"pendingRestore does not swap the map theme over a pending chip jingle")
check(ChipAudio.awaitingFirstBuffer(),
"awaitingFirstBuffer reports the forced window")
clearAwait()
-- sfx shape dispatch -- sfx shape dispatch
check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing") check(Sound.play(data, "Beep") == nil, "Sound.play returns nothing")
check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source") check(lastSource().file == "assets/beep.wav", "a bare string sfx is a static source")
+41 -3
View File
@@ -22,6 +22,7 @@ local StateStack = require("src.core.StateStack")
local ManagerState = require("src.mods.ManagerState") local ManagerState = require("src.mods.ManagerState")
local ModUI = require("src.ui.ModUI") local ModUI = require("src.ui.ModUI")
local Theme = require("src.ui.Theme") local Theme = require("src.ui.Theme")
local FrameCap = require("src.core.FrameCap")
local savedEvents, savedHooks, savedErrors = local savedEvents, savedHooks, savedErrors =
Runtime.events, Runtime.hooks, Runtime.errors Runtime.events, Runtime.hooks, Runtime.errors
@@ -205,7 +206,7 @@ end
local om = OptionsMenu.new(optGame()) local om = OptionsMenu.new(optGame())
local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset", local WANT_IDS = { "textSpeed", "animations", "battleStyle", "ruleset",
"musicVol", "sfxVol", "musicFilter", "colors", "tilt", "musicVol", "sfxVol", "musicFilter", "colors", "tilt",
"gbcfx", "videoMode", "speed", "mods", "controls" } "gbcfx", "videoMode", "fpsCap", "speed", "mods", "controls" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id) check(om.rows[i].id == id, "options row order: " .. id)
@@ -235,10 +236,47 @@ check(om.game.save.options.musicVol == 6, "music volume steps down")
for _ = 1, 10 do om.rows[5].step(om.game, -1) end for _ = 1, 10 do om.rows[5].step(om.game, -1) end
check(om.game.save.options.musicVol == 0, "music volume clamps at 0") check(om.game.save.options.musicVol == 0, "music volume clamps at 0")
-- the MAX FPS row cycles the render-cap steps and shows the value plain
om.game.save.options.fpsCap = nil
check(om.rows[12].value(om.game) == "60",
"MAX FPS row defaults to 60 with no saved cap")
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 75, "MAX FPS steps up from 60 to 75")
check(om.rows[12].value(om.game) == "75", "the MAX FPS row renders the cap")
om.game.save.options.fpsCap = 160
om.rows[12].step(om.game, 1)
check(om.game.save.options.fpsCap == 30, "MAX FPS wraps past the ceiling to 30")
om.rows[12].step(om.game, -1)
check(om.game.save.options.fpsCap == 160, "MAX FPS wraps back down to the ceiling")
-- ------- FrameCap normalize / cycle (issue #88)
check(FrameCap.normalize(nil) == 60, "FrameCap defaults nil to 60")
check(FrameCap.normalize("junk") == 60, "FrameCap defaults garbage to 60")
check(FrameCap.normalize(60) == 60, "FrameCap keeps an exact step")
check(FrameCap.normalize(58) == 60, "FrameCap snaps 58 to the nearest step 60")
check(FrameCap.normalize(72) == 75, "FrameCap snaps 72 to the nearest step 75")
check(FrameCap.normalize(0) == 30, "FrameCap clamps below the floor to 30")
check(FrameCap.normalize(9999) == 160, "FrameCap clamps above the ceiling to 160")
check(FrameCap.normalize(30) == 30 and FrameCap.normalize(160) == 160,
"FrameCap keeps the exact floor and ceiling")
check(FrameCap.label(nil) == "60" and FrameCap.label(144) == "144",
"FrameCap.label renders the normalized cap as plain text")
check(FrameCap.cycle(60, 1) == 75, "FrameCap cycles 60 up to 75")
check(FrameCap.cycle(60, -1) == 50, "FrameCap cycles 60 down to 50")
check(FrameCap.cycle(160, 1) == 30, "FrameCap cycle wraps the ceiling to the floor")
check(FrameCap.cycle(30, -1) == 160, "FrameCap cycle wraps the floor to the ceiling")
check(FrameCap.cycle(nil, 1) == 75,
"FrameCap cycle normalizes a nil cap (60) before stepping")
-- apply drives the live value the run loop paces to; never touches love.timer
FrameCap.apply(144)
check(FrameCap.current == 144, "FrameCap.apply stores the live cap")
FrameCap.applyOptions({})
check(FrameCap.current == 60, "FrameCap.applyOptions defaults a missing key to 60")
-- the MODS row is the manager's discoverable home -- the MODS row is the manager's discoverable home
local mgGame = optGame() local mgGame = optGame()
om = OptionsMenu.new(mgGame) om = OptionsMenu.new(mgGame)
om.rows[13].activate(mgGame) om.rows[14].activate(mgGame)
check(getmetatable(mgGame.stack:top()) == ManagerState, check(getmetatable(mgGame.stack:top()) == ManagerState,
"the MODS row opens the manager") "the MODS row opens the manager")
check(mgGame.stack:top().screenId == "ManagerState", check(mgGame.stack:top().screenId == "ManagerState",
@@ -248,7 +286,7 @@ check(mgGame.stack:top().screenId == "ManagerState",
local BindingsMenu = require("src.ui.BindingsMenu") local BindingsMenu = require("src.ui.BindingsMenu")
local cbGame = optGame() local cbGame = optGame()
om = OptionsMenu.new(cbGame) om = OptionsMenu.new(cbGame)
om.rows[14].activate(cbGame) om.rows[15].activate(cbGame)
local bm = cbGame.stack:top() local bm = cbGame.stack:top()
check(getmetatable(bm) == BindingsMenu, check(getmetatable(bm) == BindingsMenu,
"the CONTROLS row opens the rebind list") "the CONTROLS row opens the rebind list")
+17 -7
View File
@@ -2091,6 +2091,7 @@ do
local GBCFX = require("src.render.GBCFX") local GBCFX = require("src.render.GBCFX")
local GameSpeed = require("src.core.GameSpeed") local GameSpeed = require("src.core.GameSpeed")
local VideoMode = require("src.core.VideoMode") local VideoMode = require("src.core.VideoMode")
local FrameCap = require("src.core.FrameCap")
local SD = require("src.core.SaveData") local SD = require("src.core.SaveData")
-- Isolate from earlier save/options writes in this suite -- Isolate from earlier save/options writes in this suite
SD.saveOptions(SD.defaultOptions()) SD.saveOptions(SD.defaultOptions())
@@ -2155,7 +2156,16 @@ do
eq(og.save.options.videoMode, "windowed", eq(og.save.options.videoMode, "windowed",
"VIDEO MODE wraps back to WINDOWED") "VIDEO MODE wraps back to WINDOWED")
press("down") press("down")
eq(om.index, 12, "cursor reaches GAME SPEED") eq(om.index, 12, "cursor reaches MAX FPS")
press("a")
eq(og.save.options.fpsCap, 75, "A cycles MAX FPS up from 60 to 75")
eq(FrameCap.current, 75, "the live render cap tracks the MAX FPS option")
-- Driven by the step list rather than a literal press count, like GAME
-- SPEED below: a full loop of #STEPS presses returns to the 60 default.
for _ = 1, #FrameCap.STEPS - 1 do press("a") end
eq(og.save.options.fpsCap, 60, "MAX FPS wraps back to 60")
press("down")
eq(om.index, 13, "cursor reaches GAME SPEED")
press("a") press("a")
eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X") eq(og.save.options.speed, 2, "A cycles GAME SPEED to 2X")
-- Driven by the level list rather than a literal press count: adding a -- Driven by the level list rather than a literal press count: adding a
@@ -2164,19 +2174,19 @@ do
for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end for _ = 1, #GameSpeed.LEVELS - 1 do press("a") end
eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL") eq(og.save.options.speed, 1, "GAME SPEED wraps back to NORMAL")
press("down") press("down")
eq(om.index, 13, "cursor reaches MODS") eq(om.index, 14, "cursor reaches MODS")
press("down") press("down")
eq(om.index, 14, "cursor reaches CONTROLS") eq(om.index, 15, "cursor reaches CONTROLS")
press("down") press("down")
eq(om.index, 15, "CANCEL stays the fixed final row") eq(om.index, 16, "CANCEL stays the fixed final row")
eq(om.scroll, 10, "CANCEL keeps the last option boxes on screen") eq(om.scroll, 11, "CANCEL keeps the last option boxes on screen")
om:draw() -- smoke: scrolled layout draws under the headless stub om:draw() -- smoke: scrolled layout draws under the headless stub
press("a") press("a")
check(popped, "A on CANCEL closes the options menu") check(popped, "A on CANCEL closes the options menu")
local om2 = OptionsMenu.new(og) local om2 = OptionsMenu.new(og)
OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {} OInput.pressed = { up = true }; om2:update(1 / 60); OInput.pressed = {}
eq(om2.index, 15, "up from the top wraps to CANCEL") eq(om2.index, 16, "up from the top wraps to CANCEL")
eq(om2.scroll, 10, "wrapping to CANCEL scrolls to the tail") eq(om2.scroll, 11, "wrapping to CANCEL scrolls to the tail")
-- headless-safe: no love.audio, setters only update internal state -- headless-safe: no love.audio, setters only update internal state
require("src.core.Music").applyOptions(og.save.options) require("src.core.Music").applyOptions(og.save.options)
require("src.core.Sound").applyOptions(og.save.options) require("src.core.Sound").applyOptions(og.save.options)