SettingsMenu.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
-- 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..", help = "The diorama itself: how far the world bends, how much of it is " .. "drawn, what the water does and what hour it is outdoors." }, { id = "battles", label = "BATTLES..", summary = function() return V.require("OverworldBattle").setting:valueLabel() end, help = "What a fight is drawn over, how it is framed, and how a ball is " .. "thrown." }, { id = "perf", label = "PERFORMANCE..", help = "What the look costs -- the three most expensive things in the " .. "frame after the geometry itself." }, { id = "vr", label = "VR..", summary = function() return V.require("VR").setting:valueLabel() end, help = "PCVR through OpenXR, and the one comfort setting that belongs to " .. "the headset alone." }, } -- ------- help for the rows that are not settings -- -- The thirteen settings each carry their own paragraph in main.lua's SETTINGS, -- next to the row it explains. What is left is the two pipeline rows -- whose -- descriptors belong to the ENGINE, so there is nowhere in them to put this -- -- and the ROM import, which is an action rather than a setting and has no -- SETTINGS entry to live in. local ROW_HELP = { ["pipeline:voxel"] = "The overworld extruded into real geometry and walked " .. "by a 3D camera, with the numbered rungs its angle in degrees.", ["pipeline:tiltshift"] = "A tilt-shift blur that sells the miniature-model " .. "look, sharp across the middle and softening above and below it.", ["DRAMATIC_SHAPE:stadiumRom"] = "Imports the Pokemon Stadium (US) 1.0 " .. "cartridge that 3D-BTL's STADIUM rungs need.", } -- ------- 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 -- What SELECT shows for a row: the setting's own paragraph out of SETTINGS, -- the category's out of CATEGORIES, or one of the three above for the rows -- that have nowhere else to keep it. -- -- Looked up BY ID rather than hung on the row as a field, because two of -- these rows are the engine's own tables reused verbatim -- and annotating -- somebody else's table is how a mod ends up owning a field it never meant -- to. nil for a row with nothing to say, which SELECT reads as "no box". function SettingsMenu.helpFor(id) if ROW_HELP[id] then return ROW_HELP[id] end for _, cat in ipairs(SettingsMenu.CATEGORIES) do if SettingsMenu.id(cat.id) == id then return cat.help end end for _, entry in ipairs(settings) do if "DRAMATIC_SHAPE:" .. entry[1].key == id then return entry[2] end end return nil 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 -- ------- settings that belong to no category -- -- A row can name SettingsMenu.ROOT as its `cat` and sit on the top-level -- menu next to the pipeline rows. For a setting that is about the GAME -- rather than about one of the four things the categories are for -- -- SHINY ODDS is the first -- burying it under a heading it does not -- belong to is worse than the flat list this menu was built to end. -- -- Above the categories, because these are rows you CHANGE and those are -- rows you OPEN: everything with a value on it stays together at the top -- of the screen, and the "..." rows read as the way further in. for _, entry in ipairs(settings) do if entry.cat == SettingsMenu.ROOT and offered(entry, full) then out[#out + 1] = entry[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 -- ------- and the ROM import, last, on the top-level menu -- -- 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 rather than living in SETTINGS. -- -- On the ROOT menu rather than under the battles whose STADIUM rungs it -- unlocks. It is a piece of one-time SETUP -- point the mod at a cartridge -- and wait while it builds -- and a player who has been told to import a -- ROM should find the row where the mod begins, not two levels down a -- category they have no reason to open until it has worked. Last, because -- the categories are what the menu is FOR. local ok, importRow = pcall(function() return V.require("StadiumRomPick").row() end) if ok and importRow then out[#out + 1] = importRow 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 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 -- The bottom line is the only place on this screen to say anything that is -- not a row: OptionRows' four boxes fill everything above it and there is no -- header slot. It spends that line on the two buttons that are not obvious. -- -- It used to carry the category's NAME instead, for orientation. The hint -- won: a binding nobody knows about is worth nothing, and where the player is -- was just answered by the row they pressed A on. Sixteen characters of the -- eighteen the line has, which is also why the name could not stay -- "BACK: -- PERFORMANCE" is seventeen on its own. SettingsMenu.BACK_LABEL = "B BACK SEL HELP" function SettingsMenu:backLabel() return SettingsMenu.BACK_LABEL 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("select") then -- SELECT explains the row the cursor is on. Every row on these menus has -- something to say -- the settings have carried a paragraph each since -- they were written, and nothing has ever drawn it (see SettingsHelp) -- -- but a row that does not is simply left alone rather than opening an -- empty box. local row = rows[self.index] local help = row and SettingsMenu.helpFor(row.id) if help and self.game.stack then self.game.stack:push( V.require("SettingsHelp").new(self.game, row.label, help)) end return 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