extends SceneTree const ACTION_EDITOR_SCRIPT := preload("res://scripts/editors/stage/action_editor.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd") const PROP_ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd") const STAGE_SELECTION_SCRIPT := preload("res://scripts/stage/stage_selection.gd") const CAPTURE_SESSION_SCRIPT := preload("res://scripts/editors/stage/stage_target_capture_session.gd") const DIRECTOR_VISUALS_SCRIPT := preload("res://scripts/stage/stage_director_visuals.gd") const CAPTURE_DIR := "res://tests/phase20b_captures/pickup_prop_ui" var _failures: int = 0 var _long_name: bool = false var _resolved_action: Dictionary = {} func _initialize() -> void: call_deferred("_run") func _run() -> void: root.size = Vector2i(1280, 720) root.gui_embed_subwindows = true DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR)) var camera := Camera2D.new() root.add_child(camera) camera.make_current() var world := Node2D.new() world.name = "World" root.add_child(world) var eligible: PropBlock = _make_prop("pickup-prop-1", "Brown Suitcase", Vector2(-120.0, 30.0), true) var ineligible: PropBlock = _make_prop("pickup-prop-2", "Garden Gnome", Vector2(190.0, 30.0), false) world.add_child(eligible) world.add_child(ineligible) await _wait_frames(3) var visuals: StageDirectorVisuals = DIRECTOR_VISUALS_SCRIPT.new() as StageDirectorVisuals visuals.camera = camera visuals.world = world visuals.ui_font = ThemeDB.fallback_font visuals.emoji_font = ThemeDB.fallback_font root.add_child(visuals) var candidate_props: Array[PropBlock] = [eligible] visuals.set_pickup_candidates(candidate_props, eligible) await _wait_frames(3) if _can_capture_image(): var candidate_texture: Texture2D = root.get_texture() if candidate_texture != null: var candidate_image: Image = candidate_texture.get_image() if candidate_image != null and not candidate_image.is_empty(): candidate_image.save_png(ProjectSettings.globalize_path(CAPTURE_DIR.path_join("pickup_candidates.png"))) _check(visuals.get("_pickup_candidate_props").size() == 1, "only eligible props are highlighted as pickup candidates") _check(visuals.get("_pickup_hovered_prop") == eligible, "hover focus stays on an eligible prop") var selection: StageSelection = STAGE_SELECTION_SCRIPT.new(world, camera) as StageSelection var session: StageTargetCaptureSession = CAPTURE_SESSION_SCRIPT.new() as StageTargetCaptureSession session.begin(StageTargetCaptureSession.Kind.PICKUP_PROP, "Choose a prop", Callable(), Callable()) var valid_result: Dictionary = session.resolve_target(eligible.global_position, selection, visuals, false, 16.0) _check(bool(valid_result.get("valid", false)) and valid_result.get("value") == eligible, "pickup capture resolves an eligible prop") var invalid_result: Dictionary = session.resolve_target(ineligible.global_position, selection, visuals, false, 16.0) _check(not bool(invalid_result.get("valid", false)) and String(invalid_result.get("reason", "")).contains("grip"), "pickup capture explains why a non-pickup prop cannot be chosen") session.kind = StageTargetCaptureSession.Kind.PROP var generic_result: Dictionary = session.resolve_target(ineligible.global_position, selection, visuals, false, 16.0) _check(bool(generic_result.get("valid", false)) and generic_result.get("value") == ineligible, "generic prop capture remains able to target any prop") var pickup_action: Dictionary = {"type": "pick_up_prop", "target_prop_id": eligible.stage_object_id} _check(ACTION_REGISTRY.summarize(pickup_action, Callable(self, "_resolve_prop_name")) == "Pick Up Prop ยท Brown Suitcase", "pickup summaries use the stage prop name") _check(ACTION_REGISTRY.summarize(pickup_action) == "Pick Up Prop", "pickup summaries never expose internal IDs when no name resolver is set") visuals.clear_pickup_candidates() await _wait_frames(3) var action_editor: ActionEditor = ACTION_EDITOR_SCRIPT.new() as ActionEditor root.add_child(action_editor) action_editor.set_prop_target_name_resolver(Callable(self, "_resolve_prop_name")) action_editor.open_new(false, false, "pick_up_prop") action_editor.set_prop_target(eligible.stage_object_id) await _wait_frames(3) var prop_label: Label = action_editor.get("_prop_target_label") as Label var change_button: Button = action_editor.get("_set_prop_target_btn") as Button var left_button: Button = _button_with_text(action_editor, "Left") var auto_button: Button = _button_with_text(action_editor, "Auto") var right_button: Button = _button_with_text(action_editor, "Right") var done_button: Button = _button_with_text(action_editor, "Done") var cancel_button: Button = _button_with_text(action_editor, "Cancel") _check(prop_label != null and prop_label.text == "Brown Suitcase" and not prop_label.text.contains(eligible.stage_object_id), "pickup dialog shows the friendly stage name instead of the ID") _check(change_button != null and change_button.text == "Change prop" and change_button.get_global_rect().size.x >= 300.0, "Change prop is its own wide control below the name card") _check(left_button != null and auto_button != null and right_button != null and auto_button.custom_minimum_size.y >= 44.0 and left_button.custom_minimum_size.y >= 44.0 and right_button.custom_minimum_size.y >= 44.0, "hand choices are large, visible segmented buttons") _check(done_button != null and cancel_button != null and done_button.get_global_rect().end.y <= root.size.y and cancel_button.get_global_rect().end.y <= root.size.y, "Cancel and Done remain visible in the safe viewport") _long_name = true action_editor.set_prop_target(eligible.stage_object_id) await _wait_frames(3) _check(prop_label.text.length() > 50 and prop_label.get_global_rect().position.x >= 0.0 and prop_label.get_global_rect().end.x <= root.size.x, "a long prop name wraps inside the dialog without clipping its controls") var body_scroll: ScrollContainer = action_editor.find_child("ActionEditorBodyScroll", true, false) as ScrollContainer _check(body_scroll != null and auto_button.get_global_rect().position.y >= body_scroll.get_global_rect().position.y and right_button.get_global_rect().end.y <= body_scroll.get_global_rect().end.y, "all hand choices remain visible without scrolling when the prop name is long") if _can_capture_image(): var dialog_texture: Texture2D = root.get_texture() if dialog_texture != null: var dialog_image: Image = dialog_texture.get_image() if dialog_image != null and not dialog_image.is_empty(): dialog_image.save_png(ProjectSettings.globalize_path(CAPTURE_DIR.path_join("pickup_action_dialog_long_name.png"))) action_editor.call("_set_pickup_hand", "left") _check(String(action_editor.call("_selected_pickup_hand")) == "left", "hand selection records the clicked hand choice") action_editor.committed.connect(func(action: Dictionary) -> void: _resolved_action = action.duplicate(true)) action_editor.call("_on_ok_pressed") print("Pickup action commit: %s" % JSON.stringify(_resolved_action)) _check(String(_resolved_action.get("target_prop_id", "")) == eligible.stage_object_id and String(_resolved_action.get("hand", "")) == "left", "Done retains the prop ID for saved data and commits the selected hand") _check((visuals.get("_pickup_candidate_props") as Array).is_empty(), "pickup highlights can be cleared after capture ends") _print_result() quit(1 if _failures > 0 else 0) func _make_prop(stage_id: String, display_name: String, position: Vector2, eligible: bool) -> PropBlock: var prop: PropBlock = PROP_BLOCK_SCRIPT.new() as PropBlock prop.name = display_name.replace(" ", "") prop.stage_object_id = stage_id prop.stage_name = display_name prop.position = position prop.polygon_points = PackedVector2Array([Vector2(-44.0, -28.0), Vector2(44.0, -28.0), Vector2(44.0, 28.0), Vector2(-44.0, 28.0)]) prop.stage_roles = {"can_pick_up": eligible} prop.set_interaction_anchors(PROP_ANCHOR_DATA.preset_layout("one_handed", Rect2(Vector2(-44.0, -28.0), Vector2(88.0, 56.0)))) return prop func _resolve_prop_name(_prop_id: String) -> String: return "The Very Long Brown Suitcase with a Surprisingly Long Name for Layout Testing" if _long_name else "Brown Suitcase" func _button_with_text(root_node: Node, button_text: String) -> Button: if root_node == null: return null for candidate: Node in root_node.find_children("*", "Button", true, false): var button: Button = candidate as Button if button != null and button.text == button_text: return button return null func _wait_frames(count: int) -> void: for _index: int in count: await process_frame func _can_capture_image() -> bool: var display_name: String = DisplayServer.get_name().to_lower() return not OS.has_feature("headless") and display_name != "headless" and display_name != "dummy" func _check(condition: bool, description: String) -> void: if condition: print("PASS: %s" % description) else: _failures += 1 push_error("FAIL: %s" % description) func _print_result() -> void: print("PHASE20B_PICKUP_PROP_UI_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])