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)