mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 10:10:51 +02:00
fix catch regression + add new menus
This commit is contained in:
@@ -0,0 +1,387 @@
|
||||
-- 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
|
||||
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
|
||||
-- and the ROM import, which is 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 here rather than living in
|
||||
-- SETTINGS. It sits under the battles because the STADIUM rungs of 3D-BTL
|
||||
-- are what it unlocks.
|
||||
if catId == "battles" then
|
||||
local ok, importRow = pcall(function()
|
||||
return V.require("StadiumRomPick").row()
|
||||
end)
|
||||
if ok and importRow then out[#out + 1] = importRow 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
|
||||
Reference in New Issue
Block a user