Takes the port branch up to77320d5e-- the state the human play-tested on 2026-09-02 -- for SOURCE paths only. Not a branch merge: `auto/port-p6-audio` is 366 commits and 938 files, and most of that must not land. WHAT COMES IN (76 files, all human-confirmed working): * the logo splash animation.08ed3dd1found it: `pose_at` ASSIGNED the settle instant instead of clamping to it, so the splash never animated at all -- and the same bug manufactured a passing harness result, because the harness photographed t past the settle. Confirmed by play-test: "cannot notice any obvious difference from the actual game." * gamepad input -- (A)/(B) bound additively (`ui_accept` ships with NO joypad binding), stick latched with hysteresis at the game's own 61% digitise threshold. This is what made (A), video-skip and Extras work at all. * menu navigation and flow, menu audio, the exporter, the authored declarations, and 23 verification tools under tools/port/. WHAT IS DELIBERATELY LEFT ON THE BRANCH: * everything afterc0ae460a-- the F5/F6 title-timing investigation, whose own tip commit calls itself a "hand-off for one-minute human checks". Unchecked by definition; it goes through the new review gate like anything else. * the OPTIONS menu work of 2026-09-03. Real, probably good, NOT play-tested. * the F1 repeat mechanism, which its own commit calls "deliberately inert". WHAT MUST NOT LAND, AND WHY THE .gitignore CHANGED: 545 MB of extracted game content was committed on that branch -- 850 sprite, audio and transcoded video files under `export-probe/` and `export-probe2/`, plus 246 MB of loose .wav and .tsv at the repo root. This repository's own rule, in this file, is "never game content". The rule was not missing. It was written, and it was tightened on that very branch, with a careful comment explaining why BOTH `export/` and `data/base/` had to be listed -- while the exporter was writing to a third name that nobody had thought to list. Enumerating names is the thing that failed. So the ignore rules now describe the SHAPE: any top-level `export*/`, game media by extension, and loose capture output at the root. Verified both ways -- it catches all four offenders and ignores nothing currently tracked. Verified: `cargo check --workspace` clean; all nine GDScript files parse in project context, with a positive control (an injected syntax error is detected, 3 lines) so the clean result means something. `tools/port/check-all` was NOT run -- it needs the container, the export tree and a display.
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}
|