mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 08:21:02 +02:00
3c71afb9fa
GameSpeed is a single fast-forward multiplier applied uniformly to the whole logic clock -- overworld walking, menu navigation and battle turns all scale together. A player who wants 4X battles but 1X overworld (so a cutscene or NPC dialogue doesn't blur past) has no way to get both. Splits save.options.speed into speedOverworld/speedBattle/speedMenu, each cycling independently, with an automatic migration so an existing save's speed choice carries over. Game.speedCategoryInStack resolves which category is active by walking the state stack (the same idiom wideBattleInStack/fillScaleInStack already use), so a menu opened mid- battle inherits battle speed rather than resetting to whatever "menu" defaults to. Adds a new core.logic_speed hook so a mod can read or override the resolved multiplier for the current frame regardless of which category produced it, sitting after the link-play and run-argument overrides so neither is a seam a mod can defeat. RFC 0007 status: Proposed.
475 lines
17 KiB
Lua
475 lines
17 KiB
Lua
-- Launcher settings rows: the gear menu's model layer.
|
|
--
|
|
-- The in-game OPTION menu (src/ui/OptionsMenu.lua) mutates game.save.options
|
|
-- and live-applies each change to the running engine. The launcher has no
|
|
-- running engine, so this builds the same ladders against the persisted
|
|
-- options.lua table (src/core/SaveData.loadOptions/saveOptions) and lets the
|
|
-- next boot's applyOptions pick the values up. Every ladder mirrors
|
|
-- OptionsMenu's semantics and stored values; when editing one, keep the two
|
|
-- in sync. ZOOM is deliberately absent: its range depends on the live
|
|
-- renderer's fit scale (Renderer:fitScale), which does not exist here.
|
|
--
|
|
-- Rows are the same descriptor idiom OptionRows draws in game:
|
|
-- { label, value = fn() -> string, step = fn(dir) -> changed,
|
|
-- editText = { maxLen } } -- editText marks a free-text row; the view
|
|
-- opens its prompt and commits via setText.
|
|
--
|
|
-- Mod rows come from each enabled mod's options_schema (the manager's auto-UI
|
|
-- contract, src/mods/ManagerState.lua buildOptionRows) and persist in
|
|
-- options.modOptions[modId][key], the exact table the loader reads on boot.
|
|
|
|
local Strings = require("src.core.Strings")
|
|
local SaveData = require("src.core.SaveData")
|
|
|
|
local LauncherSettings = {}
|
|
|
|
local function wrapIndex(i, n)
|
|
i = i % n
|
|
if i < 0 then i = i + n end
|
|
return i
|
|
end
|
|
|
|
local function volLabel(v)
|
|
v = v or 7
|
|
return v == 0 and "OFF" or tostring(v)
|
|
end
|
|
|
|
local function stepVolume(v, dir)
|
|
return math.max(0, math.min(7, (v or 7) + dir))
|
|
end
|
|
|
|
-- Cycle a stored value through an ordered list of {stored, label} pairs.
|
|
local function ladder(opts, key, pairsList, default)
|
|
local function index()
|
|
local cur = opts[key]
|
|
if cur == nil then cur = default end
|
|
for i, p in ipairs(pairsList) do
|
|
if p[1] == cur then return i end
|
|
end
|
|
return 1
|
|
end
|
|
return function() return Strings(pairsList[index()][2]) end,
|
|
function(dir)
|
|
opts[key] = pairsList[wrapIndex(index() - 1 + (dir or 1), #pairsList) + 1][1]
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- TextSpeedOptionData delays with the original labels (OptionsMenu SPEEDS).
|
|
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
|
|
local FILTERS = { "OFF", "1X", "2X", "3X" }
|
|
|
|
-- The core rows. Helper modules are required lazily under pcall: they are
|
|
-- pure label/cycle tables, but the launcher must never die because a render
|
|
-- module grew a dependency on live game data.
|
|
local function coreRows(opts, hooks)
|
|
local rows = {}
|
|
local function add(label, value, step)
|
|
rows[#rows + 1] = { label = label, value = value, step = step }
|
|
end
|
|
|
|
add(Strings("TEXT SPEED"), ladder(opts, "textSpeed", SPEEDS, 3))
|
|
add(Strings("BATTLE ANIMATION"),
|
|
ladder(opts, "animations",
|
|
{ { true, "ON" }, { false, "OFF" } }, true))
|
|
add(Strings("BATTLE STYLE"),
|
|
ladder(opts, "battleStyle",
|
|
{ { "shift", "SHIFT" }, { "set", "SET" } }, "shift"))
|
|
add(Strings("BATTLE LAYOUT"),
|
|
ladder(opts, "battleLayout",
|
|
{ { "og", "OG" }, { "wide", "WIDE" } }, "og"))
|
|
add(Strings("BATTLE SIZE"),
|
|
ladder(opts, "battleFit",
|
|
{ { "fixed", "FIXED" }, { "fill", "FILL" } }, "fixed"))
|
|
add(Strings("BATTLE BG"),
|
|
ladder(opts, "battleBg",
|
|
{ { "white", "WHITE" }, { "black", "BLACK" }, { "world", "WORLD" } },
|
|
"white"))
|
|
add(Strings("UI LAYOUT"),
|
|
ladder(opts, "uiLayout",
|
|
{ { "centered", "CENTERED" }, { "dynamic", "DYNAMIC" } }, "centered"))
|
|
|
|
add(Strings("MUSIC VOL"),
|
|
function() return volLabel(opts.musicVol) end,
|
|
function(dir) opts.musicVol = stepVolume(opts.musicVol, dir); return true end)
|
|
add(Strings("SFX VOL"),
|
|
function() return volLabel(opts.sfxVol) end,
|
|
function(dir) opts.sfxVol = stepVolume(opts.sfxVol, dir); return true end)
|
|
add(Strings("MUSIC FILTER"),
|
|
function() return FILTERS[(opts.musicFilter or 0) + 1] end,
|
|
function(dir)
|
|
opts.musicFilter = ((opts.musicFilter or 0) + dir) % #FILTERS
|
|
return true
|
|
end)
|
|
|
|
local okPerf, Performance = pcall(require, "src.core.Performance")
|
|
if okPerf then
|
|
add(Strings("PERFORMANCE"),
|
|
function() return Strings(Performance.label(opts.performance)) end,
|
|
function(dir)
|
|
opts.performance = Performance.cycle(opts.performance, dir)
|
|
return true
|
|
end)
|
|
end
|
|
|
|
local okPal, PaletteFX = pcall(require, "src.render.PaletteFX")
|
|
if okPal then
|
|
add(Strings("COLORS"),
|
|
function() return PaletteFX.modeLabel(opts.colors or "gbc") end,
|
|
function(dir)
|
|
local cur, idx = opts.colors or "gbc", 1
|
|
for i, m in ipairs(PaletteFX.MODES) do
|
|
if m == cur then idx = i break end
|
|
end
|
|
opts.colors = PaletteFX.MODES[wrapIndex(idx - 1 + dir, #PaletteFX.MODES) + 1]
|
|
return true
|
|
end)
|
|
end
|
|
|
|
local okTilt, Tilt = pcall(require, "src.render.Tilt")
|
|
if okTilt then
|
|
add(Strings("TILT"),
|
|
function() return Tilt.levelLabel(opts.tilt or 0) end,
|
|
function(dir)
|
|
opts.tilt = wrapIndex((opts.tilt or 0) + dir, 4)
|
|
return true
|
|
end)
|
|
end
|
|
|
|
-- issue #136: GBC FX soft-bricks the mobile present shader; same gate as
|
|
-- the in-game row.
|
|
local okFx, GBCFX = pcall(require, "src.render.GBCFX")
|
|
if okFx and GBCFX.isSupported() then
|
|
add(Strings("GBC FX"),
|
|
function() return GBCFX.levelLabel(opts.gbcfx or 0) end,
|
|
function(dir)
|
|
opts.gbcfx = wrapIndex((opts.gbcfx or 0) + dir, 5)
|
|
return true
|
|
end)
|
|
end
|
|
|
|
local okTile, TileRenderer = pcall(require, "src.render.TileRenderer")
|
|
if okTile and TileRenderer.VOID_FILLS then
|
|
add(Strings("VOID FILL"),
|
|
function() return TileRenderer.voidFillLabel(opts.voidFill) end,
|
|
function(dir)
|
|
local modes = TileRenderer.VOID_FILLS
|
|
local cur, idx = opts.voidFill or "trees", 1
|
|
for i, m in ipairs(modes) do
|
|
if m == cur then idx = i break end
|
|
end
|
|
opts.voidFill = modes[wrapIndex(idx - 1 + dir, #modes) + 1]
|
|
return true
|
|
end)
|
|
end
|
|
|
|
local okVm, VideoMode = pcall(require, "src.core.VideoMode")
|
|
if okVm then
|
|
add(Strings("VIDEO MODE"),
|
|
function() return VideoMode.modeLabel(opts.videoMode) end,
|
|
function(dir)
|
|
opts.videoMode = VideoMode.cycle(opts.videoMode, dir)
|
|
return true
|
|
end)
|
|
end
|
|
|
|
-- ORIENTATION (#592): Android only -- the lock rides SDL's orientation
|
|
-- hint, which iOS reads only at startup (the Info.plist governs there) and
|
|
-- desktop ignores. Unlike the other launcher rows this one live-applies:
|
|
-- the window exists here too, and rotating under the player's finger is
|
|
-- the only feedback that reads.
|
|
do
|
|
local osName = love.system and love.system.getOS and love.system.getOS()
|
|
local okOr, Orientation = pcall(require, "src.core.Orientation")
|
|
if okOr and osName == "Android" then
|
|
add(Strings("ORIENTATION"),
|
|
function() return Strings(Orientation.modeLabel(opts.orientation)) end,
|
|
function(dir)
|
|
opts.orientation = Orientation.cycle(opts.orientation, dir)
|
|
Orientation.apply(opts.orientation)
|
|
return true
|
|
end)
|
|
end
|
|
end
|
|
|
|
local okFr, FaithfulRes = pcall(require, "src.core.FaithfulRes")
|
|
if okFr then
|
|
add(Strings("FAITHFUL RATIO"),
|
|
function() return FaithfulRes.label(opts.faithfulRes) end,
|
|
function(dir)
|
|
opts.faithfulRes = FaithfulRes.cycle(opts.faithfulRes, dir)
|
|
return true
|
|
end)
|
|
end
|
|
|
|
local okCap, FrameCap = pcall(require, "src.core.FrameCap")
|
|
if okCap then
|
|
add(Strings("MAX FPS"),
|
|
function() return FrameCap.label(opts.fpsCap) end,
|
|
function(dir)
|
|
opts.fpsCap = FrameCap.cycle(opts.fpsCap, dir)
|
|
return true
|
|
end)
|
|
end
|
|
|
|
local okSpd, GameSpeed = pcall(require, "src.core.GameSpeed")
|
|
if okSpd then
|
|
-- Per-category (RFC 0007): overworld/battle/menu each cycle their own
|
|
-- multiplier, mirroring OptionsMenu.lua's three rows.
|
|
add(Strings("OVERWORLD SPEED"),
|
|
function() return GameSpeed.levelLabel(opts.speedOverworld) end,
|
|
function(dir)
|
|
opts.speedOverworld = GameSpeed.cycle(opts.speedOverworld, dir)
|
|
return true
|
|
end)
|
|
add(Strings("BATTLE SPEED"),
|
|
function() return GameSpeed.levelLabel(opts.speedBattle) end,
|
|
function(dir)
|
|
opts.speedBattle = GameSpeed.cycle(opts.speedBattle, dir)
|
|
return true
|
|
end)
|
|
add(Strings("MENU SPEED"),
|
|
function() return GameSpeed.levelLabel(opts.speedMenu) end,
|
|
function(dir)
|
|
opts.speedMenu = GameSpeed.cycle(opts.speedMenu, dir)
|
|
return true
|
|
end)
|
|
end
|
|
|
|
-- TOUCH PAD only where the overlay can appear, mirroring OptionsMenu's
|
|
-- gate (mobile, or desktop forced by POKEPORT_TOUCH=1).
|
|
do
|
|
local env = os.getenv("POKEPORT_TOUCH")
|
|
local osName = love.system and love.system.getOS and love.system.getOS()
|
|
local show = env == "1"
|
|
or (env ~= "0" and (osName == "Android" or osName == "iOS"))
|
|
if show then
|
|
add(Strings("TOUCH PAD"),
|
|
function()
|
|
local tc = opts.touchControls
|
|
local on = not (type(tc) == "table" and tc.enabled == false)
|
|
return on and Strings("ON") or Strings("OFF")
|
|
end,
|
|
function()
|
|
local tc = type(opts.touchControls) == "table" and opts.touchControls or {}
|
|
tc.enabled = tc.enabled == false
|
|
opts.touchControls = tc
|
|
return true
|
|
end)
|
|
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
|
|
-- the row buzzes once at the level being selected.
|
|
local okTC, TC = pcall(require, "src.core.TouchControls")
|
|
if okTC then
|
|
add(Strings("VIBRATION"),
|
|
function() return Strings(TC.hapticLabel(opts.haptics)) end,
|
|
function(dir)
|
|
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
|
|
TC.buzz(opts.haptics)
|
|
return true
|
|
end)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- TOUCH CONTROLS, the on-screen pad's layout editor. It used to be a
|
|
-- button on the game panel, once per game -- but the overlay layout is
|
|
-- global (options.touchControls.layouts), so three tabs offered three
|
|
-- buttons that edited the same thing while crowding the column that has to
|
|
-- hold Play. It belongs with the other control rows, behind the gear.
|
|
-- The host owns the editor screen, so the row only fires when a hook was
|
|
-- supplied (the standalone save editor opens this model with none).
|
|
if hooks and hooks.editTouchControls then
|
|
rows[#rows + 1] = {
|
|
label = Strings("TOUCH CONTROLS"),
|
|
actionLabel = Strings("Edit"),
|
|
action = function()
|
|
hooks.editTouchControls()
|
|
-- The editor replaces the whole screen: nothing left to persist here
|
|
-- beyond what the caller already saved on the way out.
|
|
return false
|
|
end,
|
|
}
|
|
end
|
|
|
|
-- RESET REBINDS, directly under the touch-pad row. Rebinds are additive
|
|
-- (src/core/Input.lua:applyBindings layers options.bindings over the
|
|
-- defaults rather than replacing them), so a player who has bound
|
|
-- themselves into a corner has no in-game way back -- there is no "unbind"
|
|
-- gesture. Clearing the table restores the stock keyboard and pad layout
|
|
-- on the next Input:applyBindings, which the game does on its next start.
|
|
-- The dragged touch-overlay layout goes with it: it is the same class of
|
|
-- customisation and the same class of getting stuck.
|
|
rows[#rows + 1] = {
|
|
label = Strings("RESET REBINDS"),
|
|
actionLabel = Strings("Reset"),
|
|
danger = true,
|
|
action = function()
|
|
opts.bindings = nil
|
|
local tc = opts.touchControls
|
|
if type(tc) == "table" then tc.layouts = nil end
|
|
return true
|
|
end,
|
|
}
|
|
|
|
return rows
|
|
end
|
|
|
|
-- ------- per-mod options (the manager's options_schema auto-UI contract)
|
|
|
|
local OPTION_TYPES = { toggle = true, choice = true, number = true, text = true }
|
|
|
|
-- Enabled mods with a loadable options_schema, discovered the same way
|
|
-- LauncherMods discovers manifests (mods/ one level deep; the launcher's
|
|
-- readiness check has already mounted a portable install's game folder).
|
|
local function discoverModSchemas(opts)
|
|
local fs = love and love.filesystem
|
|
local out = {}
|
|
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
|
|
if not fs.getInfo("mods") then return out end
|
|
local okJson, Json = pcall(require, "src.link.Json")
|
|
local okMan, Manifest = pcall(require, "src.mods.Manifest")
|
|
if not (okJson and okMan) then return out end
|
|
local enabledFlags = opts.mods or {}
|
|
local seen = {}
|
|
for _, name in ipairs(fs.getDirectoryItems("mods")) do
|
|
local path = "mods/" .. name
|
|
local info = fs.getInfo(path)
|
|
if info and (info.type == "directory" or info.type == "symlink") then
|
|
local raw = fs.read(path .. "/manifest.json")
|
|
local data = raw and select(1, Json.decode(raw))
|
|
local okV, m = false, nil
|
|
if data then okV, m = pcall(Manifest.validate, data, path) end
|
|
if okV and m and not seen[m.id] and m.options_schema then
|
|
seen[m.id] = true
|
|
-- deriveList's enable resolution: a missing entry means enabled,
|
|
-- except experimental mods, which stay off until opted in.
|
|
local flag = enabledFlags[m.id]
|
|
local enabled = flag == true or (flag == nil and not m.experimental)
|
|
if enabled then
|
|
local chunk = fs.load(path .. "/" .. m.options_schema)
|
|
if chunk then
|
|
local okR, schema = pcall(chunk)
|
|
if okR and type(schema) == "table" then
|
|
out[#out + 1] = { id = m.id, name = m.name or m.id, schema = schema }
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
table.sort(out, function(a, b) return a.id < b.id end)
|
|
return out
|
|
end
|
|
|
|
-- Rows for one mod's schema against options.modOptions (ManagerState's
|
|
-- persistence shape, so the game sees launcher edits on its next boot).
|
|
local function modRows(opts, mod)
|
|
local rows = {}
|
|
local modId = mod.id
|
|
local function stored()
|
|
local t = opts.modOptions
|
|
return t and t[modId] or nil
|
|
end
|
|
local function get(row)
|
|
local s = stored()
|
|
local v = s and s[row.key]
|
|
if v == nil then v = row.default end
|
|
return v
|
|
end
|
|
local function set(key, value)
|
|
opts.modOptions = opts.modOptions or {}
|
|
opts.modOptions[modId] = opts.modOptions[modId] or {}
|
|
opts.modOptions[modId][key] = value
|
|
end
|
|
|
|
for _, row in ipairs(mod.schema) do
|
|
if type(row) ~= "table" or type(row.key) ~= "string" or row.key == ""
|
|
or not OPTION_TYPES[row.type] then
|
|
-- malformed rows are skipped silently here; the in-game manager is
|
|
-- where schema errors are reported to the author
|
|
elseif row.type == "toggle" then
|
|
rows[#rows + 1] = { label = row.label or row.key,
|
|
value = function() return get(row) and Strings("ON") or Strings("OFF") end,
|
|
step = function()
|
|
set(row.key, not get(row))
|
|
return true
|
|
end }
|
|
elseif row.type == "choice" then
|
|
rows[#rows + 1] = { label = row.label or row.key,
|
|
value = function()
|
|
local cur = get(row)
|
|
for _, choice in ipairs(row.choices or {}) do
|
|
if choice[2] == cur then return tostring(choice[1]) end
|
|
end
|
|
local first = (row.choices or {})[1]
|
|
return first and tostring(first[1]) or "----"
|
|
end,
|
|
step = function(dir)
|
|
local choices = row.choices or {}
|
|
if #choices == 0 then return false end
|
|
local cur, index = get(row), 1
|
|
for i, choice in ipairs(choices) do
|
|
if choice[2] == cur then index = i break end
|
|
end
|
|
set(row.key, choices[wrapIndex(index - 1 + dir, #choices) + 1][2])
|
|
return true
|
|
end }
|
|
elseif row.type == "number" then
|
|
rows[#rows + 1] = { label = row.label or row.key,
|
|
value = function() return tostring(get(row) or 0) end,
|
|
step = function(dir)
|
|
local v = (tonumber(get(row)) or 0) + dir * (row.step or 1)
|
|
if row.min then v = math.max(row.min, v) end
|
|
if row.max then v = math.min(row.max, v) end
|
|
set(row.key, v)
|
|
return true
|
|
end }
|
|
elseif row.type == "text" then
|
|
rows[#rows + 1] = { label = row.label or row.key,
|
|
value = function() return tostring(get(row) or "") end,
|
|
editText = { maxLen = row.maxLen or 7 },
|
|
setText = function(text) set(row.key, text) end }
|
|
end
|
|
end
|
|
if #rows > 0 then
|
|
rows[#rows + 1] = { label = Strings("RESET DEFAULTS"),
|
|
value = function() return "" end,
|
|
step = function()
|
|
for _, row in ipairs(mod.schema) do
|
|
if type(row) == "table" and type(row.key) == "string"
|
|
and OPTION_TYPES[row.type] then
|
|
set(row.key, row.default)
|
|
end
|
|
end
|
|
return true
|
|
end }
|
|
end
|
|
return rows
|
|
end
|
|
|
|
-- Build the whole settings model: one options table (edited in place),
|
|
-- sections of rows, and a save() that persists it. The caller keeps the
|
|
-- model for as long as the panel is open; nothing else in the launcher
|
|
-- writes options while a modal covers it, so the cached table stays true.
|
|
-- `hooks` carries the host actions a row cannot perform itself:
|
|
-- editTouchControls() -- hand the screen to the touch-overlay editor
|
|
function LauncherSettings.open(hooks)
|
|
local opts = SaveData.loadOptions()
|
|
local sections = {
|
|
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
|
|
}
|
|
for _, mod in ipairs(discoverModSchemas(opts)) do
|
|
local rows = modRows(opts, mod)
|
|
if #rows > 0 then
|
|
sections[#sections + 1] = { title = mod.name, rows = rows }
|
|
end
|
|
end
|
|
return {
|
|
opts = opts,
|
|
sections = sections,
|
|
save = function() SaveData.saveOptions(opts) end,
|
|
}
|
|
end
|
|
|
|
return LauncherSettings
|