For several iterations I said the MISSION-SELECT-versus-top-item ambiguity needed
a screen whose opening item is not its first, and that none was known. The Decoder
found one and reported it had been sitting unconnected in their corpus.
It is in mine too, and I authored it. authored/flow.json under
main_menu/buttons/ptbtn01 has read since 1defbe0 on 2026-08-29: 'MEASURED
destination (EASY/NORMAL/HARD/BACK, opening on NORMAL, then SELECT DATA)'.
DIFFICULTY opens on the second of four. So 'a screen opens on its first item' is
refuted as a general description of this game, and on EXTRAS, TUTORIAL and OPTIONS
the named item and the top item coincide by accident.
Worse than an index failing to amplify: my extras/initial_focus_why framed the
ambiguity as conditional -- 'it matters IF another screen is ever authored' -- in
the same file that already recorded such a screen. Future tense over a fact twelve
keys away. Corrected to name DIFFICULTY concretely.
MenuFlow.initial_focus's buttons[0] fallback is now documented as a repair for
broken data rather than a default, and that is measured rather than fastidious: if
a screen reaches that line silently the port shows a top-item default for a game
that does not always have one. No authored value moves -- DIFFICULTY is not a
GP_TITLE build and EXTRAS keeps ptbtn11, correct under either reading. Walk re-run
unchanged.
It does not settle the question, which is about reset rather than opening. That
needs the cursor moved inside DIFFICULTY, left and re-entered, and its forward
path crashes the guest at SELECT DATA so the run must go back rather than on.
No checker either of us has built would have caught this. Every instrument here
verifies that a claim matches a value; nothing detects that an answer already
written down is not being connected to the question it answers -- and mine had
both halves in one file.
It also makes the previous iteration's restraint look better: declining to promote
'4/4 submenus reset' to a rule was argued from the principle that a generalisation
should not pre-decide the next screen, and the next screen turns out to be one the
generalisation would have got wrong.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
236 lines
9.7 KiB
GDScript
236 lines
9.7 KiB
GDScript
# Where the buttons go, and what the d-pad does.
|
|
#
|
|
# EVERYTHING IN HERE IS AUTHORED OR MEASURED -- none of it is on the disc.
|
|
# HANDOFF Q6 closed the "what drives the flow" question with a negative: the
|
|
# order is code, not data, in all four places it could have been. So this class
|
|
# reads `authored/flow.json` and holds no rule of its own.
|
|
#
|
|
# The split is deliberate and is the derived/authored contract in miniature:
|
|
#
|
|
# * the ORDER of the items is DERIVED -- each screen file's `buttons`, which
|
|
# the exporter fills from the button-role elements sorted by resting Y;
|
|
# * WHERE an item goes, WHICH item opens focused, and WHAT (B) does are
|
|
# AUTHORED, because they were measured off the running game or chosen.
|
|
#
|
|
# A rule that lived in GDScript instead would be invisible to the person whose
|
|
# job is to notice that we decided it.
|
|
class_name MenuFlow
|
|
extends RefCounted
|
|
|
|
## The authored `screens` map: screen name -> destinations, initial focus, (B).
|
|
var screens: Dictionary = {}
|
|
## The authored `navigation` block: wrap, left/right, input during a transition.
|
|
var navigation: Dictionary = {}
|
|
|
|
## Where we are and how we got here, oldest first. The last entry is current.
|
|
## (B) restores the focus recorded on the entry it pops back to -- HANDOFF Q5
|
|
## measured that the game does this, so the stack carries a focus, not just a
|
|
## name.
|
|
var stack: Array[Dictionary] = []
|
|
|
|
var error: String = ""
|
|
|
|
## Nothing happened. Returned rather than `null` so a caller reads one shape.
|
|
const NONE := {"kind": "none"}
|
|
|
|
|
|
func configure(flow: Variant) -> bool:
|
|
if typeof(flow) != TYPE_DICTIONARY:
|
|
error = "authored/flow.json did not parse to an object"
|
|
return false
|
|
if not flow.has("screens") or not flow.has("navigation"):
|
|
error = "authored/flow.json has no `screens`/`navigation` block -- this build needs both"
|
|
return false
|
|
screens = flow["screens"]
|
|
navigation = flow["navigation"]
|
|
return true
|
|
|
|
|
|
func known(name: String) -> bool:
|
|
return screens.has(name) and typeof(screens[name]) == TYPE_DICTIONARY
|
|
|
|
|
|
func current() -> String:
|
|
return String(stack[stack.size() - 1]["screen"]) if not stack.is_empty() else ""
|
|
|
|
|
|
func focus() -> String:
|
|
return String(stack[stack.size() - 1]["focus"]) if not stack.is_empty() else ""
|
|
|
|
|
|
## The item a screen opens on.
|
|
##
|
|
## Authored per screen. Where the authored value names a button this screen does
|
|
## not have -- a mistyped id, or an export whose buttons moved -- fall back to
|
|
## the first button rather than to nothing, and SAY SO: a menu that opens with
|
|
## no focus looks like a rendering bug, and this is the one place that mistake
|
|
## would hide.
|
|
##
|
|
## 🔴 THE FALLBACK IS A REPAIR, NOT A DEFAULT, and since 2026-08-31 that is
|
|
## measured rather than fastidious. `DIFFICULTY` -- EASY/NORMAL/HARD/BACK --
|
|
## opens on **NORMAL, the second of four**, so "a screen opens on its first item"
|
|
## is refuted as a description of this game. On `EXTRAS`, `TUTORIAL` and
|
|
## `OPTIONS` the named item and the top item coincide by accident.
|
|
##
|
|
## So `buttons[0]` here is what to draw when the DATA IS BROKEN, and it warns
|
|
## precisely because it is not a claim about the game. If a screen ever reaches
|
|
## this line silently, the port will be showing a top-item default for a game
|
|
## that does not always have one.
|
|
func initial_focus(name: String, buttons: Array) -> String:
|
|
if buttons.is_empty():
|
|
return ""
|
|
var want := String(screens.get(name, {}).get("initial_focus", ""))
|
|
if want != "" and buttons.has(want):
|
|
return want
|
|
if want != "":
|
|
push_warning("flow.json opens %s on %s, which is not one of its buttons %s" % [name, want, buttons])
|
|
return String(buttons[0])
|
|
|
|
|
|
## Where a screen's cursor was when the player last left it.
|
|
##
|
|
## MEASURED 2026-08-30: the main menu remembers its cursor across a round trip
|
|
## through the title -- (B) out and (A) back returns to the item you left. The
|
|
## port used to reset to `initial_focus` on every entry, so a player who moved to
|
|
## EXTRAS, pressed (B) and then (A) landed back on NEW GAME.
|
|
##
|
|
## 🔴 Which screens have this is AUTHORED, not derived: `focus_persists` in
|
|
## `authored/flow.json`, true only on `main_menu`. The measurement is of that one
|
|
## screen, and widening it would contradict another measurement -- `extras` opens
|
|
## on MISSION SELECT as a MEASURED initial focus, which a remembered cursor would
|
|
## override. `wrap` generalises because it was measured on two screens; this was
|
|
## measured on one.
|
|
var remembered: Dictionary = {}
|
|
|
|
|
|
## What a screen opens on: what it was left on, if it is one of the screens that
|
|
## remembers, else the authored opening item.
|
|
func opening_focus(name: String, buttons: Array) -> String:
|
|
var keep := bool(screens.get(name, {}).get("focus_persists", false))
|
|
var was := String(remembered.get(name, ""))
|
|
if keep and was != "" and buttons.has(was):
|
|
return was
|
|
return initial_focus(name, buttons)
|
|
|
|
|
|
func enter(name: String, buttons: Array) -> void:
|
|
stack.append({"screen": name, "focus": opening_focus(name, buttons)})
|
|
|
|
|
|
## Move the cursor. Returns true when it actually moved, so a caller can fire the
|
|
## move cue only on a real move (P6) rather than on every press.
|
|
##
|
|
## MEASURED, HANDOFF Q5: up/down move one item and WRAP at both ends -- on the
|
|
## 5-item main menu and the 3-item EXTRAS both, so it is a menu rule. `wrap` is
|
|
## read from `authored/flow.json` rather than written here, because it is a
|
|
## measurement and the day it is contradicted the fix is a data edit.
|
|
func move(step: int, buttons: Array) -> bool:
|
|
if stack.is_empty() or buttons.size() < 2:
|
|
return false
|
|
var at := buttons.find(focus())
|
|
if at < 0:
|
|
at = 0
|
|
var to := at + step
|
|
if bool(navigation.get("wrap", true)):
|
|
to = posmod(to, buttons.size())
|
|
else:
|
|
to = clampi(to, 0, buttons.size() - 1)
|
|
if to == at:
|
|
return false
|
|
set_focus(String(buttons[to]))
|
|
return true
|
|
|
|
|
|
## Set the top of the stack's focus AND remember it, in one place.
|
|
##
|
|
## Two call sites set focus -- a cursor move and (B)'s restore -- and a memory
|
|
## updated at only one of them would be right until the player used the other.
|
|
func set_focus(id: String) -> void:
|
|
if stack.is_empty():
|
|
return
|
|
stack[stack.size() - 1]["focus"] = id
|
|
remembered[current()] = id
|
|
|
|
|
|
## (A). Returns what the authored flow says the focused item opens.
|
|
##
|
|
## {"kind": "enter", "goto": <screen>, "label": …} -- go there
|
|
## {"kind": "blocked", "label": …, "why": …} -- a real destination
|
|
## that is not in this
|
|
## export
|
|
## {"kind": "video", "video": …, "skipped": […], -- the destination is
|
|
## "after": {…}} absent but its chain
|
|
## ends in a movie we
|
|
## DO have (P7)
|
|
## {"kind": "none"} -- nothing bound
|
|
##
|
|
## `blocked` is not an error and is not an unknown. Those five destinations were
|
|
## measured off the running game; they live in other archives and this milestone
|
|
## does not export them. Saying "blocked" rather than "none" keeps the two apart.
|
|
func accept(buttons: Array) -> Dictionary:
|
|
if stack.is_empty():
|
|
return NONE
|
|
var screen: Dictionary = screens.get(current(), {})
|
|
# A screen with no buttons -- the title -- can still take (A).
|
|
if buttons.is_empty():
|
|
return _target(screen.get("on_accept", null), "A")
|
|
var button: Dictionary = screen.get("buttons", {}).get(focus(), {})
|
|
if button.is_empty():
|
|
return NONE
|
|
var label := String(button.get("label", focus()))
|
|
if button.get("goto", null) == null:
|
|
# A destination this export does not carry, but whose CHAIN ends in
|
|
# something it does: `NEW GAME` opens `DIFFICULTY`, then `SELECT DATA`,
|
|
# and only then the new-game movie. The port has the movie and neither
|
|
# screen (P7).
|
|
#
|
|
# This is returned as its own kind rather than folded into `blocked`,
|
|
# because the caller has to announce the skip. A port that quietly
|
|
# jumped from `NEW GAME` to the intro would be showing a sequence the
|
|
# game does not have, and nothing on screen would say so.
|
|
if button.get("then_video", null) != null:
|
|
return {
|
|
"kind": "video",
|
|
"label": label,
|
|
"video": String(button["then_video"]),
|
|
"skipped": button.get("skipped_chain", []),
|
|
"skippable": bool(button.get("skippable", false)),
|
|
"after": button.get("after_video", {}),
|
|
}
|
|
return {"kind": "blocked", "label": label, "why": String(button.get("blocked", ""))}
|
|
return {"kind": "enter", "goto": String(button["goto"]), "label": label}
|
|
|
|
|
|
## (B), and EXTRAS' own `BACK` item, which is treated as the same thing --
|
|
## nothing measured distinguishes them and inventing a difference would be a
|
|
## guess with no evidence behind it.
|
|
##
|
|
## MEASURED, HANDOFF Q5: (B) goes up one level and RESTORES FOCUS to the item you
|
|
## came from. So the target comes from the authored flow, but the focus comes
|
|
## from the STACK -- and only when the stack agrees about where we are going. A
|
|
## run that started straight on a submenu has no history to restore and enters
|
|
## the parent at its authored initial focus instead.
|
|
func cancel() -> Dictionary:
|
|
if stack.is_empty():
|
|
return NONE
|
|
var target: Variant = screens.get(current(), {}).get("on_cancel", null)
|
|
var out := _target(target, "B")
|
|
if out["kind"] != "enter":
|
|
return out
|
|
if stack.size() >= 2 and String(stack[stack.size() - 2]["screen"]) == out["goto"]:
|
|
out["restore_focus"] = String(stack[stack.size() - 2]["focus"])
|
|
out["pop"] = true
|
|
return out
|
|
|
|
|
|
## Pop back to the parent, keeping the focus it was left on.
|
|
func pop() -> void:
|
|
if stack.size() >= 2:
|
|
stack.pop_back()
|
|
|
|
|
|
static func _target(target: Variant, label: String) -> Dictionary:
|
|
if typeof(target) != TYPE_DICTIONARY or target.get("goto", null) == null:
|
|
return NONE
|
|
return {"kind": "enter", "goto": String(target["goto"]), "label": label}
|