feat(stage): complete phase 20b action plans

This commit is contained in:
2026-11-04 01:19:57 -05:00
parent 87a33b0abc
commit a9c0886348
70 changed files with 14234 additions and 469 deletions
+157
View File
@@ -0,0 +1,157 @@
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])