fix catch regression + add new menus

This commit is contained in:
DramaticShape
2026-08-08 12:08:27 -04:00
parent 121c87b629
commit 66da6cbc4b
7 changed files with 1213 additions and 198 deletions
+220 -103
View File
@@ -98,6 +98,9 @@ local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove")
local CamControl = V.require("CamControl")
local VR = V.require("VR")
-- the mod's settings menus: the categories, the screens they open, and the
-- red ink that marks this mod's one row on the engine's OPTIONS list
local SettingsMenu = V.require("SettingsMenu")
-- HORDE MODE: the konami code's minigame. Horde owns the state machine and
-- every hook; the other four are the gun, the crowd, the readout and the
-- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
@@ -449,8 +452,26 @@ local function stagedBattles()
return OverworldBattle.enabled()
end
-- ------- this mod's settings, grouped the way the menus present them
--
-- One entry per setting: the ModSetting itself, the help text the mod
-- manager's page carries, and the fields that decide where it is offered.
--
-- cat which of SettingsMenu's categories the row lives on. The table is
-- kept in category order as well, so the mod manager's own page --
-- which has no categories to give and lists every row flat -- at
-- least keeps related settings next to each other.
-- when a predicate. The row is off the menu entirely while it answers
-- false, because a row that decides nothing reads as a broken mod.
-- full the row SURVIVES the FULL preset. FULL owns the look, so a row
-- goes with it by default; `full` marks the ones that were never
-- about the look. SettingsMenu leans on this and needs no rule of
-- its own: 3D WORLD is exactly the rows WITHOUT it, so that whole
-- category empties out under FULL and takes itself off the menu.
local SETTINGS = {
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge." },
-- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's
{ VoxelGrid.setting, "One-pixel wireframe along every voxel edge.",
cat = "world" },
{ WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style. 1 is a "
.. "hint of roll at the frame edges and 2 is the classic read; 3 is as "
@@ -458,7 +479,8 @@ local SETTINGS = {
.. "walk into. 4 and 5 are past that on purpose and they are for a "
.. "headset's DIORAMA, where the world is a model being looked at "
.. "rather than walked around in -- 5 curls it into a half sphere, a "
.. "town on top of its own little planet." },
.. "town on top of its own little planet.",
cat = "world" },
{ ViewBox.setting,
"How much of the map the camera bothers to draw. FIT is exactly the "
.. "ground on screen and no more -- the shape a tilted camera really "
@@ -472,24 +494,23 @@ local SETTINGS = {
.. "closest, WIDE through WIDEST push the world's edge further out, "
.. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are "
.. "standing in the world there -- and the box opens out and away as "
.. "the camera dives in." },
.. "the camera dives in.",
cat = "world" },
{ Water.setting,
"Reflections on water. FULL adds screen-space reflections of the "
.. "shoreline, the trees and the buildings behind it; SKY is the sky, "
.. "the sun and the moon alone, which is most of the look for a "
.. "fraction of the cost." },
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
-- supersampling that is a question about the hardware, not the look.
{ ForestAtmos.setting,
"The air of the deep woods (Viridian Forest): a ground haze, and "
.. "volumetric light let down through the unseen canopy overhead -- "
.. "gold spears of sun by day, silver moon rays at night, pollen "
.. "drifting through the beams and fireflies once they cool. LOW "
.. "keeps the haze, halves the beam march and stands the particles "
.. "down. On a phone the row offers LOW alone: the beams need a "
.. "depth texture the pass can read back, and no mobile driver here "
.. "grants one.",
full = true },
.. "fraction of the cost.",
cat = "world" },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following -- or SYNC it to the "
.. "clock on the wall, so Kanto's evening falls when yours does.",
cat = "world" },
-- ------- BATTLES -- what a fight is drawn over, and how it is played
--
-- `full` marks a row FULL does not take away. FULL owns the diorama's own
-- knobs; what a battle is drawn over, and how it is framed, are not that.
-- Off the OPTIONS menu while VR is on: the headset REQUIRES staged
@@ -509,6 +530,7 @@ local SETTINGS = {
.. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the "
.. "STADIUM ROM row, or drop it in the baseroms folder and restart. No "
.. "other version works: the reader is keyed to that one cartridge.",
cat = "battles",
when = function() return not VR.enabled() end, full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL
-- off the engine draws the classic screen, which is this row's ON already,
@@ -517,6 +539,7 @@ local SETTINGS = {
"Keep your own Pokemon on the battle menu, seen from behind in its "
.. "original slot, instead of standing it on the map facing the foe. "
.. "The foe is still out there on its own tile.",
cat = "battles",
when = function() return stagedBattles() and not VR.enabled() end,
full = true },
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on
@@ -534,12 +557,26 @@ local SETTINGS = {
.. "throws, new species and your running catch combo. Needs 3D-BTL "
.. "on; anywhere the staged fight cannot stand, balls quietly throw "
.. "the classic way.",
full = true },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
.. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "shadows, the sky and the light following -- or SYNC it to the "
.. "clock on the wall, so Kanto's evening falls when yours does." },
cat = "battles", full = true },
-- ------- PERFORMANCE -- what the look COSTS, which is a different question
--
-- All three are `full`, and all three for the same reason: FULL is a preset
-- for the diorama, not a licence to spend whatever the machine it happens
-- to be running on has got. The player decides what their hardware can
-- carry, from inside FULL like anywhere else.
-- `full` for the AA reason: additive shafts are fill rate, and under 4X
-- supersampling that is a question about the hardware, not the look.
{ ForestAtmos.setting,
"The air of the deep woods (Viridian Forest): a ground haze, and "
.. "volumetric light let down through the unseen canopy overhead -- "
.. "gold spears of sun by day, silver moon rays at night, pollen "
.. "drifting through the beams and fireflies once they cool. LOW "
.. "keeps the haze, halves the beam march and stands the particles "
.. "down. On a phone the row offers LOW alone: the beams need a "
.. "depth texture the pass can read back, and no mobile driver here "
.. "grants one.",
cat = "perf", full = true },
-- `full` on AA's reasoning below, and for the same reason: the sun's pass
-- is the most expensive thing in the frame after the geometry, so this is
-- a question about the machine rather than a knob on the diorama, and it
@@ -554,13 +591,9 @@ local SETTINGS = {
.. "phone or an old machine. OFF is no shadow at all, the flat drop "
.. "shadows under characters included, and the forest's light shafts go "
.. "with it: the beams are lit by the sun's own map.",
full = true },
cat = "perf", full = true },
-- Marked `full` for the opposite reason the battle rows are: this is not a
-- knob on the look at all, it is what the look COSTS. FULL is a preset for
-- the diorama, not a licence to spend four times the fill rate on the
-- machine it happens to be running on, so it neither sets this nor takes
-- the row away -- the player decides what their hardware can carry, from
-- inside FULL like anywhere else.
-- knob on the look at all, it is what the look COSTS.
{ AntiAlias.setting,
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
.. "lips, a tree against the sky -- by rendering the diorama larger than "
@@ -569,7 +602,10 @@ local SETTINGS = {
.. "reads smoother rather than sharper. 2X costs half again as many "
.. "pixels in each direction and 4X twice, which makes this the most "
.. "expensive row in the mod.",
full = true },
cat = "perf", full = true },
-- ------- VR -- the headset, and the one comfort knob that is only its
--
-- `full` for the same reason as AA: not a knob on the look, a question
-- about the hardware on the desk.
{ VR.setting,
@@ -588,6 +624,7 @@ local SETTINGS = {
.. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime "
.. "and the mod running from a real folder; without them the row stays "
.. "and the game stays flat, with the reason on the console.",
cat = "vr",
-- on Windows the row stays even when a runtime is missing (the console
-- says why); off Windows -- mobile above all -- there is no VR to have
-- and the row does not exist
@@ -601,12 +638,15 @@ local SETTINGS = {
.. "world past a head that did not move, which is the most reliable way "
.. "to make somebody ill in a headset. Turn it on if you have your sea "
.. "legs and want the continuity.",
cat = "vr",
-- and only under STANDARD: the stick turns a HEAD, and neither diorama
-- mode has the player standing in the world to be turned
when = function() return VR.enabled() and not VR.dioramaMode() end,
full = true },
}
SettingsMenu.define(SETTINGS)
local schema = {}
for _, entry in ipairs(SETTINGS) do
-- the VR rows are absent from the mod manager's page too where the
@@ -783,29 +823,31 @@ do
end
end
-- ------- the mode's rows, kept together
-- ------- the mode's rows, on menus of their own
--
-- The engine splices a pipeline's row in beside TILT, because a display mode
-- belongs with the other display modes; a mod's own ui.options.rows
-- additions land at the END of the list. That left this mod's four rows in
-- two places with unrelated engine rows between them, which reads as two
-- unrelated features rather than one mode with settings.
-- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one
-- block spliced in beside the pipeline rows. OptionRows shows four boxes at a
-- time, so that was four screens of scrolling inside a list that already
-- carried twenty engine rows, and finding SHADOWS meant knowing it was in
-- there past the wireframe and the horizon bend.
--
-- So the plain settings are inserted directly after the last of this mod's
-- PIPELINE rows instead of appended. Nothing else moves: the block lands
-- where the engine already decided display modes go.
local function insertGrouped(out, extra)
local anchor = nil
for i, row in ipairs(out) do
local id = type(row) == "table" and row.id
if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end
-- Now there is ONE row, and it leads the list. What it opens -- the
-- categories, the screens, and why the split falls where it does -- is
-- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's
-- display modes, the engine only spliced them beside TILT because it had
-- nowhere better, and TILT is not on the menu any more anyway (see below).
--
-- Two things it takes to move a pipeline row: the engine's descriptor is
-- captured on the way past and handed to SettingsMenu VERBATIM -- it persists
-- through its own step function into save.options.pipelines, and rebuilding
-- it here would be a second implementation of something the engine already
-- got right -- and the row is then dropped from the top-level list so it is
-- not in two places at once.
local function captureRow(out, id)
for _, row in ipairs(out) do
if type(row) == "table" and row.id == id then return row end
end
if not anchor then
for _, row in ipairs(extra) do out[#out + 1] = row end
return out
end
for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end
return out
return nil
end
-- FULL owns the settings that describe the LOOK, so while it is selected those
@@ -875,6 +917,25 @@ local function pinEngineFx(game)
if changed and game.writeOptions then pcall(game.writeOptions, game) end
end
-- ------- the values that follow other values
--
-- Two settings hold a third in place. 3D-BTL pins BATTLE LAYOUT to OG while a
-- fight can be staged on the map, and FULL pins DAYTIME to SYNC while it owns
-- that row. Both pins used to be a side effect of the rows hook, which every
-- step on the OPTIONS menu reran -- so they happened whether or not the step
-- was the one that mattered, and nothing had to name them.
--
-- Now a step can happen on the mod's own menu, where no hook runs, or on the
-- mod manager's page, where one never did. So the pinning is a function, and
-- all three routes ask for it.
local function pinDependents(game)
if stagedBattles() then OverworldBattle.forceOG(game) end
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync(game) end
end
SettingsMenu.setOnChanged(pinDependents)
-- call next() first and decorate what comes back, so every other mod's
-- rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows)
@@ -901,46 +962,50 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows)
OverworldBattle.forceOG(game)
dropRow(out, "battleLayout")
end
local full = Voxel.isFull(Pipelines.level("voxel"))
if full then
if Voxel.isFull(Pipelines.level("voxel")) then
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
-- horizon bend, the blur, the hour -- so those come off the menu and
-- DAYTIME is held at SYNC while its row is unreachable.
-- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its
-- row is unreachable. The rows themselves come off inside SettingsMenu,
-- which is where they live now: T-SHIFT with the wireframe and the bend,
-- and each of them by the same `full` rule rather than by name.
DayNight.forceSync(game)
dropRow(out, "pipeline:tiltshift")
end
local extra = {}
for _, entry in ipairs(SETTINGS) do
-- Two things decide whether a row is offered.
--
-- FULL: a preset that owns the look, so the rows that describe the look go
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see
-- applyFull); it does not hold them, so leaving them on the menu is the
-- difference between a preset and a lock.
--
-- 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 offered = (entry.full or not full)
and (not entry.when or entry.when())
if offered then extra[#extra + 1] = entry[1]:row() end
-- The two pipeline rows move INTO the mod's own root menu: captured as the
-- engine built them, then dropped from here so they are not in two places.
local captured, voxelRow = {}, nil
for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do
local row = captureRow(out, id)
-- a pipeline the registry refused is simply not there, and the menu says
-- so by not offering it rather than by offering a hole
if row then captured[#captured + 1] = row end
if id == "pipeline:voxel" then voxelRow = row end
dropRow(out, id)
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. nil on a platform with no file dialog, which takes it off the
-- menu rather than offering a button that cannot do anything.
-- On EVERY platform. Where there is no file dialog it says WHERE? and
-- shows the folder to put the cartridge in, which is the one thing a
-- player on a phone could not otherwise find out -- the row used to vanish
-- there, which reads as the feature being missing rather than manual.
local okPick, importRow = pcall(function()
return V.require("StadiumRomPick").row()
end)
if okPick and importRow then extra[#extra + 1] = importRow end
return insertGrouped(out, extra)
SettingsMenu.setPipelineRows(captured)
-- ------- one row, and it leads the list
--
-- At the TOP rather than spliced in beside the display modes it used to sit
-- with. This is a mod that replaces the whole look of the game, and a player
-- who installed it and went looking for its settings should not have to
-- scroll to find out where they went -- least of all past the engine rows it
-- has quietly taken away.
--
-- Inserted after next() has run, so it leads every OTHER mod's rows too. The
-- second line is VOXEL's own value function, which makes the row say what
-- the mode is currently doing without opening it -- and reuses the engine's
-- label ladder rather than restating it.
table.insert(out, 1, {
id = SettingsMenu.id(SettingsMenu.ROOT),
label = SettingsMenu.ROOT_LABEL,
value = voxelRow and voxelRow.value or nil,
-- `activate` and not `step`: the engine fires activate on A alone, and a
-- row that OPENS something should not also answer Left and Right
-- (src/ui/OptionsMenu.update).
activate = function(g)
g.stack:push(SettingsMenu.new(g, SettingsMenu.ROOT))
end,
})
return out
end)
-- The mod manager writes and persists on its own, so the only thing left
@@ -951,14 +1016,11 @@ mod.events:on("mod.options_changed", function(payload)
if payload.key == entry[1].key then entry[1]:sync(payload.value) end
end
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as
-- the OPTIONS row does. The manager persists its own value; this is the one
-- that has to follow it.
if stagedBattles() then OverworldBattle.forceOG() end
-- and DAYTIME changed from the manager's page while FULL owns it snaps
-- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is
-- not, and FULL's pin must hold against both
local Pipelines = require("src.render.Pipelines")
if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end
-- the mod's own row does, and DAYTIME changed there while FULL owns it snaps
-- straight back to SYNC -- that row is off the mod's menus under FULL, but
-- the manager's page carries every setting unconditionally, and the pin has
-- to hold against both.
pinDependents()
end)
-- ------- keeping the geometry in step with the world
@@ -1058,30 +1120,55 @@ end)
-- rerun every mod's ui.options.rows hook once per keypress. The cursor is
-- clamped rather than reset, so it stays on the row it was just used on
-- instead of jumping to the top when the list below it shortens.
--
-- Held on the INSTANCE rather than compared across one call of update, and
-- that is not a tidying: those three rows live in a SUBMENU now, and the
-- stack only ticks its top state (src/core/StateStack.update). So the step
-- that changes them happens while this menu is suspended and a
-- before/after pair taken around inner() would both be read after the fact
-- and always agree. A signature that outlives the suspension does not.
do
local OptionsMenu = require("src.ui.OptionsMenu")
if not OptionsMenu.dramaticShapeFullHook then
local OptionRows = require("src.ui.OptionRows")
local Pipelines = require("src.render.Pipelines")
local inner = OptionsMenu.update
local innerPalettes = OptionsMenu.sgbPalettes
local function idAt(menu, index)
local row = menu.rows and menu.rows[index or 1]
return type(row) == "table" and row.id or nil
end
-- What the row LIST depends on: whether FULL is selected (it owns the
-- rows that describe the look), and the two switches that give and take
-- an engine row -- 3D-BTL, which owns BATTLE LAYOUT, and VR, which hides
-- both battle rows while it is on. Only the FULL-ness of the voxel level
-- matters, so stepping 35 to 50 is not a change.
local function signature()
return string.format("%s|%s|%s",
tostring(Voxel.isFull(Pipelines.level("voxel"))),
tostring(OverworldBattle.enabled()), tostring(VR.enabled()))
end
-- Stamped where the ROWS are built, which is the thing the signature is a
-- signature OF. Read lazily on the first update instead and a menu opened
-- before the change and updated after it would compare the new state
-- against itself and never rebuild.
local innerNew = OptionsMenu.new
function OptionsMenu.new(game, opts)
local menu = innerNew(game, opts)
menu.dramaticShapeSig = signature()
return menu
end
function OptionsMenu:update(dt)
local before = Pipelines.level("voxel")
local hadBattles = OverworldBattle.enabled()
-- the VR row hides the two battle rows while it is on, so stepping
-- it changes the LIST exactly the way 3D-BTL does
local hadVR = VR.enabled()
local wasOn = idAt(self, self.index)
local before = self.dramaticShapeSig or signature()
inner(self, dt)
local after = Pipelines.level("voxel")
local crossedFull = after ~= before
and (Voxel.isFull(before) or Voxel.isFull(after))
if crossedFull or OverworldBattle.enabled() ~= hadBattles
or VR.enabled() ~= hadVR then
local after = signature()
self.dramaticShapeSig = after
if before ~= after then
local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows
-- Follow the row the cursor was ON rather than the slot it was in:
@@ -1095,6 +1182,36 @@ do
end
end
-- ------- and the mod's own row is red
--
-- Why this is a palette zone and not love.graphics.setColor -- twice over
-- -- is written out in lib/SettingsMenu.lua, next to the code that builds
-- the palette. The short of it: setColor picks a SHADE on this screen and
-- the zone picks the COLOR.
--
-- Addressed by SLOT, because the row scrolls: it leads the list, so it is
-- normally the top box, but a player who scrolls past it must not leave a
-- red band behind on whatever takes its place. Searched by id rather than
-- assumed to be row 1 for the same reason -- another mod's hook running
-- after ours could put something above it.
function OptionsMenu:sgbPalettes(game)
local zones = innerPalettes and innerPalettes(self, game) or nil
local scroll = self.scroll or 0
for slot = 1, OptionRows.VISIBLE do
local row = self.rows and self.rows[scroll + slot]
if type(row) == "table"
and row.id == SettingsMenu.id(SettingsMenu.ROOT) then
local zone = SettingsMenu.rowZone(game and game.data, slot)
if zone then
zones = zones or {}
zones[#zones + 1] = zone
end
break
end
end
return zones
end
OptionsMenu.dramaticShapeFullHook = true
end
end