port: P5 -- the menus navigate, and the focus ring is drawn wrong on purpose
P5's gate is "a human clicks through it". The artifact is a scripted walk that
proves the wiring rather than the intent -- up (wraps 01->05), five down, (A)
into EXTRAS, down, (B) back, landing on the main menu with focus RESTORED to
EXTRAS, ten PNGs one per settled step:
xvfb-run -a godot --path port -- --menu \
--script=up,down,down,down,down,down,accept,down,cancel --shots=/tmp/p5
--script posts InputEventAction through Input.parse_input_event so the presses
arrive at _unhandled_input exactly as a d-pad's would. Calling MenuFlow directly
would have been shorter and would have proved nothing: the wiring between a
press and the cursor is the part most likely to be broken, and a direct call is
exactly the part that skips it.
Derived vs authored, which P5 is the easiest place to blur:
* DERIVED -- the ORDER of the items, from each screen file's `buttons`, which
the exporter already fills from button-role elements sorted by resting Y.
* AUTHORED -- destinations, initial focus, what (B) does, and left/right being
a no-op. All measured off the running game (HANDOFF Q4/Q5) or chosen, none
on the disc, all in authored/flow.json with a why.
Four of five main-menu destinations are `goto: null` with a `blocked` note. That
is a MILESTONE BOUNDARY, not an unknown -- DIFFICULTY, the save list, the lesson
list and OPTIONS were all measured and live in archives this export does not
carry. `blocked` and `none` are kept apart so nobody later "discovers" the gap.
--headless CANNOT DRAW, and the port hung instead of saying so.
Measured, not assumed: under --headless Godot's dummy renderer never emits
RenderingServer.frame_post_draw, so every capture path awaited it forever --
--capture since P1, --film since P3, --shots as of now. With stdout block-
buffered the observable behaviour was SILENCE, FOREVER, which in a loop reads as
a job still working. Isolated by `--quit` (prints, exits 0) vs `--capture` (zero
bytes, killed at 40 s). Now those three flags refuse at STARTUP naming the
xvfb-run line that works, and --script no longer waits for a frame it is not
going to photograph -- so headless walks the menus in 4.5 s as a cheap
regression check needing no X server.
REFUTATION ATTEMPT, against the Decoder's 7eeae30 point 2 ("the oracle confirms
the game renders the ring's rotation"). Aimed there because PROTOCOL says to aim
at a claim the port is about to build on that rests on an estimator whose own
control the Decoder reported as +/-19.8 deg. IT SURVIVES, more strongly than
claimed.
Both captures draw the SAME sprite (ptbtneff01) 240 px apart, so "is it drawn
rotated" becomes "are these two crops one image at a different angle" -- no crop
offset needed and no reference to our own renderer. 360-bin angular luminance
profile over the annulus, circularly cross-correlated. Two controls first: known
rotations 0/30/90/150/210/270/330 recovered with 0 deg error, and a ring-free
patch of the same capture peaks at 0.369, so the estimator does not manufacture
matches. Then: A vs B 134 deg (corr 0.968), sprite vs A 76 deg, sprite vs B
210 deg -- and 210-76 = 134, which nothing in the method forced.
So 0 deg is NOT A POSE THE GAME SHOWS, and screen_view.gd draws the ring at
0 deg. That is now stated in the code as known-wrong rather than suspected. The
port did NOT start spinning it: the period has two unknowns and both are the
Decoder's -- the second keyframe is untimed, and "groups hold" predicts a stop
at 360 = 0 which contradicts both captures. Two frames of one focused button a
known time apart settle it. Filed in BLOCKED.md and asked over the channel.
BLOCKED.md's staleness check was half a check. It tested whether that page is
stale relative to HANDOFF; it cannot see the other direction, and the other
direction is what happened -- 7eeae30 lands 27 minutes AFTER HANDOFF was last
written and answers a question HANDOFF still lists as open. Added the missing
half: `git log --oneline 9ca1eb5..HEAD -- docs/re/`.
Also recorded, since the two were nearly confused: the ring's annulus centroid
lands within ~0.4 px of its design position under a ZERO crop offset, which
corroborates ORACLE-CAPTURES' "1279x675, top-left aligned" on a feature nobody
chose for the purpose. The earlier "text bands at design y + 23" is an offset
WITHIN the button sprite, not a crop offset.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CtmUw5N5LJaMW1Njb8Ziey
This commit is contained in:
@@ -10,6 +10,16 @@
|
||||
# godot --path port -- --screen=main_menu --pose=rest --capture=/tmp/rest.png
|
||||
# godot --path port -- --boot # the whole boot sequence
|
||||
# godot --path port -- --boot --film=/tmp/boot # ...and a frame every 0.25 s
|
||||
# godot --path port -- --menu # P5: navigate the menus
|
||||
# godot --path port -- --menu=extras # ...starting somewhere else
|
||||
# godot --path port -- --boot --play # boot, then hand over to P5
|
||||
# godot --path port -- --menu --script=down,down,accept,cancel --shots=/tmp/p5
|
||||
#
|
||||
# `--menu` is the P5 mode: the d-pad moves the cursor, (A) opens, (B) goes back.
|
||||
# `--script` drives the SAME input path with synthetic events -- it does not call
|
||||
# the navigation functions directly, because then the artifact would prove
|
||||
# nothing about whether a human's press arrives. `--shots` writes one PNG per
|
||||
# scripted step, after the screen it produced has settled.
|
||||
#
|
||||
# `--time` is in SECONDS and freezes the timeline there; without it the screen
|
||||
# animates in real time from t=0. `--pose=rest` draws the export's declared
|
||||
@@ -32,6 +42,10 @@ var viewport: SubViewport = null
|
||||
|
||||
func _ready() -> void:
|
||||
var args := _args()
|
||||
for flag: String in ["capture", "film", "shots"]:
|
||||
if args.has(flag) and not _has_display(flag):
|
||||
get_tree().quit(4)
|
||||
return
|
||||
var export_tree := ExportTree.locate()
|
||||
if export_tree.root == "":
|
||||
push_error(export_tree.error)
|
||||
@@ -39,17 +53,32 @@ func _ready() -> void:
|
||||
return
|
||||
|
||||
_flow = export_tree.authored("flow.json")
|
||||
if _flow == null and (args.has("boot") or args.has("menu")):
|
||||
push_error(export_tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
if args.has("boot"):
|
||||
if _flow == null:
|
||||
push_error(export_tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
for step: Dictionary in _flow["boot"]:
|
||||
_sequence.append(step)
|
||||
_film = args.get("film", "")
|
||||
_film = args.get("film", "")
|
||||
_shots = args.get("shots", "")
|
||||
if args.has("script"):
|
||||
_script = args["script"].split(",", false)
|
||||
# P5. `--play` boots first and hands over on the title; `--menu` starts on a
|
||||
# screen directly, which is what makes an unattended run cheap -- it does not
|
||||
# sit through 137 s of intro to press a d-pad.
|
||||
_play = args.has("play") or args.has("menu")
|
||||
if _play:
|
||||
_menu = MenuFlow.new()
|
||||
if not _menu.configure(_flow):
|
||||
push_error(_menu.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
|
||||
var name: String = String(_sequence[0].get("screen", "")) if not _sequence.is_empty() \
|
||||
else args.get("screen", DEFAULT_SCREEN)
|
||||
else args.get("menu", args.get("screen", DEFAULT_SCREEN))
|
||||
if name == "1":
|
||||
name = DEFAULT_SCREEN # bare `--menu`
|
||||
if name == "":
|
||||
name = DEFAULT_SCREEN # the sequence opens on a video; load something to size the viewport
|
||||
var screen: Dictionary = export_tree.screen(name)
|
||||
@@ -99,6 +128,10 @@ func _ready() -> void:
|
||||
get_tree().quit(2)
|
||||
return
|
||||
|
||||
# Not booting: `--menu` opens straight onto a screen, so the stack starts here.
|
||||
if _menu != null and _sequence.is_empty():
|
||||
_menu_enter(name, true)
|
||||
|
||||
var settle := view.settle_time()
|
||||
print("screen %s: %d elements, %d in paint order, design %dx%d, settles at t=%d (%.3f s)" % [
|
||||
name, view.screen["elements"].size(), view.screen["paint_order"].size(),
|
||||
@@ -120,6 +153,12 @@ func _ready() -> void:
|
||||
|
||||
var _frozen := false
|
||||
var _flow: Variant = null
|
||||
var _menu: MenuFlow = null
|
||||
var _play := false
|
||||
var _pending: Variant = null
|
||||
var _script: PackedStringArray = PackedStringArray()
|
||||
var _shots := ""
|
||||
var _script_started := false
|
||||
var _sequence: Array[Dictionary] = []
|
||||
var _player: VideoStreamPlayer = null
|
||||
var _step := 0
|
||||
@@ -137,7 +176,18 @@ func _process(delta: float) -> void:
|
||||
_elapsed += delta
|
||||
view.queue_redraw()
|
||||
|
||||
if _sequence.is_empty() or _player != null:
|
||||
if _player != null:
|
||||
return
|
||||
|
||||
# A menu transition. This is checked BEFORE the boot sequence and outside
|
||||
# its emptiness guard: `--menu` has no sequence at all, and an earlier
|
||||
# version returned here, so the screen faded out and nothing ever arrived.
|
||||
if _pending != null:
|
||||
if view.time_units >= view.exit_time():
|
||||
_menu_arrive()
|
||||
return
|
||||
|
||||
if _sequence.is_empty():
|
||||
return
|
||||
|
||||
# A screen holds at `rest` until it has arrived, then plays itself out and
|
||||
@@ -154,7 +204,13 @@ func _process(delta: float) -> void:
|
||||
elif not _boot_done:
|
||||
_boot_done = true
|
||||
print("boot sequence complete after %.2f s, holding on %s" % [_elapsed, _sequence[_step]])
|
||||
if _film == "":
|
||||
# P5 takes over here: the boot ends on the title and the title has
|
||||
# somewhere to go. Without `--play` the run still stops, because a
|
||||
# boot that ends by waiting for a key it will never get is worse
|
||||
# than one that exits.
|
||||
if _play:
|
||||
_menu_enter(String(_sequence[_step].get("screen", "")), true)
|
||||
elif _film == "":
|
||||
get_tree().quit(0)
|
||||
elif not view.holding and view.time_units >= view.exit_time():
|
||||
_advance()
|
||||
@@ -221,14 +277,120 @@ func _video_finished() -> void:
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
# HANDOFF Q9, measured: one (A) press skips a movie -- the title was reached
|
||||
# at 57 s against a 193 s baseline. This is the only input the port handles
|
||||
# so far; menu navigation is P5.
|
||||
if _player == null or not _skippable:
|
||||
# at 57 s against a 193 s baseline.
|
||||
if _player != null:
|
||||
if _skippable and (event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel")):
|
||||
print(" video skipped at %.2f s" % _elapsed)
|
||||
_player.stop()
|
||||
_video_finished()
|
||||
return
|
||||
if event.is_action_pressed("ui_accept") or event.is_action_pressed("ui_cancel"):
|
||||
print(" video skipped at %.2f s" % _elapsed)
|
||||
_player.stop()
|
||||
_video_finished()
|
||||
if _menu == null or _menu.stack.is_empty():
|
||||
return
|
||||
# AUTHORED, not measured: a press during a screen's fade-out is dropped.
|
||||
# `authored/flow.json` says why -- nobody has watched what the game does
|
||||
# here, and dropping invents less than queueing.
|
||||
if _pending != null:
|
||||
return
|
||||
var buttons: Array = view.screen.get("buttons", [])
|
||||
if event.is_action_pressed("ui_up"):
|
||||
_menu_move(-1, buttons)
|
||||
elif event.is_action_pressed("ui_down"):
|
||||
_menu_move(1, buttons)
|
||||
elif event.is_action_pressed("ui_left") or event.is_action_pressed("ui_right"):
|
||||
# MEASURED, HANDOFF Q5: left/right do nothing. Written out rather than
|
||||
# left unhandled so that "the game ignores it" and "we never wired it"
|
||||
# are different lines of code.
|
||||
pass
|
||||
elif event.is_action_pressed("ui_accept"):
|
||||
_menu_activate(_menu.accept(buttons))
|
||||
elif event.is_action_pressed("ui_cancel"):
|
||||
_menu_activate(_menu.cancel())
|
||||
|
||||
|
||||
func _menu_move(step: int, buttons: Array) -> void:
|
||||
if _menu.move(step, buttons):
|
||||
view.focused_id = _menu.focus()
|
||||
view.queue_redraw()
|
||||
print(" focus -> %s" % view.focused_id)
|
||||
|
||||
|
||||
## Act on what the flow returned. A destination starts the screen playing itself
|
||||
## out; the arrival happens in `_process` when the exit ramp is done, so the
|
||||
## fade is the transition HANDOFF Q7 measured and not a cut.
|
||||
func _menu_activate(action: Dictionary) -> void:
|
||||
match String(action.get("kind", "none")):
|
||||
"enter":
|
||||
print(" (%s) -> %s" % [action.get("label", ""), action["goto"]])
|
||||
_pending = action
|
||||
view.holding = false
|
||||
"blocked":
|
||||
# A real, measured destination that is not in this export. Say which
|
||||
# -- silence here would read as a dead button.
|
||||
print(" (%s) opens a screen this export does not carry: %s"
|
||||
% [action.get("label", ""), action.get("why", "")])
|
||||
_:
|
||||
pass
|
||||
|
||||
|
||||
## Enter a screen with the menu live. `fresh` seeds the stack rather than
|
||||
## replacing the top, which is what a boot handover and `--menu` both want.
|
||||
func _menu_enter(name: String, fresh: bool) -> void:
|
||||
if name == "" or not _menu.known(name):
|
||||
push_warning("flow.json describes no screen named %s -- navigation stops here" % name)
|
||||
return
|
||||
if fresh:
|
||||
_menu.enter(name, view.screen.get("buttons", []))
|
||||
view.focused_id = _menu.focus()
|
||||
view.queue_redraw()
|
||||
print(" menu on %s, focus %s" % [name, view.focused_id])
|
||||
if not _script.is_empty() and not _script_started:
|
||||
_script_started = true
|
||||
_run_script()
|
||||
|
||||
|
||||
## The moment a screen has finished fading out and the next one takes over.
|
||||
func _menu_arrive() -> void:
|
||||
var action: Dictionary = _pending
|
||||
_pending = null
|
||||
var name := String(action["goto"])
|
||||
view.holding = true
|
||||
view.time_units = 0.0
|
||||
if not view.load_screen(view.tree, name):
|
||||
push_error(view.tree.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
var buttons: Array = view.screen.get("buttons", [])
|
||||
if action.get("pop", false):
|
||||
# MEASURED, HANDOFF Q5: (B) restores the focus you came from.
|
||||
_menu.pop()
|
||||
_menu.stack[_menu.stack.size() - 1]["focus"] = String(action["restore_focus"])
|
||||
view.focused_id = _menu.focus()
|
||||
view.queue_redraw()
|
||||
print(" menu on %s, focus restored to %s" % [name, view.focused_id])
|
||||
else:
|
||||
_menu_enter(name, true)
|
||||
|
||||
|
||||
## Whether this process can produce a picture at all.
|
||||
##
|
||||
## MEASURED here, not assumed: under `--headless` Godot's dummy renderer never
|
||||
## emits `RenderingServer.frame_post_draw`, so every `await` on it blocks
|
||||
## forever. `godot-headless --path port -- --screen=main_menu --capture=…`
|
||||
## therefore hung with NO OUTPUT until it was killed -- the same run with
|
||||
## `--quit` prints and exits, which is how the difference was isolated.
|
||||
##
|
||||
## That is the worst shape a failure can take in an unattended loop: it does not
|
||||
## fail, it waits, and a job that waits forever reads as a job still working.
|
||||
## So the flags that need a frame refuse at STARTUP and say what to run instead,
|
||||
## rather than dying somewhere in the middle of a filmstrip.
|
||||
func _has_display(flag: String) -> bool:
|
||||
if DisplayServer.get_name() != "headless":
|
||||
return true
|
||||
push_error(("--%s needs a drawn frame, and --headless never draws one: " +
|
||||
"Godot's dummy renderer does not emit frame_post_draw, so this would " +
|
||||
"hang rather than fail. Run it under Xvfb instead:\n" +
|
||||
" xvfb-run -a godot --path port -- …--%s=…") % [flag, flag])
|
||||
return false
|
||||
|
||||
|
||||
func _capture(path: String) -> void:
|
||||
@@ -271,3 +433,101 @@ static func _args() -> Dictionary:
|
||||
elif arg.begins_with("--"):
|
||||
out[arg.substr(2)] = "1"
|
||||
return out
|
||||
|
||||
|
||||
# ── The scripted walk ───────────────────────────────────────────────────────
|
||||
#
|
||||
# `--script=down,down,accept,cancel` presses those buttons in order and, with
|
||||
# `--shots=`, leaves one PNG per step behind. This is the P5 artifact for an
|
||||
# unattended run.
|
||||
#
|
||||
# It sends synthetic events through `Input.parse_input_event`, so they arrive at
|
||||
# `_unhandled_input` exactly as a d-pad's would. Calling the navigation
|
||||
# functions directly would have been three lines shorter and would have proved
|
||||
# nothing: the thing most likely to be broken is the wiring between a press and
|
||||
# the cursor, and that is the part a direct call skips.
|
||||
|
||||
const SCRIPT_ACTIONS := {
|
||||
"up": "ui_up", "down": "ui_down", "left": "ui_left", "right": "ui_right",
|
||||
"accept": "ui_accept", "a": "ui_accept", "cancel": "ui_cancel", "b": "ui_cancel",
|
||||
}
|
||||
|
||||
## How long a single step may take before the run is called stuck, in seconds.
|
||||
## A screen that never settles would otherwise hang an unattended job forever;
|
||||
## the title's own timeline is 4.5 s, so this is generous rather than tuned.
|
||||
const SCRIPT_STEP_TIMEOUT := 20.0
|
||||
|
||||
|
||||
func _run_script() -> void:
|
||||
if not await _script_settled("start"):
|
||||
return
|
||||
await _shoot("00_start")
|
||||
for i in range(_script.size()):
|
||||
var token := _script[i].strip_edges().to_lower()
|
||||
if token == "wait":
|
||||
pass
|
||||
elif SCRIPT_ACTIONS.has(token):
|
||||
print("script[%d] %s" % [i + 1, token])
|
||||
_press(String(SCRIPT_ACTIONS[token]))
|
||||
else:
|
||||
push_error("--script: no such step %s (have %s, wait)" % [token, ", ".join(SCRIPT_ACTIONS.keys())])
|
||||
get_tree().quit(2)
|
||||
return
|
||||
# `Input.parse_input_event` is flushed with the frame, not on the call.
|
||||
# Without these two frames the settle check runs while the press has not
|
||||
# been delivered yet, decides nothing is moving, and photographs the
|
||||
# screen the press was about to leave.
|
||||
await get_tree().process_frame
|
||||
await get_tree().process_frame
|
||||
if not await _script_settled(token):
|
||||
return
|
||||
await _shoot("%02d_%s" % [i + 1, token])
|
||||
print("script complete after %.2f s on %s, focus %s"
|
||||
% [_elapsed, _menu.current(), view.focused_id])
|
||||
get_tree().quit(0)
|
||||
|
||||
|
||||
func _press(action: String) -> void:
|
||||
for down in [true, false]:
|
||||
var e := InputEventAction.new()
|
||||
e.action = action
|
||||
e.pressed = down
|
||||
Input.parse_input_event(e)
|
||||
|
||||
|
||||
## Wait until nothing is moving: no transition pending, and the screen has
|
||||
## reached its own hold. Shooting before that would photograph a fade.
|
||||
func _script_settled(what: String) -> bool:
|
||||
var deadline := _elapsed + SCRIPT_STEP_TIMEOUT
|
||||
while _pending != null or _player != null or not view.holding \
|
||||
or view.time_units < view.settle_time():
|
||||
if _elapsed > deadline:
|
||||
# Stop the run. Carrying on would write a whole filmstrip of the
|
||||
# screen that got stuck and call it a walk through the menus.
|
||||
push_error("--script: %s never settled within %.0f s -- stopping"
|
||||
% [what, SCRIPT_STEP_TIMEOUT])
|
||||
get_tree().quit(3)
|
||||
return false
|
||||
await get_tree().process_frame
|
||||
# Only a run that is about to photograph the frame needs to wait for one to
|
||||
# be drawn. `--script` on its own is a navigation check and must still work
|
||||
# where nothing draws -- see `_has_display`.
|
||||
if _shots != "":
|
||||
await RenderingServer.frame_post_draw
|
||||
await RenderingServer.frame_post_draw
|
||||
return true
|
||||
|
||||
|
||||
func _shoot(label: String) -> void:
|
||||
if _shots == "":
|
||||
return
|
||||
var path := "%s_%s.png" % [_shots, label]
|
||||
var img := viewport.get_texture().get_image()
|
||||
# Write to a temp name and rename on completion: another agent probing a
|
||||
# file this is still writing gets a confident wrong number.
|
||||
var tmp := path + ".part"
|
||||
if img.save_png(tmp) != OK:
|
||||
push_error("cannot write %s" % tmp)
|
||||
return
|
||||
DirAccess.rename_absolute(tmp, path)
|
||||
print(" shot %s (%s, focus %s)" % [path, _menu.current(), view.focused_id])
|
||||
|
||||
165
port/scripts/menu_flow.gd
Normal file
165
port/scripts/menu_flow.gd
Normal file
@@ -0,0 +1,165 @@
|
||||
# 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": <screen>, "label": …} -- go there
|
||||
## {"kind": "blocked", "label": …, "why": …} -- a real destination
|
||||
## that is not in this
|
||||
## export
|
||||
## {"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:
|
||||
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}
|
||||
1
port/scripts/menu_flow.gd.uid
Normal file
1
port/scripts/menu_flow.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dyqb3b450d21x
|
||||
@@ -326,12 +326,21 @@ func _draw_focus(element: Dictionary) -> void:
|
||||
if tex == null:
|
||||
skipped.append("%s (focus sprite failed to load)" % fe.get("id", ""))
|
||||
continue
|
||||
# The ring's rest pose. Its spin is real -- rotation_deg ramps 0 -> 360
|
||||
# with position, scale and alpha all constant -- but the PERIOD is not
|
||||
# established: the ramp's second keyframe is untimed, and what an untimed
|
||||
# keyframe means inside a leaf (rather than at screen level, where it is
|
||||
# the exit) is untested. So this holds the resting angle and does not
|
||||
# invent a spin rate.
|
||||
# The ring's rest pose, which is rotation_deg 0.
|
||||
#
|
||||
# ⚠️ THIS IS KNOWN TO BE WRONG, and is drawn anyway because the right
|
||||
# answer is a guess. The spin is real -- rotation_deg ramps 0 -> 360
|
||||
# with position, scale and alpha all constant -- and measuring the two
|
||||
# oracle captures says the game never shows 0: the same sprite sits at
|
||||
# ~76 deg with NEW GAME focused and ~210 deg with OPTIONS focused,
|
||||
# 134 deg apart at peak correlation 0.97 against a null control of 0.37
|
||||
# (docs/port/DECISIONS.md, "the focus ring IS drawn rotated").
|
||||
#
|
||||
# What is missing is the PERIOD, and it has two unknowns, both the
|
||||
# Decoder's: the ramp's second keyframe is untimed, and "groups hold"
|
||||
# predicts a stop at 360 = 0, which is not what either capture shows.
|
||||
# Holding at 0 is the pose that invents nothing; a spin rate would be
|
||||
# invented. See docs/port/BLOCKED.md.
|
||||
var pose: Dictionary = fe.get("rest", {})
|
||||
var pivot := _vec(fe.get("pivot", [0, 0]))
|
||||
var pos := _vec(pose.get("pos", [0, 0]))
|
||||
|
||||
Reference in New Issue
Block a user