# 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]) func enter(name: String, buttons: Array) -> void: stack.append({"screen": name, "focus": initial_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 stack[stack.size() - 1]["focus"] = String(buttons[to]) return true ## (A). Returns what the authored flow says the focused item opens. ## ## {"kind": "enter", "goto": , "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}