fix catch regression + add new menus
DramaticShape · 2026-08-08 ·
parent 121c87b629
fix catch regression + add new menus
Changed files
CHANGELOG.md | 66 +++++++++
lib/CatchThrow.lua | 40 +++++-
lib/ModSetting.lua | 16 ++-
lib/SettingsMenu.lua | 387 ++++++++++++++++++++++++++++++++++++++++++++++++++
main.lua | 323 ++++++++++++++++++++++++++++--------------
tests/dramatic_shape_test.lua | 475 ++++++++++++++++++++++++++++++++++++++++++++++++++------------
tests/letsgo_route6.lua | 104 ++++++++++++++
7 files changed, 1213 insertions(+), 198 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c468440..73c81ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -77,8 +77,74 @@
nor takes it away, on the same reasoning as AA -- what the look costs is
the player's question, not a preset's.
+### Changed
+
+- **The settings live on menus of their own now, behind one red row at the
+ top of OPTIONS.** This mod had grown to fourteen rows on the engine's
+ list, spliced in as one block. OPTIONS 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 somewhere
+ past the wireframe and the horizon bend.
+
+ What is on OPTIONS now is `DRAMATIC SHAPE..`, and it leads the list --
+ a mod that replaces the look of the whole game should not make the player
+ scroll to find out where its settings went, least of all past the engine
+ rows it has quietly taken away. It opens VOXEL and T-SHIFT, which came off
+ the engine's list with it, and four categories: **3D WORLD** (V-GRID,
+ V-CURVE, RENDER DIST, WATER, DAYTIME), **BATTLES** (3D-BTL, BACK SPRITES,
+ LET'S GO, STADIUM ROM), **PERFORMANCE** (FOREST FX, SHADOWS, AA) and
+ **VR** (VR, SMOOTH TURN).
+
+ The split is not a new opinion: it is the `full` flag each row already
+ carried. `full` marks a row the FULL preset does not take away, and the
+ reason written beside each one was 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 rows FULL owns, and needs no rule
+ to disappear under it: every child filters itself out and an empty category
+ is not offered. Under FULL the menu is four rows on one screen with no
+ scroll arrow. The same rule retires VR where there is no VR to have.
+
+ **Nothing you had set has moved.** Every setting keeps its stored key, its
+ ladder and its row id, so `options.lua` is byte-identical across the
+ upgrade for a player who changes nothing -- and the hotkeys are untouched,
+ which is what makes the nesting affordable: 3, 5, 6, 7, 8 and 9 still put
+ every buried row one keypress away. The mod manager's own page still lists
+ all thirteen settings flat, now in category order.
+
+ The row is drawn in red, which is a palette zone rather than a color:
+ `setColor` cannot tint this text, because the glyph atlas is black ink and
+ LOVE tints multiplicatively, and because the palette shader keys on the red
+ channel alone and would send a red pixel to the lightest slot. What the
+ zone changes is which color the shade the text was drawn in comes out as.
+ It is MEWMON -- the palette the OPTIONS menu already wears -- copied with
+ only the ink slot replaced, so the paper under the row is the same white as
+ the row above it in all three ROMs, and the band covers the two text lines
+ alone rather than the cursor and the box borders beside them. SGB INV
+ reverses a palette, so there the red starts in the other slot and still
+ lands on the ink; OG, OG INV and CLASSIC substitute their own tables
+ outright, and the row simply draws monochrome, which is what asking for a
+ screen with no colors in it should get.
+
### Fixed
+- **A setting that pins another one now pins it from wherever it was
+ changed.** 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 pins
+ used to be a side effect of the options-rows hook, which every step on the
+ OPTIONS menu happened to rerun -- so they fired whether or not the step was
+ the one that mattered, and nothing had to name them. A step made on the
+ mod's own menus reruns no hook, so the pinning is a function now, and the
+ hook, the menus and the mod manager's page all ask for it.
+
+- **An open OPTIONS menu notices a change made on a menu pushed over it.**
+ The rebuild that keeps the row list honest compared the voxel level and the
+ two battle switches across one call of `update`. The stack ticks its top
+ state only, so a step taken on one of the mod's own menus happens while
+ OPTIONS is suspended: both halves of that comparison were read after the
+ fact and always agreed, and OPTIONS came back still showing a BATTLE LAYOUT
+ row that no longer belonged there. The signature is held on the menu and
+ stamped where the rows are built, which is the thing it is a signature of.
+
- **A building's back no longer wears its own front door.** Every voxelized
building is its drawing extruded straight through the footprint, so the
far wall is the facade again -- and read from behind, the facade mirrored:
diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua
index 287e0f3..777ec75 100644
--- a/lib/CatchThrow.lua
+++ b/lib/CatchThrow.lua
@@ -285,19 +285,55 @@ end
-- ball at the bottom of the frame, the throw a straight shot up the
-- middle. Handed to BattleScene.render through the capture table's `rig`;
-- everything downstream (pins, cards, sun, fov) is camera-generic.
+-- ------- and how far back the WORLD lets it stand
+--
+-- SEAT_BACK is what the shot wants. It is not always available: this seat
+-- is low (SEAT_UP is 13 world pixels, under a single cell) where the
+-- battle's own rig stands at 37.9, and it is planted three cells behind
+-- the player wherever the encounter happened -- which on a hedged route
+-- like ROUTE 6 is inside the hedge. The eye then looks out from within
+-- the foliage and the scenery it is standing in hangs across the top of
+-- the frame, over the horizon and the distant rooftops both.
+--
+-- ThirdPerson's boom answers exactly this question for the free-roam
+-- camera, so it answers it here rather than being restated: march back
+-- from the player's cell and stop at the first thing the eye may not be
+-- inside, keeping its PAD. Its rule is the right one for a seat this low,
+-- too -- terrain height always blocks, and anything BUILT on a cell
+-- blocks below head height, so a kerb is passed over and a hedge is not.
+--
+-- Pulling in brings the foe closer in a fixed field of view, which is a
+-- composition the throw already handles: the ring, the collision and the
+-- drag are all measured from the live shot, so they follow the seat.
+local SEAT_MIN_BACK = 20 -- never nearer than this: the held ball hangs
+ -- at the player's own cell and has to stay in
+ -- front of the eye
+
+local function seatBack(px, pz, ax, az, eyeY)
+ local ok, ow = pcall(function() return game().overworld end)
+ if not (ok and ow) then return SEAT_BACK end
+ local ThirdPerson = V.require("ThirdPerson")
+ local okR, reach = pcall(ThirdPerson.reach, ow, { px, eyeY, pz },
+ -ax, 0, -az, SEAT_BACK)
+ if not (okR and reach) then return SEAT_BACK end
+ return math.max(SEAT_MIN_BACK, math.min(SEAT_BACK, reach))
+end
+
local function captureRig(arena, groundY)
local ex, ez = arena.enemy[1], arena.enemy[2]
local px, pz = arena.player[1], arena.player[2]
local ax, az = ex - px, ez - pz
local l = math.sqrt(ax * ax + az * az)
if l < 1e-6 then ax, az = 0, -1 else ax, az = ax / l, az / l end
+ local eyeY = groundY + SEAT_UP
+ local back = seatBack(px, pz, ax, az, eyeY)
local cam = {
- eye = { px - ax * SEAT_BACK, groundY + SEAT_UP, pz - az * SEAT_BACK },
+ eye = { px - ax * back, eyeY, pz - az * back },
focus = { ex, groundY + 8, ez },
fov = SEAT_FOV,
}
-- the pitch VoxelScene.pull wants: how far below level the seat looks
- local pitch = math.atan2(SEAT_UP - 8, SEAT_BACK + l)
+ local pitch = math.atan2(SEAT_UP - 8, back + l)
return cam, math.max(pitch, 0.05), SEAT_FRAME
end
diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua
index 33c44ad..d87112b 100644
--- a/lib/ModSetting.lua
+++ b/lib/ModSetting.lua
@@ -168,6 +168,15 @@ function ModSetting:sync(value)
self.index = indexOf(self, value)
end
+-- The label of the rung actually in force, which is not the stored one when
+-- that rung has been gated away (see get). Its own entry point because a
+-- caller can want the label without wanting a row: SettingsMenu puts one
+-- setting's rung on the second line of the CATEGORY that contains it.
+function ModSetting:valueLabel()
+ local i = self:read()
+ return self.labels[self:allows(i) and i or 1]
+end
+
-- The descriptor src/ui/OptionRows.lua renders, in the shape the
-- ui.options.rows hook appends.
function ModSetting:row()
@@ -175,12 +184,7 @@ function ModSetting:row()
return {
id = "DRAMATIC_SHAPE:" .. self.key,
label = self.label,
- -- the label of the rung actually in force, which is not the stored one
- -- when that rung has been gated away (see get)
- value = function()
- local i = self_:read()
- return self_.labels[self_:allows(i) and i or 1]
- end,
+ value = function() return self_:valueLabel() end,
step = function(game, dir)
self_:cycle(game, dir)
return true
diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua
new file mode 100644
index 0000000..0b19d67
--- /dev/null
+++ b/lib/SettingsMenu.lua
@@ -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
diff --git a/main.lua b/main.lua
index 810c023..9e236bd 100644
--- a/main.lua
+++ b/main.lua
@@ -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
- end
- if not anchor then
- for _, row in ipairs(extra) do out[#out + 1] = row end
- return out
+-- 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
- 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
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index 7cc7a53..d50fc97 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -119,6 +119,46 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label")
local Runtime = require("src.mods.Runtime")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
+-- ------- reaching the rows now that they are on menus of their own
+--
+-- The ui.options.rows hook contributes ONE row to the engine's list, and that
+-- row opens the mod's own screens. So a test asks SettingsMenu.rows -- which
+-- is pure on purpose, precisely so that asking what is on a menu does not mean
+-- pushing one -- rather than scanning the hook's output for a setting.
+--
+-- Keyed by id, because that is the one thing about a row that is a promise.
+-- The old positional lookups (hookedRows[7] is 3D-BTL) pinned an ORDER the
+-- menus are now free to change, and every one of them had to be rewritten to
+-- get here; none of them was testing the order on purpose.
+--
+-- One table rather than three locals: this file is one Lua chunk, a chunk has
+-- 200 local slots, and it is already spending them carefully (see the scoped
+-- sections throughout).
+local Menus = { lib = run.loader.exports.DRAMATIC_SHAPE.lib.require("SettingsMenu") }
+
+function Menus.rows(catId, game)
+ local ids = {}
+ for _, row in ipairs(Menus.lib.rows(catId, game or { data = Data })) do
+ ids[row.id] = row
+ end
+ return ids
+end
+
+-- every row the mod offers ANYWHERE: the root menu plus all four categories.
+-- What most of this file wants, since "is this setting on a menu at all" is
+-- the question the `when` and `full` gates actually answer.
+function Menus.offered(game)
+ local ids = Menus.rows(Menus.lib.ROOT, game)
+ for _, cat in ipairs(Menus.lib.CATEGORIES) do
+ for id, row in pairs(Menus.rows(cat.id, game)) do ids[id] = row end
+ end
+ return ids
+end
+
+-- the id of a category's own row on the root menu
+function Menus.catId(catId)
+ return Menus.lib.id(catId)
+end
-- ------- FULL is a preset that owns the rows describing the LOOK
--
@@ -131,36 +171,67 @@ local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
-- diorama the preset is a preset FOR. FULL sets them on arrival and then lets
-- go, which is what makes it a preset rather than a lock.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL)
+-- scoped, like the sections below: this file is one Lua chunk and a chunk has
+-- 200 local slots, so a section that wants four borrows them
+do
local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" } })
local fullIds = {}
for _, row in ipairs(fullRows) do fullIds[row.id] = true end
-T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
-T.check(not fullIds["pipeline:tiltshift"],
+-- what the hook leaves on the ENGINE's list: one row, and the pipeline rows
+-- gone from it because they have moved onto the mod's own root menu
+T.check(fullIds[Menus.catId(Menus.lib.ROOT)],
+ "the mod contributes exactly one row to the OPTIONS menu")
+T.check(not fullIds["pipeline:voxel"],
+ "and takes VOXEL off the engine's list -- it lives on the mod's menu now")
+T.check(not fullIds["pipeline:tiltshift"], "and T-SHIFT with it")
+T.eq(fullRows[1].id, Menus.catId(Menus.lib.ROOT),
+ "and it LEADS the list: a mod that replaces the look of the whole game "
+ .. "should not make the player scroll to find out where its settings went")
+
+local fullRoot = Menus.rows(Menus.lib.ROOT)
+local fullOffered = Menus.offered()
+T.check(fullRoot["pipeline:voxel"], "FULL keeps the VOXEL row it lives on")
+T.check(not fullRoot["pipeline:tiltshift"],
"FULL takes T-SHIFT off the menu -- it owns the blur")
-T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID")
-T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE")
-T.check(not fullIds["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST")
-T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
+T.check(not fullOffered["DRAMATIC_SHAPE:grid"], "and V-GRID")
+T.check(not fullOffered["DRAMATIC_SHAPE:curve"], "and V-CURVE")
+T.check(not fullOffered["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST")
+T.check(not fullOffered["DRAMATIC_SHAPE:water"], "and WATER")
+T.check(not fullOffered["DRAMATIC_SHAPE:daytime"], "and DAYTIME")
+-- and with all five of them gone the category has nothing left in it, so it
+-- takes ITSELF off the root menu. No rule anywhere says "hide 3D WORLD under
+-- FULL" -- that category is exactly the rows FULL owns, and an empty category
+-- is not offered.
+T.check(not fullRoot[Menus.catId("world")],
+ "so 3D WORLD is empty, and an empty category is not offered")
-- but the battle rows survive it: they are not knobs on the look, and FULL
-- sets them once rather than holding them, so a player who wants the classic
-- back sprite (or no staged fights at all) can still say so from inside FULL
-T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL")
-T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
+T.check(fullOffered["DRAMATIC_SHAPE:battles"],
+ "3D-BTL is still on the menu under FULL")
+T.check(fullOffered["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it")
+T.check(fullRoot[Menus.catId("battles")],
+ "and the category that carries them is still offered")
-- and AA, for the opposite reason: it is not a knob on the look at all, it is
-- what the look COSTS, and only the player knows what their machine can carry
-T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns")
+T.check(fullOffered["DRAMATIC_SHAPE:aa"],
+ "and AA, which FULL neither sets nor owns")
-- SHADOWS on the same reasoning: the sun's pass is what the look costs, and
-- a preset for the diorama does not get to spend a second draw of the whole
-- world on a machine that cannot carry one
-T.check(fullIds["DRAMATIC_SHAPE:shadows"],
+T.check(fullOffered["DRAMATIC_SHAPE:shadows"],
"and SHADOWS, the other row that is a question about the hardware")
+T.check(fullRoot[Menus.catId("perf")],
+ "PERFORMANCE is the category for exactly that question, and it survives too")
-- VR survives FULL on AA's reasoning: whether a headset is on the desk is
-- not the diorama's to decide
-T.check(fullIds["DRAMATIC_SHAPE:vr"], "and VR, likewise the hardware's question")
+T.check(fullOffered["DRAMATIC_SHAPE:vr"],
+ "and VR, likewise the hardware's question")
+end
-- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot
-- be reached while FULL owns it, so a value changed underneath (the mod
@@ -290,8 +361,14 @@ local fxIds = {}
for _, row in ipairs(fxRows) do fxIds[row.id] = true end
T.check(not fxIds["tilt"], "TILT is off the OPTIONS menu")
T.check(not fxIds["gbcfx"], "and so is GBC FX")
-T.check(fxIds["colors"] and fxIds["pipeline:voxel"],
+T.check(fxIds["colors"],
"with every other row the engine offered still on it")
+-- VOXEL is off this list too, but for the opposite reason: not taken away,
+-- MOVED -- onto the mod's own root menu, which the one remaining row opens
+T.check(not fxIds["pipeline:voxel"] and fxIds[Menus.catId(Menus.lib.ROOT)],
+ "and VOXEL moved onto the mod's own menu rather than being dropped")
+T.check(Menus.rows(Menus.lib.ROOT)["pipeline:voxel"],
+ "-- where it is still offered")
T.eq(fxGame.save.options.tilt, 0,
"a save that had TILT on is pinned back to off, not left on with no row")
@@ -310,32 +387,64 @@ T.check(not fullFxIds["tilt"] and not fullFxIds["gbcfx"],
Pipelines.setLevel("voxel", 2)
end
--- ------- and off FULL, the rows come back, grouped with the mode
+-- ------- and off FULL, the rows come back -- on the mod's own menus
--
--- The engine splices a pipeline row in beside TILT and lands a mod's own
--- additions at the END of the list, which would leave this mode's four rows
--- in two places with unrelated rows between them.
+-- What the engine's list carries is ONE row, and it leads. What that row opens
+-- is the mod: the two pipeline rows it took off the engine's list, and the
+-- four categories.
Pipelines.setLevel("voxel", 2)
+do
local grouped = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" },
{ id = "void_fill" } })
-local order = {}
-for i, row in ipairs(grouped) do order[row.id] = i end
-T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"],
- "the mode's settings follow its pipeline rows")
--- V-GRID, V-CURVE, RENDER DIST, WATER, FOREST FX, then 3D-BTL
-T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 6,
- "and sit in one unbroken block, not scattered to the end of the list")
-T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"],
- "with the engine's own later rows still after them")
+-- the engine gave it four rows and got three back: three of ours are gone
+-- (tilt dropped, both pipelines moved) and one is added, at the front
+-- the engine offered four and two come back: TILT dropped, both pipeline rows
+-- moved onto the mod's own menu, and one row added at the front
+T.eq(#grouped, 2, "one row for the whole mod, and the engine's own beneath it")
+T.eq(grouped[1].id, Menus.catId(Menus.lib.ROOT), "and it leads the list")
+T.eq(grouped[2].id, "void_fill",
+ "with the engine's own rows following in the order it built them")
+
+local root = Menus.rows(Menus.lib.ROOT)
+T.check(root["pipeline:voxel"] and root["pipeline:tiltshift"],
+ "both pipeline rows are on the mod's root menu")
+for _, cat in ipairs({ "world", "battles", "perf", "vr" }) do
+ T.check(root[Menus.catId(cat)],
+ ("off FULL the %s category is offered"):format(cat))
+end
+
+-- the split itself: each setting on the category that owns it, and nowhere
+-- else. Order inside a category is free to change; membership is the promise.
+local WHERE = {
+ grid = "world", curve = "world", viewbox = "world", water = "world",
+ daytime = "world",
+ battles = "battles", battleBack = "battles", letsgo = "battles",
+ atmos = "perf", shadows = "perf", aa = "perf",
+ vr = "vr", smoothturn = "vr",
+}
+for key, cat in pairs(WHERE) do
+ local id = "DRAMATIC_SHAPE:" .. key
+ -- smoothturn and battleBack need their own switch on first; they are
+ -- checked where those switches are, so only assert the ones offered now
+ if Menus.offered()[id] then
+ T.check(Menus.rows(cat)[id],
+ ("%s is on the %s menu"):format(key, cat))
+ end
+end
+-- the ROM import is an action, not a setting, and it sits with the rungs of
+-- 3D-BTL it unlocks
+T.check(Menus.rows("battles")["DRAMATIC_SHAPE:stadiumRom"],
+ "and STADIUM ROM is under the battles, which is what it unlocks")
+end
-- ------- the open menu notices when FULL is stepped onto or off
--
--- OptionsMenu reads its row list every frame but builds it once, so without
--- a rebuild the rows FULL owns stay on screen until the menu is reopened --
--- and stepping OFF FULL never brings them back.
+-- A menu reads its row list every frame but builds it once, so without a
+-- rebuild the rows FULL owns stay on screen until it is reopened -- and
+-- stepping OFF FULL never brings them back.
local OptionsMenu = require("src.ui.OptionsMenu")
local pressed = {}
local menuGame = {
@@ -343,17 +452,30 @@ local menuGame = {
save = { options = { pipelines = {}, modOptions = {} } },
mods = { modOptions = {} },
input = { wasPressed = function(_, k) return pressed[k] or false end },
- stack = { pop = function() end },
+ -- a real stack, because the mod's own menus push and pop on it
+ stack = {
+ states = {},
+ push = function(s, st) s.states[#s.states + 1] = st end,
+ pop = function(s) s.states[#s.states] = nil end,
+ top = function(s) return s.states[#s.states] end,
+ },
writeOptions = function() end,
}
-Pipelines.setLevel("voxel", 2)
-local menu = OptionsMenu.new(menuGame)
local function rowIndex(m, id)
for i, row in ipairs(m.rows) do if row.id == id then return i end end
end
-T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
- "off FULL the menu opens with the mode's settings on it")
+
+Pipelines.setLevel("voxel", 2)
+-- The mod's root menu shows the ENGINE's own pipeline descriptors, captured by
+-- the hook on its way past. The sections above fed the hook stub rows, which
+-- are enough to test what is dropped but carry no step function -- so hand it
+-- the real ones before driving the menu, or a keypress on VOXEL does nothing.
+Runtime.call("ui.options.rows", function(_, r) return r end, menuGame,
+ Pipelines.rows(menuGame))
+local menu = Menus.lib.new(menuGame, Menus.lib.ROOT)
+T.check(rowIndex(menu, Menus.catId("world")),
+ "off FULL the root menu opens with the 3D WORLD category on it")
-- step the VOXEL row from 15 down to FULL, the way the player would
menu.index = rowIndex(menu, "pipeline:voxel")
@@ -361,10 +483,12 @@ pressed = { left = true }
menu:update(0)
pressed = {}
T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL")
-T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"),
- "and the rows FULL owns left the OPEN menu at once")
-T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them")
+T.check(not rowIndex(menu, Menus.catId("world")),
+ "and the category FULL emptied left the OPEN menu at once")
+T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with it")
T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range")
+T.eq(menu.index, rowIndex(menu, "pipeline:voxel"),
+ "and stayed on the row the player just used")
-- and back off it again
menu.index = rowIndex(menu, "pipeline:voxel")
@@ -372,16 +496,17 @@ pressed = { right = true }
menu:update(0)
pressed = {}
T.eq(Pipelines.level("voxel"), 2, "the step left FULL")
-T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"),
- "and the rows came straight back without reopening the menu")
+T.check(rowIndex(menu, Menus.catId("world")),
+ "and the category came straight back without reopening the menu")
T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too")
--- ------- 3D-BTL owns BATTLE LAYOUT, and takes it off the OPEN menu too
+-- ------- 3D-BTL owns BATTLE LAYOUT, from a menu the OPTIONS list cannot see
--
--- The row this one takes away sits ABOVE it in the list, so the cursor has to
--- follow the row it was ON rather than the slot it was in -- otherwise the very
--- press that switched staged battles on would leave the cursor a row further
--- down than the player left it.
+-- The switch lives on the mod's BATTLES menu now, and the stack ticks its TOP
+-- state only (src/core/StateStack.update). So the step happens while the
+-- OPTIONS menu underneath is suspended, and a before/after pair taken around
+-- one call of its update would be read entirely after the fact and always
+-- agree. The signature is held on the instance for exactly this.
do
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
Battles.setting:setValue(false, menuGame) -- staged battles off
@@ -390,37 +515,214 @@ Pipelines.setLevel("voxel", 2)
local layoutMenu = OptionsMenu.new(menuGame)
T.check(rowIndex(layoutMenu, "battleLayout"),
"with staged battles off, the engine's BATTLE LAYOUT row is on the menu")
-layoutMenu.index = rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles")
+
+local battleMenu = Menus.lib.new(menuGame, "battles")
+T.check(not rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"),
+ "and BACK SPRITES is off the BATTLES menu, having no staged fight to frame")
+battleMenu.index = rowIndex(battleMenu, "DRAMATIC_SHAPE:battles")
pressed = { right = true }
-layoutMenu:update(0)
+battleMenu:update(0)
pressed = {}
T.eq(Battles.setting:get(), true, "the step switched staged battles on")
-T.check(not rowIndex(layoutMenu, "battleLayout"),
- "and BATTLE LAYOUT left the open menu with the same keypress")
T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out")
-T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"),
+T.check(rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"),
+ "and BACK SPRITES arrived on the open submenu with the same keypress")
+-- it arrives BELOW the row that was used, so this one only holds because the
+-- cursor follows the row rather than the slot
+T.eq(battleMenu.index, rowIndex(battleMenu, "DRAMATIC_SHAPE:battles"),
"with the cursor still on the row the player just used")
+
+layoutMenu:update(0)
+T.check(not rowIndex(layoutMenu, "battleLayout"),
+ "and the OPTIONS menu underneath drops BATTLE LAYOUT on its next update, "
+ .. "though the step it is reacting to happened while it was suspended")
+end
+
+-- ------- the row OPENS the menus, and B comes back one level at a time
+do
+menuGame.stack.states = {}
+Pipelines.setLevel("voxel", 2)
+local hookRow
+for _, row in ipairs(Runtime.call("ui.options.rows", function(_, r) return r end,
+ menuGame, Pipelines.rows(menuGame))) do
+ if row.id == Menus.catId(Menus.lib.ROOT) then hookRow = row end
+end
+T.check(hookRow.activate and not hookRow.step,
+ "the row answers A alone -- `activate` SHADOWS `step` in the engine's own "
+ .. "dispatch, so a row carrying both would silently ignore Left and Right")
+T.eq(hookRow.value(menuGame), "15",
+ "and its second line is VOXEL's own, so it says what the mode is doing "
+ .. "without being opened")
+
+hookRow.activate(menuGame)
+local root = menuGame.stack:top()
+T.check(root ~= nil, "pressing A pushed a screen")
+T.eq(#root.rows, #Menus.lib.rows(Menus.lib.ROOT, menuGame),
+ "carrying the root menu's rows")
+T.eq(root:backLabel(), "BACK", "and a way out named for what it does")
+
+-- the second line of a category row
+T.eq(root.rows[rowIndex(root, Menus.catId("battles"))].value(),
+ Menus.rows("battles", menuGame)["DRAMATIC_SHAPE:battles"].value(),
+ "a category one setting speaks for says that setting's rung")
+T.eq(root.rows[rowIndex(root, Menus.catId("world"))].value(), "5 SETTINGS",
+ "and one no single row speaks for counts them instead, which is honest "
+ .. "rather than arbitrary")
+
+-- into a category, and back out of it a level at a time
+root.index = rowIndex(root, Menus.catId("world"))
+pressed = { a = true }
+root:update(0)
+pressed = {}
+T.eq(menuGame.stack:top():backLabel(), "BACK: 3D WORLD",
+ "A on a category opens it, and the bottom line says which one -- "
+ .. "OptionRows has no header, so the way out carries the name")
+T.check(menuGame.stack:top() ~= root, "which is a screen of its own")
+
+pressed = { b = true }
+menuGame.stack:top():update(0)
+pressed = {}
+T.eq(menuGame.stack:top(), root, "B comes back ONE level, to the root menu")
+pressed = { start = true }
+root:update(0)
+pressed = {}
+T.eq(menuGame.stack:top(), nil, "and START leaves it the way B does")
+end
+
+-- ------- the mod's row is RED, which is a palette zone and not a color
+--
+-- setColor cannot do it here twice over: the glyph atlas is black ink and LOVE
+-- tints multiplicatively, and the palette shader keys on the red channel alone
+-- so a red pixel would land in the LIGHTEST slot (see lib/SettingsMenu.lua).
+-- What a zone changes is which color the SHADE the text was drawn in comes out
+-- as, so what there is to assert is where the band sits and which slot carries
+-- the ink.
+do
+local PaletteFX = require("src.render.PaletteFX")
+-- the fixture dataset ships no palettes; stand MEWMON up, since the point is
+-- that the red palette is MEWMON COPIED rather than four invented colors
+Data.palettes = { palettes = {
+ MEWMON = { { 255, 239, 255 }, { 255, 140, 140 }, { 80, 80, 255 }, { 0, 0, 0 } },
+} }
+
+local red = Menus.lib.redPalette(Data)
+T.eq(#red, 4, "the red palette is a palette like any other, four colors")
+T.eq(red[1][2], 239,
+ "and its paper is MEWMON's own -- Red, Blue and Yellow ship different "
+ .. "tables, and the row must not shift the white it sits on")
+
+-- What has to hold is not which slot the red is in but where it comes OUT:
+-- black text is shade 3 and the white box fill shade 0, and the display mode
+-- transforms the table before the shader ever sees it (effectiveColors). So
+-- assert through that, which is the thing the screen actually draws.
+do
+ local eff = PaletteFX.effectiveColors(red)
+ T.check(eff[4][1] == 255 and eff[4][2] == 0 and eff[4][3] == 0,
+ "the ink shade comes out red")
+ T.eq(eff[1][2], 239, "and the paper shade is the white it always was")
+end
+
+-- SGB INV REVERSES the table, so the red has to start in the other slot to
+-- land on the ink. Without the special case the row draws as a solid red
+-- block with the letters cut out of it.
+PaletteFX.mode = "gbc_inv"
+do
+ local eff = PaletteFX.effectiveColors(Menus.lib.redPalette(Data))
+ T.check(eff[4][1] == 255 and eff[4][2] == 0,
+ "under SGB INV the ink shade STILL comes out red, once the mode has had "
+ .. "its turn at the table")
+ T.eq(eff[1][1], 0,
+ "on the same dark paper SGB INV gives every other row -- red ink, not a "
+ .. "red block")
+end
+PaletteFX.mode = "gbc"
+
+-- the band covers the two TEXT lines of the box and nothing else: tile 1 is
+-- the cursor and tiles 0 and 19 are the box borders, all of them black glyphs
+-- that would turn red with the label if the zone spanned the whole row
+local zone = Menus.lib.rowZone(Data, 1)
+T.eq(zone.x, 16, "the band starts at the label column, past the cursor")
+T.eq(zone.y, 8, "on the first box's label line")
+T.eq(zone.w, 17 * 8, "and stops short of the box's right border")
+T.eq(zone.h, 16, "covering the label and the value under it")
+T.eq(Menus.lib.rowZone(Data, 3).y, 8 * ((3 - 1) * 4 + 1),
+ "and it is addressed by SLOT, so it follows the row down the screen")
+
+-- on the menu itself: the whole-screen palette it always had, plus one band
+menuGame.stack.states = {}
+Runtime.call("ui.options.rows", function(_, r) return r end, menuGame,
+ Pipelines.rows(menuGame))
+local palMenu = OptionsMenu.new(menuGame)
+T.eq(#palMenu:sgbPalettes(menuGame), 2,
+ "the OPTIONS menu paints its own palette and then the one band")
+T.eq(palMenu:sgbPalettes(menuGame)[2].y, 8,
+ "on row 1, where the mod's row leads the list")
+T.eq(palMenu:sgbPalettes(menuGame)[2].colors[4][1], 255, "and it is the red one")
+
+-- scrolled past, the band goes with it: a red stripe left behind on whatever
+-- took the slot would be worse than no marker at all
+palMenu.scroll = 4
+T.eq(#palMenu:sgbPalettes(menuGame), 1,
+ "scrolled off screen, the row takes its band with it")
+
+-- and a submenu paints its own. This is the whole reason it has the method:
+-- Game:draw stops at the first state that HAS one, so a submenu without it
+-- would inherit the band above and land it on an unrelated row.
+T.eq(#Menus.lib.new(menuGame, Menus.lib.ROOT):sgbPalettes(menuGame), 1,
+ "a submenu owns its palette rather than inheriting the red band")
+
+Data.palettes = nil
end
-- level 2 is the "15" rung: any rung that is not FULL, so the settings the
-- preset owns are back on the menu
Pipelines.setLevel("voxel", 2)
-local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
- { data = Data }, { { id = "text_speed" } })
+do
+ -- what the engine really hands the hook: its own rows, the pipeline rows
+ -- among them. The mod moves those two onto its root menu, so a call that
+ -- left them out would leave the root menu with nothing but categories.
+ local given = { { id = "text_speed" } }
+ for _, row in ipairs(Pipelines.rows({ data = Data })) do
+ given[#given + 1] = row
+ end
+ Runtime.call("ui.options.rows", function(_, r) return r end,
+ { data = Data }, given)
+end
+-- Every row the mod offers, keyed by id. RENDER DIST, FOREST FX, LET'S GO,
+-- SHADOWS and AA are read straight out of it where they are used rather than
+-- named here, because this chunk is one main function and has 200 local slots
+-- to spend.
+local hookedRows = Menus.offered()
-- one per setting, plus the STADIUM ROM action row -- which is not a setting
-- (nothing to store, nothing for the mod manager to persist) and is offered
-- on every platform, saying WHERE? rather than IMPORT where there is no file
--- dialog to open
-T.eq(#hookedRows, 14, "the options hook added a row per setting, plus the "
- .. "STADIUM ROM action row")
-local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5]
-local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10]
--- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO
--- hookedRows[9], SHADOWS hookedRows[11] and AA hookedRows[12]; all five are
--- read where they are used rather than named here, because this chunk is one
--- main function and has 200 local slots to spend
-T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label")
-T.eq(hookedRows[4].value(), "FIT",
+-- dialog to open -- and the four category rows that carry them
+do
+ -- named rather than counted: a count says "something moved" and this says
+ -- WHAT is meant to be reachable. SMOOTH TURN is absent because VR is off,
+ -- which is its own gate and is tested where VR is turned on.
+ local want = { "grid", "curve", "viewbox", "water", "daytime",
+ "battles", "battleBack", "letsgo", "stadiumRom",
+ "atmos", "shadows", "aa", "vr" }
+ local n = 0
+ for _ in pairs(hookedRows) do n = n + 1 end
+ for _, key in ipairs(want) do
+ T.check(hookedRows["DRAMATIC_SHAPE:" .. key],
+ ("%s is reachable from the mod's menus"):format(key))
+ end
+ T.check(hookedRows["pipeline:voxel"] and hookedRows["pipeline:tiltshift"],
+ "and both pipeline rows with them")
+ -- those, the two pipelines and the four categories, and nothing else
+ T.eq(n, #want + 2 + 4, "and nothing else is on them")
+end
+local grid = hookedRows["DRAMATIC_SHAPE:grid"]
+local curve, water = hookedRows["DRAMATIC_SHAPE:curve"], hookedRows["DRAMATIC_SHAPE:water"]
+local battles = hookedRows["DRAMATIC_SHAPE:battles"]
+local backRow, daytime = hookedRows["DRAMATIC_SHAPE:battleBack"],
+ hookedRows["DRAMATIC_SHAPE:daytime"]
+T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].label, "RENDER DIST",
+ "the viewport row carries its label")
+T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].value(), "FIT",
"and defaults to FIT -- the cut IS the window the flat game already "
.. "framed, which is the whole claim the row makes")
T.eq(water.label, "WATER", "the water row carries its label")
@@ -429,8 +731,8 @@ T.eq(water.value(), "FULL",
water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
T.eq(water.value(), "SKY",
"stepping down drops the screen-space march and keeps the sky, sun and moon")
-T.eq(hookedRows[6].label, "FOREST FX", "the atmosphere row carries its label")
-T.eq(hookedRows[6].value(), "FULL",
+T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].label, "FOREST FX", "the atmosphere row carries its label")
+T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].value(), "FULL",
"and defaults to FULL -- it only spends anything on a map with an "
.. "atmosphere entry, which is one forest today")
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
@@ -451,8 +753,8 @@ T.eq(backRow.value(), "OFF",
.. "map, so the classic slot is opt-in")
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true),
"on its own key, so it persists beside 3D-BTL rather than over it")
-T.eq(hookedRows[9].label, "LET'S GO", "the capture-mode row carries its label")
-T.eq(hookedRows[9].value(), "OFF",
+T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].label, "LET'S GO", "the capture-mode row carries its label")
+T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].value(), "OFF",
"and starts OFF -- a gameplay mode is opt-in, whatever the diorama does")
-- stepping writes through to the one place both rows read
@@ -532,7 +834,7 @@ do
local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local aaGame = { save = { options = {} }, mods = { modOptions = {} } }
-local aa = hookedRows[12]
+local aa = hookedRows["DRAMATIC_SHAPE:aa"]
T.eq(aa.label, "AA", "the anti-aliasing row carries its label")
T.eq(aa.value(), "OFF",
"and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -588,7 +890,7 @@ end
;(function()
local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows")
local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
-local shadowRow = hookedRows[11]
+local shadowRow = hookedRows["DRAMATIC_SHAPE:shadows"]
local shGame = { save = { options = {} }, mods = { modOptions = {} } }
T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label")
T.eq(shadowRow.value(), "ON",
@@ -3336,19 +3638,16 @@ T.eq(Battles.backSetting:get(), true, "without being rewritten underneath")
-- ...so the row comes off the menu with it, on the same reasoning the mod's
-- other absent rows come off: a row that no longer decides anything is worse
-- than no row
-local offRows = Runtime.call("ui.options.rows", function(_, r) return r end,
- backGame, { { id = "tilt" } })
-local offIds = {}
-for _, row in ipairs(offRows) do offIds[row.id] = true end
-T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
-T.check(not offIds["DRAMATIC_SHAPE:battleBack"],
+local offRows = Menus.rows("battles", backGame)
+T.check(offRows["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
+T.check(not offRows["DRAMATIC_SHAPE:battleBack"],
"but BACK SPRITES is off the menu while there is no staged fight to be about")
Battles.setting:setValue(true, backGame)
-local onRows = Runtime.call("ui.options.rows", function(_, r) return r end,
- backGame, { { id = "tilt" } })
+-- switched back on it returns, and to the BATTLES menu -- the one the switch
+-- it depends on is on, so the two are read together
local onAt = {}
-for i, row in ipairs(onRows) do onAt[row.id] = i end
+for i, row in ipairs(Menus.lib.rows("battles", backGame)) do onAt[row.id] = i end
T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row")
T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
"directly under the row it belongs to")
@@ -5962,26 +6261,28 @@ end)()
local VRMod = lib.require("VR")
T.eq(VRMod.smoothTurn:get(), false, "SMOOTH TURN is off out of the box")
- local function optionRows()
- local out = Runtime.call("ui.options.rows", function(_, r) return r end,
- { data = Data }, { { id = "tilt" } })
- local ids = {}
- for _, row in ipairs(out) do ids[row.id] = row end
- return ids
- end
-
Pipelines.setLevel("voxel", 3) -- off FULL, which owns other rows
VRMod.setting:sync(false)
- T.check(not optionRows()["DRAMATIC_SHAPE:smoothturn"],
- "with VR off the row is not on the OPTIONS menu")
+ T.check(not Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"],
+ "with VR off the row is not on the VR menu")
+ -- and with the headset's own row the only thing left on it, that menu is
+ -- still worth offering: a category is dropped when it is EMPTY, not when it
+ -- is short
+ T.check(Menus.rows(Menus.lib.ROOT)[Menus.catId("vr")],
+ "though the category is, on the strength of the VR row alone")
VRMod.setting:sync(true)
- local smoothRow = optionRows()["DRAMATIC_SHAPE:smoothturn"]
+ local smoothRow = Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"]
T.check(smoothRow ~= nil, "and with VR on it is")
if smoothRow then
T.eq(smoothRow.label, "SMOOTH TURN", "under its own name")
T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise")
end
+ -- VR on takes the two battle rows away, which is the other half of the same
+ -- gate: the headset requires staged battles and forbids back sprites, so
+ -- both rows decide nothing there
+ T.check(not Menus.rows("battles")["DRAMATIC_SHAPE:battles"],
+ "and the battle rows are gone, being decided by the headset instead")
VRMod.setting:sync(false)
-- ------- the gun's own bookkeeping
diff --git a/tests/letsgo_route6.lua b/tests/letsgo_route6.lua
new file mode 100644
index 0000000..4d668f4
--- /dev/null
+++ b/tests/letsgo_route6.lua
@@ -0,0 +1,104 @@
+-- Probe: the reported grass along the TOP of the frame in GO-style
+-- battles on ROUTE 6.
+--
+-- The capture seat is a different camera from the battle's own: the "tele"
+-- rig stands 145 world px back at a height of 37.9 (well over two cells,
+-- clear of anything that grows on the ground), while the head-on capture
+-- seat stands 46 back at a height of 13 -- BELOW the top of a 16px grass
+-- tuft or hedge. On a route lined with the stuff, the eye is inside it.
+--
+-- So: stage a capture at several spots along Route 6 and record where the
+-- eye actually is relative to the ground, with a shot of each.
+--
+-- SHOT_DIR=.scratchpad/route6 \
+-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_route6.lua \
+-- "/c/Program Files/LOVE/lovec.exe" .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route6"
+ local Pokemon = require("src.pokemon.Pokemon")
+ local BattleState = require("src.battle.BattleState")
+ local Bag = require("src.inventory.Bag")
+
+ local exports = game.mods and game.mods.exports
+ local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+ if not lib then
+ U.log("DRAMATIC_SHAPE is not loaded")
+ return
+ end
+ local LetsGo = lib.require("LetsGo")
+ local CatchThrow = lib.require("CatchThrow")
+ local ChunkMesher = lib.require("ChunkMesher")
+
+ pcall(os.execute, 'mkdir -p "' .. DIR .. '" 2>/dev/null')
+ pcall(os.execute, 'mkdir "' .. DIR:gsub("/", "\\") .. '" 2>nul')
+
+ game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) }
+ game.save.player.name = "RED"
+ Bag.add(game.save, "POKE_BALL", 99, game.data)
+ CatchThrow.lastBall = "POKE_BALL"
+ LetsGo.setting:setValue("full", game)
+
+ local MAP = os.getenv("DS_MAP") or "ROUTE_6"
+ -- a spread down the route: the reported shot is on the path with hedges
+ -- both sides, which is where a low seat has the least room
+ local SPOTS = {}
+ for _, xy in ipairs({ { 5, 6 }, { 5, 14 }, { 9, 20 }, { 4, 26 }, { 10, 32 } }) do
+ SPOTS[#SPOTS + 1] = xy
+ end
+
+ for i, sp in ipairs(SPOTS) do
+ U.teleport(game, MAP, sp[1], sp[2], "up")
+ U.wait(50)
+ -- confirm the teleport actually landed: a first cut reported the SAME
+ -- eye at all five spots, which meant the probe never left the save's
+ -- own position and every "spot" was one place wearing five labels
+ do
+ local ow = game.overworld
+ local m = ow and ow.map
+ U.log(("spot %d: on map %s at cell %s,%s")
+ :format(i, tostring(m and (m.id or m.name) or "?"),
+ tostring(ow and ow.player and ow.player.cellX),
+ tostring(ow and ow.player and ow.player.cellY)))
+ end
+ for _ = 1, 600 do
+ if ChunkMesher.pending() == 0 then break end
+ U.wait(1)
+ end
+
+ local bt = BattleState.newWild(game, "ODDISH", 13)
+ bt.onFinish = function() end
+ game.overworld:pushBattle(bt)
+ U.wait(70)
+ for _ = 1, 80 do
+ if bt.phase == "menu" or CatchThrow.session() then break end
+ U.tap(game, "a")
+ U.wait(6)
+ end
+ U.wait(40)
+
+ local s = CatchThrow.session()
+ if s and s.shot and s.shot.eye then
+ local e = s.shot.eye
+ local p = s.playerPos
+ local back = math.sqrt((e[1] - p[1]) ^ 2 + (e[3] - p[3]) ^ 2)
+ U.log(("spot %d (%d,%d): eye = %.1f,%.1f,%.1f height %.1f over ground "
+ .. "seat %.1f back (wants 46)%s")
+ :format(i, sp[1], sp[2], e[1], e[2], e[3], e[2] - s.groundY, back,
+ back < 45.5 and " <-- the world pulled it in" or ""))
+ else
+ U.log(("spot %d (%d,%d): no capture session"):format(i, sp[1], sp[2]))
+ end
+ U.shot(game, ("%s/spot%d.png"):format(DIR, i))
+
+ -- out, and all the way off the stack before the next spot
+ U.tap(game, "b")
+ for _ = 1, 300 do
+ if not CatchThrow.session() and game.stack:top() ~= bt then break end
+ U.tap(game, "a")
+ U.wait(4)
+ end
+ U.wait(30)
+ end
+ U.log("done -- " .. DIR)
+end