Files
Sylpheed/port/scripts/menu_flow.gd
Sylpheed port agent 50be9578c5 recover: the F5/F6 port work from the deleted auto/port-p6-audio
A snapshot of the non-game files as of 0148cb8 ("port: F5/F6 hand-off --
one-minute human checks, and a refutation attempt that survived",
2026-09-04), the tip of auto/port-p6-audio. The branch was deleted from the
server on 2026-09-17 during the consolidation cleanup; issue #7 asks for
this work as a reviewable PR, so it is recovered here before the commits
are garbage collected.

Contents: the 84 files the branch changed relative to its fork point
b305aa4, which is this commit's parent. The tree is therefore 0148cb8's
tree with the 854 exported game assets left out -- export-probe/,
export-probe2/, three .wav renders of game audio and adv-v2-screenlog.tsv.
Game data stays out of git; the exporter regenerates those from the disc.
docs/port/DECISIONS.md still refers to them by name.

Not recovered: the branch's own 366 commits. Keeping them would make those
assets reachable again, so this is one snapshot instead. The original
commits stay unreferenced in the server's object store, and in this clone
under the local branch archive/port-p6-audio, until either is garbage
collected.

Refs #7. The OPTIONS work that issue #6 asks for is a subset of this
branch, also recovered as recover/options-menu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 20:48:47 +02:00

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}