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
+66
View File
@@ -77,8 +77,74 @@
nor takes it away, on the same reasoning as AA -- what the look costs is nor takes it away, on the same reasoning as AA -- what the look costs is
the player's question, not a preset's. 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 ### 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 - **A building's back no longer wears its own front door.** Every voxelized
building is its drawing extruded straight through the footprint, so the building is its drawing extruded straight through the footprint, so the
far wall is the facade again -- and read from behind, the facade mirrored: far wall is the facade again -- and read from behind, the facade mirrored:
+38 -2
View File
@@ -285,19 +285,55 @@ end
-- ball at the bottom of the frame, the throw a straight shot up the -- 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`; -- middle. Handed to BattleScene.render through the capture table's `rig`;
-- everything downstream (pins, cards, sun, fov) is camera-generic. -- 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 function captureRig(arena, groundY)
local ex, ez = arena.enemy[1], arena.enemy[2] local ex, ez = arena.enemy[1], arena.enemy[2]
local px, pz = arena.player[1], arena.player[2] local px, pz = arena.player[1], arena.player[2]
local ax, az = ex - px, ez - pz local ax, az = ex - px, ez - pz
local l = math.sqrt(ax * ax + az * az) 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 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 = { 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 }, focus = { ex, groundY + 8, ez },
fov = SEAT_FOV, fov = SEAT_FOV,
} }
-- the pitch VoxelScene.pull wants: how far below level the seat looks -- 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 return cam, math.max(pitch, 0.05), SEAT_FRAME
end end
+10 -6
View File
@@ -168,6 +168,15 @@ function ModSetting:sync(value)
self.index = indexOf(self, value) self.index = indexOf(self, value)
end 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 -- The descriptor src/ui/OptionRows.lua renders, in the shape the
-- ui.options.rows hook appends. -- ui.options.rows hook appends.
function ModSetting:row() function ModSetting:row()
@@ -175,12 +184,7 @@ function ModSetting:row()
return { return {
id = "DRAMATIC_SHAPE:" .. self.key, id = "DRAMATIC_SHAPE:" .. self.key,
label = self.label, label = self.label,
-- the label of the rung actually in force, which is not the stored one value = function() return self_:valueLabel() end,
-- 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,
step = function(game, dir) step = function(game, dir)
self_:cycle(game, dir) self_:cycle(game, dir)
return true return true
+387
View File
@@ -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
+220 -103
View File
@@ -98,6 +98,9 @@ local FirstPerson = V.require("FirstPerson")
local FreeMove = V.require("FreeMove") local FreeMove = V.require("FreeMove")
local CamControl = V.require("CamControl") local CamControl = V.require("CamControl")
local VR = V.require("VR") 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 -- 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 -- 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. -- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design.
@@ -449,8 +452,26 @@ local function stagedBattles()
return OverworldBattle.enabled() return OverworldBattle.enabled()
end 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 = { 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, { WorldCurve.setting,
"Bend the world down over the horizon, Animal Crossing style. 1 is a " "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 " .. "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 " .. "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 " .. "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 " .. "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, { ViewBox.setting,
"How much of the map the camera bothers to draw. FIT is exactly the " "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 " .. "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, " .. "closest, WIDE through WIDEST push the world's edge further out, "
.. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are " .. "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 " .. "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, { Water.setting,
"Reflections on water. FULL adds screen-space reflections of the " "Reflections on water. FULL adds screen-space reflections of the "
.. "shoreline, the trees and the buildings behind it; SKY is the sky, " .. "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 " .. "the sun and the moon alone, which is most of the look for a "
.. "fraction of the cost." }, .. "fraction of the cost.",
-- `full` for the AA reason: additive shafts are fill rate, and under 4X cat = "world" },
-- supersampling that is a question about the hardware, not the look. { DayNight.setting,
{ ForestAtmos.setting, "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
"The air of the deep woods (Viridian Forest): a ground haze, and " .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
.. "volumetric light let down through the unseen canopy overhead -- " .. "shadows, the sky and the light following -- or SYNC it to the "
.. "gold spears of sun by day, silver moon rays at night, pollen " .. "clock on the wall, so Kanto's evening falls when yours does.",
.. "drifting through the beams and fireflies once they cool. LOW " cat = "world" },
.. "keeps the haze, halves the beam march and stands the particles "
.. "down. On a phone the row offers LOW alone: the beams need a " -- ------- BATTLES -- what a fight is drawn over, and how it is played
.. "depth texture the pass can read back, and no mobile driver here " --
.. "grants one.",
full = true },
-- `full` marks a row FULL does not take away. FULL owns the diorama's own -- `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. -- 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 -- 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 " .. "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 " .. "STADIUM ROM row, or drop it in the baseroms folder and restart. No "
.. "other version works: the reader is keyed to that one cartridge.", .. "other version works: the reader is keyed to that one cartridge.",
cat = "battles",
when = function() return not VR.enabled() end, full = true }, when = function() return not VR.enabled() end, full = true },
-- Only offered while a fight can actually be staged on the map: with 3D-BTL -- 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, -- 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 " "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. " .. "original slot, instead of standing it on the map facing the foe. "
.. "The foe is still out there on its own tile.", .. "The foe is still out there on its own tile.",
cat = "battles",
when = function() return stagedBattles() and not VR.enabled() end, when = function() return stagedBattles() and not VR.enabled() end,
full = true }, full = true },
-- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on -- `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 " .. "throws, new species and your running catch combo. Needs 3D-BTL "
.. "on; anywhere the staged fight cannot stand, balls quietly throw " .. "on; anywhere the staged fight cannot stand, balls quietly throw "
.. "the classic way.", .. "the classic way.",
full = true }, cat = "battles", full = true },
{ DayNight.setting,
"What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " -- ------- PERFORMANCE -- what the look COSTS, which is a different question
.. "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 " -- All three are `full`, and all three for the same reason: FULL is a preset
.. "clock on the wall, so Kanto's evening falls when yours does." }, -- 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 -- `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 -- 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 -- 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 " .. "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 " .. "shadows under characters included, and the forest's light shafts go "
.. "with it: the beams are lit by the sun's own map.", .. "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 -- 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 -- knob on the look at all, it is what the look COSTS.
-- 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.
{ AntiAlias.setting, { AntiAlias.setting,
"Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge " "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge "
.. "lips, a tree against the sky -- by rendering the diorama larger than " .. "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 " .. "reads smoother rather than sharper. 2X costs half again as many "
.. "pixels in each direction and 4X twice, which makes this the most " .. "pixels in each direction and 4X twice, which makes this the most "
.. "expensive row in the mod.", .. "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 -- `full` for the same reason as AA: not a knob on the look, a question
-- about the hardware on the desk. -- about the hardware on the desk.
{ VR.setting, { VR.setting,
@@ -588,6 +624,7 @@ local SETTINGS = {
.. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime " .. "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 mod running from a real folder; without them the row stays "
.. "and the game stays flat, with the reason on the console.", .. "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 -- 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 -- says why); off Windows -- mobile above all -- there is no VR to have
-- and the row does not exist -- 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 " .. "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 " .. "to make somebody ill in a headset. Turn it on if you have your sea "
.. "legs and want the continuity.", .. "legs and want the continuity.",
cat = "vr",
-- and only under STANDARD: the stick turns a HEAD, and neither diorama -- and only under STANDARD: the stick turns a HEAD, and neither diorama
-- mode has the player standing in the world to be turned -- mode has the player standing in the world to be turned
when = function() return VR.enabled() and not VR.dioramaMode() end, when = function() return VR.enabled() and not VR.dioramaMode() end,
full = true }, full = true },
} }
SettingsMenu.define(SETTINGS)
local schema = {} local schema = {}
for _, entry in ipairs(SETTINGS) do for _, entry in ipairs(SETTINGS) do
-- the VR rows are absent from the mod manager's page too where the -- the VR rows are absent from the mod manager's page too where the
@@ -783,29 +823,31 @@ do
end end
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 -- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one
-- belongs with the other display modes; a mod's own ui.options.rows -- block spliced in beside the pipeline rows. OptionRows shows four boxes at a
-- additions land at the END of the list. That left this mod's four rows in -- time, so that was four screens of scrolling inside a list that already
-- two places with unrelated engine rows between them, which reads as two -- carried twenty engine rows, and finding SHADOWS meant knowing it was in
-- unrelated features rather than one mode with settings. -- there past the wireframe and the horizon bend.
-- --
-- So the plain settings are inserted directly after the last of this mod's -- Now there is ONE row, and it leads the list. What it opens -- the
-- PIPELINE rows instead of appended. Nothing else moves: the block lands -- categories, the screens, and why the split falls where it does -- is
-- where the engine already decided display modes go. -- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's
local function insertGrouped(out, extra) -- display modes, the engine only spliced them beside TILT because it had
local anchor = nil -- nowhere better, and TILT is not on the menu any more anyway (see below).
for i, row in ipairs(out) do --
local id = type(row) == "table" and row.id -- Two things it takes to move a pipeline row: the engine's descriptor is
if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end -- 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 end
if not anchor then return nil
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
end end
-- FULL owns the settings that describe the LOOK, so while it is selected those -- 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 if changed and game.writeOptions then pcall(game.writeOptions, game) end
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 -- call next() first and decorate what comes back, so every other mod's
-- rows survive this one -- rows survive this one
mod.hooks:wrap("ui.options.rows", function(next, game, rows) 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) OverworldBattle.forceOG(game)
dropRow(out, "battleLayout") dropRow(out, "battleLayout")
end end
local full = Voxel.isFull(Pipelines.level("voxel")) if Voxel.isFull(Pipelines.level("voxel")) then
if full then
-- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the -- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the
-- horizon bend, the blur, the hour -- so those come off the menu and -- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its
-- DAYTIME is held at SYNC while its row is unreachable. -- 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) DayNight.forceSync(game)
dropRow(out, "pipeline:tiltshift")
end end
local extra = {} -- The two pipeline rows move INTO the mod's own root menu: captured as the
for _, entry in ipairs(SETTINGS) do -- engine built them, then dropped from here so they are not in two places.
-- Two things decide whether a row is offered. local captured, voxelRow = {}, nil
-- for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do
-- FULL: a preset that owns the look, so the rows that describe the look go local row = captureRow(out, id)
-- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is -- a pipeline the registry refused is simply not there, and the menu says
-- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on -- so by not offering it rather than by offering a hole
-- the diorama FULL is a preset for. FULL still SETS them on arrival (see if row then captured[#captured + 1] = row end
-- applyFull); it does not hold them, so leaving them on the menu is the if id == "pipeline:voxel" then voxelRow = row end
-- difference between a preset and a lock. dropRow(out, id)
--
-- 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
end end
-- and the ROM import, which is an ACTION and not a setting: there is no SettingsMenu.setPipelineRows(captured)
-- rung to store, nothing for the mod manager's page to persist and nothing -- ------- one row, and it leads the list
-- 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 -- At the TOP rather than spliced in beside the display modes it used to sit
-- menu rather than offering a button that cannot do anything. -- with. This is a mod that replaces the whole look of the game, and a player
-- On EVERY platform. Where there is no file dialog it says WHERE? and -- who installed it and went looking for its settings should not have to
-- shows the folder to put the cartridge in, which is the one thing a -- scroll to find out where they went -- least of all past the engine rows it
-- player on a phone could not otherwise find out -- the row used to vanish -- has quietly taken away.
-- there, which reads as the feature being missing rather than manual. --
local okPick, importRow = pcall(function() -- Inserted after next() has run, so it leads every OTHER mod's rows too. The
return V.require("StadiumRomPick").row() -- second line is VOXEL's own value function, which makes the row say what
end) -- the mode is currently doing without opening it -- and reuses the engine's
if okPick and importRow then extra[#extra + 1] = importRow end -- label ladder rather than restating it.
return insertGrouped(out, extra) 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) end)
-- The mod manager writes and persists on its own, so the only thing left -- 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 if payload.key == entry[1].key then entry[1]:sync(payload.value) end
end end
-- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as -- 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 -- the mod's own row does, and DAYTIME changed there while FULL owns it snaps
-- that has to follow it. -- straight back to SYNC -- that row is off the mod's menus under FULL, but
if stagedBattles() then OverworldBattle.forceOG() end -- the manager's page carries every setting unconditionally, and the pin has
-- and DAYTIME changed from the manager's page while FULL owns it snaps -- to hold against both.
-- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is pinDependents()
-- 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
end) end)
-- ------- keeping the geometry in step with the world -- ------- 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 -- 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 -- 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. -- 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 do
local OptionsMenu = require("src.ui.OptionsMenu") local OptionsMenu = require("src.ui.OptionsMenu")
if not OptionsMenu.dramaticShapeFullHook then if not OptionsMenu.dramaticShapeFullHook then
local OptionRows = require("src.ui.OptionRows")
local Pipelines = require("src.render.Pipelines") local Pipelines = require("src.render.Pipelines")
local inner = OptionsMenu.update local inner = OptionsMenu.update
local innerPalettes = OptionsMenu.sgbPalettes
local function idAt(menu, index) local function idAt(menu, index)
local row = menu.rows and menu.rows[index or 1] local row = menu.rows and menu.rows[index or 1]
return type(row) == "table" and row.id or nil return type(row) == "table" and row.id or nil
end 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) 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 wasOn = idAt(self, self.index)
local before = self.dramaticShapeSig or signature()
inner(self, dt) inner(self, dt)
local after = Pipelines.level("voxel") local after = signature()
local crossedFull = after ~= before self.dramaticShapeSig = after
and (Voxel.isFull(before) or Voxel.isFull(after)) if before ~= after then
if crossedFull or OverworldBattle.enabled() ~= hadBattles
or VR.enabled() ~= hadVR then
local rebuilt = OptionsMenu.new(self.game) local rebuilt = OptionsMenu.new(self.game)
self.rows = rebuilt.rows self.rows = rebuilt.rows
-- Follow the row the cursor was ON rather than the slot it was in: -- Follow the row the cursor was ON rather than the slot it was in:
@@ -1095,6 +1182,36 @@ do
end end
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 OptionsMenu.dramaticShapeFullHook = true
end end
end end
+388 -87
View File
@@ -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 Runtime = require("src.mods.Runtime")
local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") 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 -- ------- 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 -- 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. -- go, which is what makes it a preset rather than a lock.
Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL) 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, local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" }, { { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" } }) { id = "pipeline:tiltshift" } })
local fullIds = {} local fullIds = {}
for _, row in ipairs(fullRows) do fullIds[row.id] = true end for _, row in ipairs(fullRows) do fullIds[row.id] = true end
T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on") -- what the hook leaves on the ENGINE's list: one row, and the pipeline rows
T.check(not fullIds["pipeline:tiltshift"], -- 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") "FULL takes T-SHIFT off the menu -- it owns the blur")
T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID") T.check(not fullOffered["DRAMATIC_SHAPE:grid"], "and V-GRID")
T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE") T.check(not fullOffered["DRAMATIC_SHAPE:curve"], "and V-CURVE")
T.check(not fullIds["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST") T.check(not fullOffered["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST")
T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") 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 -- 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 -- 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 -- 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(fullOffered["DRAMATIC_SHAPE:battles"],
T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") "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 -- 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 -- 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 -- 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 -- a preset for the diorama does not get to spend a second draw of the whole
-- world on a machine that cannot carry one -- 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") "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 -- VR survives FULL on AA's reasoning: whether a headset is on the desk is
-- not the diorama's to decide -- 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 -- 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 -- 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 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["tilt"], "TILT is off the OPTIONS menu")
T.check(not fxIds["gbcfx"], "and so is GBC FX") 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") "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, T.eq(fxGame.save.options.tilt, 0,
"a save that had TILT on is pinned back to off, not left on with no row") "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) Pipelines.setLevel("voxel", 2)
end 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 -- What the engine's list carries is ONE row, and it leads. What that row opens
-- additions at the END of the list, which would leave this mode's four rows -- is the mod: the two pipeline rows it took off the engine's list, and the
-- in two places with unrelated rows between them. -- four categories.
Pipelines.setLevel("voxel", 2) Pipelines.setLevel("voxel", 2)
do
local grouped = Runtime.call("ui.options.rows", function(_, r) return r end, local grouped = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { data = Data },
{ { id = "tilt" }, { id = "pipeline:voxel" }, { { id = "tilt" }, { id = "pipeline:voxel" },
{ id = "pipeline:tiltshift" }, { id = "pipeline:tiltshift" },
{ id = "void_fill" } }) { id = "void_fill" } })
local order = {} -- the engine gave it four rows and got three back: three of ours are gone
for i, row in ipairs(grouped) do order[row.id] = i end -- (tilt dropped, both pipelines moved) and one is added, at the front
T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], -- the engine offered four and two come back: TILT dropped, both pipeline rows
"the mode's settings follow its pipeline rows") -- moved onto the mod's own menu, and one row added at the front
-- V-GRID, V-CURVE, RENDER DIST, WATER, FOREST FX, then 3D-BTL T.eq(#grouped, 2, "one row for the whole mod, and the engine's own beneath it")
T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 6, T.eq(grouped[1].id, Menus.catId(Menus.lib.ROOT), "and it leads the list")
"and sit in one unbroken block, not scattered to the end of the list") T.eq(grouped[2].id, "void_fill",
T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], "with the engine's own rows following in the order it built them")
"with the engine's own later rows still after 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 -- ------- 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 menu reads its row list every frame but builds it once, so without a
-- a rebuild the rows FULL owns stay on screen until the menu is reopened -- -- rebuild the rows FULL owns stay on screen until it is reopened -- and
-- and stepping OFF FULL never brings them back. -- stepping OFF FULL never brings them back.
local OptionsMenu = require("src.ui.OptionsMenu") local OptionsMenu = require("src.ui.OptionsMenu")
local pressed = {} local pressed = {}
local menuGame = { local menuGame = {
@@ -343,17 +452,30 @@ local menuGame = {
save = { options = { pipelines = {}, modOptions = {} } }, save = { options = { pipelines = {}, modOptions = {} } },
mods = { modOptions = {} }, mods = { modOptions = {} },
input = { wasPressed = function(_, k) return pressed[k] or false end }, 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, writeOptions = function() end,
} }
Pipelines.setLevel("voxel", 2)
local menu = OptionsMenu.new(menuGame)
local function rowIndex(m, id) local function rowIndex(m, id)
for i, row in ipairs(m.rows) do if row.id == id then return i end end for i, row in ipairs(m.rows) do if row.id == id then return i end 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 -- step the VOXEL row from 15 down to FULL, the way the player would
menu.index = rowIndex(menu, "pipeline:voxel") menu.index = rowIndex(menu, "pipeline:voxel")
@@ -361,10 +483,12 @@ pressed = { left = true }
menu:update(0) menu:update(0)
pressed = {} pressed = {}
T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL") T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL")
T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"), T.check(not rowIndex(menu, Menus.catId("world")),
"and the rows FULL owns left the OPEN menu at once") "and the category FULL emptied left the OPEN menu at once")
T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them") T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with it")
T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range") 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 -- and back off it again
menu.index = rowIndex(menu, "pipeline:voxel") menu.index = rowIndex(menu, "pipeline:voxel")
@@ -372,16 +496,17 @@ pressed = { right = true }
menu:update(0) menu:update(0)
pressed = {} pressed = {}
T.eq(Pipelines.level("voxel"), 2, "the step left FULL") T.eq(Pipelines.level("voxel"), 2, "the step left FULL")
T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"), T.check(rowIndex(menu, Menus.catId("world")),
"and the rows came straight back without reopening the menu") "and the category came straight back without reopening the menu")
T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too") 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 -- The switch lives on the mod's BATTLES menu now, and the stack ticks its TOP
-- follow the row it was ON rather than the slot it was in -- otherwise the very -- state only (src/core/StateStack.update). So the step happens while the
-- press that switched staged battles on would leave the cursor a row further -- OPTIONS menu underneath is suspended, and a before/after pair taken around
-- down than the player left it. -- 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 do
local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle")
Battles.setting:setValue(false, menuGame) -- staged battles off Battles.setting:setValue(false, menuGame) -- staged battles off
@@ -390,37 +515,214 @@ Pipelines.setLevel("voxel", 2)
local layoutMenu = OptionsMenu.new(menuGame) local layoutMenu = OptionsMenu.new(menuGame)
T.check(rowIndex(layoutMenu, "battleLayout"), T.check(rowIndex(layoutMenu, "battleLayout"),
"with staged battles off, the engine's BATTLE LAYOUT row is on the menu") "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 } pressed = { right = true }
layoutMenu:update(0) battleMenu:update(0)
pressed = {} pressed = {}
T.eq(Battles.setting:get(), true, "the step switched staged battles on") 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(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") "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 end
-- level 2 is the "15" rung: any rung that is not FULL, so the settings the -- level 2 is the "15" rung: any rung that is not FULL, so the settings the
-- preset owns are back on the menu -- preset owns are back on the menu
Pipelines.setLevel("voxel", 2) Pipelines.setLevel("voxel", 2)
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, do
{ data = Data }, { { id = "text_speed" } }) -- 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 -- 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 -- (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 -- on every platform, saying WHERE? rather than IMPORT where there is no file
-- dialog to open -- dialog to open -- and the four category rows that carry them
T.eq(#hookedRows, 14, "the options hook added a row per setting, plus the " do
.. "STADIUM ROM action row") -- named rather than counted: a count says "something moved" and this says
local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5] -- WHAT is meant to be reachable. SMOOTH TURN is absent because VR is off,
local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10] -- which is its own gate and is tested where VR is turned on.
-- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO local want = { "grid", "curve", "viewbox", "water", "daytime",
-- hookedRows[9], SHADOWS hookedRows[11] and AA hookedRows[12]; all five are "battles", "battleBack", "letsgo", "stadiumRom",
-- read where they are used rather than named here, because this chunk is one "atmos", "shadows", "aa", "vr" }
-- main function and has 200 local slots to spend local n = 0
T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label") for _ in pairs(hookedRows) do n = n + 1 end
T.eq(hookedRows[4].value(), "FIT", 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 " "and defaults to FIT -- the cut IS the window the flat game already "
.. "framed, which is the whole claim the row makes") .. "framed, which is the whole claim the row makes")
T.eq(water.label, "WATER", "the water row carries its label") 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) water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1)
T.eq(water.value(), "SKY", T.eq(water.value(), "SKY",
"stepping down drops the screen-space march and keeps the sky, sun and moon") "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["DRAMATIC_SHAPE:atmos"].label, "FOREST FX", "the atmosphere row carries its label")
T.eq(hookedRows[6].value(), "FULL", T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].value(), "FULL",
"and defaults to FULL -- it only spends anything on a map with an " "and defaults to FULL -- it only spends anything on a map with an "
.. "atmosphere entry, which is one forest today") .. "atmosphere entry, which is one forest today")
T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") 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") .. "map, so the classic slot is opt-in")
T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true), 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") "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["DRAMATIC_SHAPE:letsgo"].label, "LET'S GO", "the capture-mode row carries its label")
T.eq(hookedRows[9].value(), "OFF", T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].value(), "OFF",
"and starts OFF -- a gameplay mode is opt-in, whatever the diorama does") "and starts OFF -- a gameplay mode is opt-in, whatever the diorama does")
-- stepping writes through to the one place both rows read -- 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 AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias")
local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid")
local aaGame = { save = { options = {} }, mods = { modOptions = {} } } 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.label, "AA", "the anti-aliasing row carries its label")
T.eq(aa.value(), "OFF", T.eq(aa.value(), "OFF",
"and starts off -- supersampling is a cost knob, and a mod must not spend " "and starts off -- supersampling is a cost knob, and a mod must not spend "
@@ -588,7 +890,7 @@ end
;(function() ;(function()
local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows") local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows")
local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") 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 = {} } } local shGame = { save = { options = {} }, mods = { modOptions = {} } }
T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label") T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label")
T.eq(shadowRow.value(), "ON", 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 -- ...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 -- other absent rows come off: a row that no longer decides anything is worse
-- than no row -- than no row
local offRows = Runtime.call("ui.options.rows", function(_, r) return r end, local offRows = Menus.rows("battles", backGame)
backGame, { { id = "tilt" } }) T.check(offRows["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered")
local offIds = {} T.check(not offRows["DRAMATIC_SHAPE:battleBack"],
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"],
"but BACK SPRITES is off the menu while there is no staged fight to be about") "but BACK SPRITES is off the menu while there is no staged fight to be about")
Battles.setting:setValue(true, backGame) Battles.setting:setValue(true, backGame)
local onRows = Runtime.call("ui.options.rows", function(_, r) return r end, -- switched back on it returns, and to the BATTLES menu -- the one the switch
backGame, { { id = "tilt" } }) -- it depends on is on, so the two are read together
local onAt = {} 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.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row")
T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1,
"directly under the row it belongs to") "directly under the row it belongs to")
@@ -5962,26 +6261,28 @@ end)()
local VRMod = lib.require("VR") local VRMod = lib.require("VR")
T.eq(VRMod.smoothTurn:get(), false, "SMOOTH TURN is off out of the box") 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 Pipelines.setLevel("voxel", 3) -- off FULL, which owns other rows
VRMod.setting:sync(false) VRMod.setting:sync(false)
T.check(not optionRows()["DRAMATIC_SHAPE:smoothturn"], T.check(not Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"],
"with VR off the row is not on the OPTIONS menu") "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) 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") T.check(smoothRow ~= nil, "and with VR on it is")
if smoothRow then if smoothRow then
T.eq(smoothRow.label, "SMOOTH TURN", "under its own name") T.eq(smoothRow.label, "SMOOTH TURN", "under its own name")
T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise") T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise")
end 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) VRMod.setting:sync(false)
-- ------- the gun's own bookkeeping -- ------- the gun's own bookkeeping
+104
View File
@@ -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