extends SceneTree const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn") const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd") const STAGE_GIZMOS := preload("res://scripts/stage/stage_gizmos.gd") const CAPTURE_DIR: String = "res://tests/phase20a_captures" var _failures: int = 0 func _initialize() -> void: call_deferred("_run_capture_scenarios") func _run_capture_scenarios() -> void: root.size = Vector2i(1280, 720) root.position = Vector2i(40, 40) root.gui_embed_subwindows = true DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR)) var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage root.add_child(stage) await _wait_frames(8) stage.call("_on_grid_toggled", false) var spawner: StageSpawner = stage.get("_spawner") as StageSpawner spawner.selected_prop_id = "crate" stage.set_placement_mode("prop") var center_screen: Vector2 = _screen_at(stage, Vector2.ZERO) await _send_mouse_motion(center_screen) await _wait_frames(3) _check(_placement_prompt(stage).visible, "placement prompt is visible") _check(_placement_prompt(stage).text.contains("Click to place Crate"), "placement prompt shows the next automatic name") await _save_capture("placement_name_prompt") await _send_click(center_screen) await _wait_frames(4) var placed_props: Array[PropBlock] = _placed_props(stage) var chooser: PopupPanel = null _check(placed_props.size() == 1, "first prop is placed through the viewport input path") if placed_props.size() >= 1: _check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name") await _send_click(center_screen) await _wait_frames(4) placed_props = _placed_props(stage) _check(placed_props.size() == 2, "second prop is placed through the viewport input path") if placed_props.size() < 2: stage.queue_free() await _wait_frames(2) quit(1) return _check(placed_props[0].stage_name == "Crate", "first prop receives its asset-based stage name") _check(placed_props[1].stage_name == "Crate 2", "repeat placement gets the next unique name") await _make_prop_rectangle(placed_props[0], Color("#C46B48")) await _make_prop_rectangle(placed_props[1], Color("#4D86C6")) stage.call("_show_placement_feedback", placed_props[1], placed_props[1].stage_name) stage.set_placement_mode("") await _wait_frames(3) await _save_capture("placement_feedback") await create_timer(1.8).timeout # A third prop shares the broad-phase bounds, but its visible outline leaves # the click point empty. The production chooser should exclude it. var outline_decoy: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO) if outline_decoy != null: stage.call("_finalize_placed_node", outline_decoy) await _make_prop_outline(outline_decoy) var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity var rename_result: Dictionary = identity.rename(outline_decoy, "Outline Decoy", stage.get_node("World") as Node2D) _check(bool(rename_result.get("ok", false)), "outline-only decoy gets a unique label") else: _check(false, "outline-only decoy was spawned") await _wait_frames(4) var world: Node2D = stage.get_node("World") as Node2D var character: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-560.0, 0.0)) if character != null: stage.call("_finalize_placed_node", character) var selection: StageSelection = stage.get("_selection") as StageSelection selection.select_only(character) await _wait_frames(4) await _save_capture("character_inspector") var character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView var rename_button: Button = character_view.find_child("RenameStageObject", true, false) as Button var character_card: StageInspectorPanel = character_view.get_card() as StageInspectorPanel var character_title: Label = character_card.find_child("InspectorTitle", true, false) as Label var character_body: ScrollContainer = character_card.find_child("InspectorBodyScroll", true, false) as ScrollContainer _check(rename_button != null and rename_button.visible and rename_button.text == "Rename", "character inspector has a visible Rename action in its header") _check(character_title != null and character_title.text == character.stage_name, "character name appears once as the inspector heading") _check(character_body != null and not character_body.visible, "character inspector stays a compact name row until editing") if rename_button != null: stage.call("_set_scene_dirty", false) _check(not bool(stage.get("_dirty")), "name editor starts from a clean document") await _click_control(rename_button) await _wait_frames(3) var character_edit: LineEdit = character_view.find_child("StageNameEdit", true, false) as LineEdit _check(character_edit != null and character_edit.visible, "Rename opens the inline editor") await _save_capture("character_name_edit") if character_edit != null: _check(character_edit.has_focus(), "inline name editor receives keyboard focus") await _send_key(KEY_A, false, true) await _type_text("Milo") _check(character_edit.text == "Milo", "injected keyboard input replaces the character name") var done_button: Button = character_view.find_child("DoneStageName", true, false) as Button if done_button != null: await _click_control(done_button) await _wait_frames(3) _check(character.stage_name == "Milo", "valid name commits from the inline editor") _check(character_title != null and character_title.text == "Milo", "renamed character heading updates immediately") _check(bool(stage.get("_dirty")), "valid rename marks the document dirty") await _save_capture("character_renamed") if rename_button != null: stage.call("_set_scene_dirty", false) _check(not bool(stage.get("_dirty")), "duplicate-name check starts from a clean document") await _click_control(rename_button) await _wait_frames(3) character_edit = character_view.find_child("StageNameEdit", true, false) as LineEdit if character_edit != null: _check(character_edit.has_focus(), "duplicate-name editor receives keyboard focus") await _send_key(KEY_A, false, true) await _type_text(placed_props[0].stage_name) _check(character_edit.text == placed_props[0].stage_name, "injected keyboard input enters the duplicate name") await _send_key(KEY_ENTER) await _wait_frames(3) var feedback: Label = character_view.find_child("StageNameFeedback", true, false) as Label _check(feedback != null and feedback.visible and feedback.text.contains("Crate"), "character duplicate name remains in the editor with feedback") _check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "invalid duplicate rename leaves the name and dirty state unchanged") await _save_capture("character_duplicate_name_feedback") var cancel_name_button: Button = character_view.find_child("CancelStageName", true, false) as Button if cancel_name_button != null: await _click_control(cancel_name_button) await _wait_frames(3) _check(not bool(stage.get("_dirty")) and character.stage_name == "Milo", "Cancel discards the draft without marking the scene dirty") else: _check(false, "character asset is spawned for inspector capture") placed_props = _placed_props(stage) var first_prop: PropBlock = placed_props[0] if not placed_props.is_empty() else null var second_prop: PropBlock = placed_props[1] if placed_props.size() > 1 else null var selection_after_setup: StageSelection = stage.get("_selection") as StageSelection if first_prop != null: selection_after_setup.select_only(first_prop) await _wait_frames(3) await _save_capture("prop_inspector") var prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView _check(prop_view != null and prop_view.visible, "selected prop inspector is visible") var prop_card: StageInspectorPanel = prop_view.get_card() as StageInspectorPanel var prop_title: Label = prop_card.find_child("InspectorTitle", true, false) as Label var prop_rename_button: Button = prop_card.find_child("RenameStageObject", true, false) as Button var collapse_button: Button = prop_card.find_child("CollapseStageInspector", true, false) as Button _check(prop_title != null and prop_title.text == first_prop.stage_name, "prop name appears once in the inspector heading") _check(_find_label_with_text(prop_view, "Name on stage") == null, "prop inspector does not repeat its name in the body") _check(prop_rename_button != null and prop_rename_button.visible and prop_rename_button.text == "Rename", "prop inspector shares the header Rename action") _check(collapse_button != null and collapse_button.visible and not prop_card.is_collapsed(), "expanded prop inspector has a disclosure control") if collapse_button != null: await _click_control(collapse_button) await _wait_frames(3) _check(prop_card.visible and prop_card.is_collapsed() and collapse_button.text == "▾", "Close collapses to the named prop header") _check(_find_button_with_text(prop_view, "Settings") == null, "collapsed inspector does not replace the name with a Settings button") await _save_capture("prop_inspector_collapsed") if prop_rename_button != null: await _click_control(prop_rename_button) await _wait_frames(3) var prop_name_edit: LineEdit = prop_view.find_child("StageNameEdit", true, false) as LineEdit _check(not prop_card.is_collapsed() and prop_name_edit != null and prop_name_edit.visible, "Rename expands the inspector and opens its name editor") var prop_cancel_name: Button = prop_view.find_child("CancelStageName", true, false) as Button if prop_cancel_name != null: await _click_control(prop_cancel_name) await _wait_frames(3) _check(prop_card.is_collapsed(), "Cancel returns to the prior collapsed state") await _click_control(collapse_button) await _wait_frames(3) _check(not prop_card.is_collapsed() and prop_view.get_fields().visible, "chevron expands the full prop settings body") # Click at a true overlap through the stage's normal unhandled input route. # Existing selection remains unchanged while the chooser is open. if first_prop != null and second_prop != null: selection_after_setup.select_only(first_prop) await _wait_frames(2) await _send_click(center_screen) await _wait_frames(5) chooser = stage.get("_objects_here_popup") as PopupPanel _check(chooser != null and chooser.visible, "overlap click opens Objects here") _check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "opening chooser leaves the previous selection unchanged") var candidates: Array[Node2D] = selection_after_setup.hit_test_all(Vector2.ZERO) _check(candidates.size() == 2 and candidates.has(first_prop) and candidates.has(second_prop), "chooser hit test excludes the empty center of the outline-only decoy") if chooser != null: var chooser_shell: StyleBoxFlat = chooser.get_theme_stylebox("panel") as StyleBoxFlat _check(chooser_shell != null and chooser_shell.corner_radius_top_left >= 12, "overlap chooser shell has the rounded popup treatment") _check(chooser_shell != null and chooser_shell.border_width_left >= 2 and chooser_shell.shadow_size > 0, "overlap chooser shell has a clear border and local shadow") var selected_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button var selected_candidate_style: StyleBoxFlat = selected_candidate.get_theme_stylebox("normal") as StyleBoxFlat if selected_candidate != null else null _check(selected_candidate_style != null and selected_candidate_style.corner_radius_top_left >= 10, "overlap candidate rows use rounded corners") var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button var cancel_style: StyleBoxFlat = cancel_button.get_theme_stylebox("normal") as StyleBoxFlat if cancel_button != null else null _check(cancel_button != null and cancel_button.custom_minimum_size.y >= 48, "overlap chooser Cancel has a large target") _check(cancel_style != null and cancel_style.corner_radius_top_left >= 8, "overlap chooser Cancel uses the rounded secondary treatment") await _save_capture("overlap_chooser") if chooser != null: var candidate_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button if candidate_button != null: var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos await _send_mouse_motion(candidate_button.get_screen_position() + candidate_button.size * 0.5, candidate_button.get_window().get_window_id()) await _wait_frames(3) _check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "pointer hover previews the exact overlap candidate") await _save_capture("overlap_hover_preview") await _send_mouse_motion(Vector2(860.0, 360.0), chooser.get_window().get_window_id()) await _wait_frames(3) _check(gizmos != null and gizmos.get("_candidate_preview") == null, "leaving a candidate row clears hover preview") await _send_key(KEY_TAB, true) await _send_key(KEY_TAB, true) await _wait_frames(3) _check(candidate_button.has_focus(), "Shift+Tab keyboard navigation focuses the first overlap candidate") _check(gizmos != null and gizmos.get("_candidate_preview") == second_prop, "keyboard focus previews the exact candidate") await _save_capture("overlap_candidate_preview") var cancel_button: Button = chooser.find_child("CancelObjectsHere", true, false) as Button if cancel_button != null: await _click_control(cancel_button) await _wait_frames(4) _check(stage.get("_objects_here_popup") == null, "Cancel closes the chooser") _check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Cancel preserves the previous selection") await _save_capture("overlap_cancelled") await _send_click(center_screen) await _wait_frames(4) await _send_key(KEY_ESCAPE) await _wait_frames(4) _check(stage.get("_objects_here_popup") == null, "Escape closes the chooser") _check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "Escape preserves the previous selection") var escape_gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos _check(escape_gizmos == null or escape_gizmos.get("_candidate_preview") == null, "Escape clears the transient candidate preview") await _save_capture("overlap_escape_dismissal") await _send_click(center_screen) await _wait_frames(4) await _send_click(_screen_at(stage, Vector2(-60.0, -40.0))) await _wait_frames(4) _check(stage.get("_objects_here_popup") == null, "outside dismissal click closes the chooser") _check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "outside dismissal consumes the click and preserves selection") await _save_capture("overlap_outside_dismissal") await _send_click(center_screen) await _wait_frames(4) chooser = stage.get("_objects_here_popup") as PopupPanel if chooser != null: var choose_button: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button if choose_button != null: await _click_control(choose_button) await _wait_frames(5) _check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == second_prop, "choosing a row commits that object once") await _save_capture("overlap_committed") # DIRECT uses the same geometry-filtered chooser with a prop-only list and # target selection does not take over EDIT selection. var direct_use_spot_id: String = second_prop.add_use_spot(Vector2.ZERO) if second_prop != null else "" _check(not direct_use_spot_id.is_empty(), "DIRECT target fixture has a valid Use spot") stage.set_mode(SandboxStage.StageMode.DIRECT) selection_after_setup.select_only(first_prop) stage.call("_begin_use_target_capture", "direct") await _wait_frames(4) await _send_click(center_screen) await _wait_frames(4) chooser = stage.get("_objects_here_popup") as PopupPanel _check(chooser != null and chooser.visible, "DIRECT Use opens the shared prop chooser") if chooser != null: var direct_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer _check(direct_rows != null and direct_rows.get_child_count() == 2, "DIRECT Use chooser contains only the two props that actually cover the point") await _save_capture("direct_use_chooser") var direct_candidate: Button = chooser.find_child("ObjectCandidate_0", true, false) as Button if direct_candidate != null: await _send_key(KEY_TAB, true) await _send_key(KEY_TAB, true) await _wait_frames(3) _check(direct_candidate.has_focus(), "DIRECT picker candidate can receive keyboard focus") await _save_capture("direct_use_preview") await _click_control(direct_candidate) await _wait_frames(4) var target_capture: StageTargetCaptureSession = stage.get("_target_capture") as StageTargetCaptureSession _check(target_capture != null and target_capture.use_target_prop == second_prop and target_capture.use_target_step == 1, "DIRECT chooser advances to Use spot selection for the chosen prop") var direct_capture_prompt: Label = stage.find_child("Prompt", true, false) as Label _check(direct_capture_prompt != null and direct_capture_prompt.text == "Choose a spot on the prop", "valid DIRECT prop selection shows the next-step prompt") _check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == first_prop, "DIRECT target selection leaves object selection untouched") await _save_capture("direct_use_target_selected") else: _check(false, "DIRECT target chooser is available for interaction") # A director selects between overlapping named characters. A nearby Blue Door # remains visible in the scene but its actual art does not cover the click. stage.set_mode(SandboxStage.StageMode.EDIT) var props_clear_of_character_point: Array[PropBlock] = _placed_props(stage) for prop_index: int in props_clear_of_character_point.size(): props_clear_of_character_point[prop_index].position = Vector2(1000.0 + float(prop_index) * 220.0, 700.0) if character != null: character.position = Vector2.ZERO var ava: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", character.position) var character_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity if ava != null: stage.call("_finalize_placed_node", ava) var ava_result: Dictionary = character_identity.rename(ava, "Ava", stage.get_node("World") as Node2D) _check(bool(ava_result.get("ok", false)), "overlapping second character receives the distinct name Ava") else: _check(false, "overlapping second character is spawned") var blue_door: PropBlock = spawner.spawn_prop_asset("crate", character.position + Vector2(220.0, 0.0)) if blue_door != null: stage.call("_finalize_placed_node", blue_door) await _make_blue_door(blue_door) var door_result: Dictionary = character_identity.rename(blue_door, "Blue Door", stage.get_node("World") as Node2D) _check(bool(door_result.get("ok", false)), "nearby prop receives the descriptive name Blue Door") else: _check(false, "nearby Blue Door prop is spawned") if ava != null and blue_door != null: var shared_point: Vector2 = _find_shared_hit_point(stage.get("_selection") as StageSelection, [character, ava]) _check(shared_point.is_finite(), "overlapping Milo and Ava share a visible hit point") if shared_point.is_finite(): var shared_hits: Array[Node2D] = (stage.get("_selection") as StageSelection).hit_test_all(shared_point) _check(shared_hits.size() == 2 and shared_hits.has(character) and shared_hits.has(ava) and not shared_hits.has(blue_door), "character overlap hit test omits the nearby Blue Door outside visible geometry") selection_after_setup.select_only(first_prop) await _send_click(_screen_at(stage, shared_point)) await _wait_frames(5) chooser = stage.get("_objects_here_popup") as PopupPanel _check(chooser != null and chooser.visible, "overlapping Milo and Ava open the production Objects here chooser") if chooser != null: var character_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer _check(character_rows != null and character_rows.get_child_count() == 2, "character overlap chooser contains Milo and Ava only") var row_names: Array[String] = [] if character_rows != null: for row_index: int in character_rows.get_child_count(): var row_button: Button = character_rows.get_child(row_index) as Button if row_button == null: continue for label_node: Node in row_button.find_children("*", "Label", true, false): var row_label: Label = label_node as Label if row_label != null: for expected_name: String in ["Milo", "Ava"]: if row_label.text.contains(expected_name) and not row_names.has(expected_name): row_names.append(expected_name) _check(row_names.size() == 2 and row_names.has("Milo") and row_names.has("Ava"), "chooser rows identify the two characters by their stage names") await _save_capture("character_overlap_chooser") var ava_button: Button = _find_candidate_button_for_name(chooser, "Ava") if ava_button != null: await _send_mouse_motion(ava_button.get_screen_position() + ava_button.size * 0.5, ava_button.get_window().get_window_id()) await _wait_frames(3) await _save_capture("character_overlap_preview") await _click_control(ava_button) await _wait_frames(4) _check(selection_after_setup.get_selected().size() == 1 and selection_after_setup.get_selected()[0] == ava, "choosing Ava commits the character selection") else: _check(false, "Ava row is available for mouse selection") await _save_capture("character_overlap_committed") else: _check(false, "character overlap chooser is available for candidate inspection") for prop_index: int in props_clear_of_character_point.size(): props_clear_of_character_point[prop_index].position = Vector2.ZERO else: _check(false, "Milo is available for the character overlap scenario") # The larger overlap exercises the chooser's bounded scroll area at a small # viewport while retaining the normal stage click and popup input routes. stage.set_mode(SandboxStage.StageMode.EDIT) for extra_index: int in range(5): var extra_prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2.ZERO) if extra_prop == null: _check(false, "crowded chooser prop %d is spawned" % extra_index) continue stage.call("_finalize_placed_node", extra_prop) await _make_prop_rectangle(extra_prop, Color.from_hsv(float(extra_index) / 5.0, 0.55, 0.82)) root.size = Vector2i(920, 560) await _wait_frames(8) var stage_characters: Array[StickmanRig] = [] for child: Node in (stage.get_node("World") as Node2D).get_children(): if child is StickmanRig: stage_characters.append(child as StickmanRig) (child as StickmanRig).visible = false if first_prop != null: selection_after_setup.select_only(first_prop) stage.call("_refresh_prop_settings_card") await _wait_frames(4) var prop_identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity var original_prop_name: String = first_prop.stage_name var long_prop_name: String = "Crate with a Very Long Descriptive Stage Name" var long_prop_result: Dictionary = prop_identity.rename(first_prop, long_prop_name, stage.get_node("World") as Node2D) _check(bool(long_prop_result.get("ok", false)), "long prop name is valid for compact-header capture") stage.call("_refresh_prop_settings_card") await _wait_frames(5) var long_prop_view: StagePropSettingsView = stage.get("_prop_settings_view") as StagePropSettingsView var long_prop_card: StageInspectorPanel = long_prop_view.get_card() as StageInspectorPanel var long_prop_title: Label = long_prop_card.find_child("InspectorTitle", true, false) as Label long_prop_card.set_collapsed(true) await _wait_frames(4) _check(long_prop_title != null and long_prop_title.max_lines_visible == 1 and long_prop_title.autowrap_mode == TextServer.AUTOWRAP_OFF, "collapsed prop title stays on one line") _check(long_prop_title != null and long_prop_title.tooltip_text == long_prop_name and long_prop_title.text_overrun_behavior == TextServer.OVERRUN_TRIM_ELLIPSIS, "collapsed prop keeps its full-name tooltip and ellipsis") await _save_capture("prop_inspector_collapsed_long_name_small_viewport") prop_identity.rename(first_prop, original_prop_name, stage.get_node("World") as Node2D) stage.call("_refresh_prop_settings_card") long_prop_card.set_collapsed(false) if character != null: var character_identity_small: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity var original_position: Vector2 = character.global_position var original_name: String = character.stage_name var original_scale: Vector2 = character.scale character.scale = Vector2(0.6, 0.6) var canvas_to_world: Transform2D = stage.get_viewport().get_canvas_transform().affine_inverse() character.visible = true character.global_position = canvas_to_world * Vector2(float(root.size.x) * 0.52, 260.0) var long_character_name: String = "Milo with a Very Long Character Name" character_identity_small.rename(character, long_character_name, stage.get_node("World") as Node2D) selection_after_setup.select_only(character) stage.call("_refresh_character_settings_card") await _wait_frames(7) var compact_character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView var compact_character_card: StageInspectorPanel = compact_character_view.get_card() as StageInspectorPanel var compact_character_title: Label = compact_character_card.find_child("InspectorTitle", true, false) as Label var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos var character_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(character) var rotation_handle: Vector2 = gizmos._rotate_handle_position(character_bounds) _check(character.source_stk_path.ends_with("break.stk"), "colorful Break character is present in the visual review") _check(compact_character_title != null and compact_character_title.max_lines_visible == 1 and compact_character_title.tooltip_text == long_character_name, "compact character header stays one line and retains the full-name tooltip") _check(rotation_handle.y > character_bounds.get_center().y, "rotate handle moves below a character near the top chrome") _check(gizmos.hit_test(rotation_handle) == STAGE_GIZMOS.Handle.ROTATE, "lower rotate handle remains inside its hit target") await _save_capture("break_character_long_name_top_handle_small_viewport") var original_rotation: float = character.global_rotation var rotate_press: Vector2 = _screen_at(stage, rotation_handle) await _send_mouse_motion(rotate_press) await _send_mouse_button(rotate_press, true) await _wait_frames(2) _check(gizmos.is_dragging(), "clicking the lower handle starts rotation through the stage input path") var rotation_target: Vector2 = character_bounds.get_center() + (rotation_handle - character_bounds.get_center()).rotated(deg_to_rad(20.0)) await _send_mouse_motion(_screen_at(stage, rotation_target)) await _send_mouse_button(_screen_at(stage, rotation_target), false) await _wait_frames(3) _check(absf(angle_difference(character.global_rotation, original_rotation + deg_to_rad(20.0))) < 0.03, "dragging the lower handle rotates the selected character") character.global_rotation = original_rotation character.global_position = original_position character.scale = original_scale character_identity_small.rename(character, original_name, stage.get_node("World") as Node2D) stage.call("_refresh_character_settings_card") for stage_character: StickmanRig in stage_characters: stage_character.visible = true selection_after_setup.select_only(first_prop) stage.call("_refresh_prop_settings_card") center_screen = _screen_at(stage, Vector2.ZERO) selection_after_setup.select_only(first_prop) await _send_click(center_screen) await _wait_frames(5) chooser = stage.get("_objects_here_popup") as PopupPanel _check(chooser != null and chooser.visible, "crowded overlap opens the chooser at a small viewport") if chooser != null: var crowded_rows: VBoxContainer = chooser.find_child("ObjectsHereRows", true, false) as VBoxContainer _check(crowded_rows != null and crowded_rows.get_child_count() == 9, "crowded chooser lists seven props and both visible characters") var popup_rect: Rect2 = Rect2(Vector2(chooser.position), Vector2(chooser.size)) _check(popup_rect.position.x >= 0.0 and popup_rect.position.y >= 64.0 and popup_rect.end.x <= float(root.size.x) and popup_rect.end.y <= float(root.size.y) - 38.0, "small viewport popup remains inside safe screen bounds") var crowded_scroll: ScrollContainer = chooser.find_child("ObjectsHereScroll", true, false) as ScrollContainer _check(crowded_scroll != null and crowded_scroll.size.y <= 320.0, "crowded chooser bounds its scroll viewport") await _save_capture("crowded_chooser_small_viewport") if crowded_scroll != null: var scroll_pointer: Vector2 = crowded_scroll.get_screen_position() + crowded_scroll.size * 0.5 await _send_mouse_wheel_down(scroll_pointer, chooser.get_window().get_window_id()) await _wait_frames(4) _check(crowded_scroll.scroll_vertical > 0, "crowded chooser scrolls through production mouse-wheel input") await _save_capture("crowded_chooser_scrolled_small_viewport") await _send_key(KEY_ESCAPE) await _wait_frames(4) _check(stage.get("_objects_here_popup") == null, "Escape closes the crowded small-viewport chooser") if _failures == 0: print("PHASE20A_GUI_CAPTURE PASS: production input/render scenarios; captures saved under %s" % CAPTURE_DIR) else: push_error("PHASE20A_GUI_CAPTURE FAIL: %d assertion(s) failed" % _failures) stage.queue_free() await _wait_frames(2) quit(1 if _failures > 0 else 0) func _placed_props(stage: SandboxStage) -> Array[PropBlock]: var result: Array[PropBlock] = [] var world: Node2D = stage.get_node("World") as Node2D for child: Node in world.get_children(): if child is PropBlock: result.append(child as PropBlock) return result func _placement_prompt(stage: SandboxStage) -> Label: return stage.find_child("PlacementNamePrompt", true, false) as Label func _make_prop_rectangle(prop: PropBlock, color: Color) -> void: var points: PackedVector2Array = PackedVector2Array([ Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0), ]) var shapes: Array[Dictionary] = [{ "shape_type": "polygon", "points": points, "closed": true, "fill_color": color.to_html(false), "outline_color": "#2D2532", "outline_width": 3.0, }] prop.set_custom_shapes(shapes, points) func _make_prop_outline(prop: PropBlock) -> void: var bars: Array[PackedVector2Array] = [ PackedVector2Array([Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, -50.0), Vector2(-80.0, -50.0)]), PackedVector2Array([Vector2(70.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(70.0, 60.0)]), PackedVector2Array([Vector2(-80.0, 50.0), Vector2(80.0, 50.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0)]), PackedVector2Array([Vector2(-80.0, -60.0), Vector2(-70.0, -60.0), Vector2(-70.0, 60.0), Vector2(-80.0, 60.0)]), ] var shapes: Array[Dictionary] = [] for points: PackedVector2Array in bars: shapes.append({ "shape_type": "polygon", "points": points, "closed": true, "fill_color": "#9B83C5", "outline_color": "#392C4C", "outline_width": 2.0, }) var hull_points: PackedVector2Array = PackedVector2Array([ Vector2(-80.0, -60.0), Vector2(80.0, -60.0), Vector2(80.0, 60.0), Vector2(-80.0, 60.0), ]) prop.set_custom_shapes(shapes, Geometry2D.convex_hull(hull_points)) func _make_blue_door(prop: PropBlock) -> void: var door_points: PackedVector2Array = PackedVector2Array([ Vector2(-45.0, -85.0), Vector2(45.0, -85.0), Vector2(45.0, 85.0), Vector2(-45.0, 85.0), ]) var shapes: Array[Dictionary] = [{ "shape_type": "polygon", "points": door_points, "closed": true, "fill_color": "#2878D5", "outline_color": "#173C6B", "outline_width": 5.0, }] var knob_points: PackedVector2Array = PackedVector2Array([ Vector2(28.0, -8.0), Vector2(34.0, -14.0), Vector2(40.0, -8.0), Vector2(34.0, -2.0), ]) shapes.append({ "shape_type": "polygon", "points": knob_points, "closed": true, "fill_color": "#F7D45C", "outline_color": "#684F1A", "outline_width": 2.0, }) prop.set_custom_shapes(shapes, door_points) func _find_shared_hit_point(selection: StageSelection, nodes: Array[Node2D]) -> Vector2: if selection == null or nodes.size() < 2: return Vector2.INF var shared_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(nodes[0]) for node_index: int in range(1, nodes.size()): shared_bounds = shared_bounds.intersection(STAGE_SELECTION.get_world_aabb(nodes[node_index])) if shared_bounds.size.x <= 0.0 or shared_bounds.size.y <= 0.0: return Vector2.INF for y_index: int in range(1, 10): for x_index: int in range(1, 10): var point: Vector2 = shared_bounds.position + Vector2(shared_bounds.size.x * float(x_index) / 10.0, shared_bounds.size.y * float(y_index) / 10.0) var hits: Array[Node2D] = selection.hit_test_all(point) var all_nodes_hit: bool = true for node: Node2D in nodes: if not hits.has(node): all_nodes_hit = false break if all_nodes_hit: return point return Vector2.INF func _find_candidate_button_for_name(popup: PopupPanel, candidate_name: String) -> Button: var rows: VBoxContainer = popup.find_child("ObjectsHereRows", true, false) as VBoxContainer if rows == null: return null for row_index: int in rows.get_child_count(): var button: Button = rows.get_child(row_index) as Button if button == null: continue for label_node: Node in button.find_children("*", "Label", true, false): var label: Label = label_node as Label if label != null and label.text.contains(candidate_name): return button return null func _find_button_with_text(parent: Node, text_value: String) -> Button: for child: Node in parent.find_children("*", "Button", true, false): var button: Button = child as Button if button != null and button.text == text_value: return button return null func _find_label_with_text(parent: Node, text_value: String) -> Label: for child: Node in parent.find_children("*", "Label", true, false): var label: Label = child as Label if label != null and label.text == text_value: return label 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: var click_position: Vector2 = control.get_screen_position() + control.size * 0.5 var owner_window: Window = control.get_window() print("PHASE20A_CONTROL_CLICK name=%s rect=%s screen=%s window_id=%d window_pos=%s root_window_id=%d" % [ control.name, control.get_global_rect(), control.get_screen_position(), owner_window.get_window_id(), owner_window.position, root.get_window_id(), ]) await _send_click(click_position, owner_window.get_window_id()) func _send_click(position: Vector2, window_id: int = 0) -> void: await _send_mouse_button(position, true, window_id) await _wait_frames(1) await _send_mouse_button(position, false, window_id) await _wait_frames(2) func _send_mouse_button(position: Vector2, pressed: bool, window_id: int = 0) -> void: var event: InputEventMouseButton = InputEventMouseButton.new() event.button_index = MOUSE_BUTTON_LEFT event.position = position event.global_position = position event.window_id = window_id event.pressed = pressed Input.parse_input_event(event) await process_frame func _send_mouse_wheel_down(position: Vector2, window_id: int = 0) -> void: var event: InputEventMouseButton = InputEventMouseButton.new() event.button_index = MOUSE_BUTTON_WHEEL_DOWN event.position = position event.global_position = position event.window_id = window_id event.pressed = true Input.parse_input_event(event) await process_frame func _send_mouse_motion(position: Vector2, window_id: int = 0) -> void: var event: InputEventMouseMotion = InputEventMouseMotion.new() event.position = position event.global_position = position event.window_id = window_id Input.parse_input_event(event) await process_frame func _send_key(keycode: Key, with_shift: bool = false, with_ctrl: bool = false) -> void: var event: InputEventKey = InputEventKey.new() event.keycode = keycode event.shift_pressed = with_shift event.ctrl_pressed = with_ctrl event.pressed = true Input.parse_input_event(event) await process_frame event = InputEventKey.new() event.keycode = keycode event.shift_pressed = with_shift event.ctrl_pressed = with_ctrl event.pressed = false Input.parse_input_event(event) await process_frame func _type_text(text_value: String) -> void: for character_index: int in range(text_value.length()): var character: String = text_value.substr(character_index, 1) var event: InputEventKey = InputEventKey.new() event.keycode = OS.find_keycode_from_string(character.to_upper()) event.unicode = text_value.unicode_at(character_index) event.shift_pressed = character != character.to_lower() event.pressed = true Input.parse_input_event(event) await process_frame event = InputEventKey.new() event.keycode = OS.find_keycode_from_string(character.to_upper()) event.unicode = text_value.unicode_at(character_index) event.shift_pressed = character != character.to_lower() event.pressed = false Input.parse_input_event(event) await process_frame func _wait_frames(count: int) -> void: for _index: int in count: await process_frame await RenderingServer.frame_post_draw func _save_capture(name: String) -> void: await RenderingServer.frame_post_draw var image: Image = root.get_texture().get_image() var path: String = CAPTURE_DIR.path_join("%s.png" % name) var error: Error = image.save_png(ProjectSettings.globalize_path(path)) print("PHASE20A_CAPTURE path=%s size=%s error=%d" % [path, image.get_size(), error]) _check(error == OK and image.get_size() == root.size, "capture %s is saved at the expected viewport size" % name) func _check(condition: bool, label: String) -> void: if condition: print("PHASE20A_CHECK PASS: %s" % label) else: _failures += 1 push_error("PHASE20A_CHECK FAIL: %s" % label)