diff --git a/CHANGELOG.md b/CHANGELOG.md index c468440..73c81ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,8 +77,74 @@ nor takes it away, on the same reasoning as AA -- what the look costs is the player's question, not a preset's. +### Changed + +- **The settings live on menus of their own now, behind one red row at the + top of OPTIONS.** This mod had grown to fourteen rows on the engine's + list, spliced in as one block. OPTIONS shows four boxes at a time, so that + was four screens of scrolling inside a list that already carried twenty + engine rows, and finding SHADOWS meant knowing it was in there somewhere + past the wireframe and the horizon bend. + + What is on OPTIONS now is `DRAMATIC SHAPE..`, and it leads the list -- + a mod that replaces the look of the whole game should not make the player + scroll to find out where its settings went, least of all past the engine + rows it has quietly taken away. It opens VOXEL and T-SHIFT, which came off + the engine's list with it, and four categories: **3D WORLD** (V-GRID, + V-CURVE, RENDER DIST, WATER, DAYTIME), **BATTLES** (3D-BTL, BACK SPRITES, + LET'S GO, STADIUM ROM), **PERFORMANCE** (FOREST FX, SHADOWS, AA) and + **VR** (VR, SMOOTH TURN). + + The split is not a new opinion: it is the `full` flag each row already + carried. `full` marks a row the FULL preset does not take away, and the + reason written beside each one was always the same -- this is a question + about the HARDWARE, or about the GAME, not a knob on the diorama FULL is a + preset for. So 3D WORLD is exactly the rows FULL owns, and needs no rule + to disappear under it: every child filters itself out and an empty category + is not offered. Under FULL the menu is four rows on one screen with no + scroll arrow. The same rule retires VR where there is no VR to have. + + **Nothing you had set has moved.** Every setting keeps its stored key, its + ladder and its row id, so `options.lua` is byte-identical across the + upgrade for a player who changes nothing -- and the hotkeys are untouched, + which is what makes the nesting affordable: 3, 5, 6, 7, 8 and 9 still put + every buried row one keypress away. The mod manager's own page still lists + all thirteen settings flat, now in category order. + + The row is drawn in red, which is a palette zone rather than a color: + `setColor` cannot tint this text, because the glyph atlas is black ink and + LOVE tints multiplicatively, and because the palette shader keys on the red + channel alone and would send a red pixel to the lightest slot. What the + zone changes is which color the shade the text was drawn in comes out as. + It is MEWMON -- the palette the OPTIONS menu already wears -- copied with + only the ink slot replaced, so the paper under the row is the same white as + the row above it in all three ROMs, and the band covers the two text lines + alone rather than the cursor and the box borders beside them. SGB INV + reverses a palette, so there the red starts in the other slot and still + lands on the ink; OG, OG INV and CLASSIC substitute their own tables + outright, and the row simply draws monochrome, which is what asking for a + screen with no colors in it should get. + ### Fixed +- **A setting that pins another one now pins it from wherever it was + changed.** 3D-BTL holds BATTLE LAYOUT at OG while a fight can be staged on + the map, and FULL holds DAYTIME at SYNC while it owns that row. Both pins + used to be a side effect of the options-rows hook, which every step on the + OPTIONS menu happened to rerun -- so they fired whether or not the step was + the one that mattered, and nothing had to name them. A step made on the + mod's own menus reruns no hook, so the pinning is a function now, and the + hook, the menus and the mod manager's page all ask for it. + +- **An open OPTIONS menu notices a change made on a menu pushed over it.** + The rebuild that keeps the row list honest compared the voxel level and the + two battle switches across one call of `update`. The stack ticks its top + state only, so a step taken on one of the mod's own menus happens while + OPTIONS is suspended: both halves of that comparison were read after the + fact and always agreed, and OPTIONS came back still showing a BATTLE LAYOUT + row that no longer belonged there. The signature is held on the menu and + stamped where the rows are built, which is the thing it is a signature of. + - **A building's back no longer wears its own front door.** Every voxelized building is its drawing extruded straight through the footprint, so the far wall is the facade again -- and read from behind, the facade mirrored: diff --git a/lib/CatchThrow.lua b/lib/CatchThrow.lua index 287e0f3..777ec75 100644 --- a/lib/CatchThrow.lua +++ b/lib/CatchThrow.lua @@ -285,19 +285,55 @@ end -- ball at the bottom of the frame, the throw a straight shot up the -- middle. Handed to BattleScene.render through the capture table's `rig`; -- everything downstream (pins, cards, sun, fov) is camera-generic. +-- ------- and how far back the WORLD lets it stand +-- +-- SEAT_BACK is what the shot wants. It is not always available: this seat +-- is low (SEAT_UP is 13 world pixels, under a single cell) where the +-- battle's own rig stands at 37.9, and it is planted three cells behind +-- the player wherever the encounter happened -- which on a hedged route +-- like ROUTE 6 is inside the hedge. The eye then looks out from within +-- the foliage and the scenery it is standing in hangs across the top of +-- the frame, over the horizon and the distant rooftops both. +-- +-- ThirdPerson's boom answers exactly this question for the free-roam +-- camera, so it answers it here rather than being restated: march back +-- from the player's cell and stop at the first thing the eye may not be +-- inside, keeping its PAD. Its rule is the right one for a seat this low, +-- too -- terrain height always blocks, and anything BUILT on a cell +-- blocks below head height, so a kerb is passed over and a hedge is not. +-- +-- Pulling in brings the foe closer in a fixed field of view, which is a +-- composition the throw already handles: the ring, the collision and the +-- drag are all measured from the live shot, so they follow the seat. +local SEAT_MIN_BACK = 20 -- never nearer than this: the held ball hangs + -- at the player's own cell and has to stay in + -- front of the eye + +local function seatBack(px, pz, ax, az, eyeY) + local ok, ow = pcall(function() return game().overworld end) + if not (ok and ow) then return SEAT_BACK end + local ThirdPerson = V.require("ThirdPerson") + local okR, reach = pcall(ThirdPerson.reach, ow, { px, eyeY, pz }, + -ax, 0, -az, SEAT_BACK) + if not (okR and reach) then return SEAT_BACK end + return math.max(SEAT_MIN_BACK, math.min(SEAT_BACK, reach)) +end + local function captureRig(arena, groundY) local ex, ez = arena.enemy[1], arena.enemy[2] local px, pz = arena.player[1], arena.player[2] local ax, az = ex - px, ez - pz local l = math.sqrt(ax * ax + az * az) if l < 1e-6 then ax, az = 0, -1 else ax, az = ax / l, az / l end + local eyeY = groundY + SEAT_UP + local back = seatBack(px, pz, ax, az, eyeY) local cam = { - eye = { px - ax * SEAT_BACK, groundY + SEAT_UP, pz - az * SEAT_BACK }, + eye = { px - ax * back, eyeY, pz - az * back }, focus = { ex, groundY + 8, ez }, fov = SEAT_FOV, } -- the pitch VoxelScene.pull wants: how far below level the seat looks - local pitch = math.atan2(SEAT_UP - 8, SEAT_BACK + l) + local pitch = math.atan2(SEAT_UP - 8, back + l) return cam, math.max(pitch, 0.05), SEAT_FRAME end diff --git a/lib/ModSetting.lua b/lib/ModSetting.lua index 33c44ad..d87112b 100644 --- a/lib/ModSetting.lua +++ b/lib/ModSetting.lua @@ -168,6 +168,15 @@ function ModSetting:sync(value) self.index = indexOf(self, value) end +-- The label of the rung actually in force, which is not the stored one when +-- that rung has been gated away (see get). Its own entry point because a +-- caller can want the label without wanting a row: SettingsMenu puts one +-- setting's rung on the second line of the CATEGORY that contains it. +function ModSetting:valueLabel() + local i = self:read() + return self.labels[self:allows(i) and i or 1] +end + -- The descriptor src/ui/OptionRows.lua renders, in the shape the -- ui.options.rows hook appends. function ModSetting:row() @@ -175,12 +184,7 @@ function ModSetting:row() return { id = "DRAMATIC_SHAPE:" .. self.key, label = self.label, - -- the label of the rung actually in force, which is not the stored one - -- when that rung has been gated away (see get) - value = function() - local i = self_:read() - return self_.labels[self_:allows(i) and i or 1] - end, + value = function() return self_:valueLabel() end, step = function(game, dir) self_:cycle(game, dir) return true diff --git a/lib/SettingsMenu.lua b/lib/SettingsMenu.lua new file mode 100644 index 0000000..0b19d67 --- /dev/null +++ b/lib/SettingsMenu.lua @@ -0,0 +1,387 @@ +-- This mod's settings, in categories, on menus of their own. +-- +-- ------- why the flat list had to end +-- +-- Every setting used to be spliced straight into the engine's OPTIONS list, +-- one unbroken block of fourteen rows after the pipeline rows. OptionRows +-- shows FOUR boxes at a time (src/ui/OptionRows.VISIBLE), so that block alone +-- was four screens of scrolling inside a list that already carried twenty +-- engine rows -- and a player looking for SHADOWS had to know it was in there +-- somewhere, past the wireframe and the horizon bend. +-- +-- The engine has no grouping to borrow: a row descriptor is +-- { id, label, value, step, activate } and nothing else. No headers, no +-- sections, no pages. What it DOES have is `activate`, and a state stack that +-- any state may push onto -- which is how the engine's own MODS and CONTROLS +-- rows work (src/ui/OptionsMenu.lua). So the categories are real screens. +-- +-- ------- how the split was chosen +-- +-- Not invented here: the mod already sorted its own settings, in the `full` +-- flag on each SETTINGS entry. `full` marks a row the FULL preset does NOT +-- take away, and the reasoning written next to each one is always the same +-- -- this is a question about the HARDWARE, or about the GAME, not a knob on +-- the diorama FULL is a preset for. +-- +-- So 3D WORLD is exactly the set FULL owns, which is why it needs no special +-- case to disappear under FULL: every child filters itself out and the +-- category goes with them (see rows). PERFORMANCE is the three rows marked +-- `full` for cost -- FOREST FX among them, on its own comment's reasoning +-- ("`full` for the AA reason: additive shafts are fill rate"). BATTLES and VR +-- are the two features that are not about the look at all. +-- +-- ------- what did NOT change +-- +-- Nothing that persists. Every ModSetting keeps its key, its ladder and its +-- row id, so options.lua is byte-identical for a player who upgrades and +-- changes nothing -- see lib/ModSetting.lua for why the key is the only +-- identity a setting has. The hotkeys are untouched too, which is what makes +-- the nesting affordable: a buried row is still one keypress away. + +-- the mod namespace (see main.lua) +local V = ... + +local OptionRows = require("src.ui.OptionRows") +local PaletteFX = require("src.render.PaletteFX") + +local SettingsMenu = {} +SettingsMenu.__index = SettingsMenu + +-- Opaque like the OPTIONS menu it sits on: the screen underneath is fully +-- covered, so there is no reason to pay for drawing it. +SettingsMenu.isOpaque = true + +SettingsMenu.ROOT = "root" +SettingsMenu.ROOT_LABEL = "DRAMATIC SHAPE.." + +-- Row ids live in a namespace of their own -- "menu." rather than a setting +-- key -- so they can never collide with the "DRAMATIC_SHAPE:" ids the +-- settings rows have carried since the beginning. +function SettingsMenu.id(catId) + return "DRAMATIC_SHAPE:menu." .. catId +end + +-- ------- the categories, in menu order +-- +-- `summary` is the second line of the category's own row, the way MODS reads +-- "%d INSTALLED" on the engine's menu. Where one setting IS the category -- +-- 3D-BTL for the battles, VR for the headset -- it says that setting's +-- current rung, which is the thing a player actually wants to know without +-- opening it. Where no single row speaks for the rest, it counts them, which +-- is honest rather than arbitrary. +SettingsMenu.CATEGORIES = { + { id = "world", label = "3D WORLD.." }, + { id = "battles", label = "BATTLES..", + summary = function() return V.require("OverworldBattle").setting:valueLabel() end }, + { id = "perf", label = "PERFORMANCE.." }, + { id = "vr", label = "VR..", + summary = function() return V.require("VR").setting:valueLabel() end }, +} + +-- ------- what the menus are built from +-- +-- SETTINGS lives in main.lua, next to the help text that goes with each row +-- and the comments explaining every `when` and `full`. It is handed here +-- rather than moved, so this file stays about PRESENTATION and that one stays +-- the single place the mod's settings are declared. +local settings = {} +local pipelineRows = {} + +function SettingsMenu.define(list) + settings = list or {} +end + +-- VOXEL and T-SHIFT are the ENGINE's row descriptors (src/render/Pipelines +-- .rows), captured by the options hook on its way past and shown here instead +-- of at the top level. Reused verbatim, tables and all: they persist in +-- save.options.pipelines through their own step functions, and rebuilding +-- them here would be a second implementation of a thing the engine already +-- got right. +function SettingsMenu.setPipelineRows(rows) + pipelineRows = rows or {} +end + +-- ------- a step here has the same consequences as a step anywhere +-- +-- Two of these settings PIN something else when they change: 3D-BTL holds +-- BATTLE LAYOUT at OG while a fight can be staged on the map, and FULL holds +-- DAYTIME at SYNC while it owns that row. Both used to happen because every +-- step on the OPTIONS menu reran the ui.options.rows hook, which does the +-- pinning on its way past. +-- +-- Nothing reruns that hook from in here, so the pin is asked for directly. +-- main.lua supplies it, because WHICH values follow which is a question about +-- the mod's settings and not about the menu they are on. +local onChanged = nil + +function SettingsMenu.setOnChanged(fn) + onChanged = fn +end + +local function isFull() + local Pipelines = require("src.render.Pipelines") + return V.require("VoxelState").isFull(Pipelines.level("voxel")) +end + +-- The one rule that decides whether a setting is on a menu, lifted unchanged +-- from the options hook it used to live in. +-- +-- FULL: a preset that owns the look, so the rows that describe the look go +-- with it. And a row whose own switch is off the table this frame (BACK +-- SPRITES, which needs a staged fight to be about) is left off with it. The +-- mod manager's page carries every one of them either way. +local function offered(entry, full) + return (entry.full or not full) and (not entry.when or entry.when()) +end + +-- The rows of one category, or of the root menu. PURE -- no state, no stack, +-- no side effects -- so a caller that only wants to know what is on a menu +-- (a test, or the root menu asking whether a category has anything in it) +-- does not have to push a screen to find out. +function SettingsMenu.rows(catId, game) + local full = isFull() + local out = {} + if catId == SettingsMenu.ROOT then + for _, row in ipairs(pipelineRows) do + -- FULL owns the blur exactly as it owns the wireframe and the horizon + -- bend, so T-SHIFT comes off with them + if not (full and row.id == "pipeline:tiltshift") then + out[#out + 1] = row + end + end + for _, cat in ipairs(SettingsMenu.CATEGORIES) do + local kids = SettingsMenu.rows(cat.id, game) + -- An EMPTY category is not offered. This is the whole of what makes + -- 3D WORLD disappear under FULL and VR disappear off Windows: no + -- special case, just nothing left inside to open. + if kids[1] then + out[#out + 1] = { + id = SettingsMenu.id(cat.id), + label = cat.label, + value = cat.summary + or function() return ("%d SETTINGS"):format(#SettingsMenu.rows(cat.id, game)) end, + activate = function(g) + g.stack:push(SettingsMenu.new(g, cat.id)) + end, + } + end + end + return out + end + for _, entry in ipairs(settings) do + if entry.cat == catId and offered(entry, full) then + out[#out + 1] = entry[1]:row() + end + end + -- and the ROM import, which is an ACTION and not a setting: there is no + -- rung to store, nothing for the mod manager's page to persist and nothing + -- to restore on the next boot, so it is appended here rather than living in + -- SETTINGS. It sits under the battles because the STADIUM rungs of 3D-BTL + -- are what it unlocks. + if catId == "battles" then + local ok, importRow = pcall(function() + return V.require("StadiumRomPick").row() + end) + if ok and importRow then out[#out + 1] = importRow end + end + return out +end + +-- ------- red ink for the mod's row on the OPTIONS menu +-- +-- love.graphics.setColor CANNOT do this, and it is worth writing down why so +-- nobody spends an afternoon on it. Twice over: +-- +-- 1. The glyph atlas is BLACK ink on transparent (tools/extract/font.py), +-- and Font.drawCode is a plain love.graphics.draw, which LOVE tints +-- MULTIPLICATIVELY. black x red is black. +-- 2. Even if it drew red, the palette shader (PaletteFX.shader) keys on the +-- RED CHANNEL alone and throws G and B away -- r > 0.83 ? c0 : ... So a +-- red pixel lands in c0, the LIGHTEST slot: white text on white paper. +-- +-- What actually happens on this screen is that setColor picks a SHADE and the +-- zone palette picks the COLOR. Black text is c3 and the white box fill is +-- c0, so a zone whose c3 is red draws red text on paper that has not moved. +-- The engine does the same thing for the party menu's HP bars +-- (src/ui/PartyMenu.lua), which is the pattern this follows. +SettingsMenu.INK = { 255, 0, 0 } + +-- Built by copying MEWMON -- the palette the OPTIONS menu already wears -- +-- and replacing ONLY the ink slot, rather than inventing four colors. Red, +-- Blue and Yellow ship different MEWMON tables, and this way the paper under +-- the row is the same white as the row above it in all three. +function SettingsMenu.redPalette(data) + local base = PaletteFX.pal(data, "MEWMON") + if not base then return nil end + local out = { base[1], base[2], base[3], base[4] } + -- SGB INV REVERSES the table (PaletteFX.effectiveColors, INV_MAP), so under + -- it the ink is the first slot and the paper the last. Put the red where it + -- will land on the INK either way: without this the row draws as a solid + -- red block with white letters cut out of it. + -- + -- The other modes need nothing. OG, OG INV and CLASSIC discard the table + -- outright and substitute their own, so the row simply draws monochrome -- + -- which is correct: the player asked for a screen with no colors in it. + out[PaletteFX.mode == "gbc_inv" and 1 or 4] = SettingsMenu.INK + return out +end + +-- The two TEXT lines of the row in `slot` (1..OptionRows.VISIBLE), and only +-- those. OptionRows.draw puts the label at x=16 and the value at x=24 -- tiles +-- 2 and 3 -- on the second and third rows of each four-tile box. Tiles 0 and +-- 19 are the box's own borders and tile 1 is the cursor, and all three are +-- black glyphs that would turn red along with the text if the band spanned +-- the whole row. +function SettingsMenu.rowZone(data, slot) + local pal = SettingsMenu.redPalette(data) + if not pal then return nil end + local top = (slot - 1) * 4 + 1 + return PaletteFX.zone(pal, 2, top, 18, top + 1) +end + +-- ------- the screen +-- +-- Deliberately NOT an OptionsMenu instance, though the update loop below is +-- modelled on its. main.lua monkey-patches OptionsMenu.update on the CLASS, +-- and that patch rebuilds self.rows from OptionsMenu.new whenever the voxel +-- level or the battle rows change -- which would replace a submenu's rows +-- with the whole top-level OPTIONS list under the player's cursor. A state of +-- our own cannot be caught by it. +-- +-- It still renders through OptionRows, so it is the same four boxes, the same +-- cursor and the same bottom line as every other menu in the game. +function SettingsMenu.new(game, catId) + local self = setmetatable({ + game = game, + cat = catId or SettingsMenu.ROOT, + index = 1, + scroll = 0, + }, SettingsMenu) + self.rows = SettingsMenu.rows(self.cat, game) + self.sig = SettingsMenu.signature(self.rows) + return self +end + +function SettingsMenu.signature(rows) + local ids = {} + for i, row in ipairs(rows) do ids[i] = tostring(row.id) end + return table.concat(ids, "\1") +end + +-- OptionRows has no room for a title: the four boxes fill the screen down to +-- the bottom line. So the bottom line carries the name as well as the way +-- out, which is the one place left to say where the player is. "BACK" alone +-- at the root, where "BACK: DRAMATIC SHAPE" would run past the 18 characters +-- the line has. +function SettingsMenu:backLabel() + if self.cat == SettingsMenu.ROOT then return "BACK" end + for _, cat in ipairs(SettingsMenu.CATEGORIES) do + if cat.id == self.cat then + return "BACK: " .. (cat.label:gsub("%.%.$", "")) + end + end + return "BACK" +end + +-- A category's contents can change while the player is looking at them: 3D-BTL +-- gives and takes BACK SPRITES, VR gives and takes SMOOTH TURN, and stepping +-- VOXEL onto FULL empties 3D WORLD outright. Rebuilt only when the LIST +-- actually differs, so the common case -- every other rung of every other row +-- -- costs one string compare. +function SettingsMenu:refresh() + local rows = SettingsMenu.rows(self.cat, self.game) + local sig = SettingsMenu.signature(rows) + if sig == self.sig then return end + -- Follow the row the cursor was ON rather than the slot it was in: a row + -- can appear ABOVE the one just used, which would otherwise slide the + -- cursor onto its neighbour. The bottom line follows itself. + local wasBack = self.index > #self.rows + local wasOn = self.rows[self.index] and self.rows[self.index].id + self.rows, self.sig = rows, sig + self.index, self.scroll = 1, 0 + if wasBack then + self.index = #rows + 1 + else + for i, row in ipairs(rows) do + if wasOn and row.id == wasOn then self.index = i break end + end + end +end + +local function pop(self) + local stack = self.game and self.game.stack + if self.game and self.game.data then + require("src.core.Sound").play(self.game.data, "Press_AB") + end + if stack and stack:top() == self then stack:pop() end +end + +-- The engine's own options loop (src/ui/OptionsMenu.update), including its +-- two conventions worth naming: `activate` SHADOWS `step` and fires on A +-- alone, and the bottom line is a synthetic index past the end of the list +-- rather than a row, so nothing a category contains can orphan the way out. +function SettingsMenu:update() + local input = self.game and self.game.input + if not input then return end + local rows = self.rows + local back = #rows + 1 + local changed = false + if input:wasPressed("up") then + self.index = self.index - 1 + if self.index < 1 then self.index = back end + elseif input:wasPressed("down") then + self.index = self.index + 1 + if self.index > back then self.index = 1 end + elseif input:wasPressed("left") or input:wasPressed("right") + or input:wasPressed("a") then + local dir = input:wasPressed("left") and -1 or 1 + local row = rows[self.index] + if row and row.activate then + if input:wasPressed("a") then row.activate(self.game) end + elseif row and row.step then + changed = row.step(self.game, dir) and true or false + elseif input:wasPressed("a") then + pop(self) + return + end + elseif input:wasPressed("b") or input:wasPressed("start") then + -- B and START both, like every other menu -- and one level only: this + -- pops US, leaving the OPTIONS menu underneath exactly as the player + -- left it, with its own onCancel still to fire when they leave THAT. + pop(self) + return + end + if changed then + -- before the rebuild, not after: pinning can itself change which rows are + -- offered (3D-BTL switched on takes BACK SPRITES from off the table to on + -- it), and refresh has to see the settled answer + if onChanged then pcall(onChanged, self.game) end + if self.game.writeOptions then + pcall(self.game.writeOptions, self.game) + end + end + self:refresh() + self.scroll = OptionRows.clampScroll(self.index, self.scroll, #self.rows, + #self.rows + 1) +end + +function SettingsMenu:draw() + OptionRows.draw(self.game, self.rows, self.index, self.scroll, + self:backLabel(), #self.rows + 1) +end + +-- REQUIRED, even though nothing here is red. +-- +-- Game:draw walks the stack from the top and stops at the first state that +-- HAS this method, not the first that answers something. Without one of our +-- own the walk would fall through to the OPTIONS menu underneath -- whose +-- sgbPalettes main.lua has patched to paint the mod's row red -- and that +-- zone is addressed by SLOT, so it would land on whatever this menu happens +-- to be showing in the same box. +-- +-- MEWMON is what the OPTIONS menu wears, so a submenu is the same paper. +function SettingsMenu:sgbPalettes(game) + return PaletteFX.wholeNamed(game.data, "MEWMON") +end + +return SettingsMenu diff --git a/main.lua b/main.lua index 810c023..9e236bd 100644 --- a/main.lua +++ b/main.lua @@ -98,6 +98,9 @@ local FirstPerson = V.require("FirstPerson") local FreeMove = V.require("FreeMove") local CamControl = V.require("CamControl") local VR = V.require("VR") +-- the mod's settings menus: the categories, the screens they open, and the +-- red ink that marks this mod's one row on the engine's OPTIONS list +local SettingsMenu = V.require("SettingsMenu") -- HORDE MODE: the konami code's minigame. Horde owns the state machine and -- every hook; the other four are the gun, the crowd, the readout and the -- chip-synthesized sounds it fires. See lib/Horde.lua for the whole design. @@ -449,8 +452,26 @@ local function stagedBattles() return OverworldBattle.enabled() end +-- ------- this mod's settings, grouped the way the menus present them +-- +-- One entry per setting: the ModSetting itself, the help text the mod +-- manager's page carries, and the fields that decide where it is offered. +-- +-- cat which of SettingsMenu's categories the row lives on. The table is +-- kept in category order as well, so the mod manager's own page -- +-- which has no categories to give and lists every row flat -- at +-- least keeps related settings next to each other. +-- when a predicate. The row is off the menu entirely while it answers +-- false, because a row that decides nothing reads as a broken mod. +-- full the row SURVIVES the FULL preset. FULL owns the look, so a row +-- goes with it by default; `full` marks the ones that were never +-- about the look. SettingsMenu leans on this and needs no rule of +-- its own: 3D WORLD is exactly the rows WITHOUT it, so that whole +-- category empties out under FULL and takes itself off the menu. local SETTINGS = { - { VoxelGrid.setting, "One-pixel wireframe along every voxel edge." }, + -- ------- 3D WORLD -- the diorama's own knobs, every one of them FULL's + { VoxelGrid.setting, "One-pixel wireframe along every voxel edge.", + cat = "world" }, { WorldCurve.setting, "Bend the world down over the horizon, Animal Crossing style. 1 is a " .. "hint of roll at the frame edges and 2 is the classic read; 3 is as " @@ -458,7 +479,8 @@ local SETTINGS = { .. "walk into. 4 and 5 are past that on purpose and they are for a " .. "headset's DIORAMA, where the world is a model being looked at " .. "rather than walked around in -- 5 curls it into a half sphere, a " - .. "town on top of its own little planet." }, + .. "town on top of its own little planet.", + cat = "world" }, { ViewBox.setting, "How much of the map the camera bothers to draw. FIT is exactly the " .. "ground on screen and no more -- the shape a tilted camera really " @@ -472,24 +494,23 @@ local SETTINGS = { .. "closest, WIDE through WIDEST push the world's edge further out, " .. "and OFF stops cutting entirely. Not on 1ST or 3RD -- you are " .. "standing in the world there -- and the box opens out and away as " - .. "the camera dives in." }, + .. "the camera dives in.", + cat = "world" }, { Water.setting, "Reflections on water. FULL adds screen-space reflections of the " .. "shoreline, the trees and the buildings behind it; SKY is the sky, " .. "the sun and the moon alone, which is most of the look for a " - .. "fraction of the cost." }, - -- `full` for the AA reason: additive shafts are fill rate, and under 4X - -- supersampling that is a question about the hardware, not the look. - { ForestAtmos.setting, - "The air of the deep woods (Viridian Forest): a ground haze, and " - .. "volumetric light let down through the unseen canopy overhead -- " - .. "gold spears of sun by day, silver moon rays at night, pollen " - .. "drifting through the beams and fireflies once they cool. LOW " - .. "keeps the haze, halves the beam march and stands the particles " - .. "down. On a phone the row offers LOW alone: the beams need a " - .. "depth texture the pass can read back, and no mobile driver here " - .. "grants one.", - full = true }, + .. "fraction of the cost.", + cat = "world" }, + { DayNight.setting, + "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " + .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " + .. "shadows, the sky and the light following -- or SYNC it to the " + .. "clock on the wall, so Kanto's evening falls when yours does.", + cat = "world" }, + + -- ------- BATTLES -- what a fight is drawn over, and how it is played + -- -- `full` marks a row FULL does not take away. FULL owns the diorama's own -- knobs; what a battle is drawn over, and how it is framed, are not that. -- Off the OPTIONS menu while VR is on: the headset REQUIRES staged @@ -509,6 +530,7 @@ local SETTINGS = { .. "Pokemon Stadium (US) 1.0 ROM of your own -- import it from the " .. "STADIUM ROM row, or drop it in the baseroms folder and restart. No " .. "other version works: the reader is keyed to that one cartridge.", + cat = "battles", when = function() return not VR.enabled() end, full = true }, -- Only offered while a fight can actually be staged on the map: with 3D-BTL -- off the engine draws the classic screen, which is this row's ON already, @@ -517,6 +539,7 @@ local SETTINGS = { "Keep your own Pokemon on the battle menu, seen from behind in its " .. "original slot, instead of standing it on the map facing the foe. " .. "The foe is still out there on its own tile.", + cat = "battles", when = function() return stagedBattles() and not VR.enabled() end, full = true }, -- `full` like the battle rows: this is a GAMEPLAY mode, not a knob on @@ -534,12 +557,26 @@ local SETTINGS = { .. "throws, new species and your running catch combo. Needs 3D-BTL " .. "on; anywhere the staged fight cannot stand, balls quietly throw " .. "the classic way.", - full = true }, - { DayNight.setting, - "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, " - .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the " - .. "shadows, the sky and the light following -- or SYNC it to the " - .. "clock on the wall, so Kanto's evening falls when yours does." }, + cat = "battles", full = true }, + + -- ------- PERFORMANCE -- what the look COSTS, which is a different question + -- + -- All three are `full`, and all three for the same reason: FULL is a preset + -- for the diorama, not a licence to spend whatever the machine it happens + -- to be running on has got. The player decides what their hardware can + -- carry, from inside FULL like anywhere else. + -- `full` for the AA reason: additive shafts are fill rate, and under 4X + -- supersampling that is a question about the hardware, not the look. + { ForestAtmos.setting, + "The air of the deep woods (Viridian Forest): a ground haze, and " + .. "volumetric light let down through the unseen canopy overhead -- " + .. "gold spears of sun by day, silver moon rays at night, pollen " + .. "drifting through the beams and fireflies once they cool. LOW " + .. "keeps the haze, halves the beam march and stands the particles " + .. "down. On a phone the row offers LOW alone: the beams need a " + .. "depth texture the pass can read back, and no mobile driver here " + .. "grants one.", + cat = "perf", full = true }, -- `full` on AA's reasoning below, and for the same reason: the sun's pass -- is the most expensive thing in the frame after the geometry, so this is -- a question about the machine rather than a knob on the diorama, and it @@ -554,13 +591,9 @@ local SETTINGS = { .. "phone or an old machine. OFF is no shadow at all, the flat drop " .. "shadows under characters included, and the forest's light shafts go " .. "with it: the beams are lit by the sun's own map.", - full = true }, + cat = "perf", full = true }, -- Marked `full` for the opposite reason the battle rows are: this is not a - -- knob on the look at all, it is what the look COSTS. FULL is a preset for - -- the diorama, not a licence to spend four times the fill rate on the - -- machine it happens to be running on, so it neither sets this nor takes - -- the row away -- the player decides what their hardware can carry, from - -- inside FULL like anywhere else. + -- knob on the look at all, it is what the look COSTS. { AntiAlias.setting, "Smooth the stair-stepped edges of the 3D world -- roof ridges, ledge " .. "lips, a tree against the sky -- by rendering the diorama larger than " @@ -569,7 +602,10 @@ local SETTINGS = { .. "reads smoother rather than sharper. 2X costs half again as many " .. "pixels in each direction and 4X twice, which makes this the most " .. "expensive row in the mod.", - full = true }, + cat = "perf", full = true }, + + -- ------- VR -- the headset, and the one comfort knob that is only its + -- -- `full` for the same reason as AA: not a knob on the look, a question -- about the hardware on the desk. { VR.setting, @@ -588,6 +624,7 @@ local SETTINGS = { .. "Menus and dialogs float on a panel. Needs a Windows OpenXR runtime " .. "and the mod running from a real folder; without them the row stays " .. "and the game stays flat, with the reason on the console.", + cat = "vr", -- on Windows the row stays even when a runtime is missing (the console -- says why); off Windows -- mobile above all -- there is no VR to have -- and the row does not exist @@ -601,12 +638,15 @@ local SETTINGS = { .. "world past a head that did not move, which is the most reliable way " .. "to make somebody ill in a headset. Turn it on if you have your sea " .. "legs and want the continuity.", + cat = "vr", -- and only under STANDARD: the stick turns a HEAD, and neither diorama -- mode has the player standing in the world to be turned when = function() return VR.enabled() and not VR.dioramaMode() end, full = true }, } +SettingsMenu.define(SETTINGS) + local schema = {} for _, entry in ipairs(SETTINGS) do -- the VR rows are absent from the mod manager's page too where the @@ -783,29 +823,31 @@ do end end --- ------- the mode's rows, kept together +-- ------- the mode's rows, on menus of their own -- --- The engine splices a pipeline's row in beside TILT, because a display mode --- belongs with the other display modes; a mod's own ui.options.rows --- additions land at the END of the list. That left this mod's four rows in --- two places with unrelated engine rows between them, which reads as two --- unrelated features rather than one mode with settings. +-- This mod used to put FOURTEEN rows on the engine's OPTIONS list, in one +-- block spliced in beside the pipeline rows. OptionRows shows four boxes at a +-- time, so that was four screens of scrolling inside a list that already +-- carried twenty engine rows, and finding SHADOWS meant knowing it was in +-- there past the wireframe and the horizon bend. -- --- So the plain settings are inserted directly after the last of this mod's --- PIPELINE rows instead of appended. Nothing else moves: the block lands --- where the engine already decided display modes go. -local function insertGrouped(out, extra) - local anchor = nil - for i, row in ipairs(out) do - local id = type(row) == "table" and row.id - if id == "pipeline:voxel" or id == "pipeline:tiltshift" then anchor = i end +-- Now there is ONE row, and it leads the list. What it opens -- the +-- categories, the screens, and why the split falls where it does -- is +-- lib/SettingsMenu.lua. VOXEL and T-SHIFT go with it: they are this mod's +-- display modes, the engine only spliced them beside TILT because it had +-- nowhere better, and TILT is not on the menu any more anyway (see below). +-- +-- Two things it takes to move a pipeline row: the engine's descriptor is +-- captured on the way past and handed to SettingsMenu VERBATIM -- it persists +-- through its own step function into save.options.pipelines, and rebuilding +-- it here would be a second implementation of something the engine already +-- got right -- and the row is then dropped from the top-level list so it is +-- not in two places at once. +local function captureRow(out, id) + for _, row in ipairs(out) do + if type(row) == "table" and row.id == id then return row end end - if not anchor then - for _, row in ipairs(extra) do out[#out + 1] = row end - return out - end - for i, row in ipairs(extra) do table.insert(out, anchor + i, row) end - return out + return nil end -- FULL owns the settings that describe the LOOK, so while it is selected those @@ -875,6 +917,25 @@ local function pinEngineFx(game) if changed and game.writeOptions then pcall(game.writeOptions, game) end end +-- ------- the values that follow other values +-- +-- Two settings hold a third in place. 3D-BTL pins BATTLE LAYOUT to OG while a +-- fight can be staged on the map, and FULL pins DAYTIME to SYNC while it owns +-- that row. Both pins used to be a side effect of the rows hook, which every +-- step on the OPTIONS menu reran -- so they happened whether or not the step +-- was the one that mattered, and nothing had to name them. +-- +-- Now a step can happen on the mod's own menu, where no hook runs, or on the +-- mod manager's page, where one never did. So the pinning is a function, and +-- all three routes ask for it. +local function pinDependents(game) + if stagedBattles() then OverworldBattle.forceOG(game) end + local Pipelines = require("src.render.Pipelines") + if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync(game) end +end + +SettingsMenu.setOnChanged(pinDependents) + -- call next() first and decorate what comes back, so every other mod's -- rows survive this one mod.hooks:wrap("ui.options.rows", function(next, game, rows) @@ -901,46 +962,50 @@ mod.hooks:wrap("ui.options.rows", function(next, game, rows) OverworldBattle.forceOG(game) dropRow(out, "battleLayout") end - local full = Voxel.isFull(Pipelines.level("voxel")) - if full then + if Voxel.isFull(Pipelines.level("voxel")) then -- FULL owns the rows that PARAMETERISE the diorama -- the wireframe, the - -- horizon bend, the blur, the hour -- so those come off the menu and - -- DAYTIME is held at SYNC while its row is unreachable. + -- horizon bend, the blur, the hour -- so DAYTIME is held at SYNC while its + -- row is unreachable. The rows themselves come off inside SettingsMenu, + -- which is where they live now: T-SHIFT with the wireframe and the bend, + -- and each of them by the same `full` rule rather than by name. DayNight.forceSync(game) - dropRow(out, "pipeline:tiltshift") end - local extra = {} - for _, entry in ipairs(SETTINGS) do - -- Two things decide whether a row is offered. - -- - -- FULL: a preset that owns the look, so the rows that describe the look go - -- with it. The BATTLE rows are not that -- 3D-BTL decides what a fight is - -- drawn OVER and BACK SPRITES how it is framed, and neither is a knob on - -- the diorama FULL is a preset for. FULL still SETS them on arrival (see - -- applyFull); it does not hold them, so leaving them on the menu is the - -- difference between a preset and a lock. - -- - -- And a row whose own switch is off the table this frame (BACK SPRITES, - -- which needs a staged fight to be about) is left off with it. The mod - -- manager's page carries every one of them either way. - local offered = (entry.full or not full) - and (not entry.when or entry.when()) - if offered then extra[#extra + 1] = entry[1]:row() end + -- The two pipeline rows move INTO the mod's own root menu: captured as the + -- engine built them, then dropped from here so they are not in two places. + local captured, voxelRow = {}, nil + for _, id in ipairs({ "pipeline:voxel", "pipeline:tiltshift" }) do + local row = captureRow(out, id) + -- a pipeline the registry refused is simply not there, and the menu says + -- so by not offering it rather than by offering a hole + if row then captured[#captured + 1] = row end + if id == "pipeline:voxel" then voxelRow = row end + dropRow(out, id) end - -- and the ROM import, which is an ACTION and not a setting: there is no - -- rung to store, nothing for the mod manager's page to persist and nothing - -- to restore on the next boot, so it is appended here rather than living in - -- SETTINGS. nil on a platform with no file dialog, which takes it off the - -- menu rather than offering a button that cannot do anything. - -- On EVERY platform. Where there is no file dialog it says WHERE? and - -- shows the folder to put the cartridge in, which is the one thing a - -- player on a phone could not otherwise find out -- the row used to vanish - -- there, which reads as the feature being missing rather than manual. - local okPick, importRow = pcall(function() - return V.require("StadiumRomPick").row() - end) - if okPick and importRow then extra[#extra + 1] = importRow end - return insertGrouped(out, extra) + SettingsMenu.setPipelineRows(captured) + -- ------- one row, and it leads the list + -- + -- At the TOP rather than spliced in beside the display modes it used to sit + -- with. This is a mod that replaces the whole look of the game, and a player + -- who installed it and went looking for its settings should not have to + -- scroll to find out where they went -- least of all past the engine rows it + -- has quietly taken away. + -- + -- Inserted after next() has run, so it leads every OTHER mod's rows too. The + -- second line is VOXEL's own value function, which makes the row say what + -- the mode is currently doing without opening it -- and reuses the engine's + -- label ladder rather than restating it. + table.insert(out, 1, { + id = SettingsMenu.id(SettingsMenu.ROOT), + label = SettingsMenu.ROOT_LABEL, + value = voxelRow and voxelRow.value or nil, + -- `activate` and not `step`: the engine fires activate on A alone, and a + -- row that OPENS something should not also answer Left and Right + -- (src/ui/OptionsMenu.update). + activate = function(g) + g.stack:push(SettingsMenu.new(g, SettingsMenu.ROOT)) + end, + }) + return out end) -- The mod manager writes and persists on its own, so the only thing left @@ -951,14 +1016,11 @@ mod.events:on("mod.options_changed", function(payload) if payload.key == entry[1].key then entry[1]:sync(payload.value) end end -- 3D-BTL switched on from the manager's page pins BATTLE LAYOUT exactly as - -- the OPTIONS row does. The manager persists its own value; this is the one - -- that has to follow it. - if stagedBattles() then OverworldBattle.forceOG() end - -- and DAYTIME changed from the manager's page while FULL owns it snaps - -- straight back to SYNC -- the OPTIONS row is hidden, but the manager's is - -- not, and FULL's pin must hold against both - local Pipelines = require("src.render.Pipelines") - if Voxel.isFull(Pipelines.level("voxel")) then DayNight.forceSync() end + -- the mod's own row does, and DAYTIME changed there while FULL owns it snaps + -- straight back to SYNC -- that row is off the mod's menus under FULL, but + -- the manager's page carries every setting unconditionally, and the pin has + -- to hold against both. + pinDependents() end) -- ------- keeping the geometry in step with the world @@ -1058,30 +1120,55 @@ end) -- rerun every mod's ui.options.rows hook once per keypress. The cursor is -- clamped rather than reset, so it stays on the row it was just used on -- instead of jumping to the top when the list below it shortens. +-- +-- Held on the INSTANCE rather than compared across one call of update, and +-- that is not a tidying: those three rows live in a SUBMENU now, and the +-- stack only ticks its top state (src/core/StateStack.update). So the step +-- that changes them happens while this menu is suspended and a +-- before/after pair taken around inner() would both be read after the fact +-- and always agree. A signature that outlives the suspension does not. do local OptionsMenu = require("src.ui.OptionsMenu") if not OptionsMenu.dramaticShapeFullHook then + local OptionRows = require("src.ui.OptionRows") local Pipelines = require("src.render.Pipelines") local inner = OptionsMenu.update + local innerPalettes = OptionsMenu.sgbPalettes local function idAt(menu, index) local row = menu.rows and menu.rows[index or 1] return type(row) == "table" and row.id or nil end + -- What the row LIST depends on: whether FULL is selected (it owns the + -- rows that describe the look), and the two switches that give and take + -- an engine row -- 3D-BTL, which owns BATTLE LAYOUT, and VR, which hides + -- both battle rows while it is on. Only the FULL-ness of the voxel level + -- matters, so stepping 35 to 50 is not a change. + local function signature() + return string.format("%s|%s|%s", + tostring(Voxel.isFull(Pipelines.level("voxel"))), + tostring(OverworldBattle.enabled()), tostring(VR.enabled())) + end + + -- Stamped where the ROWS are built, which is the thing the signature is a + -- signature OF. Read lazily on the first update instead and a menu opened + -- before the change and updated after it would compare the new state + -- against itself and never rebuild. + local innerNew = OptionsMenu.new + function OptionsMenu.new(game, opts) + local menu = innerNew(game, opts) + menu.dramaticShapeSig = signature() + return menu + end + function OptionsMenu:update(dt) - local before = Pipelines.level("voxel") - local hadBattles = OverworldBattle.enabled() - -- the VR row hides the two battle rows while it is on, so stepping - -- it changes the LIST exactly the way 3D-BTL does - local hadVR = VR.enabled() local wasOn = idAt(self, self.index) + local before = self.dramaticShapeSig or signature() inner(self, dt) - local after = Pipelines.level("voxel") - local crossedFull = after ~= before - and (Voxel.isFull(before) or Voxel.isFull(after)) - if crossedFull or OverworldBattle.enabled() ~= hadBattles - or VR.enabled() ~= hadVR then + local after = signature() + self.dramaticShapeSig = after + if before ~= after then local rebuilt = OptionsMenu.new(self.game) self.rows = rebuilt.rows -- Follow the row the cursor was ON rather than the slot it was in: @@ -1095,6 +1182,36 @@ do end end + -- ------- and the mod's own row is red + -- + -- Why this is a palette zone and not love.graphics.setColor -- twice over + -- -- is written out in lib/SettingsMenu.lua, next to the code that builds + -- the palette. The short of it: setColor picks a SHADE on this screen and + -- the zone picks the COLOR. + -- + -- Addressed by SLOT, because the row scrolls: it leads the list, so it is + -- normally the top box, but a player who scrolls past it must not leave a + -- red band behind on whatever takes its place. Searched by id rather than + -- assumed to be row 1 for the same reason -- another mod's hook running + -- after ours could put something above it. + function OptionsMenu:sgbPalettes(game) + local zones = innerPalettes and innerPalettes(self, game) or nil + local scroll = self.scroll or 0 + for slot = 1, OptionRows.VISIBLE do + local row = self.rows and self.rows[scroll + slot] + if type(row) == "table" + and row.id == SettingsMenu.id(SettingsMenu.ROOT) then + local zone = SettingsMenu.rowZone(game and game.data, slot) + if zone then + zones = zones or {} + zones[#zones + 1] = zone + end + break + end + end + return zones + end + OptionsMenu.dramaticShapeFullHook = true end end diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua index 7cc7a53..d50fc97 100644 --- a/tests/dramatic_shape_test.lua +++ b/tests/dramatic_shape_test.lua @@ -119,6 +119,46 @@ T.eq(byLabel.VOXEL.value(), "FULL", "the row renders the current rung's label") local Runtime = require("src.mods.Runtime") local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") +-- ------- reaching the rows now that they are on menus of their own +-- +-- The ui.options.rows hook contributes ONE row to the engine's list, and that +-- row opens the mod's own screens. So a test asks SettingsMenu.rows -- which +-- is pure on purpose, precisely so that asking what is on a menu does not mean +-- pushing one -- rather than scanning the hook's output for a setting. +-- +-- Keyed by id, because that is the one thing about a row that is a promise. +-- The old positional lookups (hookedRows[7] is 3D-BTL) pinned an ORDER the +-- menus are now free to change, and every one of them had to be rewritten to +-- get here; none of them was testing the order on purpose. +-- +-- One table rather than three locals: this file is one Lua chunk, a chunk has +-- 200 local slots, and it is already spending them carefully (see the scoped +-- sections throughout). +local Menus = { lib = run.loader.exports.DRAMATIC_SHAPE.lib.require("SettingsMenu") } + +function Menus.rows(catId, game) + local ids = {} + for _, row in ipairs(Menus.lib.rows(catId, game or { data = Data })) do + ids[row.id] = row + end + return ids +end + +-- every row the mod offers ANYWHERE: the root menu plus all four categories. +-- What most of this file wants, since "is this setting on a menu at all" is +-- the question the `when` and `full` gates actually answer. +function Menus.offered(game) + local ids = Menus.rows(Menus.lib.ROOT, game) + for _, cat in ipairs(Menus.lib.CATEGORIES) do + for id, row in pairs(Menus.rows(cat.id, game)) do ids[id] = row end + end + return ids +end + +-- the id of a category's own row on the root menu +function Menus.catId(catId) + return Menus.lib.id(catId) +end -- ------- FULL is a preset that owns the rows describing the LOOK -- @@ -131,36 +171,67 @@ local VoxelState = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState") -- diorama the preset is a preset FOR. FULL sets them on arrival and then lets -- go, which is what makes it a preset rather than a lock. Pipelines.setLevel("voxel", VoxelState.FULL_LEVEL) +-- scoped, like the sections below: this file is one Lua chunk and a chunk has +-- 200 local slots, so a section that wants four borrows them +do local fullRows = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "tilt" }, { id = "pipeline:voxel" }, { id = "pipeline:tiltshift" } }) local fullIds = {} for _, row in ipairs(fullRows) do fullIds[row.id] = true end -T.check(fullIds["pipeline:voxel"], "FULL keeps the VOXEL row it lives on") -T.check(not fullIds["pipeline:tiltshift"], +-- what the hook leaves on the ENGINE's list: one row, and the pipeline rows +-- gone from it because they have moved onto the mod's own root menu +T.check(fullIds[Menus.catId(Menus.lib.ROOT)], + "the mod contributes exactly one row to the OPTIONS menu") +T.check(not fullIds["pipeline:voxel"], + "and takes VOXEL off the engine's list -- it lives on the mod's menu now") +T.check(not fullIds["pipeline:tiltshift"], "and T-SHIFT with it") +T.eq(fullRows[1].id, Menus.catId(Menus.lib.ROOT), + "and it LEADS the list: a mod that replaces the look of the whole game " + .. "should not make the player scroll to find out where its settings went") + +local fullRoot = Menus.rows(Menus.lib.ROOT) +local fullOffered = Menus.offered() +T.check(fullRoot["pipeline:voxel"], "FULL keeps the VOXEL row it lives on") +T.check(not fullRoot["pipeline:tiltshift"], "FULL takes T-SHIFT off the menu -- it owns the blur") -T.check(not fullIds["DRAMATIC_SHAPE:grid"], "and V-GRID") -T.check(not fullIds["DRAMATIC_SHAPE:curve"], "and V-CURVE") -T.check(not fullIds["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST") -T.check(not fullIds["DRAMATIC_SHAPE:daytime"], "and DAYTIME") +T.check(not fullOffered["DRAMATIC_SHAPE:grid"], "and V-GRID") +T.check(not fullOffered["DRAMATIC_SHAPE:curve"], "and V-CURVE") +T.check(not fullOffered["DRAMATIC_SHAPE:viewbox"], "and RENDER DIST") +T.check(not fullOffered["DRAMATIC_SHAPE:water"], "and WATER") +T.check(not fullOffered["DRAMATIC_SHAPE:daytime"], "and DAYTIME") +-- and with all five of them gone the category has nothing left in it, so it +-- takes ITSELF off the root menu. No rule anywhere says "hide 3D WORLD under +-- FULL" -- that category is exactly the rows FULL owns, and an empty category +-- is not offered. +T.check(not fullRoot[Menus.catId("world")], + "so 3D WORLD is empty, and an empty category is not offered") -- but the battle rows survive it: they are not knobs on the look, and FULL -- sets them once rather than holding them, so a player who wants the classic -- back sprite (or no staged fights at all) can still say so from inside FULL -T.check(fullIds["DRAMATIC_SHAPE:battles"], "3D-BTL is still on the menu under FULL") -T.check(fullIds["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") +T.check(fullOffered["DRAMATIC_SHAPE:battles"], + "3D-BTL is still on the menu under FULL") +T.check(fullOffered["DRAMATIC_SHAPE:battleBack"], "and BACK SPRITES with it") +T.check(fullRoot[Menus.catId("battles")], + "and the category that carries them is still offered") -- and AA, for the opposite reason: it is not a knob on the look at all, it is -- what the look COSTS, and only the player knows what their machine can carry -T.check(fullIds["DRAMATIC_SHAPE:aa"], "and AA, which FULL neither sets nor owns") +T.check(fullOffered["DRAMATIC_SHAPE:aa"], + "and AA, which FULL neither sets nor owns") -- SHADOWS on the same reasoning: the sun's pass is what the look costs, and -- a preset for the diorama does not get to spend a second draw of the whole -- world on a machine that cannot carry one -T.check(fullIds["DRAMATIC_SHAPE:shadows"], +T.check(fullOffered["DRAMATIC_SHAPE:shadows"], "and SHADOWS, the other row that is a question about the hardware") +T.check(fullRoot[Menus.catId("perf")], + "PERFORMANCE is the category for exactly that question, and it survives too") -- VR survives FULL on AA's reasoning: whether a headset is on the desk is -- not the diorama's to decide -T.check(fullIds["DRAMATIC_SHAPE:vr"], "and VR, likewise the hardware's question") +T.check(fullOffered["DRAMATIC_SHAPE:vr"], + "and VR, likewise the hardware's question") +end -- DAYTIME is not only hidden under FULL, it is HELD at SYNC: the row cannot -- be reached while FULL owns it, so a value changed underneath (the mod @@ -290,8 +361,14 @@ local fxIds = {} for _, row in ipairs(fxRows) do fxIds[row.id] = true end T.check(not fxIds["tilt"], "TILT is off the OPTIONS menu") T.check(not fxIds["gbcfx"], "and so is GBC FX") -T.check(fxIds["colors"] and fxIds["pipeline:voxel"], +T.check(fxIds["colors"], "with every other row the engine offered still on it") +-- VOXEL is off this list too, but for the opposite reason: not taken away, +-- MOVED -- onto the mod's own root menu, which the one remaining row opens +T.check(not fxIds["pipeline:voxel"] and fxIds[Menus.catId(Menus.lib.ROOT)], + "and VOXEL moved onto the mod's own menu rather than being dropped") +T.check(Menus.rows(Menus.lib.ROOT)["pipeline:voxel"], + "-- where it is still offered") T.eq(fxGame.save.options.tilt, 0, "a save that had TILT on is pinned back to off, not left on with no row") @@ -310,32 +387,64 @@ T.check(not fullFxIds["tilt"] and not fullFxIds["gbcfx"], Pipelines.setLevel("voxel", 2) end --- ------- and off FULL, the rows come back, grouped with the mode +-- ------- and off FULL, the rows come back -- on the mod's own menus -- --- The engine splices a pipeline row in beside TILT and lands a mod's own --- additions at the END of the list, which would leave this mode's four rows --- in two places with unrelated rows between them. +-- What the engine's list carries is ONE row, and it leads. What that row opens +-- is the mod: the two pipeline rows it took off the engine's list, and the +-- four categories. Pipelines.setLevel("voxel", 2) +do local grouped = Runtime.call("ui.options.rows", function(_, r) return r end, { data = Data }, { { id = "tilt" }, { id = "pipeline:voxel" }, { id = "pipeline:tiltshift" }, { id = "void_fill" } }) -local order = {} -for i, row in ipairs(grouped) do order[row.id] = i end -T.check(order["pipeline:tiltshift"] < order["DRAMATIC_SHAPE:grid"], - "the mode's settings follow its pipeline rows") --- V-GRID, V-CURVE, RENDER DIST, WATER, FOREST FX, then 3D-BTL -T.eq(order["DRAMATIC_SHAPE:battles"] - order["pipeline:tiltshift"], 6, - "and sit in one unbroken block, not scattered to the end of the list") -T.check(order["void_fill"] > order["DRAMATIC_SHAPE:battles"], - "with the engine's own later rows still after them") +-- the engine gave it four rows and got three back: three of ours are gone +-- (tilt dropped, both pipelines moved) and one is added, at the front +-- the engine offered four and two come back: TILT dropped, both pipeline rows +-- moved onto the mod's own menu, and one row added at the front +T.eq(#grouped, 2, "one row for the whole mod, and the engine's own beneath it") +T.eq(grouped[1].id, Menus.catId(Menus.lib.ROOT), "and it leads the list") +T.eq(grouped[2].id, "void_fill", + "with the engine's own rows following in the order it built them") + +local root = Menus.rows(Menus.lib.ROOT) +T.check(root["pipeline:voxel"] and root["pipeline:tiltshift"], + "both pipeline rows are on the mod's root menu") +for _, cat in ipairs({ "world", "battles", "perf", "vr" }) do + T.check(root[Menus.catId(cat)], + ("off FULL the %s category is offered"):format(cat)) +end + +-- the split itself: each setting on the category that owns it, and nowhere +-- else. Order inside a category is free to change; membership is the promise. +local WHERE = { + grid = "world", curve = "world", viewbox = "world", water = "world", + daytime = "world", + battles = "battles", battleBack = "battles", letsgo = "battles", + atmos = "perf", shadows = "perf", aa = "perf", + vr = "vr", smoothturn = "vr", +} +for key, cat in pairs(WHERE) do + local id = "DRAMATIC_SHAPE:" .. key + -- smoothturn and battleBack need their own switch on first; they are + -- checked where those switches are, so only assert the ones offered now + if Menus.offered()[id] then + T.check(Menus.rows(cat)[id], + ("%s is on the %s menu"):format(key, cat)) + end +end +-- the ROM import is an action, not a setting, and it sits with the rungs of +-- 3D-BTL it unlocks +T.check(Menus.rows("battles")["DRAMATIC_SHAPE:stadiumRom"], + "and STADIUM ROM is under the battles, which is what it unlocks") +end -- ------- the open menu notices when FULL is stepped onto or off -- --- OptionsMenu reads its row list every frame but builds it once, so without --- a rebuild the rows FULL owns stay on screen until the menu is reopened -- --- and stepping OFF FULL never brings them back. +-- A menu reads its row list every frame but builds it once, so without a +-- rebuild the rows FULL owns stay on screen until it is reopened -- and +-- stepping OFF FULL never brings them back. local OptionsMenu = require("src.ui.OptionsMenu") local pressed = {} local menuGame = { @@ -343,17 +452,30 @@ local menuGame = { save = { options = { pipelines = {}, modOptions = {} } }, mods = { modOptions = {} }, input = { wasPressed = function(_, k) return pressed[k] or false end }, - stack = { pop = function() end }, + -- a real stack, because the mod's own menus push and pop on it + stack = { + states = {}, + push = function(s, st) s.states[#s.states + 1] = st end, + pop = function(s) s.states[#s.states] = nil end, + top = function(s) return s.states[#s.states] end, + }, writeOptions = function() end, } -Pipelines.setLevel("voxel", 2) -local menu = OptionsMenu.new(menuGame) local function rowIndex(m, id) for i, row in ipairs(m.rows) do if row.id == id then return i end end end -T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"), - "off FULL the menu opens with the mode's settings on it") + +Pipelines.setLevel("voxel", 2) +-- The mod's root menu shows the ENGINE's own pipeline descriptors, captured by +-- the hook on its way past. The sections above fed the hook stub rows, which +-- are enough to test what is dropped but carry no step function -- so hand it +-- the real ones before driving the menu, or a keypress on VOXEL does nothing. +Runtime.call("ui.options.rows", function(_, r) return r end, menuGame, + Pipelines.rows(menuGame)) +local menu = Menus.lib.new(menuGame, Menus.lib.ROOT) +T.check(rowIndex(menu, Menus.catId("world")), + "off FULL the root menu opens with the 3D WORLD category on it") -- step the VOXEL row from 15 down to FULL, the way the player would menu.index = rowIndex(menu, "pipeline:voxel") @@ -361,10 +483,12 @@ pressed = { left = true } menu:update(0) pressed = {} T.eq(Pipelines.level("voxel"), 1, "the step landed on FULL") -T.check(not rowIndex(menu, "DRAMATIC_SHAPE:grid"), - "and the rows FULL owns left the OPEN menu at once") -T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with them") +T.check(not rowIndex(menu, Menus.catId("world")), + "and the category FULL emptied left the OPEN menu at once") +T.check(not rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT with it") T.check(menu.index <= #menu.rows + 1, "the cursor stayed in range") +T.eq(menu.index, rowIndex(menu, "pipeline:voxel"), + "and stayed on the row the player just used") -- and back off it again menu.index = rowIndex(menu, "pipeline:voxel") @@ -372,16 +496,17 @@ pressed = { right = true } menu:update(0) pressed = {} T.eq(Pipelines.level("voxel"), 2, "the step left FULL") -T.check(rowIndex(menu, "DRAMATIC_SHAPE:grid"), - "and the rows came straight back without reopening the menu") +T.check(rowIndex(menu, Menus.catId("world")), + "and the category came straight back without reopening the menu") T.check(rowIndex(menu, "pipeline:tiltshift"), "T-SHIFT too") --- ------- 3D-BTL owns BATTLE LAYOUT, and takes it off the OPEN menu too +-- ------- 3D-BTL owns BATTLE LAYOUT, from a menu the OPTIONS list cannot see -- --- The row this one takes away sits ABOVE it in the list, so the cursor has to --- follow the row it was ON rather than the slot it was in -- otherwise the very --- press that switched staged battles on would leave the cursor a row further --- down than the player left it. +-- The switch lives on the mod's BATTLES menu now, and the stack ticks its TOP +-- state only (src/core/StateStack.update). So the step happens while the +-- OPTIONS menu underneath is suspended, and a before/after pair taken around +-- one call of its update would be read entirely after the fact and always +-- agree. The signature is held on the instance for exactly this. do local Battles = run.loader.exports.DRAMATIC_SHAPE.lib.require("OverworldBattle") Battles.setting:setValue(false, menuGame) -- staged battles off @@ -390,37 +515,214 @@ Pipelines.setLevel("voxel", 2) local layoutMenu = OptionsMenu.new(menuGame) T.check(rowIndex(layoutMenu, "battleLayout"), "with staged battles off, the engine's BATTLE LAYOUT row is on the menu") -layoutMenu.index = rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles") + +local battleMenu = Menus.lib.new(menuGame, "battles") +T.check(not rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"), + "and BACK SPRITES is off the BATTLES menu, having no staged fight to frame") +battleMenu.index = rowIndex(battleMenu, "DRAMATIC_SHAPE:battles") pressed = { right = true } -layoutMenu:update(0) +battleMenu:update(0) pressed = {} T.eq(Battles.setting:get(), true, "the step switched staged battles on") -T.check(not rowIndex(layoutMenu, "battleLayout"), - "and BATTLE LAYOUT left the open menu with the same keypress") T.eq(menuGame.save.options.battleLayout, "og", "pinned to OG on the way out") -T.eq(layoutMenu.index, rowIndex(layoutMenu, "DRAMATIC_SHAPE:battles"), +T.check(rowIndex(battleMenu, "DRAMATIC_SHAPE:battleBack"), + "and BACK SPRITES arrived on the open submenu with the same keypress") +-- it arrives BELOW the row that was used, so this one only holds because the +-- cursor follows the row rather than the slot +T.eq(battleMenu.index, rowIndex(battleMenu, "DRAMATIC_SHAPE:battles"), "with the cursor still on the row the player just used") + +layoutMenu:update(0) +T.check(not rowIndex(layoutMenu, "battleLayout"), + "and the OPTIONS menu underneath drops BATTLE LAYOUT on its next update, " + .. "though the step it is reacting to happened while it was suspended") +end + +-- ------- the row OPENS the menus, and B comes back one level at a time +do +menuGame.stack.states = {} +Pipelines.setLevel("voxel", 2) +local hookRow +for _, row in ipairs(Runtime.call("ui.options.rows", function(_, r) return r end, + menuGame, Pipelines.rows(menuGame))) do + if row.id == Menus.catId(Menus.lib.ROOT) then hookRow = row end +end +T.check(hookRow.activate and not hookRow.step, + "the row answers A alone -- `activate` SHADOWS `step` in the engine's own " + .. "dispatch, so a row carrying both would silently ignore Left and Right") +T.eq(hookRow.value(menuGame), "15", + "and its second line is VOXEL's own, so it says what the mode is doing " + .. "without being opened") + +hookRow.activate(menuGame) +local root = menuGame.stack:top() +T.check(root ~= nil, "pressing A pushed a screen") +T.eq(#root.rows, #Menus.lib.rows(Menus.lib.ROOT, menuGame), + "carrying the root menu's rows") +T.eq(root:backLabel(), "BACK", "and a way out named for what it does") + +-- the second line of a category row +T.eq(root.rows[rowIndex(root, Menus.catId("battles"))].value(), + Menus.rows("battles", menuGame)["DRAMATIC_SHAPE:battles"].value(), + "a category one setting speaks for says that setting's rung") +T.eq(root.rows[rowIndex(root, Menus.catId("world"))].value(), "5 SETTINGS", + "and one no single row speaks for counts them instead, which is honest " + .. "rather than arbitrary") + +-- into a category, and back out of it a level at a time +root.index = rowIndex(root, Menus.catId("world")) +pressed = { a = true } +root:update(0) +pressed = {} +T.eq(menuGame.stack:top():backLabel(), "BACK: 3D WORLD", + "A on a category opens it, and the bottom line says which one -- " + .. "OptionRows has no header, so the way out carries the name") +T.check(menuGame.stack:top() ~= root, "which is a screen of its own") + +pressed = { b = true } +menuGame.stack:top():update(0) +pressed = {} +T.eq(menuGame.stack:top(), root, "B comes back ONE level, to the root menu") +pressed = { start = true } +root:update(0) +pressed = {} +T.eq(menuGame.stack:top(), nil, "and START leaves it the way B does") +end + +-- ------- the mod's row is RED, which is a palette zone and not a color +-- +-- setColor cannot do it here twice over: the glyph atlas is black ink and LOVE +-- tints multiplicatively, and the palette shader keys on the red channel alone +-- so a red pixel would land in the LIGHTEST slot (see lib/SettingsMenu.lua). +-- What a zone changes is which color the SHADE the text was drawn in comes out +-- as, so what there is to assert is where the band sits and which slot carries +-- the ink. +do +local PaletteFX = require("src.render.PaletteFX") +-- the fixture dataset ships no palettes; stand MEWMON up, since the point is +-- that the red palette is MEWMON COPIED rather than four invented colors +Data.palettes = { palettes = { + MEWMON = { { 255, 239, 255 }, { 255, 140, 140 }, { 80, 80, 255 }, { 0, 0, 0 } }, +} } + +local red = Menus.lib.redPalette(Data) +T.eq(#red, 4, "the red palette is a palette like any other, four colors") +T.eq(red[1][2], 239, + "and its paper is MEWMON's own -- Red, Blue and Yellow ship different " + .. "tables, and the row must not shift the white it sits on") + +-- What has to hold is not which slot the red is in but where it comes OUT: +-- black text is shade 3 and the white box fill shade 0, and the display mode +-- transforms the table before the shader ever sees it (effectiveColors). So +-- assert through that, which is the thing the screen actually draws. +do + local eff = PaletteFX.effectiveColors(red) + T.check(eff[4][1] == 255 and eff[4][2] == 0 and eff[4][3] == 0, + "the ink shade comes out red") + T.eq(eff[1][2], 239, "and the paper shade is the white it always was") +end + +-- SGB INV REVERSES the table, so the red has to start in the other slot to +-- land on the ink. Without the special case the row draws as a solid red +-- block with the letters cut out of it. +PaletteFX.mode = "gbc_inv" +do + local eff = PaletteFX.effectiveColors(Menus.lib.redPalette(Data)) + T.check(eff[4][1] == 255 and eff[4][2] == 0, + "under SGB INV the ink shade STILL comes out red, once the mode has had " + .. "its turn at the table") + T.eq(eff[1][1], 0, + "on the same dark paper SGB INV gives every other row -- red ink, not a " + .. "red block") +end +PaletteFX.mode = "gbc" + +-- the band covers the two TEXT lines of the box and nothing else: tile 1 is +-- the cursor and tiles 0 and 19 are the box borders, all of them black glyphs +-- that would turn red with the label if the zone spanned the whole row +local zone = Menus.lib.rowZone(Data, 1) +T.eq(zone.x, 16, "the band starts at the label column, past the cursor") +T.eq(zone.y, 8, "on the first box's label line") +T.eq(zone.w, 17 * 8, "and stops short of the box's right border") +T.eq(zone.h, 16, "covering the label and the value under it") +T.eq(Menus.lib.rowZone(Data, 3).y, 8 * ((3 - 1) * 4 + 1), + "and it is addressed by SLOT, so it follows the row down the screen") + +-- on the menu itself: the whole-screen palette it always had, plus one band +menuGame.stack.states = {} +Runtime.call("ui.options.rows", function(_, r) return r end, menuGame, + Pipelines.rows(menuGame)) +local palMenu = OptionsMenu.new(menuGame) +T.eq(#palMenu:sgbPalettes(menuGame), 2, + "the OPTIONS menu paints its own palette and then the one band") +T.eq(palMenu:sgbPalettes(menuGame)[2].y, 8, + "on row 1, where the mod's row leads the list") +T.eq(palMenu:sgbPalettes(menuGame)[2].colors[4][1], 255, "and it is the red one") + +-- scrolled past, the band goes with it: a red stripe left behind on whatever +-- took the slot would be worse than no marker at all +palMenu.scroll = 4 +T.eq(#palMenu:sgbPalettes(menuGame), 1, + "scrolled off screen, the row takes its band with it") + +-- and a submenu paints its own. This is the whole reason it has the method: +-- Game:draw stops at the first state that HAS one, so a submenu without it +-- would inherit the band above and land it on an unrelated row. +T.eq(#Menus.lib.new(menuGame, Menus.lib.ROOT):sgbPalettes(menuGame), 1, + "a submenu owns its palette rather than inheriting the red band") + +Data.palettes = nil end -- level 2 is the "15" rung: any rung that is not FULL, so the settings the -- preset owns are back on the menu Pipelines.setLevel("voxel", 2) -local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end, - { data = Data }, { { id = "text_speed" } }) +do + -- what the engine really hands the hook: its own rows, the pipeline rows + -- among them. The mod moves those two onto its root menu, so a call that + -- left them out would leave the root menu with nothing but categories. + local given = { { id = "text_speed" } } + for _, row in ipairs(Pipelines.rows({ data = Data })) do + given[#given + 1] = row + end + Runtime.call("ui.options.rows", function(_, r) return r end, + { data = Data }, given) +end +-- Every row the mod offers, keyed by id. RENDER DIST, FOREST FX, LET'S GO, +-- SHADOWS and AA are read straight out of it where they are used rather than +-- named here, because this chunk is one main function and has 200 local slots +-- to spend. +local hookedRows = Menus.offered() -- one per setting, plus the STADIUM ROM action row -- which is not a setting -- (nothing to store, nothing for the mod manager to persist) and is offered -- on every platform, saying WHERE? rather than IMPORT where there is no file --- dialog to open -T.eq(#hookedRows, 14, "the options hook added a row per setting, plus the " - .. "STADIUM ROM action row") -local grid, curve, water = hookedRows[2], hookedRows[3], hookedRows[5] -local battles, backRow, daytime = hookedRows[7], hookedRows[8], hookedRows[10] --- the RENDER DIST row is hookedRows[4], FOREST FX hookedRows[6], LET'S GO --- hookedRows[9], SHADOWS hookedRows[11] and AA hookedRows[12]; all five are --- read where they are used rather than named here, because this chunk is one --- main function and has 200 local slots to spend -T.eq(hookedRows[4].label, "RENDER DIST", "the viewport row carries its label") -T.eq(hookedRows[4].value(), "FIT", +-- dialog to open -- and the four category rows that carry them +do + -- named rather than counted: a count says "something moved" and this says + -- WHAT is meant to be reachable. SMOOTH TURN is absent because VR is off, + -- which is its own gate and is tested where VR is turned on. + local want = { "grid", "curve", "viewbox", "water", "daytime", + "battles", "battleBack", "letsgo", "stadiumRom", + "atmos", "shadows", "aa", "vr" } + local n = 0 + for _ in pairs(hookedRows) do n = n + 1 end + for _, key in ipairs(want) do + T.check(hookedRows["DRAMATIC_SHAPE:" .. key], + ("%s is reachable from the mod's menus"):format(key)) + end + T.check(hookedRows["pipeline:voxel"] and hookedRows["pipeline:tiltshift"], + "and both pipeline rows with them") + -- those, the two pipelines and the four categories, and nothing else + T.eq(n, #want + 2 + 4, "and nothing else is on them") +end +local grid = hookedRows["DRAMATIC_SHAPE:grid"] +local curve, water = hookedRows["DRAMATIC_SHAPE:curve"], hookedRows["DRAMATIC_SHAPE:water"] +local battles = hookedRows["DRAMATIC_SHAPE:battles"] +local backRow, daytime = hookedRows["DRAMATIC_SHAPE:battleBack"], + hookedRows["DRAMATIC_SHAPE:daytime"] +T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].label, "RENDER DIST", + "the viewport row carries its label") +T.eq(hookedRows["DRAMATIC_SHAPE:viewbox"].value(), "FIT", "and defaults to FIT -- the cut IS the window the flat game already " .. "framed, which is the whole claim the row makes") T.eq(water.label, "WATER", "the water row carries its label") @@ -429,8 +731,8 @@ T.eq(water.value(), "FULL", water.step({ save = { options = {} }, mods = { modOptions = {} } }, 1) T.eq(water.value(), "SKY", "stepping down drops the screen-space march and keeps the sky, sun and moon") -T.eq(hookedRows[6].label, "FOREST FX", "the atmosphere row carries its label") -T.eq(hookedRows[6].value(), "FULL", +T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].label, "FOREST FX", "the atmosphere row carries its label") +T.eq(hookedRows["DRAMATIC_SHAPE:atmos"].value(), "FULL", "and defaults to FULL -- it only spends anything on a map with an " .. "atmosphere entry, which is one forest today") T.eq(daytime.label, "DAYTIME", "the day/night row carries its label") @@ -451,8 +753,8 @@ T.eq(backRow.value(), "OFF", .. "map, so the classic slot is opt-in") T.check(backRow.id ~= battles.id and backRow.id:find("battleBack", 1, true), "on its own key, so it persists beside 3D-BTL rather than over it") -T.eq(hookedRows[9].label, "LET'S GO", "the capture-mode row carries its label") -T.eq(hookedRows[9].value(), "OFF", +T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].label, "LET'S GO", "the capture-mode row carries its label") +T.eq(hookedRows["DRAMATIC_SHAPE:letsgo"].value(), "OFF", "and starts OFF -- a gameplay mode is opt-in, whatever the diorama does") -- stepping writes through to the one place both rows read @@ -532,7 +834,7 @@ do local AntiAlias = run.loader.exports.DRAMATIC_SHAPE.lib.require("AntiAlias") local VoxelGrid = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelGrid") local aaGame = { save = { options = {} }, mods = { modOptions = {} } } -local aa = hookedRows[12] +local aa = hookedRows["DRAMATIC_SHAPE:aa"] T.eq(aa.label, "AA", "the anti-aliasing row carries its label") T.eq(aa.value(), "OFF", "and starts off -- supersampling is a cost knob, and a mod must not spend " @@ -588,7 +890,7 @@ end ;(function() local Shadows = run.loader.exports.DRAMATIC_SHAPE.lib.require("Shadows") local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap") -local shadowRow = hookedRows[11] +local shadowRow = hookedRows["DRAMATIC_SHAPE:shadows"] local shGame = { save = { options = {} }, mods = { modOptions = {} } } T.eq(shadowRow.label, "SHADOWS", "the shadow row carries its label") T.eq(shadowRow.value(), "ON", @@ -3336,19 +3638,16 @@ T.eq(Battles.backSetting:get(), true, "without being rewritten underneath") -- ...so the row comes off the menu with it, on the same reasoning the mod's -- other absent rows come off: a row that no longer decides anything is worse -- than no row -local offRows = Runtime.call("ui.options.rows", function(_, r) return r end, - backGame, { { id = "tilt" } }) -local offIds = {} -for _, row in ipairs(offRows) do offIds[row.id] = true end -T.check(offIds["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered") -T.check(not offIds["DRAMATIC_SHAPE:battleBack"], +local offRows = Menus.rows("battles", backGame) +T.check(offRows["DRAMATIC_SHAPE:battles"], "3D-BTL itself is still offered") +T.check(not offRows["DRAMATIC_SHAPE:battleBack"], "but BACK SPRITES is off the menu while there is no staged fight to be about") Battles.setting:setValue(true, backGame) -local onRows = Runtime.call("ui.options.rows", function(_, r) return r end, - backGame, { { id = "tilt" } }) +-- switched back on it returns, and to the BATTLES menu -- the one the switch +-- it depends on is on, so the two are read together local onAt = {} -for i, row in ipairs(onRows) do onAt[row.id] = i end +for i, row in ipairs(Menus.lib.rows("battles", backGame)) do onAt[row.id] = i end T.check(onAt["DRAMATIC_SHAPE:battleBack"], "switched back on, so is the row") T.eq(onAt["DRAMATIC_SHAPE:battleBack"] - onAt["DRAMATIC_SHAPE:battles"], 1, "directly under the row it belongs to") @@ -5962,26 +6261,28 @@ end)() local VRMod = lib.require("VR") T.eq(VRMod.smoothTurn:get(), false, "SMOOTH TURN is off out of the box") - local function optionRows() - local out = Runtime.call("ui.options.rows", function(_, r) return r end, - { data = Data }, { { id = "tilt" } }) - local ids = {} - for _, row in ipairs(out) do ids[row.id] = row end - return ids - end - Pipelines.setLevel("voxel", 3) -- off FULL, which owns other rows VRMod.setting:sync(false) - T.check(not optionRows()["DRAMATIC_SHAPE:smoothturn"], - "with VR off the row is not on the OPTIONS menu") + T.check(not Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"], + "with VR off the row is not on the VR menu") + -- and with the headset's own row the only thing left on it, that menu is + -- still worth offering: a category is dropped when it is EMPTY, not when it + -- is short + T.check(Menus.rows(Menus.lib.ROOT)[Menus.catId("vr")], + "though the category is, on the strength of the VR row alone") VRMod.setting:sync(true) - local smoothRow = optionRows()["DRAMATIC_SHAPE:smoothturn"] + local smoothRow = Menus.rows("vr")["DRAMATIC_SHAPE:smoothturn"] T.check(smoothRow ~= nil, "and with VR on it is") if smoothRow then T.eq(smoothRow.label, "SMOOTH TURN", "under its own name") T.eq(smoothRow.value(), "OFF", "reading OFF until the player says otherwise") end + -- VR on takes the two battle rows away, which is the other half of the same + -- gate: the headset requires staged battles and forbids back sprites, so + -- both rows decide nothing there + T.check(not Menus.rows("battles")["DRAMATIC_SHAPE:battles"], + "and the battle rows are gone, being decided by the headset instead") VRMod.setting:sync(false) -- ------- the gun's own bookkeeping diff --git a/tests/letsgo_route6.lua b/tests/letsgo_route6.lua new file mode 100644 index 0000000..4d668f4 --- /dev/null +++ b/tests/letsgo_route6.lua @@ -0,0 +1,104 @@ +-- Probe: the reported grass along the TOP of the frame in GO-style +-- battles on ROUTE 6. +-- +-- The capture seat is a different camera from the battle's own: the "tele" +-- rig stands 145 world px back at a height of 37.9 (well over two cells, +-- clear of anything that grows on the ground), while the head-on capture +-- seat stands 46 back at a height of 13 -- BELOW the top of a 16px grass +-- tuft or hedge. On a route lined with the stuff, the eye is inside it. +-- +-- So: stage a capture at several spots along Route 6 and record where the +-- eye actually is relative to the ground, with a shot of each. +-- +-- SHOT_DIR=.scratchpad/route6 \ +-- POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/letsgo_route6.lua \ +-- "/c/Program Files/LOVE/lovec.exe" . +return function(game) + local U = dofile("tests/drivers/util.lua") + local DIR = os.getenv("SHOT_DIR") or ".scratchpad/route6" + local Pokemon = require("src.pokemon.Pokemon") + local BattleState = require("src.battle.BattleState") + local Bag = require("src.inventory.Bag") + + local exports = game.mods and game.mods.exports + local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib + if not lib then + U.log("DRAMATIC_SHAPE is not loaded") + return + end + local LetsGo = lib.require("LetsGo") + local CatchThrow = lib.require("CatchThrow") + local ChunkMesher = lib.require("ChunkMesher") + + pcall(os.execute, 'mkdir -p "' .. DIR .. '" 2>/dev/null') + pcall(os.execute, 'mkdir "' .. DIR:gsub("/", "\\") .. '" 2>nul') + + game.save.party = { Pokemon.new(game.data, "CHARIZARD", 45) } + game.save.player.name = "RED" + Bag.add(game.save, "POKE_BALL", 99, game.data) + CatchThrow.lastBall = "POKE_BALL" + LetsGo.setting:setValue("full", game) + + local MAP = os.getenv("DS_MAP") or "ROUTE_6" + -- a spread down the route: the reported shot is on the path with hedges + -- both sides, which is where a low seat has the least room + local SPOTS = {} + for _, xy in ipairs({ { 5, 6 }, { 5, 14 }, { 9, 20 }, { 4, 26 }, { 10, 32 } }) do + SPOTS[#SPOTS + 1] = xy + end + + for i, sp in ipairs(SPOTS) do + U.teleport(game, MAP, sp[1], sp[2], "up") + U.wait(50) + -- confirm the teleport actually landed: a first cut reported the SAME + -- eye at all five spots, which meant the probe never left the save's + -- own position and every "spot" was one place wearing five labels + do + local ow = game.overworld + local m = ow and ow.map + U.log(("spot %d: on map %s at cell %s,%s") + :format(i, tostring(m and (m.id or m.name) or "?"), + tostring(ow and ow.player and ow.player.cellX), + tostring(ow and ow.player and ow.player.cellY))) + end + for _ = 1, 600 do + if ChunkMesher.pending() == 0 then break end + U.wait(1) + end + + local bt = BattleState.newWild(game, "ODDISH", 13) + bt.onFinish = function() end + game.overworld:pushBattle(bt) + U.wait(70) + for _ = 1, 80 do + if bt.phase == "menu" or CatchThrow.session() then break end + U.tap(game, "a") + U.wait(6) + end + U.wait(40) + + local s = CatchThrow.session() + if s and s.shot and s.shot.eye then + local e = s.shot.eye + local p = s.playerPos + local back = math.sqrt((e[1] - p[1]) ^ 2 + (e[3] - p[3]) ^ 2) + U.log(("spot %d (%d,%d): eye = %.1f,%.1f,%.1f height %.1f over ground " + .. "seat %.1f back (wants 46)%s") + :format(i, sp[1], sp[2], e[1], e[2], e[3], e[2] - s.groundY, back, + back < 45.5 and " <-- the world pulled it in" or "")) + else + U.log(("spot %d (%d,%d): no capture session"):format(i, sp[1], sp[2])) + end + U.shot(game, ("%s/spot%d.png"):format(DIR, i)) + + -- out, and all the way off the stack before the next spot + U.tap(game, "b") + for _ = 1, 300 do + if not CatchThrow.session() and game.stack:top() ~= bt then break end + U.tap(game, "a") + U.wait(4) + end + U.wait(30) + end + U.log("done -- " .. DIR) +end