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
@@ -0,0 +1,102 @@
Godot Engine v4.7.1.stable.official.a13da4feb - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 2060 SUPER
ERROR: Failed to read the root certificate store.
at: get_system_ca_certificates (platform/windows/os_windows.cpp:2582)
PHASE20B_GUI_CHECK PASS: production spawner creates the actor and named-prop fixtures
PHASE20B_GUI_CHECK PASS: fixture actors receive long and distinct stage names through production identity
PHASE20B_GUI_CHECK PASS: DIRECT mode button is present in the Stage chrome
PHASE20B_GUI_HOVER target=@Button@36 viewport_position=(640.0, 32.0) screen_position=(640.0, 32.0) window=0 window_position=(0, 0) hovered=@Button@36
PHASE20B_GUI_CHECK PASS: pointer activation switches the production Stage to DIRECT
PHASE20B_GUI_CHECK PASS: viewport pointer selects the actor in DIRECT
PHASE20B_GUI_CHECK PASS: approved empty-plan prompt is visible and fits inside the Step Stack
PHASE20B_GUI_CHECK PASS: pinned Move, Say, and Wait quick actions are visible
PHASE20B_CAPTURE path=res://tests/phase20b_captures/action_drawer_full_height/wide_empty_step_stack.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: rendered capture wide_empty_step_stack is saved at viewport size
PHASE20B_GUI_CHECK PASS: selected actor exposes the pinned Add step control
PHASE20B_GUI_HOVER target=AddActionStep viewport_position=(1192.0, 318.0) screen_position=(1192.0, 318.0) window=0 window_position=(0, 0) hovered=AddActionStep
PHASE20B_GUI_CHECK PASS: drawer visibly slides left from the inspector while fading in
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_opening.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_opening is saved at viewport size
PHASE20B_GUI_CHECK PASS: Add step opens the production Choose an action drawer
PHASE20B_GUI_CHECK PASS: drawer keeps the scrollable action list and pinned Cancel visible
PHASE20B_GUI_CHECK PASS: drawer extends beyond the inspector while keeping Cancel pinned below the list
PHASE20B_GUI_CHECK PASS: tall drawer shows every action without scrolling
PHASE20B_GUI_CHECK PASS: drawer stays above the Stage status bar and safe bottom inset
PHASE20B_GUI_CHECK PASS: drawer top aligns with inspector and opens from its left edge
PHASE20B_GUI_CHECK PASS: scrim covers the inspector bounds only
PHASE20B_GUI_CHECK PASS: inspector scrim is a light 32 percent shade while the drawer stays opaque
PHASE20B_GUI_CHECK PASS: opening the drawer moves keyboard focus to the first action choice
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_open.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_open is saved at viewport size
PHASE20B_GUI_HOVER target=CancelTogether viewport_position=(752.0, 816.0) screen_position=(752.0, 816.0) window=0 window_position=(0, 0) hovered=CancelTogether
PHASE20B_GUI_CHECK PASS: Cancel reverses the drawer slide and fades the inspector scrim
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_canceling.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_canceling is saved at viewport size
PHASE20B_GUI_CHECK PASS: Cancel slides the drawer and inspector scrim away
PHASE20B_GUI_CHECK PASS: Cancel returns keyboard focus to Add step
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_cancelled.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_cancelled is saved at viewport size
PHASE20B_GUI_HOVER target=AddActionStep viewport_position=(1192.0, 318.0) screen_position=(1192.0, 318.0) window=0 window_position=(0, 0) hovered=AddActionStep
PHASE20B_GUI_CHECK PASS: outside click starts the same reverse slide and fade
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_outside_closing.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_outside_closing is saved at viewport size
PHASE20B_GUI_CHECK PASS: left click outside slides the drawer closed
PHASE20B_GUI_CHECK PASS: outside click is consumed without clearing the actor selection on stage
PHASE20B_GUI_CHECK PASS: outside click returns keyboard focus to Add step
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_outside_clicked.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_outside_clicked is saved at viewport size
PHASE20B_GUI_HOVER target=AddActionStep viewport_position=(1192.0, 318.0) screen_position=(1192.0, 318.0) window=0 window_position=(0, 0) hovered=AddActionStep
PHASE20B_GUI_CHECK PASS: clicking the dimmed inspector closes the drawer without reopening it
PHASE20B_GUI_CHECK PASS: inspector click is consumed while the drawer is open
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_inspector_clicked.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_inspector_clicked is saved at viewport size
PHASE20B_GUI_HOVER target=AddActionStep viewport_position=(1192.0, 318.0) screen_position=(1192.0, 318.0) window=0 window_position=(0, 0) hovered=AddActionStep
PHASE20B_GUI_CHECK PASS: Escape starts the same reverse slide and fade
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_escape_closing.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_escape_closing is saved at viewport size
PHASE20B_GUI_CHECK PASS: Escape slides the drawer closed and removes the scrim
PHASE20B_GUI_CHECK PASS: Escape returns keyboard focus to Add step
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_escape_closed.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_escape_closed is saved at viewport size
PHASE20B_GUI_HOVER target=AddActionStep viewport_position=(1192.0, 318.0) screen_position=(1192.0, 318.0) window=0 window_position=(0, 0) hovered=AddActionStep
PHASE20B_GUI_CHECK PASS: drawer retains working action choices after each dismissal path
PHASE20B_GUI_HOVER target=PlanActionChoice_move_to viewport_position=(752.0, 254.0) screen_position=(752.0, 254.0) window=0 window_position=(0, 0) hovered=PlanActionChoice_move_to
PHASE20B_GUI_CHECK PASS: choosing an action waits for the drawer to close before opening its editor
PHASE20B_GUI_HOVER target=ActionEditorCancel viewport_position=(716.0, 634.0) screen_position=(716.0, 634.0) window=0 window_position=(360, 220) hovered=ActionEditorCancel
PHASE20B_GUI_CHECK PASS: canceling the opened action draft leaves the stage unchanged
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/action_drawer_action_selected.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture action_drawer_action_selected is saved at viewport size
PHASE20B_GUI_CHECK PASS: Say-only fixture provides compatible Move and Wait choices for Add together
PHASE20B_GUI_CHECK PASS: Say step exposes its Add together control
PHASE20B_GUI_HOVER target=@Button@1392 viewport_position=(1164.0, 521.0) screen_position=(1164.0, 521.0) window=0 window_position=(0, 0) hovered=@Button@1392
PHASE20B_GUI_CHECK PASS: Add together uses the same slide-out and fade as Add step
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/add_together_drawer_opening.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture add_together_drawer_opening is saved at viewport size
PHASE20B_GUI_CHECK PASS: Add together opens the shared drawer with its step title
PHASE20B_GUI_CHECK PASS: drawer preserves the source Say summary and duration context
PHASE20B_GUI_CHECK PASS: long source summary is limited to two lines and remains available in its tooltip
PHASE20B_GUI_CHECK PASS: Add together keeps only compatible Move and Wait choices
PHASE20B_GUI_CHECK PASS: Add together drawer aligns with the inspector and dims its shell
PHASE20B_GUI_CHECK PASS: Add together drawer gives focus to its first compatible choice
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/add_together_drawer_open.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture add_together_drawer_open is saved at viewport size
PHASE20B_GUI_HOVER target=CancelTogether viewport_position=(752.0, 539.0) screen_position=(752.0, 539.0) window=0 window_position=(0, 0) hovered=CancelTogether
PHASE20B_GUI_CHECK PASS: Add together Cancel slides the shared drawer and scrim back
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/add_together_drawer_canceling.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture add_together_drawer_canceling is saved at viewport size
PHASE20B_GUI_CHECK PASS: Add together Cancel slides in and restores focus to its trigger
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/action_drawer_full_height/add_together_drawer_cancelled.png size=(1280, 900) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture add_together_drawer_cancelled is saved at viewport size
PHASE20B_GUI_HOVER target=@Button@1392 viewport_position=(1164.0, 521.0) screen_position=(1164.0, 521.0) window=0 window_position=(0, 0) hovered=@Button@1392
PHASE20B_GUI_CHECK PASS: Add together closes on outside click and restores focus
PHASE20B_GUI_CHECK PASS: Add together outside click is consumed by the drawer
PHASE20B_GUI_HOVER target=@Button@1392 viewport_position=(1164.0, 521.0) screen_position=(1164.0, 521.0) window=0 window_position=(0, 0) hovered=@Button@1392
PHASE20B_GUI_CHECK PASS: Add together closes on Escape and restores focus
PHASE20B_GUI_HOVER target=@Button@1392 viewport_position=(1164.0, 521.0) screen_position=(1164.0, 521.0) window=0 window_position=(0, 0) hovered=@Button@1392
PHASE20B_GUI_HOVER target=PlanActionChoice_move_to viewport_position=(752.0, 311.0) screen_position=(752.0, 311.0) window=0 window_position=(0, 0) hovered=PlanActionChoice_move_to
PHASE20B_GUI_CHECK PASS: compatible choice opens its editor for the same step after the drawer closes
PHASE20B_GUI_HOVER target=ActionEditorCancel viewport_position=(716.0, 634.0) screen_position=(716.0, 634.0) window=0 window_position=(360, 220) hovered=ActionEditorCancel
PHASE20B_GUI_CHECK PASS: Canceling a together action draft leaves the source plan unchanged
PHASE20B_ACTION_DRAWER rect=[P: (624.0, 184.0), S: (256.0, 666.0)] card=[P: (888.0, 184.0), S: (380.0, 284.0)] dimmer=[P: (888.0, 184.0), S: (380.0, 284.0)] focus=<Object#null>
PHASE20B_GUI_SLICE_RESULT scenario=action_drawer failures=0
+50
View File
@@ -0,0 +1,50 @@
Godot Engine v4.7.1.stable.official.a13da4feb - https://godotengine.org
ERROR: Failed to read the root certificate store.
at: get_system_ca_certificates (platform/windows/os_windows.cpp:2582)
PASS: PLAN_PLACE capture Vector2 normalizes to an explicit free-place destination
PASS: exact-prop capture dictionaries preserve the stable target ID and world offset
PASS: unsupported capture payloads do not author a destination
PASS: Move plus Say is a valid together step
PASS: Move plus Wait is rejected as an authored together step
PASS: Wait step offers Say as a compatible together action
PASS: full step rejects a third together action
PASS: step reorder preserves action identity
PASS: deleting one grouped action preserves the remaining step
PASS: scene-scoped allocator avoids IDs already present on a live actor
PASS: v1.8 migrates to v1.9
PASS: v1.8 migration removes the legacy authoritative queue
PASS: keep_acting Say pairs with its following Move
PASS: paired legacy Say retains nonblocking compatibility
PASS: legacy flat Move migrates to an explicit place target
PASS: keep_acting Say pairs with its following Wait
PASS: unpaired legacy Say retains its explicit compatibility flag
PASS: legacy action IDs are deterministic across repeated migration
PASS: v1.8 migration keeps its authored background values
PASS: malformed legacy actions keep skip-and-warn migration behavior
PASS: canonical plans encode without warnings
PASS: valid v1.9 grouped Move+Say and place actions validate
PASS: prop destination persists the exact stage object ID
PASS: prop destination persists its world-aligned offset
PASS: duplicate stable action IDs across actors are rejected
PASS: v1.9 rejects a second authoritative action_queue
PASS: v1.9 rejects incompatible grouped actions
PASS: v1.9 rejects a prop destination with a missing exact ID
PASS: v1.9 plan document writes and reads through the production IO path
PASS: round-trip preserves stable action IDs
PASS: round-trip decodes the prop offset as a Vector2
PASS: nearby free-place intent remains an explicit place after round-trip
PASS: runtime fixture accepts a grouped plan
PASS: group actions emit start together in plan order
PASS: grouped actions start on the same runtime tick
PASS: next step waits after primary Wait ends while grouped Say remains active
PASS: paused runtime freezes the Say barrier
PASS: completed Say releases the barrier before the next runner tick
PASS: next step starts only after every grouped action ends
PASS: pursuit fixture accepts an exact prop Move
PASS: prop Move starts toward its world-aligned approach offset
PASS: moving prop retargets after its shift using the fixed world offset
PASS: paused pursuit does not retarget or spend its time budget
PASS: pursuit resumes and retargets from the prop's translated position
PASS: vanished prop ends the active Move as failure, not arrival
PHASE 20B ACTION PLAN: 45 checks, 0 failures
@@ -0,0 +1,279 @@
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const CAPTURE_DIR: String = "res://tests/phase20b_captures/capture_overlay_review"
var _stage: SandboxStage
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var capture_width: int = int(OS.get_environment("PHASE20B_CAPTURE_WIDTH"))
root.size = Vector2i(capture_width if capture_width > 0 else 1280, 720)
root.gui_embed_subwindows = true
var make_dir_error: Error = DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR))
if make_dir_error != OK and make_dir_error != ERR_ALREADY_EXISTS:
_fail("could not create capture output directory")
quit(1)
return
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await _wait_frames(8)
var recovery: ConfirmationDialog = _stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog
if recovery != null:
recovery.hide()
var spawner: StageSpawner = _stage.get("_spawner") as StageSpawner
var actor: 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(actor != null and prop != null, "production spawner creates capture fixtures")
if actor == null or prop == null:
await _finish()
return
_stage.call("_finalize_placed_node", actor)
_stage.call("_finalize_placed_node", prop)
var selection: StageSelection = _stage.get("_selection") as StageSelection
_stage.set_mode(SandboxStage.StageMode.DIRECT)
selection.select_only(actor)
_stage.call("_refresh_character_settings_card")
await _wait_frames(6)
var character_view: StageCharacterSettingsView = _stage.get("_character_settings_view") as StageCharacterSettingsView
var character_card: Control = character_view.get_card()
var quick_actions: Control = character_view.find_child("QuickActionButtons", true, false) as Control
var wait_button: Button = _find_button_with_text(quick_actions, "⏳ Wait")
_check(character_card != null and character_card.is_visible_in_tree() and wait_button != null, "selected character inspector and quick action controls are visible")
var visual_scenario: String = OS.get_environment("PHASE20B_CAPTURE_SCENARIO")
if visual_scenario.is_empty():
visual_scenario = "plan"
if visual_scenario == "set_down":
await _send_mouse_motion(Vector2(350.0, 520.0))
_stage.call("_begin_set_down_position_capture", actor, "Click where to let go", Callable(), Callable())
await _wait_frames(3)
var set_down_overlay: StageInspectorCaptureOverlay = _stage.get("_inspector_capture_overlay") as StageInspectorCaptureOverlay
_check(set_down_overlay.has_capture() and set_down_overlay.get_inspector_rect().is_equal_approx(character_card.get_global_rect()), "set-down capture attaches the strip to the selected character inspector")
var set_down_prompt: Label = set_down_overlay.find_child("CapturePrompt", true, false) as Label
var set_down_strip: Control = set_down_overlay.find_child("CaptureCancelStrip", true, false) as Control
var set_down_cancel: Button = _find_button_with_text(set_down_overlay, "Cancel")
_check(set_down_prompt != null and set_down_prompt.text == "Set down prop", "set-down capture caption only names the action")
_check(set_down_cancel != null and set_down_strip != null and set_down_cancel.get_global_rect().end.x <= set_down_strip.get_global_rect().end.x + 1.0, "set-down Cancel stays inside the inspector strip")
var set_down_tooltip: Control = _stage.find_child("ActionTooltip", true, false) as Control
_check(set_down_tooltip == null or not set_down_tooltip.visible, "set-down capture keeps instructions out of the inspector prompt")
await _save_capture("set_down_capture_attached_cancel")
await _send_escape()
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and not set_down_overlay.has_capture(), "Escape cancels set-down capture and removes the dim layer")
print("PHASE20B_CAPTURE_OVERLAY_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])
await _finish()
return
if visual_scenario == "use_spot":
_stage.set_mode(SandboxStage.StageMode.EDIT)
selection.select_only(prop)
_stage.call("_refresh_prop_settings_card")
await _wait_frames(3)
_stage.call("_begin_spot_edit", prop, "")
await _wait_frames(3)
var spot_overlay: StageInspectorCaptureOverlay = _stage.get("_inspector_capture_overlay") as StageInspectorCaptureOverlay
var prop_card: Control = (_stage.get("_prop_settings_view") as StagePropSettingsView).get_card()
_check(spot_overlay.has_capture() and spot_overlay.get_inspector_rect().is_equal_approx(prop_card.get_global_rect()), "Use-spot capture attaches the strip to the selected prop inspector")
var spot_instruction: String = String((_stage.get("_use_spot_overlay") as StageUseSpotOverlay).get("_instruction"))
_check(spot_instruction.contains("Press Escape or right-click to cancel"), "Use-spot cursor pill includes cancellation instructions")
_check(not (_stage.get("_chrome") as StageChrome).get_tooltip().visible, "Use-spot capture avoids a duplicate Stage tooltip")
await _save_capture("use_spot_capture_attached_cancel")
var spot_scrim: Control = spot_overlay.find_child("InspectorInputBlocker", true, false) as Control
if spot_scrim != null:
await _send_right_click(spot_scrim.get_global_rect().get_center())
await _wait_frames(3)
_check(not spot_overlay.has_capture() and not bool(_stage.get("_spot_edit_active")), "right-click cancels Use-spot placement")
print("PHASE20B_CAPTURE_OVERLAY_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])
await _finish()
return
if visual_scenario == "prop_target":
await _send_mouse_motion(Vector2(350.0, 520.0))
_stage.call("_begin_capture", SandboxStage.CaptureKind.PROP, "Choose a prop for break", Callable(), Callable())
await _wait_frames(3)
var prop_overlay: StageInspectorCaptureOverlay = _stage.get("_inspector_capture_overlay") as StageInspectorCaptureOverlay
_check(prop_overlay.has_capture() and prop_overlay.get_inspector_rect().is_equal_approx(character_card.get_global_rect()), "prop-target capture attaches Cancel to the character inspector")
_check_capture_tooltip_layout(prop_overlay, _stage.get("_chrome") as StageChrome)
await _save_capture("prop_target_capture_attached_cancel")
await _send_escape()
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and not prop_overlay.has_capture(), "Escape cancels prop-target capture")
print("PHASE20B_CAPTURE_OVERLAY_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])
await _finish()
return
if visual_scenario == "use_target":
var empty_preview_prefix: Array[Dictionary] = []
_stage.call("_begin_use_target_capture", "direct", actor, empty_preview_prefix, true)
_stage.call("_select_use_target_prop", prop, Vector2.ZERO)
await _wait_frames(3)
var use_overlay: StageInspectorCaptureOverlay = _stage.get("_inspector_capture_overlay") as StageInspectorCaptureOverlay
var use_prompt: Label = use_overlay.find_child("CapturePrompt", true, false) as Label
var use_strip: Control = use_overlay.find_child("CaptureCancelStrip", true, false) as Control
var use_actions: Control = use_overlay.find_child("CaptureActions", true, false) as Control
var use_cancel: Button = _find_button_with_text(use_overlay, "Cancel")
_check(use_prompt != null and use_prompt.text == "Use", "Use target capture keeps a short action caption")
_check(use_strip != null and use_strip.size.y >= 112.0 and use_actions != null and use_actions.get_global_rect().position.y >= use_prompt.get_global_rect().position.y, "multiple capture actions stack below the title")
_check(use_cancel != null and use_cancel.visible and use_cancel.custom_minimum_size.y >= 44.0 and use_cancel.get_global_rect().end.x <= use_strip.get_global_rect().end.x + 1.0, "stacked Cancel remains visible and touch-sized")
_check_capture_tooltip_layout(use_overlay, _stage.get("_chrome") as StageChrome)
await _save_capture("use_target_capture_stacked_actions")
_stage.call("_cancel_capture")
_check(not use_overlay.has_capture(), "Cancel closes the stacked Use-target capture")
print("PHASE20B_CAPTURE_OVERLAY_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])
await _finish()
return
await _send_mouse_motion(Vector2(350.0, 520.0))
_stage.call("_begin_capture", SandboxStage.CaptureKind.PLAN_PLACE, "Choose a place for break", Callable(), Callable())
await _wait_frames(3)
var overlay: StageInspectorCaptureOverlay = _stage.get("_inspector_capture_overlay") as StageInspectorCaptureOverlay
var cancel_button: Button = overlay.find_child("Capture_cancel_capture", true, false) as Button
_check(overlay.has_capture() and overlay.visible and not (_stage.get("_use_target_capture_bar") as StageUseTargetCaptureBar).visible, "place capture shows the attached inspector strip instead of a detached capture bar")
_check(overlay.get_inspector_rect().is_equal_approx(character_card.get_global_rect()), "capture dimmer aligns with the selected character inspector")
_check(cancel_button != null and cancel_button.visible and cancel_button.custom_minimum_size.y >= 44.0 and cancel_button.has_focus(), "Cancel is visible, touch-sized, and owns keyboard focus")
var tooltip: Control = _stage.find_child("ActionTooltip", true, false) as Control
var chrome: StageChrome = _stage.get("_chrome") as StageChrome
_check(tooltip != null and tooltip.visible and String(tooltip.get("text")).contains("Press Escape or right-click to cancel"), "cursor hint includes the full cancel shortcuts")
if tooltip != null and chrome != null:
_check(not tooltip.get_global_rect().intersects(overlay.get_inspector_rect()), "cursor hint avoids the active inspector")
_check(not tooltip.get_global_rect().intersects(chrome.get_timestamp_badge().get_global_rect()), "cursor hint avoids the timer badge")
await _save_capture("place_capture_attached_cancel")
if wait_button != null:
await _send_click(wait_button.get_global_rect().get_center())
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.PLAN_PLACE and not (_stage.get("_wait_dialog") as Window).visible, "dimmed inspector controls cannot be activated by clicking through the scrim")
if cancel_button != null:
await _send_click(cancel_button.get_global_rect().get_center())
await _wait_frames(2)
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and not overlay.has_capture(), "attached Cancel resolves through the production capture cancel path")
if visual_scenario == "plan":
print("PHASE20B_CAPTURE_OVERLAY_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])
await _finish()
return
_stage.call("_begin_set_down_position_capture", actor, "Click where to let go", Callable(), Callable())
await _wait_frames(2)
_check(overlay.has_capture(), "set-down target capture uses the same inspector cancel strip")
await _save_capture("set_down_capture_attached_cancel")
var escape: InputEventKey = InputEventKey.new()
escape.keycode = KEY_ESCAPE
escape.pressed = true
Input.parse_input_event(escape)
await _wait_frames(3)
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and not overlay.has_capture(), "Escape cancels set-down capture and removes the dim layer")
_stage.set_mode(SandboxStage.StageMode.EDIT)
selection.select_only(prop)
_stage.call("_refresh_prop_settings_card")
await _wait_frames(3)
_stage.call("_begin_spot_edit", prop, "")
await _wait_frames(3)
_check(overlay.has_capture() and overlay.get_inspector_rect().is_equal_approx((_stage.get("_prop_settings_view") as StagePropSettingsView).get_card().get_global_rect()), "Use-spot placement attaches Cancel to and dims the prop inspector")
await _save_capture("use_spot_capture_attached_cancel")
var scrim: Control = overlay.find_child("InspectorInputBlocker", true, false) as Control
if scrim != null:
await _send_right_click(scrim.get_global_rect().get_center())
await _wait_frames(3)
_check(not overlay.has_capture() and not bool(_stage.get("_spot_edit_active")), "right-clicking the dimmed inspector cancels spot placement")
print("PHASE20B_CAPTURE_OVERLAY_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])
await _finish()
func _find_button_with_text(root_node: Node, text: String) -> Button:
if root_node == null:
return null
for node: Node in root_node.find_children("*", "Button", true, false):
var button: Button = node as Button
if button != null and button.text == text:
return button
return null
func _check_capture_tooltip_layout(overlay: StageInspectorCaptureOverlay, chrome: StageChrome) -> void:
var tooltip: Control = _stage.find_child("ActionTooltip", true, false) as Control
_check(tooltip != null and tooltip.visible and String(tooltip.get("text")).contains("Press Escape or right-click to cancel"), "capture tooltip includes the Escape and right-click cancel hint")
if tooltip == null or chrome == null or overlay == null:
return
_check(not tooltip.get_global_rect().intersects(overlay.get_inspector_rect()), "capture tooltip avoids the active inspector")
_check(not tooltip.get_global_rect().intersects(chrome.get_timestamp_badge().get_global_rect()), "capture tooltip avoids the timer badge")
func _send_click(position: Vector2) -> void:
var motion: InputEventMouseMotion = InputEventMouseMotion.new()
motion.position = position
motion.global_position = position
Input.parse_input_event(motion)
await process_frame
var down: InputEventMouseButton = InputEventMouseButton.new()
down.button_index = MOUSE_BUTTON_LEFT
down.pressed = true
down.position = position
down.global_position = position
Input.parse_input_event(down)
await process_frame
var up: InputEventMouseButton = InputEventMouseButton.new()
up.button_index = MOUSE_BUTTON_LEFT
up.pressed = false
up.position = position
up.global_position = position
Input.parse_input_event(up)
await _wait_frames(2)
func _send_mouse_motion(position: Vector2) -> void:
var motion: InputEventMouseMotion = InputEventMouseMotion.new()
motion.position = position
motion.global_position = position
Input.parse_input_event(motion)
await _wait_frames(2)
func _send_right_click(position: Vector2) -> void:
var event: InputEventMouseButton = InputEventMouseButton.new()
event.button_index = MOUSE_BUTTON_RIGHT
event.pressed = true
event.position = position
event.global_position = position
Input.parse_input_event(event)
await _wait_frames(3)
func _send_escape() -> void:
var escape: InputEventKey = InputEventKey.new()
escape.keycode = KEY_ESCAPE
escape.pressed = true
Input.parse_input_event(escape)
await _wait_frames(3)
func _save_capture(file_name: String) -> void:
await RenderingServer.frame_post_draw
var image: Image = root.get_texture().get_image()
var error: Error = image.save_png(ProjectSettings.globalize_path(CAPTURE_DIR.path_join(file_name + ".png")))
_check(error == OK and image.get_size() == root.size, "rendered %s capture saves at viewport size" % file_name)
func _wait_frames(count: int) -> void:
for _index: int in count:
await process_frame
await RenderingServer.frame_post_draw
func _check(condition: bool, message: String) -> void:
if condition:
print("PHASE20B_CAPTURE_OVERLAY_CHECK PASS: %s" % message)
else:
_fail(message)
func _fail(message: String) -> void:
_failures += 1
push_error("PHASE20B_CAPTURE_OVERLAY_CHECK FAIL: %s" % message)
func _finish() -> void:
if _stage != null and is_instance_valid(_stage):
_stage.queue_free()
await process_frame
quit(1 if _failures > 0 else 0)
@@ -0,0 +1 @@
uid://w38bcl5nayq0
+76
View File
@@ -0,0 +1,76 @@
Godot Engine v4.7.1.stable.official.a13da4feb - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 2060 SUPER
ERROR: Failed to read the root certificate store.
at: get_system_ca_certificates (platform/windows/os_windows.cpp:2582)
PHASE20B_GUI_CHECK PASS: production spawner creates the actor and named-prop fixtures
PHASE20B_GUI_CHECK PASS: fixture actors receive long and distinct stage names through production identity
PHASE20B_GUI_CHECK PASS: DIRECT mode button is present in the Stage chrome
PHASE20B_GUI_HOVER target=@Button@36 viewport_position=(640.0, 32.0) screen_position=(640.0, 32.0) window=0 window_position=(0, 0) hovered=@Button@36
PHASE20B_GUI_CHECK PASS: pointer activation switches the production Stage to DIRECT
PHASE20B_GUI_CHECK PASS: viewport pointer selects the actor in DIRECT
PHASE20B_GUI_CHECK PASS: approved empty-plan prompt is visible and fits inside the Step Stack
PHASE20B_GUI_CHECK PASS: pinned Move, Say, and Wait quick actions are visible
PHASE20B_CAPTURE path=res://tests/phase20b_captures/implementation_style/wide_empty_step_stack.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: rendered capture wide_empty_step_stack is saved at viewport size
PHASE20B_GUI_CHECK PASS: visual-only fixture supplies one committed Walk step for Add together authoring
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_visual_fixture_move_step.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_visual_fixture_move_step is saved at viewport size
PHASE20B_GUI_CHECK PASS: fixture Move shows plain Step 1 text with no tooltip
PHASE20B_GUI_CHECK PASS: whole step card is keyboard-focusable
PHASE20B_GUI_CHECK PASS: Enter activates the focused step card
PHASE20B_GUI_HOVER target=@Button@1388 viewport_position=(1164.0, 521.0) screen_position=(1164.0, 521.0) window=0 window_position=(0, 0) hovered=@Button@1388
PHASE20B_GUI_CHECK PASS: Add together chooser omits the compatibility helper copy
PHASE20B_GUI_SLICE together_popup rect=[P: (818.0, 418.4), S: (256.0, 228.0)] trigger=[P: (1082.0, 499.0), S: (164.0, 44.0)] custom=(256.0, 228.0) title=Add to Step 1 scroll=[P: (828.0, 428.4), S: (236.0, 208.0)] choices=1 cancel=(236.0, 48.0) focus=@Button@1389:<Button#225888444127>
PHASE20B_GUI_CHECK PASS: Add together interaction keeps the plan actor selected
PHASE20B_GUI_CHECK PASS: Move step offers one Say choice and a touch-sized visible Cancel
PHASE20B_GUI_CHECK PASS: Add together chooser repeats the source action while covering the Step Stack row
PHASE20B_GUI_CHECK PASS: Add together chooser names its step and stays inside the safe viewport
PHASE20B_GUI_CHECK PASS: Add together chooser stays visually attached to its trigger without covering the pinned actions
PHASE20B_GUI_CHECK PASS: keyboard focus moves to the compatible Say choice
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_final_add_together_picker.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_final_add_together_picker is saved at viewport size
PHASE20B_GUI_HOVER target=@Button@1389 viewport_position=(946.0, 520.4) screen_position=(946.0, 520.4) window=0 window_position=(0, 0) hovered=@Button@1389
PHASE20B_GUI_CHECK PASS: visible Say choice opens the production speech composer
PHASE20B_GUI_CHECK PASS: speech draft exposes its live bubble preview
PHASE20B_GUI_CHECK PASS: speech draft exposes its labeled Done speech control
PHASE20B_GUI_HOVER target=@Button@1317 viewport_position=(926.5724, 488.0) screen_position=(926.5724, 488.0) window=0 window_position=(0, 0) hovered=@Button@1317
PHASE20B_GUI_SPEECH_VALIDATION focus=0 events=[{ "message": "Each bubble needs text and must fit within 240 characters.", "bubble_index": 0 }] message_rect=[P: (644.0724, 194.0), S: (267.0, 56.0)]
PHASE20B_GUI_CHECK PASS: Done speech validation guides the author to the first empty bubble
PHASE20B_GUI_CHECK PASS: validation focus exposes the production Message control for the bubble
PHASE20B_GUI_CHECK PASS: production validation opens and focuses the bubble's Message editor
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_final_grouped_say_message_dialog.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_final_grouped_say_message_dialog is saved at viewport size
PHASE20B_GUI_CHECK PASS: validation focus exposes the production Message control
PHASE20B_GUI_CHECK PASS: production validation opens and focuses the speech text editor
PHASE20B_GUI_CHECK PASS: Message text is entered through the focused production TextEdit
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_final_grouped_say_composer.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_final_grouped_say_composer is saved at viewport size
PHASE20B_GUI_HOVER target=@Button@1232 viewport_position=(957.0724, 474.0) screen_position=(957.0724, 474.0) window=0 window_position=(0, 0) hovered=@Button@1232
PHASE20B_GUI_HOVER target=@Button@1257 viewport_position=(1002.072, 424.0) screen_position=(1002.072, 424.0) window=0 window_position=(0, 0) hovered=@Button@1257
PHASE20B_GUI_HOVER target=@Button@1317 viewport_position=(1012.572, 488.0) screen_position=(1012.572, 488.0) window=0 window_position=(0, 0) hovered=@Button@1317
PHASE20B_GUI_CHECK PASS: real Add together and Done speech interactions commit exactly one Say alongside the Move fixture
PHASE20B_GUI_CHECK PASS: committed grouped Say preserves the entered message
PHASE20B_GUI_CHECK PASS: Step Stack spells out the Say label and duration in the committed action summary
PHASE20B_GUI_CHECK PASS: Say row separates its spoken text and secondary duration like the mockup
PHASE20B_GUI_CHECK PASS: Say row uses the same 48 px twin-rail layout and options cue as Move
PHASE20B_GUI_CHECK PASS: Say row uses the matching warm tint from the approved mockup
PHASE20B_GUI_CHECK PASS: grouped Say marker projects once at the same starting point as Move
PHASE20B_GUI_CHECK PASS: transient validation status clears before the committed-plan capture
PHASE20B_SAY_MARKER center_world=(-344.0, -25.0) center_screen=(468.0, 547.5) sample=[P: (444.0, 527.5), S: (48.0, 40.0)] yellow_pixels=299
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_final_walk_say_committed.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_final_walk_say_committed is saved at viewport size
PHASE20B_GUI_CHECK PASS: grouped Say marker is visibly rendered at the projected Step 1 start
PHASE20B_GUI_HOVER target=@Button@184 viewport_position=(1199.5, 262.0) screen_position=(1199.5, 262.0) window=0 window_position=(0, 0) hovered=@Button@184
PHASE20B_WAIT_LAYOUT [[P: (360.0, 130.0), S: (560.0, 460.0)], [P: (2.0, 2.0), S: (556.0, 456.0)], (332.0, 204.0), 0.0, 0.0, 0.0, 0.0, [P: (24.0, 22.0), S: (512.0, 418.0)], (288.0, 166.0), [P: (24.0, 124.0), S: (512.0, 252.0)], (120.0, 0.0), (120.0, 68.0), [P: (424.0, 388.0), S: (112.0, 52.0)]]
PHASE20B_GUI_CHECK PASS: Wait dialog and Done footer remain inside the production popup and viewport
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_wait_dialog_footer_visible.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_wait_dialog_footer_visible is saved at viewport size
PHASE20B_GUI_HOVER target=ActionEditorDone viewport_position=(840.0, 544.0) screen_position=(840.0, 544.0) window=0 window_position=(360, 130) hovered=ActionEditorDone
PHASE20B_GUI_CHECK PASS: quick Wait appends a separate second step after the committed Walk+Say
PHASE20B_GUI_CHECK PASS: one-second Wait summary uses singular grammar
PHASE20B_GUI_CHECK PASS: Step 2 uses the same trailing action-options cue as Step 1
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_final_walk_say_then_wait.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_final_walk_say_then_wait is saved at viewport size
PHASE20B_GUI_CHECK PASS: valid production commits mark the scene dirty
PHASE20B_GUI_SLICE_RESULT scenario=grouping failures=0
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://bhqgkkobko7o0
+57
View File
@@ -0,0 +1,57 @@
Godot Engine v4.7.1.stable.official.a13da4feb - https://godotengine.org
Vulkan 1.4.351 - Forward+ - Using Device #0: NVIDIA - NVIDIA GeForce RTX 2060 SUPER
ERROR: Failed to read the root certificate store.
at: get_system_ca_certificates (platform/windows/os_windows.cpp:2582)
PHASE20B_GUI_CHECK PASS: production spawner creates the actor and named-prop fixtures
PHASE20B_GUI_CHECK PASS: fixture actors receive long and distinct stage names through production identity
PHASE20B_GUI_CHECK PASS: DIRECT mode button is present in the Stage chrome
PHASE20B_GUI_HOVER target=@Button@36 viewport_position=(640.0, 32.0) screen_position=(640.0, 32.0) window=0 window_position=(0, 0) hovered=@Button@36
PHASE20B_GUI_CHECK PASS: pointer activation switches the production Stage to DIRECT
PHASE20B_GUI_CHECK PASS: viewport pointer selects the actor in DIRECT
PHASE20B_GUI_CHECK PASS: approved empty-plan prompt is visible and fits inside the Step Stack
PHASE20B_GUI_CHECK PASS: pinned Move, Say, and Wait quick actions are visible
PHASE20B_CAPTURE path=res://tests/phase20b_captures/implementation_style/wide_empty_step_stack.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: rendered capture wide_empty_step_stack is saved at viewport size
PHASE20B_GUI_CHECK PASS: visual fixture seeds a grouped Walk+Say step followed by a Run step
PHASE20B_GUI_CHECK PASS: production spawner creates a second actor for Step Spotlight visibility checks
PHASE20B_GUI_CHECK PASS: second actor receives its distinct stage name through production identity
PHASE20B_GUI_CHECK PASS: second actor receives its own visual-only Run route
PHASE20B_GUI_CHECK PASS: DIRECT defaults Step Spotlight to the selected actor's first pending step
PHASE20B_GUI_CHECK PASS: theme loads the thicker, brighter route and contrast edge
PHASE20B_GUI_CHECK PASS: theme loads prominent rounded action badges and clear headwear clearance
PHASE20B_GUI_CHECK PASS: DIRECT Step Spotlight draws the selected actor's name badge
PHASE20B_GUI_CHECK PASS: DIRECT name badge stays above visible headwear with at least 24 screen pixels of clearance
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_direct_name_badge_clearance.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_direct_name_badge_clearance is saved at viewport size
PHASE20B_GUI_CHECK PASS: unselected actors' hidden routes do not retain invisible hit targets
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_step_spotlight_step1_walk_say.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_step_spotlight_step1_walk_say is saved at viewport size
PHASE20B_GUI_CHECK PASS: action summary and plain Step 1 title are selectable without a redundant tooltip
PHASE20B_GUI_CHECK PASS: hovering the plain step title does not shift or resize it
PHASE20B_GUI_CHECK PASS: clicking blank Step 2 card space selects it with the amber border
PHASE20B_GUI_HOVER target=@Button@1523 viewport_position=(1156.0, 628.0) screen_position=(1156.0, 628.0) window=0 window_position=(0, 0) hovered=@Button@1523
PHASE20B_GUI_CHECK PASS: Add together selects its parent step while opening the choice picker
PHASE20B_GUI_HOVER target=CancelTogether viewport_position=(938.0, 604.0) screen_position=(938.0, 604.0) window=0 window_position=(0, 0) hovered=CancelTogether
PHASE20B_GUI_HOVER target=ActionSummary_spotlight-milo-step-2 viewport_position=(1074.0, 575.0) screen_position=(1074.0, 575.0) window=0 window_position=(0, 0) hovered=ActionSummary_spotlight-milo-step-2
PHASE20B_GUI_CHECK PASS: selecting Step 2 action opens its options and amber action highlight
PHASE20B_GUI_HOVER target=StepHeading viewport_position=(977.5, 403.0) screen_position=(977.5, 403.0) window=0 window_position=(0, 0) hovered=StepHeading
PHASE20B_GUI_CHECK PASS: selecting Step 1 heading closes the previously expanded Step 2 action
PHASE20B_GUI_CHECK PASS: step selection clears the old action highlight and leaves the amber border on its card
PHASE20B_GUI_CHECK PASS: selected step title remains plain text without a tooltip
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_step_spotlight_step1_heading_selected.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_step_spotlight_step1_heading_selected is saved at viewport size
PHASE20B_GUI_HOVER target=StepHeading viewport_position=(1020.0, 587.0) screen_position=(1020.0, 587.0) window=0 window_position=(0, 0) hovered=StepHeading
PHASE20B_GUI_CHECK PASS: clicking the Step 2 heading switches the Step Spotlight
PHASE20B_GUI_CHECK PASS: faint prior-step flag keeps its action-ID hit target
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_step_spotlight_step2_run_ghost_flag.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_step_spotlight_step2_run_ghost_flag is saved at viewport size
PHASE20B_GUI_CHECK PASS: clicking a ghost flag returns focus to its authored step
PHASE20B_GUI_CHECK PASS: switching Step Spotlight by card or flag does not dirty the scene
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_step_spotlight_ghost_flag_focus.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_step_spotlight_ghost_flag_focus is saved at viewport size
PHASE20B_GUI_CHECK PASS: DIRECT name badge moves beside a head near the top toolbar
PHASE20B_GUI_CHECK PASS: top-edge name badge stays inside the safe viewport
PHASE20B_CAPTURE_STABLE path=res://tests/phase20b_captures/implementation_style/phase20b_direct_name_badge_top_edge.png size=(1280, 720) error=0
PHASE20B_GUI_CHECK PASS: settled rendered capture phase20b_direct_name_badge_top_edge is saved at viewport size
PHASE20B_GUI_SLICE_RESULT scenario=step_spotlight failures=0
+129
View File
@@ -6,10 +6,12 @@ const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const PROP_BLOCK_SCRIPT := preload("res://scripts/props/prop_block.gd")
const PROP_INTERACTOR_SCRIPT := preload("res://scripts/characters/stickman/stickman_prop_interactor.gd")
const PROP_ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
var _checks: int = 0
var _failures: int = 0
var _pickup_test_prop: PropBlock = null
func _initialize() -> void:
@@ -22,6 +24,8 @@ func _run() -> void:
_test_set_down_serialization()
_test_unknown_preview()
await _test_runtime_profiles()
await _test_set_down_owner_collision_grace()
await _test_reserved_pickup_collision_protection()
await _test_start_still_support_contact()
await _test_set_down_clearance_and_release()
await _test_set_down_preserves_pickup_orientation()
@@ -140,6 +144,131 @@ func _test_runtime_profiles() -> void:
await process_frame
func _test_set_down_owner_collision_grace() -> void:
var world := Node2D.new()
root.add_child(world)
var owner_rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
world.add_child(owner_rig)
var other_rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
other_rig.position = Vector2(1000.0, 0.0)
world.add_child(other_rig)
await physics_frame
var owner_bodies: Dictionary = owner_rig.call("get_collision_bodies")
var torso: AnimatableBody2D = owner_bodies.get("torso") as AnimatableBody2D
_expect(torso != null, "Set Down grace fixture has an animated torso collider")
if torso == null:
world.queue_free()
await process_frame
return
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
prop.polygon_points = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)])
prop.gravity_scale = 0.0
world.add_child(prop)
await physics_frame
prop.global_position = torso.global_position
prop.set_stage_roles(PROP_STAGE_ROLES.new_placement(true))
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
prop.interaction_owner_id = owner_rig.get_instance_id()
var interactor: Node = owner_rig.get_node(NodePath("RigComponents/PropInteractor"))
interactor.set("_held_prop", prop)
interactor.call("_release_held", "set_down", false, Vector2.ZERO, 0.0, true, false)
for _frame: int in 4:
await physics_frame
_expect(prop.runtime_role == PropBlock.RuntimeRole.RELEASED and prop.is_actor_blocking(), "Set Down grace preserves the prop's authored released collision role")
_expect(prop.is_owner_collision_grace_active_for(owner_rig), "Set Down release starts grace for the releasing stickman")
_expect(not prop.is_owner_collision_grace_active_for(other_rig), "Set Down grace does not apply to a different stickman")
var owner_exceptions: Array[PhysicsBody2D] = torso.get_collision_exceptions()
_expect(owner_exceptions.has(prop) and prop.get_collision_exceptions().has(torso), "Set Down grace installs a symmetric exception for the releasing stickman's collider")
var other_bodies: Dictionary = other_rig.call("get_collision_bodies")
var other_torso: AnimatableBody2D = other_bodies.get("torso") as AnimatableBody2D
_expect(other_torso != null and not other_torso.get_collision_exceptions().has(prop), "other stickmen keep ordinary prop collision")
var locomotion: Node = owner_rig.call("get_locomotion") as Node
var owner_sweep: Dictionary = locomotion.call("_sweep_actor_blocking_props", Vector2(40.0, 0.0))
_expect(float(owner_sweep.get("fraction", 0.0)) >= 0.999, "the releasing stickman's movement sweep skips its grace prop")
owner_rig.global_position += Vector2(1000.0, 0.0)
await physics_frame
_expect(prop.is_owner_collision_grace_active_for(owner_rig), "Set Down grace waits for a second clear physics frame")
await physics_frame
_expect(not prop.is_owner_collision_grace_active_for(owner_rig), "Set Down grace ends after the releasing stickman's collision shapes clear the prop")
_expect(not torso.get_collision_exceptions().has(prop) and not prop.get_collision_exceptions().has(torso), "clearance removes both sides of the temporary collision exception")
prop.apply_runtime_role(PropBlock.RuntimeRole.HELD)
interactor.set("_held_prop", prop)
interactor.call("_release_held", "manual", false, Vector2.ZERO, 0.0, true, false)
_expect(not prop.is_owner_collision_grace_active_for(owner_rig), "Drop does not receive Set Down collision grace")
world.queue_free()
await process_frame
func _test_reserved_pickup_collision_protection() -> void:
var world := Node2D.new()
root.add_child(world)
var rig := STICKMAN_RIG_SCENE.instantiate() as Node2D
world.add_child(rig)
await physics_frame
var collision_bodies: Dictionary = rig.call("get_collision_bodies")
var torso: AnimatableBody2D = collision_bodies.get("torso") as AnimatableBody2D
_expect(torso != null, "pickup protection fixture has an animated torso collider")
if torso == null:
world.queue_free()
await process_frame
return
var prop: PropBlock = PROP_BLOCK_SCRIPT.new()
prop.stage_object_id = "pickup-protection-test-prop"
prop.polygon_points = PackedVector2Array([Vector2(-24.0, -20.0), Vector2(24.0, -20.0), Vector2(24.0, 20.0), Vector2(-24.0, 20.0)])
prop.gravity_scale = 0.0
world.add_child(prop)
await physics_frame
prop.global_position = torso.global_position + Vector2(800.0, 0.0)
prop.set_interaction_anchors(PROP_ANCHOR_DATA.preset_layout("one_handed", Rect2(Vector2(-24.0, -20.0), Vector2(48.0, 40.0))))
var roles: Dictionary = PROP_STAGE_ROLES.new_placement(true)
roles["blocks_stickmen"] = true
roles["released_blocks_stickmen"] = true
prop.set_stage_roles(roles)
prop.apply_runtime_role(PropBlock.RuntimeRole.RELEASED)
_pickup_test_prop = prop
var interactor: Node = rig.get_node(NodePath("RigComponents/PropInteractor"))
interactor.set("_resolver", Callable(self, "_resolve_pickup_test_prop"))
var action := {"type": "pick_up_prop", "target_prop_id": prop.stage_object_id, "hand": "auto"}
var started: bool = bool(interactor.call("start_action", action))
_expect(started and int(interactor.get("_phase")) == PROP_INTERACTOR_SCRIPT.Phase.APPROACHING, "pickup reserves a distant prop and enters the approach phase")
_expect(prop.interaction_reserved_by == rig.get_instance_id() and prop.is_owner_collision_grace_active_for(rig), "pickup reservation starts protection for its target")
for _frame: int in 5:
await physics_frame
_expect(prop.is_owner_collision_grace_active_for(rig), "reserved pickup protection stays active while the stickman is far away")
var locomotion: Node = rig.call("get_locomotion") as Node
var approach_sweep: Dictionary = locomotion.call("_sweep_actor_blocking_props", Vector2(1000.0, 0.0))
_expect(float(approach_sweep.get("fraction", 0.0)) >= 0.999, "the owner's approach sweep skips its reserved pickup target")
var hand_marker: Marker2D = interactor.call("_hand_marker") as Marker2D
interactor.call("_commit_pickup", hand_marker)
_expect(prop.runtime_role == PropBlock.RuntimeRole.HELD and not prop.is_owner_collision_grace_active_for(rig), "successful pickup clears target protection through the held profile")
interactor.call("reset_for_scene_exit")
await physics_frame
var torso_local: Vector2 = rig.to_local(torso.global_position)
locomotion.call("cancel_walk")
rig.global_position = prop.global_position - torso_local
await physics_frame
var retry_started: bool = bool(interactor.call("start_action", action))
_expect(retry_started and prop.is_owner_collision_grace_active_for(rig), "a retry protects its target before approach begins")
locomotion.call("cancel_walk")
interactor.call("cancel_action")
_expect(prop.interaction_reserved_by == 0 and prop.is_owner_collision_grace_active_for(rig), "cancel releases the reservation but keeps exit protection")
for _frame: int in 4:
await physics_frame
_expect(prop.is_owner_collision_grace_active_for(rig), "cancel protection remains while the stickman still overlaps the target")
rig.global_position += Vector2(1000.0, 0.0)
await physics_frame
_expect(prop.is_owner_collision_grace_active_for(rig), "cancel protection waits for a second clear frame")
await physics_frame
_expect(not prop.is_owner_collision_grace_active_for(rig), "cancel protection clears after the stickman leaves the target")
_pickup_test_prop = null
world.queue_free()
await process_frame
func _resolve_pickup_test_prop(prop_id: String) -> PropBlock:
return _pickup_test_prop if _pickup_test_prop != null and _pickup_test_prop.stage_object_id == prop_id else null
func _test_start_still_support_contact() -> void:
var world := Node2D.new()
root.add_child(world)
+5 -1
View File
@@ -170,7 +170,7 @@ func _test_adapter_identity_round_trip() -> void:
"stk_path": "res://content/stickmen/phase20a_missing_character.stk",
"position": {"x": -360.0, "y": 0.0},
"rotation": 0.0,
"action_queue": [],
"action_plan": [],
}
var missing_character: Node2D = MISSING_STICKMAN.new() as Node2D
missing_character.configure(missing_character_entry)
@@ -282,6 +282,10 @@ func _object(file_id: String, object_type: String, stage_id: String, stage_name:
func _current_document(objects: Array) -> Dictionary:
for raw_object: Variant in objects:
if raw_object is Dictionary and String((raw_object as Dictionary).get("type", "")) == "stickman":
(raw_object as Dictionary)["action_plan"] = []
(raw_object as Dictionary).erase("action_queue")
return {
"version": STAGE_SCENE_IO.FILE_VERSION,
"scene_name": "Identity Test",
+376
View File
@@ -0,0 +1,376 @@
extends SceneTree
const STAGE_ACTION_PLAN := preload("res://scripts/stage/stage_action_plan.gd")
const STAGE_ACTION_IDENTITY := preload("res://scripts/stage/stage_action_identity.gd")
const STAGE_SCENE_IO := preload("res://scripts/stage/stage_scene_io.gd")
const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd")
const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd")
const STICKMAN_RIG_SCENE := preload("res://scenes/characters/stickman/stickman_rig.tscn")
const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd")
const STICKMAN_LOCOMOTION := preload("res://scripts/characters/stickman/stickman_locomotion.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const TARGET_CAPTURE := preload("res://scripts/editors/stage/stage_target_capture_session.gd")
const STAGE_PLACEMENT_OVERLAY := preload("res://scripts/stage/stage_placement_overlay.gd")
const STAGE_DIRECTOR_VISUALS := preload("res://scripts/stage/stage_director_visuals.gd")
class FakeLocomotion extends "res://scripts/characters/stickman/stickman_locomotion.gd":
var walking: bool = false
var current_target: Vector2 = Vector2.ZERO
var retargets: Array[Vector2] = []
var styles: Array[String] = []
func project_target_to_navigation(target: Vector2) -> Vector2:
return target
func move_to(target: Vector2, style: String = "walk", _speed_override: float = -1.0) -> bool:
current_target = target
styles.append(style)
walking = true
return true
func retarget_walk(target: Vector2) -> bool:
current_target = target
retargets.append(target)
walking = true
return true
func is_walking() -> bool:
return walking
func cancel_walk(_restore_idle_pose: bool = true) -> void:
walking = false
func consume_walk_failed() -> bool:
return false
func consume_walk_completed() -> bool:
return false
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_plan_helpers()
_test_v18_migration()
_test_v19_validation_and_round_trip()
await _test_group_runtime_barrier()
await _test_group_recover_say_runtime_barrier()
await _test_moving_prop_pursuit()
print("PHASE 20B ACTION PLAN: %d checks, %d failures" % [_checks, _failures])
quit(1 if _failures > 0 else 0)
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
push_error("FAIL: " + message)
func _test_plan_helpers() -> void:
var placement_overlay: StagePlacementOverlay = STAGE_PLACEMENT_OVERLAY.new() as StagePlacementOverlay
placement_overlay.set_action_trajectory(Vector2.ZERO, Vector2(100.0, 80.0), true)
_check(placement_overlay.action_show_trajectory and not placement_overlay.action_grounded_trajectory, "ordinary trajectories keep their authored direction")
placement_overlay.set_grounded_action_trajectory(Vector2.ZERO, Vector2(100.0, 80.0), true)
_check(placement_overlay.action_show_trajectory and placement_overlay.action_grounded_trajectory, "walk and run previews can keep a horizontal ground route")
placement_overlay.set_action_marker(Vector2(40.0, 55.0))
_check(placement_overlay.action_visible and not placement_overlay.action_show_trajectory and not placement_overlay.action_grounded_trajectory and placement_overlay.action_target.is_equal_approx(Vector2(40.0, 55.0)), "selection previews keep a cursor marker without a movement trajectory")
placement_overlay.free()
var director_visuals: StageDirectorVisuals = STAGE_DIRECTOR_VISUALS.new() as StageDirectorVisuals
director_visuals.set_plan_candidate(null, "candidate", {}, false, Vector2.INF, "run")
_check(director_visuals._plan_candidate_movement_label == "Run", "candidate route label follows the selected Run style")
director_visuals.free()
var place_capture: Dictionary = TARGET_CAPTURE.normalize_plan_destination(Vector2(33.0, 44.0))
_check(String(place_capture.get("kind", "")) == "place" and (place_capture.get("position", Vector2.INF) as Vector2).is_equal_approx(Vector2(33.0, 44.0)), "PLAN_PLACE capture Vector2 normalizes to an explicit free-place destination")
var prop_capture: Dictionary = {"kind": "prop", "target_prop_id": "stage-prop-1", "approach_offset": Vector2(12.0, -8.0)}
_check(TARGET_CAPTURE.normalize_plan_destination(prop_capture) == prop_capture, "exact-prop capture dictionaries preserve the stable target ID and world offset")
_check(TARGET_CAPTURE.normalize_plan_destination(null).is_empty(), "unsupported capture payloads do not author a destination")
var move: Dictionary = {"action_id": "move-1", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(120.0, 420.0)}}
var say: Dictionary = {"action_id": "say-1", "type": "speak", "text": "Ready", "duration": 1.0}
var wait_action: Dictionary = {"action_id": "wait-1", "type": "wait", "duration": 0.25}
var together: Array[Dictionary] = [{"actions": [move, say]}]
_check(STAGE_ACTION_PLAN.validate_steps(together).is_empty(), "Move plus Say is a valid together step")
_check(not STAGE_ACTION_PLAN.validate_steps([{"actions": [move, wait_action]}]).is_empty(), "Move plus Wait is rejected as an authored together step")
_check(STAGE_ACTION_PLAN.can_add_together({"actions": [wait_action]}, "speak"), "Wait step offers Say as a compatible together action")
for action_type: String in ACTION_REGISTRY.types():
if action_type == "speak":
continue
var primary_action: Dictionary = {"action_id": "primary-%s" % action_type, "type": action_type}
_check(STAGE_ACTION_PLAN.can_add_together({"actions": [say]}, action_type), "%s step offers Say as a compatible together action" % ACTION_REGISTRY.label(action_type))
_check(STAGE_ACTION_PLAN.can_add_together({"actions": [primary_action]}, "speak"), "Say can join %s" % ACTION_REGISTRY.label(action_type))
_check(STAGE_ACTION_PLAN.validate_steps([{"actions": [primary_action, say]}]).is_empty(), "%s plus Say is a valid together step" % ACTION_REGISTRY.label(action_type))
_check(not STAGE_ACTION_PLAN.can_add_together({"actions": [say]}, "speak"), "a step cannot contain two Say actions")
_check(not STAGE_ACTION_PLAN.can_add_together({"actions": [move, say]}, "wait"), "full step rejects a third together action")
var reordered: Array[Dictionary] = STAGE_ACTION_PLAN.move_step([{"actions": [move]}, {"actions": [wait_action]}], 0, 1)
_check(String(reordered[1]["actions"][0]["action_id"]) == "move-1", "step reorder preserves action identity")
var removed: Array[Dictionary] = STAGE_ACTION_PLAN.remove_action(together, "say-1")
_check(removed.size() == 1 and (removed[0]["actions"] as Array).size() == 1, "deleting one grouped action preserves the remaining step")
var identity: StageActionIdentity = STAGE_ACTION_IDENTITY.new() as StageActionIdentity
var world: Node2D = Node2D.new()
var rig: StickmanRig = STICKMAN_RIG.new() as StickmanRig
world.add_child(rig)
rig.set_action_plan(together)
var first_id: String = identity.allocate(world)
_check(not first_id.is_empty() and first_id not in ["move-1", "say-1"], "scene-scoped allocator avoids IDs already present on a live actor")
world.free()
func _test_v18_migration() -> void:
var original: Dictionary = _legacy_v18_document([
{"type": "speak", "text": "Go!", "duration": 0.4, "keep_acting": true},
{"type": "move_to", "target": {"x": 130.0, "y": 420.0}, "style": "run"},
{"type": "speak", "text": "Wait", "duration": 0.4, "keep_acting": true},
{"type": "wait", "duration": 0.2},
{"type": "speak", "text": "Legacy", "duration": 0.4, "keep_acting": true},
{"type": "ragdoll"},
])
var migrated: Dictionary = STAGE_SCENE_IO._migrate_to_current(original)
var repeated: Dictionary = STAGE_SCENE_IO._migrate_to_current(original.duplicate(true))
var objects: Array = migrated.get("objects", [])
var actor: Dictionary = objects[0] as Dictionary
var plan: Array = actor.get("action_plan", [])
_check(String(migrated.get("version", "")) == "1.9", "v1.8 migrates to v1.9")
_check(not actor.has("action_queue"), "v1.8 migration removes the legacy authoritative queue")
_check(plan.size() == 4 and (plan[0]["actions"] as Array).size() == 2, "keep_acting Say pairs with its following Move")
_check(bool((plan[0]["actions"] as Array)[0].get("legacy_nonblocking", false)), "paired legacy Say retains nonblocking compatibility")
var moved_action: Dictionary = (plan[0]["actions"] as Array)[1] as Dictionary
var moved_destination: Dictionary = moved_action.get("destination", {})
_check(String(moved_destination.get("kind", "")) == "place" and moved_destination.get("position", {}) == {"x": 130.0, "y": 420.0}, "legacy flat Move migrates to an explicit place target")
_check((plan[1]["actions"] as Array).size() == 2 and String(((plan[1]["actions"] as Array)[1] as Dictionary).get("type", "")) == "wait", "keep_acting Say pairs with its following Wait")
_check(bool(((plan[2]["actions"] as Array)[0] as Dictionary).get("legacy_nonblocking", false)), "unpaired legacy Say retains its explicit compatibility flag")
_check(JSON.stringify(plan) == JSON.stringify((repeated["objects"] as Array)[0]["action_plan"]), "legacy action IDs are deterministic across repeated migration")
_check(String(migrated.get("sky_id", "")) == "sky_sunset" and String(migrated.get("background_kind", "")) == "plain", "v1.8 migration keeps its authored background values")
var warning_source: Dictionary = _legacy_v18_document([{"type": "not_a_real_action"}])
var warning_migration: Dictionary = STAGE_SCENE_IO._migrate_to_current(warning_source)
_check(not (warning_migration.get("_migration_warnings", []) as Array).is_empty(), "malformed legacy actions keep skip-and-warn migration behavior")
func _test_v19_validation_and_round_trip() -> void:
var canonical_plan: Array[Dictionary] = [
{"actions": [
{"action_id": "stable-move", "type": "move_to", "style": "run", "destination": {"kind": "prop", "target_prop_id": "prop-blue-door", "approach_offset": Vector2(-42.0, 0.0), "last_known_approach": Vector2(310.0, 420.0)}},
{"action_id": "stable-say", "type": "speak", "text": "Let's go!", "duration": 1.2},
]},
{"actions": [{"action_id": "stable-place", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(510.0, 420.0)}}]},
]
var encoded_result: Dictionary = STAGE_SCENE_IO.encode_plan(canonical_plan)
var encoded_steps: Array = encoded_result.get("steps", [])
_check((encoded_result.get("warnings", []) as Array).is_empty(), "canonical plans encode without warnings")
var doc: Dictionary = _current_document([_stickman_entry("actor-a", "stage-actor-a", "Milo", encoded_steps)])
_check(bool(STAGE_SCENE_IO._validate_root(doc).get("ok", false)), "valid v1.9 grouped Move+Say and place actions validate")
var current_move: Dictionary = (((doc["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"][0]
var current_destination: Dictionary = current_move.get("destination", {})
_check(String(current_destination.get("target_prop_id", "")) == "prop-blue-door", "prop destination persists the exact stage object ID")
_check(current_destination.get("approach_offset", {}) == {"x": -42.0, "y": 0.0}, "prop destination persists its world-aligned offset")
var bad_duplicate: Dictionary = doc.duplicate(true)
var duplicated_actor: Dictionary = _stickman_entry("actor-b", "stage-actor-b", "Ava", encoded_steps.duplicate(true))
(bad_duplicate["objects"] as Array).append(duplicated_actor)
_check(not bool(STAGE_SCENE_IO._validate_root(bad_duplicate).get("ok", true)), "duplicate stable action IDs across actors are rejected")
var bad_queue: Dictionary = doc.duplicate(true)
((bad_queue["objects"] as Array)[0] as Dictionary)["action_queue"] = []
_check(not bool(STAGE_SCENE_IO._validate_root(bad_queue).get("ok", true)), "v1.9 rejects a second authoritative action_queue")
var bad_group: Dictionary = doc.duplicate(true)
var bad_first_action: Dictionary = ((((bad_group["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"] as Array)[0]
bad_first_action["type"] = "ragdoll"
bad_first_action.erase("style")
bad_first_action.erase("destination")
var bad_second_action: Dictionary = ((((bad_group["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"] as Array)[1]
bad_second_action["type"] = "wait"
bad_second_action["duration"] = 0.5
bad_second_action.erase("text")
_check(not bool(STAGE_SCENE_IO._validate_root(bad_group).get("ok", true)), "v1.9 rejects incompatible grouped actions")
var bad_destination: Dictionary = doc.duplicate(true)
var bad_move: Dictionary = ((((bad_destination["objects"] as Array)[0] as Dictionary)["action_plan"] as Array)[0]["actions"] as Array)[0]
(bad_move["destination"] as Dictionary).erase("target_prop_id")
_check(not bool(STAGE_SCENE_IO._validate_root(bad_destination).get("ok", true)), "v1.9 rejects a prop destination with a missing exact ID")
var save_path: String = ProjectSettings.globalize_path("res://tests/.phase20b_plan_round_trip.scene")
var write_result: Dictionary = STAGE_SCENE_IO.write_file_safe(save_path, doc)
var read_result: Dictionary = STAGE_SCENE_IO.read_file(save_path)
_check(bool(write_result.get("ok", false)) and bool(read_result.get("ok", false)), "v1.9 plan document writes and reads through the production IO path")
var read_actor: Dictionary = (((read_result.get("data", {}) as Dictionary).get("objects", []) as Array)[0] as Dictionary)
var decoded: Dictionary = STAGE_SCENE_IO.decode_plan(read_actor.get("action_plan", []))
var decoded_steps: Array = decoded.get("steps", [])
var decoded_prop: Dictionary = (((decoded_steps[0]["actions"] as Array)[0] as Dictionary).get("destination", {}) as Dictionary)
var decoded_place: Dictionary = (((decoded_steps[1]["actions"] as Array)[0] as Dictionary).get("destination", {}) as Dictionary)
_check(String(((decoded_steps[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == "stable-move", "round-trip preserves stable action IDs")
_check(decoded_prop.get("approach_offset", Vector2.ZERO) == Vector2(-42.0, 0.0), "round-trip decodes the prop offset as a Vector2")
_check(String(decoded_place.get("kind", "")) == "place" and decoded_place.get("position", Vector2.ZERO) == Vector2(510.0, 420.0), "nearby free-place intent remains an explicit place after round-trip")
DirAccess.remove_absolute(save_path)
DirAccess.remove_absolute(save_path + ".bak")
func _test_group_runtime_barrier() -> void:
var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig
root.add_child(rig)
await process_frame
var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
var started: Array[Dictionary] = []
rig.planned_action_started.connect(func(action_id: String, _action: Dictionary, step_index: int) -> void:
started.append({"id": action_id, "step": step_index, "frame": Engine.get_physics_frames()})
)
var grouped: Array[Dictionary] = [
{"actions": [
{"action_id": "runtime-wait", "type": "wait", "duration": 0.05},
{"action_id": "runtime-say", "type": "speak", "text": "Together", "duration": 0.3, "legacy_fixed": true},
]},
{"actions": [{"action_id": "runtime-after", "type": "wait", "duration": 0.05}]},
]
_check(rig.set_action_plan(grouped), "runtime fixture accepts a grouped plan")
rig.start_queue()
runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 2 and String(started[0]["id"]) == "runtime-wait" and String(started[1]["id"]) == "runtime-say", "group actions emit start together in plan order")
_check(int(started[0]["frame"]) == int(started[1]["frame"]), "grouped actions start on the same runtime tick")
runner.tick(0.06, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 2, "next step waits after primary Wait ends while grouped Say remains active")
runner.pause_simulation()
runner.tick(2.0, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 2, "paused runtime freezes the Say barrier")
runner.resume_simulation()
runner.tick(0.31, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 2, "completed Say releases the barrier before the next runner tick")
runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 3 and String(started[2]["id"]) == "runtime-after", "next step starts only after every grouped action ends")
rig.stop_queue()
rig.queue_free()
await process_frame
func _test_group_recover_say_runtime_barrier() -> void:
var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig
root.add_child(rig)
await process_frame
var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
var started: Array[String] = []
rig.planned_action_started.connect(func(action_id: String, _action: Dictionary, _step_index: int) -> void:
started.append(action_id)
)
var grouped: Array[Dictionary] = [
{"actions": [
{"action_id": "runtime-recover", "type": "recover"},
{"action_id": "runtime-recover-say", "type": "speak", "text": "I am okay!", "duration": 0.3, "legacy_fixed": true},
]},
{"actions": [{"action_id": "runtime-after-recover", "type": "wait", "duration": 0.05}]},
]
_check(rig.set_action_plan(grouped), "runtime fixture accepts Recover plus Say")
rig.start_queue()
runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false)
_check(started == ["runtime-recover", "runtime-recover-say"], "Recover and Say start on the same runtime tick")
runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 2, "next step waits after Recover ends while its grouped Say remains active")
runner.tick(0.31, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 2, "Recover plus Say completes its speech barrier before advancing")
runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false)
_check(started.size() == 3 and started[2] == "runtime-after-recover", "the following step starts after Recover and Say both end")
rig.stop_queue()
rig.queue_free()
await process_frame
func _test_moving_prop_pursuit() -> void:
var world: Node2D = Node2D.new()
root.add_child(world)
var rig: StickmanRig = STICKMAN_RIG_SCENE.instantiate() as StickmanRig
world.add_child(rig)
var prop: PropBlock = PROP_BLOCK.new() as PropBlock
prop.stage_object_id = "prop-fetch-target"
prop.global_position = Vector2(120.0, 420.0)
world.add_child(prop)
var fake_locomotion: FakeLocomotion = FakeLocomotion.new() as FakeLocomotion
var runner: StickmanActionRunner = ACTION_RUNNER.new() as StickmanActionRunner
root.add_child(runner)
await process_frame
runner.configure(rig, fake_locomotion)
var outcome: Array[StringName] = []
runner.planned_action_ended.connect(func(action_id: String, result: StringName, _step: int) -> void:
if action_id == "fetch-move":
outcome.append(result)
)
var move_plan: Array[Dictionary] = [{"actions": [{
"action_id": "fetch-move",
"type": "move_to",
"style": "run",
"destination": {"kind": "prop", "target_prop_id": prop.stage_object_id, "approach_offset": Vector2(-30.0, 0.0), "last_known_approach": Vector2(90.0, 420.0)},
}]}]
_check(runner.set_action_plan(move_plan), "pursuit fixture accepts an exact prop Move")
runner.start_queue()
runner.tick(0.01, int(StickmanRig.RigState.ANIMATED), false)
_check(runner.is_prop_pursuit_active() and fake_locomotion.current_target == Vector2(90.0, 420.0), "prop Move starts toward its world-aligned approach offset")
prop.global_position += Vector2(24.0, 0.0)
runner.pre_movement(0.11)
_check(fake_locomotion.retargets.size() == 1 and fake_locomotion.retargets[0] == Vector2(114.0, 420.0), "moving prop retargets after its shift using the fixed world offset")
runner.pause_simulation()
prop.global_position += Vector2(30.0, 0.0)
runner.pre_movement(0.5)
_check(fake_locomotion.retargets.size() == 1, "paused pursuit does not retarget or spend its time budget")
runner.resume_simulation()
runner.pre_movement(0.11)
_check(fake_locomotion.retargets.size() == 2 and fake_locomotion.retargets[1] == Vector2(144.0, 420.0), "pursuit resumes and retargets from the prop's translated position")
prop.queue_free()
await process_frame
runner.pre_movement(0.11)
_check(outcome == [&"failed"], "vanished prop ends the active Move as failure, not arrival")
runner.stop_queue()
world.queue_free()
runner.queue_free()
fake_locomotion.free()
await process_frame
func _legacy_v18_document(queue: Array) -> Dictionary:
var wall: Dictionary = {
"file_id": "wall-1",
"type": "wall_panel",
"position": {"x": 0.0, "y": 0.0},
"rotation": 0.0,
"size": {"x": 320.0, "y": 400.0},
"finish_id": "finish_wall_paint",
"color": "#123456",
}
var actor: Dictionary = _stickman_entry("actor-a", "stage-actor-a", "Milo", [])
actor["action_queue"] = queue.duplicate(true)
return {
"version": "1.8",
"scene_name": "Legacy Plan",
"sky_id": "sky_sunset",
"background_kind": "plain",
"plain_color": "#F5EFE0",
"camera": {"position": {"x": 0.0, "y": 0.0}, "zoom": 0.5},
"objects": [actor, wall],
"event_rules": [],
"metadata": {},
}
func _current_document(objects: Array) -> Dictionary:
return {
"version": STAGE_SCENE_IO.FILE_VERSION,
"scene_name": "Plan Round Trip",
"sky_id": "sky_neutral",
"background_kind": "sky",
"plain_color": "#F5EFE0",
"camera": {"position": {"x": 0.0, "y": 0.0}, "zoom": 0.5},
"objects": objects,
"event_rules": [],
"metadata": {},
}
func _stickman_entry(file_id: String, stage_id: String, stage_name: String, action_plan: Array) -> Dictionary:
return {
"file_id": file_id,
"type": "stickman",
"stage_object_id": stage_id,
"stage_name": stage_name,
"stk_path": "res://content/stickmen/basic.stk",
"position": {"x": 0.0, "y": 0.0},
"rotation": 0.0,
"action_plan": action_plan.duplicate(true),
}
+1
View File
@@ -0,0 +1 @@
uid://dbf2n56l7bjk7
@@ -0,0 +1,102 @@
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_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd")
const CAPTURE_DIR := "res://tests/phase20b_captures/pickup_action_marker"
var _stage: SandboxStage
var _failures: int = 0
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))
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await _wait_frames(10)
var recovery: ConfirmationDialog = _stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog
if recovery != null:
STAGE_DIALOG_STYLER.hide(recovery)
_stage.call("_on_grid_toggled", false)
var spawner: StageSpawner = _stage.get("_spawner") as StageSpawner
var world: Node2D = _stage.get_node("World") as Node2D
var actor: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-160.0, -190.0))
var prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2(150.0, -10.0))
_check(actor != null and prop != null, "production Stage fixture creates a character and pickup target")
if actor == null or prop == null:
await _finish()
return
_stage.call("_finalize_placed_node", actor)
_stage.call("_finalize_placed_node", prop)
var identity: StageObjectIdentity = _stage.get("_object_identity") as StageObjectIdentity
var rename_result: Dictionary = identity.rename(prop, "Brown Suitcase", world)
_check(bool(rename_result.get("ok", false)), "pickup target receives its friendly stage name")
var pickup_action: Dictionary = {
"action_id": "pickup-marker-step-one",
"type": "pick_up_prop",
"target_prop_id": prop.stage_object_id,
"hand": "auto",
}
_check(actor.set_action_plan([{"actions": [pickup_action]}]), "Step 1 receives a real Pick Up Prop action")
_stage.call("set_mode", SandboxStage.StageMode.DIRECT)
var selection: StageSelection = _stage.get("_selection") as StageSelection
selection.select_only(actor)
_stage.call("_refresh_character_settings_card")
await _wait_frames(8)
var visuals: StageDirectorVisuals = _stage.get("_director_visuals") as StageDirectorVisuals
var projection: Dictionary = {}
for entry: Dictionary in visuals._plan_projection(actor):
if String(entry.get("action_id", "")) == String(pickup_action["action_id"]):
projection = entry
break
_check(String(projection.get("kind", "")) == "other" and String((projection.get("action", {}) as Dictionary).get("type", "")) == "pick_up_prop", "pickup action reaches the production Stage overlay projection")
var canvas_transform: Transform2D = _stage.get_viewport().get_canvas_transform()
var prop_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
var prop_screen_bounds: Rect2 = visuals._world_rect_to_screen_rect(prop_world_bounds, canvas_transform)
var badge_size: Vector2 = visuals._text_action_badge_screen_size("Pick up", visuals._zoom())
var viewport_size: Vector2 = root.size
var safe_rect: Rect2 = Rect2(Vector2(8.0, 72.0), Vector2(viewport_size.x - 16.0, viewport_size.y - 112.0))
var actor_name_rect: Rect2 = visuals._actor_name_badge_screen_rect(actor, visuals._zoom())
var badge_rect: Rect2 = visuals._pickup_badge_screen_rect(prop_screen_bounds, badge_size, safe_rect, actor_name_rect)
_check(safe_rect.encloses(badge_rect), "Pick up pill stays inside the visible Stage safe area")
_check(not badge_rect.intersects(prop_screen_bounds), "Pick up pill sits outside the target prop bounds")
if _can_capture_image():
await RenderingServer.frame_post_draw
var image: Image = root.get_texture().get_image()
var path: String = CAPTURE_DIR.path_join("pickup_step_one_pill_over_prop.png")
var save_error: Error = image.save_png(ProjectSettings.globalize_path(path)) if image != null and not image.is_empty() else ERR_CANT_CREATE
_check(save_error == OK, "focused Godot run saves the rendered pickup action marker PNG")
print("PICKUP_ACTION_MARKER_CAPTURE path=%s size=%s" % [path, str(image.get_size()) if image != null else "missing"])
print("PHASE20B_PICKUP_ACTION_MARKER_RESULT failures=%d" % _failures)
await _finish()
quit(1 if _failures > 0 else 0)
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, message: String) -> void:
if condition:
print("PASS: %s" % message)
else:
_failures += 1
push_error("FAIL: " + message)
func _finish() -> void:
if _stage != null and is_instance_valid(_stage):
_stage.queue_free()
await process_frame
@@ -0,0 +1 @@
uid://stxib3pjfl3i
@@ -0,0 +1,147 @@
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const PROP_ANCHOR_DATA := preload("res://scripts/props/prop_anchor_data.gd")
const CAPTURE_DIR := "res://tests/phase20b_captures/pickup_prop_ui"
var _stage: SandboxStage
var _resolved_prop: Node2D
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var capture_width: int = int(OS.get_environment("PHASE20B_CAPTURE_WIDTH"))
root.size = Vector2i(capture_width if capture_width > 0 else 1280, 720)
root.gui_embed_subwindows = true
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR))
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await _wait_frames(10)
var recovery: ConfirmationDialog = _stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog
if recovery != null:
recovery.hide()
var spawner: StageSpawner = _stage.get("_spawner") as StageSpawner
var world: Node2D = _stage.get_node("World") as Node2D
var actor: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-160.0, -190.0))
var eligible: PropBlock = spawner.spawn_prop_asset("crate", Vector2(150.0, -10.0))
var unavailable: PropBlock = spawner.spawn_prop_asset("crate", Vector2(350.0, -10.0))
_check(actor != null and eligible != null and unavailable != null, "real Stage fixtures spawn")
if actor == null or eligible == null or unavailable == null:
await _finish()
return
_stage.call("_finalize_placed_node", actor)
_stage.call("_finalize_placed_node", eligible)
_stage.call("_finalize_placed_node", unavailable)
var identity: StageObjectIdentity = _stage.get("_object_identity") as StageObjectIdentity
identity.rename(eligible, "Brown Suitcase", world)
identity.rename(unavailable, "Garden Gnome", world)
eligible.set_interaction_anchors(PROP_ANCHOR_DATA.preset_layout("one_handed", Rect2(Vector2(-44.0, -32.0), Vector2(88.0, 64.0))))
eligible.stage_roles["can_pick_up"] = true
unavailable.set_interaction_anchors(PROP_ANCHOR_DATA.preset_layout("one_handed", Rect2(Vector2(-44.0, -32.0), Vector2(88.0, 64.0))))
unavailable.stage_roles["can_pick_up"] = false
_stage.call("set_mode", SandboxStage.StageMode.DIRECT)
var selection: StageSelection = _stage.get("_selection") as StageSelection
selection.select_only(actor)
_stage.call("_refresh_character_settings_card")
await _wait_frames(5)
var queue_controller: StageActionQueueController = _stage.get("_action_queue_controller") as StageActionQueueController
queue_controller.set_editor_actor_id(actor.get_instance_id())
var action_editor: ActionEditor = _stage.get("_action_editor") as ActionEditor
action_editor.open_new(false, false, "pick_up_prop")
await _wait_frames(2)
var choose_prop_button: Button = _button_with_text(action_editor, "Choose a prop")
_check(choose_prop_button != null, "pickup action editor offers a separate Choose a prop control")
if choose_prop_button != null:
choose_prop_button.pressed.emit()
await _wait_frames(3)
_stage.set_process(false)
var visuals: StageDirectorVisuals = _stage.get("_director_visuals") as StageDirectorVisuals
var candidates: Array = visuals.get("_pickup_candidate_props")
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.PICKUP_PROP and candidates.size() == 1 and candidates[0] == eligible, "Change prop enters pickup-specific capture and highlights only eligible props")
var pickup_candidates: Array[PropBlock] = [eligible]
visuals.set_pickup_candidates(pickup_candidates, eligible)
await _wait_frames(3)
_check(visuals.get("_pickup_hovered_prop") == eligible, "hovering the eligible prop highlights it and shows its stage name")
var overlay: StageInspectorCaptureOverlay = _stage.get("_inspector_capture_overlay") as StageInspectorCaptureOverlay
_check(overlay.has_capture(), "pickup capture keeps the visible Cancel strip on the character inspector")
var capture_prompt: Label = overlay.find_child("CapturePrompt", true, false) as Label
var capture_strip: Control = overlay.find_child("CaptureCancelStrip", true, false) as Control
var cancel_button: Button = overlay.find_child("Capture_cancel_capture", true, false) as Button
_check(capture_prompt != null and capture_prompt.text == "Pick up prop", "pickup capture caption only names the action")
_check(cancel_button != null and capture_strip != null and cancel_button.get_global_rect().end.x <= capture_strip.get_global_rect().end.x + 1.0, "pickup Cancel stays inside the inspector strip")
if _can_capture_image():
await RenderingServer.frame_post_draw
var texture: Texture2D = root.get_texture()
if texture != null:
var image: Image = texture.get_image()
if image != null and not image.is_empty():
image.save_png(ProjectSettings.globalize_path(CAPTURE_DIR.path_join("stage_pickup_capture.png")))
_stage.call("_resolve_capture", unavailable.global_position)
await _wait_frames(2)
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.PICKUP_PROP and String(_stage.get("_toast_text")).contains("grip"), "clicking an unavailable prop explains the pickup requirement and leaves capture active")
_stage.call("_resolve_capture", eligible.global_position)
await _wait_frames(3)
var prop_label: Label = action_editor.get("_prop_target_label") as Label
var change_prop_button: Button = _button_with_text(action_editor, "Change prop")
_check(action_editor.visible and prop_label != null and prop_label.text == "Brown Suitcase" and not prop_label.text.contains(eligible.stage_object_id), "selecting a prop returns to the dialog with its friendly name")
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and change_prop_button != null, "selection resolves capture and clears the temporary cues")
_check((visuals.get("_pickup_candidate_props") as Array).is_empty() and not overlay.has_capture(), "capture end removes both canvas cues and the inspector dimmer")
if _can_capture_image():
await RenderingServer.frame_post_draw
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("stage_pickup_action_dialog.png")))
change_prop_button.pressed.emit()
await _wait_frames(2)
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.PICKUP_PROP, "Change prop opens the same pickup capture flow")
_stage.call("_cancel_capture")
_check(int(_stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and (visuals.get("_pickup_candidate_props") as Array).is_empty() and action_editor.visible and prop_label.text == "Brown Suitcase", "cancel clears the overlay and returns to the named prop dialog")
eligible.stage_roles["can_pick_up"] = false
action_editor.hide()
_stage.call("_begin_capture", SandboxStage.CaptureKind.PICKUP_PROP, "Choose a prop to pick up", Callable(), Callable())
var empty_capture_state: Dictionary = _stage.call("_target_capture_surface_state")
_check(String(empty_capture_state.get("prompt", "")) == "Pick up prop", "empty pickup capture keeps the action-only caption")
_stage.call("_cancel_capture")
print("PHASE20B_PICKUP_PROP_STAGE_RESULT failures=%d captures=%s" % [_failures, CAPTURE_DIR])
await _finish()
quit(1 if _failures > 0 else 0)
func _button_with_text(root_node: Node, expected_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 == expected_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 _finish() -> void:
if _stage != null and is_instance_valid(_stage):
_stage.queue_free()
await process_frame
@@ -0,0 +1 @@
uid://dlfqvtmghrm0w
+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])
@@ -0,0 +1 @@
uid://cyrngnepw6vvi
@@ -0,0 +1,146 @@
extends SceneTree
const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn")
const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd")
const CAPTURE_DIR := "res://tests/phase20b_captures/set_down_surface_highlights"
var _stage: SandboxStage
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
root.size = Vector2i(1280, 720)
root.gui_embed_subwindows = true
var make_dir_error: Error = DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(CAPTURE_DIR))
_check(make_dir_error == OK or make_dir_error == ERR_ALREADY_EXISTS, "capture folder is available")
_stage = SANDBOX_STAGE.instantiate() as SandboxStage
root.add_child(_stage)
await _wait_frames(10)
var recovery: ConfirmationDialog = _stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog
if recovery != null:
recovery.hide()
_stage.call("_on_grid_toggled", false)
var spawner: StageSpawner = _stage.get("_spawner") as StageSpawner
var actor: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-160.0, -190.0))
var supported_prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2(110.0, -10.0))
var unsupported_prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2(300.0, -10.0))
_check(actor != null and supported_prop != null and unsupported_prop != null, "stage fixtures spawn")
if actor == null or supported_prop == null or unsupported_prop == null:
await _finish()
quit(1)
return
_stage.call("_finalize_placed_node", actor)
_stage.call("_finalize_placed_node", supported_prop)
_stage.call("_finalize_placed_node", unsupported_prop)
var supported_roles: Dictionary = PROP_STAGE_ROLES.new_placement(supported_prop.has_usable_grip())
supported_roles["holds_other_props"] = true
supported_prop.set_stage_roles(supported_roles)
var unsupported_roles: Dictionary = PROP_STAGE_ROLES.new_placement(unsupported_prop.has_usable_grip())
unsupported_roles["holds_other_props"] = false
unsupported_prop.set_stage_roles(unsupported_roles)
var set_down_action: Dictionary = {
"action_id": "set-down-highlight-step-one",
"type": "set_down_prop",
"target_position": Vector2(110.0, -10.0),
}
_check(actor.set_action_plan([{"actions": [set_down_action]}]), "Step 1 contains the Set Down Prop action")
_stage.call("set_mode", SandboxStage.StageMode.DIRECT)
var selection: StageSelection = _stage.get("_selection") as StageSelection
selection.select_only(actor)
_stage.call("_refresh_character_settings_card")
await _wait_frames(8)
var overlay: StagePlacementOverlay = _stage.get("_placement_overlay") as StagePlacementOverlay
_check(overlay != null and overlay.surface_world == _stage.get_node("World"), "set-down overlay reads the live stage world")
var support_points: PackedVector2Array = overlay.call("_prop_collision_world_points", supported_prop)
var excluded_points: PackedVector2Array = overlay.call("_prop_collision_world_points", unsupported_prop)
var support_edges: Array[PackedVector2Array] = overlay.call("_top_facing_edges", support_points)
var excluded_edges: Array[PackedVector2Array] = overlay.call("_top_facing_edges", excluded_points)
_check(not support_edges.is_empty() and not excluded_edges.is_empty(), "both props expose a top edge for role filtering")
if support_edges.is_empty() or excluded_edges.is_empty():
await _finish()
quit(1)
return
var target_world: Vector2 = (support_edges[0][0] + support_edges[0][1]) * 0.5
var screen_target: Vector2 = _stage.get_viewport().get_canvas_transform() * target_world
await _send_mouse_motion(screen_target)
_stage.call("_begin_set_down_position_capture", actor, "Click where to let go", Callable(), Callable())
await _wait_frames(6)
var capture_overlay: StageInspectorCaptureOverlay = _stage.get("_inspector_capture_overlay") as StageInspectorCaptureOverlay
var capture_prompt: Label = capture_overlay.find_child("CapturePrompt", true, false) as Label if capture_overlay != null else null
var cancel_button: Button = capture_overlay.find_child("Capture_cancel_capture", true, false) as Button if capture_overlay != null else null
_check(capture_overlay != null and capture_overlay.has_capture(), "Set Down capture shows the inspector capture row")
_check(capture_prompt != null and capture_prompt.text == "Set Down Prop", "inspector row names the action without a long hint")
_check(cancel_button != null and cancel_button.visible, "the inspector Cancel button stays visible")
var action_tooltip: Control = _stage.find_child("ActionTooltip", true, false) as Control
_check(action_tooltip == null or not action_tooltip.visible, "set-down capture has no extra floating instruction text")
_check(overlay.action_visible and overlay.set_down_preview_visible and not overlay.action_show_trajectory, "set-down capture shows surfaces without the old trajectory")
var capture_path: String = CAPTURE_DIR.path_join("set_down_support_surface_filter.png")
await RenderingServer.frame_post_draw
var image: Image = root.get_texture().get_image()
var save_error: Error = image.save_png(ProjectSettings.globalize_path(capture_path)) if image != null and not image.is_empty() else ERR_CANT_CREATE
_check(save_error == OK, "focused Godot run saves the set-down surface capture")
var canvas_transform: Transform2D = _stage.get_viewport().get_canvas_transform()
var supported_mid: Vector2 = canvas_transform * ((support_edges[0][0] + support_edges[0][1]) * 0.5)
var unsupported_mid: Vector2 = canvas_transform * ((excluded_edges[0][0] + excluded_edges[0][1]) * 0.5)
_check(_has_cyan_near(image, supported_mid), "Props can sit on top surface receives a cyan highlight")
_check(not _has_cyan_near(image, unsupported_mid), "prop with Props can sit on top off receives no cyan highlight")
print("SET_DOWN_SURFACE_CAPTURE path=%s size=%s supported=%s unsupported=%s" % [capture_path, str(image.get_size()) if image != null else "missing", str(supported_mid), str(unsupported_mid)])
await _send_escape()
_check(not capture_overlay.has_capture(), "Escape cancels set-down capture and removes the inspector strip")
print("PHASE20B_SET_DOWN_SURFACE_RESULT failures=%d" % _failures)
await _finish()
quit(1 if _failures > 0 else 0)
func _has_cyan_near(image: Image, position: Vector2) -> bool:
if image == null or image.is_empty():
return false
var center: Vector2i = Vector2i(roundi(position.x), roundi(position.y))
for y: int in range(center.y - 3, center.y + 4):
for x: int in range(center.x - 3, center.x + 4):
if x < 0 or y < 0 or x >= image.get_width() or y >= image.get_height():
continue
var pixel: Color = image.get_pixel(x, y)
if pixel.g > pixel.r + 0.08 and pixel.b > pixel.r + 0.05:
return true
return false
func _send_mouse_motion(position: Vector2) -> void:
var motion: InputEventMouseMotion = InputEventMouseMotion.new()
motion.position = position
motion.global_position = position
Input.parse_input_event(motion)
await _wait_frames(2)
func _send_escape() -> void:
var escape: InputEventKey = InputEventKey.new()
escape.keycode = KEY_ESCAPE
escape.pressed = true
Input.parse_input_event(escape)
await _wait_frames(3)
func _wait_frames(count: int) -> void:
for _index: int in count:
await process_frame
await RenderingServer.frame_post_draw
func _check(condition: bool, message: String) -> void:
if condition:
print("PASS: %s" % message)
else:
_failures += 1
push_error("FAIL: " + message)
func _finish() -> void:
if _stage != null and is_instance_valid(_stage):
_stage.queue_free()
await process_frame