Files
DramaticShapeVoxelMod/lib/SettingsMenu.lua
T
DramaticShape 2907aba6ff new menus
2026-08-08 12:18:35 -04:00

393 lines
16 KiB
Lua

-- This mod's settings, in categories, on menus of their own.
--
-- ------- why the flat list had to end
--
-- Every setting used to be spliced straight into the engine's OPTIONS list,
-- one unbroken block of fourteen rows after the pipeline rows. OptionRows
-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone
-- was four screens of scrolling inside a list that already carried twenty
-- engine rows -- and a player looking for SHADOWS had to know it was in there
-- somewhere, past the wireframe and the horizon bend.
--
-- The engine has no grouping to borrow: a row descriptor is
-- { id, label, value, step, activate } and nothing else. No headers, no
-- sections, no pages. What it DOES have is `activate`, and a state stack that
-- any state may push onto -- which is how the engine's own MODS and CONTROLS
-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens.
--
-- ------- how the split was chosen
--
-- Not invented here: the mod already sorted its own settings, in the `full`
-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT
-- take away, and the reasoning written next to each one is always the same
-- -- this is a question about the HARDWARE, or about the GAME, not a knob on
-- the diorama FULL is a preset for.
--
-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special
-- case to disappear under FULL: every child filters itself out and the
-- category goes with them (see rows). PERFORMANCE is the three rows marked
-- `full` for cost -- FOREST FX among them, on its own comment's reasoning
-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR
-- are the two features that are not about the look at all.
--
-- ------- what did NOT change
--
-- Nothing that persists. Every ModSetting keeps its key, its ladder and its
-- row id, so options.lua is byte-identical for a player who upgrades and
-- changes nothing -- see lib/ModSetting.lua for why the key is the only
-- identity a setting has. The hotkeys are untouched too, which is what makes
-- the nesting affordable: a buried row is still one keypress away.
-- the mod namespace (see main.lua)
local V = ...
local OptionRows = require("src.ui.OptionRows")
local PaletteFX = require("src.render.PaletteFX")
local SettingsMenu = {}
SettingsMenu.__index = SettingsMenu
-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully
-- covered, so there is no reason to pay for drawing it.
SettingsMenu.isOpaque = true
SettingsMenu.ROOT = "root"
SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.."
-- Row ids live in a namespace of their own -- "menu." rather than a setting
-- key -- so they can never collide with the "DRAMATIC_SHAPE:<key>" ids the
-- settings rows have carried since the beginning.
function SettingsMenu.id(catId)
return "DRAMATIC_SHAPE:menu." .. catId
end
-- ------- the categories, in menu order
--
-- `summary` is the second line of the category's own row, the way MODS reads
-- "%d INSTALLED" on the engine's menu. Where one setting IS the category --
-- 3D-BTL for the battles, VR for the headset -- it says that setting's
-- current rung, which is the thing a player actually wants to know without
-- opening it. Where no single row speaks for the rest, it counts them, which
-- is honest rather than arbitrary.
SettingsMenu.CATEGORIES = {
{ id = "world", label = "3D WORLD.." },
{ id = "battles", label = "BATTLES..",
summary = function() return V.require("OverworldBattle").setting:valueLabel() end },
{ id = "perf", label = "PERFORMANCE.." },
{ id = "vr", label = "VR..",
summary = function() return V.require("VR").setting:valueLabel() end },
}
-- ------- what the menus are built from
--
-- SETTINGS lives in main.lua, next to the help text that goes with each row
-- and the comments explaining every `when` and `full`. It is handed here
-- rather than moved, so this file stays about PRESENTATION and that one stays
-- the single place the mod's settings are declared.
local settings = {}
local pipelineRows = {}
function SettingsMenu.define(list)
settings = list or {}
end
-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines
-- .rows), captured by the options hook on its way past and shown here instead
-- of at the top level. Reused verbatim, tables and all: they persist in
-- save.options.pipelines through their own step functions, and rebuilding
-- them here would be a second implementation of a thing the engine already
-- got right.
function SettingsMenu.setPipelineRows(rows)
pipelineRows = rows or {}
end
-- ------- a step here has the same consequences as a step anywhere
--
-- Two of these settings PIN something else when they change: 3D-BTL holds
-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds
-- DAYTIME at SYNC while it owns that row. Both used to happen because every
-- step on the OPTIONS menu reran the ui.options.rows hook, which does the
-- pinning on its way past.
--
-- Nothing reruns that hook from in here, so the pin is asked for directly.
-- main.lua supplies it, because WHICH values follow which is a question about
-- the mod's settings and not about the menu they are on.
local onChanged = nil
function SettingsMenu.setOnChanged(fn)
onChanged = fn
end
local function isFull()
local Pipelines = require("src.render.Pipelines")
return V.require("VoxelState").isFull(Pipelines.level("voxel"))
end
-- The one rule that decides whether a setting is on a menu, lifted unchanged
-- from the options hook it used to live in.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. And a row whose own switch is off the table this frame (BACK
-- SPRITES, which needs a staged fight to be about) is left off with it. The
-- mod manager's page carries every one of them either way.
local function offered(entry, full)
return (entry.full or not full) and (not entry.when or entry.when())
end
-- The rows of one category, or of the root menu. PURE -- no state, no stack,
-- no side effects -- so a caller that only wants to know what is on a menu
-- (a test, or the root menu asking whether a category has anything in it)
-- does not have to push a screen to find out.
function SettingsMenu.rows(catId, game)
local full = isFull()
local out = {}
if catId == SettingsMenu.ROOT then
for _, row in ipairs(pipelineRows) do
-- FULL owns the blur exactly as it owns the wireframe and the horizon
-- bend, so T-SHIFT comes off with them
if not (full and row.id == "pipeline:tiltshift") then
out[#out + 1] = row
end
end
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
local kids = SettingsMenu.rows(cat.id, game)
-- An EMPTY category is not offered. This is the whole of what makes
-- 3D WORLD disappear under FULL and VR disappear off Windows: no
-- special case, just nothing left inside to open.
if kids[1] then
out[#out + 1] = {
id = SettingsMenu.id(cat.id),
label = cat.label,
value = cat.summary
or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end,
activate = function(g)
g.stack:push(SettingsMenu.new(g, cat.id))
end,
}
end
end
-- ------- and the ROM import, last, on the top-level menu
--
-- An ACTION and not a setting: there is no rung to store, nothing for the
-- mod manager's page to persist and nothing to restore on the next boot,
-- so it is appended rather than living in SETTINGS.
--
-- On the ROOT menu rather than under the battles whose STADIUM rungs it
-- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge
-- and wait while it builds -- and a player who has been told to import a
-- ROM should find the row where the mod begins, not two levels down a
-- category they have no reason to open until it has worked. Last, because
-- the categories are what the menu is FOR.
local ok, importRow = pcall(function()
return V.require("StadiumRomPick").row()
end)
if ok and importRow then out[#out + 1] = importRow end
return out
end
for _, entry in ipairs(settings) do
if entry.cat == catId and offered(entry, full) then
out[#out + 1] = entry[1]:row()
end
end
return out
end
-- ------- red ink for the mod's row on the OPTIONS menu
--
-- love.graphics.setColor CANNOT do this, and it is worth writing down why so
-- nobody spends an afternoon on it. Twice over:
--
-- 1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py),
-- and Font.drawCode is a plain love.graphics.draw, which LOVE tints
-- MULTIPLICATIVELY. black x red is black.
-- 2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the
-- RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a
-- red pixel lands in c0, the LIGHTEST slot: white text on white paper.
--
-- What actually happens on this screen is that setColor picks a SHADE and the
-- zone palette picks the COLOR. Black text is c3 and the white box fill is
-- c0, so a zone whose c3 is red draws red text on paper that has not moved.
-- The engine does the same thing for the party menu's HP bars
-- (src/ui/PartyMenu.lua), which is the pattern this follows.
SettingsMenu.INK = { 255, 0, 0 }
-- Built by copying MEWMON -- the palette the OPTIONS menu already wears --
-- and replacing ONLY the ink slot, rather than inventing four colors. Red,
-- Blue and Yellow ship different MEWMON tables, and this way the paper under
-- the row is the same white as the row above it in all three.
function SettingsMenu.redPalette(data)
local base = PaletteFX.pal(data, "MEWMON")
if not base then return nil end
local out = { base[1], base[2], base[3], base[4] }
-- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under
-- it the ink is the first slot and the paper the last. Put the red where it
-- will land on the INK either way: without this the row draws as a solid
-- red block with white letters cut out of it.
--
-- The other modes need nothing. OG, OG INV and CLASSIC discard the table
-- outright and substitute their own, so the row simply draws monochrome --
-- which is correct: the player asked for a screen with no colors in it.
out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK
return out
end
-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only
-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles
-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and
-- 19 are the box's own borders and tile 1 is the cursor, and all three are
-- black glyphs that would turn red along with the text if the band spanned
-- the whole row.
function SettingsMenu.rowZone(data, slot)
local pal = SettingsMenu.redPalette(data)
if not pal then return nil end
local top = (slot - 1) * 4 + 1
return PaletteFX.zone(pal, 2, top, 18, top + 1)
end
-- ------- the screen
--
-- Deliberately NOT an OptionsMenu instance, though the update loop below is
-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS,
-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel
-- level or the battle rows change -- which would replace a submenu's rows
-- with the whole top-level OPTIONS list under the player's cursor. A state of
-- our own cannot be caught by it.
--
-- It still renders through OptionRows, so it is the same four boxes, the same
-- cursor and the same bottom line as every other menu in the game.
function SettingsMenu.new(game, catId)
local self = setmetatable({
game = game,
cat = catId or SettingsMenu.ROOT,
index = 1,
scroll = 0,
}, SettingsMenu)
self.rows = SettingsMenu.rows(self.cat, game)
self.sig = SettingsMenu.signature(self.rows)
return self
end
function SettingsMenu.signature(rows)
local ids = {}
for i, row in ipairs(rows) do ids[i] = tostring(row.id) end
return table.concat(ids, "\1")
end
-- OptionRows has no room for a title: the four boxes fill the screen down to
-- the bottom line. So the bottom line carries the name as well as the way
-- out, which is the one place left to say where the player is. "BACK" alone
-- at the root, where "BACK: DRAMATIC SHAPE" would run past the 18 characters
-- the line has.
function SettingsMenu:backLabel()
if self.cat == SettingsMenu.ROOT then return "BACK" end
for _, cat in ipairs(SettingsMenu.CATEGORIES) do
if cat.id == self.cat then
return "BACK: " .. (cat.label:gsub("%.%.$", ""))
end
end
return "BACK"
end
-- A category's contents can change while the player is looking at them: 3D-BTL
-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping
-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST
-- actually differs, so the common case -- every other rung of every other row
-- -- costs one string compare.
function SettingsMenu:refresh()
local rows = SettingsMenu.rows(self.cat, self.game)
local sig = SettingsMenu.signature(rows)
if sig == self.sig then return end
-- Follow the row the cursor was ON rather than the slot it was in: a row
-- can appear ABOVE the one just used, which would otherwise slide the
-- cursor onto its neighbour. The bottom line follows itself.
local wasBack = self.index > #self.rows
local wasOn = self.rows[self.index] and self.rows[self.index].id
self.rows, self.sig = rows, sig
self.index, self.scroll = 1, 0
if wasBack then
self.index = #rows + 1
else
for i, row in ipairs(rows) do
if wasOn and row.id == wasOn then self.index = i break end
end
end
end
local function pop(self)
local stack = self.game and self.game.stack
if self.game and self.game.data then
require("src.core.Sound").play(self.game.data, "Press_AB")
end
if stack and stack:top() == self then stack:pop() end
end
-- The engine's own options loop (src/ui/OptionsMenu.update), including its
-- two conventions worth naming: `activate` SHADOWS `step` and fires on A
-- alone, and the bottom line is a synthetic index past the end of the list
-- rather than a row, so nothing a category contains can orphan the way out.
function SettingsMenu:update()
local input = self.game and self.game.input
if not input then return end
local rows = self.rows
local back = #rows + 1
local changed = false
if input:wasPressed("up") then
self.index = self.index - 1
if self.index < 1 then self.index = back end
elseif input:wasPressed("down") then
self.index = self.index + 1
if self.index > back then self.index = 1 end
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
pop(self)
return
end
elseif input:wasPressed("b") or input:wasPressed("start") then
-- B and START both, like every other menu -- and one level only: this
-- pops US, leaving the OPTIONS menu underneath exactly as the player
-- left it, with its own onCancel still to fire when they leave THAT.
pop(self)
return
end
if changed then
-- before the rebuild, not after: pinning can itself change which rows are
-- offered (3D-BTL switched on takes BACK SPRITES from off the table to on
-- it), and refresh has to see the settled answer
if onChanged then pcall(onChanged, self.game) end
if self.game.writeOptions then
pcall(self.game.writeOptions, self.game)
end
end
self:refresh()
self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows,
#self.rows + 1)
end
function SettingsMenu:draw()
OptionRows.draw(self.game, self.rows, self.index, self.scroll,
self:backLabel(), #self.rows + 1)
end
-- REQUIRED, even though nothing here is red.
--
-- Game:draw walks the stack from the top and stops at the first state that
-- HAS this method, not the first that answers something. Without one of our
-- own the walk would fall through to the OPTIONS menu underneath -- whose
-- sgbPalettes main.lua has patched to paint the mod's row red -- and that
-- zone is addressed by SLOT, so it would land on whatever this menu happens
-- to be showing in the same box.
--
-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper.
function SettingsMenu:sgbPalettes(game)
return PaletteFX.wholeNamed(game.data, "MEWMON")
end
return SettingsMenu