Measured by the Decoder today: (B) from the menu to the title and (A) back returns to the item you left, not to a default; their control passed first, two delivery-confirmed DOWNs moving the cursor exactly two items before the round trip. The port reset to initial_focus on every entry, so a player who moved to EXTRAS, pressed (B) then (A) landed back on NEW GAME. MenuFlow.enter() now consults opening_focus(), and a new set_focus() writes the memory. set_focus() exists because two call sites set focus -- a cursor move and (B)'s restore -- and a memory updated at only one of them is right until the player uses the other. focus_persists is true on main_menu and nowhere else, and the scope is the authored part. wrap generalised because it was measured on two screens; this was measured on one. Here that is stronger than a preference: extras opens on MISSION SELECT as a MEASURED initial focus, so a menu-wide memory would have silently replaced a measured value with a derived one. Both halves are in one artifact, because a one-sided test passes a port that quietly generalised: the menu returns to ptbtn05 after the round trip, and extras opens on ptbtn11 both times despite being left on ptbtn12. contract-check asserts the pair -- on where measured, off elsewhere -- and fails its known negative. Eleven checks. Not assumed: whether the memory survives a reboot, or whether any other screen has it. Their reach is one boot, one round trip, one direction. The finding also reframes this morning's initial-focus warning without settling it -- if focus persists, a reading not taken on a fresh boot's first entry is measuring history. NEW GAME stays authored, on its own reasoning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
225 lines
9.1 KiB
GDScript
225 lines
9.1 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.
|
|
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}
|