mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-26 21:35:42 -04:00
899 lines
35 KiB
Lua
899 lines
35 KiB
Lua
-- Options: text speed, battle animation on/off, battle style SHIFT/SET
|
|
-- (engine/menus/main_menu.asm DisplayOptionMenu), the battle ruleset
|
|
-- (cycles the merged rulesets registry; gen1_faithful keeps the original
|
|
-- quirks), plus the port's audio rows and display rows: music/SFX
|
|
-- volume (0-7), PIKACHU VOL (0-7, Yellow only: trims the PCM voice clips
|
|
-- under SFX VOL), music low-pass filter (OFF/1X/2X/3X), COLORS / TILT /
|
|
-- SHADER FX / ZOOM / VOID FILL / VIDEO MODE, and the MODS row that opens
|
|
-- the mod manager.
|
|
-- Rows are descriptors fed through the ui.options.rows hook, so mods can
|
|
-- add their own; CANCEL is appended after the hook and stays fixed on the
|
|
-- bottom line like pokered's.
|
|
|
|
local PaletteFX = require("src.render.PaletteFX")
|
|
local Palette = require("src.render.Palette")
|
|
local Pipelines = require("src.render.Pipelines")
|
|
local Tilt = require("src.render.Tilt")
|
|
local ShaderFX = require("src.render.ShaderFX")
|
|
local Zoom = require("src.render.Zoom")
|
|
local Letterbox = require("src.render.Letterbox")
|
|
local TileRenderer = require("src.render.TileRenderer")
|
|
local GameSpeed = require("src.core.GameSpeed")
|
|
local GameVersion = require("src.core.GameVersion")
|
|
local VideoMode = require("src.core.VideoMode")
|
|
local Orientation = require("src.core.Orientation")
|
|
local FaithfulRes = require("src.core.FaithfulRes")
|
|
local ScreenPosition = require("src.core.ScreenPosition")
|
|
local FrameCap = require("src.core.FrameCap")
|
|
local VSync = require("src.core.VSync")
|
|
local LogicClock = require("src.core.LogicClock")
|
|
local Performance = require("src.core.Performance")
|
|
local Logger = require("src.core.Logger")
|
|
local Runtime = require("src.mods.Runtime")
|
|
local OptionRows = require("src.ui.OptionRows")
|
|
local Renderer = require("src.render.Renderer")
|
|
local Strings = require("src.core.Strings")
|
|
|
|
local OptionsMenu = {}
|
|
OptionsMenu.__index = OptionsMenu
|
|
OptionsMenu.isOpaque = true
|
|
|
|
-- Shared by the three GameSpeed rows below (overworld/battle/menu): mirrors
|
|
-- src/ui/gen2/OptionsMenu.lua's own speed row so the translated "NORMAL"/
|
|
-- "%dX" catalog keys apply on both generations' options screens.
|
|
local function gameSpeedLabel(v)
|
|
local speed = tonumber(v) or GameSpeed.DEFAULT
|
|
if speed == 1 then return Strings("NORMAL") end
|
|
return Strings("%dX", speed)
|
|
end
|
|
|
|
-- Opaque full-screen menu: own MEWMON so opening OPTION from the title
|
|
-- (or over the overworld) does not inherit TitleState's LOGO1 band -- that
|
|
-- zone covers UI rows 8-9, which is the third options box label line
|
|
-- (pink "MODS" strip when Blue's ROM LOGO1 white is {255,239,255}).
|
|
function OptionsMenu:sgbPalettes(game)
|
|
return PaletteFX.wholeNamed(game.data, "MEWMON")
|
|
end
|
|
|
|
-- TextSpeedOptionData frame delays with the original labels
|
|
local SPEEDS = { { 1, "FAST" }, { 3, "MEDIUM" }, { 5, "SLOW" } }
|
|
-- no-loader fallback for the ruleset row, same pair BattleState keeps
|
|
local Rulesets = {
|
|
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
|
|
modern_clean = require("src.battle.rulesets.modern_clean"),
|
|
}
|
|
local FILTERS = { "OFF", "1X", "2X", "3X" }
|
|
local DATE_FORMATS = {
|
|
{ "device", "DEVICE" }, { "dmy", "DD-MM-YYYY" },
|
|
{ "mdy", "MM-DD-YYYY" }, { "ymd", "YYYY-MM-DD" },
|
|
}
|
|
local TIME_FORMATS = {
|
|
{ "device", "DEVICE" }, { "24h", "24 HOUR" }, { "12h", "12 HOUR" },
|
|
}
|
|
|
|
local function preferenceIndex(rows, value)
|
|
for index, row in ipairs(rows) do
|
|
if row[1] == value then return index end
|
|
end
|
|
return 1
|
|
end
|
|
|
|
local function preferenceStep(rows, value, direction)
|
|
local index = preferenceIndex(rows, value)
|
|
direction = direction and direction < 0 and -1 or 1
|
|
return rows[((index - 1 + direction) % #rows) + 1][1]
|
|
end
|
|
|
|
local function preferenceLabel(rows, value)
|
|
return rows[preferenceIndex(rows, value)][2]
|
|
end
|
|
|
|
local function speedIndex(game)
|
|
-- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions
|
|
local cur = game.save.options.textSpeed or 3
|
|
for i, s in ipairs(SPEEDS) do
|
|
if s[1] == cur then return i end
|
|
end
|
|
return 2 -- MEDIUM
|
|
end
|
|
|
|
-- the ruleset row cycles the sorted non-hidden ids of the merged
|
|
-- registry (07-battle-extensibility.md 4.6), so mod-registered
|
|
-- rulesets are selectable; hidden marks a total conversion's exclusions
|
|
local function rulesetIds(game)
|
|
local rulesets = game.data and game.data.rulesets or Rulesets
|
|
local ids = {}
|
|
for id, record in pairs(rulesets) do
|
|
if not record.hidden then ids[#ids + 1] = id end
|
|
end
|
|
table.sort(ids)
|
|
return ids
|
|
end
|
|
|
|
local function rulesetIndex(game, ids)
|
|
local constants = game.data and game.data.constants
|
|
local cur = game.save.options.ruleset
|
|
or (constants and constants.defaultRuleset) or "gen1_faithful"
|
|
for i, id in ipairs(ids) do
|
|
if id == cur then return i end
|
|
end
|
|
return 1
|
|
end
|
|
|
|
local function rulesetName(game)
|
|
local rulesets = game.data and game.data.rulesets or Rulesets
|
|
local ids = rulesetIds(game)
|
|
local id = ids[rulesetIndex(game, ids)] or game.save.options.ruleset
|
|
local record = id and rulesets[id]
|
|
return Strings(record and record.name or id or "----")
|
|
end
|
|
|
|
-- 0-7 volume level display (0 = OFF)
|
|
local function volLabel(v)
|
|
v = v or 7
|
|
return v == 0 and "OFF" or tostring(v)
|
|
end
|
|
|
|
-- volume rows clamp at the ends, like pokered's text-speed cursor
|
|
-- (.pressedLeftInTextSpeed stays at FAST rather than wrapping)
|
|
local function stepVolume(v, dir)
|
|
return math.max(0, math.min(7, (v or 7) + dir))
|
|
end
|
|
|
|
local function wrapIndex(i, n)
|
|
i = i % n
|
|
if i < 0 then i = i + n end
|
|
return i
|
|
end
|
|
|
|
local function sameRows(_, rows) return rows end
|
|
|
|
-- "OFF" plus every entry's display name, extension stripped -- a
|
|
-- Strings() lookup like RULESET's own record.name so a translation catalog
|
|
-- could rewrite "OFF" without needing to know about arbitrary preset
|
|
-- filenames.
|
|
local function shaderfxLabel(entry)
|
|
if not entry then return Strings("OFF") end
|
|
return Strings((entry.name:gsub("%.slangp$", "")))
|
|
end
|
|
|
|
-- Dual-shader slots: "main" backs the original SHADER
|
|
-- FX row (unchanged save key, unchanged default OFF), "secondary" backs the
|
|
-- new SHADER FX 2 row below it -- when both are set, ShaderFX.render() runs
|
|
-- main's chain into secondary's, same as stacking two RetroArch presets.
|
|
|
|
local function bgLocked(o)
|
|
return o.battleLayout == "wide" and o.battleFit == "fill"
|
|
and o.battleHud == "extended"
|
|
end
|
|
|
|
-- the vanilla rows as descriptors; each step body is the old per-index
|
|
-- ladder's, so the save.options mutations are unchanged
|
|
local function buildRows(game)
|
|
local rows = {
|
|
{ id = "textSpeed", label = Strings("TEXT SPEED"),
|
|
value = function(g) return Strings(SPEEDS[speedIndex(g)][2]) end,
|
|
step = function(g)
|
|
local i = speedIndex(g) % #SPEEDS + 1
|
|
g.save.options.textSpeed = SPEEDS[i][1]
|
|
return true
|
|
end },
|
|
{ id = "animations", label = Strings("BATTLE ANIMATION"),
|
|
value = function(g)
|
|
return g.save.options.animations == false and Strings("OFF") or Strings("ON")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
o.animations = o.animations == false and true or false
|
|
return true
|
|
end },
|
|
{ id = "battleStyle", label = Strings("BATTLE STYLE"),
|
|
value = function(g)
|
|
return g.save.options.battleStyle == "set" and Strings("SET") or Strings("SHIFT")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
o.battleStyle = o.battleStyle == "set" and "shift" or "set"
|
|
return true
|
|
end },
|
|
-- OG is the classic 160x144 battle screen; WIDE is the 304x144
|
|
-- widescreen composition (src/battle/WideBattle.lua)
|
|
{ id = "battleLayout", label = Strings("BATTLE LAYOUT"),
|
|
value = function(g)
|
|
return g.save.options.battleLayout == "wide" and Strings("WIDE") or Strings("OG")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
o.battleLayout = o.battleLayout == "wide" and "og" or "wide"
|
|
if o.battleLayout ~= "wide" then
|
|
o.battleHud = "standard"
|
|
end
|
|
return true
|
|
end },
|
|
-- FIXED keeps the classic integer-scaled letterbox -- a GB pixel is a
|
|
-- whole number of screen pixels and the battle is the same size at any
|
|
-- zoom. FILL scales the battle surface to the window so it fills
|
|
-- vertically; that needs a fractional scale, so pixels stop being evenly
|
|
-- sized. Battle only: the overworld is untouched either way.
|
|
{ id = "battleFit", label = Strings("BATTLE SIZE"),
|
|
value = function(g)
|
|
return g.save.options.battleFit == "fill" and Strings("FILL")
|
|
or Strings("FIXED")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
o.battleFit = o.battleFit == "fill" and "fixed" or "fill"
|
|
return true
|
|
end },
|
|
{ id = "battleHud", label = Strings("BATTLE HUD"),
|
|
value = function(g)
|
|
local o = g.save.options
|
|
return o.battleLayout == "wide" and o.battleHud == "extended"
|
|
and Strings("EXTENDED")
|
|
or Strings("STANDARD")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
-- The extended HUD is a widescreen-only composition. Keep OG locked
|
|
-- to the author's standard HUD even if an older save says otherwise.
|
|
if o.battleLayout ~= "wide" then
|
|
o.battleHud = "standard"
|
|
return false
|
|
end
|
|
o.battleHud = o.battleHud == "extended" and "standard" or "extended"
|
|
return true
|
|
end },
|
|
-- What sits behind and around the battle. WHITE is the classic paper
|
|
-- field; BLACK swaps it for black bars; WORLD leaves the frozen overworld
|
|
-- visible underneath, dimmed (the battle stops being opaque, so the map
|
|
-- shows through everywhere the battle does not paint).
|
|
{ id = "battleBg", label = Strings("BATTLE BG"),
|
|
value = function(g)
|
|
local o = g.save.options
|
|
if bgLocked(o) then return Strings("AUTO (FILL HUD)") end
|
|
local m = o.battleBg
|
|
if m == "black" then return Strings("BLACK") end
|
|
if m == "world" then return Strings("WORLD") end
|
|
return Strings("WHITE")
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
if bgLocked(o) then return false end
|
|
local order = { "white", "black", "world" }
|
|
local cur = 1
|
|
for i, m in ipairs(order) do if o.battleBg == m then cur = i break end end
|
|
o.battleBg = order[(cur - 1 + (dir or 1)) % #order + 1]
|
|
return true
|
|
end },
|
|
-- CENTERED is a fixed letterbox: elements stay inside the 160x144 canvas
|
|
-- and the UI does not follow the survey zoom, so nothing moves or resizes
|
|
-- under the player. The composition the port shipped with. DYNAMIC docks
|
|
-- the dialogue box to the window's bottom edge and the START menu to its
|
|
-- top right, and steps the UI down with the zoom -- easier to read zoomed
|
|
-- out, but it moves furniture the original never moved, so it is opt-in.
|
|
-- BATTLE SIZE is independent of this and works under either.
|
|
{ id = "uiLayout", label = Strings("UI LAYOUT"),
|
|
value = function(g)
|
|
return g.save.options.uiLayout == "dynamic" and Strings("DYNAMIC")
|
|
or Strings("CENTERED")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
o.uiLayout = o.uiLayout == "dynamic" and "centered" or "dynamic"
|
|
return true
|
|
end },
|
|
{ id = "ruleset", label = Strings("RULESET"),
|
|
value = function(g) return rulesetName(g) end,
|
|
step = function(g, dir)
|
|
local ids = rulesetIds(g)
|
|
if #ids == 0 then return false end
|
|
local i = rulesetIndex(g, ids)
|
|
g.save.options.ruleset = ids[wrapIndex(i - 1 + dir, #ids) + 1]
|
|
return true
|
|
end },
|
|
{ id = "musicVol", label = Strings("MUSIC VOL"),
|
|
value = function(g) return volLabel(g.save.options.musicVol) end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.musicVol = stepVolume(o.musicVol, dir)
|
|
require("src.core.Music").setVolumeLevel(o.musicVol)
|
|
return true
|
|
end },
|
|
{ id = "sfxVol", label = Strings("SFX VOL"),
|
|
value = function(g) return volLabel(g.save.options.sfxVol) end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.sfxVol = stepVolume(o.sfxVol, dir)
|
|
require("src.core.Sound").setVolumeLevel(o.sfxVol)
|
|
return true
|
|
end },
|
|
-- Yellow only (filtered out below on Red/Blue): trims Pikachu's voice
|
|
-- clips under the SFX level, since Yellow plays them constantly (every
|
|
-- follower interaction, the title screen, the dex) and they are mixed
|
|
-- much hotter than the chip cries.
|
|
{ id = "pikaVol", label = Strings("PIKACHU VOL"),
|
|
value = function(g) return volLabel(g.save.options.pikaVol) end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.pikaVol = stepVolume(o.pikaVol, dir)
|
|
require("src.core.Sound").setPikaVolumeLevel(o.pikaVol)
|
|
return true
|
|
end },
|
|
{ id = "musicFilter", label = Strings("MUSIC FILTER"),
|
|
value = function(g)
|
|
return FILTERS[(g.save.options.musicFilter or 0) + 1]
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.musicFilter = ((o.musicFilter or 0) + dir) % #FILTERS
|
|
require("src.core.Music").setFilterLevel(o.musicFilter)
|
|
return true
|
|
end },
|
|
-- Heads the port's display group: one tier that scales the heavy extras
|
|
-- (TILT / survey ZOOM) and the FPS ceiling for weaker devices.
|
|
-- AUTO picks a default from the hardware; every tier is overridable.
|
|
-- Re-applies live so the extras clamp (or, on a higher tier, restore to
|
|
-- the player's stored TILT / ZOOM) the moment the row changes.
|
|
{ id = "performance", label = Strings("PERFORMANCE"),
|
|
value = function(g)
|
|
return Strings(Performance.label(g.save.options.performance))
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.performance = Performance.cycle(o.performance, dir)
|
|
g:applyOptions(o)
|
|
return true
|
|
end },
|
|
{ id = "colors", label = Strings("COLORS"),
|
|
value = function(g)
|
|
local id = g.save.options.palette
|
|
if id and id ~= "" then
|
|
return Palette.label(id) or PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
|
end
|
|
return PaletteFX.modeLabel(g.save.options.colors or "gbc")
|
|
end,
|
|
activate = function(g)
|
|
require("src.ui.Screens").push(g, "PaletteScreen")
|
|
end },
|
|
{ id = "tilt", label = Strings("TILT"),
|
|
value = function(g) return Tilt.levelLabel(g.save.options.tilt or 0) end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.tilt = wrapIndex((o.tilt or 0) + dir, 4)
|
|
Tilt.setLevel(o.tilt)
|
|
-- tilt and a mod's world pipeline are two answers to the same
|
|
-- question; turning this on switches that off (Pipelines does the
|
|
-- same in the other direction)
|
|
if o.tilt > 0 then
|
|
for _, entry in ipairs(Pipelines.list()) do
|
|
if entry.def.drawWorld then Pipelines.setLevel(entry.id, 0) end
|
|
end
|
|
Pipelines.syncOptions(o)
|
|
end
|
|
return true
|
|
end },
|
|
-- The generalized slang-shader-preset feature: a picker over
|
|
-- ShaderFX.list()'s real drop-in presets -- replaced GBCFX.lua's fixed
|
|
-- level ladder entirely (GBCFX.lua removed). A pushes a real list
|
|
-- screen -- OFF plus one row per .slangp found -- the same
|
|
-- "activate, not step" shape CONTROLS/MODS already use, rather than
|
|
-- cycling in place on this row.
|
|
-- ShaderFXScreen.lua does the actual list/activate; this row only opens
|
|
-- it and shows what is currently active.
|
|
{ id = "uiLetterbox", label = Strings("UI LETTERBOX"),
|
|
value = function(g)
|
|
return Strings(Letterbox.label(g.save.options.uiLetterbox))
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.uiLetterbox = Letterbox.cycle(o.uiLetterbox, dir)
|
|
Letterbox.setMode(o.uiLetterbox)
|
|
return true
|
|
end },
|
|
{ id = "shaderfx", label = Strings("SHADER FX"),
|
|
value = function(g)
|
|
return shaderfxLabel(ShaderFX.activeEntry("main"))
|
|
end,
|
|
activate = function(g)
|
|
require("src.ui.Screens").push(g, "ShaderFXScreen", "main")
|
|
end },
|
|
-- The secondary slot: same picker screen, opened on "secondary" instead
|
|
-- -- its own row so both slots are visible/settable independently
|
|
-- without a submenu inside a submenu.
|
|
{ id = "shaderfx2", label = Strings("SHADER FX 2"),
|
|
value = function(g)
|
|
return shaderfxLabel(ShaderFX.activeEntry("secondary"))
|
|
end,
|
|
activate = function(g)
|
|
require("src.ui.Screens").push(g, "ShaderFXScreen", "secondary")
|
|
end },
|
|
{ id = "zoom", label = Strings("ZOOM"),
|
|
value = function(g)
|
|
local offset = math.floor(tonumber(g.save.options.zoom) or 0)
|
|
if offset == 0 then return Strings("FIT") end
|
|
return offset < 0 and Strings("OUT%d", -offset) or Strings("IN%d", offset)
|
|
end,
|
|
step = function(g, dir)
|
|
Zoom.nudgeOptions(g.save.options, dir, Renderer:fitScale())
|
|
return true
|
|
end },
|
|
{ id = "voidFill", label = Strings("VOID FILL"),
|
|
value = function(g)
|
|
return TileRenderer.voidFillLabel(g.save.options.voidFill)
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
local modes = TileRenderer.VOID_FILLS
|
|
local cur = o.voidFill or "trees"
|
|
local i = 1
|
|
for idx, m in ipairs(modes) do
|
|
if m == cur then i = idx; break end
|
|
end
|
|
o.voidFill = modes[wrapIndex(i - 1 + dir, #modes) + 1]
|
|
TileRenderer.setVoidFill(o.voidFill)
|
|
return true
|
|
end },
|
|
{ id = "videoMode", label = Strings("VIDEO MODE"),
|
|
value = function(g)
|
|
return VideoMode.modeLabel(g.save.options.videoMode)
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.videoMode = VideoMode.cycle(o.videoMode, dir)
|
|
VideoMode.apply(o.videoMode)
|
|
return true
|
|
end },
|
|
-- Android orientation lock (#592): AUTO / PORTRAIT / LANDSCAPE /
|
|
-- REVERSE LANDSCAPE, live-applied through SDL's orientation hint.
|
|
-- Filtered out below on everything that is not Android.
|
|
{ id = "orientation", label = Strings("ORIENTATION"),
|
|
value = function(g)
|
|
return Strings(Orientation.modeLabel(g.save.options.orientation))
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.orientation = Orientation.cycle(o.orientation, dir)
|
|
Orientation.apply(o.orientation)
|
|
return true
|
|
end },
|
|
-- Lock the window to an exact 160x144 multiple, so the surface IS the
|
|
-- Game Boy screen with no letterbox at all. Sits next to VIDEO MODE
|
|
-- because it overrides it: holding an exact size means dropping
|
|
-- fullscreen.
|
|
{ id = "faithfulRes", label = Strings("FAITHFUL RATIO"),
|
|
value = function(g)
|
|
return FaithfulRes.label(g.save.options.faithfulRes)
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.faithfulRes = FaithfulRes.cycle(o.faithfulRes, dir)
|
|
FaithfulRes.apply(o.faithfulRes)
|
|
return true
|
|
end },
|
|
{ id = "screenPos", label = Strings("SCREEN POS"),
|
|
value = function(g)
|
|
return Strings(ScreenPosition.label(g.save.options.screenPos))
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.screenPos = ScreenPosition.cycle(o.screenPos, dir)
|
|
ScreenPosition.setMode(o.screenPos)
|
|
return true
|
|
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 = Strings("MAX FPS"),
|
|
value = function(g)
|
|
local value = FrameCap.normalize(g.save.options.fpsCap)
|
|
if value == FrameCap.DISPLAY then
|
|
local label = FrameCap.label(value)
|
|
local hz = tonumber(label:match("^DISPLAY %((%d+)HZ%)$"))
|
|
return hz and Strings("DISPLAY (%dHZ)", hz) or Strings("DISPLAY")
|
|
end
|
|
return FrameCap.label(value)
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.fpsCap = FrameCap.cycle(o.fpsCap, dir)
|
|
FrameCap.apply(o.fpsCap)
|
|
return true
|
|
end },
|
|
{ id = "vsync", label = Strings("VSYNC"),
|
|
value = function(g)
|
|
local ok, PS = pcall(require, "src.core.PresentSync")
|
|
if ok and PS.vsyncEnableBlocked and PS.vsyncEnableBlocked() then
|
|
return Strings("UNAVAILABLE")
|
|
end
|
|
return Strings(VSync.label(g.save.options.vsync))
|
|
end,
|
|
step = function(g, dir)
|
|
local ok, PS = pcall(require, "src.core.PresentSync")
|
|
if ok and PS.vsyncStepAllowed
|
|
and not PS.vsyncStepAllowed(g.save.options.vsync, dir) then
|
|
return false
|
|
end
|
|
local o = g.save.options
|
|
o.vsync = VSync.cycle(o.vsync, dir)
|
|
VSync.apply(o.vsync)
|
|
return true
|
|
end },
|
|
{ id = "logicClock", label = Strings("LOGIC CLOCK"),
|
|
value = function(g)
|
|
return Strings(LogicClock.label(g.save.options.logicClock))
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.logicClock = LogicClock.cycle(o.logicClock, dir)
|
|
LogicClock.apply(o.logicClock)
|
|
return true
|
|
end },
|
|
-- fast-forward the logic clock only; music and sfx keep their tempo
|
|
-- (src/core/GameSpeed.lua), so this is safe to leave on. Per-category
|
|
-- (RFC 0007): overworld walking, battle turns and menu navigation each
|
|
-- cycle their own multiplier -- GameSpeed.CATEGORIES is the single
|
|
-- source of truth for which three rows exist.
|
|
{ id = "speedOverworld", label = Strings("OVERWORLD SPEED"),
|
|
value = function(g)
|
|
return gameSpeedLabel(g.save.options.speedOverworld)
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.speedOverworld = GameSpeed.cycle(o.speedOverworld, dir)
|
|
return true
|
|
end },
|
|
{ id = "speedBattle", label = Strings("BATTLE SPEED"),
|
|
value = function(g)
|
|
return gameSpeedLabel(g.save.options.speedBattle)
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.speedBattle = GameSpeed.cycle(o.speedBattle, dir)
|
|
return true
|
|
end },
|
|
{ id = "speedMenu", label = Strings("MENU SPEED"),
|
|
value = function(g)
|
|
return gameSpeedLabel(g.save.options.speedMenu)
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
o.speedMenu = GameSpeed.cycle(o.speedMenu, dir)
|
|
return true
|
|
end },
|
|
-- the manager's discoverable home (18-mod-manager-ux); inert until
|
|
-- opened, so the row costs a vanilla install nothing
|
|
{ id = "mods", label = Strings("MODS"),
|
|
value = function(g)
|
|
local status = g.modStatus or {}
|
|
return Strings("%d INSTALLED", #(status.available or {}))
|
|
end,
|
|
activate = function(g)
|
|
require("src.ui.Screens").push(g, "ManagerState")
|
|
end },
|
|
-- rebinding UI (gap C2, 12-ui-extensibility 4.4); captured inputs
|
|
-- live in options.bindings, so the row costs a vanilla install nothing
|
|
{ id = "controls", label = Strings("CONTROLS"),
|
|
activate = function(g)
|
|
require("src.ui.Screens").push(g, "BindingsMenu")
|
|
end },
|
|
{ id = "dateFormat", label = Strings("DATE FORMAT"),
|
|
value = function(g)
|
|
return Strings(preferenceLabel(DATE_FORMATS, g.save.options.dateFormat))
|
|
end,
|
|
step = function(g, dir)
|
|
g.save.options.dateFormat = preferenceStep(
|
|
DATE_FORMATS, g.save.options.dateFormat, dir)
|
|
return true
|
|
end },
|
|
{ id = "timeFormat", label = Strings("TIME FORMAT"),
|
|
value = function(g)
|
|
return Strings(preferenceLabel(TIME_FORMATS, g.save.options.timeFormat))
|
|
end,
|
|
step = function(g, dir)
|
|
g.save.options.timeFormat = preferenceStep(
|
|
TIME_FORMATS, g.save.options.timeFormat, dir)
|
|
return true
|
|
end },
|
|
-- permanent on-screen pad toggle (#327); layout editing stays in the
|
|
-- launcher. Hidden where the overlay never appears (desktop without
|
|
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
|
|
{ id = "touchControls", label = Strings("TOUCH PAD"),
|
|
value = function(g)
|
|
local tc = g.save.options.touchControls
|
|
local on = not (type(tc) == "table" and tc.enabled == false)
|
|
return on and Strings("ON") or Strings("OFF")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
local tc = type(o.touchControls) == "table" and o.touchControls or {}
|
|
local on = not (tc.enabled == false)
|
|
tc.enabled = not on
|
|
-- keep any saved positions when toggling
|
|
o.touchControls = tc
|
|
require("src.core.TouchControls"):applyOptions(o)
|
|
return true
|
|
end },
|
|
-- Haptic feedback for on-screen pad presses (#806): OFF / LIGHT /
|
|
-- NORMAL / STRONG, where the intensity is a vibration duration --
|
|
-- love.system.vibrate takes nothing else. Hidden with TOUCH PAD below,
|
|
-- since the only thing that buzzes is a virtual button press.
|
|
{ id = "haptics", label = Strings("VIBRATION"),
|
|
value = function(g)
|
|
local TC = require("src.core.TouchControls")
|
|
return Strings(TC.hapticLabel(g.save.options.haptics))
|
|
end,
|
|
step = function(g, dir)
|
|
local o = g.save.options
|
|
local TC = require("src.core.TouchControls")
|
|
o.haptics = TC.cycleHaptics(o.haptics, dir)
|
|
TC:applyOptions(o)
|
|
-- sample the level being selected: stepping the row is the only way
|
|
-- to compare LIGHT against STRONG without leaving the menu
|
|
TC.buzz(o.haptics)
|
|
return true
|
|
end },
|
|
{ id = "hotbar", label = Strings("KEY BAR"),
|
|
value = function(g)
|
|
return g.save.options.hotbar == false and Strings("OFF")
|
|
or Strings("ON")
|
|
end,
|
|
step = function(g)
|
|
local o = g.save.options
|
|
o.hotbar = o.hotbar == false
|
|
require("src.core.TouchControls"):applyOptions(o)
|
|
return true
|
|
end },
|
|
}
|
|
-- ORIENTATION only on the platforms Orientation.apply reaches (#1638).
|
|
if not (Orientation.isAndroid() or Orientation.isIOS()) then
|
|
local filtered = {}
|
|
for _, row in ipairs(rows) do
|
|
if row.id ~= "orientation" then filtered[#filtered + 1] = row end
|
|
end
|
|
rows = filtered
|
|
end
|
|
if require("src.core.Platform").isNX() then
|
|
local filtered = {}
|
|
for _, row in ipairs(rows) do
|
|
if row.id ~= "videoMode" then filtered[#filtered + 1] = row end
|
|
end
|
|
rows = filtered
|
|
end
|
|
-- TOUCH PAD and VIBRATION only where the overlay can appear (mobile, or
|
|
-- desktop with POKEPORT_TOUCH=1). POKEPORT_TOUCH=0 forces it off
|
|
-- everywhere. VIBRATION rides the same gate: nothing else in the port
|
|
-- vibrates, and love.system.vibrate is a no-op on desktop anyway.
|
|
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 not show then
|
|
local filtered = {}
|
|
for _, row in ipairs(rows) do
|
|
if row.id ~= "touchControls" and row.id ~= "haptics"
|
|
and row.id ~= "hotbar" then
|
|
filtered[#filtered + 1] = row
|
|
end
|
|
end
|
|
rows = filtered
|
|
end
|
|
end
|
|
-- PIKACHU VOL only means something where the voice clips exist: Yellow
|
|
-- (data.audio.pikaCries is the clip count the importer wrote). Red/Blue
|
|
-- keep the row list they always had.
|
|
local audio = game and game.data and game.data.audio
|
|
if not (GameVersion.isYellow() and audio and audio.pikaCries) then
|
|
local filtered = {}
|
|
for _, row in ipairs(rows) do
|
|
if row.id ~= "pikaVol" then filtered[#filtered + 1] = row end
|
|
end
|
|
rows = filtered
|
|
end
|
|
-- A mod's render pipelines are display modes like TILT, so their rows sit
|
|
-- with it rather than at the end of the list where a mod's own
|
|
-- ui.options.rows additions land. Nothing registered means nothing
|
|
-- spliced, so a vanilla install sees the list it always had.
|
|
local pipelineRows = Pipelines.rows(game)
|
|
if pipelineRows[1] then
|
|
local merged = {}
|
|
for _, row in ipairs(rows) do
|
|
merged[#merged + 1] = row
|
|
if row.id == "tilt" then
|
|
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
|
|
end
|
|
end
|
|
-- no TILT row to anchor to (a future build could drop it): append
|
|
-- rather than silently lose the modes
|
|
if #merged == #rows then
|
|
for _, extra in ipairs(pipelineRows) do merged[#merged + 1] = extra end
|
|
end
|
|
rows = merged
|
|
end
|
|
return rows
|
|
end
|
|
|
|
-- Grouping runs AFTER the ui.options.rows hook and never touches self.rows,
|
|
-- so a mod still sees and edits the flat list it always did; a row in no
|
|
-- group stays where it is, which is where a mod's own additions land.
|
|
local GROUPS = {
|
|
{ id = "group.battle", label = "BATTLE OPTIONS",
|
|
members = { "animations", "battleStyle", "battleLayout", "battleFit",
|
|
"battleHud", "battleBg" } },
|
|
{ id = "group.audio", label = "AUDIO",
|
|
members = { "musicVol", "sfxVol", "pikaVol", "musicFilter" } },
|
|
{ id = "group.video", label = "VIDEO",
|
|
members = { "uiLayout", "videoMode", "orientation", "faithfulRes",
|
|
"screenPos", "fpsCap", "vsync", "logicClock" } },
|
|
{ id = "group.speed", label = "SPEED",
|
|
members = { "textSpeed", "speedOverworld", "speedBattle", "speedMenu" } },
|
|
{ id = "group.graphics", label = "GRAPHICS",
|
|
members = { "colors", "uiLetterbox", "shaderfx", "shaderfx2" } },
|
|
-- A mod's Pipelines row splices in after TILT and is in no group, so it
|
|
-- stays on the top level rather than being swallowed into this page.
|
|
{ id = "group.extras", label = "EXTRAS",
|
|
members = { "tilt", "zoom", "voidFill" } },
|
|
}
|
|
|
|
-- The top level's order, groups and singles alike. Anything not named here
|
|
-- (a mod's row, the touch rows) keeps its flat position after these.
|
|
local ORDER = {
|
|
"group.speed", "group.video", "group.graphics", "group.audio",
|
|
"performance", "ruleset", "group.battle", "group.extras", "mods",
|
|
}
|
|
|
|
-- Each group replaced by one opener.
|
|
local function groupRows(game, rows)
|
|
local owner, picked = {}, {}
|
|
for _, group in ipairs(GROUPS) do
|
|
for _, id in ipairs(group.members) do owner[id] = group end
|
|
picked[group.id] = {}
|
|
end
|
|
for _, row in ipairs(rows) do
|
|
local group = row.id and owner[row.id]
|
|
if group then picked[group.id][#picked[group.id] + 1] = row end
|
|
end
|
|
local made = {}
|
|
for _, group in ipairs(GROUPS) do
|
|
local members = picked[group.id]
|
|
if #members > 0 then
|
|
made[group.id] = {
|
|
id = group.id, label = Strings(group.label), group = true,
|
|
value = function() return Strings("%d OPTIONS", #members) end,
|
|
-- No onCancel: BACK out of a page returns here, it does not close
|
|
-- OPTIONS, so the caller's close callback must not fire.
|
|
activate = function(g)
|
|
g.stack:push(OptionsMenu.new(g, { rows = members }))
|
|
end,
|
|
}
|
|
end
|
|
end
|
|
local byId, view, taken = {}, {}, {}
|
|
for _, row in ipairs(rows) do
|
|
if row.id and not owner[row.id] then byId[row.id] = row end
|
|
end
|
|
for _, id in ipairs(ORDER) do
|
|
local row = made[id] or byId[id]
|
|
if row then
|
|
view[#view + 1] = row
|
|
taken[id] = true
|
|
end
|
|
end
|
|
-- Whatever ORDER does not name, in the order the flat list had it: a mod's
|
|
-- own rows, and the platform rows that come and go.
|
|
for _, row in ipairs(rows) do
|
|
local id = row.id
|
|
if not (id and (owner[id] or taken[id])) then view[#view + 1] = row end
|
|
end
|
|
return view
|
|
end
|
|
|
|
-- opts.rows makes a submenu: a fixed row list, no hook and no regrouping, so
|
|
-- a group's page is this same screen driving the rows it was handed.
|
|
function OptionsMenu.new(game, opts)
|
|
opts = opts or {}
|
|
if opts.rows then
|
|
return setmetatable({ game = game, rows = opts.rows, view = opts.rows,
|
|
index = 1, scroll = 0, sub = true,
|
|
onCancel = opts.onCancel }, OptionsMenu)
|
|
end
|
|
local rows = buildRows(game)
|
|
local hooked = Runtime.call("ui.options.rows", sameRows, game, rows)
|
|
if type(hooked) == "table" then
|
|
rows = hooked
|
|
else
|
|
Logger.error("ui.options.rows returned %s; keeping the vanilla rows",
|
|
type(hooked))
|
|
end
|
|
local self = setmetatable({ game = game, rows = rows, index = 1, scroll = 0,
|
|
onCancel = opts.onCancel }, OptionsMenu)
|
|
self.view = groupRows(game, rows)
|
|
return self
|
|
end
|
|
|
|
-- Cursor onto a row by id, opening its group page first if it lives in one.
|
|
-- Returns the screen the row ended up on, so a caller can keep driving it.
|
|
function OptionsMenu:focusRow(id)
|
|
local rows = self.view or self.rows
|
|
for i, row in ipairs(rows) do
|
|
if row.id == id then
|
|
self.index = i
|
|
self.scroll = OptionRows.clampScroll(i, self.scroll or 0, #rows, #rows + 1)
|
|
return self
|
|
end
|
|
end
|
|
for i, row in ipairs(rows) do
|
|
if row.group and row.activate then
|
|
local group
|
|
for _, g in ipairs(GROUPS) do if g.id == row.id then group = g end end
|
|
for _, member in ipairs(group and group.members or {}) do
|
|
if member == id then
|
|
self.index = i
|
|
row.activate(self.game)
|
|
local sub = self.game.stack:top()
|
|
return sub.focusRow and sub:focusRow(id) or sub
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
function OptionsMenu:update(dt)
|
|
local input = self.game.input
|
|
local rows = self.view or self.rows
|
|
-- BACK sits below the hook-built rows so a mod cannot orphan the exit
|
|
local cancelRow = #rows + 1
|
|
local changed = false
|
|
if input:wasPressed("up") then
|
|
self.index = self.index > 1 and self.index - 1 or cancelRow
|
|
elseif input:wasPressed("down") then
|
|
self.index = self.index < cancelRow and self.index + 1 or 1
|
|
elseif input:wasPressed("left") or input:wasPressed("right")
|
|
or input:wasPressed("a") then
|
|
local dir = input:wasPressed("left") and -1 or 1
|
|
local row = rows[self.index]
|
|
if row and row.activate then
|
|
if input:wasPressed("a") then row.activate(self.game) end
|
|
elseif row and row.step then
|
|
changed = row.step(self.game, dir) and true or false
|
|
elseif input:wasPressed("a") then -- CANCEL
|
|
-- DisplayOptionMenu .exitMenu (engine/menus/main_menu.asm) is the
|
|
-- only spot in this menu that plays SFX_PRESS_AB: A on a setting row
|
|
-- and the Left/Right toggles stay silent (#570). game.data is nil
|
|
-- under the stub games the UI harnesses drive.
|
|
if self.game.data then
|
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
|
end
|
|
self.game.stack:pop()
|
|
if self.onCancel then self.onCancel() end
|
|
end
|
|
elseif input:wasPressed("b") or input:wasPressed("start") then
|
|
-- .exitMenu again: B and START leave the same way, sound and all
|
|
if self.game.data then
|
|
require("src.core.Sound").play(self.game.data, "Press_AB")
|
|
end
|
|
self.game.stack:pop()
|
|
if self.onCancel then self.onCancel() end
|
|
end
|
|
if changed and self.game.writeOptions then
|
|
self.game:writeOptions()
|
|
end
|
|
self.scroll = OptionRows.clampScroll(self.index, self.scroll or 0,
|
|
#rows, cancelRow)
|
|
end
|
|
|
|
function OptionsMenu:draw()
|
|
-- Through Strings, like every other label on this menu. BACK is
|
|
-- appended AFTER the rows hook (see the header), which is what keeps a mod
|
|
-- from orphaning the exit -- but it also means a translation mod never sees
|
|
-- this string, and cannot: there is no row for it to rewrite. So the one
|
|
-- word a Spanish player could not read on a fully translated OPTIONS menu
|
|
-- was the way out of it.
|
|
local rows = self.view or self.rows
|
|
OptionRows.draw(self.game, rows, self.index, self.scroll or 0,
|
|
Strings("BACK"), #rows + 1)
|
|
end
|
|
|
|
return OptionsMenu
|