Files
stickman/tests/phase20b_gui_visual_capture.gd
T

2543 lines
189 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const CAPTURE_DIR: String = "res://tests/phase20b_captures/review_round43"
const PLAN_PLACE: int = 8
const PLAN_PROP: int = 9
var _failures: int = 0
var _active_capture_dir: String = CAPTURE_DIR
var _debug_stage: SandboxStage
var _capture_after_next_click: bool = false
var _skip_capture_hover: bool = false
var _trace_next_click: bool = false
var _last_click_label: String = ""
var _last_injected_mouse_position: Vector2 = Vector2.ZERO
var _watchdog: Timer
func _initialize() -> void:
call_deferred("_run_scenarios")
func _run_scenarios() -> void:
_skip_capture_hover = OS.get_environment("PHASE20B_SKIP_CAPTURE_HOVER") == "1"
var scenario: String = OS.get_environment("PHASE20B_GUI_SCENARIO")
var requested_capture_dir: String = OS.get_environment("PHASE20B_GUI_CAPTURE_DIR")
_active_capture_dir = requested_capture_dir if not requested_capture_dir.is_empty() else ("res://tests/phase20b_captures/review_round44" if scenario == "busy_scene_badge_stability" else CAPTURE_DIR)
_watchdog = Timer.new()
_watchdog.one_shot = true
_watchdog.wait_time = 45.0
_watchdog.timeout.connect(_on_gui_watchdog_timeout)
root.add_child(_watchdog)
_watchdog.start()
root.size = Vector2i(1280, 900) if scenario == "action_drawer" else Vector2i(1280, 720)
root.position = Vector2i.ZERO
root.gui_embed_subwindows = true
root.grab_focus()
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_active_capture_dir))
var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage
_debug_stage = stage
root.add_child(stage)
await _wait_frames(10)
var recovery_dialog: ConfirmationDialog = stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog
if recovery_dialog != null and STAGE_DIALOG_STYLER.is_open(recovery_dialog):
# Keep the user's autosave intact while allowing this isolated GUI capture
# to exercise the production inspector underneath the startup prompt.
STAGE_DIALOG_STYLER.hide(recovery_dialog)
if scenario == "busy_scene_badge_stability":
await _run_busy_scene_badge_stability(stage)
print("PHASE20B_GUI_SLICE_RESULT scenario=%s failures=%d captures=%s" % [scenario, _failures, _active_capture_dir])
await _finish(stage)
quit(1 if _failures > 0 else 0)
return
if scenario == "menu_focused_stack":
await _run_menu_focused_stack_slice(stage)
print("PHASE20B_GUI_SLICE_RESULT scenario=%s failures=%d captures=%s" % [scenario, _failures, _active_capture_dir])
await _finish(stage)
quit(1 if _failures > 0 else 0)
return
stage.call("_on_grid_toggled", false)
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
var world: Node2D = stage.get_node("World") as Node2D
var character: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-120.0, -170.0))
var prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2(150.0, 0.0))
_check(character != null and prop != null, "production spawner creates the actor and named-prop fixtures")
if character == null or prop == null:
await _finish(stage)
quit(1)
return
stage.call("_finalize_placed_node", character)
stage.call("_finalize_placed_node", prop)
var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
var renamed_character: Dictionary = identity.rename(character, "Milo the Magnificent Fetch Director", world)
var renamed_prop: Dictionary = identity.rename(prop, "Blue Fetch Ball", world)
_check(bool(renamed_character.get("ok", false)) and bool(renamed_prop.get("ok", false)), "fixture actors receive long and distinct stage names through production identity")
_make_small_prop(prop)
await _wait_frames(5)
# Mode and selection are driven through the same visible controls and viewport
# pointer routes used by the editor.
var direct_button: Button = _find_button_containing(stage, "Direct")
_check(direct_button != null, "DIRECT mode button is present in the Stage chrome")
if direct_button != null:
await _click_control(direct_button)
var selection: StageSelection = stage.get("_selection") as StageSelection
await _send_click(_screen_at(stage, character.global_position))
await _wait_frames(4)
_check(int(stage.get("current_mode")) == SandboxStage.StageMode.DIRECT, "pointer activation switches the production Stage to DIRECT")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "viewport pointer selects the actor in DIRECT")
var character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
var card: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
var stack: Control = character_view.find_child("ActionStepStack", true, false) as Control
var empty_label: Label = character_view.find_child("EmptyActionPlan", true, false) as Label
var quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control
var empty_prompt_rect: Rect2 = empty_label.get_global_rect() if empty_label != null else Rect2()
_check(stack != null and stack.visible and empty_label != null and empty_label.visible and empty_label.text == "Choose an action to start %s’s plan." % character.stage_name and empty_prompt_rect.size.x > 0.0 and stack.get_global_rect().encloses(empty_prompt_rect), "approved empty-plan prompt is visible and fits inside the Step Stack")
_check(quick_row != null and quick_row.visible and _find_button_with_text(quick_row, "🚶 Move") != null and _find_button_with_text(quick_row, "💬 Say") != null and _find_button_with_text(quick_row, "⏳ Wait") != null, "pinned Move, Say, and Wait quick actions are visible")
await _save_capture("wide_empty_step_stack")
if not scenario.is_empty():
await _run_visual_slice(scenario, stage, character, prop, character_view, card, quick_row)
print("PHASE20B_GUI_SLICE_RESULT scenario=%s failures=%d" % [scenario, _failures])
await _finish(stage)
quit(1 if _failures > 0 else 0)
return
# Author a place Move via the Quick Move button, the Action Editor, and the
# transient stage capture bar. The click is sent through SandboxStage input.
stage.call("_set_scene_dirty", false)
var move_button: Button = _find_button_with_text(quick_row, "🚶 Move")
await _click_control(move_button)
var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
var place_button: Button = _find_button_containing(action_editor, "Choose a place")
_check(action_editor != null and action_editor.visible and place_button != null, "Quick Move opens the production Action Editor with a place route")
if place_button == null:
await _finish(stage)
quit(1)
return
var place_press_events: Array[bool] = []
place_button.pressed.connect(func() -> void: place_press_events.append(true))
_capture_after_next_click = true
await _click_control(place_button)
_check(place_press_events.size() == 1, "Choose a place button receives the actual injected mouse click")
_check(int(stage.get("_capture_kind")) == PLAN_PLACE, "place button enters the production temporary capture state")
var first_place: Vector2 = Vector2(-20.0, 0.0)
_trace_next_click = true
await _send_click(_screen_at(stage, first_place))
await _wait_frames(3)
_check(action_editor.visible, "captured place returns to the Action Editor for confirmation")
var move_done: Button = _find_button_with_text(action_editor, "Done")
await _click_control(move_done)
await _wait_frames(4)
var plan: Array[Dictionary] = character.get_action_plan()
if plan.is_empty():
_check(false, "valid Move is added before continuing GUI scenarios")
await _finish(stage)
quit(1)
return
_check(plan.size() == 1 and (plan[0]["actions"] as Array).size() == 1, "Done adds one ordered Move step through the UI")
var first_move: Dictionary = (plan[0]["actions"] as Array)[0]
var move_id: String = String(first_move.get("action_id", ""))
var move_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Place 1")
_check(move_summary != null and not move_summary.text.contains("(-"), "free-place action summary uses its derived step label without raw coordinates")
var move_row_text: Label = character_view.find_child("ActionSummaryText_%s" % move_id, true, false) as Label
var move_row_rail: Control = character_view.find_child("ActionAccentRail_%s" % move_id, true, false) as Control
var move_icon_tile: Control = character_view.find_child("ActionIconTile", true, false) as Control
var move_row_options: Label = character_view.find_child("ActionSummaryOptions_%s" % move_id, true, false) as Label
var move_row_style: StyleBoxFlat = move_summary.get_theme_stylebox("normal") as StyleBoxFlat if move_summary != null else null
_check(move_row_text != null and move_row_text.text == "Walk to Place 1" and move_row_text.get_theme_font_size("font_size") == 18, "Move summary uses the mockup's bold 18 px action text without a redundant type chip")
_check(move_row_rail != null and move_row_rail.custom_minimum_size.x == 4.0 and move_icon_tile != null and move_icon_tile.custom_minimum_size == Vector2(32.0, 32.0) and move_row_options != null and move_row_options.text == "..." and move_summary.custom_minimum_size.y >= 48.0, "Move row has the twin rail, 32 px icon tile, 48 px target, and trailing options cue")
_check(move_row_style != null and move_row_style.bg_color.is_equal_approx(Color("#17313D")), "Move row uses the themed muted cyan tint from the approved mockup")
await RenderingServer.frame_post_draw
var move_row_image: Image = root.get_texture().get_image()
var move_rail_rect: Rect2 = move_row_rail.get_global_rect() if move_row_rail != null else Rect2()
var move_rail_pixel: Color = move_row_image.get_pixelv(Vector2i(roundi(move_rail_rect.get_center().x), roundi(move_rail_rect.get_center().y))) if move_row_image != null and not move_row_image.is_empty() and move_row_rail != null else Color.TRANSPARENT
_check(move_rail_pixel.is_equal_approx(Color("#8BD9FF")), "rendered Move accent rail uses the cyan route color")
_check(String((first_move.get("destination", {}) as Dictionary).get("kind", "")) == "place", "captured free destination is persisted as kind place")
_check(bool(stage.get("_dirty")), "valid Move commit marks the document dirty")
await _save_capture("wide_step_1_place")
# Click the rendered numbered flag. It must select/focus the same stable action
# without making a document edit.
stage.call("_set_scene_dirty", false)
await _send_click(_screen_at(stage, first_place))
await _wait_frames(4)
var move_options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control
_check(move_options != null and move_options.visible, "numbered route flag opens the matching action options by stable ID")
var expanded_card_rect: Rect2 = card.get_global_rect()
var pinned_move_rect: Rect2 = move_button.get_global_rect()
_check(expanded_card_rect.position.x >= 0.0 and expanded_card_rect.end.x <= root.size.x and expanded_card_rect.size.x <= 380.0, "expanded action options stay inside the 380 px inspector shell")
_check(move_button.visible and pinned_move_rect.position.y >= expanded_card_rect.position.y and pinned_move_rect.end.y <= expanded_card_rect.end.y, "quick actions stay visible in the fixed inspector area after expanding action options")
var route_entry: Dictionary = {}
for projection: Dictionary in (stage.get("_director_visuals") as StageDirectorVisuals)._plan_projection(character):
if String(projection.get("action_id", "")) == move_id:
route_entry = projection
break
var route_hit_rect: Rect2 = route_entry.get("hit_rect", Rect2())
var route_hit_size: Vector2 = route_hit_rect.size * (stage.get("_director_visuals") as StageDirectorVisuals)._zoom()
_check(route_hit_size.x >= 44.0 and route_hit_size.y >= 44.0, "numbered movement flag hit target is at least 44×44 screen pixels")
_check(not bool(stage.get("_dirty")), "opening route flag options does not dirty the scene")
await _save_capture("wide_flag_focused_action")
# Edit the Move action to choose an exact prop, then a nearby free place.
# The action options should match Say's stacked Edit/Erase layout.
var move_edit_button: Button = _find_button_with_text(move_options, "Edit action")
var move_erase_button: Button = _find_button_with_text(move_options, "Erase action")
var duplicate_change_place: Button = _find_button_with_text(move_options, "Change place")
_check(duplicate_change_place == null and move_edit_button != null and move_erase_button != null, "Move options match Say with only Edit action and Erase action")
_check(move_edit_button != null and move_erase_button != null and move_edit_button.get_parent() == move_options and move_erase_button.get_parent() == move_options and move_edit_button.get_global_rect().size.x >= 44.0 and move_edit_button.get_global_rect().size.x == move_erase_button.get_global_rect().size.x and move_edit_button.get_global_rect().end.y <= move_erase_button.get_global_rect().position.y, "Move Edit and Erase actions are full-width controls stacked vertically")
if move_edit_button != null:
await _click_control(move_edit_button)
var choose_prop_button: Button = _find_button_containing(action_editor, "Choose a prop")
_check(action_editor.visible and choose_prop_button != null, "Edit action opens the Move destination editor")
if choose_prop_button != null:
await _click_control(choose_prop_button)
_check(int(stage.get("_capture_kind")) == PLAN_PROP, "prop choice enters the production prop-only capture route")
await _send_click(_screen_at(stage, prop.global_position))
await _wait_frames(4)
await _save_capture("wide_prop_candidate_route")
var done_prop: Button = _find_button_with_text(action_editor, "Done")
await _click_control(done_prop)
await _wait_frames(4)
plan = character.get_action_plan()
first_move = (plan[0]["actions"] as Array)[0]
var prop_destination: Dictionary = first_move.get("destination", {})
_check(String(first_move.get("action_id", "")) == move_id, "retargeting preserves the stable Move action ID")
_check(String(prop_destination.get("kind", "")) == "prop" and String(prop_destination.get("target_prop_id", "")) == prop.stage_object_id, "named-prop target stores the exact selected stage ID")
_check(prop_destination.get("approach_offset", Vector2.ZERO) is Vector2, "captured prop approach retains a world-space offset")
await _save_capture("wide_step_1_exact_prop")
# Retarget to a free place near the prop, then cancel a second capture with
# Escape and prove the draft and dirty state remain unchanged.
move_options = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control
move_edit_button = _find_button_with_text(move_options, "Edit action")
await _click_control(move_edit_button)
place_button = _find_button_containing(action_editor, "place")
await _click_control(place_button)
var nearby_place: Vector2 = prop.global_position + Vector2(54.0, -42.0)
await _send_click(_screen_at(stage, nearby_place))
await _wait_frames(3)
await _click_control(_find_button_with_text(action_editor, "Done"))
await _wait_frames(4)
plan = character.get_action_plan()
first_move = (plan[0]["actions"] as Array)[0]
var free_destination: Dictionary = first_move.get("destination", {})
_check(String(first_move.get("action_id", "")) == move_id and String(free_destination.get("kind", "")) == "place", "nearby free-place intent remains a place and keeps the same action ID")
stage.call("_set_scene_dirty", false)
move_options = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control
await _click_control(_find_button_with_text(move_options, "Edit action"))
await _click_control(_find_button_containing(action_editor, "place"))
_check(int(stage.get("_capture_kind")) == PLAN_PLACE, "second target edit enters capture before cancellation")
await _save_capture("wide_candidate_route_before_cancel")
await _send_key(KEY_ESCAPE)
await _wait_frames(4)
_check(int(stage.get("_capture_kind")) == 0 and not action_editor.visible, "Escape cancels the active destination capture through Stage input")
_check(not bool(stage.get("_dirty")) and character.get_action_plan()[0]["actions"] == plan[0]["actions"], "cancel leaves saved plan content and dirty state unchanged")
await _save_capture("wide_capture_cancelled")
# The Add together popup is exercised with keyboard input; a Move step offers
# Say and excludes other primary actions. The text is entered through the focused composer.
var together_button: Button = _find_button_with_text(character_view.find_child("ActionStep_1", true, false), "+ Add together")
_check(together_button != null, "singleton Move step offers Add together")
await _click_control(together_button)
var together_popup: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
var together_choices: VBoxContainer = together_popup.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if together_popup != null else null
var together_helper: Label = together_popup.find_child("TogetherCompatibilityHint", true, false) as Label if together_popup != null else null
var together_cancel: Button = _find_button_with_text(together_popup, "Cancel") if together_popup != null else null
_check(together_popup != null and together_popup.visible and together_choices != null and together_choices.get_child_count() == 1 and (together_choices.get_child(0) as Button).text.contains("Say"), "Move compatibility picker offers only Say")
_check(together_choices != null and _find_button_containing(together_choices, "Wait") == null, "Move compatibility picker excludes Wait")
_check(together_helper == null and together_cancel != null and together_cancel.visible and together_cancel.size.x >= 44.0 and together_cancel.size.y >= 44.0, "Add together picker omits redundant compatibility copy and keeps a visible touch-sized Cancel")
await _save_capture("wide_add_together_picker")
await _send_key(KEY_ENTER)
await _wait_frames(5)
var composer: Control = stage.get("_speech_composer") as Control
var message_editor: TextEdit = null
var speech_toolbar: Control = null
var done_speech: Button = null
_check(composer != null and composer.visible, "keyboard selection opens the production speech composer")
if composer != null and composer.visible:
message_editor = composer.find_child("ActiveBubbleText", true, false) as TextEdit
_check(message_editor != null and message_editor.has_focus(), "speech composer gives its message editor keyboard focus")
if message_editor != null:
await _click_control(message_editor)
await _send_key(KEY_A, false, true)
await _type_text("Fetch this ball, Milo! Fetch it now!")
speech_toolbar = stage.get("_speech_show_toolbar") as Control
done_speech = _find_button_with_text(speech_toolbar, "✓ Done speech")
_check(done_speech != null and done_speech.visible, "speech composer exposes its labeled Done speech action")
await _save_capture("wide_grouped_say_composer")
if done_speech != null:
await _click_control(done_speech)
await _wait_frames(5)
plan = character.get_action_plan()
_check((plan[0]["actions"] as Array).size() == 2, "speech Done adds Say to the selected Move step")
_check(_step_has_action_type(plan[0], "speak"), "authored together speech is a real grouped action")
await _save_capture("wide_step_1_together")
# Add Wait as the next step, group Say with that Wait, and add more future
# steps via the pinned quick action. This also builds the compact-scroll case.
var wait_button: Button = _find_button_with_text(quick_row, "⏳ Wait")
await _click_control(wait_button)
await _wait_frames(2)
await _click_control(_find_button_with_text(action_editor, "Done"))
await _wait_frames(4)
plan = character.get_action_plan()
_check(plan.size() == 2 and String(((plan[1]["actions"] as Array)[0] as Dictionary).get("type", "")) == "wait", "quick Wait appends a separate second step")
var wait_together: Button = _find_button_with_text(character_view.find_child("ActionStep_2", true, false), "+ Add together")
await _click_control(wait_together)
together_popup = character_view.find_child("AddTogetherChoices", true, false) as Panel
together_choices = together_popup.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if together_popup != null else null
_check(together_popup != null and together_popup.visible and together_choices != null and together_choices.get_child_count() == 1 and (together_choices.get_child(0) as Button).text.contains("Say"), "Wait compatibility picker offers Say as its only companion")
await _send_key(KEY_ENTER)
await _wait_frames(5)
composer = stage.get("_speech_composer") as Control
if composer != null and composer.visible:
message_editor = composer.find_child("ActiveBubbleText", true, false) as TextEdit
if message_editor != null:
await _click_control(message_editor)
await _send_key(KEY_A, false, true)
await _type_text("Fetch fetch fetch fetch fetch fetch fetch fetch fetch fetch fetch fetch")
speech_toolbar = stage.get("_speech_show_toolbar") as Control
done_speech = _find_button_with_text(speech_toolbar, "✓ Done speech")
if done_speech != null:
await _click_control(done_speech)
await _wait_frames(4)
plan = character.get_action_plan()
_check((plan[1]["actions"] as Array).size() == 2 and _step_has_action_type(plan[1], "speak"), "Wait+Say group commits to one concurrent step")
# More singleton waits are added through the quick action and Done button.
for _index: int in range(6):
await _click_control(_find_button_with_text(quick_row, "⏳ Wait"))
await _wait_frames(2)
await _click_control(_find_button_with_text(action_editor, "Done"))
await _wait_frames(3)
plan = character.get_action_plan()
_check(plan.size() == 8, "quick actions create eight ordered steps without bypassing the production UI")
# Reorder the future second step through its up arrow and back again;
# stable action IDs remain intact. Keyboard focus is checked on the step row.
var original_second_id: String = String(((plan[1]["actions"] as Array)[0] as Dictionary).get("action_id", ""))
var second_card: Control = character_view.find_child("ActionStep_2", true, false) as Control
var earlier: Button = _find_button_with_tooltip(second_card, "Move Step 2 earlier")
_check(earlier != null and earlier.text == "↑" and earlier.size.x >= 48.0 and earlier.size.y >= 48.0 and not earlier.disabled, "future step shows a touch-sized up arrow with a descriptive tooltip")
if earlier != null:
await _click_control(earlier)
await _wait_frames(4)
plan = character.get_action_plan()
_check(String(((plan[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == original_second_id, "up arrow changes authored order while retaining stable action identity")
var first_card: Control = character_view.find_child("ActionStep_1", true, false) as Control
var later: Button = _find_button_with_tooltip(first_card, "Move Step 1 later")
if later != null:
await _click_control(later)
await _wait_frames(4)
plan = character.get_action_plan()
_check(String(((plan[1]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == original_second_id, "down arrow restores the original future-step order")
# At 920x560, the card keeps its header and pinned quick actions in view and
# scrolls the long plan body. The long actor name exercises header fitting.
root.size = Vector2i(920, 560)
await _wait_frames(8)
var body_scroll: ScrollContainer = card.find_child("InspectorBodyScroll", true, false) as ScrollContainer
_check(card.visible and card.get_global_rect().position.x >= 0.0 and card.get_global_rect().end.x <= 920.0, "small viewport keeps the character card inside the horizontal safe area")
_check(quick_row.visible and quick_row.get_global_rect().position.y >= 0.0 and quick_row.get_global_rect().end.y <= 560.0, "small viewport preserves visible pinned quick actions")
_check(body_scroll != null and body_scroll.get_v_scroll_bar().max_value > 0.0, "eight-step plan scrolls inside the inspector body on the compact viewport")
await _save_capture("compact_920x560_eight_steps")
# Cancel a speech draft with the visible labeled control; it must not append
# an action or mark an otherwise-clean plan dirty.
stage.call("_set_scene_dirty", false)
await _click_control(_find_button_with_text(quick_row, "💬 Say"))
await _wait_frames(4)
composer = stage.get("_speech_composer") as Control
var speech_cancel: Button = _find_button_with_text(stage.get("_speech_show_toolbar") as Control, "Cancel speech")
_check(composer != null and composer.visible and speech_cancel != null, "new Say draft has a visible Cancel speech action")
if speech_cancel != null:
await _click_control(speech_cancel)
await _wait_frames(4)
_check(not composer.visible and not bool(stage.get("_dirty")) and character.get_action_plan().size() == 8, "Cancel speech closes the draft without a plan mutation")
await _save_capture("compact_speech_cancelled")
# Playback goes through the real Space shortcut and the runner emits precise
# physics-frame observations for the grouped barrier.
var started: Array[Dictionary] = []
var ended: Array[Dictionary] = []
character.planned_action_started.connect(func(action_id: String, action: Dictionary, step_index: int) -> void:
started.append({"id": action_id, "type": String(action.get("type", "")), "step": step_index, "frame": Engine.get_physics_frames()})
)
character.planned_action_ended.connect(func(action_id: String, outcome: StringName, step_index: int) -> void:
ended.append({"id": action_id, "outcome": String(outcome), "step": step_index, "frame": Engine.get_physics_frames()})
)
await _send_key(KEY_SPACE)
await _wait_frames(4)
_check(int(stage.get("current_mode")) == SandboxStage.StageMode.PLAY, "Space starts playback from DIRECT through the production input path")
await _save_capture("compact_playback_started")
await _wait_until_actions_end(stage, ended, 8.0)
var step_two_wait_id: String = String(((character.get_action_plan()[1]["actions"] as Array)[0] as Dictionary).get("action_id", ""))
var step_two_say_id: String = _action_id_for_type(character.get_action_plan()[1], "speak")
var step_three_start_frame: int = _start_frame_for_step(started, 2)
var group_end_frame: int = maxi(_end_frame_for_id(ended, step_two_wait_id), _end_frame_for_id(ended, step_two_say_id))
_check(_same_start_frame(started, 0), "Move and Say in the first authored step start on the same runner physics frame")
_check(_same_start_frame(started, 1), "Wait and Say in the second authored step start on the same runner physics frame")
_check(group_end_frame >= 0 and step_three_start_frame >= group_end_frame, "third step waits until both concurrent second-step actions finish")
print("PHASE20B_TIMING second_wait=%d second_say=%d third_start=%d frames" % [_end_frame_for_id(ended, step_two_wait_id), _end_frame_for_id(ended, step_two_say_id), step_three_start_frame])
await _save_capture("compact_playback_group_barrier")
# Pause through the visible DIRECT mode segment, verify the live future-only
# structural gate, then resume with Space. Completed plan rows stay erasable.
var direct_button_small: Button = _find_button_containing(stage, "Direct")
if direct_button_small != null:
await _click_control(direct_button_small)
await _wait_frames(4)
_check(int(stage.get("_run_state")) == SandboxStage.RunState.PAUSED, "DIRECT mode switch pauses an active run")
character_view.refresh_plan()
await _save_capture("compact_paused_plan")
var third_card: Control = character_view.find_child("ActionStep_3", true, false) as Control
var third_earlier: Button = _find_button_with_tooltip(third_card, "Move Step 3 earlier")
_check(third_earlier != null and third_earlier.disabled, "consumed run history prevents moving a completed/future-barrier step")
await _send_key(KEY_SPACE)
await _wait_frames(3)
_check(int(stage.get("current_mode")) == SandboxStage.StageMode.PLAY, "Space resumes playback from paused DIRECT")
print("PHASE20B_GUI_CAPTURE %s; failures=%d" % [_active_capture_dir, _failures])
await _finish(stage)
quit(1 if _failures > 0 else 0)
func _run_visual_slice(scenario: String, stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView, card: StageInspectorPanel, quick_row: Control) -> void:
match scenario:
"move_dialog":
await _run_move_dialog_slice(stage, character, prop, quick_row)
"wait_dialog":
await _run_wait_dialog_slice(stage, character, quick_row)
"move_action_options":
await _run_move_action_options_slice(stage, character, character_view)
"empty":
pass
"say":
await _run_say_slice(stage, character, character_view, quick_row)
"grouping":
await _run_grouping_slice(stage, character, character_view, quick_row)
"capture_entry":
await _run_capture_entry_slice(stage, quick_row)
"flag":
await _run_flag_slice(stage, character, character_view, card)
"step_spotlight":
await _run_step_spotlight_slice(stage, character, character_view)
"mode_overlay_and_names":
await _run_mode_overlay_and_names_slice(stage, character, prop, character_view)
"compact":
await _run_compact_slice(stage, character, character_view, card)
"action_drawer", "action_drawer_short":
await _run_action_drawer_slice(stage, character, character_view, card, quick_row)
"place_capture":
await _run_place_capture_slice(stage, character, prop, character_view, quick_row)
"prop_capture":
await _run_prop_capture_slice(stage, character, prop, character_view, quick_row)
_:
_check(false, "requested GUI scenario name is supported")
func _run_menu_focused_stack_slice(stage: SandboxStage) -> void:
root.size = Vector2i(1280, 900)
stage.call("_on_grid_toggled", false)
_check(bool(stage.call("_load_scene_from_path", "res://content/stages/menu.scene")), "the worktree menu.scene loads through the production stage loader")
await _wait_process_frames(8)
var world: Node2D = stage.get_node("World") as Node2D
var bob: StickmanRig
for child: Node in world.get_children():
if child is StickmanRig and (child as StickmanRig).stage_name == "Bob":
bob = child as StickmanRig
break
_check(bob != null and bob.get_action_plan().size() == 9, "the loaded menu.scene has Bob's nine authored steps")
if bob == null:
return
var direct_button: Button = _find_button_containing(stage, "Direct")
if direct_button != null:
await _click_control(direct_button)
var selection: StageSelection = stage.get("_selection") as StageSelection
selection.select_only(bob)
await _wait_process_frames(6)
var view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView
var card: StageInspectorPanel = view.get_card() as StageInspectorPanel
var body_scroll: ScrollContainer = card.find_child("InspectorBodyScroll", true, false) as ScrollContainer
var heading: Label = view.find_child("ActionPlanHeadingLabel", true, false) as Label
_check(heading != null and heading.text == "Plan · 9 steps", "the focused-stack header shows the plan step count")
var step_one: Control = view.find_child("ActionStep_1", true, false) as Control
var step_two: Control = view.find_child("ActionStep_2", true, false) as Control
var step_three: Control = view.find_child("ActionStep_3", true, false) as Control
var step_four: Control = view.find_child("ActionStep_4", true, false) as Control
var step_five: Control = view.find_child("ActionStep_5", true, false) as Control
var step_nine: Control = view.find_child("ActionStep_9", true, false) as Control
var use_action: Dictionary = (bob.get_action_plan()[0]["actions"] as Array)[0] as Dictionary
var use_summary: Button = view.find_child("ActionSummary_%s" % String(use_action.get("action_id", "")), true, false) as Button
var step_one_style: StyleBoxFlat = step_one.get_theme_stylebox("panel") as StyleBoxFlat if step_one != null else null
var step_one_expanded: Control = step_one.find_child("ExpandedStepContent", true, false) as Control if step_one != null else null
var use_summary_label: Label = view.find_child("ActionSummaryText_%s" % String(use_action.get("action_id", "")), true, false) as Label
_check(step_one != null and step_one_style != null and step_one_style.border_color.is_equal_approx(Color("#FFB300")) and step_one_expanded != null and step_one_expanded.visible, "selected Step 1 remains expanded with the DIRECT amber border")
_check(use_summary != null and use_summary_label != null and use_summary_label.text == "Use Wall Clock", "Use summary shows the current prop name instead of prop and spot IDs")
var step_two_compact_row: Control = step_two.find_child("CompactStepRow", true, false) as Control if step_two != null else null
_check(step_two != null and step_two_compact_row != null and step_two_compact_row.visible and not step_two.find_child("ExpandedStepContent", true, false).visible, "unselected Step 2 is a compact tappable row")
var step_two_label: Label = step_two.find_child("ActionSummaryText_Step2", true, false) as Label if step_two != null else null
var step_two_summary: Button = step_two.find_child("CompactStepSummary_2", true, false) as Button if step_two != null else null
var step_four_label: Label = step_four.find_child("ActionSummaryText_Step4", true, false) as Label if step_four != null else null
var step_nine_label: Label = step_nine.find_child("ActionSummaryText_Step9", true, false) as Label if step_nine != null else null
_check(step_two_label != null and step_two_label.text == "Brown Briefcase" and step_two_summary != null and step_two_summary.tooltip_text == "Pick up Brown Briefcase", "compact pickup summary names the prop while its tooltip states the full action")
var step_three_label: Label = step_three.find_child("ActionSummaryText_Step3", true, false) as Label if step_three != null else null
_check(step_three_label != null and step_three_label.text == "Wait 1 second", "Wait keeps its readable duration")
_check(step_four_label != null and step_four_label.text == "Set down prop", "Set Down summary omits the raw world coordinates")
_check(step_five.find_child("ActionSummaryText_Step5", true, false).text == "Run to Place 5", "Move keeps its friendly destination label")
_check(step_nine_label != null and step_nine_label.text == "Throw prop + Say", "compact Together row names both actions without target coordinates")
var visible_add_together_count: int = 0
for step_index: int in view.get("_step_cards").size():
var step_card: Control = (view.get("_step_cards") as Array)[step_index] as Control
var add_together: Control = step_card.find_child("AddTogetherRow", true, false) as Control
if add_together != null and add_together.visible:
visible_add_together_count += 1
_check(visible_add_together_count == 1, "only the selected step shows Add together")
await _save_capture_now("menu_focused_stack_step1")
if body_scroll != null and step_four != null:
card.scroll_to_control(step_four)
await _wait_process_frames(3)
var compact_step_four: Button = step_four.find_child("CompactStepSummary_4", true, false) as Button if step_four != null else null
var row_y_before: float = step_four.get_global_position().y if step_four != null else -1.0
if compact_step_four != null:
await _click_control(compact_step_four)
await _wait_process_frames(5)
step_one = view.find_child("ActionStep_1", true, false) as Control
step_four = view.find_child("ActionStep_4", true, false) as Control
var step_four_expanded: Control = step_four.find_child("ExpandedStepContent", true, false) as Control if step_four != null else null
var step_four_summary: Button = step_four.find_child("CompactStepSummary_4", true, false) as Button if step_four != null else null
var step_four_compact_row: Control = step_four.find_child("CompactStepRow", true, false) as Control if step_four != null else null
var row_y_after: float = step_four.get_global_position().y if step_four != null else -1.0
var set_down_action: Dictionary = (bob.get_action_plan()[3]["actions"] as Array)[0] as Dictionary
_check(int(view.get("_selected_step_index")) == 3 and step_four_expanded != null and step_four_expanded.visible and step_four_summary != null and step_four_compact_row != null and not step_four_compact_row.visible, "tapping a compact step expands and selects it")
_check(step_one != null and not step_one.find_child("ExpandedStepContent", true, false).visible, "the previous step collapses when another step is selected")
_check(absf(row_y_after - row_y_before) <= 2.0, "expanding a compact step keeps its row near the same screen position")
var selected_add_together: Control = step_four.find_child("AddTogetherRow", true, false) as Control if step_four != null else null
_check(selected_add_together != null and selected_add_together.visible, "the selected step exposes its compatible Add together control")
await _save_capture_now("menu_focused_stack_step4_selected")
var set_down_summary: Button = step_four.find_child("ActionSummary_%s" % String(set_down_action.get("action_id", "")), true, false) as Button if step_four != null else null
if set_down_summary != null:
await _click_control(set_down_summary)
await _wait_process_frames(3)
step_four = view.find_child("ActionStep_4", true, false) as Control
var set_down_options: Control = step_four.find_child("ActionOptions_%s" % String(set_down_action.get("action_id", "")), true, false) as Control if step_four != null else null
_check(set_down_options != null and set_down_options.visible, "action options open after the expanded step's action is tapped")
_check(not bool(stage.get("_dirty")), "selecting and expanding steps does not dirty the scene")
func _run_action_drawer_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, card: StageInspectorPanel, _quick_row: Control) -> void:
stage.call("_set_scene_dirty", false)
var add_step: Button = _find_button_with_text(character_view, "+ Add step")
_check(add_step != null and add_step.visible, "selected actor exposes the pinned Add step control")
if add_step == null:
return
await _click_control(add_step)
var drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
var dimmer: Panel = character_view.find_child("AddStepDrawerDimmer", true, false) as Panel
var opening_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
_check(drawer != null and drawer.visible and opening_rect.position.x > card.get_global_rect().position.x - drawer.size.x - 5.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "drawer visibly slides left from the inspector while fading in")
await _save_capture_now("action_drawer_opening")
await _wait_process_frames(14)
var title: Label = drawer.find_child("TogetherChoiceTitle", true, false) as Label if drawer != null else null
var choices: VBoxContainer = drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if drawer != null else null
var choices_scroll: ScrollContainer = drawer.find_child("TogetherChoiceScroll", true, false) as ScrollContainer if drawer != null else null
var cancel: Button = _find_button_with_text(drawer, "Cancel") if drawer != null else null
var drawer_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
var card_rect: Rect2 = card.get_global_rect()
var dimmer_rect: Rect2 = dimmer.get_global_rect() if dimmer != null else Rect2()
var drawer_style: StyleBoxFlat = drawer.get_theme_stylebox("panel") as StyleBoxFlat if drawer != null else null
var dimmer_style: StyleBoxFlat = dimmer.get_theme_stylebox("panel") as StyleBoxFlat if dimmer != null else null
var first_choice_rect: Rect2 = choices.get_child(0).get_global_rect() if choices != null and choices.get_child_count() > 0 else Rect2()
var last_choice_rect: Rect2 = choices.get_child(choices.get_child_count() - 1).get_global_rect() if choices != null and choices.get_child_count() > 0 else Rect2()
var choices_scroll_rect: Rect2 = choices_scroll.get_global_rect() if choices_scroll != null else Rect2()
var all_choices_visible: bool = choices_scroll != null and choices_scroll.scroll_vertical == 0 and choices_scroll_rect.encloses(first_choice_rect) and choices_scroll_rect.encloses(last_choice_rect)
_check(drawer != null and drawer.visible and title != null and title.text == "Choose an action", "Add step opens the production Choose an action drawer")
_check(choices != null and choices.get_child_count() >= 8 and cancel != null and cancel.visible and choices.get_global_rect().position.y >= drawer_rect.position.y, "drawer keeps the scrollable action list and pinned Cancel visible")
_check(drawer_rect.size.y > card_rect.size.y and cancel != null and choices_scroll_rect.end.y <= cancel.get_global_rect().position.y, "drawer extends beyond the inspector while keeping Cancel pinned below the list")
if root.size.y >= 900:
_check(all_choices_visible, "tall drawer shows every action without scrolling")
else:
_check(choices_scroll != null and last_choice_rect.end.y > choices_scroll_rect.end.y and cancel != null and cancel.get_global_rect().end.y <= drawer_rect.end.y, "short drawer scrolls the action list while keeping Cancel visible")
_check(drawer_rect.end.y <= float(root.size.y - 50), "drawer stays above the Stage status bar and safe bottom inset")
_check(absf(drawer_rect.position.y - card_rect.position.y) <= 1.0 and absf(drawer_rect.end.x - card_rect.position.x) <= 10.0, "drawer top aligns with inspector and opens from its left edge")
_check(dimmer != null and dimmer.visible and dimmer_rect.position.distance_to(card_rect.position) <= 1.0 and dimmer_rect.size.distance_to(card_rect.size) <= 1.0, "scrim covers the inspector bounds only")
_check(dimmer_style != null and is_equal_approx(dimmer_style.bg_color.a, 0.32) and drawer_style != null and drawer_style.bg_color.a >= 0.95, "inspector scrim is a light 32 percent shade while the drawer stays opaque")
_check(root.gui_get_focus_owner() == (choices.get_child(0) as Button), "opening the drawer moves keyboard focus to the first action choice")
await _save_capture_now("action_drawer_open")
if cancel != null:
await _click_control(cancel)
await _wait_process_frames(6)
var cancel_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
_check(drawer != null and drawer.visible and cancel_motion_rect.position.x > drawer_rect.position.x + 8.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "Cancel reverses the drawer slide and fades the inspector scrim")
await _save_capture_now("action_drawer_canceling")
await _wait_process_frames(12)
_check(drawer != null and not drawer.visible and dimmer != null and not dimmer.visible, "Cancel slides the drawer and inspector scrim away")
_check(root.gui_get_focus_owner() == add_step, "Cancel returns keyboard focus to Add step")
await _save_capture_now("action_drawer_cancelled")
await _click_control(add_step)
await _wait_process_frames(4)
await _send_click(Vector2(300.0, 300.0))
await _wait_process_frames(4)
var outside_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
_check(drawer.visible and outside_motion_rect.position.x > drawer_rect.position.x + 8.0 and bool(character_view.get("_plan_popup_closing")), "outside click starts the same reverse slide and fade")
await _save_capture_now("action_drawer_outside_closing")
await _wait_process_frames(12)
var selection: StageSelection = stage.get("_selection") as StageSelection
_check(not drawer.visible and not dimmer.visible, "left click outside slides the drawer closed")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "outside click is consumed without clearing the actor selection on stage")
_check(root.gui_get_focus_owner() == add_step, "outside click returns keyboard focus to Add step")
await _save_capture_now("action_drawer_outside_clicked")
await _click_control(add_step)
await _wait_process_frames(16)
await _send_click(add_step.get_global_rect().get_center())
await _wait_process_frames(16)
_check(not drawer.visible and not dimmer.visible, "clicking the dimmed inspector closes the drawer without reopening it")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "inspector click is consumed while the drawer is open")
await _save_capture_now("action_drawer_inspector_clicked")
await _click_control(add_step)
await _wait_process_frames(4)
await _send_key(KEY_ESCAPE)
await _wait_process_frames(4)
var escape_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
_check(drawer.visible and escape_motion_rect.position.x > drawer_rect.position.x + 8.0 and bool(character_view.get("_plan_popup_closing")), "Escape starts the same reverse slide and fade")
await _save_capture_now("action_drawer_escape_closing")
await _wait_process_frames(12)
_check(not drawer.visible and not dimmer.visible, "Escape slides the drawer closed and removes the scrim")
_check(root.gui_get_focus_owner() == add_step, "Escape returns keyboard focus to Add step")
await _save_capture_now("action_drawer_escape_closed")
await _click_control(add_step)
await _wait_process_frames(16)
var move_choice: Button = _find_button_containing(drawer, "Move")
_check(move_choice != null, "drawer retains working action choices after each dismissal path")
if move_choice != null:
await _click_control(move_choice)
await _wait_process_frames(16)
var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
_check(not drawer.visible and action_editor != null and action_editor.visible, "choosing an action waits for the drawer to close before opening its editor")
if action_editor != null and action_editor.visible:
await _click_control(_find_button_with_text(action_editor, "Cancel"))
await _wait_process_frames(4)
_check(not bool(stage.get("_dirty")), "canceling the opened action draft leaves the stage unchanged")
await _save_capture_now("action_drawer_action_selected")
var fixture_say: Dictionary = {"action_id": "drawer-fixture-say", "type": "speak", "text": "We are just walking around and seeing what is going on? Is there anything going on?", "duration": 6.3}
var fixture_plan: Array[Dictionary] = [{"actions": [fixture_say]}]
_check(character.set_action_plan(fixture_plan), "Say-only fixture provides compatible action choices for Add together")
character_view.refresh_plan()
stage.call("_set_scene_dirty", false)
await _wait_process_frames(4)
var say_step: Control = character_view.find_child("ActionStep_1", true, false) as Control
var together_button: Button = _find_button_with_text(say_step, "+ Add together") if say_step != null else null
_check(together_button != null, "Say step exposes its Add together control")
if together_button == null:
return
await _click_control(together_button)
await _wait_process_frames(1)
var together_entering_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
var entering_target_x: float = card.get_global_rect().position.x - drawer.size.x - 8.0 if drawer != null else 0.0
_check(drawer.visible and together_entering_rect.position.x > entering_target_x + 4.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "Add together uses the same slide-out and fade as Add step")
await _save_capture_now("add_together_drawer_opening")
await _wait_process_frames(14)
var together_title: Label = drawer.find_child("TogetherChoiceTitle", true, false) as Label if drawer != null else null
var together_source: Label = drawer.find_child("TogetherSourceAction", true, false) as Label if drawer != null else null
var together_choices: VBoxContainer = drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if drawer != null else null
var together_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
var say_step_rect: Rect2 = card.get_global_rect()
_check(drawer.visible and together_title != null and together_title.text == "Add to Step 1", "Add together opens the shared drawer with its step title")
_check(together_source != null and together_source.visible and together_source.text.begins_with("With: Say") and together_source.text.contains("We are just walking around"), "drawer preserves the source Say summary and duration context")
_check(together_source != null and together_source.max_lines_visible == 2 and together_source.tooltip_text == together_source.text, "long source summary is limited to two lines and remains available in its tooltip")
var expected_say_companions: Array[String] = ACTION_REGISTRY.types()
expected_say_companions.erase("speak")
var every_say_companion_visible: bool = together_choices != null and together_choices.get_child_count() == expected_say_companions.size()
for action_type: String in expected_say_companions:
var expected_label: String = "Move" if action_type == ACTION_REGISTRY.MOVE_TO else ACTION_REGISTRY.label(action_type)
if together_choices == null or _find_button_containing(together_choices, expected_label) == null:
every_say_companion_visible = false
_check(every_say_companion_visible and _find_button_containing(together_choices, "Say") == null, "Say Add together picker lists every other registered action and omits duplicate Say")
_check(absf(together_rect.position.y - say_step_rect.position.y) <= 1.0 and absf(together_rect.end.x - say_step_rect.position.x) <= 10.0 and dimmer.visible, "Add together drawer aligns with the inspector and dims its shell")
_check(root.gui_get_focus_owner() == together_choices.get_child(0), "Add together drawer gives focus to its first compatible choice")
await _save_capture_now("add_together_say_all_actions")
await _click_control(_find_button_with_text(drawer, "Cancel"))
await _wait_process_frames(6)
var together_cancel_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
_check(drawer.visible and together_cancel_motion_rect.position.x > together_rect.position.x + 8.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "Add together Cancel slides the shared drawer and scrim back")
await _save_capture_now("add_together_drawer_canceling")
await _wait_process_frames(10)
_check(not drawer.visible and not dimmer.visible and root.gui_get_focus_owner() == together_button, "Add together Cancel slides in and restores focus to its trigger")
await _save_capture_now("add_together_drawer_cancelled")
await _click_control(together_button)
await _wait_process_frames(16)
await _send_click(Vector2(300.0, 300.0))
await _wait_process_frames(16)
_check(not drawer.visible and not dimmer.visible and root.gui_get_focus_owner() == together_button, "Add together closes on outside click and restores focus")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "Add together outside click is consumed by the drawer")
await _click_control(together_button)
await _wait_process_frames(16)
await _send_key(KEY_ESCAPE)
await _wait_process_frames(16)
_check(not drawer.visible and not dimmer.visible and root.gui_get_focus_owner() == together_button, "Add together closes on Escape and restores focus")
await _click_control(together_button)
await _wait_process_frames(16)
await _click_control(_find_button_containing(together_choices, "Move"))
await _wait_process_frames(16)
action_editor = stage.get("_action_editor") as ActionEditor
_check(action_editor != null and action_editor.visible and int(stage.get("_plan_step_index")) == 0 and bool(stage.get("_plan_add_together")), "compatible choice opens its editor for the same step after the drawer closes")
if action_editor != null and action_editor.visible:
await _click_control(_find_button_with_text(action_editor, "Cancel"))
await _wait_process_frames(4)
_check(character.get_action_plan().size() == 1 and (character.get_action_plan()[0]["actions"] as Array).size() == 1 and not bool(stage.get("_dirty")), "Canceling a together action draft leaves the source plan unchanged")
await _click_control(together_button)
await _wait_process_frames(16)
var ragdoll_choice: Button = _find_button_containing(drawer, "Ragdoll")
_check(ragdoll_choice != null, "Say step offers Ragdoll as a together action")
if ragdoll_choice != null:
await _click_control(ragdoll_choice)
await _wait_process_frames(16)
var say_ragdoll_plan: Array[Dictionary] = character.get_action_plan()
var say_ragdoll_actions: Array = say_ragdoll_plan[0].get("actions", []) if not say_ragdoll_plan.is_empty() else []
_check(say_ragdoll_actions.size() == 2 and String((say_ragdoll_actions[0] as Dictionary).get("type", "")) == "speak" and String((say_ragdoll_actions[1] as Dictionary).get("type", "")) == "ragdoll", "choosing Ragdoll commits it alongside the existing Say")
stage.call("_refresh_character_settings_card")
await _wait_process_frames(4)
await _save_capture_now("say_ragdoll_together_pills")
print("PHASE20B_ACTION_DRAWER rect=%s card=%s dimmer=%s focus=%s" % [str(drawer_rect), str(card_rect), str(dimmer_rect), str(root.gui_get_focus_owner())])
func _run_move_dialog_slice(stage: SandboxStage, character: StickmanRig, prop_fixture: PropBlock, quick_row: Control) -> void:
stage.call("_set_scene_dirty", false)
var move_button: Button = _find_button_with_text(quick_row, "🚶 Move")
await _click_control(move_button)
var editor: ActionEditor = stage.get("_action_editor") as ActionEditor
var title: Label = editor.find_child("ActionEditorTitle", true, false) as Label if editor != null else null
var type_row: Control = editor.find_child("ActionTypeRow", true, false) as Control if editor != null else null
var move_heading: Label = _find_label_exact_text(editor, "How should Milo move?") if editor != null else null
var destination_heading: Label = _find_label_exact_text(editor, "Where should Milo go?") if editor != null else null
var walk: Button = _find_button_with_text(editor, "🚶 Walk") if editor != null else null
var run: Button = _find_button_with_text(editor, "🏃 Run") if editor != null else null
var place: Button = editor.find_child("ChoosePlanPlace", true, false) as Button if editor != null else null
var choose_prop: Button = editor.find_child("ChoosePlanProp", true, false) as Button if editor != null else null
var done: Button = editor.find_child("ActionEditorDone", true, false) as Button if editor != null else null
var cancel: Button = editor.find_child("ActionEditorCancel", true, false) as Button if editor != null else null
var backdrop: ColorRect = stage.find_child("ActionEditorBackdrop", true, false) as ColorRect
_check(editor != null and editor.visible and title != null and title.text.begins_with("Move ") and title.text.contains("Milo"), "Quick Move opens a titled production Move dialog for the selected character")
_check(type_row != null and not type_row.visible, "fixed Quick Move hides the redundant action-type row")
_check(move_heading != null and destination_heading != null, "movement and destination use separate labeled sections")
_check(walk != null and run != null and walk.text == "🚶 Walk" and run.text == "🏃 Run" and walk.custom_minimum_size.y >= 56.0 and run.custom_minimum_size.y >= 56.0, "Walk and Run are equal large choices with stable labels")
if walk != null:
var selected_walk_color: Color = walk.get_theme_color("font_pressed_color")
var walk_focus: StyleBoxFlat = walk.get_theme_stylebox("focus") as StyleBoxFlat
_check(selected_walk_color.is_equal_approx(Color("#161616")), "selected Walk label stays dark and readable on the amber surface")
_check(walk_focus != null and walk_focus.border_color.is_equal_approx(Color("#8BD9FF")) and walk_focus.border_width_left >= 3, "keyboard focus remains a distinct cyan ring on the selected Walk tile")
_check(place != null and choose_prop != null and place.custom_minimum_size.y >= 52.0 and choose_prop.custom_minimum_size.y >= 52.0, "place and prop destinations use large separate controls")
_check(done != null and done.text == "Done" and done.disabled and cancel != null and cancel.text == "Cancel" and cancel.custom_minimum_size.y >= 52.0, "Done is the disabled primary action until a destination is chosen, beside visible Cancel")
var panel_style: StyleBoxFlat = editor.get_theme_stylebox("panel") as StyleBoxFlat if editor != null else null
_check(panel_style != null and panel_style.corner_radius_top_left >= 16 and panel_style.border_width_left >= 2 and panel_style.bg_color.a >= 0.95, "Move dialog uses the shared opaque rounded shell and clear border")
_check(backdrop != null and backdrop.visible and backdrop.color.a >= 0.5, "Move dialog dims the stage behind its modal card")
if done != null and cancel != null:
var done_style: StyleBoxFlat = done.get_theme_stylebox("normal") as StyleBoxFlat
var cancel_style: StyleBoxFlat = cancel.get_theme_stylebox("normal") as StyleBoxFlat
_check(done_style != null and done_style.bg_color.is_equal_approx(Color("#087DBA")) and cancel_style != null and cancel_style.bg_color != done_style.bg_color, "Done uses the shared blue primary role and Cancel stays secondary")
var viewport_size: Vector2i = root.size
_check(editor != null and editor.size.x <= viewport_size.x - 48 and editor.size.y <= viewport_size.y - 48, "Move dialog fits within the safe viewport")
var destination_hint: Label = _find_label_exact_text(editor, "Choose a place or prop, then tap Done.") if editor != null else null
_check(destination_hint != null, "destination guidance tells the child what to do next")
if walk != null:
walk.grab_focus()
await _wait_process_frames(2)
_check(walk.has_focus(), "Walk focus is visible before choosing a destination")
await _save_capture_now("phase20b_final_move_dialog_standard")
if editor != null and done != null and prop_fixture != null:
editor.set_plan_destination({"kind": "prop", "target_prop_id": prop_fixture.stage_object_id, "approach_offset": Vector2.ZERO, "last_known_approach": prop_fixture.global_position})
editor.set_plan_destination_summary(prop_fixture.stage_name)
await _wait_process_frames(2)
var selected_destination: Label = _find_label_exact_text(editor, "Destination: %s" % prop_fixture.stage_name)
var primary_style: StyleBoxFlat = done.get_theme_stylebox("normal") as StyleBoxFlat
_check(not done.disabled and selected_destination != null, "selected prop summary enables Done with a readable destination")
_check(primary_style != null and primary_style.bg_color.is_equal_approx(Color("#087DBA")), "enabled Done keeps the shared blue primary fill")
done.grab_focus()
await _wait_process_frames(2)
var done_focus: StyleBoxFlat = done.get_theme_stylebox("focus") as StyleBoxFlat
_check(done.has_focus() and done_focus != null and done_focus.border_color.is_equal_approx(Color("#8BD9FF")), "focused primary action keeps a separate visible focus ring")
await _save_capture_now("phase20b_final_move_dialog_destination_selected")
var anchor_before_return: Vector2i = editor.position
editor.hide_for_plan_target_capture()
await _wait_process_frames(2)
editor.resume_after_plan_target_capture()
await _wait_process_frames(2)
_check(editor.visible and editor.position == anchor_before_return, "returning from destination capture preserves the dialog anchor")
if editor != null and cancel != null:
var cancel_clicks: Array[bool] = []
cancel.pressed.connect(func() -> void: cancel_clicks.append(true))
await _click_control(cancel)
await _wait_process_frames(3)
_check(cancel_clicks.size() == 1 and not editor.visible and not bool(stage.get("_dirty")), "visible Cancel closes the Move dialog without changing the plan")
func _run_wait_dialog_slice(stage: SandboxStage, character: StickmanRig, quick_row: Control) -> void:
stage.call("_set_scene_dirty", false)
var wait_quick_action: Button = _find_button_with_text(quick_row, "⏳ Wait")
_check(wait_quick_action != null, "pinned Wait action is available to open the production dialog")
if wait_quick_action == null:
return
await _click_control(wait_quick_action)
var editor: ActionEditor = stage.get("_action_editor") as ActionEditor
var value_edit: LineEdit = editor.find_child("WaitDurationValue", true, false) as LineEdit if editor != null else null
var duration_spin: SpinBox = editor.get("_duration_spin") as SpinBox if editor != null else null
var unit_label: Label = editor.find_child("WaitDurationUnit", true, false) as Label if editor != null else null
var minus: Button = editor.find_child("WaitDurationMinus", true, false) as Button if editor != null else null
var plus: Button = editor.find_child("WaitDurationPlus", true, false) as Button if editor != null else null
var one_second: Button = editor.find_child("WaitQuickPick1", true, false) as Button if editor != null else null
var three_seconds: Button = editor.find_child("WaitQuickPick3", true, false) as Button if editor != null else null
var five_seconds: Button = editor.find_child("WaitQuickPick5", true, false) as Button if editor != null else null
var done: Button = editor.find_child("ActionEditorDone", true, false) as Button if editor != null else null
var cancel: Button = editor.find_child("ActionEditorCancel", true, false) as Button if editor != null else null
await _wait_process_frames(2)
_check(editor != null and editor.visible and editor.current_action_type() == "wait", "pinned Wait opens the real plan Wait dialog")
_check(value_edit != null and duration_spin != null and minus != null and plus != null and unit_label != null and one_second != null and three_seconds != null and five_seconds != null, "Wait dialog shows the editable value, large stepper, unit, and three quick picks")
_check(value_edit != null and value_edit.text == "1" and unit_label != null and unit_label.text == "second" and one_second != null and one_second.button_pressed, "default one second is shown without a decimal and selects the matching singular preset")
var value_field: PanelContainer = editor.find_child("WaitDurationValueField", true, false) as PanelContainer if editor != null else null
var value_field_style: StyleBoxFlat = value_field.get_theme_stylebox("panel") as StyleBoxFlat if value_field != null else null
var active_theme: SandboxTheme = stage.get("_sandbox_theme") as SandboxTheme
var expected_focus_color: Color = active_theme.color("popups.confirmation.accent_color", Color("#39BFFF")) if active_theme != null else Color("#39BFFF")
_check(value_edit != null and value_edit.has_focus() and value_field_style != null and value_field_style.border_width_left >= 3 and value_field_style.border_color.is_equal_approx(expected_focus_color), "editable seconds field starts focused with a visible cyan outline")
_check(minus != null and plus != null and minus.custom_minimum_size.x >= 64.0 and minus.custom_minimum_size.y >= 64.0 and plus.custom_minimum_size.x >= 64.0 and plus.custom_minimum_size.y >= 64.0, "plus and minus controls are large touch targets")
_check(editor != null and editor.size.y <= 400 and done != null and cancel != null and done.text == "Done" and cancel.text == "Cancel", "redesigned dialog is compact and keeps visible Done and Cancel actions")
if editor == null or value_edit == null or duration_spin == null or minus == null or plus == null or one_second == null or three_seconds == null or five_seconds == null or done == null or cancel == null:
return
await _save_capture_now("wait_dialog_initial")
# Cancel a changed draft and confirm that no wait was added to the plan.
await _click_control(five_seconds)
_check(is_equal_approx(duration_spin.value, 5.0) and five_seconds.button_pressed and not one_second.button_pressed and unit_label.text == "seconds", "five-second quick pick updates the value, selected chip, and plural unit")
await _click_control(cancel)
await _wait_process_frames(3)
_check(not editor.visible and character.get_action_plan().is_empty() and not bool(stage.get("_dirty")), "Cancel discards the changed Wait draft without changing the plan")
await _click_control(wait_quick_action)
await _wait_process_frames(3)
value_edit = editor.find_child("WaitDurationValue", true, false) as LineEdit
duration_spin = editor.get("_duration_spin") as SpinBox
unit_label = editor.find_child("WaitDurationUnit", true, false) as Label
minus = editor.find_child("WaitDurationMinus", true, false) as Button
plus = editor.find_child("WaitDurationPlus", true, false) as Button
one_second = editor.find_child("WaitQuickPick1", true, false) as Button
three_seconds = editor.find_child("WaitQuickPick3", true, false) as Button
five_seconds = editor.find_child("WaitQuickPick5", true, false) as Button
done = editor.find_child("ActionEditorDone", true, false) as Button
if value_edit == null or duration_spin == null or unit_label == null or minus == null or plus == null or one_second == null or three_seconds == null or five_seconds == null or done == null:
_check(false, "Wait controls are rebuilt after cancelling the first draft")
return
await _click_control(minus)
_check(is_equal_approx(duration_spin.value, 0.5) and value_edit.text == "0.5" and unit_label.text == "seconds", "minus steps down by half a second")
await _click_control(plus)
_check(is_equal_approx(duration_spin.value, 1.0) and value_edit.text == "1" and unit_label.text == "second", "plus steps up by half a second and restores the singular unit")
await _click_control(value_edit)
await _send_key(KEY_A, false, true)
await _type_text("1.2")
_check(value_edit.text == "1.2", "duration field accepts typed decimal seconds")
await _click_control(plus)
_check(is_equal_approx(duration_spin.value, 1.7) and value_edit.text == "1.7", "stepper adds 0.5 to a typed decimal without snapping it first")
await _click_control(minus)
_check(is_equal_approx(duration_spin.value, 1.2) and value_edit.text == "1.2", "stepper subtracts 0.5 and preserves tenths")
await _click_control(three_seconds)
_check(is_equal_approx(duration_spin.value, 3.0) and three_seconds.button_pressed and not one_second.button_pressed and unit_label.text == "seconds", "three-second preset selects itself and updates the value")
await _save_capture_now("wait_dialog_preset_three_seconds")
await _click_control(one_second)
_check(is_equal_approx(duration_spin.value, 1.0) and one_second.button_pressed and not three_seconds.button_pressed and unit_label.text == "second", "one-second preset returns to singular wording and updates the selected chip")
await _click_control(value_edit)
await _send_key(KEY_A, false, true)
await _type_text("3600")
await _send_key(KEY_ENTER)
_check(editor.visible and is_equal_approx(duration_spin.value, 3600.0) and value_edit.text == "3600" and plus.disabled, "Enter commits typed maximum seconds without accepting or closing the dialog")
await _click_control(value_edit)
await _send_key(KEY_A, false, true)
await _type_text("0.1")
await _send_key(KEY_ENTER)
_check(is_equal_approx(duration_spin.value, 0.1) and minus.disabled and unit_label.text == "seconds", "typed minimum is clamped to one tenth and disables the lower step")
await _click_control(plus)
_check(is_equal_approx(duration_spin.value, 0.6) and not plus.disabled and not minus.disabled, "plus steps up from the minimum by half a second")
await _click_control(five_seconds)
await _click_control(one_second)
_check(is_equal_approx(duration_spin.value, 1.0) and one_second.button_pressed and not five_seconds.button_pressed, "preset selection follows each choice instead of leaving stale highlights")
await _save_capture_now("wait_dialog_final_one_second")
await _click_control(done)
await _wait_process_frames(4)
var plan: Array[Dictionary] = character.get_action_plan()
var committed_wait: Dictionary = {}
if plan.size() == 1 and plan[0].get("actions", []) is Array and not (plan[0]["actions"] as Array).is_empty():
committed_wait = (plan[0]["actions"] as Array)[0] as Dictionary
_check(not editor.visible and plan.size() == 1 and String(committed_wait.get("type", "")) == "wait" and is_equal_approx(float(committed_wait.get("duration", -1.0)), 1.0), "Done commits the selected duration as the Wait action in the plan")
func _run_move_action_options_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView) -> void:
var move_action: Dictionary = {
"action_id": "visual-options-move",
"type": "move_to",
"style": "walk",
"destination": {"kind": "place", "position": Vector2(-20.0, 0.0)},
}
var say_action: Dictionary = {"action_id": "visual-options-say", "type": "speak", "text": "Hello there!", "duration": 2.0}
var fixture_plan: Array[Dictionary] = [{"actions": [move_action]}, {"actions": [say_action]}]
_check(character.set_action_plan(fixture_plan), "visual comparison plan contains one Move and one Say action")
stage.call("_set_scene_dirty", false)
character_view.refresh_plan()
var move_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Walk to Place 1")
_check(move_summary != null, "Move action summary is visible for the options comparison")
if move_summary == null:
return
await _click_control(move_summary)
await _wait_process_frames(2)
var move_options: Control = character_view.find_child("ActionOptions_visual-options-move", true, false) as Control
var move_edit: Button = _find_button_with_text(move_options, "Edit action")
var move_change: Button = _find_button_with_text(move_options, "Change action")
var move_erase: Button = _find_button_with_text(move_options, "Erase action")
var move_edit_rect: Rect2 = move_edit.get_global_rect() if move_edit != null else Rect2()
var move_change_rect: Rect2 = move_change.get_global_rect() if move_change != null else Rect2()
var move_erase_rect: Rect2 = move_erase.get_global_rect() if move_erase != null else Rect2()
var move_layout_valid: bool = move_options is VBoxContainer and move_options.visible and move_edit != null and move_change != null and move_erase != null and move_edit.get_parent() == move_options and move_change.get_parent() == move_options and move_erase.get_parent() == move_options and is_equal_approx(move_edit_rect.size.x, move_change_rect.size.x) and is_equal_approx(move_edit_rect.size.x, move_erase_rect.size.x) and move_edit_rect.end.y <= move_change_rect.position.y and move_change_rect.end.y <= move_erase_rect.position.y and move_edit_rect.size.y >= 44.0 and move_change_rect.size.y >= 44.0 and move_erase_rect.size.y >= 44.0
_check(move_layout_valid, "Move has stacked, full-width Edit action, Change action, and Erase action controls in that order")
if move_layout_valid:
await _save_capture_now("move_action_options_stacked")
await _click_control(move_edit)
var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
var choose_place: Button = _find_button_containing(action_editor, "place")
var choose_prop: Button = _find_button_containing(action_editor, "prop")
var action_type_row: Control = action_editor.find_child("ActionTypeRow", true, false) as Control if action_editor != null else null
_check(action_editor != null and action_editor.visible and action_editor.current_action_type() == "move_to" and choose_place != null and choose_prop != null and action_type_row != null and not action_type_row.visible, "Edit action opens the Move destination editor without an action-type dropdown")
var cancel_edit: Button = _find_button_with_text(action_editor, "Cancel")
if cancel_edit != null:
await _click_control(cancel_edit)
await _wait_process_frames(2)
await _click_control(move_change)
await _wait_process_frames(16)
var drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
var dimmer: Panel = character_view.find_child("AddStepDrawerDimmer", true, false) as Panel
var drawer_title: Label = drawer.find_child("TogetherChoiceTitle", true, false) as Label if drawer != null else null
var drawer_source: Label = drawer.find_child("TogetherSourceAction", true, false) as Label if drawer != null else null
var drawer_choices: VBoxContainer = drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if drawer != null else null
var change_drawer_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2()
_check(drawer != null and drawer.visible and drawer_title != null and drawer_title.text == "Change action" and drawer_source != null and drawer_source.text == "Replace: Walk to Place 1", "Change action opens the shared drawer with the original action called out")
_check(drawer_choices != null and drawer_choices.get_child_count() == ACTION_REGISTRY.types().size() - 1 and _find_button_containing(drawer_choices, "Move") == null and dimmer != null and dimmer.visible, "standalone replacement picker lists all other action types and dims the inspector")
_check(absf(change_drawer_rect.position.y - character_view.get_card().get_global_rect().position.y) <= 1.0, "Change action drawer aligns to the inspector top")
await _save_capture_now("move_change_action_drawer_open")
var cancel_change: Button = _find_button_with_text(drawer, "Cancel") if drawer != null else null
if cancel_change != null:
await _click_control(cancel_change)
await _wait_process_frames(16)
var after_drawer_cancel: Array[Dictionary] = character.get_action_plan()
var drawer_cancel_preserved: bool = after_drawer_cancel.size() == 2 and String((after_drawer_cancel[0]["actions"] as Array)[0].get("type", "")) == "move_to" and String((after_drawer_cancel[0]["actions"] as Array)[0].get("action_id", "")) == "visual-options-move"
_check(drawer != null and not drawer.visible and dimmer != null and not dimmer.visible and drawer_cancel_preserved and not bool(stage.get("_dirty")), "Cancel closes the Change action drawer without modifying the plan")
await _click_control(move_change)
await _wait_process_frames(16)
var wait_choice: Button = _find_button_containing(drawer_choices, "Wait") if drawer_choices != null else null
if wait_choice != null:
await _click_control(wait_choice)
await _wait_process_frames(16)
action_editor = stage.get("_action_editor") as ActionEditor
action_type_row = action_editor.find_child("ActionTypeRow", true, false) as Control if action_editor != null else null
_check(action_editor != null and action_editor.visible and action_editor.current_action_type() == "wait" and action_type_row != null and not action_type_row.visible and String((character.get_action_plan()[0]["actions"] as Array)[0].get("action_id", "")) == "visual-options-move", "Choosing Wait opens its editor while the original Move and action ID remain until Done")
await _save_capture_now("change_action_wait_editor_open")
var cancel_replacement: Button = _find_button_with_text(action_editor, "Cancel") if action_editor != null else null
if cancel_replacement != null:
await _click_control(cancel_replacement)
await _wait_process_frames(4)
var after_editor_cancel: Array[Dictionary] = character.get_action_plan()
var editor_cancel_preserved: bool = after_editor_cancel.size() == 2 and String((after_editor_cancel[0]["actions"] as Array)[0].get("type", "")) == "move_to" and String((after_editor_cancel[0]["actions"] as Array)[0].get("action_id", "")) == "visual-options-move"
_check(editor_cancel_preserved and not bool(stage.get("_dirty")), "Canceling a type replacement leaves the original action and clean scene intact")
await _click_control(move_change)
await _wait_process_frames(16)
wait_choice = _find_button_containing(drawer_choices, "Wait") if drawer_choices != null else null
if wait_choice != null:
await _click_control(wait_choice)
await _wait_process_frames(16)
action_editor = stage.get("_action_editor") as ActionEditor
var replace_done: Button = _find_button_with_text(action_editor, "Done") if action_editor != null else null
if replace_done != null:
await _click_control(replace_done)
await _wait_process_frames(5)
var replaced_plan: Array[Dictionary] = character.get_action_plan()
var replaced_action: Dictionary = (replaced_plan[0]["actions"] as Array)[0] as Dictionary if not replaced_plan.is_empty() else {}
_check(String(replaced_action.get("type", "")) == "wait" and String(replaced_action.get("action_id", "")) == "visual-options-move" and bool(stage.get("_dirty")), "Done replaces the Move with Wait under the same action ID and dirties the scene once")
await _save_capture_now("move_replaced_by_wait")
var wait_for_say: Dictionary = replaced_action.duplicate(true)
var change_to_say_plan: Array[Dictionary] = [{"actions": [wait_for_say]}]
_check(character.set_action_plan(change_to_say_plan), "standalone Wait fixture is ready for changing to Say")
stage.call("_set_scene_dirty", false)
character_view.refresh_plan()
await _wait_process_frames(8)
var wait_to_say_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Wait")
var wait_to_say_options: Control = character_view.find_child("ActionOptions_visual-options-move", true, false) as Control
if wait_to_say_summary != null and (wait_to_say_options == null or not wait_to_say_options.visible):
await _click_control(wait_to_say_summary)
await _wait_process_frames(8)
wait_to_say_options = character_view.find_child("ActionOptions_visual-options-move", true, false) as Control
var wait_to_say_change: Button = _find_button_with_text(wait_to_say_options, "Change action")
if wait_to_say_change != null:
var inspector_for_say_change: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
if inspector_for_say_change != null:
inspector_for_say_change.scroll_to_control(wait_to_say_change)
await _wait_process_frames(4)
await _click_control(wait_to_say_change)
await _wait_process_frames(16)
var say_change_drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
var say_change_choices: VBoxContainer = say_change_drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if say_change_drawer != null else null
var say_choice: Button = _find_button_containing(say_change_choices, "Say") if say_change_choices != null else null
_check(say_change_drawer != null and say_change_drawer.visible and say_choice != null, "standalone Wait can be changed to Say from the same action drawer")
if say_choice != null:
await _click_control(say_choice)
await _wait_process_frames(16)
var replacement_speech_composer: Control = stage.get("_speech_composer") as Control
_check(replacement_speech_composer != null and replacement_speech_composer.visible and String(stage.get("_plan_action_id")) == "visual-options-move", "choosing Say opens the production speech composer for the existing action ID")
if replacement_speech_composer != null and replacement_speech_composer.visible:
await _save_capture_now("change_action_say_composer_open")
var replacement_speech_ready: bool = await _focus_speech_for_message(stage, "change_action_say_message_editor")
var replacement_text: TextEdit = replacement_speech_composer.find_child("ActiveBubbleText", true, false) as TextEdit
if replacement_speech_ready and replacement_text != null and replacement_text.has_focus():
await _send_key(KEY_A, false, true)
await _type_text("Changed into speech!")
await _click_control(_find_button_with_text(replacement_speech_composer.find_child("MessageDialog", true, false), "✓ Done editing"))
await _wait_process_frames(3)
await _click_control(_find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✓ Done styling"))
await _wait_process_frames(3)
await _click_control(_find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✓ Done speech"))
await _wait_process_frames(6)
var changed_to_say_plan: Array[Dictionary] = character.get_action_plan()
var changed_to_say: Dictionary = (changed_to_say_plan[0]["actions"] as Array)[0] as Dictionary if not changed_to_say_plan.is_empty() else {}
_check(String(changed_to_say.get("type", "")) == "speak" and String(changed_to_say.get("action_id", "")) == "visual-options-move" and _first_speech_text(changed_to_say) == "Changed into speech!", "Done speech replaces Wait with Say, saves its text, and preserves the action ID")
var grouped_move: Dictionary = move_action.duplicate(true)
var grouped_say: Dictionary = say_action.duplicate(true)
grouped_move["action_id"] = "visual-options-group-move"
grouped_say["action_id"] = "visual-options-group-say"
var grouped_plan: Array[Dictionary] = [{"actions": [grouped_move, grouped_say]}]
_check(character.set_action_plan(grouped_plan), "Together compatibility fixture contains Move plus Say")
stage.call("_set_scene_dirty", false)
character_view.refresh_plan()
await _wait_process_frames(8)
var grouped_move_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Walk to Place 1")
if grouped_move_summary != null:
await _click_control(grouped_move_summary)
await _wait_process_frames(8)
var grouped_move_options: Control = character_view.find_child("ActionOptions_visual-options-group-move", true, false) as Control
var grouped_change: Button = _find_button_with_text(grouped_move_options, "Change action")
_check(grouped_change != null, "Move in a Together block has a compatible Change action option")
if grouped_change != null:
await _wait_process_frames(8)
var inspector_for_grouped_move: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
if inspector_for_grouped_move != null:
inspector_for_grouped_move.scroll_to_control(grouped_change)
await _wait_process_frames(8)
await _save_capture_now("together_move_action_options_open")
await _click_control(grouped_change)
await _wait_process_frames(16)
var grouped_drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
var grouped_choices: VBoxContainer = grouped_drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if grouped_drawer != null else null
_check(grouped_drawer != null and grouped_drawer.visible and grouped_choices != null and grouped_choices.get_child_count() == 1 and _find_button_containing(grouped_choices, "Wait") != null and _find_button_containing(grouped_choices, "Move") == null and _find_button_containing(grouped_choices, "Say") == null, "Move plus Say offers only Wait as a valid replacement")
await _save_capture_now("together_move_change_action_compatible_only")
var grouped_cancel: Button = _find_button_with_text(grouped_drawer, "Cancel") if grouped_drawer != null else null
if grouped_cancel != null:
await _click_control(grouped_cancel)
await _wait_process_frames(16)
var say_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Say")
_check(say_summary != null, "Say action summary is visible for comparison")
if say_summary == null:
return
var inspector: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
if inspector != null:
inspector.scroll_to_control(say_summary)
await _wait_process_frames(8)
await _click_control(say_summary)
await _wait_process_frames(8)
var say_options: Control = character_view.find_child("ActionOptions_visual-options-group-say", true, false) as Control
if inspector != null and say_options != null:
inspector.scroll_to_control(say_options)
await _wait_process_frames(8)
var say_edit: Button = _find_button_with_text(say_options, "Edit action")
var say_erase: Button = _find_button_with_text(say_options, "Erase action")
var say_edit_rect: Rect2 = say_edit.get_global_rect() if say_edit != null else Rect2()
var say_erase_rect: Rect2 = say_erase.get_global_rect() if say_erase != null else Rect2()
var say_change: Button = _find_button_with_text(say_options, "Change action")
var say_layout_valid: bool = say_options is VBoxContainer and say_options.visible and say_edit != null and say_erase != null and say_change == null and is_equal_approx(say_edit_rect.size.x, say_erase_rect.size.x) and say_edit_rect.end.y <= say_erase_rect.position.y
_check(say_layout_valid and character_view.get_selected_step_index() == 0, "Say inside Move plus Say keeps Edit and Erase because no other type fits the Together block")
if say_layout_valid:
await _save_capture_now("together_say_has_no_change_option")
var grouped_wait: Dictionary = {"action_id": "visual-options-group-wait", "type": "wait", "duration": 1.0}
var wait_say_plan: Array[Dictionary] = [{"actions": [grouped_wait, grouped_say]}]
_check(character.set_action_plan(wait_say_plan), "Together compatibility fixture contains Wait plus Say")
stage.call("_set_scene_dirty", false)
character_view.refresh_plan()
await _wait_process_frames(8)
var wait_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Wait")
if wait_summary != null:
await _click_control(wait_summary)
await _wait_process_frames(8)
var wait_options: Control = character_view.find_child("ActionOptions_visual-options-group-wait", true, false) as Control
var wait_change: Button = _find_button_with_text(wait_options, "Change action")
_check(wait_change != null, "Wait in a Together block has a compatible Change action option")
if wait_change != null:
await _wait_process_frames(8)
var inspector_for_wait: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
if inspector_for_wait != null:
inspector_for_wait.scroll_to_control(wait_change)
await _wait_process_frames(8)
await _click_control(wait_change)
await _wait_process_frames(16)
var wait_change_drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
var wait_change_choices: VBoxContainer = wait_change_drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if wait_change_drawer != null else null
_check(wait_change_drawer != null and wait_change_drawer.visible and wait_change_choices != null and wait_change_choices.get_child_count() == 1 and _find_button_containing(wait_change_choices, "Move") != null and _find_button_containing(wait_change_choices, "Wait") == null and _find_button_containing(wait_change_choices, "Say") == null, "Wait plus Say offers only Move as a valid replacement")
await _save_capture_now("together_wait_change_action_compatible_only")
var wait_drawer_cancel: Button = _find_button_with_text(wait_change_drawer, "Cancel") if wait_change_drawer != null else null
if wait_drawer_cancel != null:
await _click_control(wait_drawer_cancel)
await _wait_process_frames(16)
var say_summary_in_wait_group: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Say")
if say_summary_in_wait_group != null:
await _click_control(say_summary_in_wait_group)
await _wait_process_frames(8)
var say_options_in_wait_group: Control = character_view.find_child("ActionOptions_visual-options-group-say", true, false) as Control
_check(_find_button_with_text(say_options_in_wait_group, "Change action") == null, "Say in Wait plus Say also has no incompatible type replacement")
func _find_label_exact_text(parent: Node, expected: String) -> Label:
if parent == null:
return null
for child: Node in parent.find_children("*", "Label", true, false):
var label: Label = child as Label
if label != null and label.text == expected:
return label
return null
func _run_grouping_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, quick_row: Control) -> void:
var move_action: Dictionary = {"action_id": "visual-fixture-fetch-move", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(-20.0, 0.0)}}
var fixture_plan: Array[Dictionary] = [{"actions": [move_action]}]
_check(character.set_action_plan(fixture_plan), "visual-only fixture supplies one committed Walk step for Add together authoring")
character_view.refresh_plan()
if character.get_action_plan().is_empty():
return
await _save_capture_now("phase20b_visual_fixture_move_step")
var step_card: Control = character_view.find_child("ActionStep_1", true, false) as Control
var step_heading: Label = _find_label_exact_text(step_card, "Step 1") if step_card != null else null
_check(step_heading != null and step_heading.name == "StepHeading" and step_heading.tooltip_text.is_empty() and step_heading.mouse_filter == Control.MOUSE_FILTER_PASS and step_heading.custom_minimum_size.y >= 44.0, "fixture Move shows plain Step 1 text with no tooltip")
_check(step_card != null and step_card.focus_mode == Control.FOCUS_ALL, "whole step card is keyboard-focusable")
if step_card == null or step_heading == null:
return
step_card.grab_focus()
await _send_key(KEY_ENTER)
await _wait_process_frames(3)
_check(character_view.get_selected_step_index() == 0 and step_card.has_focus(), "Enter activates the focused step card")
step_card = character_view.find_child("ActionStep_1", true, false) as Control
var together_button: Button = _find_button_with_text(step_card, "+ Add together")
await _click_control(together_button)
var popup: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
var helper: Label = popup.find_child("TogetherCompatibilityHint", true, false) as Label if popup != null else null
var choice_column: VBoxContainer = popup.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if popup != null else null
var cancel: Button = _find_button_with_text(popup, "Cancel") if popup != null else null
var picker_checks_before: int = _failures
_check(popup != null and popup.visible and helper == null, "Add together chooser omits the compatibility helper copy")
var popup_title: Label = popup.find_child("TogetherChoiceTitle", true, false) as Label if popup != null else null
var source_context: Label = popup.find_child("TogetherSourceAction", true, false) as Label if popup != null else null
var popup_scroll: ScrollContainer = popup.find_child("TogetherChoiceScroll", true, false) as ScrollContainer if popup != null else null
var picker_rect: Rect2 = popup.get_global_rect() if popup != null else Rect2()
var trigger_rect: Rect2 = together_button.get_global_rect() if together_button != null else Rect2()
var pinned_quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control
print("PHASE20B_GUI_SLICE together_popup rect=%s trigger=%s custom=%s title=%s scroll=%s choices=%d cancel=%s focus=%s" % [str(picker_rect), str(trigger_rect), str(popup.custom_minimum_size) if popup != null else "missing", popup_title.text if popup_title != null else "missing", str(popup_scroll.get_global_rect()) if popup_scroll != null else "missing", choice_column.get_child_count() if choice_column != null else -1, str(cancel.size) if cancel != null else "missing", str(root.gui_get_focus_owner())])
var selection: StageSelection = stage.get("_selection") as StageSelection
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "Add together interaction keeps the plan actor selected")
_check(choice_column != null and choice_column.get_child_count() == 1 and (choice_column.get_child(0) as Button).text.contains("Say") and cancel != null and cancel.visible and cancel.size.x >= 44.0 and cancel.size.y >= 44.0, "Move step offers one Say choice and a touch-sized visible Cancel")
_check(source_context != null and source_context.visible and source_context.text == "With: Walk to Place 1", "Add together chooser repeats the source action while covering the Step Stack row")
_check(popup_title != null and popup_title.text == "Add to Step 1" and popup != null and popup.size.y <= 288.0 and picker_rect.end.x <= root.size.x - 8.0 and picker_rect.end.y <= root.size.y - 38.0, "Add together chooser names its step and stays inside the safe viewport")
_check(together_button != null and popup != null and picker_rect.intersects(trigger_rect.grow(12.0)) and pinned_quick_row != null and not picker_rect.intersects(pinned_quick_row.get_global_rect()), "Add together chooser stays visually attached to its trigger without covering the pinned actions")
_check(root.gui_get_focus_owner() == choice_column.get_child(0), "keyboard focus moves to the compatible Say choice")
if _failures == picker_checks_before:
await _save_capture_now("phase20b_final_add_together_picker")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_add_together_picker reason=picker_state_assertions_failed")
return
await _click_control(choice_column.get_child(0) as Button)
await _wait_process_frames(3)
var composer: Control = stage.get("_speech_composer") as Control
_check(composer != null and composer.visible, "visible Say choice opens the production speech composer")
if composer == null or not composer.visible:
return
var style_ready: bool = await _focus_speech_for_message(stage, "phase20b_final_grouped_say_message_dialog")
var message_button: Button = _find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✏ Message")
_check(message_button != null and message_button.is_visible_in_tree() and message_button.get_global_rect().size.x > 0.0, "validation focus exposes the production Message control")
if not style_ready or message_button == null or not message_button.is_visible_in_tree() or message_button.get_global_rect().size.x <= 0.0:
await _click_control(_find_button_with_text(stage.get("_speech_show_toolbar") as Control, "Cancel speech"))
return
var text_edit: TextEdit = composer.find_child("ActiveBubbleText", true, false) as TextEdit if composer != null else null
_check(text_edit != null and text_edit.visible and text_edit.has_focus(), "production validation opens and focuses the speech text editor")
if text_edit != null and text_edit.has_focus():
await _send_key(KEY_A, false, true)
await _type_text("Fetch the ball, Milo!")
_check(text_edit.text == "Fetch the ball, Milo!", "Message text is entered through the focused production TextEdit")
await _save_capture_now("phase20b_final_grouped_say_composer")
await _click_control(_find_button_with_text(composer.find_child("MessageDialog", true, false), "✓ Done editing"))
await _wait_process_frames(3)
await _click_control(_find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✓ Done styling"))
await _wait_process_frames(3)
var done: Button = _find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✓ Done speech")
await _click_control(done)
await _wait_process_frames(6)
var plan: Array[Dictionary] = character.get_action_plan()
var grouped_commit_valid: bool = plan.size() == 1 and (plan[0]["actions"] as Array).size() == 2 and _step_has_action_type(plan[0], "speak")
_check(grouped_commit_valid, "real Add together and Done speech interactions commit exactly one Say alongside the Move fixture")
if not grouped_commit_valid:
return
var say_action: Dictionary = _find_action_type(plan[0], "speak")
_check(_first_speech_text(say_action) == "Fetch the ball, Milo!", "committed grouped Say preserves the entered message")
character_view.refresh_plan()
var grouped_step_card: Control = character_view.find_child("ActionStep_1", true, false) as Control
var grouped_say_summary: Button = _find_button_containing(grouped_step_card, "Say") if grouped_step_card != null else null
_check(grouped_say_summary != null and grouped_say_summary.text.begins_with("Say ") and grouped_say_summary.tooltip_text.contains("Fetch the ball, Milo!") and grouped_say_summary.tooltip_text.contains("seconds)"), "Say action keeps a concise control label and exposes the full message and duration by tooltip")
var say_id: String = String(say_action.get("action_id", ""))
var say_row_text: Label = character_view.find_child("ActionSummaryText_%s" % say_id, true, false) as Label
var say_duration: Label = character_view.find_child("ActionSummaryDuration_%s" % say_id, true, false) as Label
var say_rail: Control = character_view.find_child("ActionAccentRail_%s" % say_id, true, false) as Control
var say_row_options: Label = character_view.find_child("ActionSummaryOptions_%s" % say_id, true, false) as Label
var say_row_style: StyleBoxFlat = grouped_say_summary.get_theme_stylebox("normal") as StyleBoxFlat if grouped_say_summary != null else null
_check(say_row_text != null and say_row_text.text == "Say “Fetch the ball, Milo!”" and say_duration != null and say_duration.text.ends_with("s"), "Say row separates its spoken text and secondary duration like the mockup")
_check(say_rail != null and say_rail.custom_minimum_size.x == 4.0 and say_row_options != null and say_row_options.text == "..." and grouped_say_summary.custom_minimum_size.y >= 48.0, "Say row uses the same 48 px twin-rail layout and options cue as Move")
_check(say_row_style != null and say_row_style.bg_color.is_equal_approx(Color("#2C281C")), "Say row uses the matching warm tint from the approved mockup")
var projections: Array[Dictionary] = (stage.get("_director_visuals") as StageDirectorVisuals)._plan_projection(character)
var move_projection: Dictionary = _projection_for_action(projections, String(move_action.get("action_id", "")))
var say_projection: Dictionary = _projection_for_action(projections, String(say_action.get("action_id", "")))
var grouped_projection_valid: bool = say_projection.get("kind", "") == "say" and move_projection.get("kind", "") == "move" and say_projection.get("start", Vector2.INF) == move_projection.get("start", Vector2.ZERO)
_check(grouped_projection_valid, "grouped Say marker projects once at the same starting point as Move")
if not grouped_projection_valid:
return
await _wait_for_status_toast_clear(stage)
await _wait_process_frames(2)
await RenderingServer.frame_post_draw
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
var marker_image: Image = root.get_texture().get_image()
var say_marker_visible: bool = _say_marker_has_rendered_pixels(visuals, say_projection, marker_image)
if say_marker_visible:
await _save_capture_now("phase20b_final_walk_say_committed")
else:
await _save_capture_now("phase20b_diagnostics_say_marker")
_check(say_marker_visible, "grouped Say marker is visibly rendered at the projected Step 1 start")
await _click_control(_find_button_with_text(quick_row, "⏳ Wait"))
await _wait_process_frames(2)
var wait_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
var wait_done_button: Button = _find_button_with_text(wait_editor, "Done") if wait_editor != null else null
var wait_editor_rect: Rect2 = Rect2(Vector2(wait_editor.position), Vector2(wait_editor.size)) if wait_editor != null else Rect2()
var wait_done_rect: Rect2 = wait_done_button.get_global_rect() if wait_done_button != null else Rect2()
var wait_editor_local_rect: Rect2 = Rect2(Vector2.ZERO, Vector2(wait_editor.size)) if wait_editor != null else Rect2()
_check(wait_editor != null and wait_done_button != null and wait_editor_rect.position.x >= 0.0 and wait_editor_rect.position.y >= 0.0 and wait_editor_rect.end.x <= root.size.x and wait_editor_rect.end.y <= root.size.y and wait_editor_local_rect.encloses(wait_done_rect), "Wait dialog and Done footer remain inside the production popup and viewport")
if wait_editor != null and wait_done_button != null:
await _save_capture_now("phase20b_wait_dialog_footer_visible")
await _click_control(wait_done_button)
await _wait_process_frames(4)
plan = character.get_action_plan()
_check(plan.size() == 2 and _step_has_action_type(plan[1], "wait"), "quick Wait appends a separate second step after the committed Walk+Say")
character_view.refresh_plan()
var wait_step_card: Control = character_view.find_child("ActionStep_2", true, false) as Control
var wait_summary: Button = _find_button_containing(wait_step_card, "Wait") if wait_step_card != null else null
_check(wait_summary != null and wait_summary.text == "Wait 1 second", "one-second Wait summary uses singular grammar")
var wait_id: String = String(_find_action_type(plan[1], "wait").get("action_id", "")) if plan.size() > 1 else ""
var wait_options: Label = character_view.find_child("ActionSummaryOptions_%s" % wait_id, true, false) as Label
_check(wait_options != null and wait_options.text == "...", "Step 2 uses the same trailing action-options cue as Step 1")
await _save_capture_now("phase20b_final_walk_say_then_wait")
_check(bool(stage.get("_dirty")), "valid production commits mark the scene dirty")
func _run_capture_entry_slice(stage: SandboxStage, quick_row: Control) -> void:
await _click_control(_find_button_with_text(quick_row, "🚶 Move"))
var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
var choose_place: Button = _find_button_containing(action_editor, "Choose a place")
_check(action_editor != null and action_editor.visible and choose_place != null, "Quick Move opens its production draft before place capture")
if choose_place == null:
return
if action_editor.visible and choose_place.is_visible_in_tree():
await _save_capture_now("action_editor_choose_place_before_click")
else:
print("PHASE20B_CAPTURE_SKIPPED name=action_editor_choose_place_before_click reason=draft_or_choice_not_visible")
_capture_after_next_click = true
_trace_next_click = true
await _click_control(choose_place)
_check(int(stage.get("_capture_kind")) == PLAN_PLACE and not action_editor.visible, "Choose a place activates capture and closes the production draft")
var capture_rig: StickmanRig = stage.get("_action_editor_rig") as StickmanRig
_check(capture_rig != null and capture_rig.get_action_plan().is_empty(), "entering place capture leaves the Move draft uncommitted until Done")
var bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar
_check(bar != null and bar.visible, "place capture prompt is rendered after the production click")
func _run_say_slice(stage: SandboxStage, character: StickmanRig, _character_view: StageCharacterSettingsView, quick_row: Control) -> void:
await _click_control(_find_button_with_text(quick_row, "💬 Say"))
await _wait_process_frames(4)
var composer: Control = stage.get("_speech_composer") as Control
_check(composer != null and composer.visible, "quick Say opens the production speech composer")
if composer != null and composer.visible:
await _save_capture_now("phase20b_final_standalone_say_toolbar")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_standalone_say_toolbar reason=composer_not_open")
var style_ready: bool = await _focus_speech_for_message(stage, "phase20b_final_standalone_say_message_dialog")
var message_button: Button = _find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✏ Message")
_check(message_button != null and message_button.is_visible_in_tree() and message_button.get_global_rect().size.x > 0.0, "focused speech preview exposes the production Message control")
if not style_ready or message_button == null or not message_button.is_visible_in_tree() or message_button.get_global_rect().size.x <= 0.0:
await _click_control(_find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✕ Cancel speech"))
return
var text_edit: TextEdit = composer.find_child("ActiveBubbleText", true, false) as TextEdit if composer != null else null
var message_dialog: Control = composer.find_child("MessageDialog", true, false) as Control if composer != null else null
_check(message_button != null and message_dialog != null and message_dialog.visible and text_edit != null and text_edit.visible, "production validation opens the message editor for the empty Say bubble")
if text_edit != null and text_edit.visible and not text_edit.has_focus():
await _click_control(text_edit)
await _wait_process_frames(2)
_check(text_edit != null and text_edit.visible and text_edit.has_focus(), "production Message editor accepts pointer focus")
if text_edit != null and text_edit.has_focus():
await _send_key(KEY_A, false, true)
await _type_text("Hello from the stage!")
await _click_control(_find_button_with_text(composer.find_child("MessageDialog", true, false), "✓ Done editing"))
await _wait_process_frames(3)
await _click_control(_find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✓ Done styling"))
await _wait_process_frames(3)
var done: Button = _find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✓ Done speech")
_check(done != null and done.is_visible_in_tree() and done.get_global_rect().size.x > 0.0, "speech action exposes its labeled Done speech commit after styling closes")
if done != null:
await _click_control(done)
await _wait_process_frames(6)
var plan: Array[Dictionary] = character.get_action_plan()
_check(plan.size() == 1 and _step_has_action_type(plan[0], "speak"), "quick Say commits as its own first plan step through the production composer")
if plan.is_empty():
return
var say_action: Dictionary = _find_action_type(plan[0], "speak")
_check(_first_speech_text(say_action) == "Hello from the stage!", "standalone Say retains the committed message")
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
var say_projection: Dictionary = _projection_for_action(visuals._plan_projection(character), String(say_action.get("action_id", "")))
_check(say_projection.get("kind", "") == "say", "standalone Say has one projected preview marker")
await _wait_for_status_toast_clear(stage)
await _wait_process_frames(2)
await RenderingServer.frame_post_draw
var marker_image: Image = root.get_texture().get_image()
var say_marker_visible: bool = _say_marker_has_rendered_pixels(visuals, say_projection, marker_image)
if say_marker_visible:
await _save_capture_now("phase20b_final_standalone_say_committed")
else:
await _save_capture_now("phase20b_diagnostics_standalone_say_marker")
_check(say_marker_visible, "standalone Say marker is visibly rendered at the Step 1 start")
func _focus_speech_for_message(stage: SandboxStage, capture_name: String) -> bool:
var preview: SpeechBubble = stage.get("_speech_preview") as SpeechBubble
_check(preview != null and preview.visible, "speech draft exposes its live bubble preview")
if preview == null or not preview.visible:
return false
var composer: SpeechComposer = stage.get("_speech_composer") as SpeechComposer
var show_toolbar: Control = stage.get("_speech_show_toolbar") as Control
var done_speech: Button = _find_button_with_text(show_toolbar, "✓ Done speech")
var validation_events: Array[Dictionary] = []
if composer != null:
composer.validation_failed.connect(func(message: String, bubble_index: int) -> void: validation_events.append({"message": message, "bubble_index": bubble_index}), CONNECT_ONE_SHOT)
_check(done_speech != null and done_speech.is_visible_in_tree(), "speech draft exposes its labeled Done speech control")
if done_speech == null or not done_speech.is_visible_in_tree():
return false
await _click_control(done_speech)
await _wait_process_frames(4)
var style_toolbar: Control = stage.get("_speech_style_toolbar") as Control
var message_button: Button = _find_button_with_text(style_toolbar, "✏ Message")
var focus_index: int = int(composer.call("stage_focus_bubble_index")) if composer != null else -1
var controls_ready: bool = focus_index == 0 and message_button != null and message_button.is_visible_in_tree() and message_button.get_global_rect().size.x > 0.0 and message_button.get_global_rect().size.y > 0.0
var message_dialog: Control = composer.find_child("MessageDialog", true, false) as Control if composer != null else null
var text_edit: TextEdit = composer.find_child("ActiveBubbleText", true, false) as TextEdit if composer != null else null
print("PHASE20B_GUI_SPEECH_VALIDATION focus=%d events=%s message_rect=%s" % [focus_index, str(validation_events), str(message_button.get_global_rect()) if message_button != null else "missing"])
_check(validation_events.size() == 1 and int(validation_events[0].get("bubble_index", -1)) == 0, "Done speech validation guides the author to the first empty bubble")
_check(controls_ready, "validation focus exposes the production Message control for the bubble")
_check(composer != null and bool(composer.call("is_message_dialog_open")) and message_dialog != null and message_dialog.visible and text_edit != null and text_edit.visible and text_edit.has_focus(), "production validation opens and focuses the bubble's Message editor")
if controls_ready:
await _save_capture_now(capture_name)
return controls_ready
func _run_flag_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, card: StageInspectorPanel) -> void:
var quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control
var move: Dictionary = {"action_id": "gui-flag-move", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(-20.0, 0.0)}}
var wait_action: Dictionary = {"action_id": "gui-flag-wait", "type": "wait", "duration": 0.5}
var fixture_plan: Array[Dictionary] = [{"actions": [move]}, {"actions": [wait_action]}]
_check(character.set_action_plan(fixture_plan), "visual-only flag fixture seeds ordered Move and Wait steps through the rig plan API")
character_view.refresh_plan()
var move_id: String = String(move.get("action_id", ""))
stage.call("_set_scene_dirty", false)
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
var projection: Dictionary = {}
for entry: Dictionary in visuals._plan_projection(character):
if String(entry.get("action_id", "")) == move_id:
projection = entry
break
var hit_rect: Rect2 = projection.get("hit_rect", Rect2())
var screen_hit_size: Vector2 = hit_rect.size * visuals._zoom()
_check(String(projection.get("label", "")) == "Place 1" and screen_hit_size.x >= 44.0 and screen_hit_size.y >= 44.0, "projection carries Place 1 and a 44×44 screen hit rect")
var place_button: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Place 1")
_check(place_button != null and not place_button.text.contains("(-"), "action row uses Place 1 without exposing raw coordinates")
await _send_click(_screen_at(stage, Vector2(-20.0, 0.0)))
await _wait_process_frames(2)
var options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control
_check(options != null and options.visible and not bool(stage.get("_dirty")), "production route flag click selects and opens options without dirtying the scene")
var card_rect: Rect2 = card.get_global_rect()
_check(card_rect.position.x >= 0.0 and card_rect.end.x <= root.size.x and quick_row != null and quick_row.visible and _find_button_with_text(quick_row, "⏳ Wait") != null, "expanded action inspector retains its shell and pinned actions")
var later: Button = _find_button_with_tooltip(character_view.find_child("ActionStep_1", true, false), "Move Step 1 later")
var earlier: Button = _find_button_with_tooltip(character_view.find_child("ActionStep_1", true, false), "Move Step 1 earlier")
_check(later != null and later.text == "↓" and later.size.x >= 48.0 and later.size.y >= 48.0 and earlier != null and earlier.text == "↑" and earlier.size.x >= 48.0 and earlier.size.y >= 48.0, "reorder controls use touch-sized directional arrows with descriptive tooltips")
await _save_capture_now("phase20b_final_flag_options")
_check(not bool(stage.get("_dirty")), "flag selection and opening its options do not dirty the scene")
character_view.call("_select_plan_step", 1)
if earlier != null:
await _click_control(earlier)
await _wait_process_frames(2)
var first_step_style: StyleBoxFlat = (character_view.find_child("ActionStep_1", true, false) as Control).get_theme_stylebox("panel") as StyleBoxFlat
_check(character_view.get_selected_step_index() == 0 and first_step_style.border_color.is_equal_approx(Color("#FFB300")), "clicking the disabled boundary arrow still selects its card")
if later != null:
await _click_control(later)
await _wait_process_frames(2)
_check(String(((character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == String(wait_action.get("action_id", "")), "real down arrow reorders only the future step")
var moved_move_card: Control = character_view.find_child("ActionStep_2", true, false) as Control
var restore_move_earlier: Button = _find_button_with_tooltip(moved_move_card, "Move Step 2 earlier") if moved_move_card != null else null
_check(restore_move_earlier != null and restore_move_earlier.text == "↑" and not restore_move_earlier.disabled, "reordered Move exposes an enabled up arrow on Step 2")
await _click_control(restore_move_earlier)
await _wait_process_frames(2)
_check(String(((character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == move_id, "real up arrow restores the initial order")
func _run_step_spotlight_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView) -> void:
var first_move: Dictionary = {
"action_id": "spotlight-milo-step-1",
"type": "move_to",
"style": "walk",
"destination": {"kind": "place", "position": Vector2(80.0, 0.0)},
}
var first_say: Dictionary = {"action_id": "spotlight-milo-say", "type": "speak", "text": "Ready!", "duration": 1.0}
var second_move: Dictionary = {
"action_id": "spotlight-milo-step-2",
"type": "move_to",
"style": "run",
"destination": {"kind": "place", "position": Vector2(-300.0, -100.0)},
}
var fixture_plan: Array[Dictionary] = [
{"actions": [first_move, first_say]},
{"actions": [second_move]},
]
_check(character.set_action_plan(fixture_plan), "visual fixture seeds a grouped Walk+Say step followed by a Run step")
character_view.refresh_plan()
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
var ava: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(420.0, -170.0))
_check(ava != null, "production spawner creates a second actor for Step Spotlight visibility checks")
if ava != null:
stage.call("_finalize_placed_node", ava)
var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
var renamed: Dictionary = identity.rename(ava, "Ava", stage.get_node("World"))
_check(bool(renamed.get("ok", false)), "second actor receives its distinct stage name through production identity")
var ava_move: Dictionary = {
"action_id": "spotlight-ava-step-1",
"type": "move_to",
"style": "run",
"destination": {"kind": "place", "position": Vector2(500.0, 100.0)},
}
_check(ava.set_action_plan([{"actions": [ava_move]}]), "second actor receives its own visual-only Run route")
var selection: StageSelection = stage.get("_selection") as StageSelection
if selection != null and (selection.get_selected().size() != 1 or selection.get_selected()[0] != character):
selection.select_only(character)
character_view.refresh_plan()
stage.call("_set_scene_dirty", false)
await _wait_process_frames(4)
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
_check(character_view.get_selected_step_index() == 0 and visuals.get("_plan_spotlight_rig") == character and int(visuals.get("_plan_spotlight_step_index")) == 0, "DIRECT defaults Step Spotlight to the selected actor's first pending step")
_check(visuals.spotlight_route_width_px >= 4.0 and visuals.spotlight_route_shadow_width_px >= 6.0 and visuals.spotlight_dash_length_px >= 12.0 and visuals.spotlight_dash_gap_px >= 7.0 and visuals.spotlight_route_alpha >= 0.9, "theme loads the thicker, brighter route and contrast edge")
_check(visuals.action_badge_font_size_px >= 18.0 and visuals.action_badge_radius_px >= 10.0 and visuals.actor_name_font_size_px >= 20.0 and visuals.actor_name_radius_px >= 12.0 and visuals.actor_name_offset_px >= 24.0, "theme loads prominent rounded action badges and clear headwear clearance")
var name_layouts: Array = visuals.get("_last_actor_name_badge_layouts")
_check(name_layouts.size() == 1 and (name_layouts[0] as Dictionary).get("rig") == character, "DIRECT Step Spotlight draws the selected actor's name badge")
if name_layouts.size() == 1:
var name_layout: Dictionary = name_layouts[0]
var name_rect: Rect2 = name_layout.get("screen_rect", Rect2())
var head_rect: Rect2 = name_layout.get("head_bounds_screen", Rect2())
_check(String(name_layout.get("side", "")) == "above" and not name_rect.intersects(head_rect) and head_rect.position.y - name_rect.end.y >= 24.0, "DIRECT name badge stays above visible headwear with at least 24 screen pixels of clearance")
await _save_capture_now("phase20b_direct_name_badge_clearance")
var ava_target: Vector2 = Vector2(500.0, 100.0)
_check(ava != null and visuals.hit_test_plan_action(ava_target).is_empty(), "unselected actors' hidden routes do not retain invisible hit targets")
await _save_capture_now("phase20b_step_spotlight_step1_walk_say")
var step_two_card: Control = character_view.find_child("ActionStep_2", true, false) as Control
var step_two_action: Button = _find_button_with_text(step_two_card, "Run to Place 2")
var step_one_card: Control = character_view.find_child("ActionStep_1", true, false) as Control
var step_one: Label = _find_label_exact_text(step_one_card, "Step 1")
_check(step_two_action != null and step_one != null and step_one.tooltip_text.is_empty(), "action summary and plain Step 1 title are selectable without a redundant tooltip")
if step_two_action == null or step_one == null or step_one_card == null:
return
var heading_rect_before_hover: Rect2 = step_one.get_global_rect()
await _send_mouse_motion(step_one.get_screen_position() + step_one.size * 0.5)
await _wait_process_frames(3)
var heading_rect_after_hover: Rect2 = step_one.get_global_rect()
_check(heading_rect_before_hover.position.is_equal_approx(heading_rect_after_hover.position) and heading_rect_before_hover.size.is_equal_approx(heading_rect_after_hover.size), "hovering the plain step title does not shift or resize it")
var second_card_rect: Rect2 = step_two_card.get_global_rect()
await _send_click(Vector2(second_card_rect.position.x + 5.0, second_card_rect.get_center().y))
await _wait_process_frames(2)
var second_card_style: StyleBoxFlat = step_two_card.get_theme_stylebox("panel") as StyleBoxFlat
_check(character_view.get_selected_step_index() == 1 and second_card_style != null and second_card_style.border_color.is_equal_approx(Color("#FFB300")), "clicking blank Step 2 card space selects it with the amber border")
var step_two_add_together: Button = _find_button_with_text(step_two_card, "+ Add together")
character_view.call("_select_plan_step", 0)
var inspector: StageInspectorPanel = character_view.get_card() as StageInspectorPanel
if inspector != null and step_two_add_together != null:
inspector.scroll_to_control(step_two_add_together)
await _wait_process_frames(3)
await _click_control(step_two_add_together)
var together_popup: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel
_check(character_view.get_selected_step_index() == 1 and together_popup != null and together_popup.visible, "Add together selects its parent step while opening the choice picker")
var together_cancel: Button = _find_button_with_text(together_popup, "Cancel") if together_popup != null else null
await _click_control(together_cancel)
await _wait_process_frames(2)
await _click_control(step_two_action)
await _wait_process_frames(3)
var second_options: Control = character_view.find_child("ActionOptions_%s" % second_move.get("action_id", ""), true, false) as Control
var expanded_summary: Button = character_view.find_child("ActionSummary_%s" % second_move.get("action_id", ""), true, false) as Button
var expanded_style: StyleBoxFlat = expanded_summary.get_theme_stylebox("normal") as StyleBoxFlat if expanded_summary != null else null
_check(second_options != null and second_options.visible and expanded_style != null and expanded_style.border_color.is_equal_approx(Color("#FFB300")), "selecting Step 2 action opens its options and amber action highlight")
var step_one_heading: Label = _find_label_exact_text(character_view.find_child("ActionStep_1", true, false), "Step 1")
if inspector != null and step_one_heading != null:
inspector.scroll_to_control(step_one_heading)
await _wait_process_frames(3)
step_one_heading = _find_label_exact_text(character_view.find_child("ActionStep_1", true, false), "Step 1")
await _click_control(step_one_heading)
await _wait_process_frames(3)
var closed_options: Control = character_view.find_child("ActionOptions_%s" % second_move.get("action_id", ""), true, false) as Control
var collapsed_summary: Button = character_view.find_child("ActionSummary_%s" % second_move.get("action_id", ""), true, false) as Button
var collapsed_style: StyleBoxFlat = collapsed_summary.get_theme_stylebox("normal") as StyleBoxFlat if collapsed_summary != null else null
var selected_step_style: StyleBoxFlat = (character_view.find_child("ActionStep_1", true, false) as Control).get_theme_stylebox("panel") as StyleBoxFlat
step_one_heading = _find_label_exact_text(character_view.find_child("ActionStep_1", true, false), "Step 1")
_check(character_view.get_selected_step_index() == 0 and String(character_view.get("_expanded_action_id")) == "" and closed_options != null and not closed_options.visible, "selecting Step 1 heading closes the previously expanded Step 2 action")
_check(collapsed_style != null and not collapsed_style.border_color.is_equal_approx(Color("#FFB300")) and selected_step_style != null and selected_step_style.border_color.is_equal_approx(Color("#FFB300")), "step selection clears the old action highlight and leaves the amber border on its card")
_check(step_one_heading != null and step_one_heading.name == "StepHeading" and step_one_heading.tooltip_text.is_empty(), "selected step title remains plain text without a tooltip")
await _save_capture_now("phase20b_step_spotlight_step1_heading_selected")
var step_two: Label = _find_label_exact_text(character_view.find_child("ActionStep_2", true, false), "Step 2")
await _click_control(step_two)
await _wait_process_frames(3)
_check(character_view.get_selected_step_index() == 1 and int(visuals.get("_plan_spotlight_step_index")) == 1, "clicking the Step 2 heading switches the Step Spotlight")
var ghost_hit: Dictionary = visuals.hit_test_plan_action(Vector2(80.0, 0.0))
_check(String(ghost_hit.get("action_id", "")) == String(first_move.get("action_id", "")) and int(ghost_hit.get("step_index", -1)) == 0, "faint prior-step flag keeps its action-ID hit target")
await _save_capture_now("phase20b_step_spotlight_step2_run_ghost_flag")
await _send_click(_screen_at(stage, Vector2(80.0, 0.0)))
await _wait_process_frames(3)
_check(character_view.get_selected_step_index() == 0 and int(visuals.get("_plan_spotlight_step_index")) == 0, "clicking a ghost flag returns focus to its authored step")
_check(not bool(stage.get("_dirty")), "switching Step Spotlight by card or flag does not dirty the scene")
await _save_capture_now("phase20b_step_spotlight_ghost_flag_focus")
var original_character_position: Vector2 = character.global_position
var head_geometry: Dictionary = character.speech_head_geometry()
var edge_head_center: Vector2 = character.to_global(head_geometry.get("center", Vector2.ZERO))
var edge_head_radius: float = float(head_geometry.get("radius", 100.0)) * visuals._zoom()
var initial_head_top: float = (stage.get_viewport().get_canvas_transform() * edge_head_center).y - edge_head_radius
character.global_position.y += (84.0 - initial_head_top) / visuals._zoom()
visuals.mark_dirty()
await _wait_process_frames(4)
name_layouts = visuals.get("_last_actor_name_badge_layouts")
if name_layouts.size() == 1:
var edge_layout: Dictionary = name_layouts[0]
var edge_badge: Rect2 = edge_layout.get("screen_rect", Rect2())
var edge_head: Rect2 = edge_layout.get("head_bounds_screen", Rect2())
var safe_rect: Rect2 = Rect2(Vector2(8.0, 72.0), Vector2(float(root.size.x) - 16.0, float(root.size.y) - 112.0))
_check(String(edge_layout.get("side", "")) in ["left", "right"] and not edge_badge.intersects(edge_head), "DIRECT name badge moves beside a head near the top toolbar")
_check(safe_rect.encloses(edge_badge), "top-edge name badge stays inside the safe viewport")
else:
_check(false, "top-edge DIRECT state retains one actor-name badge layout")
await _save_capture_now("phase20b_direct_name_badge_top_edge")
character.global_position = original_character_position
visuals.mark_dirty()
await _wait_process_frames(3)
func _run_busy_scene_badge_stability(stage: SandboxStage) -> void:
var loaded: bool = bool(stage.call("_load_scene_from_path", "res://content/stages/busy.scene"))
_check(loaded, "production scene loader opens the user's busy.scene")
if not loaded:
return
await _wait_frames(12)
if int(stage.get("current_mode")) != SandboxStage.StageMode.EDIT:
await _click_control(_find_button_containing(stage, "Edit"))
var world: Node2D = stage.get_node("World") as Node2D
var target: StickmanRig
for child: Node in world.get_children():
if child is StickmanRig and String((child as StickmanRig).stage_name) == "break 2":
target = child as StickmanRig
break
_check(target != null, "busy.scene contains the named stickman shown in the user's report")
if target == null:
return
var camera: Camera2D = stage.get("_camera") as Camera2D
# Center only the test camera so the full production drag and badge remain in
# view; the scene itself is never saved or modified by this visual harness.
camera.position = target.global_position
var selection: StageSelection = stage.get("_selection") as StageSelection
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
var actor_screen: Vector2 = _screen_at(stage, target.global_position)
_check(Rect2(Vector2.ZERO, Vector2(root.size)).has_point(actor_screen), "reported stickman is visible in the rendered stage viewport")
await _send_click(actor_screen)
await _wait_frames(4)
var layouts: Array = gizmos.get("_last_selected_name_badge_layouts")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == target, "production click selects break 2 in EDIT")
_check(layouts.size() == 1, "selected break 2 has one visible EDIT name badge")
if layouts.size() != 1:
return
var initial_slot: int = int((layouts[0] as Dictionary).get("anchor_slot", -1))
_check(initial_slot >= 0, "selected EDIT badge has a latched anchor slot")
await _save_capture_now("busy_scene_badge_selected")
# Move the actor through the production stage drag path and verify the badge
# stays on its chosen side both during the gesture and after release.
var initial_world_position: Vector2 = target.global_position
var drag_origin: Vector2 = _screen_at(stage, initial_world_position)
await _send_mouse_button(drag_origin, true, root.get_window_id(), drag_origin)
for delta: Vector2 in [Vector2(-16.0, 8.0), Vector2(-34.0, 14.0), Vector2(-52.0, 18.0)]:
var drag_position: Vector2 = drag_origin + delta
_send_mouse_motion(drag_position, root.get_window_id(), drag_position, MOUSE_BUTTON_MASK_LEFT)
await _wait_process_frames(2)
layouts = gizmos.get("_last_selected_name_badge_layouts")
_check(layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge keeps its anchor while the busy.scene actor moves")
var drag_end: Vector2 = drag_origin + Vector2(-52.0, 18.0)
await _send_mouse_button(drag_end, false, root.get_window_id(), drag_end)
await _wait_frames(4)
layouts = gizmos.get("_last_selected_name_badge_layouts")
_check(gizmos.is_dragging() == false and layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge keeps its anchor after releasing the actor")
_check(not target.global_position.is_equal_approx(initial_world_position), "production drag actually moves the busy.scene actor")
await _save_capture_now("busy_scene_badge_after_move_release")
# Exercise layout recomputation at another camera zoom while keeping the
# selected actor centered, then use a real middle-button drag to pan.
var original_zoom: Vector2 = camera.zoom
camera.zoom = original_zoom * 1.1
await _wait_frames(4)
layouts = gizmos.get("_last_selected_name_badge_layouts")
_check(layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge anchor survives camera zoom")
_check(Rect2(Vector2.ZERO, Vector2(root.size)).has_point(_screen_at(stage, target.global_position)), "zoomed busy.scene actor stays in view")
camera.zoom = original_zoom
await _wait_frames(4)
await _save_capture_now("busy_scene_badge_after_zoom")
var camera_before_pan: Vector2 = camera.position
var pan_origin: Vector2 = Vector2(float(root.size.x) * 0.48, float(root.size.y) * 0.55)
var pan_end: Vector2 = pan_origin + Vector2(32.0, 16.0)
await _send_middle_button(pan_origin, true)
_send_mouse_motion(pan_end, root.get_window_id(), pan_end, MOUSE_BUTTON_MASK_MIDDLE)
await _wait_process_frames(3)
await _send_middle_button(pan_end, false)
await _wait_frames(4)
layouts = gizmos.get("_last_selected_name_badge_layouts")
_check(not camera.position.is_equal_approx(camera_before_pan), "middle-button drag pans the production Stage camera")
_check(layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge keeps its anchor after camera pan")
_check(Rect2(Vector2.ZERO, Vector2(root.size)).has_point(_screen_at(stage, target.global_position)), "panned busy.scene actor stays in view")
await _save_capture_now("busy_scene_badge_after_pan")
func _run_mode_overlay_and_names_slice(stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView) -> void:
var move_action: Dictionary = {
"action_id": "edit-name-badge-move",
"type": "move_to",
"style": "walk",
"destination": {"kind": "place", "position": Vector2(260.0, -100.0)},
}
_check(character.set_action_plan([{"actions": [move_action]}]), "visual fixture seeds an authored Move route for mode visibility checks")
character_view.refresh_plan()
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos
var selection: StageSelection = stage.get("_selection") as StageSelection
stage.call("_set_scene_dirty", false)
var edit_button: Button = _find_button_containing(stage, "Edit")
_check(edit_button != null, "EDIT mode control is available for the visibility slice")
if edit_button == null:
return
await _click_control(edit_button)
await _wait_frames(4)
_check(int(stage.get("current_mode")) == SandboxStage.StageMode.EDIT, "pointer activation switches from DIRECT to EDIT")
_check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "EDIT hides plan overlays and their hit targets when nothing is selected")
await _save_capture_now("phase20b_edit_empty_plan_hidden")
await _send_click(_screen_at(stage, character.global_position))
await _wait_frames(4)
var single_layouts: Array = gizmos.get("_last_selected_name_badge_layouts")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "production stage click selects the named stickman in EDIT")
_check(single_layouts.size() == 1 and (single_layouts[0] as Dictionary).get("node") == character, "one selected stickman receives one stage-name badge")
_check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "selected EDIT actor shows its name badge without revealing its plan route")
if single_layouts.size() == 1:
var character_badge: Dictionary = single_layouts[0]
var badge_rect: Rect2 = character_badge.get("screen_rect", Rect2())
var actor_bounds: Rect2 = character_badge.get("bounds_screen", Rect2())
_check(String(character_badge.get("stage_name", "")) == character.stage_name and String(character_badge.get("display_name", "")).ends_with("…"), "long actor stage name is elided while retaining its full source value")
_check(not badge_rect.intersects(actor_bounds), "actor badge stays clear of the actor's visible bounds")
await _save_capture_now("phase20b_edit_single_name_badge")
selection.clear_selection()
await _wait_frames(3)
var drag_origin: Vector2 = _screen_at(stage, character.global_position)
var drag_origin_world: Vector2 = character.global_position
await _send_mouse_button(drag_origin, true, root.get_window_id(), drag_origin)
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character and gizmos.is_dragging(), "pressing an unselected actor selects it and begins the production translate gesture")
await _wait_frames(2)
var drag_layouts: Array = gizmos.get("_last_selected_name_badge_layouts")
var drag_offsets_stable: bool = drag_layouts.size() == 1
var initial_badge_offset: Vector2 = Vector2.INF
if drag_layouts.size() == 1:
var initial_badge_layout: Dictionary = drag_layouts[0]
var initial_badge_rect: Rect2 = initial_badge_layout.get("screen_rect", Rect2())
var initial_actor_bounds: Rect2 = initial_badge_layout.get("bounds_screen", Rect2())
initial_badge_offset = initial_badge_rect.position - initial_actor_bounds.position
for drag_delta: Vector2 in [Vector2(-24.0, 8.0), Vector2(-52.0, 16.0), Vector2(-80.0, 4.0), Vector2(-44.0, -8.0)]:
var drag_position: Vector2 = drag_origin + drag_delta
_send_mouse_motion(drag_position, root.get_window_id(), drag_position, MOUSE_BUTTON_MASK_LEFT)
await _wait_frames(2)
drag_layouts = gizmos.get("_last_selected_name_badge_layouts")
if drag_layouts.size() != 1:
drag_offsets_stable = false
continue
var drag_layout: Dictionary = drag_layouts[0]
var current_badge_rect: Rect2 = drag_layout.get("screen_rect", Rect2())
var current_actor_bounds: Rect2 = drag_layout.get("bounds_screen", Rect2())
var current_offset: Vector2 = current_badge_rect.position - current_actor_bounds.position
if not current_offset.is_equal_approx(initial_badge_offset):
drag_offsets_stable = false
_check(gizmos.is_dragging() and not character.global_position.is_equal_approx(drag_origin_world), "production pointer motion moves the actor while the gesture stays active")
_check(drag_offsets_stable, "EDIT name badge keeps its starting side and offset throughout the move")
await _save_capture_now("phase20b_edit_drag_name_badge_stable")
var drag_end: Vector2 = drag_origin + Vector2(-44.0, -8.0)
await _send_mouse_button(drag_end, false, root.get_window_id(), drag_end)
await _wait_frames(3)
_check(not gizmos.is_dragging(), "releasing the pointer ends the translate gesture")
character.global_position = drag_origin_world
stage.call("_rebuild_grid_cells")
await _wait_frames(2)
await _send_shift_click(_screen_at(stage, prop.global_position))
await _wait_frames(4)
var multi_layouts: Array = gizmos.get("_last_selected_name_badge_layouts")
_check(selection.get_selected().size() == 2 and selection.get_selected().has(character) and selection.get_selected().has(prop), "Shift-click creates a mixed stickman-and-prop selection")
_check(multi_layouts.size() == 2, "multi-selection shows a name badge for every selected named object")
if multi_layouts.size() == 2:
var first_badge: Rect2 = (multi_layouts[0] as Dictionary).get("screen_rect", Rect2())
var second_badge: Rect2 = (multi_layouts[1] as Dictionary).get("screen_rect", Rect2())
_check(not first_badge.intersects(second_badge), "multi-selection name badges move apart to avoid overlap")
var badge_obstacles: Array = gizmos.get("_selected_name_badge_obstacles")
var badges_clear_of_ui: bool = true
for layout_value: Variant in multi_layouts:
var layout: Dictionary = layout_value as Dictionary
var layout_rect: Rect2 = layout.get("screen_rect", Rect2())
for obstacle_value: Variant in badge_obstacles:
if layout_rect.intersects(obstacle_value as Rect2):
badges_clear_of_ui = false
_check(badges_clear_of_ui, "multi-selection badges stay clear of the inspector and Stage chrome")
_check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "selected EDIT objects still show no action-plan routes or hits")
await _save_capture_now("phase20b_edit_multi_name_badges")
gizmos.set_candidate_preview(prop)
await _wait_frames(4)
_check((gizmos.get("_last_selected_name_badge_layouts") as Array).is_empty(), "Objects here candidate preview suppresses persistent selection name badges")
await _save_capture_now("phase20b_edit_candidate_preview_badge_suppressed")
gizmos.set_candidate_preview(null)
await _wait_frames(3)
var direct_button: Button = _find_button_containing(stage, "Direct")
_check(direct_button != null, "DIRECT mode control is available after the EDIT badge checks")
if direct_button == null:
return
await _click_control(direct_button)
await _wait_frames(4)
_check(selection.get_selected().is_empty(), "switching to DIRECT clears EDIT multi-selection")
_check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "DIRECT with no actor selected hides plan markers and their hit targets")
await _save_capture_now("phase20b_direct_empty_plan_hidden")
await _send_click(_screen_at(stage, character.global_position))
await _wait_frames(4)
_check(visuals.plan_overlay_visible and visuals.get("_plan_spotlight_rig") == character, "selecting one actor in DIRECT restores its Step Spotlight")
var candidate_destination: Dictionary = {"kind": "place", "position": Vector2(360.0, -60.0), "label": "Place 2"}
visuals.set_plan_candidate(character, String(move_action.get("action_id", "")), candidate_destination, true, Vector2(360.0, -60.0))
selection.clear_selection()
await _wait_frames(4)
_check(not visuals.plan_overlay_visible and visuals.get("_plan_candidate_rig") == character, "clearing selection during retarget capture hides normal plans but keeps the candidate attached")
_check(visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "faint capture routes remain non-interactive while selection is absent")
await _save_capture_now("phase20b_direct_capture_without_selection")
visuals.clear_plan_candidate()
func _run_compact_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, card: StageInspectorPanel) -> void:
var plan: Array[Dictionary] = []
for index: int in 8:
if index == 0:
plan.append({"actions": [
{"action_id": "fixture-move-0", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(180.0, -50.0)}},
{"action_id": "fixture-say-0", "type": "speak", "text": "Fetch the ball!", "duration": 1.8}
]})
elif index == 2:
plan.append({"actions": [{"action_id": "fixture-say-long", "type": "speak", "text": "We are just walking around to see what happens. Who knows!?", "duration": 5.1}]})
else:
plan.append({"actions": [{"action_id": "fixture-wait-%d" % index, "type": "wait", "duration": 0.5}]})
_check(character.set_action_plan(plan), "compact visual-only fixture seeds eight plan steps through the rig plan API")
character_view.refresh_plan()
root.size = Vector2i(920, 560)
await _wait_process_frames(6)
var quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control
var body_scroll: ScrollContainer = card.find_child("InspectorBodyScroll", true, false) as ScrollContainer
var card_rect: Rect2 = card.get_global_rect()
var selection: StageSelection = stage.get("_selection") as StageSelection
root.size = Vector2i(547, 626)
await _wait_process_frames(6)
var screenshot_size_rect: Rect2 = card.get_global_rect()
var stage_chrome: StageChrome = stage.get("_chrome") as StageChrome
var timestamp_badge: Control = stage_chrome.get_timestamp_badge() if stage_chrome != null else null
var timestamp_right: float = timestamp_badge.get_global_rect().end.x if timestamp_badge != null else -1.0
print("PHASE20B_GUI_LAYOUT_547 card=%s min=%s quick_min=%s title_min=%s" % [str(screenshot_size_rect), str(card.get_combined_minimum_size()), str(quick_row.get_combined_minimum_size()) if quick_row != null else "missing", str(card.find_child("InspectorTitle", true, false).get_combined_minimum_size()) if card.find_child("InspectorTitle", true, false) != null else "missing"])
_check(card.visible and screenshot_size_rect.position.x >= 0.0 and screenshot_size_rect.end.x <= 547.0 - 12.0 and screenshot_size_rect.size.x <= 380.0 and absf(screenshot_size_rect.end.x - timestamp_right) <= 1.0, "547 px resize aligns the inspector with the clock and leaves the 12 px frame margin")
await _save_capture_now("inspector_margin_at_547x626")
root.size = Vector2i(920, 560)
await _wait_process_frames(6)
card_rect = card.get_global_rect()
_check(card.visible and card_rect.position.x >= 0.0 and card_rect.end.x <= 920.0 and card_rect.end.y <= 560.0, "920×560 keeps the selected inspector within the viewport")
var resized_timestamp_right: float = timestamp_badge.get_global_rect().end.x if timestamp_badge != null else -1.0
_check(card_rect.size.x <= 380.0 and absf(card_rect.end.x - resized_timestamp_right) <= 1.0, "920×560 resize keeps the inspector's right edge aligned with the clock")
_check(quick_row != null and quick_row.get_global_rect().position.y >= card_rect.position.y and quick_row.get_global_rect().end.y < card_rect.end.y, "920×560 keeps pinned quick actions reachable")
_check(body_scroll != null and body_scroll.get_v_scroll_bar().max_value > 0.0, "eight-step plan scrolls within the inspector body")
var compact_step_one: Control = character_view.find_child("ActionStep_1", true, false) as Control
var compact_move: Button = _find_button_containing(compact_step_one, "Walk to Place 1")
var compact_say: Button = _find_button_containing(compact_step_one, "Say")
var compact_step_style: StyleBoxFlat = compact_step_one.get_theme_stylebox("panel") as StyleBoxFlat if compact_step_one != null else null
var compact_grouped_step_visible: bool = compact_step_one != null and body_scroll != null and body_scroll.get_global_rect().encloses(compact_step_one.get_global_rect()) and compact_move != null and compact_say != null and body_scroll.get_global_rect().encloses(compact_move.get_global_rect()) and body_scroll.get_global_rect().encloses(compact_say.get_global_rect()) and compact_step_style != null and compact_step_style.border_color.is_equal_approx(Color("#FFB300"))
_check(compact_grouped_step_visible, "compact DIRECT selection reveals the full selected grouped step with both twin-rail action rows")
_check(compact_move != null and compact_say != null and compact_move.custom_minimum_size.y == 48.0 and compact_say.custom_minimum_size.y == 48.0, "compact grouped action rows retain the mockup's 48 px targets")
var long_say_step: Control = character_view.find_child("ActionStep_3", true, false) as Control
var long_say_button: Button = character_view.find_child("ActionSummary_fixture-say-long", true, false) as Button
var long_say_label: Label = character_view.find_child("ActionSummaryText_fixture-say-long", true, false) as Label
_check(long_say_label != null and long_say_label.autowrap_mode == TextServer.AUTOWRAP_OFF and long_say_label.max_lines_visible == 1 and long_say_button != null and long_say_button.text.length() <= 24 and long_say_button.tooltip_text.contains("We are just walking around to see what happens. Who knows!?"), "long Say stays one line with ellipsis while its row tooltip retains the full speech")
_check(long_say_step != null and long_say_button != null and is_equal_approx(long_say_button.size.y, 48.0), "long Say does not make its action row taller")
var later_wait_summary: Button = _find_button_with_text(character_view.find_child("ActionStep_2", true, false), "Wait 0.5 seconds")
_check(later_wait_summary != null, "plan summaries spell out wait durations in seconds")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "viewport resize preserves the selected actor")
var compact_layout_valid: bool = card.visible and card_rect.position.x >= 0.0 and card_rect.end.x <= 920.0 and card_rect.end.y <= 560.0 and quick_row != null and quick_row.visible and quick_row.get_global_rect().position.y >= card_rect.position.y and quick_row.get_global_rect().end.y < card_rect.end.y and body_scroll != null and body_scroll.get_v_scroll_bar().max_value > 0.0 and compact_grouped_step_visible and later_wait_summary != null and selection.get_selected().size() == 1 and selection.get_selected()[0] == character
if compact_layout_valid:
await _save_capture_now("phase20b_final_compact_920x560_eight_steps")
await _save_capture_now("phase20b_final_compact_920x560_grouped_step_one")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_compact_920x560_eight_steps reason=compact_layout_assertions_failed")
var fourth_step_heading: Label = _find_label_exact_text(character_view.find_child("ActionStep_4", true, false), "Step 4")
var fourth_step_card: Control = character_view.find_child("ActionStep_4", true, false) as Control
if body_scroll != null and fourth_step_heading != null and fourth_step_card != null:
for tick: int in 40:
var step_rect: Rect2 = fourth_step_card.get_global_rect()
var scroll_rect: Rect2 = body_scroll.get_global_rect()
if scroll_rect.encloses(step_rect):
break
await _send_wheel_down(scroll_rect.get_center())
await _wait_process_frames(1)
fourth_step_heading = _find_label_exact_text(character_view.find_child("ActionStep_4", true, false), "Step 4")
fourth_step_card = character_view.find_child("ActionStep_4", true, false) as Control
if tick % 4 == 3:
print("PHASE20B_GUI_SCROLL tick=%d value=%.1f max=%.1f step4_card=%s viewport=%s" % [tick + 1, body_scroll.scroll_vertical, body_scroll.get_v_scroll_bar().max_value, str(fourth_step_card.get_global_rect()) if fourth_step_card != null else "missing", str(body_scroll.get_global_rect())])
var final_step_rect: Rect2 = fourth_step_card.get_global_rect() if fourth_step_card != null else Rect2()
_check(fourth_step_heading != null and fourth_step_card != null and body_scroll.get_global_rect().encloses(final_step_rect), "production mouse-wheel scrolling brings the full Step 4 card into the 920×560 body viewport")
if fourth_step_heading != null:
root.grab_focus()
await _wait_process_frames(1)
var heading_rect_before_hover: Rect2 = fourth_step_heading.get_global_rect()
await _send_mouse_motion(fourth_step_heading.get_screen_position() + fourth_step_heading.size * 0.5)
await _wait_process_frames(2)
var hovered: Control = root.gui_get_hovered_control()
var heading_rect_after_hover: Rect2 = fourth_step_heading.get_global_rect()
print("PHASE20B_GUI_STEP4_HOVER target=%s hovered=%s" % [str(heading_rect_after_hover), hovered.name if hovered != null else "none"])
_check(fourth_step_heading.tooltip_text.is_empty() and heading_rect_before_hover.position.is_equal_approx(heading_rect_after_hover.position) and heading_rect_before_hover.size.is_equal_approx(heading_rect_after_hover.size), "hovering Step 4 text leaves it stationary and shows no tooltip")
await _wait_process_frames(4)
if fourth_step_card != null:
fourth_step_card.grab_focus()
await _send_key(KEY_ENTER)
await _wait_process_frames(4)
var selected_step_four: Label = _find_label_exact_text(character_view.find_child("ActionStep_4", true, false), "Step 4")
var selected_step_card: Control = character_view.find_child("ActionStep_4", true, false) as Control
var selected_step_rect: Rect2 = selected_step_card.get_global_rect() if selected_step_card != null else Rect2()
var selected_step_index: int = int(character_view.get("_selected_step_index"))
var focus_owner: Control = root.gui_get_focus_owner() as Control
var focus_text: String = focus_owner.name if focus_owner != null else "no focus"
var step_four_focused: bool = selected_step_card != null and selected_step_card.has_focus() and selected_step_index == 3
var selected_style: StyleBoxFlat = selected_step_card.get_theme_stylebox("panel") as StyleBoxFlat if selected_step_card != null else null
var direct_selected: bool = selected_style != null and selected_style.border_color.is_equal_approx(Color("#FFB300")) and selected_style.border_width_left == 3
var later_arrow: Button = selected_step_card.find_child("MoveStepLater_4", true, false) as Button if selected_step_card != null else null
var later_arrow_rect: Rect2 = later_arrow.get_global_rect() if later_arrow != null else Rect2()
print("PHASE20B_GUI_SCROLL selected_step=%s focused=%s selected_index=%d direct_amber=%s value=%.1f" % [str(selected_step_rect), focus_text, selected_step_index, str(direct_selected), body_scroll.scroll_vertical if body_scroll != null else -1.0])
_check(selected_step_four != null and selected_step_card != null and body_scroll.get_global_rect().encloses(selected_step_rect) and step_four_focused and direct_selected, "full Step 4 card stays visible with its DIRECT amber selection and keyboard focus")
_check(later_arrow != null and later_arrow_rect.end.x <= selected_step_rect.end.x - 8.0, "compact Step 4 down arrow stays inset from the selected card border")
_check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "scrolling and selecting Step 4 do not dismiss the plan actor")
if step_four_focused and direct_selected and body_scroll.get_global_rect().encloses(selected_step_rect) and selection.get_selected().size() == 1 and selection.get_selected()[0] == character:
await _save_capture_now("phase20b_final_compact_920x560_step_four_selected")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_compact_920x560_step_four_selected reason=step_focus_assertion_failed")
var focused_long_say_card: Control = character_view.find_child("ActionStep_3", true, false) as Control
var focused_long_say_button: Button = character_view.find_child("ActionSummary_fixture-say-long", true, false) as Button
if focused_long_say_card != null:
character_view.call("_select_plan_step", 2, true)
await _wait_process_frames(4)
var focused_long_say_row_visible: bool = focused_long_say_button != null and body_scroll != null and body_scroll.get_global_rect().encloses(focused_long_say_button.get_global_rect())
_check(int(character_view.get_selected_step_index()) == 2 and focused_long_say_row_visible and is_equal_approx(focused_long_say_button.size.y, 48.0), "selecting a long Say step keeps its row visible at one line")
if int(character_view.get_selected_step_index()) == 2 and focused_long_say_row_visible:
await _save_capture_now("phase20b_long_say_single_line")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_long_say_single_line reason=selected_long_say_visibility_failed")
func _run_place_capture_slice(stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView, quick_row: Control) -> void:
var initial_place: Vector2 = Vector2(-500.0, 0.0)
var move: Dictionary = await _author_move_through_ui(stage, character, quick_row, "place", initial_place)
_check(not move.is_empty(), "place capture slice starts from a production-authored Move")
if move.is_empty():
return
var move_id: String = String(move.get("action_id", ""))
stage.call("_set_scene_dirty", false)
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
var committed_projection: Dictionary = _projection_for_action(visuals._plan_projection(character), move_id) if visuals != null else {}
var committed_flag_position: Vector2 = committed_projection.get("hit_position", Vector2.INF)
await _send_click(_screen_at(stage, committed_flag_position))
await _wait_process_frames(2)
var options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control
await _click_control(_find_button_with_text(options, "Edit action"))
var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
await _click_control(_find_button_containing(action_editor, "place"))
var bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar
var prompt: Label = bar.find_child("Prompt", true, false) as Label if bar != null else null
var cancel: Button = bar.find_child("Action_cancel_plan_capture", true, false) as Button if bar != null else null
var candidate_world: Vector2 = Vector2(-400.0, 0.0)
var candidate_screen: Vector2 = _screen_at(stage, candidate_world)
root.grab_focus()
_send_mouse_motion(candidate_screen)
await _wait_process_frames(3)
var cursor_prompt: Label = stage.get("_tooltip") as Label
var placement_overlay: StagePlacementOverlay = stage.get("_placement_overlay") as StagePlacementOverlay
var retarget_capture_valid: bool = int(stage.get("_capture_kind")) == PLAN_PLACE and bar != null and bar.visible and prompt != null and not prompt.visible and cancel != null and cancel.visible and cancel.size.x >= 44.0 and cancel.size.y >= 44.0 and action_editor != null and not action_editor.visible and cursor_prompt != null and cursor_prompt.visible and cursor_prompt.text == "Choose a new place or prop for Milo." and placement_overlay != null and placement_overlay.action_visible and not placement_overlay.action_show_trajectory
_check(retarget_capture_valid, "retarget capture keeps the cursor marker and instruction while showing a fixed Cancel-only control")
_check(prompt != null and not prompt.visible and cancel != null and cancel.visible and cancel.size.x >= 44.0 and cancel.size.y >= 44.0, "capture control removes the duplicate prompt and keeps Cancel touch-sized")
var capture_shell: StyleBoxFlat = bar.get_theme_stylebox("panel") as StyleBoxFlat if bar != null else null
_check(capture_shell != null and is_zero_approx(capture_shell.bg_color.a) and capture_shell.get_border_width(SIDE_LEFT) == 0, "Cancel button is shown without the old instruction-panel shell")
if retarget_capture_valid:
await _save_capture_marker_preview(stage, placement_overlay, cursor_prompt, candidate_world, candidate_screen, "phase20b_final_place_capture_preview")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_place_capture_preview reason=production_capture_state_invalid")
var cancel_position_before: Vector2 = cancel.get_global_rect().position if cancel != null else Vector2.ZERO
var candidate_world_after_move: Vector2 = Vector2(-300.0, 0.0)
var candidate_screen_after_move: Vector2 = _screen_at(stage, candidate_world_after_move)
_send_mouse_motion(candidate_screen_after_move)
await _wait_process_frames(3)
var candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer")
var candidate_destination: Dictionary = visuals.get("_plan_candidate_destination")
var candidate_label: String = String(visuals.get("_plan_candidate_movement_label"))
var candidate_target: Variant = candidate_destination.get("position", Vector2.INF)
var candidate_anchor_data: Dictionary = visuals.call("_queue_anchor", character)
var candidate_anchor: Vector2 = candidate_anchor_data.get("ground", candidate_anchor_data.get("feet", Vector2.INF))
var grounded_candidate: Vector2 = visuals.call("_project_move_display_position", candidate_target, candidate_anchor.y) if candidate_target is Vector2 and candidate_anchor.is_finite() else Vector2.INF
var retarget_candidate_valid: bool = retarget_capture_valid and candidate_pointer.is_finite() and candidate_pointer.distance_to(candidate_world_after_move) < 0.1 and bool(visuals.get("_plan_candidate_valid"))
_check(retarget_candidate_valid, "production pointer motion previews a valid new place while the saved route remains intact")
_check(candidate_label == "Walk" and grounded_candidate.is_finite() and is_equal_approx(grounded_candidate.y, candidate_anchor.y), "candidate route uses the Walk label and a grounded horizontal endpoint")
_check(cancel != null and cancel.get_global_rect().position.is_equal_approx(cancel_position_before), "Cancel remains fixed while the cursor moves")
_check(String(((character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == move_id and not bool(stage.get("_dirty")), "candidate preview keeps the old action ID and does not dirty the authored destination")
if retarget_candidate_valid:
await _save_capture_marker_preview(stage, placement_overlay, cursor_prompt, candidate_world_after_move, candidate_screen_after_move, "phase20b_final_grounded_walk_candidate")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_grounded_walk_candidate reason=valid_candidate_state_not_reached")
if cancel != null:
await _click_control(cancel)
await _wait_process_frames(3)
var current_action: Dictionary = (character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary
_check(not bar.visible and not action_editor.visible and int(stage.get("_capture_kind")) == 0 and current_action == move and String(current_action.get("action_id", "")) == move_id and not bool(stage.get("_dirty")), "visible Cancel discards the retarget draft without changing the plan")
await _save_capture_now("phase20b_final_cancel_button_closed")
options = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control
await _click_control(_find_button_with_text(options, "Edit action"))
await _click_control(_find_button_containing(action_editor, "place"))
await _wait_process_frames(2)
await _send_key(KEY_ESCAPE)
await _wait_process_frames(2)
current_action = (character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary
_check(not bar.visible and int(stage.get("_capture_kind")) == 0 and current_action == move and not bool(stage.get("_dirty")), "Escape remains a working shortcut for place-capture cancellation")
stage.set("_context_rig", character)
stage.call("_on_action_popup_id_pressed", SandboxStage.ACT_PICK_UP_PROP)
await _wait_process_frames(3)
var pickup_overlay: StagePlacementOverlay = stage.get("_placement_overlay") as StagePlacementOverlay
var pickup_target_preview_valid: bool = int(stage.get("_capture_kind")) == SandboxStage.CaptureKind.PROP and pickup_overlay != null and pickup_overlay.action_visible and not pickup_overlay.action_show_trajectory
_check(pickup_target_preview_valid, "Pick Up Prop capture shows only a cursor marker, without a movement-looking trajectory")
if pickup_target_preview_valid and prop != null:
var pickup_prompt: Label = stage.get("_tooltip") as Label
await _save_capture_marker_preview(stage, pickup_overlay, pickup_prompt, prop.global_position, _screen_at(stage, prop.global_position), "phase20b_final_pickup_prop_marker_only")
await _send_key(KEY_ESCAPE)
await _wait_process_frames(2)
_check(int(stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and not pickup_overlay.action_visible, "Escape clears the Pick Up Prop target marker")
func _run_prop_capture_slice(stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView, quick_row: Control) -> void:
var spawner: StageSpawner = stage.get("_spawner") as StageSpawner
var second_prop: PropBlock = spawner.spawn_prop_asset("crate", prop.global_position + Vector2(130.0, -90.0))
_check(second_prop != null, "prop capture scenario places a second instance of the same prop asset")
if second_prop == null:
return
stage.call("_finalize_placed_node", second_prop)
_make_small_prop(second_prop)
var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity
var renamed: Dictionary = identity.rename(second_prop, "Green Fetch Ball", stage.get_node("World") as Node2D)
_check(bool(renamed.get("ok", false)), "second prop receives a distinct visible stage name")
await _wait_process_frames(4)
var move: Dictionary = await _author_move_through_ui(stage, character, quick_row, "prop", second_prop.global_position)
if move.is_empty():
return
var exact_prop_target: bool = String((move.get("destination", {}) as Dictionary).get("target_prop_id", "")) == second_prop.stage_object_id
_check(exact_prop_target, "production target capture stores the exact selected second prop ID")
var move_id: String = String(move.get("action_id", ""))
var summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Green Fetch Ball")
var prop_move_valid: bool = exact_prop_target and summary != null and summary.is_visible_in_tree() and summary.text.contains("Green Fetch Ball")
_check(prop_move_valid, "Step Stack summary displays the selected prop's current stage name")
if prop_move_valid:
await _save_capture_now("phase20b_final_exact_prop_move")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_exact_prop_move reason=prop_route_or_summary_invalid")
stage.call("_set_scene_dirty", false)
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
var projection: Dictionary = _projection_for_action(visuals._plan_projection(character), move_id)
_check(String(projection.get("label", "")) == "Green Fetch Ball", "route projection uses the exact selected prop's stage name")
var prop_endpoint: Vector2 = projection.get("end", second_prop.global_position)
await _send_click(_screen_at(stage, prop_endpoint))
await _wait_process_frames(3)
var options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control
await _click_control(_find_button_with_text(options, "Edit action"))
var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
await _click_control(_find_button_containing(action_editor, "place"))
var bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar
var prompt: Label = bar.find_child("Prompt", true, false) as Label if bar != null else null
var free_place: Vector2 = prop.global_position + Vector2(54.0, 0.0)
var free_place_screen: Vector2 = _screen_at(stage, free_place)
root.grab_focus()
_send_mouse_motion(free_place_screen)
await _wait_process_frames(3)
var free_place_prompt_valid: bool = int(stage.get("_capture_kind")) == PLAN_PLACE and bar != null and bar.visible and prompt != null and prompt.text == "Choose a new place or prop for Milo." and not action_editor.visible
_check(free_place_prompt_valid, "Edit action exposes the production place capture prompt")
var candidate: Vector2 = visuals.get("_plan_candidate_pointer")
var free_place_candidate_valid: bool = free_place_prompt_valid and candidate.is_finite() and candidate.distance_to(free_place) < 0.1 and bool(visuals.get("_plan_candidate_valid"))
_check(free_place_candidate_valid, "candidate pointer reaches a valid free place beside the named prop")
if free_place_candidate_valid:
await _save_capture_now("phase20b_final_free_place_near_prop_candidate")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_free_place_near_prop_candidate reason=valid_candidate_state_not_reached")
await _send_click(free_place_screen)
await _wait_process_frames(4)
await _click_control(_find_button_with_text(action_editor, "Done"))
await _wait_process_frames(5)
var plan: Array[Dictionary] = character.get_action_plan()
var retargeted: Dictionary = (plan[0]["actions"] as Array)[0] as Dictionary
var destination: Dictionary = retargeted.get("destination", {})
_check(String(retargeted.get("action_id", "")) == move_id and String(destination.get("kind", "")) == "place", "Move Edit action to a nearby free point preserves the action ID and place intent")
var place_projection: Dictionary = _projection_for_action(visuals._plan_projection(character), move_id)
_check(String(place_projection.get("label", "")).contains("Place 1 · by Blue Fetch Ball"), "nearby free endpoint stays Place 1 with prop context in the route label")
await _save_capture_now("phase20b_final_free_place_by_prop_committed")
func _author_move_through_ui(stage: SandboxStage, character: StickmanRig, quick_row: Control, target_kind: String, target_world: Vector2) -> Dictionary:
stage.call("_set_scene_dirty", false)
var move_button: Button = _find_button_with_text(quick_row, "🚶 Move")
await _click_control(move_button)
var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor
var target_choice_text: String = "Choose a prop" if target_kind == "prop" else "Choose a place"
var target_choice: Button = _find_button_containing(action_editor, target_choice_text)
_check(action_editor != null and action_editor.visible and target_choice != null, "Quick Move opens the production action editor with %s" % target_choice_text)
if target_choice == null:
return {}
await _click_control(target_choice)
var expected_capture: int = PLAN_PROP if target_kind == "prop" else PLAN_PLACE
_check(int(stage.get("_capture_kind")) == expected_capture, "visible %s choice enters its production stage capture" % target_kind)
var target_screen: Vector2 = _screen_at(stage, target_world)
root.grab_focus()
_send_mouse_motion(target_screen)
await _wait_process_frames(2)
var capture_bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar
var capture_prompt: Label = capture_bar.find_child("Prompt", true, false) as Label if capture_bar != null else null
var capture_cancel: Button = capture_bar.find_child("Action_cancel_plan_capture", true, false) as Button if capture_bar != null else null
var cursor_prompt: Label = stage.get("_tooltip") as Label
var expected_cursor_prompt: String = "Choose a place for Milo." if target_kind == "place" else "Choose the prop Milo will walk to."
var placement_overlay: StagePlacementOverlay = stage.get("_placement_overlay") as StagePlacementOverlay
var capture_layout_valid: bool = int(stage.get("_capture_kind")) == expected_capture and action_editor != null and not action_editor.visible and capture_bar != null and capture_bar.visible and capture_prompt != null and not capture_prompt.visible and capture_cancel != null and capture_cancel.visible and capture_cancel.size.x >= 44.0 and capture_cancel.size.y >= 44.0 and cursor_prompt != null and cursor_prompt.visible and cursor_prompt.text == expected_cursor_prompt and placement_overlay != null and placement_overlay.action_visible and not placement_overlay.action_show_trajectory
_check(capture_layout_valid, "production %s capture shows a cursor instruction, marker-only preview, and touch-sized Cancel" % target_kind)
if not capture_layout_valid:
return {}
var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals
var candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer") if visuals != null else Vector2.INF
var candidate_valid: bool = visuals != null and candidate_pointer.is_finite() and candidate_pointer.distance_to(target_world) < 0.1 and bool(visuals.get("_plan_candidate_valid"))
_check(candidate_valid, "actual pointer motion previews a valid requested %s target" % target_kind)
if candidate_valid:
await _save_capture_marker_preview(stage, placement_overlay, cursor_prompt, target_world, target_screen, "phase20b_final_%s_candidate_preview" % target_kind)
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_%s_candidate_preview reason=valid_candidate_state_not_reached" % target_kind)
await _send_click(target_screen)
await _wait_process_frames(5)
_check(action_editor.visible, "target selection returns to the production action editor for Done")
var done_button: Button = _find_button_with_text(action_editor, "Done")
_check(done_button != null and done_button.visible, "production Move draft exposes its labeled Done action")
if done_button == null:
return {}
await _click_control(done_button)
await _wait_process_frames(5)
var plan: Array[Dictionary] = character.get_action_plan()
if plan.is_empty():
_check(false, "production Move commit creates an ordered plan step")
return {}
var action_values: Array = plan[plan.size() - 1].get("actions", [])
if action_values.is_empty() or not action_values[0] is Dictionary:
_check(false, "production Move commit contains its action payload")
return {}
var action: Dictionary = (action_values[0] as Dictionary).duplicate(true)
_check(String(action.get("type", "")) == "move_to" and String((action.get("destination", {}) as Dictionary).get("kind", "")) == target_kind, "production Move commit preserves the selected %s destination kind" % target_kind)
_check(bool(stage.get("_dirty")), "valid production Move commit marks the scene dirty")
return action
func _make_small_prop(prop: PropBlock) -> void:
var points: PackedVector2Array = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)])
var shapes: Array[Dictionary] = [{
"shape_type": "polygon",
"points": points,
"closed": true,
"fill_color": "#E6A73B",
"outline_color": "#3A2A11",
"outline_width": 3.0,
}]
prop.set_custom_shapes(shapes, points)
func _step_has_action_type(step: Dictionary, action_type: String) -> bool:
for action_value: Variant in step.get("actions", []):
if action_value is Dictionary and String((action_value as Dictionary).get("type", "")) == action_type:
return true
return false
func _find_action_type(step: Dictionary, action_type: String) -> Dictionary:
for action_value: Variant in step.get("actions", []):
if action_value is Dictionary and String((action_value as Dictionary).get("type", "")) == action_type:
return (action_value as Dictionary).duplicate(true)
return {}
func _first_speech_text(action: Dictionary) -> String:
var bubbles: Variant = action.get("bubbles", [])
if not bubbles is Array or (bubbles as Array).is_empty() or not (bubbles as Array)[0] is Dictionary:
return ""
return String(((bubbles as Array)[0] as Dictionary).get("text", ""))
func _projection_for_action(projections: Array[Dictionary], action_id: String) -> Dictionary:
for projection: Dictionary in projections:
if String(projection.get("action_id", "")) == action_id:
return projection
return {}
func _action_id_for_type(step: Dictionary, action_type: String) -> String:
for action_value: Variant in step.get("actions", []):
if action_value is Dictionary and String((action_value as Dictionary).get("type", "")) == action_type:
return String((action_value as Dictionary).get("action_id", ""))
return ""
func _same_start_frame(events: Array[Dictionary], step_index: int) -> bool:
var frames: Array[int] = []
for event: Dictionary in events:
if int(event.get("step", -1)) == step_index:
frames.append(int(event.get("frame", -1)))
return frames.size() == 2 and frames[0] == frames[1]
func _start_frame_for_step(events: Array[Dictionary], step_index: int) -> int:
for event: Dictionary in events:
if int(event.get("step", -1)) == step_index:
return int(event.get("frame", -1))
return -1
func _end_frame_for_id(events: Array[Dictionary], action_id: String) -> int:
for event: Dictionary in events:
if String(event.get("id", "")) == action_id:
return int(event.get("frame", -1))
return -1
func _wait_until_actions_end(stage: SandboxStage, events: Array[Dictionary], timeout: float) -> void:
var elapsed: float = 0.0
while elapsed < timeout and events.size() < 4 and int(stage.get("_run_state")) == SandboxStage.RunState.RUNNING:
await create_timer(0.05).timeout
elapsed += 0.05
await _wait_frames(3)
func _find_button_with_text(parent: Node, value: String) -> Button:
if parent == null:
return null
for child: Node in parent.find_children("*", "Button", true, false):
var button: Button = child as Button
if button != null and button.text == value:
return button
return null
func _find_button_containing(parent: Node, value: String) -> Button:
if parent == null:
return null
for child: Node in parent.find_children("*", "Button", true, false):
var button: Button = child as Button
if button != null and button.text.contains(value):
return button
return null
func _find_button_with_tooltip(parent: Node, value: String) -> Button:
if parent == null:
return null
for child: Node in parent.find_children("*", "Button", true, false):
var button: Button = child as Button
if button != null and button.tooltip_text == value:
return button
return null
func _screen_at(stage: SandboxStage, world_position: Vector2) -> Vector2:
return stage.get_viewport().get_canvas_transform() * world_position
func _click_control(control: Control) -> void:
if control == null:
_check(false, "requested production UI control exists for pointer click")
return
if not control.is_visible_in_tree() or control.get_global_rect().size.x <= 0.0 or control.get_global_rect().size.y <= 0.0:
_check(false, "requested production UI control is visible and has a rendered hit area")
return
var viewport_position: Vector2 = control.get_global_rect().get_center()
var owner_window: Window = control.get_window()
var window_id: int = owner_window.get_window_id()
var screen_position: Vector2 = control.get_screen_position() + control.size * 0.5
var input_position: Vector2 = screen_position if owner_window.position != Vector2i.ZERO else viewport_position
_last_click_label = control.text if control is Button else control.name
_send_mouse_motion(input_position, window_id, screen_position)
await _wait_process_frames(1)
var owner_viewport: Viewport = control.get_viewport()
var hovered_control: Control = owner_viewport.gui_get_hovered_control() as Control
print("PHASE20B_GUI_HOVER target=%s viewport_position=%s screen_position=%s window=%d window_position=%s hovered=%s" % [control.name, str(input_position), str(screen_position), window_id, str(owner_window.position), hovered_control.name if hovered_control != null else "none"])
if hovered_control != control:
print("PHASE20B_GUI_HOVER_MISMATCH target=%s hovered=%s; button activation is checked after press/release" % [control.name, hovered_control.name if hovered_control != null else "none"])
if control is Button and ((control as Button).text.contains("Message") or (control as Button).text.contains("Choose a place") or (control as Button).text == "Step 4"):
print("PHASE20B_GUI_CONTROL_CLICK text=%s rect=%s viewport_position=%s screen_position=%s window_id=%d window_pos=%s root_window_id=%d" % [(control as Button).text, str(control.get_global_rect()), str(viewport_position), str(screen_position), window_id, str(owner_window.position), root.get_window_id()])
print("PHASE20B_GUI_CONTROL_STATE visible=%s inside=%s focus=%s hovered=%s mouse_filter=%d disabled=%s" % [str(control.is_visible_in_tree()), str(control.is_inside_tree()), str(control.has_focus()), str(root.gui_get_hovered_control()), control.mouse_filter, str((control as Button).disabled)])
await _send_click(input_position, window_id, screen_position)
_last_click_label = ""
func _send_click(position: Vector2, window_id: int = 0, global_position: Vector2 = Vector2.INF) -> void:
var trace_this_click: bool = _trace_next_click
_trace_next_click = false
var event_global_position: Vector2 = global_position if global_position.is_finite() else position
await _send_mouse_button(position, true, window_id, event_global_position)
if trace_this_click:
print("PHASE20B_TARGET_CLICK after_press capture=%d speech_pending=%s speech_hits=%s hovered=%s focus=%s" % [int(_debug_stage.get("_capture_kind")), str(_debug_stage.get("_speech_bubble_pointer_pending")), str(_debug_stage.get("_speech_bubble_pointer_hits")), str(root.gui_get_hovered_control()), str(root.gui_get_focus_owner())])
await _wait_process_frames(1)
if _capture_after_next_click:
_capture_after_next_click = false
var target_viewport_position: Vector2 = _screen_at(_debug_stage, Vector2(-20.0, 0.0))
var stage_window: Window = _debug_stage.get_window()
var target_screen_position: Vector2 = target_viewport_position + Vector2(stage_window.position)
await _send_mouse_button(position, false, window_id, event_global_position, target_viewport_position, target_screen_position)
var frame_started_msec: int = Time.get_ticks_msec()
var editor: ActionEditor = _debug_stage.get("_action_editor") as ActionEditor
var capture_bar: StageUseTargetCaptureBar = _debug_stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar
var prompt: Label = capture_bar.find_child("Prompt", true, false) as Label if capture_bar != null else null
var cancel_button: Button = capture_bar.find_child("Action_cancel_plan_capture", true, false) as Button if capture_bar != null else null
var visuals: StageDirectorVisuals = _debug_stage.get("_director_visuals") as StageDirectorVisuals
var character_view: StageCharacterSettingsView = _debug_stage.get("_character_settings_view") as StageCharacterSettingsView
var inspector: StageInspectorPanel = character_view.get_card() as StageInspectorPanel if character_view != null else null
var capture_kind_matches: bool = not paused and int(_debug_stage.get("_capture_kind")) == PLAN_PLACE and editor != null and not editor.visible
_check(capture_kind_matches, "post-click state enters place capture and closes the draft editor")
_check(capture_bar != null and capture_bar.visible and prompt != null and prompt.text == "Choose a place for Milo.", "production capture bar shows the concise place prompt")
_check(cancel_button != null and cancel_button.visible and cancel_button.size.x >= 44.0 and cancel_button.size.y >= 44.0, "capture Cancel remains visible at a touch-sized target")
_check(visuals != null and visuals.get("_plan_candidate_rig") != null, "live route candidate remains attached to the selected actor")
var inspector_clear: bool = capture_bar != null and inspector != null and not capture_bar.get_global_rect().intersects(inspector.get_global_rect())
if capture_bar != null and inspector != null:
_check(inspector_clear, "capture bar stays clear of the selected Step Stack inspector")
var prompt_layout_valid: bool = capture_bar != null and capture_bar.visible and prompt != null and prompt.text == "Choose a place for Milo." and cancel_button != null and cancel_button.visible and cancel_button.size.x >= 44.0 and cancel_button.size.y >= 44.0 and capture_bar.size.x <= 440.0 and capture_bar.size.y <= 160.0 and capture_bar.get_global_rect().position.y >= 64.0 and inspector_clear
if capture_bar != null:
var capture_rect: Rect2 = capture_bar.get_global_rect()
print("PHASE20B_GUI_CAPTURE_BAR rect=%s size=%s prompt=%s" % [str(capture_rect), str(capture_rect.size), prompt.text if prompt != null else "missing"])
_check(capture_rect.size.x <= 440.0 and capture_rect.size.y <= 160.0 and capture_rect.position.y >= 64.0, "place capture prompt stays compact and below the top toolbar")
if capture_kind_matches and prompt_layout_valid:
if OS.get_environment("PHASE20B_SETTLE_CAPTURE_FRAME") == "1":
await _wait_process_frames(2)
_save_capture_current_frame("phase20b_place_capture_entry_current_frame")
else:
print("PHASE20B_CAPTURE_SKIPPED name=phase20b_place_capture_entry_current_frame reason=capture_ui_assertions_failed")
if _skip_capture_hover:
print("PHASE20B_GUI_CAPTURE_ENTRY_ONLY: capture state, draft closure, prompt, Cancel, and layout assertions complete; candidate pointer remains unverified")
return
await _wait_process_frames(2)
var release_candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer") if visuals != null else Vector2.INF
var candidate_matches: bool = release_candidate_pointer.is_finite() and release_candidate_pointer.distance_to(Vector2(-20.0, 0.0)) < 0.1 and visuals != null and bool(visuals.get("_plan_candidate_valid"))
_check(candidate_matches, "post-release viewport motion updates the valid open-ground candidate before preview")
if capture_kind_matches and prompt_layout_valid and candidate_matches and visuals != null and visuals.get("_plan_candidate_rig") != null:
_save_capture_current_frame("wide_place_capture_prompt")
else:
print("PHASE20B_CAPTURE_SKIPPED name=wide_place_capture_prompt reason=production_capture_state_invalid")
var candidate_screen_position: Vector2 = _screen_at(_debug_stage, Vector2(-20.0, 0.0))
root.grab_focus()
_send_mouse_motion(candidate_screen_position)
print("PHASE20B_GUI_FRAME mouse_motion_sent window_focused=%s" % root.has_focus())
await process_frame
print("PHASE20B_GUI_FRAME after_motion_process_frame")
await _wait_process_frames(2)
print("PHASE20B_GUI_FRAME after_motion_process_frames")
var candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer") if visuals != null else Vector2.INF
var candidate_position_valid: bool = candidate_pointer.is_finite() and candidate_pointer.distance_to(Vector2(-20.0, 0.0)) < 0.1
var candidate_is_valid: bool = visuals != null and bool(visuals.get("_plan_candidate_valid"))
_check(candidate_position_valid, "actual viewport motion updates the live destination candidate")
_check(candidate_is_valid, "free open ground previews as a valid destination")
print("PHASE20B_RENDERED_STATE elapsed_msec=%d capture=%d bar_rect=%s inspector_rect=%s candidate=%s valid=%s focus=%s" % [Time.get_ticks_msec() - frame_started_msec, int(_debug_stage.get("_capture_kind")), str(capture_bar.get_global_rect()) if capture_bar != null else "missing", str(inspector.get_global_rect()) if inspector != null else "missing", str(candidate_pointer), bool(visuals.get("_plan_candidate_valid")) if visuals != null else false, str(root.gui_get_focus_owner())])
if capture_kind_matches and prompt_layout_valid and candidate_position_valid and candidate_is_valid:
await _save_capture_after_process_frames("wide_place_capture_bar")
else:
print("PHASE20B_CAPTURE_SKIPPED name=wide_place_capture_bar reason=valid_candidate_state_not_reached")
else:
await _send_mouse_button(position, false, window_id, event_global_position)
await _wait_process_frames(2)
if trace_this_click:
print("PHASE20B_TARGET_CLICK after_release capture=%d speech_pending=%s speech_hits=%s focus=%s" % [int(_debug_stage.get("_capture_kind")), str(_debug_stage.get("_speech_bubble_pointer_pending")), str(_debug_stage.get("_speech_bubble_pointer_hits")), str(root.gui_get_focus_owner())])
func _send_shift_click(position: Vector2) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.position = position
event.global_position = position
event.window_id = root.get_window_id()
event.shift_pressed = true
event.pressed = true
Input.parse_input_event(event)
await process_frame
event.pressed = false
Input.parse_input_event(event)
await process_frame
await _wait_frames(2)
func _send_mouse_button(position: Vector2, pressed: bool, window_id: int = 0, global_position: Vector2 = Vector2.INF, post_release_viewport_position: Vector2 = Vector2.INF, post_release_global_position: Vector2 = Vector2.INF) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_LEFT
event.position = position
event.global_position = global_position if global_position.is_finite() else position
event.window_id = window_id
event.pressed = pressed
var trace_input: bool = _last_click_label.contains("Choose a place")
if trace_input:
print("PHASE20B_INPUT before_parse label=%s pressed=%s position=%s window_id=%d" % [_last_click_label, str(pressed), str(position), window_id])
Input.parse_input_event(event)
_last_injected_mouse_position = position
if not pressed and post_release_viewport_position.is_finite():
var event_global_position: Vector2 = post_release_global_position if post_release_global_position.is_finite() else post_release_viewport_position
_send_mouse_motion(post_release_viewport_position, window_id, event_global_position)
if trace_input:
print("PHASE20B_INPUT post_release_viewport_motion label=%s position=%s global_position=%s window_position=%s" % [_last_click_label, str(post_release_viewport_position), str(event_global_position), str(_debug_stage.get_window().position)])
if trace_input:
print("PHASE20B_INPUT after_parse label=%s pressed=%s capture=%d" % [_last_click_label, str(pressed), int(_debug_stage.get("_capture_kind"))])
print("PHASE20B_INPUT before_process_frame label=%s pressed=%s" % [_last_click_label, str(pressed)])
await process_frame
if trace_input:
var bar: StageUseTargetCaptureBar = _debug_stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar
print("PHASE20B_INPUT after_process_frame label=%s pressed=%s capture=%d bar_visible=%s" % [_last_click_label, str(pressed), int(_debug_stage.get("_capture_kind")), str(bar != null and bar.visible)])
func _send_mouse_motion(position: Vector2, window_id: int = 0, global_position: Vector2 = Vector2.INF, button_mask: int = 0) -> void:
var motion: InputEventMouseMotion = InputEventMouseMotion.new()
motion.position = position
motion.global_position = global_position if global_position.is_finite() else position
motion.relative = position - _last_injected_mouse_position
motion.button_mask = button_mask
motion.window_id = window_id
if _last_click_label.contains("Choose a place"):
print("PHASE20B_INPUT motion_event position=%s global_position=%s relative=%s mask=%d window_id=%d" % [str(motion.position), str(motion.global_position), str(motion.relative), int(motion.button_mask), window_id])
Input.parse_input_event(motion)
_last_injected_mouse_position = position
func _send_wheel_down(position: Vector2) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_WHEEL_DOWN
event.position = position
event.global_position = position
event.factor = 1.0
event.window_id = root.get_window_id()
event.pressed = true
Input.parse_input_event(event)
await process_frame
func _send_middle_button(position: Vector2, pressed: bool) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_MIDDLE
event.position = position
event.global_position = position
event.window_id = root.get_window_id()
event.pressed = pressed
Input.parse_input_event(event)
_last_injected_mouse_position = position
await process_frame
func _send_key(keycode: Key, with_shift: bool = false, with_ctrl: bool = false) -> void:
var down: InputEventKey = InputEventKey.new()
down.keycode = keycode
down.shift_pressed = with_shift
down.ctrl_pressed = with_ctrl
down.pressed = true
Input.parse_input_event(down)
await process_frame
var up: InputEventKey = InputEventKey.new()
up.keycode = keycode
up.shift_pressed = with_shift
up.ctrl_pressed = with_ctrl
up.pressed = false
Input.parse_input_event(up)
await process_frame
func _type_text(value: String) -> void:
for index: int in value.length():
var character: String = value.substr(index, 1)
var event: InputEventKey = InputEventKey.new()
event.keycode = OS.find_keycode_from_string(character.to_upper())
event.unicode = value.unicode_at(index)
event.shift_pressed = character != character.to_lower()
event.pressed = true
Input.parse_input_event(event)
await process_frame
var release: InputEventKey = event.duplicate() as InputEventKey
release.pressed = false
Input.parse_input_event(release)
await process_frame
func _wait_frames(count: int) -> void:
for _index: int in count:
await process_frame
await RenderingServer.frame_post_draw
func _wait_process_frames(count: int) -> void:
for _index: int in count:
await process_frame
func _wait_for_status_toast_clear(stage: SandboxStage) -> void:
for _frame: int in 180:
if float(stage.get("_toast_timer")) <= 0.0:
break
await process_frame
_check(float(stage.get("_toast_timer")) <= 0.0, "transient validation status clears before the committed-plan capture")
func _say_marker_has_rendered_pixels(visuals: StageDirectorVisuals, projection: Dictionary, image: Image) -> bool:
if visuals == null or image == null or image.is_empty():
return false
var start: Vector2 = projection.get("start", Vector2.INF)
if not start.is_finite():
return false
var marker_center_world: Vector2 = visuals._say_marker_center(start, visuals._zoom())
var marker_center_screen: Vector2 = visuals.get_global_transform_with_canvas() * marker_center_world
var sample_rect: Rect2 = Rect2(marker_center_screen - Vector2(24.0, 20.0), Vector2(48.0, 40.0))
var stage_view_rect: Rect2 = Rect2(Vector2(0.0, 64.0), Vector2(float(root.size.x), float(root.size.y) - 104.0))
if not stage_view_rect.encloses(sample_rect):
print("PHASE20B_SAY_MARKER_OUTSIDE_STAGE center_screen=%s sample=%s stage_view=%s" % [str(marker_center_screen), str(sample_rect), str(stage_view_rect)])
return false
var yellow_pixels: int = 0
var start_x: int = maxi(0, int(floorf(sample_rect.position.x)))
var end_x: int = mini(image.get_width(), int(ceilf(sample_rect.end.x)))
var start_y: int = maxi(0, int(floorf(sample_rect.position.y)))
var end_y: int = mini(image.get_height(), int(ceilf(sample_rect.end.y)))
for y: int in range(start_y, end_y):
for x: int in range(start_x, end_x):
var pixel: Color = image.get_pixel(x, y)
if pixel.r > 0.72 and pixel.g > 0.48 and pixel.b < 0.52:
yellow_pixels += 1
print("PHASE20B_SAY_MARKER center_world=%s center_screen=%s sample=%s yellow_pixels=%d" % [str(marker_center_world), str(marker_center_screen), str(sample_rect), yellow_pixels])
return yellow_pixels >= 20
func _save_capture(name: String) -> void:
await RenderingServer.frame_post_draw
var image: Image = root.get_texture().get_image()
var path: String = _active_capture_dir.path_join("%s.png" % name)
var error: Error = image.save_png(ProjectSettings.globalize_path(path))
print("PHASE20B_CAPTURE path=%s size=%s error=%d" % [path, image.get_size(), error])
_check(error == OK and image.get_size() == root.size, "rendered capture %s is saved at viewport size" % name)
func _save_capture_now(name: String) -> void:
await process_frame
await RenderingServer.frame_post_draw
var image: Image = root.get_texture().get_image()
var path: String = _active_capture_dir.path_join("%s.png" % name)
var error: Error = image.save_png(ProjectSettings.globalize_path(path))
print("PHASE20B_CAPTURE_STABLE path=%s size=%s error=%d" % [path, image.get_size(), error])
_check(error == OK and image.get_size() == root.size, "settled rendered capture %s is saved at viewport size" % name)
func _save_capture_marker_preview(stage: SandboxStage, placement_overlay: StagePlacementOverlay, cursor_prompt: Label, world_position: Vector2, screen_position: Vector2, name: String) -> void:
# Synthetic pointer input updates the production capture state but does not
# move the desktop cursor. Freeze the Stage for this frame and place the
# cursor marker and its prompt at the injected pointer for a representative
# rendered screenshot.
stage.set_process(false)
placement_overlay.set_action_marker(world_position)
cursor_prompt.position = screen_position + Vector2(20.0, -20.0)
await _save_capture_now(name)
stage.set_process(true)
func _save_capture_after_process_frames(name: String) -> void:
await _wait_process_frames(2)
var image: Image = root.get_texture().get_image()
var path: String = _active_capture_dir.path_join("%s.png" % name)
var error: Error = image.save_png(ProjectSettings.globalize_path(path))
print("PHASE20B_CAPTURE_PROCESS_FRAME path=%s size=%s error=%d" % [path, image.get_size(), error])
_check(error == OK and image.get_size() == root.size, "process-settled capture %s is saved at viewport size" % name)
func _save_capture_current_frame(name: String) -> void:
var image: Image = root.get_texture().get_image()
var path: String = _active_capture_dir.path_join("%s.png" % name)
var error: Error = image.save_png(ProjectSettings.globalize_path(path))
print("PHASE20B_CAPTURE_CURRENT_FRAME path=%s size=%s error=%d" % [path, image.get_size(), error])
_check(error == OK and image.get_size() == root.size, "current rendered capture %s is saved at viewport size" % name)
func _check(condition: bool, label: String) -> void:
if condition:
print("PHASE20B_GUI_CHECK PASS: %s" % label)
else:
_failures += 1
push_error("PHASE20B_GUI_CHECK FAIL: %s" % label)
func _finish(stage: SandboxStage) -> void:
if _watchdog != null:
_watchdog.stop()
if stage != null and is_instance_valid(stage):
stage.queue_free()
await _wait_frames(2)
func _on_gui_watchdog_timeout() -> void:
push_error("Phase 20b GUI harness exceeded its 45-second frame watchdog")
print("PHASE20B_GUI_WATCHDOG mode=%s capture=%s paused=%s" % [str(_debug_stage.get("current_mode")) if _debug_stage != null and is_instance_valid(_debug_stage) else "missing", str(_debug_stage.get("_capture_kind")) if _debug_stage != null and is_instance_valid(_debug_stage) else "missing", paused])
quit(2)