diff --git a/.codex/agents/developer.toml b/.codex/agents/developer.toml index 0047fea..c6086da 100644 --- a/.codex/agents/developer.toml +++ b/.codex/agents/developer.toml @@ -14,6 +14,7 @@ You are a Godot 4 / GDScript 2.0 implementation developer. Adhere to coding stan - **Static Typing:** Enforce explicit type hints for all signatures and variables. - **Decoupling:** Follow "call down, signal up". - **Validation:** Resolve the Godot 4.7.1 binary for the current platform using `AGENTS.md`. Run syntax/import checks, relevant existing suites, and focused automated tests of the feature's observable outcomes. For runtime interactions, exercise a minimal scene or fixture through the real production path, advance frames or physics ticks, and assert the result. Fix failures and rerun the affected checks. Inspect rendered output when available; state clearly when visual behavior could not be checked. +- **Windows visual verification:** The Windows screenshot service repeatedly times out on Project Manager/Godot windows. Do not retry it; use a focused Godot GUI run or harness that saves a viewport PNG, inspect the saved image locally, and assert the relevant interactions in the same focused verification workflow. ## Output Format diff --git a/.codex/agents/uiux.toml b/.codex/agents/uiux.toml index ffb0db8..ce1061d 100644 --- a/.codex/agents/uiux.toml +++ b/.codex/agents/uiux.toml @@ -5,6 +5,10 @@ model_reasoning_effort = "high" sandbox_mode = "read-only" developer_instructions = """ +## Windows UI visual checks + +The Windows screenshot service repeatedly times out when capturing Project Manager/Godot windows. Do not rely on or retry its live-window feed. When a visual implementation check is needed, direct the developer to run a focused Godot scene or harness that saves a viewport PNG and inspect that local image; pair it with interaction checks for the relevant controls. + ## Worktree choice You are read-only and must not create worktrees or write project files. For any handoff that will create project files, confirm that the orchestrator has asked the user whether to use a worktree and supplied the selected project path and branch. Do not require a worktree when the user chooses the current checkout. diff --git a/AGENTS.md b/AGENTS.md index a61540b..54f7759 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,11 @@ - A load failure shows a "Could Not Load Scene" dialog and falls back to a blank scene. - **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines. +## Visual verification on Windows + +- The Windows screenshot service repeatedly times out when capturing the Project Manager or Godot windows. Do not retry or depend on its live-window feed. +- For visual UI verification, run a focused Godot scene or test harness that exercises the relevant controls and saves a viewport PNG, then inspect that image locally. Keep interaction assertions in the focused run as well; use headless runs for behavior that does not need rendered output. + ## Project overview Stickman Studio is a kid-friendly 2D scene-building and directing project—not only a diff --git a/content/stages/phase20b_test2_moving_prop_chase.scene b/content/stages/phase20b_test2_moving_prop_chase.scene new file mode 100644 index 0000000..68b2ad3 --- /dev/null +++ b/content/stages/phase20b_test2_moving_prop_chase.scene @@ -0,0 +1,293 @@ +{ + "version": "1.9", + "scene_name": "Phase 20b - Moving Prop Chase", + "sky_id": "sky_neutral", + "background_kind": "sky", + "plain_color": "#F5EFE0", + "camera": { + "position": { + "y": -240.0, + "x": -180.0 + }, + "zoom": 0.55 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "y": 160.0, + "x": -960.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "y": 160.0, + "x": -768.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "y": 160.0, + "x": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "y": 160.0, + "x": -384.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "y": 160.0, + "x": -192.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "y": 160.0, + "x": 0.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "y": 160.0, + "x": 192.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "y": 160.0, + "x": 384.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "y": 160.0, + "x": 576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "y": 160.0, + "x": 768.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "y": 160.0, + "x": 960.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "y": -225.0, + "x": -700.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "stage_object_id": "stage_phase20b_test2_chaser", + "stage_name": "Chaser", + "facing_profile": "FORWARD", + "action_plan": [ + { + "actions": [ + { + "type": "wait", + "action_id": "action_phase20b_test2_wait", + "duration": 1.0 + } + ] + }, + { + "actions": [ + { + "action_id": "action_phase20b_test2_pursue", + "type": "move_to", + "style": "run", + "destination": { + "kind": "prop", + "last_known_approach": { + "y": -215.0, + "x": -30.0 + }, + "target_prop_id": "stage_phase20b_test2_baseball", + "approach_offset": { + "y": 0.0, + "x": -80.0 + } + } + } + ] + } + ] + }, + { + "file_id": "prop_ramp", + "type": "prop", + "position": { + "y": -38.0, + "x": 160.0 + }, + "rotation": 30.0, + "asset_id": "res://content/props/prop_coffee_table-v1.prop", + "stage_object_id": "stage_phase20b_test2_ramp", + "stage_name": "Tilted Ramp", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": true, + "blocks_stickmen": false, + "can_pick_up": false, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "prop_rolling_baseball", + "type": "prop", + "position": { + "y": -215.0, + "x": 50.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/baseball.prop", + "stage_object_id": "stage_phase20b_test2_baseball", + "stage_name": "Rolling Baseball", + "roles": { + "depth": "behind", + "start_motion": "physics", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 0.99999862909317, + "y": 0.99999862909317 + }, + "material_preset": 0 + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1793685806, + "source_path": "", + "autosave": false, + "modified_unix": 1793686116 + } +} \ No newline at end of file diff --git a/content/stages/phase20b_test3_timeout_missing_target.scene b/content/stages/phase20b_test3_timeout_missing_target.scene new file mode 100644 index 0000000..0287f35 --- /dev/null +++ b/content/stages/phase20b_test3_timeout_missing_target.scene @@ -0,0 +1,884 @@ +{ + "version": "1.9", + "scene_name": "Phase 20b - Timeout and Missing Target", + "sky_id": "sky_neutral", + "background_kind": "sky", + "plain_color": "#F5EFE0", + "camera": { + "zoom": 0.15, + "position": { + "x": -2800.0, + "y": -240.0 + } + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -7680.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -7488.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -7296.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -7104.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -6912.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": -6720.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": -6528.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": -6336.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": -6144.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": -5952.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": -5760.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "x": -5568.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "x": -5376.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "x": -5184.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "x": -4992.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "x": -4800.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "x": -4608.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "x": -4416.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_19", + "type": "terrain", + "position": { + "x": -4224.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_20", + "type": "terrain", + "position": { + "x": -4032.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_21", + "type": "terrain", + "position": { + "x": -3840.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_22", + "type": "terrain", + "position": { + "x": -3648.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_23", + "type": "terrain", + "position": { + "x": -3456.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_24", + "type": "terrain", + "position": { + "x": -3264.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_25", + "type": "terrain", + "position": { + "x": -3072.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_26", + "type": "terrain", + "position": { + "x": -2880.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_27", + "type": "terrain", + "position": { + "x": -2688.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_28", + "type": "terrain", + "position": { + "x": -2496.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_29", + "type": "terrain", + "position": { + "x": -2304.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_30", + "type": "terrain", + "position": { + "x": -2112.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_31", + "type": "terrain", + "position": { + "x": -1920.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_32", + "type": "terrain", + "position": { + "x": -1728.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_33", + "type": "terrain", + "position": { + "x": -1536.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_34", + "type": "terrain", + "position": { + "x": -1344.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_35", + "type": "terrain", + "position": { + "x": -1152.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_36", + "type": "terrain", + "position": { + "x": -960.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_37", + "type": "terrain", + "position": { + "x": -768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_38", + "type": "terrain", + "position": { + "x": -576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_39", + "type": "terrain", + "position": { + "x": -384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_40", + "type": "terrain", + "position": { + "x": -192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_41", + "type": "terrain", + "position": { + "x": 0.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_42", + "type": "terrain", + "position": { + "x": 192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_43", + "type": "terrain", + "position": { + "x": 384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_44", + "type": "terrain", + "position": { + "x": 576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_45", + "type": "terrain", + "position": { + "x": 768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_46", + "type": "terrain", + "position": { + "x": 960.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_47", + "type": "terrain", + "position": { + "x": 1152.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_48", + "type": "terrain", + "position": { + "x": 1344.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_49", + "type": "terrain", + "position": { + "x": 1536.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_50", + "type": "terrain", + "position": { + "x": 1728.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_51", + "type": "terrain", + "position": { + "x": 1920.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "stickman_timeout_test", + "type": "stickman", + "position": { + "x": -6000.0, + "y": -225.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "stage_object_id": "stage_phase20b_test3_chaser", + "stage_name": "Chaser", + "facing_profile": "FORWARD", + "action_plan": [ + { + "actions": [ + { + "action_id": "action_phase20b_test3_chase", + "type": "move_to", + "destination": { + "target_prop_id": "stage_phase20b_test3_target", + "last_known_approach": { + "x": -30.0, + "y": -215.0 + }, + "kind": "prop", + "approach_offset": { + "x": -80.0, + "y": 0.0 + } + }, + "style": "run" + } + ] + } + ] + }, + { + "file_id": "prop_ramp", + "type": "prop", + "position": { + "x": 160.0, + "y": -38.0 + }, + "rotation": 30.0, + "asset_id": "res://content/props/prop_coffee_table-v1.prop", + "stage_object_id": "stage_phase20b_test3_ramp", + "stage_name": "Rolling Ramp", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": true, + "blocks_stickmen": false, + "can_pick_up": false, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "prop_timeout_target", + "type": "prop", + "position": { + "x": 50.0, + "y": -215.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/baseball.prop", + "stage_object_id": "stage_phase20b_test3_target", + "stage_name": "Target Ball", + "roles": { + "depth": "behind", + "start_motion": "physics", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 0.99999862909317, + "y": 0.99999862909317 + }, + "material_preset": 0 + } + ], + "event_rules": [], + "metadata": { + "autosave": false, + "source_path": "", + "modified_unix": 1793757248, + "created_unix": 1793757048 + } +} \ No newline at end of file diff --git a/content/stages/phase20b_test4_pause_during_pursuit.scene b/content/stages/phase20b_test4_pause_during_pursuit.scene new file mode 100644 index 0000000..09170e6 --- /dev/null +++ b/content/stages/phase20b_test4_pause_during_pursuit.scene @@ -0,0 +1,413 @@ +{ + "version": "1.9", + "scene_name": "Phase 20b - Pause During Pursuit", + "sky_id": "sky_neutral", + "background_kind": "sky", + "plain_color": "#F5EFE0", + "camera": { + "position": { + "x": -650.0, + "y": -240.0 + }, + "zoom": 0.42 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "y": 160.0, + "x": -1920 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "y": 160.0, + "x": -1728 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "y": 160.0, + "x": -1536 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "y": 160.0, + "x": -1344 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "y": 160.0, + "x": -1152 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "y": 160.0, + "x": -960 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "y": 160.0, + "x": -768 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "y": 160.0, + "x": -576 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "y": 160.0, + "x": -384 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "y": 160.0, + "x": -192 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "y": 160.0, + "x": 0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_12", + "type": "terrain", + "position": { + "y": 160.0, + "x": 192 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_13", + "type": "terrain", + "position": { + "y": 160.0, + "x": 384 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_14", + "type": "terrain", + "position": { + "y": 160.0, + "x": 576 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_15", + "type": "terrain", + "position": { + "y": 160.0, + "x": 768 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_16", + "type": "terrain", + "position": { + "y": 160.0, + "x": 960 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_17", + "type": "terrain", + "position": { + "y": 160.0, + "x": 1152 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_18", + "type": "terrain", + "position": { + "y": 160.0, + "x": 1344 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_19", + "type": "terrain", + "position": { + "y": 160.0, + "x": 1536 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "stickman_test4_walker", + "type": "stickman", + "position": { + "x": -1300.0, + "y": -225.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "stage_object_id": "stage_phase20b_test4_walker", + "stage_name": "Walker", + "facing_profile": "FORWARD", + "action_plan": [ + { + "actions": [ + { + "type": "wait", + "duration": 0.5, + "action_id": "action_phase20b_test4_wait" + } + ] + }, + { + "actions": [ + { + "type": "move_to", + "style": "run", + "destination": { + "kind": "prop", + "target_prop_id": "stage_phase20b_test4_ball", + "approach_offset": { + "x": -80.0, + "y": 0.0 + }, + "last_known_approach": { + "x": -30.0, + "y": -215.0 + } + }, + "action_id": "action_phase20b_test4_pursue" + } + ] + } + ] + }, + { + "file_id": "prop_test4_ramp", + "type": "prop", + "position": { + "x": 160.0, + "y": -38.0 + }, + "rotation": 30.0, + "asset_id": "res://content/props/prop_coffee_table-v1.prop", + "stage_object_id": "stage_phase20b_test4_ramp", + "stage_name": "Tilted Ramp", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": true, + "blocks_stickmen": false, + "can_pick_up": false, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "prop_test4_ball", + "type": "prop", + "position": { + "x": 50.0, + "y": -215.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/baseball.prop", + "stage_object_id": "stage_phase20b_test4_ball", + "stage_name": "Rolling Ball", + "roles": { + "depth": "behind", + "start_motion": "physics", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 0 + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1793765197, + "source_path": "", + "autosave": false, + "modified_unix": 1793765197 + } +} \ No newline at end of file diff --git a/content/stages/phase20b_test5_target_identity.scene b/content/stages/phase20b_test5_target_identity.scene new file mode 100644 index 0000000..7680d3e --- /dev/null +++ b/content/stages/phase20b_test5_target_identity.scene @@ -0,0 +1,270 @@ +{ + "version": "1.9", + "scene_name": "Phase 20b - Target Identity and Repair", + "sky_id": "sky_neutral", + "background_kind": "sky", + "plain_color": "#F5EFE0", + "camera": { + "position": { + "x": 0.0, + "y": -240.0 + }, + "zoom": 0.519999980926514 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -960.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": -192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": 0.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": 192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": 384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": 768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "x": 960.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -620.0, + "y": -225.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "stage_object_id": "stage_phase20b_test5_walker", + "stage_name": "Walker", + "facing_profile": "FORWARD", + "action_plan": [ + { + "actions": [ + { + "type": "move_to", + "style": "walk", + "destination": { + "kind": "prop", + "target_prop_id": "stage_phase20b_test5_briefcase", + "approach_offset": { + "x": -40.0, + "y": 0.0 + }, + "last_known_approach": { + "x": 220.0, + "y": -225.0 + } + }, + "action_id": "action_phase20b_test5_move_to_prop" + } + ] + }, + { + "actions": [ + { + "type": "move_to", + "style": "walk", + "destination": { + "kind": "place", + "position": { + "x": 360.0, + "y": -225.0 + } + }, + "action_id": "action_phase20b_test5_free_place" + } + ] + } + ] + }, + { + "file_id": "prop_1", + "type": "prop", + "position": { + "x": 260.0, + "y": 64.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/briefcase.prop", + "stage_object_id": "stage_phase20b_test5_briefcase", + "stage_name": "Named Briefcase", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1793765197, + "modified_unix": 1793765903.57, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/content/stages/phase20b_test6_direct_editing_focus.scene b/content/stages/phase20b_test6_direct_editing_focus.scene new file mode 100644 index 0000000..a90dbb4 --- /dev/null +++ b/content/stages/phase20b_test6_direct_editing_focus.scene @@ -0,0 +1,262 @@ +{ + "version": "1.9", + "scene_name": "Phase 20b - DIRECT Editing and Focus", + "sky_id": "sky_neutral", + "background_kind": "sky", + "plain_color": "#F5EFE0", + "camera": { + "position": { + "x": 0.0, + "y": -240.0 + }, + "zoom": 0.52 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "y": 160.0, + "x": -960 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "y": 160.0, + "x": -768 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "y": 160.0, + "x": -576 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "y": 160.0, + "x": -384 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "y": 160.0, + "x": -192 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "y": 160.0, + "x": 0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "y": 160.0, + "x": 192 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "y": 160.0, + "x": 384 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "y": 160.0, + "x": 576 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "y": 160.0, + "x": 768 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "y": 160.0, + "x": 960 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "stickman_test6_direct", + "type": "stickman", + "position": { + "x": -500.0, + "y": -225.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "stage_object_id": "stage_phase20b_test6_direct", + "stage_name": "Director", + "facing_profile": "FORWARD", + "action_plan": [] + }, + { + "file_id": "prop_test6_table", + "type": "prop", + "position": { + "x": 320.0, + "y": 32.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/prop_coffee_table-v1.prop", + "stage_object_id": "stage_phase20b_test6_table", + "stage_name": "Coffee Table", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": true, + "blocks_stickmen": false, + "can_pick_up": false, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "prop_test6_case", + "type": "prop", + "position": { + "x": 560.0, + "y": 64.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/briefcase.prop", + "stage_object_id": "stage_phase20b_test6_case", + "stage_name": "Briefcase", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1793765197, + "source_path": "", + "autosave": false, + "modified_unix": 1793765197 + } +} \ No newline at end of file diff --git a/content/stages/phase20b_test7_target_picking_cancel.scene b/content/stages/phase20b_test7_target_picking_cancel.scene new file mode 100644 index 0000000..ca454a5 --- /dev/null +++ b/content/stages/phase20b_test7_target_picking_cancel.scene @@ -0,0 +1,285 @@ +{ + "version": "1.9", + "scene_name": "Phase 20b - Target Picking and Cancel", + "sky_id": "sky_neutral", + "background_kind": "sky", + "plain_color": "#F5EFE0", + "camera": { + "position": { + "x": 0.0, + "y": -240.0 + }, + "zoom": 0.52 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "y": 160.0, + "x": -960 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "y": 160.0, + "x": -768 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "y": 160.0, + "x": -576 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "y": 160.0, + "x": -384 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "y": 160.0, + "x": -192 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "y": 160.0, + "x": 0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "y": 160.0, + "x": 192 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "y": 160.0, + "x": 384 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "y": 160.0, + "x": 576 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "y": 160.0, + "x": 768 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_11", + "type": "terrain", + "position": { + "y": 160.0, + "x": 960 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "stickman_test7_capture", + "type": "stickman", + "position": { + "x": -520.0, + "y": -225.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "stage_object_id": "stage_phase20b_test7_capture", + "stage_name": "Director", + "facing_profile": "FORWARD", + "action_plan": [ + { + "actions": [ + { + "type": "pick_up_prop", + "target_prop_id": "stage_phase20b_test7_case", + "hand": "auto", + "action_id": "action_phase20b_test7_pickup" + } + ] + }, + { + "actions": [ + { + "type": "set_down_prop", + "target_position": { + "x": 320.0, + "y": -256.0 + }, + "action_id": "action_phase20b_test7_setdown" + } + ] + } + ] + }, + { + "file_id": "prop_test7_case", + "type": "prop", + "position": { + "x": -220.0, + "y": 64.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/briefcase.prop", + "stage_object_id": "stage_phase20b_test7_case", + "stage_name": "Briefcase", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": false, + "blocks_stickmen": false, + "can_pick_up": true, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + }, + { + "file_id": "prop_test7_table", + "type": "prop", + "position": { + "x": 320.0, + "y": 32.0 + }, + "rotation": 0.0, + "asset_id": "res://content/props/prop_coffee_table-v1.prop", + "stage_object_id": "stage_phase20b_test7_table", + "stage_name": "Coffee Table", + "roles": { + "depth": "behind", + "start_motion": "still", + "holds_other_props": true, + "blocks_stickmen": false, + "can_pick_up": false, + "released_blocks_stickmen": true, + "released_bumps_other_props": true, + "legacy_draw_order": false + }, + "use_spots": [], + "next_use_spot_number": 1, + "wobble_on_impact": false, + "scale": { + "x": 1.0, + "y": 1.0 + }, + "material_preset": 1 + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1793765197, + "source_path": "", + "autosave": false, + "modified_unix": 1793765197 + } +} \ No newline at end of file diff --git a/content/stages/phase20b_together_timing_sample.scene b/content/stages/phase20b_together_timing_sample.scene new file mode 100644 index 0000000..5fc2d93 --- /dev/null +++ b/content/stages/phase20b_together_timing_sample.scene @@ -0,0 +1,206 @@ +{ + "version": "1.9", + "scene_name": "Phase 20b - Together Timing", + "sky_id": "sky_neutral", + "background_kind": "sky", + "plain_color": "#F5EFE0", + "camera": { + "position": { + "x": -300.0, + "y": -240.0 + }, + "zoom": 0.55 + }, + "objects": [ + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": -384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": -192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": 0.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": 192.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": 384.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": 576.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 768.0, + "y": 160.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + }, + "finish_id": "finish_ground_grass" + }, + { + "file_id": "stickman_timing_test", + "type": "stickman", + "position": { + "x": -400.0, + "y": -225.0 + }, + "rotation": 0.0, + "stk_path": "res://content/stickmen/break.stk", + "stage_object_id": "stage_phase20b_test3_walker", + "stage_name": "Walker", + "facing_profile": "FORWARD", + "action_plan": [ + { + "actions": [ + { + "destination": { + "kind": "place", + "position": { + "x": -220.0, + "y": -225.0 + } + }, + "action_id": "action_phase20b_test3_walk", + "type": "move_to", + "style": "walk" + }, + { + "type": "speak", + "text": "I am walking and talking!", + "stop_current": false, + "action_id": "action_phase20b_test3_say", + "duration": 3.0, + "keep_acting": false + } + ] + }, + { + "actions": [ + { + "action_id": "action_phase20b_test3_wait", + "type": "wait", + "duration": 0.5 + } + ] + } + ] + } + ], + "event_rules": [], + "metadata": { + "source_path": "", + "created_unix": 1793745212, + "autosave": false, + "modified_unix": 1793745212 + } +} \ No newline at end of file diff --git a/docs/architecture/tech_debt_and_optimizations.md b/docs/architecture/tech_debt_and_optimizations.md index 0441cbb..f680937 100644 --- a/docs/architecture/tech_debt_and_optimizations.md +++ b/docs/architecture/tech_debt_and_optimizations.md @@ -92,6 +92,10 @@ This document tracks known technical debt, optimization opportunities, and minor | 80 | **Stickman panel nodes currently own both document shapes and local view state** — rebuilding a panel for a focused layout would reset selection, zoom, and pan and risk save/preview drift. | Medium | Spec prepared; implementation pending | Move the same ten BodyPartPanel instances between Overview and Focus hosts, retain one permanent WholeStickmanPreview sibling, and defer switching while a drag or popup is active. The controller owns session-only view choice and active part; manually chosen views survive resize, and narrow Overview remains scrollable. A future document-model extraction is warranted only if new editor features make live-node ownership limiting. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) | | 81 | **Stickman authored transforms lack a complete dirty-state boundary** — preview translation emits `part_moved`, while rotation and scale currently have no commit signal; Save/Load/Clear do not track unsaved work. | Medium | Spec prepared; implementation pending | Add a controller-observable transform commit path for position, rotation, and scale, plus `_dirty`/`_current_path` and Save/Discard Changes/Cancel transactions for replacement and close. Keep Overview/Focus, guide, grid, selection, and zoom/pan as session UI state that never marks dirty. Preserve the `.stk` format. See `docs/specs/editor_visual_harmonization.md`. (2026-10-23) | | 82 | **Phase 20a introduces persistent stage identity alongside temporary save IDs** — props already use persistent `stage_object_id`, while stickmen currently depend on runtime instance IDs and each save regenerates `file_id` by World order. | Medium | Spec prepared; implementation pending | Keep `file_id` for the current rule remap, retain existing prop IDs, and add a persistent character ID plus scene-unique display names independent of assets. Migrate legacy character IDs deterministically from their pre-migration `file_id`; preserve IDs and names through missing-asset placeholders. A scene-scoped World scan for name allocation is simpler than a mutable registry at current scene sizes. Phase 20b–20e may consume these IDs for authored links; this spec does not implement those links. See `docs/specs/phase_20a_stage_identities_and_names.md`. (2026-10-25) | +| 83 | **Phase 20b needs persistent action identity and a plan migration across flat queue callers** — current integer IDs regenerate at load/PLAY, and flat encoders strip unknown action fields. | High | Spec prepared; implementation pending | Save v1.9 nested steps and scene-unique string action IDs; derive deterministic IDs from actor ID and flat index for older scenes. Keep IDs across edit/reorder/regroup/retarget, regenerate on actor duplication, and validate current-format collisions before World apply. Adapt queue/editor/visual/rule callers through typed plan APIs and a derived flat compatibility view; never persist session integer IDs or use stage names as keys. The v1.8 `speak.keep_acting` overlap may require a narrow legacy-only execution flag when it cannot become a compatible Move/Wait+Say step. See `docs/specs/phase_20b_action_plans_and_direct_authoring.md`. (2026-10-26) | +| 84 | **Live named-prop pursuit can churn navigation or finish at a stale point** — a rigid prop can translate and spin while an authored Move runs. | Medium | Spec prepared; tuning pending | Store a world-aligned offset from the stable prop ID, retarget only for meaningful translation without restarting gait, and cap unpaused pursuit time. Check both current approach arrival and live prop reach before success; a missing target, blocked route, invalid projection, or timeout fails and emits no reached-endpoint event. Initial 16-unit/0.1-second retarget and 12-second budget values are tuning candidates for repeatable behavior and rendered checks, not permanent schema fields. Future Follow has a separate continuing stop contract. See `docs/specs/phase_20b_action_plans_and_direct_authoring.md`. (2026-10-26) | +| 85 | **A just-set-down prop can be bumped by the releasing stickman during the next queued move** — the actor may still be beside the new placement when the following action begins. | Low | Implemented; 57 focused runtime checks pass | Set Down alone adds a temporary collision exception for the releasing rig, and that rig's movement sweep skips the prop until its collision shapes remain clear by 24 px for two physics frames. Keep the authored released flags, support contact, and all other actors' contacts unchanged; Drop, Throw, and ragdoll release receive no grace. This deliberately revises Phase 17a's earlier no-owner-grace rule to protect the immediate Set Down → Move sequence. See `docs/specs/phase_17a_prop_roles.md`. (2026-11-02) | +| 86 | **A stickman can bump a reserved pickup target while approaching it** — normal collision handling can move the prop before the reach completes. | Low | Implemented; 68 focused runtime checks pass | Keep a pair-specific exception for the reserving actor and target throughout approach/reach. Successful pickup clears it through the HELD collision profile; cancel/failure changes it to clearance grace. The target retains support and contact with every other actor and prop. See `docs/specs/phase_17a_prop_roles.md`. (2026-11-02) | --- @@ -197,4 +201,10 @@ This document tracks known technical debt, optimization opportunities, and minor | 2026-10-25 | Prepared the Phase 20a identities, names, and overlap-selection architecture specification (`docs/specs/phase_20a_stage_identities_and_names.md`) for user sign-off. It specifies persistent character/prop IDs and scene-unique names alongside the existing temporary rule `file_id`, deterministic legacy migration to scene v1.8, shared EDIT name inspectors, and a visible-geometry click check with a transient overlap preview. Updated open #12 for the bounded character/prop precision plan and added #82 for the identity and migration trade-off. No runtime code or tests changed. | +| 2026-10-26 | Prepared the Phase 20b Step Stack UI/architecture specification (`docs/specs/phase_20b_action_plans_and_direct_authoring.md`) for final user sign-off. It records the approved Move/Wait+Say compatibility, explicit free-place versus named-prop links, finite world-aligned moving-prop pursuit, future-only paused regroup/reorder, scene v1.9 plan/action-ID migration, runtime group barrier, and three verification gates. Added #83–#84 for the flat-queue compatibility and pursuit tuning trade-offs. No implementation code or tests changed. | + +| 2026-11-02 | Revised Phase 17a's Set Down release rule for the reported Set Down → Move collision: only the releasing rig receives a contact exception, held until its collision shapes clear the prop by 24 px for two physics frames. The exception also applies to that rig's movement sweep, and the prop retains its authored collision role with supports and other actors. Added #85; `test_phase17a_prop_roles.gd` passes 57 runtime checks. | + +| 2026-11-02 | Added reserved-pickup collision protection: the pickup target remains exempt only for its reserving rig through approach and reach, then returns to normal contact on pickup or clearance after cancellation/failure. The existing locomotion sweep honors the same target-specific exemption. Added #86; `test_phase17a_prop_roles.gd` passes 68 focused runtime checks. | + _End of Log_ diff --git a/docs/guides/editor_visual_style_guide.md b/docs/guides/editor_visual_style_guide.md index b4f872a..35d0c5e 100644 --- a/docs/guides/editor_visual_style_guide.md +++ b/docs/guides/editor_visual_style_guide.md @@ -22,6 +22,7 @@ The Sandbox Stage establishes a useful baseline for hierarchy, contrast, child-f | --- | --- | --- | | Sandbox Stage | SandboxTheme loads validated tokens from [sandbox_theme.json](../../sandbox_theme.json). Stage inspector controls live under [scripts/shared/stage_ui](../../scripts/shared/stage_ui/). | Use as the product reference for semantic colors, readable type, focus, action roles, and reusable theme access. | | Stage inspector | The approved [Stage panel UI system](../specs/stage_panel_ui_system.md) targets in-stage selected-object cards. | Do not insert StageInspectorPanel into editor inspectors by default. Borrow fitting rules and state behavior; use its components only if the layout and ownership match. | +| Stage action plans | [Phase 20b](../specs/phase_20b_action_plans_and_direct_authoring.md) defines the DIRECT Step Stack, action markers, target capture, and action picker. | Use these as Stage-specific references. Reuse their general feedback principles in editor workflows only when the interaction fits. | | Stage object names and overlap chooser | [Phase 20a](../specs/phase_20a_stage_identities_and_names_ui_ux.md) defines the name-and-Rename inspector header and the anchored **Objects here** chooser. | Reuse the rename interaction for other renameable objects; reuse the chooser shell and state tokens for similar non-modal object pickers. Follow the click-versus-drag and transform-handle priority in §4.6. Keep object-specific naming rules with the owning feature. | | Stage and editor dialogs | [StageDialogStyler](../../scripts/shared/stage_ui/stage_dialog_styler.gd) draws the shared confirmation card over the active scene while retaining native dialog signals. The style reads `popups.confirmation` from SandboxTheme. | Use this shared card for Prop and Stickman confirmation, recovery, and short action dialogs. Native file picker content remains native. | | Shared editor chrome | [EditorVisualTheme](../../scripts/shared/editor_ui/editor_visual_theme.gd) loads validated tokens from [editor_visual_theme.json](../../editor_visual_theme.json) and builds each editor's local control Theme. `EditorSelectionDropdown` and `EditorContextMenu` supply shared selection and canvas-menu styling. | Use shared semantic tokens and controls for matching editor workflows. Keep editor-specific document behavior in its owner. | @@ -70,6 +71,7 @@ Use these Stage examples as starting points, then tune them for the denser edito - Stage inspector shell: **12 px** padding, **8 px** header gap, **12 px** section gap, **10 px** corner radius. - Stage choice row: **56 px** minimum height; Stage toggle row: **64 px** minimum, increasing when helper text is visible. - Large child-facing dialogs: title about **28–30 px**, body about **20 px**, button about **20 px**, **2 px** borders, **12–18 px** corner radii, and generous footer spacing. +- For short numeric settings such as a wait duration, keep the value directly editable, show its unit, and offer a large touch-size stepper. Optional quick picks should update the same value. Keep typed input, stepping, quick picks, and bounds synchronized; choose an increment that suits the setting. - Stage generally uses dark surfaces with a restrained border. The dialog guide's stronger borders and rounder controls are for modal emphasis, not a requirement to put every editor widget inside a thick outline. For compact inspectors, shorten the hierarchy only after checking it at the intended window size. Prefer a clear label and value over shrinking type until it becomes difficult to read. Use container spacing and alignment rather than per-control position nudges. @@ -158,6 +160,14 @@ Use an anchored chooser when a click lands on multiple real canvas targets and t - Keep the clicked art visible where possible, fit the popup to safe viewport margins, and cap the list height so longer candidate lists scroll. Keep pointer hover and keyboard focus behavior equivalent when they preview a candidate. - Define shell, row, selected, hover, focus, badge, spacing, and Cancel values as theme tokens. The Stage implementation uses `SandboxTheme.popups.anchored_chooser`; other workflows should reuse the visual contract through their own appropriate theme owner. +### 4.7 Stage action planning and target capture + +These are Stage-specific references, not a new inspector layout for the standalone editors. The [Phase 20b spec](../specs/phase_20b_action_plans_and_direct_authoring.md) owns the exact action behavior and component placement. + +**Temporary target capture:** Keep one short, action-specific prompt and a visible **Cancel** together in a stable capture bar. Dim and block the underlying inspector while the stage is waiting for a target. Escape and right-click may act as shortcuts, but the visible Cancel remains available. Cancel discards the draft, restores the prior selection and focus, and leaves the plan and dirty state unchanged. Show a cursor marker and action-specific feedback: highlight only valid Set Down surfaces, keep Move feedback grounded at the actor's walking level, and preserve Throw's ballistic preview. Do not show a movement-looking trajectory while choosing a Pick Up prop. + +**Action markers:** Use one readable icon-and-verb pill for each action. When actions run together, keep their pills separate and stack them so each remains identifiable. Anchor **Pick Up** at its prop, **Say** and **Wait** by the actor, and **Move** at its destination. Let pill color reinforce the action type while the label remains the source of meaning. Do not repeat the step number in the pill when its route flag already carries that number. + ## 5. Editor flows and state maps The following maps describe the current interaction architecture at a level useful for future visual work. They are not proposed additions to file formats. @@ -300,7 +310,10 @@ Do not add spinners or blocking overlays for synchronous work without evidence t - Use motion to show cause and effect: a brief selection flash, a panel entering, or a successful action cue. Keep it short and do not delay the next input. - Do not animate every hover or value change. Motion should help the child understand an edit, not decorate idle controls. - Keep persistent mode and selection state visible after any transition ends. +- For a secondary chooser that slides out beside an inspector, align the panel tops and briefly dim the parent to show that the chooser is temporary. Keep a visible **Cancel**; Escape and an outside click should reverse the same transition. On close, restore the parent and return focus to the control that opened the chooser. +- For card reordering, keep bold up/down buttons stationary and animate the affected cards exchanging places after activation. Keep the selected card selected and visually in front, preserve scroll and focus, and ignore repeat input until the swap finishes. - Respect a reduced-motion preference for nonessential transitions and flashes. Provide an equally clear static state. +- When reduced motion is requested, show the chooser or reordered cards directly in their final state without the transition. - The current code has short visual effects such as the Prop canvas's new-point flash and Stage completion effects. A unified reduced-motion preference is not established by the referenced theme/specs; treat it as a future product-level decision. ## 7. Theme and implementation checklist @@ -404,5 +417,6 @@ To make this guide a stronger long-term design reference, add these pieces as th - [Prop canvas](../../scripts/editors/prop/prop_canvas.gd) — mode behavior, canvas gestures, selection handles, and context menus. - [Stickman Editor controller](../../scripts/editors/stickman/stickman_editor.gd), [Stickman Editor scene](../../scenes/editors/stickman/stickman_editor.tscn), [BodyPartPanel](../../scripts/editors/stickman/body_part_panel.gd), and [Whole Stickman preview](../../scripts/editors/stickman/whole_stickman_preview.gd). - [Stage dialog style helper](../../scripts/shared/stage_ui/stage_dialog_styler.gd) and [Stage capture bar](../../scripts/editors/stage/stage_use_target_capture_bar.gd). +- [Phase 20b action plans and DIRECT authoring](../specs/phase_20b_action_plans_and_direct_authoring.md) — Stage-specific action pills, capture feedback, and plan editing. - [Prop Editor UX specification](../specs/phase_9c_prop_editor_ux.md) — current approved Prop Editor mode, Attach preview, marker, and layout behavior. diff --git a/docs/plans/action_reactions_design_brief.md b/docs/plans/action_reactions_design_brief.md index d53c368..952100c 100644 --- a/docs/plans/action_reactions_design_brief.md +++ b/docs/plans/action_reactions_design_brief.md @@ -47,8 +47,8 @@ Numbered steps and paired actions should fit the shared Stage inspector visual s ## DIRECT mode action previews - A selected character’s route is labeled with its authored movement type, such as **Walk** or **Run**. The destination marker identifies the named target, such as **Blue Door**. Do not use a generic “planned walk” label; show only movement types the editor supports. -- A **Say** step is represented on the stage by a single speech-bubble marker at the action’s starting point. It signals that speech will happen without displaying a bubble count or keeping multiple dialogue bubbles on the stage. Any step number identifies the action’s place in the plan, not the number of speech bubbles. -- Tapping a numbered movement flag selects its existing plan step and reveals **Change place**; selecting alone never moves the destination, and flags are not draggable by default. **Change place** enters destination capture with a clear prompt and visible **Cancel**. The old flag stays faintly visible while the candidate route and flag follow the cursor. Choosing a new point or exact named prop commits the change; **Cancel** or **Esc** restores the original destination. +- A standalone **Say** step uses a compact speech-bubble marker in the plan overview. In Step Spotlight, show a yellow-bordered pill containing the speech glyph and **Say**, without a step number or leader. Place it just above the Say step’s predicted ground start point, with a small gap; when Say follows a Move, align it above that Move’s endpoint flag. Keep the dialogue text in the inspector. A focused **Wait** step uses a purple-bordered pill containing the hourglass glyph and **Wait**, without a step number or leader, just above its predicted ground start point. A focused Wait+Say step stacks Wait above Say. For a concurrent Move+Say step, show stacked Move and **Say** action pills near the first third of the grounded route, using the same small action glyphs and cyan/yellow colors as the inspector; do not also draw a separate speech marker for that Say. Say may accompany every other registered action type, while Say+Say, primary+primary, and any third action remain unavailable. In Step Spotlight, show each Together action that can share the actor’s space as two stacked, icon-bearing pills with its primary action above plain **Say**, using each action’s color; fit the pair as one group against the character name, inspector, and viewport. Keep Move+Say near the route’s first third, Wait and other physical actions near their predicted start, and Throw Prop beside the hands or upper torso. Keep Pick Up over its target prop and Say by the character so the target stays clear. The normal plan overview remains compact. These cues signal actions without displaying bubble counts or multiple dialogue bubbles. +- Tapping a numbered movement flag selects its existing plan step and reveals the stacked **Edit action** and **Erase action** controls; selecting alone never moves the destination, and flags are not draggable by default. **Edit action** opens the destination editor, where choosing a new place or exact named prop enters capture with a clear prompt and visible **Cancel**. The old flag stays faintly visible while the candidate route and flag follow the cursor. Choosing a new point or exact named prop commits the change; **Cancel** or **Esc** restores the original destination. ## Action moments Only moments relevant to the selected authored action are shown. “Starts” is omitted from Drop and Set Down labels because the start timing is implied. diff --git a/docs/plans/action_reactions_mockups/phase_20b_add_together_compatibility.png b/docs/plans/action_reactions_mockups/phase_20b_add_together_compatibility.png new file mode 100644 index 0000000..d686b7c Binary files /dev/null and b/docs/plans/action_reactions_mockups/phase_20b_add_together_compatibility.png differ diff --git a/docs/plans/action_reactions_mockups/phase_20b_step_stack.png b/docs/plans/action_reactions_mockups/phase_20b_step_stack.png new file mode 100644 index 0000000..6a25728 Binary files /dev/null and b/docs/plans/action_reactions_mockups/phase_20b_step_stack.png differ diff --git a/docs/plans/phase_20_action_plans_and_reactions.md b/docs/plans/phase_20_action_plans_and_reactions.md index 65fee76..c65a3b3 100644 --- a/docs/plans/phase_20_action_plans_and_reactions.md +++ b/docs/plans/phase_20_action_plans_and_reactions.md @@ -24,6 +24,8 @@ Do not duplicate detailed decisions here. Add new decisions to the design brief ### Phase 20b — Action plans and DIRECT authoring +**Status:** Implementation delivered; the user verified manual scenarios 1–7. Broader production-interaction and UI/UX-review acceptance evidence remains unrecorded. The branch remains unmerged. See the [Phase 20b implementation and verification spec](../specs/phase_20b_action_plans_and_direct_authoring.md) for the delivery record. + - Author each character's ordered action plan, including actions grouped to run together. - Author movement destinations as either a free place or a specific named prop instance. - Support selecting a route marker, deliberately changing its destination, and the agreed DIRECT preview labels and speech marker. diff --git a/docs/specs/phase_17a_prop_roles.md b/docs/specs/phase_17a_prop_roles.md index 47870f3..c27c979 100644 --- a/docs/specs/phase_17a_prop_roles.md +++ b/docs/specs/phase_17a_prop_roles.md @@ -137,9 +137,9 @@ Add `set_down_prop` to `ActionRegistry`, `ActionEditor`, stickman action popup, The interactor adds SET_DOWN_APPROACHING and SET_DOWN_REACHING (or equivalent explicit substates), with one shared nonmutating solver for authoring preview and PLAY validation. It reuses the queued held-prop preview prefix from Throw to determine the prop, grip, hand, and predicted actor pose. The prefix evaluator must also consume earlier Set Down actions as release actions and account for Drop/Throw; it may return `known=false` when a rule or earlier action makes the held prop uncertain. Do not move or reserve a live prop during preview. When unknown, show the neutral marker and **Prop depends on earlier actions**, and assess grounded approach/reach where possible without pretending to know exact occupancy. As with the existing Throw preview, cache physics shape checks in `_physics_process()` and draw the latest result in `_process()`; do not perform fresh direct-space queries from every pointer draw. -For a known prop, retain its current held orientation and authored/play scale. Treat `target_position` as the requested release origin, then check the prospective collision shape against terrain and released-profile actor/prop contacts; exclude the held prop's own RID but check the acting rig's torso/head and other solid parts when released actor bumps are On. If the shape overlaps terrain or a prop whose **Holds other props** setting is On, raise the prospective origin vertically just enough to clear the overlapping support geometry. Do not shift it sideways or select a named surface. Keep the requested point unchanged in the saved action. If the shape overlaps the acting character or a non-support prop, reject it with **No room to let go here**. Do not add an implicit owner-collision grace period or permit release inside the torso. An open-air point remains exactly where clicked. Compute the grip position from the resolved transform and asset anchor, then solve a grounded approach on terrain to either side and check wrist/IK reach. Do not teleport the actor or clamp the point to make it reachable. Preview the requested point and corrected release origin when they differ. Re-resolve at runtime because actors and props may have moved since authoring. If the route is blocked or the point remains occupied, keep the prop held, restore its hand pose, show **Too far to reach** or **No room to let go here**, finish the action safely, and let the child edit the target. If no prop is held, show a short cue on that actor and finish safely without creating or dropping a prop. +For a known prop, retain its current held orientation and authored/play scale. Treat `target_position` as the requested release origin, then check the prospective collision shape against terrain and released-profile actor/prop contacts; exclude the held prop's own RID but check the acting rig's torso/head and other solid parts when released actor bumps are On. If the shape overlaps terrain or a prop whose **Holds other props** setting is On, raise the prospective origin vertically just enough to clear the overlapping support geometry. Do not shift it sideways or select a named surface. Keep the requested point unchanged in the saved action. If the shape overlaps the acting character or a non-support prop, reject it with **No room to let go here**. An open-air point remains exactly where clicked. Compute the grip position from the resolved transform and asset anchor, then solve a grounded approach on terrain to either side and check wrist/IK reach. Do not teleport the actor or clamp the point to make it reachable. Preview the requested point and corrected release origin when they differ. Re-resolve at runtime because actors and props may have moved since authoring. If the route is blocked or the point remains occupied, keep the prop held, restore its hand pose, show **Too far to reach** or **No room to let go here**, finish the action safely, and let the child edit the target. If no prop is held, show a short cue on that actor and finish safely without creating or dropping a prop. -During the reach, move the held prop smoothly with the hand marker toward the resolved release transform. If the point is low, bend the torso and knees during the reach, then release the prop and ease the arm and body back to their resting pose. At release, use the resolved origin and apply `RELEASED` with zero launch velocity so gravity carries the prop onto the surface. The support prop's collision geometry and gravity determine where the object settles. While held, the prop has no collision layer or mask; released collision settings take effect at the release moment. A pause freezes the animation and prop state. Ordinary action cancellation before release keeps the prop held and restores the marker; ragdoll aborts Set Down and uses the existing forced-release path with the authored released profile. STOP restores authored placement. Preserve the existing grip attachment logic and scale conversion rather than assuming an unscaled local anchor. +During the reach, move the held prop smoothly with the hand marker toward the resolved release transform. If the point is low, bend the torso and knees during the reach, then release the prop and ease the arm and body back to their resting pose. At release, use the resolved origin and apply `RELEASED` with zero launch velocity so gravity carries the prop onto the surface. The support prop's collision geometry and gravity determine where the object settles. While held, the prop has no collision layer or mask; released collision settings take effect at the release moment. For a pickup, when the actor reserves its target, add an owner-specific contact exception between that target and the reserving rig's animated and ragdoll collision bodies. Keep it while `interaction_reserved_by` names that rig, regardless of distance or elapsed time; the owner's movement sweeps also skip only that target. On successful pickup, the `HELD` role clears the exception with the collision profile. On cancel or failure, release the reservation but retain an exit grace until the actor's collision shapes clear the prop by a 24 px query margin for two consecutive physics frames. For Set Down, add the same owner-specific exception after release and keep it until those shapes clear by that margin. Neither behavior changes authored collision flags or contact with the support, other props, or other stickmen; normal contacts resume after clearance. These protections are owner-specific and clearance-based, not inherited from the support; pickup protection lasts for the full reservation so it cannot expire while the actor is far away. Drop, Throw, and ragdoll forced release keep their authored contact behavior. A pause freezes the animation and prop state. Ordinary action cancellation before release keeps the prop held and restores the marker; STOP clears temporary exceptions and restores authored placement. Preserve the existing grip attachment logic and scale conversion rather than assuming an unscaled local anchor. ## Stage UI, selection, and theme @@ -151,6 +151,6 @@ Use `SandboxTheme.control_theme()` on the card and popup. Read dimensions, fonts ## Regression checklist -For future regression runs, use `tests/test_phase17a_prop_roles.gd` for role defaults/migration, queue and rule Set Down serialization, unknown-prop preview, released-vs-held transitions, and support contact, followed by Godot headless syntax validation. A small runtime scene can check that a support-only table catches a released pot while a stickman passes through, that switching **Can bump into other props** Off makes the pot fall through the table but still land on terrain, and that **Blocks stickmen** stops walking instead of letting the root tunnel through. Reopen saved scenes, copy a placement, select a fully occluded prop, and check correction of a bad Set Down target. Repeat PLAY after STOP and confirm identical authored arrangement, role settings, and usable stable IDs. Check pause/resume during holding and after release, plus ragdoll release. +For future regression runs, use `tests/test_phase17a_prop_roles.gd` for role defaults/migration, queue and rule Set Down serialization, unknown-prop preview, released-vs-held transitions, support contact, Set Down owner-collision grace, and reserved-pickup protection, followed by Godot headless syntax validation. A runtime Set Down followed immediately by Move should confirm that the releasing stickman can clear the prop without knocking it away, the temporary exception is removed after clearance, a different stickman can still contact it, and the prop still rests on its support. A reserved pickup should stay protected through a long approach, clear on successful pickup, and transition to exit grace on cancellation/failure. Also verify that switching **Can bump into other props** Off makes the pot fall through the table but still land on terrain, and that **Blocks stickmen** stops walking instead of letting the root tunnel through. Reopen saved scenes, copy a placement, select a fully occluded prop, and check correction of a bad Set Down target. Repeat PLAY after STOP and confirm identical authored arrangement, role settings, and usable stable IDs. Check pause/resume during holding and after release, plus ragdoll release. The approved table/pot, open-air Set Down, invalid-target feedback, overlap chooser, card readability, old-scene replay, and movement-blocking behavior are useful visual/runtime regression scenarios. diff --git a/docs/specs/phase_20b_action_plans_and_direct_authoring.md b/docs/specs/phase_20b_action_plans_and_direct_authoring.md new file mode 100644 index 0000000..5fc7894 --- /dev/null +++ b/docs/specs/phase_20b_action_plans_and_direct_authoring.md @@ -0,0 +1,226 @@ +# Phase 20b — Action plans and DIRECT authoring + +> **Status:** Implementation delivered; the user verified manual scenarios 1–7. The broader acceptance matrix still has unrecorded production-interaction and UI/UX-review evidence. The branch remains unmerged. +> **Approval:** User approved the specifications in this conversation. +> **Branch:** `feature/phase-20-action-reactions` in `C:\Godot4\stickman\.worktrees\phase-20-action-reactions`. +> **Scope:** The [Phase 20 roadmap](../plans/phase_20_action_plans_and_reactions.md) defines 20b as ordered, optionally grouped character plans, explicit place/prop Move targets, DIRECT route authoring, and stable action identity. [Phase 20c–20e](../plans/phase_20_action_plans_and_reactions.md) retain reaction authoring/runtime and prop event handling. + +## Sources and current boundary + +Use the accepted [action/reaction design brief](../plans/action_reactions_design_brief.md), [Phase 20a identity contract](phase_20a_stage_identities_and_names.md), [Stage panel UI system](stage_panel_ui_system.md), [editor visual style guide](../guides/editor_visual_style_guide.md), and branch-local [Step Stack](../plans/action_reactions_mockups/phase_20b_step_stack.png) and [Add together](../plans/action_reactions_mockups/phase_20b_add_together_compatibility.png) mockups. The mockups show the selected direction; this spec settles data and interaction behavior where a static image cannot. + +Today `.scene` v1.8 saves a flat `action_queue` per stickman. `StickmanActionRunner` runs one primary queue action at a time, with a replaceable speech overlay; its `int` action IDs are regenerated by `set_authored_queue()` and at PLAY start. `QueuePanel` and `ActionEditor` are separate popup workflows. `StageDirectorVisuals` draws and hits flat queue positions separately. `StickmanRig` and `PropBlock` now have persistent `stage_object_id` and scene-unique `stage_name`; `file_id` remains only the save-local rule remap. This spec replaces authored plan identity and scheduling without changing those Phase 20a identities or the existing generic rule system in 20b. + +## Approved product decisions + +1. The selected-character DIRECT inspector uses the **Step Stack**: numbered steps in order; all actions in a step begin together; the next step waits until every action in the current step has ended. +2. The fixed **Move**, **Say**, and **Wait** shortcuts always append a *new* step, even when a card is selected. **+ Add together** is the explicit way to group. +3. A 20b concurrent step contains one supported primary action plus at most one **Say**. Say can accompany every other registered action type, including Move, Wait, prop actions, Ragdoll, and Recover. Two primary actions, Say+Say, and any third action stay incompatible. The group picker shows only compatible choices; when the selected step is Say, it offers every other action type. +4. A named-prop Move keeps the exact `stage_object_id` and a world-aligned approach offset. It pursues that prop for a bounded time during this Move, reaches only when both current approach point and prop are within reach, and fails safely if the prop vanishes, route blocks, or time expires. Rotation of a thrown/spinning prop never rotates the saved offset. +5. In paused DIRECT, grouping and step reorder affect future steps only. Existing active-action replace/delete behavior remains. Completed/interrupted actions cannot be edited or moved, but their action rows still offer confirmed **Erase action** for the next run, as in [Phase 12](phase_12_live_director.md). +6. Phase 20b creates no reaction editor or reaction execution. It persists action IDs for future links and exposes completion outcomes so a later event can distinguish a reached endpoint from failure. + +## Child-facing Step Stack + +```text +Selected Milo in DIRECT stage route +┌───────────────────────────────────┐ Milo ── Walk ── 🚩 1 Blue Door +│ Milo │ 💬 1 +│ [ Move ] [ Say ] [ Wait ] │ +│ Plan [+ Add step] │ +│ ┌ Step 1 Together ─────── ↑ ↓ ┐ │ +│ │ Walk to Blue Door │ │ +│ │ Say “Let's go!” │ │ +│ │ [+ Add together] │ │ +│ └────────────────────────────────┘ │ +│ ┌ Step 2 ───────────────── ↑ ↓ ┐ │ +│ │ Wait 2 seconds │ │ +│ └────────────────────────────────┘ │ +└───────────────────────────────────┘ +``` + +The inspector is anchored on the right for **one selected character** in DIRECT. Its right edge aligns with the clock above it, using the shared 12 px viewport inset so the orange DIRECT frame remains visible. Its width is capped at 380 px and shrinks only when the safe viewport is narrower. It uses the selected character's current stage name and icon as its header. Clicking empty stage space dismisses it and clears selection. There is no Close button, the explicit exception already accepted in the design brief. DIRECT selection does not rename the character; the separate EDIT naming inspector retains its own controls. A character switch resets plan scroll and expanded action; an in-place edit preserves the anchor and scroll as far as possible. No character selected means no Director inspector. + +`StageInspectorPanel` provides the shell, fixed header, safe rectangle, focus, and scroll behavior. Extend it with a caller-owned **pinned content slot** between header and scroll body for the three quick actions and plan heading/add control; include the slot in its height budget so only the plan list scrolls. Keep the existing `body()` API and other inspector behavior. Use `set_close_visible(false)` and explicit outside-click handling in the Stage owner; do not add a duplicate shell. `StagePanelChoiceGroup` suits exclusive Walk/Run and Prop/Place choices. `StagePanelToggleRow` and `StagePanelSwitchVisual` are not needed because 20b has no on/off setting. `StageUseTargetCaptureBar` should be generalized or reused for destination capture; keep its prompt and visible Cancel together below the top toolbar/mode pill. `StageDialogStyler` styles action deletion confirmations. Anchored prop and overlap choices use `popups.anchored_chooser` and the 20a **Objects here** behavior. + +The scroll body shows **Plan**, **+ Add step**, and ordered **Step 1**, **Step 2**, etc. Numbers derive from order and update after reorder. Each step title is plain, stationary text in a focusable card. Clicking anywhere on a card, including its title or controls, selects that step; a child control still performs its own action. The card's DIRECT amber border marks selection. Tab focus on an unselected card is shown by a separate white inset outline; the selected card uses its amber border as the focus cue. Enter or Space selects the focused card. The title has no tooltip or hover style. Selecting a different step clears the previous action focus and closes its Action options. Selecting an action summary selects its owning step and opens only that action's **Action options** inline. Long Say summaries stay on one line and end with an ellipsis; hovering the Say row reveals the full utterance in its tooltip, and **Edit action** opens the full speech composer. A step with two actions says **Together**. Actions with editable settings offer vertically stacked, full-width **Edit action**, **Change action** when a different type is valid, and **Erase action** controls. Parameter-free actions such as Ragdoll, Recover, and Drop Prop omit **Edit action**. **Edit action** changes that action's settings; its type is fixed in the plan editor. **Change action** opens the existing slide-out action picker, omits the current type, and shows a **Replace: [current summary]** context line. A type choice opens its normal editor; changes apply only on **Done** (or **Done speech** for Say). Choosing a parameter-free type adds it immediately, or replaces the selected action immediately while preserving its ID, with no empty editor dialog. Cancel/Escape/outside click in the picker closes without changing the plan, and Cancel/Escape in the editor leaves the plan unchanged. The replacement keeps its stable action ID and remains selected. For a Together action, choices are filtered against the other action so the resulting pair stays valid; when no different type can fit, hide **Change action**. Move destination changes are made through **Edit action**, which opens the place/prop destination editor. In paused DIRECT, a completed or interrupted action's options retain **Erase action** with confirmation while **Edit action** is unavailable. There is no **Make someone react to this…** control until 20c. Bold solid up/down arrows reorder a step earlier or later inside 48 × 48 px targets; each has a descriptive tooltip and is disabled at the first/last position or when runtime state forbids reordering. Moving one step swaps only the two neighboring cards with a brief ~200 ms ease-out slide. The moved card remains selected and is drawn in front while they move; the same stable action ID preserves selection/spotlight through the list rebuild. Reorder buttons are disabled during the slide, and the prior scroll position and keyboard focus are restored afterward. Drag reorder is optional and cannot be the only route. Empty state: **Choose an action to start Milo’s plan.** + +**+ Add step** opens a left-side **Choose an action** drawer with every supported action shown as a 48 px icon-and-label choice and a visible **Cancel** button pinned below the list. Its top aligns with the inspector, and it grows below the inspector to show all choices when the safe viewport allows. On shorter viewports, only the action list scrolls; Cancel stays visible and the drawer stays above the Stage status bar. Choosing an action with settings opens that action's editor; Say opens the speech composer. Parameter-free actions (Ragdoll, Recover, and Drop Prop) are added immediately after selection. The selected action type stays fixed in the new plan editor. A step is appended only after its complete draft is committed. **+ Add together** appears only when the selected step has a compatible action to add, and its picker offers only compatible choices: a one-action Say step offers every other action, while a different one-action step offers Say. The same two-action limit prevents Say+Say and primary-action pairs. Move setup asks **How should Milo move?** (Walk/Run) and **Where should Milo go?** (**Choose a prop**/**Choose a place**). The prop roster uses current stage names, a preview/highlight, and optional **Pick on stage**. Overlap picking reuses the 20a geometry-filtered chooser, including preview, Cancel, Escape, and no click-through. During plan place/prop capture, show one cursor-following instruction, a fixed **✕ Cancel** button, the cursor crosshair, and only the grounded route preview with a **Walk**/**Run** label; hide the duplicate prompt panel and generic diagonal trajectory. Place capture asks **Choose a place for Milo.**; prop capture asks **Choose the prop Milo will walk to.** Editing a target asks **Choose a new place or prop for Milo.** Capture is complete only after valid target selection and **Done** where a draft needs confirmation. **Cancel**/Escape discards the draft, creates no empty step, and causes no dirty mark. An invalid landing point says **Choose open ground for Milo.** For other target capture, prop/actor/area/waypoint selection shows only the cursor marker; direct Move To and rule Move previews stay horizontal at the actor's ground level. Throw retains its ballistic arc, and Set Down retains its prop/reach preview. + +The existing speech composer creates exactly one Say action in the chosen step. Commit is atomic at **Done speech**; cancel does not create a step or replace the old Say. Existing bubble styling and content stay inside that action. **Erase action** asks **Erase this action?** via the shared confirmation card; Cancel preserves it. Removing the last action removes its step and renumbers later cards. A type replacement through **Change action** keeps its action ID because it explicitly replaces that selected action; later reaction cleanup rules belong to 20c. + +### Stage preview and capture + +- Keep the stage canvas quiet outside directing. In EDIT, hide authored action-plan routes, flags, Walk/Run badges, Say/Wait markers, and DIRECT actor badges. Keep placement feedback, the grid, selection outlines, and transform handles. When one or more named visible stickmen or props are selected, show a rounded, screen-sized badge for each selected object using its `stage_name`; use the EDIT cyan outline, position from the object's visual bounds, and prefer above the object. If a badge would collide with another badge, a transform handle, Stage chrome, an inspector, or the viewport edge, move it to the nearest clear adjacent position and add a short tether when displaced. Never omit a selected object's badge. Long names elide at the theme maximum width; the full name remains available through the single-object inspector or by selecting that object alone. Badges are read-only and input-transparent; Rename remains in the inspector. While **Objects here** previews a candidate, hide these persistent EDIT badges so the chooser's existing preview-name pill stays distinct. +- DIRECT uses **Step Spotlight** only when exactly one character is selected. With no actor selected or with a multi-selection, hide authored action-plan routes, flags, movement badges, and Say/Wait markers; keep the amber DIRECT frame and the existing direction hint. With one character selected, show that actor's stage name in an amber rounded, screen-sized badge centered above the head. Show the focused step's full route and its action markers; hide routes for other actors. If the plan has no steps, keep the name badge and show no route. On first selection, focus the first pending step, or the active/next pending step while paused. A character switch applies the same default. +- Movement steps outside the focus remain as faint numbered destination flags without route lines or action labels. These ghost flags keep their 44 px hit areas. Selecting a step card or ghost flag moves the focus without changing or dirtying the plan. A flag hit still resolves through the existing `action_id`, scrolls its step into view, and opens its Action options. Flags are not draggable; **Edit action** is the deliberate retarget entry and opens the destination editor. +- Draw the focused route with its authored **Walk** or **Run** label. Use a bright 4 px cyan dashed line with a 6 px dark contrast edge, 12 px dashes, and 7 px gaps. Place the rounded, bold action badge near the first third of the route, away from the center crossing. Keep the destination label separate: an endpoint linked to a prop shows that prop's current `stage_name`, such as **Blue Door**; a free destination uses only its numbered flag, without a generic **Place 2** label or an inferred nearby prop name. +- Round Move action badges have a dark surface, 2 px action-tinted border, bold 18–20 px light text, and generous padding. The actor-name pill uses a dark surface and 2 px DIRECT amber border, bold 20 px text, 12 px corners, and a 260 px maximum width; long names elide while the inspector retains the full stage name. Badges are visual only and do not intercept stage input. +- A focused Move+Say step shows two stacked action pills near the first third of the grounded route: the movement glyph and **Walk**/**Run** label above a speech glyph and **Say** label. Use the Move cyan and Say yellow action colors; this pair replaces the separate Say marker for that step. A focused standalone Say shows a yellow-bordered pill with a speech glyph and **Say**, without a step number or leader. A focused standalone Wait shows a purple-bordered pill with an hourglass glyph and **Wait**, also without a step number or leader; place it just above the action’s predicted ground start point with the same safe-viewport placement as Say. A focused Wait+Say step shows both pills stacked near that point, with Wait above Say. In Step Spotlight, show each primary-action+Say Together step as two stacked, icon-bearing pills with the primary action above plain **Say**; fit the pair as one group against the name badge, inspector, and safe viewport. Place Move+Say near the first third of the grounded route; Wait, Ragdoll, Recover, Drop Prop, Set Down Prop, and Use near the predicted start; Throw Prop beside the character’s hands or upper torso. Keep Pick Up over its target prop and place its Say cue by the character so the target stays clear. Keep dialogue text in the inspector. In the plan overview, paired Move+Say keeps its route stack and other Say pairings remain compact. Markers from nonfocused steps are hidden. When prior steps contain unpredictable physics, show a conditional/uncertain anchor using existing preview semantics rather than pretending an exact simulated future position. +- During retarget capture keep the old route/flag faint, even if selection is temporarily cleared. Draw a candidate route and flag with a validity state and relevant label at the pointer or exact picked prop. The old destination remains the authored value until a valid commit; Cancel/Escape restores its exact prior kind, ID, offset, and coordinate and preserves selection. +- Factor the route/marker layout into one preview projection used by both drawing and hit testing. Its entries carry `action_id`, `step_index`, world points, label, status, and hit shape. This prevents a drawn flag from selecting a different flat queue index after reorder. + +Use `SandboxTheme` as the sole Stage visual source: `stage_panels` for shell/type/action/focus sizes, `mode_colors.direct` for selection, `popups.anchored_chooser` for rosters, `popups.confirmation` for deletion, and a small `director_plan` subtree for card/badge/route/flag/speech/old-target/candidate states. Theme both `SandboxTheme.DEFAULT_CONFIG` and `sandbox_theme.json`; use local stylebox overrides instead of mutating its cached `Theme`. Button targets are at least 44 × 44 logical pixels, preferably 48 px. Fit at wide and approximately 920×560 viewports below Stage chrome; the header and quick actions remain visible while eight or more steps scroll. Long stage names wrap or elide with full tooltip and do not cover actions. Focus/hover/selected/disabled/invalid states are visually distinct and described with text or icon as well as color. In-place picker growth keeps its anchor and only nudges within the safe viewport. + +## Canonical document model and identity + +One placed character owns an ordered `action_plan: Array[Dictionary]`. Each step is `{ "actions": Array[Dictionary] }`, with one or two nonempty actions; no persisted step ID or number. Each action has an opaque nonempty **string** `action_id` plus the action's canonical `type` and parameters. IDs are unique across authored plans in one `.scene`, independent of actor name, step/order, destination, and action type. They survive save/load, stage rename, edit, regroup, and step reorder. A duplicated *placed* character receives a new `stage_object_id` (20a) and fresh action IDs for all copied plan actions. Deletion retires IDs; a new action never reuses them in that scene. A replacement through Edit action keeps the selected ID; **+ Add step** or **+ Add together** allocates a new ID only on valid commit. Cancel never consumes a persisted ID or mutates the plan. Scene names are presentation values only and never link keys. + +Use a scene-scoped `StageActionIdentity` (`RefCounted`, not autoload) to allocate/check IDs against real and missing character entries. A stable ID may be an opaque `action_` string; its textual shape is not part of the UI or event contract. Legacy v1.8 migration derives deterministic IDs from `stage_object_id` plus original flat index, with deterministic collision suffixes. A current-format file with duplicate/blank IDs is rejected before World clear, not silently repaired. A missing `.stk` placeholder preserves the entire plan and IDs in its original entry through save and asset repair. + +### Target payloads + +The canonical Move action uses one discriminated `destination` dictionary. Its position values are authored world coordinates, encoded with the existing `{x,y}` vector helper. The runtime `target: Vector2` used by present locomotion is derived, not a second authoritative serialized target. + +```json +{ + "action_id": "action_opaque_17", + "type": "move_to", + "style": "walk", + "destination": { + "kind": "prop", + "target_prop_id": "stage_opaque_4", + "approach_offset": { "x": -42.0, "y": 0.0 }, + "last_known_approach": { "x": 310.0, "y": 420.0 } + } +} +``` + +```json +{ + "action_id": "action_opaque_18", + "type": "move_to", + "style": "run", + "destination": { "kind": "place", "position": { "x": 510.0, "y": 420.0 } } +} +``` + +`target_prop_id` is the exact 20a `PropBlock.stage_object_id`, including missing-prop placeholders; `approach_offset` is a **world-aligned translation** from prop origin to the selected approach point. It does not rotate with the prop. `last_known_approach` is only an authored editor fallback for an unresolved/deleted prop link; PLAY motion does not rewrite it on save. It never turns the action into a free place or implies successful arrival. Prop rename changes displayed text but not this payload. A deleted target remains a repairable unresolved link labeled **Missing prop** and fails at PLAY if not retargeted. `kind: "place"` has no prop ID, even when its coordinate overlaps or is near a prop. Changing place commits a new destination dictionary on the same action ID. Validate finite vectors, known kinds, nonblank string prop IDs, and Walk/Run style; validate referenced prop IDs when present, while allowing an unresolved ID to preserve repairable authored intent. Never infer a prop link from coordinates. `ContentPaths.canonicalize()` remains on existing asset path fields; these identity and coordinate fields are not paths. + +### Scene v1.9 boundary + +`StageSceneIO.FILE_VERSION` becomes `"1.9"`. Current v1.9 character entries save `action_plan` and do not save a second authoritative `action_queue`. Add `encode_plan()`/`decode_plan()` or equivalent so IDs and destinations cannot be stripped by today's flat `_encode_flat_action()`/`_decode_flat_action()`. Preserve full speech payload normalization. Existing `event_rules`, their save-local `file_id` remap, background fields, and 20a stage identities/names stay intact. `StageSceneAdapter.collect()` writes the rig's canonical plan; `apply()` restores it after object spawn and identity assignment. Missing-character placeholders keep the canonical plan in `original_entry` so a save does not lose it. + +For v1.0–v1.7, run the existing historical migrations, including the 20a identity/name pass, then migrate flat actions. For v1.8, retain its validated identities/names and migrate each valid flat `action_queue` entry to an ordered singleton step with a deterministic action ID; old `move_to.target` becomes a `place` destination. Existing malformed queue entries keep the present skip-and-warn behavior, while the current-format v1.9 shape is strictly validated. Preserve the precise old Sky/Plain and wall-tint version conditions: v1.8 must not receive older background defaults or wall rewrites. A migrated file saves as v1.9. `read_file()` migrates and validates the whole document before `StageSceneAdapter.apply()` clears World, including cross-actor action ID uniqueness, nonempty steps, compatibility, and destination shape. + +The old `speak.keep_acting` overlay is a compatibility seam. Migration pairs a v1.8 overlay Say with its immediately following Move/Wait when that creates a compatible step, retaining order and approximate concurrent intent. Otherwise it makes a singleton Say with an explicit legacy-only nonblocking runtime flag to preserve old playback. The legacy flag is an exception to the new step barrier; new 20b authoring never creates it. Do not silently drop speech or reinterpret it as a prop/place target. Record paired and unpaired cases in migration tests. The new authored Say in a group remains active until its own bubble/line duration finishes, so its barrier is real. + +## Runtime step execution + +One runner owns the actor's canonical plan and an ephemeral execution cursor. At step start, snapshot both actions, mark both active, emit each `action_started` in plan order in the same physics tick, and start the primary track (Move/Wait) and optional Say track. The Say track's completion is the actual speech duration, not the instant its overlay is shown. Each action independently enters `PENDING`, `ACTIVE`, `SUCCEEDED`, `FAILED`, or `CANCELLED`; a step ends when all its actions have ended, then starts the next step on the next runner tick. Failure is an ended state and advances the plan; it is never reported as a reached endpoint. An empty plan completes immediately. STOP resets runtime status but does not change saved IDs or steps. Pausing freezes timers/pursuit and resumes them without consuming the bounded pursuit budget. + +```mermaid +stateDiagram-v2 + [*] --> Pending + Pending --> Active: step starts + Active --> Succeeded: action succeeds + Active --> Failed: missing target / blocked / timeout + Active --> Cancelled: edit, delete, or STOP + Succeeded --> [*] + Failed --> [*] + Cancelled --> [*] +``` + +Keep `StickmanRig` as the public façade. Add typed plan APIs and typed event signals while adapting existing flat/index callers deliberately; do not persist or regenerate the old session-only `int` IDs as link keys. `ActionRegistry` owns an explicit authored-step compatibility predicate, separate from `ReactiveRole`/`overlay_eligible`, which still govern existing generic rule injection. Current generic rules and reactive actions remain operational, but are not converted into reaction plans. For 20b, preserve their present priority behavior and do not design 20d interruption/resume here. + +| Owner | Proposed typed contract and work | +| --- | --- | +| `StageActionPlan extends RefCounted` (`scripts/stage/stage_action_plan.gd`) | `static func validate_steps(steps: Array[Dictionary]) -> String`; `static func can_add_together(step: Dictionary, action_type: String) -> bool`; pure deep-copy edit/reorder helpers returning a new plan or error. It owns structure/compatibility, no nodes or UI. | +| `StageActionIdentity extends RefCounted` (`scripts/stage/stage_action_identity.gd`) | `func allocate(world: Node2D) -> String`; `func validate_scene_ids(objects: Array) -> String`; scan current scene and placeholders rather than maintaining a second mutable registry. | +| `StickmanRig extends Node2D` | `func get_action_plan() -> Array[Dictionary]`; `func set_action_plan(steps: Array[Dictionary]) -> bool`; `func find_action(action_id: String) -> Dictionary`; `signal planned_action_started(action_id: String, action: Dictionary, step_index: int)`; `signal planned_action_ended(action_id: String, outcome: StringName, step_index: int)`. Existing `action_started/action_finished`, `arrived`, `get_queue()` and queue mutation callers need a tested compatibility path during migration. | +| `StickmanActionRunner extends Node` | Execute current step with primary + Say tracks; track statuses by string action ID; expose `get_plan_view() -> Array[Dictionary]` with `step_index`, `action_id`, `action`, `status`; preserve `pause_simulation()`/`resume_simulation()` and active replace/delete semantics. A direct `get_queue_view()` can be a derived flat projection for legacy consumers during migration, never the persistence model. | +| `StickmanLocomotion extends Node` | Add a lightweight `retarget_walk(target: Vector2) -> bool` for the current active Move: update `_walk_target_feet`/`NavigationAgent2D.target_position` without resetting gait, facing, pause, or settle history. Report reached versus blocked explicitly. | +| `StageSceneIO extends RefCounted` / `StageSceneAdapter extends RefCounted` | v1.9 encode/decode/migrate/validate plan, actor IDs, prop links, and missing placeholders before live apply; preserve old rule remap and path canonicalization. | +| `StageActionQueueController extends RefCounted` and `StageDirectorController extends Node` | Coordinate atomic draft/commit by string action ID, capture, step edits, pausable history guard, and plan UI intent. Convert delayed editor/capture references from index/session `int` to stable `String`; re-resolve against the live plan on commit. | +| `StageDirectorVisuals extends Node2D` | Produce one route/marker projection shared by draw and hit. Hit result uses `action_id: String`, not a stale index. Keep rule visuals independent. | +| `QueuePanel extends PopupPanel` / `ActionEditor extends PopupPanel` | Reuse action-type editing/composer behavior where it fits. The selected-character Step Stack becomes the primary DIRECT plan view; retire or adapt the flat queue popup without a second conflicting authoring source. Dialogs need their own focus/capture layout and should not be forced into `StageInspectorPanel`. | + +Suggested event flow: + +```mermaid +flowchart LR + UI[Step Stack / flag hit] --> Intent[StageActionQueueController by action_id] + Intent --> Draft[Editor or destination capture] + Draft -->|Done and valid| Plan[StageActionPlan commit] + Draft -->|Cancel| UI + Plan --> Rig[StickmanRig plan facade] + Rig --> Draw[StageDirectorVisuals projection] + Rig --> IO[StageSceneAdapter -> StageSceneIO v1.9] + Rig --> Runner[StickmanActionRunner] + Runner -->|started / ended with outcome| Stage[SandboxStage listeners] +``` + +All UI and visual pointers re-resolve action ID at commit/hit; index is only a derived display position. Selection and opening Action options are read-only. `queue_changed`/plan-changed refresh happens after one valid transaction; mark the Stage document dirty exactly once per commit. Cancel, invalid input, selection, and hover never mark dirty. + +### Bounded pursuit for named props + +At this Move's start, resolve `target_prop_id` to the exact live `PropBlock`, including a missing placeholder check. Project `prop.global_position + approach_offset` onto a reachable terrain approach point, retaining the offset in world axes even while the rigid body spins. While the Move is active, sample the prop's translation before locomotion's movement tick and retarget only after both a meaningful target shift and a minimum interval (initial tuning: at least 16 world units **and** 0.1 seconds since the last retarget) so a rolling ball does not restart the walk animation or repath every tiny physics move. The implementation may tune thresholds from rendered/behavior checks but must retain a finite budget (initial candidate: 12 seconds of unpaused active pursuit). A settled prop simply leaves the latest destination in place; if it moves again before arrival, retarget while budget remains. + +Success requires the actor to finish arrival at the **current** ground-projected approach point *and* the live prop to be within the interaction reach used for an ordinary grounded actor/prop approach. Arrival at a stale point, or directly under an airborne out-of-reach prop, is not success: continue waiting/retargeting until reachable or timeout. A vanished/unresolvable prop, blocked/unreachable path, invalid ground projection, or expired budget ends the Move as `FAILED`, advances the step when its other action ends, and emits no successful endpoint moment. The failure may show brief child-facing feedback and an action status, without a blocking popup during PLAY. Freeze the budget during pause; STOP cancels it. Ensure `StickmanRig.arrived`/legacy waypoint rules do not receive a false intermediate arrival from a retargeted prop Move; later action-specific endpoint events must derive from `planned_action_ended(..., SUCCEEDED)` for that exact ID. A free-place Move keeps the existing finite walk target and arrival behavior. + +This Move is finite pursuit of *one prop for one destination action*. It ends when reached or failed. The future 20c **Follow** action is a different authored action with its own explicit stop condition and continues to track after catch-up; do not turn every prop-linked Move into an indefinite Follow. + +### Paused DIRECT invariants + +Step reorder and Add together are allowed only on wholly future steps. A step containing any `ACTIVE`, `SUCCEEDED`, `FAILED`, `CANCELLED`, or legacy interrupted action cannot be regrouped or reordered; disable those structural controls with a concise reason. Completed/interrupted action **contents** cannot be edited, retargeted, or moved during this run, but their rows retain **Erase action** with the normal confirmation, matching Phase 12. That deletion removes the authored action for the next run while leaving consumed runtime history and already-emitted outcomes untouched; if it empties a step, remove that step from the saved plan without replaying or renumbering the live execution cursor. Preserve the existing ability to replace/delete the active action while paused: cancel that action's live track, keep the unaffected concurrent action/status in the same step, re-resolve by stable ID, then run the replacement or finish the step as appropriate. Editing a pending action preserves its ID and cannot alter an already-started Say event. Future-step edits rebuild only future runtime snapshots. Resume uses the modified future plan; STOP/PLAY starts from the authored beginning. + +## Acceptance and verification plan + +The roadmap requires automated behavior/persistence assertions, production-path interaction, and rendered UIUX review against the style guide/mockups. The checks below define the acceptance scope; completion status is recorded above. + +### 1. Automated behavior and persistence + +1. Validate singleton steps and Say paired with every other registered action type; reject primary+primary, Say+Say, a third action, empty steps, duplicate/blank action IDs, malformed destination variants, nonfinite vectors, and unknown style. Confirm **+ Add together** appears on a standalone Say step and offers all other action types, while every other singleton offers Say. Confirm the picker omits choices that would create an invalid pair. Confirm all edit/reorder/regroup/retarget operations retain IDs, duplicate actor regenerates every copied action ID, and invalid/cancel creates no saved mutation. +2. Load v1.0–v1.8 fixtures; assert Phase 20a IDs/names, background/wall migrations, deterministic legacy action IDs, fixed Move targets becoming places, and current `.scene` v1.9 save/reopen preserving every action, speech payload, prop target ID/offset, and existing generic rules. Reject malformed current v1.9 plans before the live World is cleared. Save/reload missing actor and missing/deleted prop references without silently turning them into coordinates. +3. Run a small repeated step timing scenario: a plan with `Step 1 = Walk 180 units + Say 3 seconds`, `Step 2 = Wait 0.5 seconds`. Record physics timestamps for both Step 1 starts, Walk end, Say end, and Wait start. Both starts occur in the same runner tick; Wait starts only after `max(Walk end, Say end)`. Repeat with Wait+Say and Recover+Say, STOP/PLAY, and pause/resume. A failed Move still lets its Say finish before the next step. +4. Run a repeatable moving-prop scenario on simple Ground: actor Move targets a named rigid ball with a fixed world-left offset; move and spin the ball during the action. Confirm destination updates with translation but not rotation, gait does not restart on retarget, and success occurs only after the actor reaches the latest approach and the ball is within reach. Repeat with an airborne ball over the actor (no early success), a removed target, a blocking prop/path, timeout, and pause (budget frozen). Assert no reached-endpoint event on failure or stale intermediate arrival. +5. Check existing generic rule/queue callers and old `keep_acting` migration cases. A new grouped Say remains active to its real end. Existing rule `file_id` remaps, prop Use/Pick Up IDs, and `ContentPaths` behavior survive. + +### 2. Production-path interaction + +In the real Sandbox Stage, verify EDIT with no selection has no plan markers; selecting a stickman and a prop individually shows one cyan stage-name badge each; mixed multi-selection shows every selected named object badge without overlap or input interception; and the **Objects here** candidate preview suppresses persistent badges. Keep each selected object's badge anchor stable through translate/rotate/scale, release, camera pan, and zoom. Reposition only if that anchor becomes obstructed by the actor, another badge, a transform handle, Stage chrome, an inspector, or the safe viewport; keep unaffected multi-selection badges anchored. Plan routes and action markers stay hidden throughout EDIT. In DIRECT, verify an empty stage with no selection has no plan markers, and selecting exactly one actor restores Step Spotlight. Place the actor-name badge at least 24 screen pixels above the actual visible head shape, including headwear; when the top safe area prevents that placement, move the badge beside the head with a short tether and keep it inside the safe viewport. Select an empty-plan Milo, see the empty message and fixed quick actions, then dismiss on empty stage and reselect. Add Move by Walk/Run and exact prop/place selection; add Say to the selected step via **+ Add together**, then append Wait with its quick action. Select an action in Step 2, then click Step 1's plain title; verify the old action options close and its amber highlight clears. Verify that clicking blank card space and each child control selects its owning card while preserving that control's action. Hover over a step title and verify the text remains stationary with no tooltip. Tab to a card, verify the white inset focus outline, and use Enter/Space to select it. Verify **↑**/**↓** buttons reorder future steps, preserve action IDs, and remain disabled at boundaries or when runtime state forbids reordering. Observe Step 1 **Together** and Step 2 Wait, play and observe concurrent Walk+Say with the barrier before Wait. On a standalone Say step, open **+ Add together** and verify the drawer offers every other registered action; on any other singleton, verify it offers Say. Confirm the picker never offers Say+Say or primary+primary and the two-action card cap stays enforced. Use the reorder arrows and verify numbering, routes, action IDs, and saved order. + +Select Ragdoll, Recover, or Drop Prop from the add/change picker and verify each commits immediately without opening an editor; replacement keeps the action ID, and parameter-free cards have no **Edit action** button. + +Place two instances of the same prop asset, select one by current distinct stage name/visual preview, rename it in EDIT, and verify the Move card/flag update while the saved target ID stays fixed. Place a free destination beside that prop and verify it remains **Place N**. In DIRECT, select a multi-step character and verify only the focused route is bright, other actors' routes are hidden, and other movement steps retain faint numbered flags; click a step card and a ghost flag to switch focus without dirtying the scene. Check the rounded name/action badges, long-name elision, route contrast over sky/building/grass, and that marker labels stay away from route crossings. Tap one numbered flag: the correct existing action opens with no dirty mark or move. Open **Edit action**, inspect the old faint and candidate routes while retargeting, Cancel/Escape, then retarget to the other prop and to a free place; verify the same action ID and saved destination variant each time. Open **Change action** on a standalone Move, verify the slide-in picker lists other types, then Cancel and confirm the plan stays clean. Choose Wait, Cancel its editor, and confirm the Move and ID remain; repeat and commit, then verify Wait replaced Move under the same ID. In a Move+Say Together step, verify changing Move offers all other primary actions except Move, while Say has no **Change action** control; in Wait+Say, changing Wait offers all other primary actions except Wait. Check that Pick Up stays anchored to its prop while Say stays by the actor, and that Recover+Say pills stack without overlap. Check invalid terrain, overlap chooser Cancel, keyboard-only target selection, and speech composer Cancel. Pause during a grouped step: active replace/delete works; grouping/reorder offers only future steps. After one action completes or is interrupted, verify its options offer confirmed Erase but not Edit action; delete it, resume without replaying history, then Reset and confirm the deletion affects the next run. + +### 3. Rendered captures and UIUX review + +Capture the actual GUI states reached by those interactions, not an isolated screenshot generator: EDIT empty selection, selected actor/prop names, mixed multi-selection labels, badge position during a real drag, and overlap chooser preview; DIRECT empty selection, focused Step 1, clear name-to-head gap, and top-edge tether fallback; the Choose an action picker with scrolling and a pinned Cancel button; the Together picker for a standalone Say showing every other action and for another singleton offering Say, without compatibility helper copy; Walk+Say stacked action pills and a later Wait; a prop action+Say with Pick Up over its prop and Say by the actor; Recover+Say with distinct stacked action pills; exact prop roster/preview and 20a overlap chooser; free place near a prop; selected flag/options with stacked Edit, Change, and Erase controls; the Change action drawer for a standalone action and for a Together pair with only compatible replacement choices; Cancel from the drawer/editor and a committed replacement preserving the action ID; Move retarget old/candidate/invalid/Cancel through Edit action; focused standalone Say and Wait pills, stacked Wait+Say pills, and compact overview markers; paused active/future controls; and playback start/barrier frames. Include wide and approximately 920×560 viewports with a long character name and eight or more steps, visible header/quick buttons, body scrolling, bold 48×48 px reorder arrows, an animated adjacent-card swap with the moved card in front, persistent selected-card/action identity, preserved scroll/focus, plain stationary step titles with no tooltip, blank-card and child-control selection, keyboard focus outline, and safe capture bar placement. Compare against the branch-local mockups and `docs/guides/editor_visual_style_guide.md`; record any intentional deviation. An automated assertion, log line, or image file alone is not proof that the production interaction and visual state were reached. + +## Implementation and verification record + +### Delivered + +- **Plan data and identity:** Scene v1.9 stores ordered `action_plan` steps with stable string `action_id` values. Move destinations explicitly identify a free `place` or a prop by its stable stage object ID and world-aligned approach offset. Legacy flat queues are migrated to plans. +- **Step execution:** A step may contain one primary action and one Say. They start together; the next step waits until both finish. The runner records per-action outcomes, and a failed Move does not count as reaching its endpoint. Named-prop pursuit follows translation without rotating the saved offset, pauses its time budget with the simulation, and fails on a missing target, blocked or unreachable route, or expired pursuit budget. +- **DIRECT authoring:** The inspector provides the ordered Step Stack, quick actions, compatible Add together choices, action editing/replacement, cancellation, and step reordering. Selection follows the same action through reorder; route spotlight and on-stage action markers follow the focused step. +- **Target and prop feedback:** Stage target capture uses a visible Cancel and action-specific preview. Pickup selection uses friendly prop names and eligible-target feedback; Set Down highlights only valid support surfaces. Prop interaction safety protects an intended pickup target from its approaching actor and gives the releasing actor collision grace after Set Down. + +### Verification evidence + +- The automated action-plan suite recorded **45 checks, 0 failures** in [the final log](../../tests/phase20b_action_plan_test_final.log). Its checks cover plan validation and serialization, stable IDs, grouped-action timing/barriers, pause behavior, moving-prop retargeting, and missing-target failure. The test source is [test_phase20b_action_plan.gd](../../tests/test_phase20b_action_plan.gd). +- Focused production-GUI runs recorded passing results for selected slices, including grouped authoring, action drawer, step spotlight/reorder, flag selection, and Move editing. Examples include [grouping](../../tests/phase20b_grouping_success_check.log), [Step Spotlight](../../tests/phase20b_step_spotlight_final.log), and [action drawer](../../tests/phase20b_action_drawer_full_height.log). These are targeted slice results, not a single run of every item in the broader GUI checklist above. +- The user reported completing and passing the following seven manual scenarios: + 1. **Airborne target:** pursuing a prop above the actor does not count as arrival before it is reachable. + 2. **Blocked route:** a blocked Move does not pass through the obstacle or report success, and eventually fails. + 3. **Timeout and missing target:** both cases fail without an arrival result or a stalled plan. + 4. **Pause during pursuit:** pausing longer than the pursuit limit does not consume the budget; pursuit resumes toward the prop's current position. Fixture: [Test 4](../../content/stages/phase20b_test4_pause_during_pursuit.scene). + 5. **Save, reload, rename, and delete:** a prop Move retains its identity through rename and remains repairable after deletion; a nearby free destination remains a place target. Fixture: [Test 5](../../content/stages/phase20b_test5_target_identity.scene). + 6. **DIRECT editing and focus:** Move+Say grouping, adding Wait, selection/focus changes, reorder identity, and Change Action cancellation behave as expected. Fixture: [Test 6](../../content/stages/phase20b_test6_direct_editing_focus.scene). + 7. **Target picking and cancel:** Escape cancels place, Pick Up, and Set Down capture without committing the draft; the inspector and target feedback recover. Fixture: [Test 7](../../content/stages/phase20b_test7_target_picking_cancel.scene). + +### Remaining boundary + +The user-verified seven-case matrix and the cited automated/GUI slices do not establish that every detailed item in the broad acceptance matrix was independently exercised and captured. In particular, the complete production-interaction matrix and a recorded UI/UX review remain unrecorded as a single sign-off. Phase 20b does not include event-specific reaction authoring or reaction runtime; those remain in Phases 20c–20e. + +## Decisions and trade-offs + +- **Nested steps and string IDs:** A true step array makes order and barrier semantics explicit. Persistent opaque strings cost more migration and adaptation than flat session indices but keep later event links stable under edits and saves. Step numbers remain derived, avoiding unnecessary step identity. +- **Explicit destination kind:** The discriminated payload prevents proximity inference and keeps prop rename separate from identity. A cached last-known approach supports repairable missing targets; it never acts as an implicit fallback success location. +- **Finite live pursuit:** Retargeting by world-aligned offset supports moving/spinning props without creating a perpetual Follow. Throttling avoids gait resets/repath churn; a bounded budget means a continually moving prop eventually fails, so the plan cannot stall forever. +- **One primary plus one Say:** A user update broadened the rule to every currently registered primary action type. The explicit pair preserves the runner's existing primary/speech split, prevents ambiguous primary+primary and Say+Say steps, and still defers a general parallel action scheduler until later phases need evidence for one. +- **Shared Stage shell, specialized plan body:** Extend `StageInspectorPanel` for pinned quick actions and use shared theme/capture/dialog patterns. The plan card and route preview remain domain-specific; modal action editing keeps its own focus model. +- **Legacy behavior:** Migrating flat queues is deterministic, while old nonblocking `keep_acting` may need a narrow compatibility flag. It must be visibly tested and must not leak into newly authored grouped steps. + +> Historical note: The user approved this spec before implementation. The implementation and verification record above documents the current delivery and evidence status. diff --git a/sandbox_theme.json b/sandbox_theme.json index 0729dab..a64b1fe 100644 --- a/sandbox_theme.json +++ b/sandbox_theme.json @@ -100,7 +100,7 @@ "dialogs": { "queue_size": [680, 520], "rule_size": [720, 560], - "action_size": [560, 390], + "action_size": [560, 460], "confirm_size": [560, 300], "font_size": 22, "message_font_size": 20 @@ -289,6 +289,7 @@ "stage_panels": { "shell": { "preferred_width": 380, + "max_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, @@ -380,13 +381,74 @@ "completed_path_color": "#959EAD", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", + "director_plan": { + "route_color": "#8BD9FF", + "run_color": "#FFB300", + "say_color": "#FFD84D", + "wait_color": "#B69CFF", + "completed_color": "#959EAD", + "failed_color": "#F05B57", + "candidate_valid_color": "#4DE666", + "candidate_invalid_color": "#FF626B", + "flag_size_px": 44, + "flag_number_size_px": 18, + "label_bg_color": "#101720E8", + "label_border_alpha": 0.8, + "spotlight_route_width_px": 4, + "spotlight_route_shadow_width_px": 6, + "spotlight_dash_length_px": 12, + "spotlight_dash_gap_px": 7, + "spotlight_route_alpha": 0.95, + "unfocused_step_alpha": 0.24, + "action_badge_font_size_px": 18, + "action_badge_padding_px": [10, 6], + "action_badge_radius_px": 11, + "action_badge_border_width_px": 2, + "action_badge_icon_slot_width_px": 22, + "action_badge_icon_text_gap_px": 6, + "action_badge_icon_scale": 0.72, + "action_badge_stack_gap_px": 4, + "action_badge_route_gap_px": 6, + "actor_name_font_size_px": 20, + "actor_name_max_width_px": 260, + "actor_name_padding_px": [12, 7], + "actor_name_radius_px": 12, + "actor_name_border_width_px": 2, + "actor_name_offset_px": 26, + "route_badge_fraction": 0.32, + "actor_name_border_color": "#FFB300", + "action_badge_text_color": "#F5F7FA", + "route_shadow_color": "#101720D9", + "action_row_move_surface": "#17313D", + "action_row_say_surface": "#2C281C", + "action_row_wait_surface": "#29243A", + "action_row_other_surface": "#202632", + "action_row_icon_surface": "#252B38", + "action_row_text_color": "#F5F7FA", + "action_row_duration_color": "#AAB2C0", + "action_row_options_color": "#8E9AAA", + "action_row_font_size_px": 18, + "action_row_duration_font_size_px": 14, + "action_row_icon_font_size_px": 20, + "action_row_height_px": 48, + "action_row_icon_size_px": 32, + "action_row_rail_width_px": 4, + "action_row_options_width_px": 24, + "action_row_options_font_size_px": 18, + "action_row_radius_px": 8, + "action_row_selected_border_width_px": 2 + }, "valid": { "color": "#4DE66680" }, "same_type": { "color": "#FFFFFF26" }, "invalid": { "color": "#FF4D4D80" }, "set_down": { "unknown_color": "#C5CAD4", "reach_color": "#FFB300", - "ghost_alpha": 0.36 + "ghost_alpha": 0.36, + "surface_line_width_px": 5, + "surface_hover_line_width_px": 7, + "surface_alpha": 0.75, + "surface_hover_alpha": 0.95 }, "use_preview": { "valid_color": "#42D887", @@ -405,7 +467,9 @@ "candidate_name_pill_corner_radius": 8.0, "candidate_name_pill_border_width": 3.0, "candidate_name_pill_padding_x": 12.0, - "candidate_name_pill_padding_y": 8.0 + "candidate_name_pill_padding_y": 8.0, + "selected_name_pill_font_size_px": 20.0, + "selected_name_pill_max_width_px": 260.0 } }, "widgets": { diff --git a/scripts/characters/stickman/stickman_action_runner.gd b/scripts/characters/stickman/stickman_action_runner.gd index fa15911..2080a61 100644 --- a/scripts/characters/stickman/stickman_action_runner.gd +++ b/scripts/characters/stickman/stickman_action_runner.gd @@ -5,11 +5,18 @@ extends Node ## locomotion and signals requests back to the facade. const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") +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 MISSING_PROP_PLACEHOLDER := preload("res://scripts/props/missing_prop_placeholder.gd") +const PROP_INTERACTION_REACH: float = 110.0 +const PROP_PURSUIT_MIN_SHIFT: float = 16.0 +const PROP_PURSUIT_RETARGET_INTERVAL: float = 0.1 +const PROP_PURSUIT_BUDGET: float = 12.0 const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/speech/stickman_speech_bubble.gd") const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd") enum RunnerState { IDLE, EXECUTING } -enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION } +enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING, PROP_INTERACTION, GROUP_BARRIER, PURSUIT_WAIT } enum QueueStatus { NOT_STARTED, COMPLETED, INTERRUPTED, ACTIVE, PENDING } signal action_started(action: Dictionary, index: int) @@ -20,8 +27,12 @@ signal speech_finished signal ragdoll_requested(enabled: bool) signal recovery_requested signal active_action_cancelled(action: Dictionary) +signal planned_action_started(action_id: String, action: Dictionary, step_index: int) +signal planned_action_ended(action_id: String, outcome: StringName, step_index: int) +signal plan_changed var _authored_queue: Array[Dictionary] = [] +var _authored_steps: Array[Dictionary] = [] var action_queue: Array[Dictionary]: get: return _authored_queue.duplicate(true) @@ -43,7 +54,26 @@ var _speech_bubble: Node2D = null var _speech_preview_suppressed: bool = false var _action_ids: Array[int] = [] var _next_action_id: int = 1 +var _stable_to_numeric_ids: Dictionary = {} +var _stable_status: Dictionary = {} var _current_action_id: int = -1 +var _current_stable_action_id: String = "" +var _current_step_index: int = -1 +var _active_step_entry: Dictionary = {} +var _group_say_action_id: String = "" +var _group_say_numeric_id: int = -1 +var _group_say_step_index: int = -1 +var _group_say_action: Dictionary = {} +var _group_say_barrier: bool = false +var _group_primary_done: bool = false +var _parallel_active_numeric_ids: Dictionary = {} +var _pursuit_action_id: String = "" +var _pursuit_prop_id: String = "" +var _pursuit_offset: Vector2 = Vector2.ZERO +var _pursuit_last_prop_position: Vector2 = Vector2.ZERO +var _pursuit_approach: Vector2 = Vector2.INF +var _pursuit_elapsed: float = 0.0 +var _pursuit_retarget_elapsed: float = 0.0 var _completed_ids: Dictionary = {} var _interrupted_ids: Dictionary = {} var _paused: bool = false @@ -70,8 +100,14 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void: _advance_to_next_action() ActionPhase.WALKING: if _locomotion != null: - if _locomotion.consume_walk_failed() or _locomotion.consume_walk_completed(): - _finish_action() + if _locomotion.consume_walk_failed(): + _end_prop_pursuit() + _finish_action(&"failed") + elif _locomotion.consume_walk_completed(): + if _pursuit_action_id == _current_stable_action_id: + _check_prop_pursuit_arrival() + else: + _finish_action(&"succeeded") ActionPhase.SPEAKING: if _primary_speech.is_empty(): _finish_action() @@ -79,6 +115,11 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void: _phase_timer -= delta if _phase_timer <= 0.0: _finish_action() + ActionPhase.GROUP_BARRIER: + if not _group_say_barrier or _group_say_action_id.is_empty(): + _complete_active_step() + ActionPhase.PURSUIT_WAIT: + pass ActionPhase.RAGDOLLING: if ragdoll_at_rest: _finish_action() @@ -91,6 +132,48 @@ func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void: _finish_action() +func pre_movement(delta: float) -> void: + if _paused or _pursuit_action_id.is_empty() or _action_phase not in [ActionPhase.WALKING, ActionPhase.PURSUIT_WAIT]: + return + _pursuit_elapsed += delta + _pursuit_retarget_elapsed += delta + if _pursuit_elapsed >= PROP_PURSUIT_BUDGET: + if _locomotion != null: + _locomotion.cancel_walk(false) + _end_prop_pursuit() + _finish_action(&"failed") + return + var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id) + if prop == null: + if _locomotion != null: + _locomotion.cancel_walk(false) + _end_prop_pursuit() + _finish_action(&"failed") + return + var prop_position: Vector2 = prop.global_position + if prop_position.distance_to(_pursuit_last_prop_position) < PROP_PURSUIT_MIN_SHIFT or _pursuit_retarget_elapsed < PROP_PURSUIT_RETARGET_INTERVAL: + return + var projected: Vector2 = _locomotion.project_target_to_navigation(prop_position + _pursuit_offset) if _locomotion != null else Vector2.INF + var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK))) + var retargeted: bool = false + if projected.is_finite() and _locomotion != null: + retargeted = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style) + if not projected.is_finite() or not retargeted: + if _locomotion != null: + _locomotion.cancel_walk(false) + _end_prop_pursuit() + _finish_action(&"failed") + return + _pursuit_last_prop_position = prop_position + _pursuit_approach = projected + _pursuit_retarget_elapsed = 0.0 + _action_phase = ActionPhase.WALKING + + +func is_prop_pursuit_active() -> bool: + return not _pursuit_action_id.is_empty() + + func speak(text: String, duration: float) -> void: speak_action({"type": "speak", "text": text, "duration": maxf(duration, 0.0)}) @@ -134,111 +217,286 @@ func sync_facing(profile: int) -> void: func set_authored_queue(actions: Array[Dictionary]) -> bool: if _session_started: return false - _authored_queue = actions.duplicate(true) - _action_ids.clear() - for _action: Dictionary in _authored_queue: - _action_ids.append(_allocate_action_id()) + var steps: Array[Dictionary] = [] + for source_action: Dictionary in actions: + var action: Dictionary = source_action.duplicate(true) + action["action_id"] = _allocate_stable_action_id() + if ACTION_REGISTRY.is_move_action(action) and action.get("target", null) is Vector2: + action["destination"] = {"kind": "place", "position": action["target"]} + action.erase("target") + steps.append({"actions": [action]}) + _authored_steps = steps + _sync_legacy_queue() queue_changed.emit() + plan_changed.emit() return true +func get_action_plan() -> Array[Dictionary]: + return _authored_steps.duplicate(true) + + +func set_action_plan(steps: Array[Dictionary]) -> bool: + if _session_started or not STAGE_ACTION_PLAN.validate_steps(steps).is_empty(): + return false + _authored_steps = steps.duplicate(true) + _sync_legacy_queue() + queue_changed.emit() + plan_changed.emit() + return true + + +func find_action(action_id: String) -> Dictionary: + return STAGE_ACTION_PLAN.find_action(_authored_steps, action_id) + + +func get_plan_view() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for step_index: int in _authored_steps.size(): + var actions_value: Variant = _authored_steps[step_index].get("actions", []) + if not actions_value is Array: + continue + for raw_action: Variant in actions_value: + if not raw_action is Dictionary: + continue + var action: Dictionary = raw_action as Dictionary + var action_id: String = String(action.get("action_id", "")) + result.append({ + "step_index": step_index, + "action_id": action_id, + "action": action.duplicate(true), + "status": _status_for_stable_id(action_id), + "numeric_id": _numeric_for_stable_id(action_id), + }) + return result + + +func add_plan_step(action: Dictionary) -> String: + if _session_started and not _paused: + return "" + var created: Dictionary = _prepare_new_plan_action(action) + var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_step(_authored_steps, created) + return String(created.get("action_id", "")) if _commit_plan_edit(next) else "" + + +func add_plan_action_together(step_index: int, action: Dictionary) -> String: + if _session_started and not _paused: + return "" + if _session_started and _paused and not _plan_step_is_future(step_index): + return "" + var created: Dictionary = _prepare_new_plan_action(action) + var next: Array[Dictionary] = STAGE_ACTION_PLAN.append_together(_authored_steps, step_index, created) + return String(created.get("action_id", "")) if _commit_plan_edit(next) else "" + + +func replace_plan_action(action_id: String, action: Dictionary) -> bool: + if _session_started and not _paused: + return false + var status: StringName = _status_for_stable_id(action_id) + if _session_started and _paused and status in [&"succeeded", &"failed", &"cancelled"]: + return false + var next: Array[Dictionary] = STAGE_ACTION_PLAN.replace_action(_authored_steps, action_id, action) + if next.is_empty() and not _authored_steps.is_empty(): + return false + if _session_started and _paused and status == &"active": + _cancel_stable_action(action_id) + _stable_status[action_id] = &"pending" + return _commit_plan_edit(next) + + +func remove_plan_action(action_id: String) -> bool: + if _session_started and not _paused: + return false + var location: Dictionary = find_action(action_id) + if location.is_empty(): + return false + if _status_for_stable_id(action_id) == &"active": + _cancel_stable_action(action_id) + var next: Array[Dictionary] = STAGE_ACTION_PLAN.remove_action(_authored_steps, action_id) + _stable_status.erase(action_id) + return _commit_plan_edit(next, true) + + +func move_plan_step(from_index: int, to_index: int) -> bool: + if _session_started and not _paused: + return false + if _session_started and _paused and (not _plan_step_is_future(from_index) or not _plan_step_is_future(to_index)): + return false + var next: Array[Dictionary] = STAGE_ACTION_PLAN.move_step(_authored_steps, from_index, to_index) + return _commit_plan_edit(next) if from_index != to_index else true + + +func can_add_plan_action_together(step_index: int, action_type: String) -> bool: + if _session_started and _paused and not _plan_step_is_future(step_index): + return false + if _session_started and not _paused: + return false + return step_index >= 0 and step_index < _authored_steps.size() and STAGE_ACTION_PLAN.can_add_together(_authored_steps[step_index], action_type) + + +func can_reorder_plan_step(step_index: int) -> bool: + if step_index < 0 or step_index >= _authored_steps.size(): + return false + if _session_started and not _paused: + return false + return not _session_started or _plan_step_is_future(step_index) + + +func _prepare_new_plan_action(source_action: Dictionary) -> Dictionary: + var action: Dictionary = source_action.duplicate(true) + action["action_id"] = _allocate_stable_action_id() + if ACTION_REGISTRY.is_move_action(action) and not action.has("destination") and action.get("target", null) is Vector2: + action["destination"] = {"kind": "place", "position": action["target"]} + action.erase("target") + return action + + +func _commit_plan_edit(next: Array[Dictionary], allow_empty: bool = false) -> bool: + if next.is_empty() and not allow_empty: + return false + if not STAGE_ACTION_PLAN.validate_steps(next).is_empty(): + return false + _authored_steps = next.duplicate(true) + _sync_legacy_queue() + if _session_started: + _rebuild_pending_runtime_queue() + queue_changed.emit() + plan_changed.emit() + return true + + +func _sync_legacy_queue() -> void: + _authored_queue = STAGE_ACTION_PLAN.flatten(_authored_steps) + _action_ids.clear() + for action: Dictionary in _authored_queue: + var stable_id: String = String(action.get("action_id", "")) + if not _stable_to_numeric_ids.has(stable_id): + _stable_to_numeric_ids[stable_id] = _allocate_action_id() + _action_ids.append(int(_stable_to_numeric_ids[stable_id])) + + +func _allocate_stable_action_id() -> String: + var identity := STAGE_ACTION_IDENTITY.new() as StageActionIdentity + var world: Node2D = _rig.get_parent() as Node2D if _rig != null else null + return identity.allocate(world) + + +func _numeric_for_stable_id(action_id: String) -> int: + if not _stable_to_numeric_ids.has(action_id): + _stable_to_numeric_ids[action_id] = _allocate_action_id() + return int(_stable_to_numeric_ids[action_id]) + + +func _status_for_stable_id(action_id: String) -> StringName: + if _stable_status.has(action_id): + return StringName(String(_stable_status[action_id])) + return &"pending" if _session_started else &"not_started" + + +func _plan_step_is_future(step_index: int) -> bool: + if step_index < 0 or step_index >= _authored_steps.size(): + return false + var actions: Array = _authored_steps[step_index].get("actions", []) + for raw_action: Variant in actions: + if not raw_action is Dictionary: + return false + if _status_for_stable_id(String((raw_action as Dictionary).get("action_id", ""))) != &"pending": + return false + return true + + +func _cancel_stable_action(action_id: String) -> void: + if action_id == _current_stable_action_id: + _cancel_current_primary(true) + _stable_status[action_id] = &"cancelled" + elif action_id == _group_say_action_id: + _overlay_speech.clear() + _parallel_active_numeric_ids.erase(_group_say_numeric_id) + _stable_status[action_id] = &"cancelled" + planned_action_ended.emit(action_id, &"cancelled", _group_say_step_index) + _group_say_action_id = "" + _group_say_numeric_id = -1 + _group_say_action.clear() + if _group_say_barrier: + _group_say_barrier = false + + func queue_action(action: Dictionary) -> void: insert_action(action_queue.size(), action) func clear_queue() -> void: - _authored_queue.clear() - _action_ids.clear() if not _current_run_item.is_empty(): _cancel_current_primary(true) _current_action_id = -1 + _current_stable_action_id = "" + _group_say_action_id = "" + _group_say_action.clear() + _overlay_speech.clear() + _authored_steps.clear() + _sync_legacy_queue() _run_queue.clear() _run_cursor = 0 if _session_started: _runner_state = RunnerState.IDLE queue_changed.emit() + plan_changed.emit() func get_queue() -> Array[Dictionary]: - return action_queue.duplicate(true) + return _authored_queue.duplicate(true) func remove_action(index: int) -> void: - if index < 0 or index >= action_queue.size(): + if index < 0 or index >= _action_ids.size(): push_warning("StickmanActionRunner: remove_action index %d out of range." % index) return - var action_id: int = _action_ids[index] if index < _action_ids.size() else -1 - var status: int = _status_for_action_id(action_id) - if _session_started and _paused and status == QueueStatus.ACTIVE: - _cancel_current_primary(true) - _current_action_id = -1 - _runner_state = RunnerState.IDLE - _authored_queue.remove_at(index) - if index < _action_ids.size(): - _action_ids.remove_at(index) - _rebuild_pending_runtime_queue() - queue_changed.emit() + var stable_id: String = _stable_for_numeric_id(_action_ids[index]) + if not stable_id.is_empty(): + remove_plan_action(stable_id) func insert_action(index: int, action: Dictionary) -> void: + if _session_started and not _paused: + return var target: int = clampi(index, 0, _authored_queue.size()) - if _session_started and _paused: - if target <= _paused_history_floor(): - return - var action_id: int = _allocate_action_id() - _authored_queue.insert(target, action.duplicate(true)) - _action_ids.insert(target, action_id) - if _session_started and _paused: - _rebuild_pending_runtime_queue() - queue_changed.emit() + var step_target: int = _step_boundary_for_flat_index(target) + if _session_started and _paused and not _plan_step_is_future(step_target): + return + var created: Dictionary = _prepare_new_plan_action(action) + var next: Array[Dictionary] = _authored_steps.duplicate(true) + next.insert(clampi(step_target, 0, next.size()), {"actions": [created]}) + _commit_plan_edit(next) func replace_action(index: int, action: Dictionary) -> bool: if index < 0 or index >= _authored_queue.size(): return false - var action_id: int = _action_ids[index] - var status: int = _status_for_action_id(action_id) - if _session_started and _paused and (status == QueueStatus.COMPLETED or status == QueueStatus.INTERRUPTED): - return false - if _session_started and _paused and status == QueueStatus.ACTIVE: - _cancel_current_primary(true) - _current_action_id = -1 - _runner_state = RunnerState.IDLE - _authored_queue[index] = action.duplicate(true) - if _session_started and _paused: - _completed_ids.erase(action_id) - _interrupted_ids.erase(action_id) - _rebuild_pending_runtime_queue() - queue_changed.emit() - return true + var stable_id: String = String(_authored_queue[index].get("action_id", "")) + var replacement: Dictionary = action.duplicate(true) + if ACTION_REGISTRY.is_move_action(replacement) and replacement.get("target", null) is Vector2: + replacement["destination"] = {"kind": "place", "position": replacement["target"]} + replacement.erase("target") + return replace_plan_action(stable_id, replacement) func move_action(from_index: int, to_index: int) -> bool: if from_index < 0 or from_index >= _authored_queue.size() or to_index < 0 or to_index >= _authored_queue.size(): return false - if _session_started and _paused: - var id: int = _action_ids[from_index] - if _status_for_action_id(id) != QueueStatus.PENDING: - return false - if to_index <= _paused_history_floor(): - return false - if from_index == to_index: - return true - var action: Dictionary = _authored_queue.pop_at(from_index) - var action_id: int = _action_ids.pop_at(from_index) - _authored_queue.insert(to_index, action) - _action_ids.insert(to_index, action_id) - if _session_started and _paused: - _rebuild_pending_runtime_queue() - queue_changed.emit() - return true + var source_id: String = String(_authored_queue[from_index].get("action_id", "")) + var target_id: String = String(_authored_queue[to_index].get("action_id", "")) + var source: Dictionary = find_action(source_id) + var target: Dictionary = find_action(target_id) + if source.is_empty() or target.is_empty(): + return false + return move_plan_step(int(source["step_index"]), int(target["step_index"])) func get_queue_view() -> Array[Dictionary]: var result: Array[Dictionary] = [] for index: int in _authored_queue.size(): var action_id: int = _action_ids[index] if index < _action_ids.size() else -1 - result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id}) + result.append({"action": _authored_queue[index].duplicate(true), "authored_index": index, "status": _status_for_action_id(action_id), "action_id": action_id, "stable_action_id": String(_authored_queue[index].get("action_id", "")), "step_index": int(_authored_queue[index].get("step_index", -1))}) return result @@ -266,7 +524,10 @@ func clear_pause_state() -> void: _paused = false _completed_ids.clear() _interrupted_ids.clear() + _stable_status.clear() + _parallel_active_numeric_ids.clear() _current_action_id = -1 + _current_stable_action_id = "" _session_started = false @@ -360,10 +621,17 @@ func stop_queue() -> void: _action_phase = ActionPhase.NONE _current_index = -1 _current_action_id = -1 + _current_stable_action_id = "" + _current_step_index = -1 + _active_step_entry.clear() + _group_say_action_id = "" + _group_say_action.clear() + _parallel_active_numeric_ids.clear() _paused = false _session_started = false _completed_ids.clear() _interrupted_ids.clear() + _stable_status.clear() func is_queue_running() -> bool: @@ -381,8 +649,12 @@ func _advance_to_next_action() -> void: _current_index = -1 queue_finished.emit() return - _current_run_item = _run_queue[_run_cursor] + var next_item: Dictionary = _run_queue[_run_cursor] _run_cursor += 1 + if next_item.has("actions"): + _start_plan_step(next_item) + return + _current_run_item = next_item _current_index = int(_current_run_item.get("queue_index", -1)) _current_action_id = int(_current_run_item.get("action_id", -1)) _current_index = _index_for_action_id(_current_action_id) if _current_action_id >= 0 else -1 @@ -418,6 +690,121 @@ func _advance_to_next_action() -> void: _begin_action(action, legacy_speed_override) +func _start_plan_step(step_entry: Dictionary) -> void: + _active_step_entry = step_entry.duplicate(true) + _current_step_index = int(step_entry.get("step_index", -1)) + _group_primary_done = false + _group_say_barrier = false + _group_say_action_id = "" + _group_say_numeric_id = -1 + _group_say_action.clear() + var raw_actions: Array = step_entry.get("actions", []) + var primary_action: Dictionary = {} + var primary_id: String = "" + var primary_numeric_id: int = -1 + var speech_started: bool = false + for action_index: int in raw_actions.size(): + var raw_action: Variant = raw_actions[action_index] + if not raw_action is Dictionary: + continue + var authored_action: Dictionary = raw_action as Dictionary + var action_id: String = String(authored_action.get("action_id", "")) + var numeric_id: int = _numeric_for_stable_id(action_id) + var runtime_action: Dictionary = _runtime_action_for_plan(authored_action) + _stable_status[action_id] = &"active" + _parallel_active_numeric_ids[numeric_id] = true + var flat_index: int = _flat_index_for_action(action_id) + planned_action_started.emit(action_id, runtime_action.duplicate(true), _current_step_index) + action_started.emit(runtime_action.duplicate(true), flat_index) + if String(authored_action.get("type", "")) == "speak" and raw_actions.size() == 2: + speech_started = true + speak_overlay_action(runtime_action) + if bool(authored_action.get("legacy_nonblocking", false)): + _stable_status[action_id] = &"succeeded" + _completed_ids[numeric_id] = true + _parallel_active_numeric_ids.erase(numeric_id) + planned_action_ended.emit(action_id, &"succeeded", _current_step_index) + action_finished.emit(runtime_action.duplicate(true), flat_index) + else: + _group_say_action_id = action_id + _group_say_numeric_id = numeric_id + _group_say_step_index = _current_step_index + _group_say_action = runtime_action.duplicate(true) + _group_say_barrier = true + elif primary_action.is_empty(): + primary_action = runtime_action + primary_id = action_id + primary_numeric_id = numeric_id + if primary_action.is_empty(): + _complete_active_step() + return + _current_run_item = { + "action": primary_action.duplicate(true), + "queue_index": _flat_index_for_action(primary_id), + "action_id": primary_numeric_id, + "stable_action_id": primary_id, + "step_index": _current_step_index, + "reactive": false, + } + _current_action_id = primary_numeric_id + _current_stable_action_id = primary_id + _current_index = int(_current_run_item.get("queue_index", -1)) + var action: Dictionary = primary_action.duplicate(true) + if ACTION_REGISTRY.is_move_action(action): + action["type"] = ACTION_REGISTRY.MOVE_TO + var style: String = ACTION_REGISTRY.checked_style(action.get("style", null)) + action["style"] = style if not style.is_empty() else ACTION_REGISTRY.WALK + _begin_action(action) + if not speech_started and String(primary_action.get("type", "")) == "speak": + # Singleton Say is executed by the primary speech state, not an overlay. + _group_say_action_id = "" + + +func _runtime_action_for_plan(action: Dictionary) -> Dictionary: + var runtime_action: Dictionary = action.duplicate(true) + if ACTION_REGISTRY.is_move_action(runtime_action): + var destination_value: Variant = runtime_action.get("destination", {}) + if destination_value is Dictionary: + var destination: Dictionary = destination_value + var point_value: Variant = destination.get("position", destination.get("last_known_approach", Vector2.ZERO)) + runtime_action["target"] = point_value + return runtime_action + + +func _complete_active_step() -> void: + _active_step_entry.clear() + _current_step_index = -1 + _group_primary_done = false + _group_say_barrier = false + _action_phase = ActionPhase.NONE + + +func _flat_index_for_action(action_id: String) -> int: + for index: int in _authored_queue.size(): + if String(_authored_queue[index].get("action_id", "")) == action_id: + return index + return -1 + + +func _stable_for_numeric_id(numeric_id: int) -> String: + for stable_id: Variant in _stable_to_numeric_ids: + if int(_stable_to_numeric_ids[stable_id]) == numeric_id: + return String(stable_id) + return "" + + +func _step_boundary_for_flat_index(flat_index: int) -> int: + var cursor: int = 0 + for step_index: int in _authored_steps.size(): + var actions: Array = _authored_steps[step_index].get("actions", []) + if flat_index <= cursor: + return step_index + if flat_index < cursor + actions.size(): + return step_index + 1 + cursor += actions.size() + return _authored_steps.size() + + func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> void: match String(action.get("type", "")): "move_to": @@ -425,8 +812,15 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v var style: String = ACTION_REGISTRY.checked_style(action.get("style", null)) if style.is_empty(): style = ACTION_REGISTRY.WALK - if _locomotion == null or not _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override): - _finish_action() + var destination_value: Variant = action.get("destination", {}) + var move_started: bool = false + if destination_value is Dictionary and String((destination_value as Dictionary).get("kind", "")) == "prop": + move_started = _start_prop_pursuit(action, destination_value as Dictionary, style, legacy_speed_override) + elif _locomotion != null: + move_started = _locomotion.move_to(action.get("target", Vector2.ZERO), style, legacy_speed_override) + if not move_started: + _end_prop_pursuit() + _finish_action(&"failed") "speak": if bool(action.get("keep_acting", false)): speak_overlay_action(action) @@ -445,34 +839,142 @@ func _begin_action(action: Dictionary, legacy_speed_override: float = -1.0) -> v recovery_requested.emit() "pick_up_prop", "drop_prop", "throw_prop", "set_down_prop", "use_prop": if _rig == null or not _rig.start_prop_action(action): - _finish_action() + _finish_action(&"failed") else: _prop_action_finished = false _action_phase = ActionPhase.PROP_INTERACTION _: push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", ""))) - _finish_action() + _finish_action(&"failed") -func _finish_action() -> void: +func _start_prop_pursuit(action: Dictionary, destination: Dictionary, style: String, speed_override: float) -> bool: + var prop_id_value: Variant = destination.get("target_prop_id", null) + var offset_value: Variant = destination.get("approach_offset", null) + if not prop_id_value is String or not offset_value is Vector2 or not (offset_value as Vector2).is_finite(): + return false + var prop: PropBlock = _resolve_pursuit_prop(String(prop_id_value)) + if prop == null or _locomotion == null: + return false + var approach: Vector2 = prop.global_position + (offset_value as Vector2) + var projected: Vector2 = _locomotion.project_target_to_navigation(approach) + if not projected.is_finite(): + return false + _pursuit_action_id = String(action.get("action_id", _current_stable_action_id)) + _pursuit_prop_id = String(prop_id_value) + _pursuit_offset = offset_value as Vector2 + _pursuit_last_prop_position = prop.global_position + _pursuit_approach = projected + _pursuit_elapsed = 0.0 + _pursuit_retarget_elapsed = 0.0 + return _locomotion.move_to(projected, style, speed_override) + + +func _resolve_pursuit_prop(prop_id: String) -> PropBlock: + if prop_id.is_empty() or _rig == null or not is_instance_valid(_rig): + return null + var world: Node = _rig.get_parent() + if world == null: + return null + for child: Node in world.get_children(): + if child is PropBlock and not child.is_queued_for_deletion() and String((child as PropBlock).stage_object_id) == prop_id: + return child as PropBlock + if child.get_script() == MISSING_PROP_PLACEHOLDER and String(child.get("stage_object_id")) == prop_id: + return null + return null + + +func _check_prop_pursuit_arrival() -> void: + var prop: PropBlock = _resolve_pursuit_prop(_pursuit_prop_id) + if prop == null or _locomotion == null or _rig == null: + _end_prop_pursuit() + _finish_action(&"failed") + return + var projected: Vector2 = _locomotion.project_target_to_navigation(prop.global_position + _pursuit_offset) + if not projected.is_finite(): + _end_prop_pursuit() + _finish_action(&"failed") + return + var actor_feet: Vector2 = _rig.global_position - _locomotion.get_foot_offset() + if actor_feet.distance_to(projected) <= 28.0 and actor_feet.distance_to(prop.global_position) <= PROP_INTERACTION_REACH: + _end_prop_pursuit() + _finish_action(&"succeeded") + return + if actor_feet.distance_to(projected) > 28.0: + var move_style: String = ACTION_REGISTRY.checked_style(String(_current_run_item.get("action", {}).get("style", ACTION_REGISTRY.WALK))) + var restarted: bool = _locomotion.retarget_walk(projected) if _locomotion.is_walking() else _locomotion.move_to(projected, move_style) + if restarted: + _pursuit_approach = projected + _pursuit_last_prop_position = prop.global_position + _pursuit_retarget_elapsed = 0.0 + _action_phase = ActionPhase.WALKING + return + _end_prop_pursuit() + _finish_action(&"failed") + return + _locomotion.cancel_walk(false) + _pursuit_approach = projected + _pursuit_last_prop_position = prop.global_position + _pursuit_retarget_elapsed = 0.0 + _action_phase = ActionPhase.PURSUIT_WAIT + + +func _end_prop_pursuit() -> void: + _pursuit_action_id = "" + _pursuit_prop_id = "" + _pursuit_offset = Vector2.ZERO + _pursuit_last_prop_position = Vector2.ZERO + _pursuit_approach = Vector2.INF + _pursuit_elapsed = 0.0 + _pursuit_retarget_elapsed = 0.0 + + +func _finish_action(outcome: StringName = &"succeeded") -> void: var finished: Dictionary = _current_run_item.get("action", {}) var index: int = int(_current_run_item.get("queue_index", -1)) var action_id: int = int(_current_run_item.get("action_id", -1)) + var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id)) + if stable_id == _pursuit_action_id: + _end_prop_pursuit() if action_id >= 0: - _completed_ids[action_id] = true - index = _index_for_action_id(action_id) if action_id >= 0 else -1 + if outcome == &"cancelled": + _interrupted_ids[action_id] = true + else: + _completed_ids[action_id] = true + if not stable_id.is_empty(): + _stable_status[stable_id] = outcome + _parallel_active_numeric_ids.erase(action_id) + planned_action_ended.emit(stable_id, outcome, int(_current_run_item.get("step_index", _current_step_index))) + index = _flat_index_for_action(stable_id) if not stable_id.is_empty() else (_index_for_action_id(action_id) if action_id >= 0 else -1) _action_phase = ActionPhase.NONE + if not finished.is_empty(): + action_finished.emit(finished, index) _current_run_item.clear() _current_index = -1 _current_action_id = -1 - if not finished.is_empty(): - action_finished.emit(finished, index) + _current_stable_action_id = "" + if not _active_step_entry.is_empty(): + _group_primary_done = true + if _group_say_barrier and not _group_say_action_id.is_empty(): + _action_phase = ActionPhase.GROUP_BARRIER + else: + _complete_active_step() func _cancel_current_primary(notify: bool = false) -> void: + var stable_id: String = String(_current_run_item.get("stable_action_id", _current_stable_action_id)) + var step_index: int = int(_current_run_item.get("step_index", _current_step_index)) + var numeric_id: int = int(_current_run_item.get("action_id", _current_action_id)) + if stable_id == _pursuit_action_id: + _end_prop_pursuit() if notify and not _current_run_item.is_empty(): var action: Dictionary = _current_run_item.get("action", {}) active_action_cancelled.emit(action.duplicate(true)) + if not stable_id.is_empty(): + _stable_status[stable_id] = &"cancelled" + _parallel_active_numeric_ids.erase(numeric_id) + _interrupted_ids[numeric_id] = true + planned_action_ended.emit(stable_id, &"cancelled", step_index) if _locomotion != null: _locomotion.cancel_walk(not _is_blocked()) if _rig != null: @@ -484,6 +986,7 @@ func _cancel_current_primary(notify: bool = false) -> void: _current_run_item.clear() _current_index = -1 _current_action_id = -1 + _current_stable_action_id = "" if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble): _speech_bubble.hide_bubble() @@ -502,13 +1005,25 @@ func _start_session_runtime() -> void: _session_started = true _completed_ids.clear() _interrupted_ids.clear() - _action_ids.clear() - for _action: Dictionary in _authored_queue: - _action_ids.append(_allocate_action_id()) + _stable_status.clear() + _parallel_active_numeric_ids.clear() + for action: Dictionary in _authored_queue: + _stable_status[String(action.get("action_id", ""))] = &"pending" -func _runtime_item(action_id: int, action: Dictionary, index: int) -> Dictionary: - return {"action": action.duplicate(true), "queue_index": index, "action_id": action_id, "reactive": false} +func _runtime_step_item(step_index: int, step: Dictionary) -> Dictionary: + var actions: Array[Dictionary] = [] + var stable_ids: Array[String] = [] + var numeric_ids: Array[int] = [] + for raw_action: Variant in step.get("actions", []): + if not raw_action is Dictionary: + continue + var action: Dictionary = raw_action as Dictionary + var stable_id: String = String(action.get("action_id", "")) + actions.append(action.duplicate(true)) + stable_ids.append(stable_id) + numeric_ids.append(_numeric_for_stable_id(stable_id)) + return {"step_index": step_index, "actions": actions, "stable_ids": stable_ids, "numeric_ids": numeric_ids, "reactive": false} func _rebuild_pending_runtime_queue() -> void: @@ -518,11 +1033,19 @@ func _rebuild_pending_runtime_queue() -> void: var runtime_item: Dictionary = _run_queue[runtime_index] if bool(runtime_item.get("reactive", false)): retained_reactive.append(runtime_item.duplicate(true)) - for index: int in _authored_queue.size(): - var action_id: int = _action_ids[index] - var status: int = _status_for_action_id(action_id) - if status == QueueStatus.PENDING or status == QueueStatus.NOT_STARTED: - rebuilt.append(_runtime_item(action_id, _authored_queue[index], index)) + for step_index: int in _authored_steps.size(): + var step: Dictionary = _authored_steps[step_index] + var all_pending: bool = true + for raw_action: Variant in step.get("actions", []): + if not raw_action is Dictionary: + all_pending = false + break + var status: StringName = _status_for_stable_id(String((raw_action as Dictionary).get("action_id", ""))) + if status not in [&"pending", &"not_started"]: + all_pending = false + break + if all_pending: + rebuilt.append(_runtime_step_item(step_index, step)) _run_queue = retained_reactive + rebuilt _run_cursor = 0 if not _run_queue.is_empty() and _session_started: @@ -545,15 +1068,14 @@ func _index_for_action_id(action_id: int) -> int: func _status_for_action_id(action_id: int) -> int: if action_id < 0: return QueueStatus.NOT_STARTED - if _completed_ids.has(action_id): - return QueueStatus.COMPLETED - if _interrupted_ids.has(action_id): - return QueueStatus.INTERRUPTED - if action_id == _current_action_id: - return QueueStatus.ACTIVE - if _session_started: - return QueueStatus.PENDING - return QueueStatus.NOT_STARTED + var stable_id: String = _stable_for_numeric_id(action_id) + var status: StringName = _status_for_stable_id(stable_id) if not stable_id.is_empty() else &"not_started" + match status: + &"succeeded", &"failed": return QueueStatus.COMPLETED + &"cancelled": return QueueStatus.INTERRUPTED + &"active": return QueueStatus.ACTIVE + &"pending": return QueueStatus.PENDING + _: return QueueStatus.NOT_STARTED func _is_blocked() -> bool: @@ -565,6 +1087,7 @@ func _update_speech(delta: float) -> void: _overlay_speech["elapsed"] = float(_overlay_speech.get("elapsed", 0.0)) + delta if float(_overlay_speech.get("elapsed", 0.0)) >= float(_overlay_speech.get("duration", 0.0)): _overlay_speech.clear() + _finish_group_speech() speech_finished.emit() elif not _primary_speech.is_empty() and not _is_blocked(): _primary_speech["elapsed"] = float(_primary_speech.get("elapsed", 0.0)) + delta @@ -578,6 +1101,25 @@ func _update_speech(delta: float) -> void: _show_visible_speech() +func _finish_group_speech() -> void: + if _group_say_action_id.is_empty(): + return + var action_id: String = _group_say_action_id + var numeric_id: int = _group_say_numeric_id + var step_index: int = _group_say_step_index + var action: Dictionary = _group_say_action.duplicate(true) + _stable_status[action_id] = &"succeeded" + _completed_ids[numeric_id] = true + _parallel_active_numeric_ids.erase(numeric_id) + planned_action_ended.emit(action_id, &"succeeded", step_index) + action_finished.emit(action, _flat_index_for_action(action_id)) + _group_say_action_id = "" + _group_say_numeric_id = -1 + _group_say_step_index = -1 + _group_say_action.clear() + _group_say_barrier = false + + func _show_visible_speech() -> void: if _rig == null: return diff --git a/scripts/characters/stickman/stickman_locomotion.gd b/scripts/characters/stickman/stickman_locomotion.gd index 71becea..5b22bee 100644 --- a/scripts/characters/stickman/stickman_locomotion.gd +++ b/scripts/characters/stickman/stickman_locomotion.gd @@ -161,6 +161,7 @@ var collision_lengths: Dictionary = {} var collision_proxy_velocities: Dictionary = {} var _collision_proxy_positions: Dictionary = {} var _last_sweep_hit_tick: Dictionary = {} +var _owner_collision_grace_props: Dictionary = {} var _actor_prop_debug_last_sample_frame: int = -1 var _actor_prop_debug_last_hit_tick: Dictionary = {} var _collision_visuals: Dictionary = {} @@ -196,6 +197,7 @@ func setup_collision_bodies() -> void: collision_proxy_velocities.clear() _collision_proxy_positions.clear() _last_sweep_hit_tick.clear() + _owner_collision_grace_props.clear() _collision_visuals.clear() reaction_bones.clear() if _rig == null or _rig._body_container == null or _rig._skeleton == null: @@ -470,6 +472,34 @@ func move_to(target: Vector2, style: String = ACTION_REGISTRY.WALK, speed_overri _walk_latch_probe_frames = 0 return true + +func project_target_to_navigation(target: Vector2) -> Vector2: + if _nav_agent == null or not target.is_finite(): + return Vector2.INF + var navigation_map: RID = _nav_agent.get_navigation_map() + if not navigation_map.is_valid() or NavigationServer2D.map_get_iteration_id(navigation_map) == 0: + return Vector2.INF + var projected: Vector2 = NavigationServer2D.map_get_closest_point(navigation_map, target) + return projected if projected.is_finite() else Vector2.INF + + +func retarget_walk(target: Vector2) -> bool: + if _rig == null or _nav_agent == null or not target.is_finite() or (not _walking and not _settling): + return false + _walk_target_feet = target + _nav_agent.target_position = target + if _settling: + _settling = false + _walk_done = false + _arrival_emitted = false + _walk_settle_elapsed = 0.0 + _walk_settle_start_pose.clear() + _walk_settle_end_pose.clear() + _walking = true + if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation(_active_animation_name): + _rig._anim_player.play(_active_animation_name) + return true + func cancel_walk(restore_idle_pose: bool = true) -> void: if _rig == null: return @@ -624,6 +654,15 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary: for own_proxy: AnimatableBody2D in collision_bodies.values(): if own_proxy != null and is_instance_valid(own_proxy): excluded.append(own_proxy.get_rid()) + for prop_id: Variant in _owner_collision_grace_props.keys(): + var prop_ref: WeakRef = _owner_collision_grace_props.get(prop_id) as WeakRef + var grace_prop: PropBlock = prop_ref.get_ref() as PropBlock if prop_ref != null else null + if grace_prop == null or not is_instance_valid(grace_prop) or not grace_prop.is_owner_collision_grace_active_for(_rig): + _owner_collision_grace_props.erase(prop_id) + continue + var grace_prop_rid: RID = grace_prop.get_rid() + if grace_prop_rid.is_valid() and not excluded.has(grace_prop_rid): + excluded.append(grace_prop_rid) var held: PropBlock = _rig.get_held_prop() if _rig.has_method("get_held_prop") else null if held != null and is_instance_valid(held): excluded.append(held.get_rid()) @@ -779,6 +818,18 @@ func _sweep_actor_blocking_props(motion: Vector2) -> Dictionary: return {"fraction": safe_fraction} +func register_owner_collision_grace_prop(prop: PropBlock) -> void: + if prop == null or not is_instance_valid(prop): + return + _owner_collision_grace_props[prop.get_instance_id()] = weakref(prop) + + +func unregister_owner_collision_grace_prop(prop: PropBlock) -> void: + if prop == null: + return + _owner_collision_grace_props.erase(prop.get_instance_id()) + + func _prop_from_rest_info(rest_info: Dictionary) -> PropBlock: # PhysicsDirectSpaceState2D.get_rest_info() returns collider_id rather than # the `collider` object key used by ray/shape intersection queries. diff --git a/scripts/characters/stickman/stickman_prop_interactor.gd b/scripts/characters/stickman/stickman_prop_interactor.gd index 55a18aa..d88ccc8 100644 --- a/scripts/characters/stickman/stickman_prop_interactor.gd +++ b/scripts/characters/stickman/stickman_prop_interactor.gd @@ -955,6 +955,7 @@ func _begin_pickup(prop_id: String, requested_hand: String, action: Dictionary) _hand = int(picked["hand"]) _held_prop = prop _held_prop.interaction_reserved_by = _rig.get_instance_id() + _held_prop.begin_owner_collision_grace(_rig, true) _active_action = action.duplicate(true) _approach_replans = 0 var marker := _hand_marker() @@ -1028,7 +1029,7 @@ func _begin_set_down(action: Dictionary) -> bool: _rig.call("set_facing_profile", 0 if _set_down_facing_left else 1) var foot_offset: Vector2 = _locomotion.call("get_foot_offset") var target_feet := _set_down_approach_root - foot_offset - if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.RUN)): + if not bool(_locomotion.call("move_to", target_feet, ACTION_REGISTRY.WALK)): _active_action.clear() prop_event.emit("set_down_failed", _held_prop, "Too far to reach") return false @@ -1930,12 +1931,18 @@ func _release_held(reason: String, throwing: bool, throw_velocity: Vector2 = Vec release_velocity = Vector2.ZERO if not is_finite(release_angular): release_angular = 0.0 + if reason == "scene_reset": + prop.clear_owner_collision_grace() + elif _rig != null and is_instance_valid(_rig) and prop.interaction_reserved_by == _rig.get_instance_id(): + prop.release_owner_collision_grace(_rig) prop.interaction_owner_id = 0 prop.interaction_reserved_by = 0 var target_role := PropBlock.RuntimeRole.RELEASED if reason == "scene_reset": target_role = PropBlock.RuntimeRole.START_PHYSICS if String(prop.stage_roles.get("start_motion", PROP_STAGE_ROLES.START_STILL)) == PROP_STAGE_ROLES.START_PHYSICS else PropBlock.RuntimeRole.START_STILL prop.apply_runtime_role(target_role) + if reason == "set_down" and _rig != null and is_instance_valid(_rig): + prop.begin_owner_collision_grace(_rig) prop.sleeping = false prop.linear_velocity = release_velocity prop.angular_velocity = release_angular @@ -1983,6 +1990,7 @@ func _clear_reservation() -> void: if _held_prop != null and is_instance_valid(_held_prop): if _held_prop.interaction_reserved_by == _rig.get_instance_id(): _held_prop.interaction_reserved_by = 0 + _held_prop.release_owner_collision_grace(_rig) _held_prop = null _held_anchor.clear() _active_action.clear() diff --git a/scripts/characters/stickman/stickman_rig.gd b/scripts/characters/stickman/stickman_rig.gd index 7ea6b00..340c29d 100644 --- a/scripts/characters/stickman/stickman_rig.gd +++ b/scripts/characters/stickman/stickman_rig.gd @@ -182,6 +182,9 @@ signal action_started(action: Dictionary, index: int) signal action_finished(action: Dictionary, index: int) signal queue_finished # queue ran to completion (not on stop) signal queue_changed # any queue mutation +signal action_plan_changed +signal planned_action_started(action_id: String, action: Dictionary, step_index: int) +signal planned_action_ended(action_id: String, outcome: StringName, step_index: int) signal speech_finished # bubble auto-hid after speak() signal prop_interaction_event(event_type: String, prop: PropBlock, reason: String) signal use_completed(prop: PropBlock, spot_id: String, world_position: Vector2) @@ -215,6 +218,8 @@ var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lo # --------------------------------------------------------------------------- var _pending_action_queue: Array[Dictionary] = [] +var _pending_action_plan: Array[Dictionary] = [] +var _has_pending_action_plan: bool = false var action_queue: Array[Dictionary]: get: return _action_runner_component.action_queue if _action_runner_component != null else _pending_action_queue @@ -262,7 +267,10 @@ func _ready() -> void: push_warning("StickmanRig: missing RigComponents/PropInteractor; prop interactions unavailable.") if _locomotion_component != null: _locomotion_component.configure(self) - _locomotion_component.arrived.connect(func(target: Vector2) -> void: arrived.emit(target)) + _locomotion_component.arrived.connect(func(target: Vector2) -> void: + if _action_runner_component == null or not _action_runner_component.is_prop_pursuit_active(): + arrived.emit(target) + ) _locomotion_component.blocking_prop_sweep_hit.connect(_on_blocking_prop_sweep_hit) if _ragdoll_component != null: _ragdoll_component.configure(self, _locomotion_component) @@ -275,11 +283,17 @@ func _ready() -> void: _prop_interactor_component.connect("use_failed", _on_prop_use_failed) if _action_runner_component != null: _action_runner_component.configure(self, _locomotion_component, _prop_interactor_component) - _action_runner_component.action_queue = _pending_action_queue + if _has_pending_action_plan: + _action_runner_component.set_action_plan(_pending_action_plan) + else: + _action_runner_component.action_queue = _pending_action_queue _action_runner_component.action_started.connect(func(action: Dictionary, index: int) -> void: action_started.emit(action, index)) _action_runner_component.action_finished.connect(func(action: Dictionary, index: int) -> void: action_finished.emit(action, index)) _action_runner_component.queue_finished.connect(func() -> void: queue_finished.emit()) _action_runner_component.queue_changed.connect(func() -> void: queue_changed.emit()) + _action_runner_component.plan_changed.connect(func() -> void: action_plan_changed.emit()) + _action_runner_component.planned_action_started.connect(func(action_id: String, action: Dictionary, step_index: int) -> void: planned_action_started.emit(action_id, action, step_index)) + _action_runner_component.planned_action_ended.connect(func(action_id: String, outcome: StringName, step_index: int) -> void: planned_action_ended.emit(action_id, outcome, step_index)) _action_runner_component.speech_finished.connect(func() -> void: speech_finished.emit()) _action_runner_component.ragdoll_requested.connect(func(enabled: bool) -> void: set_ragdoll(enabled)) _action_runner_component.recovery_requested.connect(func() -> void: request_recovery()) @@ -336,6 +350,8 @@ func _physics_process(delta: float) -> void: _sample_ragdoll_velocity_baselines() if _ragdoll_component != null: _ragdoll_component.tick_pre_movement(delta, int(state), rest_timeout, auto_recover) + if _action_runner_component != null: + _action_runner_component.pre_movement(delta) if _locomotion_component != null: _locomotion_component.tick_movement(delta, int(state)) if _prop_interactor_component != null: @@ -947,6 +963,56 @@ func get_queue() -> Array[Dictionary]: return _action_runner_component.get_queue() if _action_runner_component != null else _pending_action_queue.duplicate(true) +func get_action_plan() -> Array[Dictionary]: + if _action_runner_component != null: + return _action_runner_component.get_action_plan() + return _pending_action_plan.duplicate(true) + + +func set_action_plan(steps: Array[Dictionary]) -> bool: + if _action_runner_component != null: + return _action_runner_component.set_action_plan(steps) + _pending_action_plan = steps.duplicate(true) + _has_pending_action_plan = true + return true + + +func find_action(action_id: String) -> Dictionary: + return _action_runner_component.find_action(action_id) if _action_runner_component != null else {} + + +func get_plan_view() -> Array[Dictionary]: + return _action_runner_component.get_plan_view() if _action_runner_component != null else [] + + +func add_action_step(action: Dictionary) -> String: + return _action_runner_component.add_plan_step(action) if _action_runner_component != null else "" + + +func add_action_together(step_index: int, action: Dictionary) -> String: + return _action_runner_component.add_plan_action_together(step_index, action) if _action_runner_component != null else "" + + +func replace_planned_action(action_id: String, action: Dictionary) -> bool: + return _action_runner_component.replace_plan_action(action_id, action) if _action_runner_component != null else false + + +func remove_planned_action(action_id: String) -> bool: + return _action_runner_component.remove_plan_action(action_id) if _action_runner_component != null else false + + +func move_plan_step(from_index: int, to_index: int) -> bool: + return _action_runner_component.move_plan_step(from_index, to_index) if _action_runner_component != null else false + + +func can_add_action_together(step_index: int, action_type: String) -> bool: + return _action_runner_component.can_add_plan_action_together(step_index, action_type) if _action_runner_component != null else false + + +func can_reorder_plan_step(step_index: int) -> bool: + return _action_runner_component.can_reorder_plan_step(step_index) if _action_runner_component != null else false + + func remove_action(index: int) -> void: if _action_runner_component != null: _action_runner_component.remove_action(index) diff --git a/scripts/editors/stage/action_editor.gd b/scripts/editors/stage/action_editor.gd index a812337..bf04047 100644 --- a/scripts/editors/stage/action_editor.gd +++ b/scripts/editors/stage/action_editor.gd @@ -8,18 +8,30 @@ extends PopupPanel ## the stage while its Walk/Run draft remains in this editor. const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") +const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd") signal committed(action: Dictionary) signal cancelled() signal target_requested() signal speech_requested(initial: Dictionary) +signal plan_destination_requested(kind: StringName) var _mode: String = "new" var _initial: Dictionary = {} var _type_option: OptionButton = null +var _type_row: HBoxContainer = null +var _dialog_title: Label = null +var _body_scroll: ScrollContainer = null +var _cancel_button: Button = null +var _done_button: Button = null var _params_box: VBoxContainer = null var _text_edit: LineEdit = null var _duration_spin: SpinBox = null +var _duration_value_edit: LineEdit = null +var _duration_unit_label: Label = null +var _duration_minus_button: Button = null +var _duration_plus_button: Button = null +var _duration_preset_buttons: Array[Button] = [] var _target_label: Label = null var _set_target_btn: Button = null var _walk_style_btn: Button = null @@ -31,9 +43,12 @@ var _surface_color: Color = Color("#252B38") var _border_color: Color = Color("#FFFFFF33") var _prop_target_label: Label = null var _set_prop_target_btn: Button = null +var _prop_target_card: PanelContainer = null +var _prop_name_icon: Label = null var _use_target_label: Label = null var _set_use_target_btn: Button = null -var _hand_option: OptionButton = null +var _hand_buttons: Array[Button] = [] +var _pickup_hand_selection: String = "auto" var _stop_current_check: CheckBox = null var _show_reactive_options: bool = false var _has_target: bool = false @@ -43,8 +58,20 @@ var _use_prop_target_id: String = "" var _use_spot_target_id: String = "" var _use_target_summary: String = "" var _use_target_summary_resolver: Callable = Callable() +var _prop_target_name_resolver: Callable = Callable() var _has_throw_target: bool = false var _throw_target_pos: Vector2 = Vector2.ZERO +var _plan_mode: bool = false +var _plan_destination: Dictionary = {} +var _plan_destination_summary: String = "" +var _plan_actor_name: String = "" +var _plan_target_anchor: Vector2i = Vector2i.ZERO +var _has_plan_target_anchor: bool = false +var _place_target_btn: Button +var _prop_target_btn: Button +var _sandbox_theme: SandboxTheme +var _preferred_min_size: Vector2i = Vector2i(560, 390) +var _configured_preferred_size: Vector2i = Vector2i(560, 390) ## Theme font overrides (set via apply_font from sandbox_theme.json). Size 0 = no ## override (engine default); Font null = no override. @@ -55,7 +82,8 @@ var _base_font_size: int = 0 func _ready() -> void: exclusive = true - popup_window = true + popup_window = false + wrap_controls = false _build_ui() @@ -66,10 +94,13 @@ func _unhandled_input(event: InputEvent) -> void: ## Opens the editor in "add" mode (empty, default Move To/Walk). -func open_new(show_reactive_options: bool = false) -> void: +func open_new(show_reactive_options: bool = false, plan_mode: bool = false, initial_type: String = ACTION_REGISTRY.MOVE_TO) -> void: _show_reactive_options = show_reactive_options + _plan_mode = plan_mode _mode = "new" _initial = {} + _plan_destination.clear() + _plan_destination_summary = "" _has_target = false _target_pos = Vector2.ZERO _move_style = ACTION_REGISTRY.WALK @@ -79,23 +110,36 @@ func open_new(show_reactive_options: bool = false) -> void: _use_target_summary = "" _has_throw_target = false _throw_target_pos = Vector2.ZERO - _select_type(ACTION_REGISTRY.MOVE_TO) - _rebuild_params(ACTION_REGISTRY.MOVE_TO) + _select_type(initial_type if ACTION_REGISTRY.has_type(initial_type) else ACTION_REGISTRY.MOVE_TO) + _rebuild_params(_current_type()) title = "Add Action" - popup_centered() + _update_dialog_presentation() + _popup_centered_safely() ## Opens the editor pre-filled from a flat queue action. -func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void: +func open_edit(action: Dictionary, show_reactive_options: bool = false, plan_mode: bool = false) -> void: _show_reactive_options = show_reactive_options + _plan_mode = plan_mode _mode = "edit" _initial = action.duplicate(true) var type := ACTION_REGISTRY.canonical_type(String(action.get("type", ACTION_REGISTRY.MOVE_TO))) if type == "speak": - speech_requested.emit(_initial.duplicate(true)) + var speech_initial: Dictionary = _initial.duplicate(true) + speech_initial.erase("action_id") + speech_initial.erase("step_index") + speech_requested.emit(speech_initial) return - _has_target = action.has("target") and action["target"] is Vector2 - _target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO + _plan_destination = {} + _plan_destination_summary = "" + if _plan_mode and action.get("destination", null) is Dictionary: + _plan_destination = (action["destination"] as Dictionary).duplicate(true) + _has_target = not _plan_destination.is_empty() + var position_value: Variant = _plan_destination.get("position", _plan_destination.get("last_known_approach", Vector2.ZERO)) + _target_pos = position_value as Vector2 if position_value is Vector2 else Vector2.ZERO + else: + _has_target = action.has("target") and action["target"] is Vector2 + _target_pos = action.get("target", Vector2.ZERO) if _has_target else Vector2.ZERO _prop_target_id = String(action.get("target_prop_id", "")) _use_prop_target_id = String(action.get("target_prop_id", "")) if type == "use_prop" else "" _use_spot_target_id = String(action.get("target_spot_id", "")) if type == "use_prop" else "" @@ -108,21 +152,64 @@ func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void: _select_type(type) _rebuild_params(type) title = "Edit Action" - popup_centered() + _update_dialog_presentation() + _popup_centered_safely() + + +func set_plan_actor_name(actor_name: String) -> void: + _plan_actor_name = actor_name.strip_edges() + _update_dialog_presentation() + + +func set_preferred_size(size: Vector2i) -> void: + _configured_preferred_size = Vector2i(maxi(320, size.x), maxi(260, size.y)) + _preferred_min_size = _configured_preferred_size + min_size = _preferred_min_size ## Called by the stage after a walk-target capture click. func set_walk_target(pos: Vector2) -> void: _has_target = true _target_pos = pos + if _plan_mode: + _plan_destination = {"kind": "place", "position": pos} _update_walk_target_label() popup_centered() +func set_plan_destination(destination: Dictionary) -> void: + _plan_destination = destination.duplicate(true) + _plan_destination_summary = "" + _has_target = not _plan_destination.is_empty() + var position_value: Variant = _plan_destination.get("position", _plan_destination.get("last_known_approach", Vector2.ZERO)) + _target_pos = position_value as Vector2 if position_value is Vector2 else Vector2.ZERO + _update_walk_target_label() + _update_done_enabled() + + +func set_plan_destination_summary(summary: String) -> void: + if not _plan_mode or _current_type() != ACTION_REGISTRY.MOVE_TO: + return + _plan_destination_summary = summary.strip_edges() + _update_walk_target_label() + + +func hide_for_plan_target_capture() -> void: + _plan_target_anchor = position + _has_plan_target_anchor = true + hide() + + +func resume_after_plan_target_capture() -> void: + if not visible: + _popup_centered_safely(true, _plan_target_anchor if _has_plan_target_anchor else Vector2i(-1, -1)) + _has_plan_target_anchor = false + + func set_prop_target(prop_id: String) -> void: _prop_target_id = prop_id _update_prop_target_label() - popup_centered() + _popup_centered_safely(true, position) func set_use_target(prop_id: String, spot_id: String, summary: String) -> void: @@ -138,6 +225,15 @@ func set_use_target_summary_resolver(resolver: Callable) -> void: refresh_use_target_summary() +func set_prop_target_name_resolver(resolver: Callable) -> void: + _prop_target_name_resolver = resolver + _update_prop_target_label() + + +func resume_editor() -> void: + _popup_centered_safely(true, position) + + func refresh_use_target_summary() -> void: if _use_target_summary_resolver.is_valid() and not _use_prop_target_id.is_empty() and not _use_spot_target_id.is_empty(): _use_target_summary = String(_use_target_summary_resolver.call(_use_prop_target_id, "specific", _use_spot_target_id)) @@ -168,50 +264,74 @@ func current_move_style() -> String: func _build_ui() -> void: var margin := MarginContainer.new() - margin.add_theme_constant_override("margin_left", 12) - margin.add_theme_constant_override("margin_right", 12) - margin.add_theme_constant_override("margin_top", 12) - margin.add_theme_constant_override("margin_bottom", 12) + margin.name = "ActionEditorMargin" + margin.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) + margin.add_theme_constant_override("margin_left", 22) + margin.add_theme_constant_override("margin_right", 22) + margin.add_theme_constant_override("margin_top", 20) + margin.add_theme_constant_override("margin_bottom", 18) add_child(margin) var vbox := VBoxContainer.new() - vbox.add_theme_constant_override("separation", 8) + vbox.name = "ActionEditorContent" + vbox.add_theme_constant_override("separation", 12) margin.add_child(vbox) - var type_row := HBoxContainer.new() - vbox.add_child(type_row) + _dialog_title = Label.new() + _dialog_title.name = "ActionEditorTitle" + _dialog_title.text = "Add Action" + _dialog_title.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _dialog_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _dialog_title.add_theme_color_override("font_color", Color("#F5F7FA")) + vbox.add_child(_dialog_title) + + _type_row = HBoxContainer.new() + _type_row.name = "ActionTypeRow" + _type_row.add_theme_constant_override("separation", 10) + vbox.add_child(_type_row) var type_label := Label.new() - type_label.text = "Type:" - type_row.add_child(type_label) + type_label.text = "Action" + _type_row.add_child(type_label) _type_option = OptionButton.new() _type_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL for type: String in ACTION_REGISTRY.types(): _type_option.add_item("%s %s" % [ACTION_REGISTRY.icon(type), ACTION_REGISTRY.label(type)]) _type_option.item_selected.connect(_on_type_selected) - type_row.add_child(_type_option) + _type_row.add_child(_type_option) + _body_scroll = ScrollContainer.new() + _body_scroll.name = "ActionEditorBodyScroll" + _body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + _body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + vbox.add_child(_body_scroll) _params_box = VBoxContainer.new() + _params_box.name = "ActionEditorParams" + _params_box.size_flags_horizontal = Control.SIZE_EXPAND_FILL _params_box.add_theme_constant_override("separation", 6) - vbox.add_child(_params_box) + _body_scroll.add_child(_params_box) var buttons := HBoxContainer.new() + buttons.name = "ActionEditorFooter" buttons.alignment = BoxContainer.ALIGNMENT_END - buttons.add_theme_constant_override("separation", 8) + buttons.add_theme_constant_override("separation", 12) vbox.add_child(buttons) - var cancel := Button.new() - cancel.text = "Cancel" - cancel.custom_minimum_size.y = 44.0 - cancel.pressed.connect(func() -> void: cancelled.emit()) - buttons.add_child(cancel) + _cancel_button = Button.new() + _cancel_button.name = "ActionEditorCancel" + _cancel_button.text = "Cancel" + _cancel_button.pressed.connect(func() -> void: cancelled.emit()) + buttons.add_child(_cancel_button) - var ok := Button.new() - ok.text = "Done" - ok.custom_minimum_size.y = 44.0 - ok.pressed.connect(_on_ok_pressed) - buttons.add_child(ok) + _done_button = Button.new() + _done_button.name = "ActionEditorDone" + _done_button.text = "Done" + _done_button.pressed.connect(_on_ok_pressed) + buttons.add_child(_done_button) - min_size = Vector2i(340, 200) + min_size = _preferred_min_size + _layout_popup_contents(_preferred_min_size) + _apply_dialog_styles() ## Applies theme font/size overrides (mirrors AssetSelector.apply_font). Called by @@ -221,13 +341,18 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void: _emoji_font = emoji_font _base_font_size = int(sizes.get("action_editor", 18)) _apply_font_recursive(self, _base_font_size) + _update_dialog_typography() + _apply_dialog_styles() func apply_theme(sandbox_theme: Variant) -> void: - if sandbox_theme != null: - self.theme = sandbox_theme.control_theme() - _direct_accent_color = sandbox_theme.color("mode_colors.direct", _direct_accent_color) - _surface_color = sandbox_theme.color("global.surface_color", _surface_color) - _border_color = sandbox_theme.color("global.border_color", _border_color) + if sandbox_theme is SandboxTheme: + _sandbox_theme = sandbox_theme as SandboxTheme + self.theme = _sandbox_theme.control_theme() + _direct_accent_color = _sandbox_theme.color("mode_colors.direct", _direct_accent_color) + _surface_color = _sandbox_theme.color("global.surface_color", _surface_color) + _border_color = _sandbox_theme.color("global.border_color", _border_color) + add_theme_stylebox_override("panel", STAGE_DIALOG_STYLER.panel_style(_sandbox_theme, false)) + _apply_dialog_styles() _sync_move_style_buttons() @@ -247,6 +372,156 @@ func _apply_font_to(c: Control, size: int) -> void: c.add_theme_font_override("font", _emoji_font) if size > 0: c.add_theme_font_size_override("font_size", size) + if c == _dialog_title: + c.add_theme_font_size_override("font_size", _theme_integer("popups.confirmation.title_font_size", 28, 20, 40)) + if c.has_meta(&"action_editor_section_heading"): + c.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.section_size", 22, 18, 28)) + + +func _theme_integer(path: String, fallback: int, minimum: int, maximum: int) -> int: + return _sandbox_theme.integer(path, fallback, minimum, maximum) if _sandbox_theme != null else fallback + + +func _apply_dialog_styles() -> void: + if _sandbox_theme != null: + add_theme_stylebox_override("panel", STAGE_DIALOG_STYLER.panel_style(_sandbox_theme, false)) + if _cancel_button != null: + STAGE_DIALOG_STYLER.style_button(_cancel_button, &"secondary", _sandbox_theme, _ui_font) + if _done_button != null: + STAGE_DIALOG_STYLER.style_button(_done_button, &"primary", _sandbox_theme, _ui_font) + for button: Button in [_set_target_btn, _set_prop_target_btn, _set_use_target_btn, _place_target_btn, _prop_target_btn]: + if button != null: + STAGE_DIALOG_STYLER.style_button(button, &"secondary", _sandbox_theme, _ui_font) + for button: Button in _hand_buttons: + STAGE_DIALOG_STYLER.style_button(button, &"secondary", _sandbox_theme, _ui_font) + for button: Button in [_duration_minus_button, _duration_plus_button]: + if button != null: + STAGE_DIALOG_STYLER.style_button(button, &"secondary", _sandbox_theme, _ui_font) + button.custom_minimum_size = Vector2(68.0, 68.0) + for button: Button in _duration_preset_buttons: + if button != null: + STAGE_DIALOG_STYLER.style_button(button, &"secondary", _sandbox_theme, _ui_font) + _update_wait_duration_controls() + _update_done_enabled() + + +func _make_section_heading(text: String) -> Label: + var label := Label.new() + label.text = text + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.set_meta(&"action_editor_section_heading", true) + label.add_theme_color_override("font_color", Color("#F5F7FA")) + return label + + +func _plan_subject_name() -> String: + if _plan_actor_name.is_empty(): + return "your character" + if _plan_actor_name.length() > 20: + var words: PackedStringArray = _plan_actor_name.split(" ", false) + return words[0] if not words.is_empty() else "your character" + return _plan_actor_name + + +func _update_dialog_presentation() -> void: + if _dialog_title == null: + return + var action_type: String = _current_type() + if _plan_mode: + match action_type: + "move_to": + _dialog_title.text = "Move %s" % _plan_subject_name() + "wait": + _dialog_title.text = "Wait for %s" % _plan_subject_name() + _: + var action_name: String = String(ACTION_REGISTRY.label(action_type)) + _dialog_title.text = "%s for %s" % [action_name, _plan_subject_name()] + else: + _dialog_title.text = "Add Action" if _mode == "new" else "Edit Action" + if _type_row != null: + _type_row.visible = not _plan_mode + var desired_size: Vector2i = _configured_preferred_size + if action_type == "wait": + desired_size.y = mini(_configured_preferred_size.y, 370 if _plan_mode else 400) + elif action_type == "pick_up_prop": + desired_size.y = maxi(_configured_preferred_size.y, 470) + if _preferred_min_size != desired_size: + _preferred_min_size = desired_size + min_size = desired_size + if visible: + _popup_centered_safely(true, position) + _update_dialog_typography() + _update_done_enabled() + + +func _update_dialog_typography() -> void: + if _dialog_title != null: + _dialog_title.add_theme_font_size_override("font_size", _theme_integer("popups.confirmation.title_font_size", 28, 20, 40)) + if _params_box == null: + return + for heading: Node in _params_box.find_children("*", "Label", true, false): + var label: Control = heading as Control + if label != null and label.has_meta(&"action_editor_section_heading"): + label.add_theme_font_size_override("font_size", _theme_integer("stage_panels.type.section_size", 22, 18, 28)) + + +func _update_done_enabled() -> void: + if _done_button == null: + return + _done_button.disabled = _plan_mode and _current_type() == ACTION_REGISTRY.MOVE_TO and _plan_destination.is_empty() + + +func _popup_centered_safely(preserve_position: bool = false, restore_position: Vector2i = Vector2i(-1, -1)) -> void: + var root_window: Window = get_tree().root + var viewport_size: Vector2 = Vector2(root_window.size) + var horizontal_margin: int = mini(24, maxi(8, int(floorf(viewport_size.x * 0.05)))) + var vertical_margin: int = mini(32, maxi(8, int(floorf(viewport_size.y * 0.05)))) + var safe_width: int = maxi(1, int(floorf(viewport_size.x)) - horizontal_margin * 2) + var safe_height: int = maxi(1, int(floorf(viewport_size.y)) - vertical_margin * 2) + var bounded_size: Vector2i = Vector2i(mini(_preferred_min_size.x, safe_width), mini(_preferred_min_size.y, safe_height)) + min_size = bounded_size + max_size = Vector2i(safe_width, safe_height) + size = bounded_size + if preserve_position: + popup() + else: + popup_centered(bounded_size) + call_deferred("_reapply_popup_geometry", bounded_size, preserve_position, restore_position) + + +func _reapply_popup_geometry(bounded_size: Vector2i, preserve_position: bool, restore_position: Vector2i = Vector2i(-1, -1)) -> void: + size = bounded_size + var root_size: Vector2i = get_tree().root.size + var margin_x: int = mini(24, maxi(8, int(floor(float(root_size.x) * 0.05)))) + var margin_y: int = mini(32, maxi(8, int(floor(float(root_size.y) * 0.05)))) + var requested_position: Vector2i = restore_position + if requested_position == Vector2i(-1, -1): + requested_position = Vector2i(round((Vector2(root_size) - Vector2(bounded_size)) * 0.5)) + var max_position: Vector2i = Vector2i( + maxi(margin_x, root_size.x - bounded_size.x - margin_x), + maxi(margin_y, root_size.y - bounded_size.y - margin_y) + ) + position = Vector2i( + clampi(requested_position.x, margin_x, max_position.x), + clampi(requested_position.y, margin_y, max_position.y) + ) + call_deferred("_layout_popup_contents", bounded_size) + call_deferred("_focus_wait_duration_field") + + +func _layout_popup_contents(popup_size: Vector2i) -> void: + var margin: Control = get_node_or_null("ActionEditorMargin") as Control + if margin == null: + return + margin.set_anchors_preset(Control.PRESET_TOP_LEFT) + margin.position = Vector2(2.0, 2.0) + margin.size = Vector2(maxi(0, popup_size.x - 4), maxi(0, popup_size.y - 4)) + + +func _focus_wait_duration_field() -> void: + if visible and _current_type() == "wait" and _duration_value_edit != null: + _duration_value_edit.grab_focus() func _select_type(type: String) -> void: @@ -269,32 +544,42 @@ func _on_type_selected(_index: int) -> void: speech_requested.emit(_initial.duplicate(true)) return _rebuild_params(_current_type()) + _update_dialog_presentation() ## Rebuilds the param controls for the given action type. func _rebuild_params(type: String) -> void: for child: Node in _params_box.get_children(): + _params_box.remove_child(child) child.queue_free() _text_edit = null _duration_spin = null + _duration_value_edit = null + _duration_unit_label = null + _duration_minus_button = null + _duration_plus_button = null + _duration_preset_buttons.clear() _target_label = null _set_target_btn = null _prop_target_label = null _set_prop_target_btn = null + _prop_target_card = null + _prop_name_icon = null _use_target_label = null _set_use_target_btn = null - _hand_option = null + _hand_buttons.clear() _stop_current_check = null _walk_style_btn = null _run_style_btn = null + _place_target_btn = null + _prop_target_btn = null match type: "move_to": - var move_label := Label.new() - move_label.text = "How should they move?" + var move_label: Label = _make_section_heading("How should %s move?" % _plan_subject_name() if _plan_mode else "How should they move?") _params_box.add_child(move_label) var style_row := HBoxContainer.new() - style_row.add_theme_constant_override("separation", 8) + style_row.add_theme_constant_override("separation", 10) _params_box.add_child(style_row) _move_style_group = ButtonGroup.new() _walk_style_btn = _make_move_style_button("🚶 Walk", ACTION_REGISTRY.WALK) @@ -302,38 +587,101 @@ func _rebuild_params(type: String) -> void: style_row.add_child(_walk_style_btn) style_row.add_child(_run_style_btn) _sync_move_style_buttons() - var trow := HBoxContainer.new() - _params_box.add_child(trow) + var where_heading: Label = _make_section_heading("Where should %s go?" % _plan_subject_name() if _plan_mode else "Where should they go?") + _params_box.add_child(where_heading) _target_label = Label.new() _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - trow.add_child(_target_label) - _set_target_btn = Button.new() - _set_target_btn.text = "🚩 Choose a Place" - _set_target_btn.pressed.connect(func() -> void: target_requested.emit()) - trow.add_child(_set_target_btn) + _target_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + if _plan_mode: + var destination_buttons := VBoxContainer.new() + destination_buttons.name = "PlanDestinationButtons" + destination_buttons.add_theme_constant_override("separation", 8) + _params_box.add_child(destination_buttons) + _place_target_btn = Button.new() + _place_target_btn.name = "ChoosePlanPlace" + _place_target_btn.text = "📍 Choose a place" + _place_target_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _place_target_btn.custom_minimum_size.y = 52.0 + _place_target_btn.pressed.connect(func() -> void: plan_destination_requested.emit(&"place")) + destination_buttons.add_child(_place_target_btn) + _prop_target_btn = Button.new() + _prop_target_btn.name = "ChoosePlanProp" + _prop_target_btn.text = "🎯 Choose a prop" + _prop_target_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _prop_target_btn.custom_minimum_size.y = 52.0 + _prop_target_btn.pressed.connect(func() -> void: plan_destination_requested.emit(&"prop")) + destination_buttons.add_child(_prop_target_btn) + _params_box.add_child(_target_label) + else: + var target_row := HBoxContainer.new() + target_row.add_theme_constant_override("separation", 10) + _params_box.add_child(target_row) + target_row.add_child(_target_label) + _set_target_btn = Button.new() + _set_target_btn.text = "🚩 Choose a Place" + _set_target_btn.custom_minimum_size.y = 52.0 + _set_target_btn.pressed.connect(func() -> void: target_requested.emit()) + target_row.add_child(_set_target_btn) _update_walk_target_label() "pick_up_prop": - var prop_row := HBoxContainer.new() - _params_box.add_child(prop_row) + var prop_heading: Label = _make_section_heading("Prop") + _params_box.add_child(prop_heading) + _prop_target_card = PanelContainer.new() + _prop_target_card.name = "PickupPropNameCard" + _prop_target_card.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _prop_target_card.custom_minimum_size.y = 66.0 + var name_card_style := StyleBoxFlat.new() + name_card_style.bg_color = Color("#18202B") + name_card_style.border_color = Color("#52647A") + name_card_style.set_border_width_all(2) + name_card_style.set_corner_radius_all(12) + name_card_style.content_margin_left = 14.0 + name_card_style.content_margin_right = 14.0 + name_card_style.content_margin_top = 8.0 + name_card_style.content_margin_bottom = 8.0 + _prop_target_card.add_theme_stylebox_override("panel", name_card_style) + _params_box.add_child(_prop_target_card) + var name_row := HBoxContainer.new() + name_row.add_theme_constant_override("separation", 12) + _prop_target_card.add_child(name_row) + _prop_name_icon = Label.new() + _prop_name_icon.text = "📦" + _prop_name_icon.add_theme_font_size_override("font_size", 28) + name_row.add_child(_prop_name_icon) _prop_target_label = Label.new() _prop_target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - prop_row.add_child(_prop_target_label) + _prop_target_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _prop_target_label.max_lines_visible = 3 + _prop_target_label.custom_minimum_size.y = 42.0 + name_row.add_child(_prop_target_label) _set_prop_target_btn = Button.new() - _set_prop_target_btn.text = "🎯 Click prop…" + _set_prop_target_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _set_prop_target_btn.custom_minimum_size.y = 52.0 _set_prop_target_btn.pressed.connect(func() -> void: target_requested.emit()) - prop_row.add_child(_set_prop_target_btn) + _params_box.add_child(_set_prop_target_btn) _update_prop_target_label() + var hand_heading: Label = _make_section_heading("Which hand?") + _params_box.add_child(hand_heading) var hand_row := HBoxContainer.new() + hand_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hand_row.add_theme_constant_override("separation", 8) _params_box.add_child(hand_row) - var hand_label := Label.new() - hand_label.text = "Hand:" - hand_row.add_child(hand_label) - _hand_option = OptionButton.new() - for label: String in ["Auto", "Left", "Right"]: - _hand_option.add_item(label) - var hand_idx := ["auto", "left", "right"].find(String(_initial.get("hand", "auto"))) - _hand_option.select(maxi(hand_idx, 0)) - hand_row.add_child(_hand_option) + _pickup_hand_selection = String(_initial.get("hand", "auto")) + if _pickup_hand_selection not in ["auto", "left", "right"]: + _pickup_hand_selection = "auto" + for hand_data: Array in [["Auto", "auto"], ["Left", "left"], ["Right", "right"]]: + var hand_button := Button.new() + hand_button.text = String(hand_data[0]) + hand_button.toggle_mode = true + hand_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hand_button.custom_minimum_size = Vector2(0.0, 50.0) + hand_button.focus_mode = Control.FOCUS_NONE + var hand_id: String = String(hand_data[1]) + hand_button.set_meta("pickup_hand", hand_id) + hand_button.set_pressed_no_signal(_pickup_hand_selection == hand_id) + hand_button.pressed.connect(_set_pickup_hand.bind(hand_id)) + _hand_buttons.append(hand_button) + hand_row.add_child(hand_button) "use_prop": var use_row := VBoxContainer.new() use_row.add_theme_constant_override("separation", 6) @@ -348,45 +696,291 @@ func _rebuild_params(type: String) -> void: use_row.add_child(_set_use_target_btn) _update_use_target_label() "throw_prop", "set_down_prop": - var throw_row := HBoxContainer.new() - _params_box.add_child(throw_row) + var throw_column := VBoxContainer.new() + throw_column.name = "TargetPositionControls" + throw_column.add_theme_constant_override("separation", 8) + throw_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _params_box.add_child(throw_column) + var target_heading: Label = _make_section_heading("Release place" if type == "set_down_prop" else "Throw target") + throw_column.add_child(target_heading) _target_label = Label.new() + _target_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _target_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL - throw_row.add_child(_target_label) + throw_column.add_child(_target_label) _set_target_btn = Button.new() _set_target_btn.text = ("📍 Change release place" if _has_throw_target else "📍 Choose release place") if type == "set_down_prop" else ("🎯 Change target…" if _has_throw_target else "🎯 Click target…") + _set_target_btn.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _set_target_btn.custom_minimum_size.y = 52.0 _set_target_btn.pressed.connect(func() -> void: target_requested.emit()) - throw_row.add_child(_set_target_btn) + throw_column.add_child(_set_target_btn) _update_throw_target_label() "speak": var speech_label := Label.new() speech_label.text = "Opening the comic speech composer…" _params_box.add_child(speech_label) "wait": - var wlabel := Label.new() - wlabel.text = "Duration (s):" + var wlabel: Label = _make_section_heading("How long should %s wait?" % _plan_subject_name() if _plan_mode else "How long should they wait?") _params_box.add_child(wlabel) - _duration_spin = _make_duration_spin(float(_initial.get("duration", 1.0))) - _params_box.add_child(_duration_spin) + _build_wait_duration_controls(float(_initial.get("duration", 1.0))) _: # ragdoll / recover (and any future no-param action) need no fields. pass _apply_font_recursive(_params_box, _base_font_size) + _update_dialog_presentation() + _update_wait_duration_typography() + _apply_dialog_styles() func _make_duration_spin(value: float) -> SpinBox: var spin := SpinBox.new() + spin.name = "WaitDurationModel" spin.min_value = 0.1 spin.max_value = 3600.0 spin.step = 0.1 spin.value = value - spin.custom_minimum_size = Vector2(120.0, 0.0) + spin.visible = false + spin.focus_mode = Control.FOCUS_NONE return spin +func _build_wait_duration_controls(value: float) -> void: + # Keep the SpinBox as the duration model for existing editor callers, while + # presenting a larger, editable numeric field with touch-sized step buttons. + _duration_spin = _make_duration_spin(value) + _duration_spin.value_changed.connect(_on_wait_duration_value_changed) + _params_box.add_child(_duration_spin) + + var stepper_row := HBoxContainer.new() + stepper_row.name = "WaitDurationStepper" + stepper_row.add_theme_constant_override("separation", 12) + stepper_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _params_box.add_child(stepper_row) + + _duration_minus_button = Button.new() + _duration_minus_button.name = "WaitDurationMinus" + _duration_minus_button.text = "−" + _duration_minus_button.tooltip_text = "Subtract half a second" + _duration_minus_button.custom_minimum_size = Vector2(68.0, 68.0) + _duration_minus_button.pressed.connect(_step_wait_duration.bind(-0.5)) + stepper_row.add_child(_duration_minus_button) + + var field := PanelContainer.new() + field.name = "WaitDurationValueField" + field.size_flags_horizontal = Control.SIZE_EXPAND_FILL + field.custom_minimum_size = Vector2(0.0, 68.0) + field.add_theme_stylebox_override("panel", _wait_duration_field_style(false)) + stepper_row.add_child(field) + var field_row := HBoxContainer.new() + field_row.add_theme_constant_override("separation", 4) + field_row.add_theme_constant_override("margin_left", 10) + field_row.add_theme_constant_override("margin_right", 10) + field.add_child(field_row) + + _duration_value_edit = LineEdit.new() + _duration_value_edit.name = "WaitDurationValue" + _duration_value_edit.alignment = HORIZONTAL_ALIGNMENT_CENTER + _duration_value_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _duration_value_edit.custom_minimum_size = Vector2(0.0, 60.0) + _duration_value_edit.max_length = 6 + _duration_value_edit.virtual_keyboard_type = LineEdit.KEYBOARD_TYPE_NUMBER_DECIMAL + _duration_value_edit.add_theme_stylebox_override("normal", StyleBoxEmpty.new()) + _duration_value_edit.add_theme_stylebox_override("focus", StyleBoxEmpty.new()) + _duration_value_edit.text_changed.connect(_on_wait_duration_text_changed) + _duration_value_edit.text_submitted.connect(_on_wait_duration_text_submitted) + _duration_value_edit.focus_exited.connect(_commit_wait_duration_text) + _duration_value_edit.gui_input.connect(func(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + _duration_value_edit.call_deferred("select_all") + ) + field_row.add_child(_duration_value_edit) + + _duration_unit_label = Label.new() + _duration_unit_label.name = "WaitDurationUnit" + _duration_unit_label.text = "second" + _duration_unit_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _duration_unit_label.add_theme_color_override("font_color", Color("#DCE3EF")) + field_row.add_child(_duration_unit_label) + _duration_value_edit.set_meta("wait_duration_field", true) + field.set_meta("wait_duration_field_container", true) + field_row.set_meta("wait_duration_field_row", true) + field_row.add_theme_constant_override("separation", 10) + field_row.mouse_filter = Control.MOUSE_FILTER_PASS + field.gui_input.connect(func(event: InputEvent) -> void: + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: + _duration_value_edit.grab_focus() + _duration_value_edit.call_deferred("select_all") + ) + _duration_value_edit.focus_entered.connect(func() -> void: + field.add_theme_stylebox_override("panel", _wait_duration_field_style(true)) + ) + _duration_value_edit.focus_exited.connect(func() -> void: + field.add_theme_stylebox_override("panel", _wait_duration_field_style(false)) + ) + + _duration_plus_button = Button.new() + _duration_plus_button.name = "WaitDurationPlus" + _duration_plus_button.text = "+" + _duration_plus_button.tooltip_text = "Add half a second" + _duration_plus_button.custom_minimum_size = Vector2(68.0, 68.0) + _duration_plus_button.pressed.connect(_step_wait_duration.bind(0.5)) + stepper_row.add_child(_duration_plus_button) + + var quick_picks_heading := Label.new() + quick_picks_heading.name = "WaitQuickPicksHeading" + quick_picks_heading.text = "Quick picks" + quick_picks_heading.add_theme_color_override("font_color", Color("#DCE3EF")) + _params_box.add_child(quick_picks_heading) + var quick_picks := HBoxContainer.new() + quick_picks.name = "WaitQuickPicks" + quick_picks.add_theme_constant_override("separation", 10) + quick_picks.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _params_box.add_child(quick_picks) + for preset_value: float in [1.0, 3.0, 5.0]: + var preset_button := Button.new() + preset_button.name = "WaitQuickPick%d" % int(preset_value) + preset_button.text = _format_wait_duration(preset_value) + (" second" if is_equal_approx(preset_value, 1.0) else " seconds") + preset_button.toggle_mode = true + preset_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + preset_button.custom_minimum_size = Vector2(0.0, 58.0) + preset_button.pressed.connect(_set_wait_duration.bind(preset_value)) + quick_picks.add_child(preset_button) + _duration_preset_buttons.append(preset_button) + _duration_value_edit.text = _format_wait_duration(_duration_spin.value) + _update_wait_duration_controls() + + +func _wait_duration_field_style(focused: bool) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = _sandbox_theme.color("popups.confirmation.secondary_surface", Color("#202633")) if _sandbox_theme != null else Color("#202633") + if focused: + style.border_color = _sandbox_theme.color("popups.confirmation.accent_color", Color("#39BFFF")) if _sandbox_theme != null else Color("#39BFFF") + else: + style.border_color = _sandbox_theme.color("popups.confirmation.secondary_border", Color("#718096")) if _sandbox_theme != null else Color("#718096") + style.set_border_width_all(3 if focused else 2) + style.set_corner_radius_all(14) + style.content_margin_left = 12.0 + style.content_margin_right = 12.0 + style.content_margin_top = 4.0 + style.content_margin_bottom = 4.0 + return style + + +func _step_wait_duration(amount: float) -> void: + _commit_wait_duration_text() + if _duration_spin == null: + return + _set_wait_duration(_duration_spin.value + amount) + + +func _set_wait_duration(value: float) -> void: + if _duration_spin == null: + return + _duration_spin.value = _normalize_wait_duration(value) + _update_wait_duration_controls() + + +func _normalize_wait_duration(value: float) -> float: + return clampf(roundf(value * 10.0) / 10.0, 0.1, 3600.0) + + +func _format_wait_duration(value: float) -> String: + var normalized: float = _normalize_wait_duration(value) + if is_equal_approx(normalized, roundf(normalized)): + return str(int(roundf(normalized))) + return "%.1f" % normalized + + +func _on_wait_duration_value_changed(value: float) -> void: + if _duration_value_edit != null and not _duration_value_edit.has_focus(): + _duration_value_edit.text = _format_wait_duration(value) + _update_wait_duration_controls() + + +func _on_wait_duration_text_changed(text: String) -> void: + var parsed: float = -1.0 + if text.strip_edges().is_valid_float(): + parsed = _normalize_wait_duration(float(text.strip_edges())) + _update_wait_duration_controls(parsed) + + +func _on_wait_duration_text_submitted(_text: String) -> void: + _commit_wait_duration_text() + if _duration_value_edit != null: + _duration_value_edit.release_focus() + + +func _commit_wait_duration_text() -> void: + if _duration_value_edit == null or _duration_spin == null: + return + var raw: String = _duration_value_edit.text.strip_edges() + if raw.is_valid_float(): + _set_wait_duration(float(raw)) + else: + _duration_value_edit.text = _format_wait_duration(_duration_spin.value) + _update_wait_duration_controls() + + +func _update_wait_duration_controls(preview_value: float = -1.0) -> void: + if _duration_spin == null: + return + var display_value: float = preview_value if preview_value > 0.0 else _duration_spin.value + if _duration_unit_label != null: + _duration_unit_label.text = "second" if is_equal_approx(display_value, 1.0) else "seconds" + if _duration_minus_button != null: + _duration_minus_button.disabled = display_value <= 0.1 + if _duration_plus_button != null: + _duration_plus_button.disabled = display_value >= 3600.0 + for index: int in _duration_preset_buttons.size(): + var button: Button = _duration_preset_buttons[index] + if button == null: + continue + var preset_value: float = [1.0, 3.0, 5.0][index] + button.set_pressed_no_signal(is_equal_approx(display_value, preset_value)) + STAGE_DIALOG_STYLER.style_button(button, &"primary" if is_equal_approx(display_value, preset_value) else &"secondary", _sandbox_theme, _ui_font) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size = Vector2(0.0, 58.0) + button.add_theme_font_size_override("font_size", 22) + if _duration_value_edit != null: + _duration_value_edit.add_theme_font_size_override("font_size", 42) + if _duration_unit_label != null: + _duration_unit_label.add_theme_font_size_override("font_size", 22) + if _duration_minus_button != null: + _duration_minus_button.add_theme_font_size_override("font_size", 38) + if _duration_plus_button != null: + _duration_plus_button.add_theme_font_size_override("font_size", 38) + + +func _update_wait_duration_typography() -> void: + if _duration_value_edit != null: + _duration_value_edit.add_theme_font_size_override("font_size", 42) + if _duration_unit_label != null: + _duration_unit_label.add_theme_font_size_override("font_size", 22) + for button: Button in _duration_preset_buttons: + if button != null: + button.add_theme_font_size_override("font_size", 22) + if _duration_minus_button != null: + _duration_minus_button.add_theme_font_size_override("font_size", 38) + if _duration_plus_button != null: + _duration_plus_button.add_theme_font_size_override("font_size", 38) + + func _update_walk_target_label() -> void: if _target_label == null: return + if _plan_mode: + var destination_kind: String = String(_plan_destination.get("kind", "")) + if destination_kind == "prop": + _target_label.text = "Destination: %s" % (_plan_destination_summary if not _plan_destination_summary.is_empty() else "prop selected") + elif destination_kind == "place": + _target_label.text = "Destination: %s" % (_plan_destination_summary if not _plan_destination_summary.is_empty() else "place selected") + else: + _target_label.text = "Choose a place or prop, then tap Done." + if _place_target_btn != null: + _place_target_btn.text = "📍 Change place" if destination_kind == "prop" or destination_kind == "place" else "📍 Choose a place" + if _prop_target_btn != null: + _prop_target_btn.text = "🎯 Change prop" if destination_kind == "prop" else "🎯 Choose a prop" + _update_done_enabled() + return if _has_target: _target_label.text = "Place chosen" if _set_target_btn != null: @@ -403,7 +997,7 @@ func _make_move_style_button(label: String, style: String) -> Button: button.toggle_mode = true button.button_group = _move_style_group button.size_flags_horizontal = Control.SIZE_EXPAND_FILL - button.custom_minimum_size = Vector2(44.0, 44.0) + button.custom_minimum_size = Vector2(0.0, 56.0) button.focus_mode = Control.FOCUS_ALL button.pressed.connect(_on_move_style_pressed.bind(style)) return button @@ -422,13 +1016,16 @@ func _sync_move_style_buttons() -> void: var style: String = String(pair[1]) var selected: bool = _move_style == style button.set_pressed_no_signal(selected) - button.text = String(pair[2]) + (" ✓" if selected else "") + button.text = String(pair[2]) button.add_theme_stylebox_override("normal", _move_style_stylebox(selected, false)) button.add_theme_stylebox_override("pressed", _move_style_stylebox(selected, false)) button.add_theme_stylebox_override("hover", _move_style_stylebox(selected, true)) button.add_theme_stylebox_override("focus", _move_style_focus_stylebox()) - button.add_theme_color_override("font_color", Color("#161616") if selected else Color.WHITE) - button.add_theme_color_override("font_hover_color", Color("#161616") if selected else Color.WHITE) + var label_color: Color = Color("#161616") if selected else Color("#F5F7FA") + button.add_theme_color_override("font_color", label_color) + button.add_theme_color_override("font_hover_color", label_color) + button.add_theme_color_override("font_pressed_color", label_color) + button.add_theme_color_override("font_hover_pressed_color", label_color) func _move_style_stylebox(selected: bool, hovered: bool) -> StyleBoxFlat: @@ -436,9 +1033,9 @@ func _move_style_stylebox(selected: bool, hovered: bool) -> StyleBoxFlat: stylebox.bg_color = _direct_accent_color if selected else _surface_color if hovered: stylebox.bg_color = stylebox.bg_color.lightened(0.12) - stylebox.border_color = Color("#FFF4CF") if selected else (_direct_accent_color if hovered else _border_color) - stylebox.set_border_width_all(3 if selected else 1) - stylebox.set_corner_radius_all(8) + stylebox.border_color = _direct_accent_color if selected or hovered else _border_color + stylebox.set_border_width_all(3 if selected else 2) + stylebox.set_corner_radius_all(14) stylebox.content_margin_left = 8.0 stylebox.content_margin_right = 8.0 stylebox.content_margin_top = 6.0 @@ -449,15 +1046,40 @@ func _move_style_stylebox(selected: bool, hovered: bool) -> StyleBoxFlat: func _move_style_focus_stylebox() -> StyleBoxFlat: var stylebox := StyleBoxFlat.new() stylebox.bg_color = Color(0.0, 0.0, 0.0, 0.0) - stylebox.border_color = Color.WHITE - stylebox.set_border_width_all(2) - stylebox.set_corner_radius_all(8) + stylebox.border_color = _theme_color("popups.confirmation.focus_color", Color("#8BD9FF")) + stylebox.set_border_width_all(3) + stylebox.set_corner_radius_all(14) return stylebox +func _theme_color(path: String, fallback: Color) -> Color: + return _sandbox_theme.color(path, fallback) if _sandbox_theme != null else fallback + + func _update_prop_target_label() -> void: if _prop_target_label != null: - _prop_target_label.text = "Prop: %s" % (_prop_target_id if not _prop_target_id.is_empty() else "not set") + var display_name: String = "No prop chosen yet" + if not _prop_target_id.is_empty(): + display_name = String(_prop_target_name_resolver.call(_prop_target_id)) if _prop_target_name_resolver.is_valid() else "Prop chosen" + if display_name.strip_edges().is_empty(): + display_name = "Prop chosen" + _prop_target_label.text = display_name + _prop_target_label.tooltip_text = display_name + if _set_prop_target_btn != null: + _set_prop_target_btn.text = "Change prop" if not _prop_target_id.is_empty() else "Choose a prop" + + +func _selected_pickup_hand() -> String: + return _pickup_hand_selection + + +func _set_pickup_hand(hand_id: String) -> void: + if hand_id not in ["auto", "left", "right"]: + return + _pickup_hand_selection = hand_id + for button: Button in _hand_buttons: + if button != null: + button.set_pressed_no_signal(String(button.get_meta("pickup_hand", "auto")) == hand_id) func _update_use_target_label() -> void: @@ -478,11 +1100,11 @@ func _update_use_target_label() -> void: func _update_throw_target_label() -> void: if _target_label == null: return - var label_prefix: String = "Release place" if _current_type() == "set_down_prop" else "Target" + var is_set_down: bool = _current_type() == "set_down_prop" if _has_throw_target: - _target_label.text = "%s: (%d, %d)" % [label_prefix, int(roundf(_throw_target_pos.x)), int(roundf(_throw_target_pos.y))] + _target_label.text = "Release place chosen" if is_set_down else "Throw target chosen" else: - _target_label.text = "%s: not set" % label_prefix + _target_label.text = "Choose where to set it down." if is_set_down else "Choose a place to throw the prop." func _on_ok_pressed() -> void: @@ -490,19 +1112,23 @@ func _on_ok_pressed() -> void: var action: Dictionary = { "type": type } match type: "move_to": - if not _has_target: - # Ask the stage to capture the target before committing. - target_requested.emit() - return - action["target"] = _target_pos + if _plan_mode: + if _plan_destination.is_empty(): + return + action["destination"] = _plan_destination.duplicate(true) + else: + if not _has_target: + # Ask the stage to capture the target before committing. + target_requested.emit() + return + action["target"] = _target_pos action["style"] = _move_style "pick_up_prop": if _prop_target_id.is_empty(): target_requested.emit() return - var hands: Array[String] = ["auto", "left", "right"] action["target_prop_id"] = _prop_target_id - action["hand"] = hands[_hand_option.selected] if _hand_option != null and _hand_option.selected >= 0 else "auto" + action["hand"] = _selected_pickup_hand() "use_prop": if _use_prop_target_id.is_empty() or _use_spot_target_id.is_empty(): target_requested.emit() diff --git a/scripts/editors/stage/queue_panel.gd b/scripts/editors/stage/queue_panel.gd index 47354ad..45c5611 100644 --- a/scripts/editors/stage/queue_panel.gd +++ b/scripts/editors/stage/queue_panel.gd @@ -37,6 +37,7 @@ var _row_font_size: int = 0 var _title_font_size: int = 0 var _title_font: Font = null var _use_target_summary_resolver: Callable = Callable() +var _prop_target_name_resolver: Callable = Callable() func _ready() -> void: @@ -62,6 +63,11 @@ func set_use_target_summary_resolver(resolver: Callable) -> void: refresh() +func set_prop_target_name_resolver(resolver: Callable) -> void: + _prop_target_name_resolver = resolver + refresh() + + func refresh() -> void: if _list == null: return @@ -151,7 +157,7 @@ func _make_row(index: int, action: Dictionary, status: int) -> PanelContainer: var summary := Label.new() summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL - var action_summary: String = ACTION_REGISTRY.summarize(action) + var action_summary: String = ACTION_REGISTRY.summarize(action, _prop_target_name_resolver) if String(action.get("type", "")) == "use_prop" and _use_target_summary_resolver.is_valid(): action_summary = String(_use_target_summary_resolver.call( String(action.get("target_prop_id", "")), diff --git a/scripts/editors/stage/sandbox_stage.gd b/scripts/editors/stage/sandbox_stage.gd index bce0bf4..129b614 100644 --- a/scripts/editors/stage/sandbox_stage.gd +++ b/scripts/editors/stage/sandbox_stage.gd @@ -38,6 +38,7 @@ const STAGE_CHARACTER_SETTINGS_VIEW_SCRIPT := preload("res://scripts/editors/sta const STAGE_OBJECT_IDENTITY_SCRIPT := preload("res://scripts/stage/stage_object_identity.gd") const STAGE_USE_SPOT_OVERLAY_SCRIPT := preload("res://scripts/editors/stage/stage_use_spot_overlay.gd") const STAGE_USE_TARGET_CAPTURE_BAR_SCRIPT := preload("res://scripts/editors/stage/stage_use_target_capture_bar.gd") +const STAGE_INSPECTOR_CAPTURE_OVERLAY_SCRIPT := preload("res://scripts/editors/stage/stage_inspector_capture_overlay.gd") const STAGE_SCENE_ADAPTER_SCRIPT := preload("res://scripts/editors/stage/stage_scene_adapter.gd") const STAGE_DOCUMENT_CONTROLLER_SCRIPT := preload("res://scripts/editors/stage/stage_document_controller.gd") const STAGE_RULE_ENGINE_SCRIPT := preload("res://scripts/stage/stage_rule_engine.gd") @@ -96,7 +97,7 @@ enum RunState { STOPPED, RUNNING, PAUSED } enum RuleStep { IDLE, SELECT_TRIGGER, TRIGGER_TARGET, SELECT_ACTION, ACTION_TARGET, ACTION_POSITION, PARAMS } ## Phase 3c target-capture kinds (what the next stage click resolves to). -enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION } +enum CaptureKind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, PLAN_PLACE, PLAN_PROP, PICKUP_PROP } enum PendingFileAction { NONE, NEW_SCENE, OPEN_DIALOG, OPEN_PATH, QUIT } # --------------------------------------------------------------------------- @@ -359,6 +360,9 @@ var _last_pointer_viewport_position: Vector2 = Vector2.ZERO var _edit_wobble_preview_prop: PropBlock var _use_spot_overlay: StageUseSpotOverlay var _use_target_capture_bar: StageUseTargetCaptureBar +var _inspector_capture_overlay: StageInspectorCaptureOverlay +var _capture_surface_signature: String = "" +var _capture_surface_using_bar: bool = false var _placement_overlay = null # StagePlacementOverlay (preloaded) var _placement_id: String = "" @@ -907,6 +911,9 @@ var _rule_editor: RuleEditor: get: return null if _rule_authoring == null else _rule_authoring.get_rule_editor() var _speech_composer: Control = null +var _plan_action_id: String = "" +var _plan_step_index: int = -1 +var _plan_add_together: bool = false var _speech_style_toolbar: Control = null var _speech_show_toolbar: Control = null var _speech_preview: SpeechBubble: @@ -1142,8 +1149,6 @@ var _capture_cancel: Callable: return _target_capture.cancel_callback set(value): _target_capture.cancel_callback = value -var _set_down_cancel_button: Button = null -var _capture_controls_left: float = 128.0 var _spot_edit_prop: PropBlock: get: return _target_capture.spot_edit_prop @@ -1366,8 +1371,10 @@ func _ready() -> void: var use_summary_resolver: Callable = Callable(self, "_resolve_use_target_summary") if _queue_panel != null: _queue_panel.set_use_target_summary_resolver(use_summary_resolver) + _queue_panel.set_prop_target_name_resolver(Callable(self, "_stage_action_target_label")) if _action_editor != null: _action_editor.set_use_target_summary_resolver(use_summary_resolver) + _action_editor.set_prop_target_name_resolver(Callable(self, "_stage_action_target_label")) if _rule_authoring != null: _rule_authoring.set_use_target_summary_resolver(use_summary_resolver) _speech_session.configure(_world, _speech_composer, _speech_style_toolbar, _speech_show_toolbar, _camera, get_viewport(), _chrome.get_canvas_layer()) @@ -1410,12 +1417,16 @@ func _process(delta: float) -> void: _update_terrain_drag(_camera.get_global_mouse_position()) elif _spot_edit_active and _spot_edit_prop != null and is_instance_valid(_spot_edit_prop): _spot_edit_ghost_world = _camera.get_global_mouse_position() - _use_spot_overlay.show_edit(_spot_edit_prop, _spot_edit_ghost_world, "Click a place on this prop" if _spot_edit_id.is_empty() else "Click the spot's new place", _spot_selected_id, _spot_edit_id) + _use_spot_overlay.show_edit(_spot_edit_prop, _spot_edit_ghost_world, _spot_capture_instruction(_spot_edit_id), _spot_selected_id, _spot_edit_id) elif _ghost != null and is_instance_valid(_ghost): _update_ghost_position() _update_placement_feedback(delta) _update_cursor_coords() _update_action_overlay() + _sync_target_capture_surface() + _update_pickup_target_preview() + _update_plan_candidate_preview() + _update_gizmo_name_badge_obstacles() _sync_awaiting_cursor() _update_speech_badge_cursor() _update_scenery_depth_cursor_badge() @@ -1460,6 +1471,31 @@ func _physics_process(delta: float) -> void: func _update_throw_preview_cache() -> void: _target_capture.update_preview(_camera, _snap_enabled, _grid_size) + + +func _update_plan_candidate_preview() -> void: + if _director_visuals == null or _capture_kind not in [CaptureKind.PLAN_PLACE, CaptureKind.PLAN_PROP]: + return + var pointer: Vector2 = _viewport_to_world(_last_pointer_viewport_position) + var resolution: Dictionary = _target_capture.resolve_target(pointer, _selection, _director_visuals, _snap_enabled, _grid_size) + var destination: Dictionary = {} + var resolved_value: Variant = resolution.get("value", null) + var valid: bool = bool(resolution.get("valid", false)) and (resolved_value is Dictionary or resolved_value is Vector2) + if valid and resolved_value is Dictionary: + destination = (resolved_value as Dictionary).duplicate(true) + elif valid and resolved_value is Vector2: + destination = {"kind": "place", "position": resolved_value} + else: + var preview_point: Vector2 = pointer + if _capture_kind == CaptureKind.PLAN_PLACE and _snap_enabled: + preview_point = Vector2(roundf(pointer.x / _grid_size) * _grid_size, roundf(pointer.y / _grid_size) * _grid_size) + var invalid_label: String = "Choose open ground" if _capture_kind == CaptureKind.PLAN_PLACE else "Choose a prop" + destination = {"kind": "place", "position": preview_point, "label": invalid_label} + _director_visuals.set_plan_candidate(_action_editor_rig, _plan_action_id, destination, valid, pointer, _plan_candidate_move_style()) + + +func _plan_candidate_move_style() -> String: + return _action_editor.current_move_style() if _action_editor != null else ACTION_REGISTRY.WALK # --------------------------------------------------------------------------- # Input # --------------------------------------------------------------------------- @@ -2810,6 +2846,8 @@ func _set_scene_dirty(value: bool) -> void: func _on_rig_queue_changed() -> void: _director_visuals.mark_dirty() + if _character_settings_view != null: + _character_settings_view.refresh_plan() if _queue_panel != null and _panel_rig != null and is_instance_valid(_panel_rig): _queue_panel.refresh() _mark_scene_dirty() @@ -3390,7 +3428,6 @@ func _build_ui() -> void: _build_character_settings_view(ui) _build_placement_name_prompt(ui) _build_scenery_inspector(ui) - _build_set_down_cancel_button(ui) _build_use_target_capture_bar(ui) _build_use_spot_overlay(ui) _refresh_prop_settings_card() @@ -3511,7 +3548,7 @@ func _build_ui() -> void: _emoji_font, _font_sizes, _theme_dialog_size("queue_size", Vector2i(680, 520)), - _theme_dialog_size("action_size", Vector2i(560, 390)) + _theme_dialog_size("action_size", Vector2i(560, 460)) ) _rig_context_popup = _modal_views.rig_context_popup _rig_context_popup.id_pressed.connect(_on_rig_context_id_pressed) @@ -3528,6 +3565,8 @@ func _build_ui() -> void: _queue_panel = _modal_views.queue_panel _action_editor = _modal_views.action_editor + if not _action_editor.plan_destination_requested.is_connected(_on_plan_destination_requested): + _action_editor.plan_destination_requested.connect(_on_plan_destination_requested) _speech_composer = _modal_views.speech_composer _speech_style_toolbar = _modal_views.speech_style_toolbar @@ -3537,6 +3576,14 @@ func _build_ui() -> void: _speech_recovery_dialog.canceled.connect(_on_speech_recovery_discard) _speech_destination_dialog = _modal_views.speech_destination_dialog _speech_destination_dialog.confirmed.connect(_commit_unbound_recovered_speech) + _build_inspector_capture_overlay(ui) + + +func _build_inspector_capture_overlay(ui: CanvasLayer) -> void: + _inspector_capture_overlay = STAGE_INSPECTOR_CAPTURE_OVERLAY_SCRIPT.new() as StageInspectorCaptureOverlay + _inspector_capture_overlay.configure(_sandbox_theme as SandboxTheme) + _inspector_capture_overlay.capture_action_requested.connect(_on_capture_surface_action_requested) + ui.add_child(_inspector_capture_overlay) func _build_prop_settings_view(ui: CanvasLayer) -> void: @@ -3559,6 +3606,11 @@ func _build_character_settings_view(ui: CanvasLayer) -> void: _character_settings_view = STAGE_CHARACTER_SETTINGS_VIEW_SCRIPT.new() as StageCharacterSettingsView _character_settings_view.configure(_sandbox_theme as SandboxTheme) _character_settings_view.rename_requested.connect(_on_character_rename_requested) + _character_settings_view.plan_action_requested.connect(_on_plan_action_requested) + _character_settings_view.plan_action_delete_requested.connect(_on_plan_action_delete_requested) + _character_settings_view.plan_step_move_requested.connect(_on_plan_step_move_requested) + _character_settings_view.plan_step_focus_changed.connect(_on_plan_step_focus_changed) + _character_settings_view.set_plan_target_label_resolver(Callable(self, "_stage_action_target_label")) ui.add_child(_character_settings_view) _character_settings_view.update_layout(_prop_settings_safe_rect()) @@ -4060,30 +4112,9 @@ func _refresh_scenery_inspector_layout() -> void: if _scenery_edit_mode: _refresh_scenery_capture_bar() -func _build_set_down_cancel_button(ui: CanvasLayer) -> void: - var button_height: float = maxf(44.0, float(_stage_panel_integer("action.min_target_size", 48, 44, 96))) - var palette_config: Dictionary = _theme.get("palette", {}) as Dictionary - _capture_controls_left = float(palette_config.get("dock_width", 116.0)) + 12.0 - var capture_top: float = TOP_BAR_HEIGHT + 56.0 - _set_down_cancel_button = Button.new() - _set_down_cancel_button.name = "SetDownCancel" - _set_down_cancel_button.text = "✕ Cancel" - _set_down_cancel_button.custom_minimum_size = Vector2(132.0, button_height) - _set_down_cancel_button.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) - _set_down_cancel_button.offset_left = _capture_controls_left - _set_down_cancel_button.offset_right = _capture_controls_left + 132.0 - _set_down_cancel_button.offset_top = capture_top - _set_down_cancel_button.offset_bottom = capture_top + button_height - _set_down_cancel_button.visible = false - _set_down_cancel_button.pressed.connect(_cancel_capture) - ui.add_child(_set_down_cancel_button) - - func _build_use_target_capture_bar(ui: CanvasLayer) -> void: _use_target_capture_bar = STAGE_USE_TARGET_CAPTURE_BAR_SCRIPT.new() as StageUseTargetCaptureBar _use_target_capture_bar.name = "UseTargetCaptureBar" - var palette_config: Dictionary = _theme.get("palette", {}) as Dictionary - _capture_controls_left = float(palette_config.get("dock_width", 116.0)) + 12.0 _use_target_capture_bar.configure(_sandbox_theme as SandboxTheme, TOP_BAR_HEIGHT) _use_target_capture_bar.cancel_requested.connect(_cancel_capture) _use_target_capture_bar.change_prop_requested.connect(_choose_different_use_prop) @@ -4284,15 +4315,18 @@ func _begin_spot_edit(prop: PropBlock, spot_id: String) -> void: if prop == null or not is_instance_valid(prop) or current_mode != StageMode.EDIT: return _spot_selected_id = spot_id - _position_cancel_button_after_prop_choice(false) _target_capture.begin_spot_edit(prop, spot_id, _camera.get_global_mouse_position()) - _set_down_cancel_button.text = "✕ Cancel" - _set_down_cancel_button.visible = true - _use_spot_overlay.show_edit(prop, _spot_edit_ghost_world, "Click a place on this prop" if spot_id.is_empty() else "Click the spot's new place", _spot_selected_id, _spot_edit_id) + _use_spot_overlay.show_edit(prop, _spot_edit_ghost_world, _spot_capture_instruction(spot_id), _spot_selected_id, _spot_edit_id) + _sync_target_capture_surface() _apply_cursor() _refresh_status() +func _spot_capture_instruction(spot_id: String) -> String: + var action: String = "Click a place on this prop" if spot_id.is_empty() else "Click the spot's new place" + return "%s\nPress Escape or right-click to cancel" % action + + func _commit_spot_position(world_position: Vector2) -> void: if _spot_edit_prop == null or not is_instance_valid(_spot_edit_prop): _cancel_spot_work() @@ -4316,7 +4350,7 @@ func _commit_spot_position(world_position: Vector2) -> void: _save_object_state(_spot_edit_prop) _mark_scene_dirty() _target_capture.finish() - _set_down_cancel_button.visible = false + _hide_target_capture_surface() _refresh_prop_settings_card() _apply_cursor() @@ -4389,8 +4423,7 @@ func _use_spot_is_referenced(prop_id: String, spot_id: String) -> bool: func _begin_use_target_capture(context: String, actor: StickmanRig = null, preview_prefix: Array[Dictionary] = [], prefix_known: bool = false) -> void: _target_capture.begin_use_target(context, actor, preview_prefix, prefix_known) - _set_down_cancel_button.visible = false - _use_target_capture_bar.set_capture_state(false) + _sync_target_capture_surface() _use_spot_overlay.show_targeting(null, {}, "Click a prop to use") _apply_cursor() _refresh_status() @@ -4452,7 +4485,7 @@ func _choose_different_use_prop() -> void: _use_choice_popup.hide() _target_capture.reset_use_target_prop() _use_spot_overlay.show_targeting(null, {}, "Click a prop to use") - _use_target_capture_bar.set_capture_state(false) + _sync_target_capture_surface() _apply_cursor() _refresh_status() @@ -4532,9 +4565,7 @@ func _stage_prop_display_name(prop: PropBlock) -> String: func _clear_use_target_capture() -> void: _target_capture.finish() _use_preview_debug_last_msec_by_key.clear() - _set_down_cancel_button.visible = false - _use_target_capture_bar.hide_capture() - _position_cancel_button_after_prop_choice(false) + _hide_target_capture_surface() _use_spot_overlay.clear_session() _apply_cursor() _refresh_status() @@ -4752,7 +4783,7 @@ func _use_preview_prefix_debug_summary() -> Array[String]: func _cancel_spot_work() -> void: if _spot_edit_active: _target_capture.finish() - _set_down_cancel_button.visible = false + _hide_target_capture_surface() _refresh_prop_settings_card() elif _use_target_active: var context: String = _use_target_context @@ -4828,10 +4859,205 @@ func _refresh_character_settings_card() -> void: if _character_settings_view == null or _selection == null: return var selected: Array[Node2D] = _selection.get_selected() - if current_mode != StageMode.EDIT or selected.size() != 1 or not selected[0] is STICKMAN_RIG: + if current_mode not in [StageMode.EDIT, StageMode.DIRECT] or selected.size() != 1 or not selected[0] is STICKMAN_RIG: _character_settings_view.hide_card(true) + if _director_visuals != null: + _director_visuals.set_plan_overlay_visible(false) + _director_visuals.clear_plan_spotlight() return - _character_settings_view.show_character(selected[0] as StickmanRig, _prop_settings_safe_rect()) + var direct_authoring: bool = current_mode == StageMode.DIRECT + _character_settings_view.show_character(selected[0] as StickmanRig, _prop_settings_safe_rect(), direct_authoring, _run_state == RunState.PAUSED) + if _director_visuals != null: + if direct_authoring: + _director_visuals.set_plan_overlay_visible(true) + _director_visuals.set_plan_spotlight(selected[0] as StickmanRig, _character_settings_view.get_selected_step_index()) + else: + _director_visuals.set_plan_overlay_visible(false) + _director_visuals.clear_plan_spotlight() + + +func _on_plan_step_focus_changed(rig: StickmanRig, step_index: int) -> void: + if _director_visuals == null: + return + if current_mode == StageMode.DIRECT and rig != null and is_instance_valid(rig): + _director_visuals.set_plan_overlay_visible(true) + _director_visuals.set_plan_spotlight(rig, step_index) + else: + _director_visuals.set_plan_overlay_visible(false) + _director_visuals.clear_plan_spotlight() + + +func _update_gizmo_name_badge_obstacles() -> void: + if _gizmos == null or not is_instance_valid(_gizmos): + return + var viewport_size: Vector2 = get_viewport_rect().size + var obstacles: Array[Rect2] = [ + Rect2(Vector2.ZERO, Vector2(viewport_size.x, TOP_BAR_HEIGHT + 8.0)), + Rect2(Vector2(0.0, viewport_size.y - STATUS_BAR_HEIGHT - 4.0), Vector2(viewport_size.x, STATUS_BAR_HEIGHT + 4.0)), + ] + _append_name_badge_obstacle(obstacles, _mode_badge) + _append_name_badge_obstacle(obstacles, _palette_dock) + _append_name_badge_obstacle(obstacles, _prop_settings_card) + _append_name_badge_obstacle(obstacles, _character_settings_view.get_card() if _character_settings_view != null else null) + _append_name_badge_obstacle(obstacles, _scenery_inspector) + _append_name_badge_obstacle(obstacles, _placement_name_prompt_panel) + _append_name_badge_obstacle(obstacles, _use_target_capture_bar) + _gizmos.set_selected_name_badge_obstacles(obstacles) + if _director_visuals != null and is_instance_valid(_director_visuals): + _director_visuals.set_actor_name_badge_obstacles(obstacles) + + +func _append_name_badge_obstacle(obstacles: Array[Rect2], control: Control) -> void: + if control != null and is_instance_valid(control) and control.is_visible_in_tree(): + obstacles.append(control.get_global_rect().grow(8.0)) + + +func _stage_action_target_label(prop_id: String) -> String: + var prop: PropBlock = _resolve_prop_by_stage_id(prop_id) + if prop != null and is_instance_valid(prop): + return prop.stage_name.strip_edges() if not prop.stage_name.strip_edges().is_empty() else "Prop" + return "Missing prop" + + +func _on_plan_action_requested(rig: StickmanRig, action_id: String, action_type: String, step_index: int, together: bool) -> void: + if current_mode != StageMode.DIRECT or rig == null or not is_instance_valid(rig): + return + if _run_state == RunState.RUNNING: + _show_toast("Pause to change future steps") + return + var is_edit: bool = not action_id.is_empty() + var action: Dictionary = {} + if is_edit: + var location: Dictionary = rig.find_action(action_id) + if location.is_empty(): + _show_toast("That action is no longer in the plan") + return + action = location.get("action", {}) + step_index = int(location.get("step_index", step_index)) + var status: StringName = _plan_action_status(rig, action_id) + if status in [&"succeeded", &"failed", &"cancelled"]: + _show_toast("Completed actions can only be erased") + return + if status == &"active" and _run_state != RunState.PAUSED: + _show_toast("Pause to edit the active action") + return + var stored_type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) if is_edit else "" + var requested_type: String = ACTION_REGISTRY.canonical_type(action_type) + var is_type_change: bool = is_edit and ACTION_REGISTRY.has_type(requested_type) and requested_type != stored_type + _plan_action_id = action_id + _plan_step_index = step_index + _plan_add_together = together + _action_editor_kind = "plan_edit" if is_edit else "plan_add" + _action_editor_rig = rig + _action_editor_restore_queue = false + var resolved_type: String = requested_type if is_type_change else stored_type if is_edit else action_type + if resolved_type == "speak": + _open_speech_composer(rig, action if is_edit and not is_type_change else {}, false, "action_editor") + return + if not ACTION_REGISTRY.has_editable_parameters(resolved_type): + if is_edit and not is_type_change: + return + _on_action_editor_committed({"type": resolved_type}) + return + if _action_editor == null: + return + _action_editor.set_plan_actor_name(rig.stage_name) + if is_edit and not is_type_change: + _action_editor.open_edit(action, false, true) + if ACTION_REGISTRY.is_move_action(action): + var existing_destination: Dictionary = action.get("destination", {}) if action.get("destination", {}) is Dictionary else {} + var existing_summary: String = _stage_action_target_label(String(existing_destination.get("target_prop_id", ""))) if String(existing_destination.get("kind", "")) == "prop" else "Open ground" + _action_editor.set_plan_destination_summary(existing_summary if not existing_destination.is_empty() else "") + else: + _action_editor.open_new(false, true, resolved_type) + if together and action_type.is_empty(): + _show_toast("Choose an action for this step") + + +func _plan_action_status(rig: StickmanRig, action_id: String) -> StringName: + if rig == null or not is_instance_valid(rig): + return &"not_started" + for entry: Dictionary in rig.get_plan_view(): + if String(entry.get("action_id", "")) == action_id: + return StringName(String(entry.get("status", "not_started"))) + return &"not_started" + + +func _on_plan_destination_requested(kind: StringName) -> void: + if _action_editor == null or _action_editor_rig == null or not is_instance_valid(_action_editor_rig): + return + var actor_name: String = _action_editor_rig.stage_name.strip_edges() + if actor_name.length() > 20: + var words: PackedStringArray = actor_name.split(" ", false) + actor_name = words[0] if not words.is_empty() else "Milo" + var hint: String = "Choose a place for %s." % actor_name if kind == &"place" else "Choose the prop %s will walk to." % actor_name + if not _plan_action_id.is_empty(): + hint = "Choose a new place or prop for %s." % actor_name + var capture_kind: CaptureKind = CaptureKind.PLAN_PLACE if kind == &"place" else CaptureKind.PLAN_PROP + _action_editor.call_deferred("hide_for_plan_target_capture") + if _director_visuals != null: + _director_visuals.set_plan_candidate(_action_editor_rig, _plan_action_id, {}, false, Vector2.INF, _plan_candidate_move_style()) + _begin_capture(capture_kind, hint, _on_plan_destination_captured, _cancel_plan_destination_capture) + + +func _on_plan_destination_captured(value: Variant) -> void: + if _action_editor == null: + return + var destination: Dictionary = STAGE_TARGET_CAPTURE_SCRIPT.normalize_plan_destination(value) + if destination.is_empty(): + return + _action_editor.set_plan_destination(destination) + var destination_kind: String = String(destination.get("kind", "")) + var destination_summary: String = _stage_action_target_label(String(destination.get("target_prop_id", ""))) if destination_kind == "prop" else "Open ground" + _action_editor.set_plan_destination_summary(destination_summary) + if _director_visuals != null: + _director_visuals.set_plan_candidate(_action_editor_rig, _plan_action_id, destination, true, Vector2.INF, _plan_candidate_move_style()) + _action_editor.call_deferred("resume_after_plan_target_capture") + + +func _cancel_plan_destination_capture() -> void: + if _director_visuals != null: + _director_visuals.clear_plan_candidate() + if _action_editor != null: + _action_editor.cancelled.emit() + + +func _on_plan_action_delete_requested(rig: StickmanRig, action_id: String) -> void: + if current_mode != StageMode.DIRECT or rig == null or not is_instance_valid(rig): + return + if _run_state != RunState.STOPPED and _run_state != RunState.PAUSED: + _show_toast("Pause before erasing plan actions") + return + if rig.find_action(action_id).is_empty(): + return + _ask_confirm("Erase action", "Erase this action?", Callable(self, "_commit_plan_action_delete").bind(rig, action_id)) + + +func _commit_plan_action_delete(rig: StickmanRig, action_id: String) -> void: + if rig == null or not is_instance_valid(rig): + return + if rig.remove_planned_action(action_id): + _mark_scene_dirty() + else: + _show_toast("That action can no longer be erased") + + +func _on_plan_step_move_requested(rig: StickmanRig, from_index: int, to_index: int) -> void: + if current_mode != StageMode.DIRECT or rig == null or not is_instance_valid(rig): + if _character_settings_view != null: + _character_settings_view.cancel_pending_step_reorder() + return + if _run_state == RunState.RUNNING: + if _character_settings_view != null: + _character_settings_view.cancel_pending_step_reorder() + _show_toast("Pause to reorder future steps") + return + if rig.move_plan_step(from_index, to_index): + _mark_scene_dirty() + else: + if _character_settings_view != null: + _character_settings_view.cancel_pending_step_reorder() + _show_toast("Only future steps can be moved") func _on_prop_rename_requested(node: Node2D, requested_name: String) -> void: @@ -4858,14 +5084,6 @@ func _on_character_rename_requested(node: Node2D, requested_name: String) -> voi _refresh_character_settings_card() -func _position_cancel_button_after_prop_choice(after_choice: bool) -> void: - if _set_down_cancel_button == null: - return - var left: float = _capture_controls_left + (184.0 if after_choice else 0.0) - _set_down_cancel_button.offset_left = left - _set_down_cancel_button.offset_right = left + 132.0 - - func _on_prop_settings_choice_selected(id: StringName, key: String) -> void: var value: String = String(id) if key == "depth": @@ -4938,6 +5156,7 @@ func _build_placement_overlay() -> void: _placement_overlay = STAGE_PLACEMENT_OVERLAY.new() _placement_overlay.name = "PlacementOverlay" _placement_overlay.camera = _camera + _placement_overlay.surface_world = _world add_child(_placement_overlay) @@ -5399,7 +5618,6 @@ func _load_starter_scene(entry: Dictionary) -> void: if _document != null: _document.accept_loaded_document("", String(entry.get("name", data.get("scene_name", "New Scene"))), int(metadata.get("created_unix", Time.get_unix_time_from_system())), "", false) _document.remove_autosave() - _set_scene_dirty(true) _refresh_status() @@ -5983,21 +6201,21 @@ func _refresh_scenery_capture_bar() -> void: if _scenery_controller != null and _scenery_controller.is_single_transform_active(): var kind: StringName = _scenery_controller.get_single_transform_kind() var prompt: String = "Move scenery · release to keep" if kind == &"move" else "Resize scenery · release to keep" - _set_scenery_selection_capture_bounds(true) + _set_stage_capture_bar_bounds(true) _use_target_capture_bar.set_scenery_capture_actions(prompt, [ {"id": "cancel_single", "label": "Cancel"}, ]) return if _scenery_edit_mode: var count: int = _selection.get_selected().size() - _set_scenery_selection_capture_bounds(count == 1) + _set_stage_capture_bar_bounds(count == 1) _use_target_capture_bar.set_scenery_edit_state(count) return _use_target_capture_bar.clear_horizontal_safe_bounds() _use_target_capture_bar.hide_capture() -func _set_scenery_selection_capture_bounds(include_inspector: bool = true) -> void: +func _set_stage_capture_bar_bounds(include_inspector: bool = true) -> void: if _use_target_capture_bar == null: return var viewport_width: float = get_viewport_rect().size.x @@ -6020,6 +6238,10 @@ func _hide_scenery_capture_bar() -> void: func _on_capture_action_requested(action_id: StringName) -> void: match action_id: + &"cancel_capture", &"cancel_plan_capture": + _on_capture_surface_action_requested(&"cancel_capture") + &"change_prop": + _choose_different_use_prop() &"depth_far": _scenery_controller.set_depth(&"far") _refresh_scenery_capture_bar() @@ -6358,6 +6580,14 @@ func _on_objects_here_selected(node: Node2D) -> void: _modal_views.dismiss_objects_here() return match context: + &"pickup_capture": + if _capture_kind == CaptureKind.PICKUP_PROP and node is PropBlock and (node as PropBlock).can_be_picked_up(): + var resolve_callback: Callable = _capture_callback + _end_capture() + if resolve_callback.is_valid(): + resolve_callback.call(node) + else: + _modal_views.dismiss_objects_here() &"edit_select": _selection.select_only(node) _show_toast("Drag to move · Click to choose") @@ -6376,6 +6606,12 @@ func _on_objects_here_candidate_preview_changed(node: Node2D) -> void: if node != null and (not is_instance_valid(node) or not _objects_here_candidates.has(node)): node = null _gizmos.set_candidate_preview(node) + if _objects_here_context == &"pickup_capture" and _director_visuals != null and is_instance_valid(_director_visuals): + var pickup_props: Array[PropBlock] = [] + for candidate: Node2D in _objects_here_candidates: + if candidate is PropBlock and (candidate as PropBlock).can_be_picked_up(): + pickup_props.append(candidate as PropBlock) + _director_visuals.set_pickup_candidates(pickup_props, node as PropBlock if node is PropBlock else null) func _on_objects_here_dismissed() -> void: @@ -6400,7 +6636,7 @@ func _select_use_target_prop(prop: PropBlock, _screen_position: Vector2) -> void return var has_use_spots: bool = not prop.get_use_spots().is_empty() _target_capture.select_use_target_prop(prop) - _use_target_capture_bar.set_capture_state(true, has_use_spots) + _sync_target_capture_surface() var instruction: String if not has_use_spots: instruction = "This prop has no Use spots. Add one in EDIT." @@ -6617,9 +6853,26 @@ func _sync_editable_physics_transform(node: Node2D) -> void: func _handle_direct_click(world_pos: Vector2) -> void: if _director_controller == null: return + if not _pending_walk_target and not _direct_move_target.is_finite(): + var plan_hit: Dictionary = _director_visuals.hit_test_plan_action(world_pos) + if not plan_hit.is_empty(): + var plan_rig: StickmanRig = plan_hit.get("rig") as StickmanRig + if plan_rig != null and is_instance_valid(plan_rig): + var selected_nodes: Array[Node2D] = _selection.get_selected() + if selected_nodes.size() != 1 or selected_nodes[0] != plan_rig: + _selection.select_only(plan_rig) + if _character_settings_view != null: + _character_settings_view.focus_action(String(plan_hit.get("action_id", ""))) + return var hit: Node2D = null if not _pending_walk_target and not _direct_move_target.is_finite(): hit = _selection.hit_test(world_pos) + if hit is StickmanRig: + _selection.select_only(hit) + return + if hit == null: + _selection.clear_selection() + return var was_pending: bool = _pending_walk_target _director_controller.handle_direct_click(world_pos, hit, _is_target_valid(world_pos), _world_to_screen(world_pos)) if was_pending and not _pending_walk_target and _direct_move_target.is_finite(): @@ -6687,7 +6940,7 @@ func _on_action_popup_id_pressed(id: int) -> void: ACT_RECOVER: _context_rig.queue_action({ "type": "recover" }) ACT_PICK_UP_PROP: - _begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_direct_pickup_prop, _cb_direct_capture_cancel) + _begin_capture(CaptureKind.PICKUP_PROP, "Choose a prop to pick up", _cb_direct_pickup_prop, _cb_direct_capture_cancel) ACT_DROP_PROP: _context_rig.queue_action({ "type": "drop_prop" }) ACT_THROW_PROP: @@ -6711,11 +6964,21 @@ func _on_action_popup_id_pressed(id: int) -> void: ACT_WHEN: _begin_rule_build(_context_rig.get_instance_id()) ACT_EDIT_QUEUE: - _open_queue_panel(_context_rig) + _focus_character_step_stack(_context_rig) ACT_EDIT_RULES: _open_rules_panel_for_rig(_context_rig) +func _focus_character_step_stack(rig: StickmanRig) -> void: + if rig == null or not is_instance_valid(rig) or current_mode != StageMode.DIRECT: + return + var selected: Array[Node2D] = _selection.get_selected() + if selected.size() != 1 or selected[0] != rig: + _selection.select_only(rig) + if _character_settings_view != null: + _character_settings_view.focus_card() + + func _cb_direct_pickup_prop(value: Node2D) -> void: if _context_rig != null and is_instance_valid(_context_rig) and value is PropBlock: var prop := value as PropBlock @@ -6763,12 +7026,18 @@ func _action_hint_text() -> String: func _capture_kind_name(kind: CaptureKind) -> String: match kind: + CaptureKind.PICKUP_PROP: + return "pickup_prop" CaptureKind.POSITION: return "position" CaptureKind.THROW_POSITION: return "throw" CaptureKind.SET_DOWN_POSITION: return "set_down" + CaptureKind.PLAN_PLACE: + return "plan_place" + CaptureKind.PLAN_PROP: + return "plan_prop" return "" if kind == CaptureKind.NONE else "target" @@ -6979,7 +7248,7 @@ func _handle_action_target_click(world_pos: Vector2) -> void: _wait_spin.value = 1.0 STAGE_DIALOG_STYLER.popup(_wait_dialog) "pick_up_prop": - _begin_capture(CaptureKind.PROP, "🫴 Click the prop to pick up (Esc or RMB to cancel)", _cb_rule_action_prop_target, _cb_rule_action_capture_cancel) + _begin_capture(CaptureKind.PICKUP_PROP, "Choose a prop to pick up", _cb_rule_action_prop_target, _cb_rule_action_capture_cancel) "throw_prop": _begin_throw_position_capture(hit as StickmanRig, "🏹 Click where to throw (Esc or RMB to cancel)", _cb_rule_action_throw_target, _cb_rule_action_capture_cancel) "set_down_prop": @@ -7469,7 +7738,7 @@ func _on_rig_context_id_pressed(id: int) -> void: return match id: RIG_CTX_EDIT_QUEUE: - _open_queue_panel(_panel_rig) + _focus_character_step_stack(_panel_rig) RIG_CTX_EDIT_RULES: _open_rules_panel_for_rig(_panel_rig) @@ -7493,9 +7762,7 @@ func _count_rules_for_waypoint(pos: Vector2) -> int: func _begin_capture(kind: CaptureKind, hint: String, on_resolve: Callable, on_cancel: Callable = Callable()) -> void: _target_capture.begin(int(kind), hint, on_resolve, on_cancel) - if _set_down_cancel_button != null: - _set_down_cancel_button.visible = kind == CaptureKind.SET_DOWN_POSITION - _position_cancel_button_after_prop_choice(false) + _sync_target_capture_surface() _apply_cursor() _refresh_status() @@ -7514,8 +7781,9 @@ func _begin_set_down_position_capture(rig: StickmanRig, hint: String, on_resolve func _end_capture() -> void: _target_capture.finish() - if _set_down_cancel_button != null: - _set_down_cancel_button.visible = false + if _director_visuals != null and is_instance_valid(_director_visuals): + _director_visuals.clear_pickup_candidates() + _hide_target_capture_surface() _apply_cursor() _refresh_status() @@ -7535,15 +7803,272 @@ func _cancel_capture() -> void: func _resolve_capture(world_pos: Vector2) -> void: + if _capture_kind == CaptureKind.PICKUP_PROP: + var pickup_overlaps: Array[Node2D] = [] + for hit: Node2D in _selection.hit_test_all(world_pos): + if hit is PropBlock and (hit as PropBlock).can_be_picked_up(): + pickup_overlaps.append(hit) + if pickup_overlaps.size() > 1: + _show_objects_here_popup(pickup_overlaps, &"pickup_capture", world_pos) + if _objects_here_popup != null and is_instance_valid(_objects_here_popup) and _objects_here_popup.visible: + return var resolution: Dictionary = _target_capture.resolve_target(world_pos, _selection, _director_visuals, _snap_enabled, _grid_size) if not bool(resolution.get("valid", false)): _refresh_status() + var reason: String = String(resolution.get("reason", "")) + if not reason.is_empty(): + _show_toast(reason) return var callback: Callable = _capture_callback _end_capture() if callback.is_valid(): callback.call(resolution.get("value")) + +func _sync_target_capture_surface() -> void: + var state: Dictionary = _target_capture_surface_state() + if state.is_empty(): + if not _capture_surface_signature.is_empty(): + _hide_target_capture_surface() + return + var prompt: String = String(state.get("prompt", "Choosing a target")) + var actions: Array[Dictionary] = state.get("actions", []) + var inspector: Control = _active_capture_inspector() + var action_signature: Array[String] = [] + for action: Dictionary in actions: + action_signature.append("%s:%s" % [String(action.get("id", "")), String(action.get("label", ""))]) + var panel_id: int = inspector.get_instance_id() if inspector != null and is_instance_valid(inspector) else 0 + var signature: String = "%s|%d|%s" % [prompt, panel_id, ";".join(action_signature)] + if signature != _capture_surface_signature: + _capture_surface_signature = signature + if inspector != null and is_instance_valid(inspector): + if _capture_surface_using_bar and _use_target_capture_bar != null: + _use_target_capture_bar.hide_capture() + _capture_surface_using_bar = false + _inspector_capture_overlay.show_capture(inspector, prompt, actions) + else: + _inspector_capture_overlay.hide_capture() + _use_target_capture_bar.set_target_capture_actions(prompt, actions) + _capture_surface_using_bar = true + var avoid_rect: Rect2 = _inspector_capture_overlay.get_inspector_rect() if _inspector_capture_overlay.has_capture() else (_use_target_capture_bar.get_global_rect() if _capture_surface_using_bar else Rect2()) + _chrome.set_action_tooltip_avoid_rect(avoid_rect) + var tooltip: String = String(state.get("tooltip", "")) + if state.get("source", "") == "session" and _capture_kind == CaptureKind.PICKUP_PROP: + tooltip = String(state.get("tooltip", "")) + elif state.get("source", "") == "session" and _capture_kind == CaptureKind.SET_DOWN_POSITION: + tooltip = "" + elif state.get("source", "") == "session" and _director_controller != null: + tooltip = _director_controller.action_hint_text( + _capture_kind_name(_capture_kind), + _capture_hint, + _target_capture.set_down_preview_cache, + _target_capture.throw_preview_cache + ) + elif state.get("source", "") == "direct_move" and _director_controller != null: + tooltip = _director_controller.action_hint_text("", "", {}, {}) + elif state.get("source", "") == "rule" and _director_controller != null: + tooltip = _director_controller.action_hint_text("", "", {}, {}) + _chrome.set_action_tooltip(not tooltip.is_empty(), tooltip) + + +func _update_pickup_target_preview() -> void: + if _director_visuals == null or not is_instance_valid(_director_visuals): + return + if _capture_kind != CaptureKind.PICKUP_PROP: + _director_visuals.clear_pickup_candidates() + return + if _objects_here_context == &"pickup_capture" and _objects_here_popup != null and is_instance_valid(_objects_here_popup) and _objects_here_popup.visible: + return + var candidates: Array[PropBlock] = [] + for node: Node2D in _world_children_selectable(): + if node is PropBlock and (node as PropBlock).can_be_picked_up(): + candidates.append(node as PropBlock) + var hovered: PropBlock = null + var mouse_world: Vector2 = _camera.get_global_mouse_position() if _camera != null else Vector2.INF + if mouse_world.is_finite() and _selection != null: + for hit: Node2D in _selection.hit_test_all(mouse_world): + if hit is PropBlock and (hit as PropBlock).can_be_picked_up(): + hovered = hit as PropBlock + break + _director_visuals.set_pickup_candidates(candidates, hovered) + + +func _has_pickup_ready_props() -> bool: + for node: Node2D in _world_children_selectable(): + if node is PropBlock and (node as PropBlock).can_be_picked_up(): + return true + return false + + +func _target_capture_surface_state() -> Dictionary: + var cancel_action: Array[Dictionary] = [{"id": "cancel_capture", "label": "Cancel", "primary": true}] + if _speech_placing: + return {"source": "speech", "prompt": "Say", "tooltip": "Choose where the speech bubble should go", "actions": cancel_action} + if _spot_edit_active: + return { + "source": "spot", + "prompt": "Use Spot", + "tooltip": "", + "actions": cancel_action, + } + if _use_target_active: + var actions: Array[Dictionary] = [] + if _use_target_step > 0: + actions.append({"id": "change_prop", "label": "Change prop"}) + actions.append_array(cancel_action) + return { + "source": "use_target", + "prompt": "Use", + "tooltip": "Choose a labeled spot on the prop" if _use_target_step > 0 else "Click a prop to use", + "actions": actions, + } + if _capture_kind != CaptureKind.NONE: + var no_pickup_targets: bool = _capture_kind == CaptureKind.PICKUP_PROP and not _has_pickup_ready_props() + return { + "source": "session", + "prompt": _capture_surface_prompt(_capture_kind), + "tooltip": "In Edit, enable pickup and add a grip to a prop." if no_pickup_targets else ("Click a highlighted prop to pick it up.\nPress Escape or right-click to cancel." if _capture_kind == CaptureKind.PICKUP_PROP else ""), + "actions": cancel_action, + } + if _pending_walk_target: + return {"source": "direct_move", "prompt": "Move", "actions": cancel_action} + match _rule_step: + RuleStep.TRIGGER_TARGET: + return {"source": "rule", "prompt": _rule_trigger_capture_prompt(), "actions": cancel_action} + RuleStep.ACTION_TARGET: + return {"source": "rule", "prompt": _rule_action_capture_prompt(), "actions": cancel_action} + RuleStep.ACTION_POSITION: + return {"source": "rule", "prompt": _rule_action_capture_prompt(), "actions": cancel_action} + return {} + + +func _capture_surface_prompt(kind: CaptureKind) -> String: + if _rule_step == RuleStep.TRIGGER_TARGET: + return _rule_trigger_capture_prompt() + if _rule_step in [RuleStep.ACTION_TARGET, RuleStep.ACTION_POSITION]: + return _rule_action_capture_prompt() + match kind: + CaptureKind.WAYPOINT: + return "Waypoint" + CaptureKind.AREA: + return "Area" + CaptureKind.PROP: + return _current_action_capture_prompt("Prop") + CaptureKind.PLAN_PROP, CaptureKind.PLAN_PLACE, CaptureKind.POSITION: + return _current_action_capture_prompt("Move") + CaptureKind.PICKUP_PROP: + return "Pick up prop" + CaptureKind.STICKMAN: + return _current_action_capture_prompt("Character") + CaptureKind.THROW_POSITION: + return "Throw prop" + CaptureKind.SET_DOWN_POSITION: + return "Set down prop" + return "Target" + + +func _current_action_capture_prompt(fallback: String) -> String: + var action_type: String = "" + if _action_editor != null and is_instance_valid(_action_editor): + action_type = _action_editor.current_action_type() + if action_type.is_empty(): + return fallback + return _capture_action_prompt(action_type, fallback) + + +func _rule_action_capture_prompt() -> String: + var actions: Array = _rule_builder.get("actions", []) + if actions.is_empty() or not actions.back() is Dictionary: + return "Character" if _rule_step == RuleStep.ACTION_TARGET else "Move" + return _capture_action_prompt(String((actions.back() as Dictionary).get("type", "")), "Character") + + +func _capture_action_prompt(action_type_value: String, fallback: String) -> String: + var action_type: String = ACTION_REGISTRY.canonical_type(action_type_value) + match action_type: + "move_to": + return "Move" + "speak": + return "Say" + "pick_up_prop": + return "Pick up prop" + "throw_prop": + return "Throw prop" + "set_down_prop": + return "Set down prop" + "use_prop": + return "Use" + "wait": + return "Wait" + "ragdoll": + return "Ragdoll" + "recover": + return "Recover" + "drop_prop": + return "Drop prop" + return String(ACTION_REGISTRY.label(action_type)) if ACTION_REGISTRY.has_type(action_type) else fallback + + +func _rule_trigger_capture_prompt() -> String: + var trigger: Dictionary = _rule_builder.get("trigger", {}) + return "Use" if String(trigger.get("type", "")) == "prop_used" else "Trigger" + + +func _active_capture_inspector() -> Control: + var primary: Node2D = _selection.get_primary() if _selection != null else null + if primary is PropBlock and _prop_settings_view != null: + var prop_card: Control = _prop_settings_view.get_card() + if prop_card != null and prop_card.is_visible_in_tree(): + return prop_card + if primary is StickmanRig and _character_settings_view != null: + var character_card: Control = _character_settings_view.get_card() + if character_card != null and character_card.is_visible_in_tree(): + return character_card + if current_mode == StageMode.DIRECT and _character_settings_view != null: + var direct_card: Control = _character_settings_view.get_card() + if direct_card != null and direct_card.is_visible_in_tree(): + return direct_card + if _prop_settings_view != null: + var visible_prop_card: Control = _prop_settings_view.get_card() + if visible_prop_card != null and visible_prop_card.is_visible_in_tree(): + return visible_prop_card + if _character_settings_view != null: + var visible_character_card: Control = _character_settings_view.get_card() + if visible_character_card != null and visible_character_card.is_visible_in_tree(): + return visible_character_card + return null + + +func _hide_target_capture_surface() -> void: + _capture_surface_signature = "" + if _inspector_capture_overlay != null: + _inspector_capture_overlay.hide_capture() + if _capture_surface_using_bar and _use_target_capture_bar != null: + _use_target_capture_bar.hide_capture() + _capture_surface_using_bar = false + if _chrome != null: + _chrome.set_action_tooltip_avoid_rect(Rect2()) + + +func _on_capture_surface_action_requested(action_id: StringName) -> void: + if action_id == &"change_prop": + _choose_different_use_prop() + return + if action_id != &"cancel_capture": + return + if _speech_placing: + if _speech_session != null: + _speech_session.cancel_placement() + return + if _spot_edit_active or _use_target_active: + _cancel_spot_work() + elif _capture_kind != CaptureKind.NONE: + _cancel_capture() + elif _pending_walk_target: + _cancel_direct_move_draft() + elif _rule_step != RuleStep.IDLE: + _cancel_rule_build() + # --------------------------------------------------------------------------- # Phase 3c.3 walk-edit (visual waypoint re-placement) # --------------------------------------------------------------------------- @@ -8174,6 +8699,23 @@ func _on_action_editor_committed(flat: Dictionary) -> bool: return false var succeeded: bool = false match _action_editor_kind: + "plan_add": + if _action_editor_rig != null and is_instance_valid(_action_editor_rig): + var created_id: String = _action_editor_rig.add_action_together(_plan_step_index, flat) if _plan_add_together else _action_editor_rig.add_action_step(flat) + if created_id.is_empty(): + _show_toast("Action was not added. Choose a valid destination and try again.") + return false + _mark_scene_dirty() + _show_toast("Action added to the plan") + succeeded = true + "plan_edit": + if _action_editor_rig != null and is_instance_valid(_action_editor_rig) and not _plan_action_id.is_empty(): + if _action_editor_rig.replace_planned_action(_plan_action_id, flat): + _mark_scene_dirty() + _show_toast("Action updated") + succeeded = true + else: + _show_toast("That action is no longer editable") "queue_add": if _action_editor_rig != null and is_instance_valid(_action_editor_rig): if not _queue_action_and_confirm(_action_editor_rig, flat): @@ -8239,6 +8781,12 @@ func _on_action_editor_committed(flat: Dictionary) -> bool: succeeded = true if not succeeded: return false + if _action_editor_kind in ["plan_add", "plan_edit"]: + if _director_visuals != null: + _director_visuals.clear_plan_candidate() + _plan_action_id = "" + _plan_step_index = -1 + _plan_add_together = false _action_editor.hide() _restore_queue_panel() return true @@ -8263,6 +8811,12 @@ func _queue_action_and_confirm(rig: StickmanRig, action: Dictionary, index: int func _on_action_editor_cancelled() -> void: if _action_queue_controller != null and _action_queue_controller.cancel_action_editor() and _rule_authoring != null: _rule_authoring.cancel_move_to_action_editor() + if _action_editor_kind in ["plan_add", "plan_edit"]: + if _director_visuals != null: + _director_visuals.clear_plan_candidate() + _plan_action_id = "" + _plan_step_index = -1 + _plan_add_together = false func _on_action_editor_capture_request( @@ -8281,14 +8835,22 @@ func _on_action_editor_capture_request( _begin_capture(CaptureKind.POSITION, hint, on_resolve, on_cancel) &"prop": _begin_capture(CaptureKind.PROP, hint, on_resolve, on_cancel) + &"pickup_prop": + _begin_capture(CaptureKind.PICKUP_PROP, hint, on_resolve, on_cancel) &"use_prop": - var queue_prefix: Array[Dictionary] = _throw_preview_prefix_for(preview_rig, exclude_action_id) if use_queue_prefix else [] + var queue_prefix: Array[Dictionary] = [] + if use_queue_prefix: + queue_prefix = _throw_preview_prefix_for(preview_rig, exclude_action_id) _begin_use_target_capture("action_editor", preview_rig, queue_prefix, use_queue_prefix) &"throw_position": - var queue_prefix: Array[Dictionary] = _throw_preview_prefix_for(preview_rig, exclude_action_id) if use_queue_prefix else [] + var queue_prefix: Array[Dictionary] = [] + if use_queue_prefix: + queue_prefix = _throw_preview_prefix_for(preview_rig, exclude_action_id) _begin_throw_position_capture(preview_rig, hint, on_resolve, on_cancel, queue_prefix) &"set_down_position": - var queue_prefix: Array[Dictionary] = _throw_preview_prefix_for(preview_rig, exclude_action_id) if use_queue_prefix else [] + var queue_prefix: Array[Dictionary] = [] + if use_queue_prefix: + queue_prefix = _throw_preview_prefix_for(preview_rig, exclude_action_id) _begin_set_down_position_capture(preview_rig, hint, on_resolve, on_cancel, queue_prefix) @@ -8426,6 +8988,8 @@ func _apply_theme_to_visuals() -> void: _gizmos.apply_theme(_sandbox_theme as SandboxTheme) if _director_visuals != null: _director_visuals.set_style(_theme) + var director_plan_style: Variant = _sandbox_theme.value("overlays.director_plan", {}) if _sandbox_theme != null else {} + _director_visuals.apply_plan_style(director_plan_style as Dictionary if director_plan_style is Dictionary else {}) _director_visuals.completed_path_alpha = _sandbox_theme.number("overlays.completed_path_alpha", 0.20, 0.0, 1.0) if _sandbox_theme != null else 0.20 _director_visuals.completed_path_color = _sandbox_theme.color("overlays.completed_path_color", Color("#959EAD")) if _sandbox_theme != null else Color("#959EAD") _director_visuals.rule_connector_color = _sandbox_theme.color("overlays.rule_connector_color", Color("#AAB2C0")) if _sandbox_theme != null else Color("#AAB2C0") diff --git a/scripts/editors/stage/stage_action_queue_controller.gd b/scripts/editors/stage/stage_action_queue_controller.gd index 15ff4f6..a4776b4 100644 --- a/scripts/editors/stage/stage_action_queue_controller.gd +++ b/scripts/editors/stage/stage_action_queue_controller.gd @@ -283,7 +283,7 @@ func set_down_target(position: Vector2) -> void: func show_action_editor() -> void: if _action_editor != null: - _action_editor.popup_centered() + _action_editor.resume_editor() func request_action_target_capture() -> void: @@ -304,7 +304,7 @@ func request_action_target_capture() -> void: var pace: String = "run" if _action_editor.current_move_style() == ACTION_REGISTRY.RUN else "walk" _emit_capture_request(&"position", "🚩 Click where they should %s (Esc or RMB to cancel)" % pace, Callable(self, "set_walk_target"), Callable(self, "cancel_target_capture")) "pick_up_prop": - _emit_capture_request(&"prop", "🫴 Click the prop to pick up (Esc or RMB to cancel)", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture")) + _emit_capture_request(&"pickup_prop", "Choose a prop to pick up", Callable(self, "accept_prop_target"), Callable(self, "cancel_target_capture")) "use_prop": _emit_capture_request(&"use_prop", "Click a prop, then choose its Use spot", Callable(self, "set_use_target_from_capture"), Callable(self, "cancel_target_capture")) "throw_prop": diff --git a/scripts/editors/stage/stage_character_settings_view.gd b/scripts/editors/stage/stage_character_settings_view.gd index e061936..096ffca 100644 --- a/scripts/editors/stage/stage_character_settings_view.gd +++ b/scripts/editors/stage/stage_character_settings_view.gd @@ -2,10 +2,56 @@ class_name StageCharacterSettingsView extends Control ## Presentation-only selected-character name card for the Sandbox Stage. +class StepReorderArrowIcon extends Control: + var direction: int = -1 + var enabled_color: Color = Color("#F5F7FA") + var disabled_color: Color = Color("#AAB2C0") + + func _draw() -> void: + var arrow_color: Color = enabled_color + var button: Button = get_parent() as Button + if button != null and button.disabled: + arrow_color = disabled_color + var center_x: float = size.x * 0.5 + var points: PackedVector2Array + if direction < 0: + points = PackedVector2Array([ + Vector2(center_x, 1.0), + Vector2(center_x - 10.0, 10.0), + Vector2(center_x - 4.0, 10.0), + Vector2(center_x - 4.0, 22.0), + Vector2(center_x + 4.0, 22.0), + Vector2(center_x + 4.0, 10.0), + Vector2(center_x + 10.0, 10.0), + ]) + else: + points = PackedVector2Array([ + Vector2(center_x - 10.0, 14.0), + Vector2(center_x - 4.0, 14.0), + Vector2(center_x - 4.0, 2.0), + Vector2(center_x + 4.0, 2.0), + Vector2(center_x + 4.0, 14.0), + Vector2(center_x + 10.0, 14.0), + Vector2(center_x, 23.0), + ]) + if points.size() >= 3: + draw_colored_polygon(points, arrow_color) + + const STAGE_INSPECTOR_PANEL := preload("res://scripts/shared/stage_ui/stage_inspector_panel.gd") const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd") +const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") +const ACTION_SUMMARY_GLYPH := preload("res://scripts/shared/stage_ui/stage_action_summary_glyph.gd") +const SAY_BUTTON_LABEL_MAX_CHARS := 24 +const COMPACT_SUMMARY_MAX_CHARS := 18 +const STEP_REORDER_ANIMATION_DURATION_SEC := 0.20 +const ADD_STEP_DRAWER_ANIMATION_DURATION_SEC := 0.20 signal rename_requested(node: Node2D, requested_name: String) +signal plan_action_requested(rig: StickmanRig, action_id: String, action_type: String, step_index: int, together: bool) +signal plan_action_delete_requested(rig: StickmanRig, action_id: String) +signal plan_step_move_requested(rig: StickmanRig, from_index: int, to_index: int) +signal plan_step_focus_changed(rig: StickmanRig, step_index: int) var _stage_theme: SandboxTheme var _card: StageInspectorPanel @@ -14,6 +60,42 @@ var _name_edit: LineEdit var _done_button: Button var _cancel_button: Button var _feedback: Label +var _plan_stack: VBoxContainer +var _plan_empty: Label +var _plan_list: VBoxContainer +var _plan_pinned: VBoxContainer +var _plan_heading_row: HBoxContainer +var _plan_heading_label: Label +var _plan_helper: Label +var _plan_target_label_resolver: Callable = Callable() +var _plan_popup: Panel +var _plan_popup_dimmer: Panel +var _plan_popup_title: Label +var _plan_popup_source: Label +var _plan_popup_content: VBoxContainer +var _plan_popup_choices: VBoxContainer +var _plan_popup_step: int = -1 +var _plan_popup_choice_types: Array[String] = [] +var _plan_popup_replace_action_id: String = "" +var _expanded_action_id: String = "" +var _selected_step_index: int = -1 +var _selected_step_action_id: String = "" +var _step_cards: Array[Control] = [] +var _plan_popup_layout: VBoxContainer +var _plan_popup_tween: Tween +var _plan_popup_origin_button: Control +var _plan_popup_closing: bool = false +var _plan_popup_restore_focus_on_close: bool = false +var _plan_popup_open_position: Vector2 = Vector2.ZERO +var _plan_popup_uses_drawer: bool = false +var _reorder_animation_active: bool = false +var _pending_reorder_state: Dictionary = {} +var _reorder_tween: Tween +var _direct_authoring: bool = false +var _paused_direct: bool = false +var _compact_layout_active: bool = false +var _pending_selected_step_reveal: bool = false +var _plan_popup_add_step: bool = false var _character: StickmanRig var _active_selection_key: String = "" var _editing: bool = false @@ -32,13 +114,53 @@ func _ready() -> void: _on_viewport_resized() -func show_character(rig: StickmanRig, safe_rect: Rect2) -> void: +func _unhandled_key_input(event: InputEvent) -> void: + if _plan_popup == null or not _plan_popup.visible or not event is InputEventKey: + return + var key_event: InputEventKey = event as InputEventKey + if key_event.pressed and not key_event.echo and key_event.keycode == KEY_ESCAPE: + _hide_plan_popup() + get_viewport().set_input_as_handled() + + +func _input(event: InputEvent) -> void: + if _plan_popup == null or not _plan_popup.visible: + return + if _plan_popup_closing: + if event is InputEventMouseButton or event is InputEventKey: + get_viewport().set_input_as_handled() + return + if event is InputEventKey: + var key_event: InputEventKey = event as InputEventKey + if key_event.pressed and not key_event.echo and key_event.keycode == KEY_ESCAPE: + _hide_plan_popup() + get_viewport().set_input_as_handled() + return + if not _plan_popup_uses_drawer or not event is InputEventMouseButton: + return + var mouse_event: InputEventMouseButton = event as InputEventMouseButton + if mouse_event.button_index != MOUSE_BUTTON_LEFT or not mouse_event.pressed: + return + var viewport_to_parent: Transform2D = get_global_transform_with_canvas().affine_inverse() + var mouse_local_position: Vector2 = viewport_to_parent * mouse_event.position + if Rect2(_plan_popup.position, _plan_popup.size).has_point(mouse_local_position): + return + _hide_plan_popup() + get_viewport().set_input_as_handled() + + +func show_character(rig: StickmanRig, safe_rect: Rect2, direct_authoring: bool = false, paused_direct: bool = false) -> void: if rig == null or not is_instance_valid(rig): hide_card() return var selection_key: String = str(rig.get_instance_id()) + if _reorder_animation_active and (selection_key != _active_selection_key or not direct_authoring or paused_direct != _paused_direct): + cancel_pending_step_reorder() if selection_key != _active_selection_key: _active_selection_key = selection_key + _expanded_action_id = "" + _selected_step_index = -1 + _selected_step_action_id = "" _editing = false _feedback.visible = false _character = rig @@ -49,8 +171,17 @@ func show_character(rig: StickmanRig, safe_rect: Rect2) -> void: _character = rig _card.set_safe_rect(safe_rect) _card.set_header(rig.stage_name, "🧍") - _card.set_rename_enabled(true) + _card.set_rename_enabled(not direct_authoring) _card.set_close_visible(false) + _direct_authoring = direct_authoring + _paused_direct = paused_direct + if _selected_step_index < 0 or _selected_step_index >= rig.get_action_plan().size(): + _selected_step_index = _default_spotlight_step_index(rig) + _plan_pinned.visible = direct_authoring + _plan_stack.visible = direct_authoring + _sync_plan_header_layout() + _card.set_body_visible(direct_authoring or _editing) + _refresh_plan() if not _editing: _name_edit.text = rig.stage_name _card.request_fit() @@ -80,8 +211,11 @@ func show_name_result(result: Dictionary) -> void: func hide_card(reset_selection: bool = false) -> void: + _dismiss_plan_popup_immediately() if reset_selection: + cancel_pending_step_reorder() _active_selection_key = "" + _selected_step_action_id = "" _set_editing(false) _feedback.visible = false _character = null @@ -103,6 +237,33 @@ func update_layout(safe_rect: Rect2) -> void: _card.set_safe_rect(safe_rect) +func set_plan_target_label_resolver(resolver: Callable) -> void: + _plan_target_label_resolver = resolver + _refresh_plan() + + +func refresh_plan() -> void: + _refresh_plan() + + +func focus_action(action_id: String) -> void: + if _character == null or not is_instance_valid(_character): + return + var location: Dictionary = _character.find_action(action_id) + if location.is_empty(): + return + _expanded_action_id = action_id + _selected_step_index = int(location.get("step_index", -1)) + _selected_step_action_id = action_id + _refresh_plan() + if _selected_step_index >= 0 and _selected_step_index < _step_cards.size(): + _card.scroll_to_control(_step_cards[_selected_step_index]) + + +func get_selected_step_index() -> int: + return _selected_step_index + + func _build() -> void: _card = STAGE_INSPECTOR_PANEL.new() as StageInspectorPanel _card.name = "CharacterSettingsCard" @@ -114,6 +275,17 @@ func _build() -> void: _card.set_body_visible(false) _card.set_title_single_line(true) _card.rename_requested.connect(_start_editing) + _plan_popup_dimmer = Panel.new() + _plan_popup_dimmer.name = "AddStepDrawerDimmer" + _plan_popup_dimmer.visible = false + _plan_popup_dimmer.z_index = 90 + _plan_popup_dimmer.mouse_filter = Control.MOUSE_FILTER_STOP + _plan_popup_dimmer.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) + var dimmer_style: StyleBoxFlat = StyleBoxFlat.new() + dimmer_style.bg_color = Color(0.0, 0.0, 0.0, 0.32) + dimmer_style.set_corner_radius_all(_stage_panel_integer("shell.corner_radius", 12, 8, 32)) + _plan_popup_dimmer.add_theme_stylebox_override("panel", dimmer_style) + add_child(_plan_popup_dimmer) var body: VBoxContainer = _card.body() body.add_theme_constant_override("separation", _stage_panel_integer("shell.section_gap", 12, 4, 32)) _name_section = VBoxContainer.new() @@ -121,6 +293,98 @@ func _build() -> void: _name_section.add_theme_constant_override("separation", 6) _name_section.visible = false body.add_child(_name_section) + _plan_stack = VBoxContainer.new() + _plan_stack.name = "ActionStepStack" + _plan_stack.add_theme_constant_override("separation", 10) + _plan_stack.visible = false + body.add_child(_plan_stack) + _plan_empty = Label.new() + _plan_empty.name = "EmptyActionPlan" + _plan_empty.text = "Choose an action to start the plan." + _plan_empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _plan_empty.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0"))) + _apply_font(_plan_empty) + _plan_stack.add_child(_plan_empty) + _plan_list = VBoxContainer.new() + _plan_list.name = "ActionSteps" + _plan_list.add_theme_constant_override("separation", 10) + _plan_stack.add_child(_plan_list) + _plan_pinned = _card.pinned_content() + _plan_pinned.name = "StepStackPinnedActions" + _plan_pinned.visible = false + _build_plan_pinned() + _plan_popup = Panel.new() + _plan_popup.name = "AddTogetherChoices" + _plan_popup.visible = false + _plan_popup.z_index = 100 + _plan_popup.mouse_filter = Control.MOUSE_FILTER_STOP + _plan_popup.clip_contents = true + _plan_popup.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) + var popup_style: StyleBoxFlat = StyleBoxFlat.new() + popup_style.bg_color = _stage_panel_color("shell.surface_color", Color("#252B38FA")) + popup_style.border_color = _stage_panel_color("global.border_color", Color("#667386")) + popup_style.set_border_width_all(1) + popup_style.set_corner_radius_all(_stage_panel_integer("shell.corner_radius", 12, 8, 32)) + popup_style.content_margin_left = 8.0 + popup_style.content_margin_right = 8.0 + popup_style.content_margin_top = 8.0 + popup_style.content_margin_bottom = 8.0 + popup_style.shadow_color = Color(0.0, 0.0, 0.0, 0.30) + popup_style.shadow_size = 6 + popup_style.shadow_offset = Vector2(0.0, 3.0) + _plan_popup.add_theme_stylebox_override("panel", popup_style) + _plan_popup_layout = VBoxContainer.new() + _plan_popup_layout.name = "PlanChoiceLayout" + _plan_popup_layout.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _plan_popup_layout.offset_left = 10.0 + _plan_popup_layout.offset_top = 10.0 + _plan_popup_layout.offset_right = -10.0 + _plan_popup_layout.offset_bottom = -10.0 + _plan_popup_layout.add_theme_constant_override("separation", 8) + _plan_popup.add_child(_plan_popup_layout) + var popup_scroll: ScrollContainer = ScrollContainer.new() + popup_scroll.name = "TogetherChoiceScroll" + popup_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL + popup_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL + popup_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + popup_scroll.vertical_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + popup_scroll.clip_contents = true + _plan_popup_layout.add_child(popup_scroll) + _plan_popup_content = VBoxContainer.new() + _plan_popup_content.add_theme_constant_override("separation", 8) + _plan_popup_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + popup_scroll.add_child(_plan_popup_content) + _plan_popup_title = Label.new() + _plan_popup_title.name = "TogetherChoiceTitle" + _plan_popup_title.text = "Add to this step" + _plan_popup_title.add_theme_font_size_override("font_size", _stage_panel_integer("type.section_size", 20, 16, 32)) + _plan_popup_title.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA"))) + _plan_popup_title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _apply_font(_plan_popup_title) + _plan_popup_content.add_child(_plan_popup_title) + _plan_popup_source = Label.new() + _plan_popup_source.name = "TogetherSourceAction" + _plan_popup_source.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _plan_popup_source.max_lines_visible = 2 + _plan_popup_source.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _plan_popup_source.custom_minimum_size.y = 24.0 + _plan_popup_source.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32)) + _plan_popup_source.add_theme_color_override("font_color", _stage_theme.color("mode_colors.direct", Color("#FFB300")) if _stage_theme != null else Color("#FFB300")) + _apply_font(_plan_popup_source) + _plan_popup_content.add_child(_plan_popup_source) + _plan_popup_choices = VBoxContainer.new() + _plan_popup_choices.name = "TogetherChoiceButtons" + _plan_popup_choices.add_theme_constant_override("separation", 6) + _plan_popup_content.add_child(_plan_popup_choices) + var popup_cancel: Button = Button.new() + popup_cancel.name = "CancelTogether" + popup_cancel.text = "Cancel" + popup_cancel.custom_minimum_size = Vector2(0.0, 48.0) + popup_cancel.focus_mode = Control.FOCUS_ALL + popup_cancel.pressed.connect(_hide_plan_popup) + _plan_popup_layout.add_child(popup_cancel) + _card.style_action_button(popup_cancel) + add_child(_plan_popup) _name_edit = LineEdit.new() _name_edit.name = "StageNameEdit" _name_edit.placeholder_text = "Character name" @@ -195,6 +459,1285 @@ func _cancel_editing() -> void: _card.request_fit() +func _build_plan_pinned() -> void: + var quick_row: HBoxContainer = HBoxContainer.new() + quick_row.name = "QuickActionButtons" + quick_row.add_theme_constant_override("separation", 6) + _plan_pinned.add_child(quick_row) + for action_config: Array in [["🚶 Move", ACTION_REGISTRY.MOVE_TO], ["💬 Say", "speak"], ["⏳ Wait", "wait"]]: + var button: Button = Button.new() + button.text = String(action_config[0]) + button.custom_minimum_size = Vector2(0.0, 48.0) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.focus_mode = Control.FOCUS_ALL + button.pressed.connect(func() -> void: plan_action_requested.emit(_character, "", String(action_config[1]), -1, false)) + _card.style_action_button(button) + quick_row.add_child(button) + _plan_heading_row = HBoxContainer.new() + _plan_heading_row.name = "ActionPlanHeading" + _plan_heading_row.add_theme_constant_override("separation", 8) + _plan_pinned.add_child(_plan_heading_row) + var heading: Label = Label.new() + heading.name = "ActionPlanHeadingLabel" + heading.text = "Plan" + heading.size_flags_horizontal = Control.SIZE_EXPAND_FILL + heading.add_theme_font_size_override("font_size", _stage_panel_integer("type.section_size", 20, 12, 40)) + _apply_font(heading) + _plan_heading_row.add_child(heading) + _plan_heading_label = heading + var add_step: Button = Button.new() + add_step.name = "AddActionStep" + add_step.text = "+ Add step" + add_step.custom_minimum_size = Vector2(128.0, 48.0) + add_step.focus_mode = Control.FOCUS_ALL + add_step.pressed.connect(_open_add_step_picker.bind(add_step)) + _card.style_primary_action_button(add_step) + _plan_heading_row.add_child(add_step) + _plan_helper = Label.new() + _plan_helper.name = "StepStackHelper" + _plan_helper.text = "Together steps start at the same time." + _plan_helper.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _plan_helper.add_theme_font_size_override("font_size", _stage_panel_integer("type.helper_size", 16, 12, 32)) + _plan_helper.add_theme_color_override("font_color", _stage_panel_color("type.muted_text_color", Color("#AAB2C0"))) + _apply_font(_plan_helper) + _plan_pinned.add_child(_plan_helper) + _sync_plan_header_layout() + + +func _sync_plan_header_layout() -> void: + if _plan_heading_row == null or _plan_helper == null or _plan_list == null or _plan_pinned == null: + return + var destination: Node = _plan_list if _is_compact_plan_layout() else _plan_pinned + for header: Control in [_plan_heading_row, _plan_helper]: + if header.get_parent() != destination: + if header.get_parent() != null: + header.get_parent().remove_child(header) + destination.add_child(header) + if destination == _plan_list: + _plan_list.move_child(_plan_heading_row, 0) + _plan_list.move_child(_plan_helper, 1) + else: + _plan_pinned.move_child(_plan_heading_row, 1) + _plan_pinned.move_child(_plan_helper, 2) + + +func _refresh_plan() -> void: + if _plan_list == null or _character == null or not is_instance_valid(_character): + return + if _reorder_animation_active and _pending_reorder_state.is_empty(): + cancel_pending_step_reorder() + var reorder_state: Dictionary = {} + if _reorder_animation_active and bool(_pending_reorder_state.get("request_emitted", false)) and _pending_reorder_state.get("rig") == _character: + reorder_state = _pending_reorder_state.duplicate(true) + _sync_plan_header_layout() + var plan: Array[Dictionary] = _character.get_action_plan() + if _plan_heading_label != null: + _plan_heading_label.text = "Plan" if plan.is_empty() else ("Plan · 1 step" if plan.size() == 1 else "Plan · %d steps" % plan.size()) + if plan.is_empty(): + _selected_step_index = -1 + _selected_step_action_id = "" + else: + if not _selected_step_action_id.is_empty(): + var selected_location: Dictionary = _character.find_action(_selected_step_action_id) + if not selected_location.is_empty(): + _selected_step_index = int(selected_location.get("step_index", _selected_step_index)) + else: + _selected_step_action_id = "" + if _selected_step_index < 0 or _selected_step_index >= plan.size(): + _selected_step_index = _default_spotlight_step_index(_character) + _pending_selected_step_reveal = true + if _selected_step_index >= 0 and _selected_step_index < plan.size() and _selected_step_action_id.is_empty(): + _selected_step_action_id = _step_identity_action_id(plan[_selected_step_index]) + for child: Node in _plan_list.get_children(): + _plan_list.remove_child(child) + if child != _plan_heading_row and child != _plan_helper: + child.queue_free() + _plan_empty.text = "Choose an action to start %s’s plan." % _character.stage_name + _step_cards.clear() + _plan_empty.visible = plan.is_empty() + for step_index: int in plan.size(): + var step_card: Control = _make_step_card(step_index, plan[step_index]) + _step_cards.append(step_card) + _plan_list.add_child(step_card) + if not reorder_state.is_empty(): + _pending_reorder_state.clear() + call_deferred("_animate_reordered_steps", reorder_state) + _card.request_fit() + plan_step_focus_changed.emit(_character, _selected_step_index) + if _pending_selected_step_reveal and _direct_authoring and _selected_step_index >= 0: + _pending_selected_step_reveal = false + call_deferred("_reveal_selected_step_after_layout") + + +func _reveal_selected_step_after_layout() -> void: + await get_tree().process_frame + await get_tree().process_frame + if _character == null or not is_instance_valid(_character) or _selected_step_index < 0 or _selected_step_index >= _step_cards.size(): + return + _card.scroll_to_control(_step_cards[_selected_step_index]) + + +func _default_spotlight_step_index(rig: StickmanRig) -> int: + if rig == null or not is_instance_valid(rig): + return -1 + var plan: Array[Dictionary] = rig.get_action_plan() + if plan.is_empty(): + return -1 + if _paused_direct: + for entry: Dictionary in rig.get_plan_view(): + if StringName(String(entry.get("status", ""))) == &"active": + return int(entry.get("step_index", 0)) + for entry: Dictionary in rig.get_plan_view(): + if StringName(String(entry.get("status", ""))) == &"not_started": + return int(entry.get("step_index", 0)) + return 0 + + +func _step_identity_action_id(step: Dictionary) -> String: + var actions_value: Variant = step.get("actions", []) + if not actions_value is Array: + return "" + var actions: Array = actions_value as Array + for raw_action: Variant in actions: + if not raw_action is Dictionary: + continue + var action_id: String = String((raw_action as Dictionary).get("action_id", "")) + if not action_id.is_empty(): + return action_id + return "" + + +func _make_step_card(step_index: int, step: Dictionary) -> Control: + var card: PanelContainer = PanelContainer.new() + card.name = "ActionStep_%d" % (step_index + 1) + card.set_meta(&"step_identity_action_id", _step_identity_action_id(step)) + card.mouse_filter = Control.MOUSE_FILTER_STOP + card.focus_mode = Control.FOCUS_ALL + card.gui_input.connect(_on_step_card_gui_input.bind(step_index)) + card.focus_entered.connect(_on_step_card_focus_changed.bind(card, true)) + card.focus_exited.connect(_on_step_card_focus_changed.bind(card, false)) + var compact: bool = _is_compact_plan_layout() + var card_style: StyleBoxFlat = StyleBoxFlat.new() + var is_selected: bool = step_index == _selected_step_index + card_style.bg_color = _stage_panel_color("choice.surface_color", Color("#171B24")) + card_style.border_color = _stage_theme.color("mode_colors.direct", Color("#FFB300")) if is_selected and _stage_theme != null else _stage_panel_color("global.border_color", Color("#FFFFFF33")) + card_style.set_border_width_all(3 if is_selected else 1) + card_style.set_corner_radius_all(_stage_panel_integer("shell.corner_radius", 10, 0, 32)) + card_style.content_margin_left = 10.0 + card_style.content_margin_right = 10.0 + card_style.content_margin_top = (6.0 if compact else 8.0) if is_selected else 4.0 + card_style.content_margin_bottom = (6.0 if compact else 8.0) if is_selected else 4.0 + card.add_theme_stylebox_override("panel", card_style) + var content: VBoxContainer = VBoxContainer.new() + content.mouse_filter = Control.MOUSE_FILTER_PASS + content.add_theme_constant_override("separation", (5 if compact else 7) if is_selected else 0) + card.add_child(content) + var header: HBoxContainer = HBoxContainer.new() + header.name = "ExpandedStepHeader" + header.mouse_filter = Control.MOUSE_FILTER_PASS + header.add_theme_constant_override("separation", 6) + header.visible = is_selected + content.add_child(header) + var step_label: Label = Label.new() + step_label.name = "StepHeading" + step_label.text = "Step %d" % (step_index + 1) + step_label.mouse_filter = Control.MOUSE_FILTER_PASS + step_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + step_label.custom_minimum_size.y = 44.0 if is_selected else 36.0 + step_label.add_theme_font_size_override("font_size", _stage_panel_integer("type.section_size", 20, 12, 40)) + step_label.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA"))) + _apply_font(step_label) + header.add_child(step_label) + var actions: Array = step.get("actions", []) + var has_together_choice: bool = actions.size() == 1 and _has_together_choice(step_index) + var compact_row: HBoxContainer = HBoxContainer.new() + compact_row.name = "CompactStepRow" + compact_row.mouse_filter = Control.MOUSE_FILTER_PASS + compact_row.add_theme_constant_override("separation", 5) + content.add_child(compact_row) + var compact_step_label: Label = Label.new() + compact_step_label.name = "CompactStepNumber" + compact_step_label.text = str(step_index + 1) + compact_step_label.custom_minimum_size = Vector2(28.0, 48.0) + compact_step_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + compact_step_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + compact_step_label.add_theme_font_size_override("font_size", _stage_panel_integer("type.body_size", 18, 12, 36)) + compact_step_label.add_theme_color_override("font_color", _stage_panel_color("type.text_color", Color("#F5F7FA"))) + compact_step_label.mouse_filter = Control.MOUSE_FILTER_PASS + _apply_font(compact_step_label) + compact_row.add_child(compact_step_label) + var compact_summary: Button = _make_compact_step_summary_button(step_index, actions) + compact_summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL + compact_row.add_child(compact_summary) + var compact_up: Button = _make_step_reorder_button(step_index, -1, true) + compact_row.add_child(compact_up) + var compact_down: Button = _make_step_reorder_button(step_index, 1, true) + compact_row.add_child(compact_down) + compact_row.visible = not is_selected + var expanded_content: VBoxContainer = VBoxContainer.new() + expanded_content.name = "ExpandedStepContent" + expanded_content.mouse_filter = Control.MOUSE_FILTER_PASS + expanded_content.add_theme_constant_override("separation", 5 if compact else 7) + expanded_content.visible = is_selected + content.add_child(expanded_content) + if actions.size() == 2 and is_selected: + var together: Label = Label.new() + together.text = "Together" + together.mouse_filter = Control.MOUSE_FILTER_PASS + together.add_theme_color_override("font_color", _stage_panel_color("action.primary_color", Color("#087DBA"))) + _apply_font(together) + header.add_child(together) + var up: Button = _make_step_reorder_button(step_index, -1) + header.add_child(up) + var down: Button = _make_step_reorder_button(step_index, 1) + header.add_child(down) + for raw_action: Variant in actions: + if not raw_action is Dictionary: + continue + var action: Dictionary = raw_action as Dictionary + var action_id: String = String(action.get("action_id", "")) + var row: HBoxContainer = HBoxContainer.new() + row.mouse_filter = Control.MOUSE_FILTER_PASS + row.add_theme_constant_override("separation", 6) + expanded_content.add_child(row) + var label: Button = _make_action_summary_button(action, action_id, step_index) + row.add_child(label) + var status: StringName = _status_for_action(action_id) + var edit_disabled: bool = status in [&"succeeded", &"failed", &"cancelled"] or (status == &"active" and not _paused_direct) + var options: VBoxContainer = VBoxContainer.new() + options.name = "ActionOptions_%s" % action_id + options.mouse_filter = Control.MOUSE_FILTER_PASS + options.add_theme_constant_override("separation", 6) + options.visible = action_id == _expanded_action_id + expanded_content.add_child(options) + if ACTION_REGISTRY.has_editable_parameters(String(action.get("type", ""))): + var edit: Button = _make_plan_button("Edit action", "Edit action", false) + edit.disabled = edit_disabled + edit.pressed.connect(func() -> void: plan_action_requested.emit(_character, action_id, String(action.get("type", "")), step_index, false)) + edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL + options.add_child(edit) + var replacement_types: Array[String] = _compatible_action_replacements(step_index, action_id, action) + if not replacement_types.is_empty(): + var change_action: Button = _make_plan_button("Change action", "Choose a different action", false) + change_action.disabled = edit_disabled + change_action.pressed.connect(_open_change_action_picker.bind(step_index, action_id, action, change_action)) + change_action.size_flags_horizontal = Control.SIZE_EXPAND_FILL + options.add_child(change_action) + var erase: Button = _make_plan_button("Erase action", "Erase action", false) + erase.disabled = not _paused_direct and status in [&"succeeded", &"failed", &"cancelled", &"active"] + erase.pressed.connect(func() -> void: plan_action_delete_requested.emit(_character, action_id)) + erase.size_flags_horizontal = Control.SIZE_EXPAND_FILL + options.add_child(erase) + if has_together_choice: + var together_row: HBoxContainer = HBoxContainer.new() + together_row.name = "AddTogetherRow" + together_row.mouse_filter = Control.MOUSE_FILTER_PASS + together_row.add_theme_constant_override("separation", 8) + content.add_child(together_row) + var spacer: Control = Control.new() + spacer.mouse_filter = Control.MOUSE_FILTER_PASS + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + together_row.add_child(spacer) + var together_button: Button = _make_plan_button("+ Add together", "Choose a compatible action", true) + together_button.pressed.connect(_open_together_picker.bind(step_index, together_button)) + together_row.add_child(together_button) + together_row.visible = is_selected + card.add_child(_make_step_focus_outline()) + return card + + +func _make_compact_step_summary_button(step_index: int, actions: Array) -> Button: + var display_action: Dictionary = {} + var display_action_id: String = "" + var summary_parts: Array[String] = [] + var includes_say: bool = false + for raw_action: Variant in actions: + if not raw_action is Dictionary: + continue + var action: Dictionary = raw_action as Dictionary + var action_type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) + if action_type == "speak": + includes_say = true + continue + if display_action.is_empty(): + display_action = action + display_action_id = String(action.get("action_id", "")) + summary_parts.append(_action_summary(action, step_index)) + if display_action.is_empty(): + for raw_action: Variant in actions: + if raw_action is Dictionary: + display_action = raw_action as Dictionary + display_action_id = String(display_action.get("action_id", "")) + break + if display_action.is_empty(): + var empty_summary: Button = Button.new() + empty_summary.name = "CompactStepSummary_%d" % (step_index + 1) + empty_summary.text = "Choose an action" + empty_summary.custom_minimum_size.y = 48.0 + empty_summary.disabled = true + return empty_summary + if actions.size() <= 1: + return _make_action_summary_button(display_action, display_action_id, step_index, true) + if includes_say: + summary_parts.append("Say") + var paired_summary: String = " + ".join(summary_parts) + return _make_action_summary_button(display_action, display_action_id, step_index, true, paired_summary, paired_summary) + + +func _make_step_reorder_button(step_index: int, direction: int, compact_row: bool = false) -> Button: + var moving_up: bool = direction < 0 + var direction_word: String = "up" if moving_up else "down" + var target_index: int = step_index + direction + var button: Button = _make_plan_button("", "Move Step %d %s" % [step_index + 1, direction_word], false) + var name_prefix: String = "CompactMoveStepEarlier_" if compact_row and moving_up else "CompactMoveStepLater_" if compact_row else "MoveStepEarlier_" if moving_up else "MoveStepLater_" + button.name = name_prefix + str(step_index + 1) + button.custom_minimum_size = Vector2(48.0, 48.0) + button.disabled = _reorder_animation_active or target_index < 0 or target_index >= _character.get_action_plan().size() or not _can_move_step(step_index) + _style_step_reorder_button(button) + var icon: StepReorderArrowIcon = StepReorderArrowIcon.new() + icon.name = "StepReorderArrowIcon" + icon.direction = -1 if moving_up else 1 + icon.enabled_color = _stage_panel_color("type.text_color", Color("#F5F7FA")) + icon.disabled_color = _stage_panel_color("type.muted_text_color", Color("#AAB2C0")) + icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + icon.offset_left = 12.0 + icon.offset_top = 12.0 + icon.offset_right = -12.0 + icon.offset_bottom = -12.0 + button.add_child(icon) + button.pressed.connect(_request_plan_step_move.bind(step_index, target_index)) + return button + + +func _style_step_reorder_button(button: Button) -> void: + _card.style_action_button(button) + var normal_surface: Color = _stage_panel_color("action.surface_color", Color("#171B24")) + var hover_surface: Color = _stage_panel_color("action.hover_surface", Color("#303A4B")) + var pressed_surface: Color = _stage_panel_color("action.selected_surface", Color("#12394A")) + var disabled_surface: Color = _stage_panel_color("choice.disabled_surface", Color("#252B38")) + var neutral_border: Color = _stage_panel_color("type.muted_text_color", Color("#AAB2C0")) + var focus_color: Color = _stage_panel_color("global.border_color", Color("#FFFFFF33")) + button.add_theme_stylebox_override("normal", _step_reorder_stylebox(normal_surface, neutral_border)) + button.add_theme_stylebox_override("hover", _step_reorder_stylebox(hover_surface, _stage_panel_color("type.text_color", Color("#F5F7FA")))) + button.add_theme_stylebox_override("pressed", _step_reorder_stylebox(pressed_surface, neutral_border)) + button.add_theme_stylebox_override("hover_pressed", _step_reorder_stylebox(pressed_surface.lightened(0.06), _stage_panel_color("type.text_color", Color("#F5F7FA")))) + button.add_theme_stylebox_override("disabled", _step_reorder_stylebox(disabled_surface, focus_color)) + + +func _step_reorder_stylebox(surface: Color, border: Color) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = surface + style.border_color = border + style.set_border_width_all(2) + style.set_corner_radius_all(8) + return style + + +func _request_plan_step_move(from_index: int, to_index: int) -> void: + if _reorder_animation_active or _character == null or not is_instance_valid(_character): + return + var plan: Array[Dictionary] = _character.get_action_plan() + if from_index < 0 or from_index >= plan.size() or to_index < 0 or to_index >= plan.size() or absi(to_index - from_index) != 1 or not _can_move_step(from_index): + return + var moving_action_id: String = _step_identity_action_id(plan[from_index]) + var neighbor_action_id: String = _step_identity_action_id(plan[to_index]) + if moving_action_id.is_empty() or neighbor_action_id.is_empty(): + return + var focus_owner: Control = get_viewport().gui_get_focus_owner() + var source_card: Control = _step_cards[from_index] if from_index < _step_cards.size() else null + var restore_focus: bool = source_card != null and focus_owner != null and (focus_owner == source_card or source_card.is_ancestor_of(focus_owner)) + _select_plan_step(from_index, false, false) + _reorder_animation_active = true + _pending_reorder_state = { + "rig": _character, + "moving_action_id": moving_action_id, + "neighbor_action_id": neighbor_action_id, + "old_positions": {}, + "scroll_vertical": _card.get_body_scroll_vertical(), + "restore_focus": restore_focus, + "request_emitted": false, + } + _refresh_reorder_button_states() + call_deferred("_capture_reorder_start_positions", from_index, to_index) + + +func _capture_reorder_start_positions(from_index: int, to_index: int) -> void: + await get_tree().process_frame + if not _reorder_animation_active or _pending_reorder_state.is_empty() or _character == null or not is_instance_valid(_character): + return + var plan: Array[Dictionary] = _character.get_action_plan() + if from_index < 0 or from_index >= plan.size() or to_index < 0 or to_index >= plan.size(): + cancel_pending_step_reorder() + return + if _step_identity_action_id(plan[from_index]) != String(_pending_reorder_state.get("moving_action_id", "")) or _step_identity_action_id(plan[to_index]) != String(_pending_reorder_state.get("neighbor_action_id", "")): + cancel_pending_step_reorder() + return + var old_positions: Dictionary = {} + for card: Control in _step_cards: + var action_id: String = String(card.get_meta(&"step_identity_action_id", "")) + if action_id in [String(_pending_reorder_state.get("moving_action_id", "")), String(_pending_reorder_state.get("neighbor_action_id", ""))]: + old_positions[action_id] = card.global_position + _pending_reorder_state["old_positions"] = old_positions + _pending_reorder_state["request_emitted"] = true + plan_step_move_requested.emit(_character, from_index, to_index) + + +func _animate_reordered_steps(state: Dictionary) -> void: + await get_tree().process_frame + if not _reorder_animation_active or _character == null or not is_instance_valid(_character) or state.get("rig") != _character: + cancel_pending_step_reorder() + return + _card.set_body_scroll_vertical(int(state.get("scroll_vertical", 0))) + await get_tree().process_frame + if not _reorder_animation_active: + return + var old_positions: Dictionary = state.get("old_positions", {}) + var moving_cards: Array[Control] = [] + var target_positions: Array[Vector2] = [] + for card: Control in _step_cards: + var action_id: String = String(card.get_meta(&"step_identity_action_id", "")) + if not old_positions.has(action_id): + continue + var target_position: Vector2 = card.position + var previous_global_position: Vector2 = old_positions[action_id] + var starting_position: Vector2 = _plan_list.get_global_transform().affine_inverse() * previous_global_position + card.position = starting_position + card.z_index = 2 if action_id == _selected_step_action_id else 1 + moving_cards.append(card) + target_positions.append(target_position) + if moving_cards.is_empty(): + _finish_step_reorder(state) + return + _reorder_tween = create_tween() + _reorder_tween.set_parallel(true) + _reorder_tween.set_trans(Tween.TRANS_CUBIC) + _reorder_tween.set_ease(Tween.EASE_OUT) + for card_index: int in moving_cards.size(): + _reorder_tween.tween_property(moving_cards[card_index], "position", target_positions[card_index], STEP_REORDER_ANIMATION_DURATION_SEC) + _reorder_tween.finished.connect(_finish_step_reorder.bind(state)) + + +func _finish_step_reorder(_state: Dictionary) -> void: + for card: Control in _step_cards: + card.z_index = 0 + _reorder_tween = null + _reorder_animation_active = false + _pending_reorder_state.clear() + _refresh_reorder_button_states() + if bool(_state.get("restore_focus", false)): + for card: Control in _step_cards: + if String(card.get_meta(&"step_identity_action_id", "")) == _selected_step_action_id: + card.grab_focus() + break + + +func cancel_pending_step_reorder() -> void: + if _reorder_tween != null and _reorder_tween.is_running(): + _reorder_tween.kill() + _reorder_tween = null + _reorder_animation_active = false + _pending_reorder_state.clear() + for card: Control in _step_cards: + card.z_index = 0 + _refresh_reorder_button_states() + + +func _refresh_reorder_button_states() -> void: + if _character == null or not is_instance_valid(_character): + return + var plan_size: int = _character.get_action_plan().size() + for step_index: int in _step_cards.size(): + var card: Control = _step_cards[step_index] + var up: Button = card.find_child("MoveStepEarlier_%d" % (step_index + 1), true, false) as Button + var down: Button = card.find_child("MoveStepLater_%d" % (step_index + 1), true, false) as Button + var compact_up: Button = card.find_child("CompactMoveStepEarlier_%d" % (step_index + 1), true, false) as Button + var compact_down: Button = card.find_child("CompactMoveStepLater_%d" % (step_index + 1), true, false) as Button + for up_button: Button in [up, compact_up]: + if up_button == null: + continue + up_button.disabled = _reorder_animation_active or step_index == 0 or not _can_move_step(step_index) + var up_icon: Control = up_button.get_node_or_null("StepReorderArrowIcon") as Control + if up_icon != null: + up_icon.queue_redraw() + for down_button: Button in [down, compact_down]: + if down_button == null: + continue + down_button.disabled = _reorder_animation_active or step_index >= plan_size - 1 or not _can_move_step(step_index) + var down_icon: Control = down_button.get_node_or_null("StepReorderArrowIcon") as Control + if down_icon != null: + down_icon.queue_redraw() + + +func _is_compact_plan_layout() -> bool: + var viewport_size: Vector2 = get_viewport_rect().size + return viewport_size.x <= 1100.0 or viewport_size.y <= 600.0 + + +func _make_plan_button(text: String, tooltip: String, primary: bool) -> Button: + var button: Button = Button.new() + button.text = text + button.tooltip_text = tooltip + button.mouse_filter = Control.MOUSE_FILTER_PASS + button.custom_minimum_size = Vector2(0.0, 44.0) + button.focus_mode = Control.FOCUS_ALL + if primary: + _card.style_primary_action_button(button) + else: + _card.style_action_button(button) + return button + + +func _make_action_summary_button(action: Dictionary, action_id: String, step_index: int, compact_row: bool = false, visible_summary_override: String = "", tooltip_override: String = "") -> Button: + var action_type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) + var accent: Color = _action_row_accent(action_type) + var surface: Color = _action_row_surface(action_type) + var row_key: String = "Step%d" % (step_index + 1) if compact_row else action_id + var button: Button = Button.new() + button.name = "CompactStepSummary_%d" % (step_index + 1) if compact_row else "ActionSummary_%s" % action_id + var full_summary: String = _action_summary(action, step_index) + if not tooltip_override.is_empty(): + full_summary = tooltip_override + var visible_summary: String = _action_row_summary(action, step_index) + if not visible_summary_override.is_empty(): + visible_summary = visible_summary_override + if compact_row and action_type == "pick_up_prop" and visible_summary.begins_with("Pick up "): + visible_summary = visible_summary.trim_prefix("Pick up ") + if compact_row and visible_summary.length() > COMPACT_SUMMARY_MAX_CHARS: + visible_summary = visible_summary.substr(0, COMPACT_SUMMARY_MAX_CHARS - 1) + "…" + button.text = visible_summary if compact_row else _action_button_accessible_label(action, step_index, full_summary) + button.tooltip_text = full_summary + button.clip_contents = true + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.custom_minimum_size.y = float(_director_plan_integer("action_row_height_px", 48, 44, 72)) + button.focus_mode = Control.FOCUS_ALL + if compact_row: + button.pressed.connect(_select_plan_step.bind(step_index, true)) + else: + button.pressed.connect(_toggle_action_options.bind(action_id, step_index)) + button.set_meta(&"action_row_accent", accent) + button.set_meta(&"action_row_surface", surface) + # Keep the native button label short enough to avoid inflating the inspector's + # minimum width; the child row supplies visible text and the tooltip keeps the + # complete action summary available on hover. + var hidden_text := Color(1.0, 1.0, 1.0, 0.0) + for state: String in ["font_color", "font_hover_color", "font_pressed_color", "font_hover_pressed_color", "font_focus_color"]: + button.add_theme_color_override(state, hidden_text) + _apply_action_summary_style(button, not compact_row and action_id == _expanded_action_id) + + var content: HBoxContainer = HBoxContainer.new() + content.name = "ActionSummaryContent" + content.mouse_filter = Control.MOUSE_FILTER_IGNORE + content.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + content.add_theme_constant_override("separation", 0) + button.add_child(content) + + var rail: Panel = Panel.new() + rail.name = "ActionAccentRail_%s" % row_key + rail.custom_minimum_size.x = float(_director_plan_integer("action_row_rail_width_px", 4, 2, 8)) + rail.size_flags_vertical = Control.SIZE_EXPAND_FILL + rail.mouse_filter = Control.MOUSE_FILTER_IGNORE + var rail_style := StyleBoxFlat.new() + rail_style.bg_color = accent + rail_style.set_corner_radius_all(2) + rail.add_theme_stylebox_override("panel", rail_style) + content.add_child(rail) + var summary_spacing: float = 6.0 if compact_row else 8.0 + _add_action_row_spacer(content, summary_spacing) + + var icon_size: int = _director_plan_integer("action_row_icon_size_px", 32, 24, 40) + if compact_row: + icon_size = mini(icon_size, 28) + var icon_tile: PanelContainer = PanelContainer.new() + icon_tile.name = "ActionIconTile" + icon_tile.custom_minimum_size = Vector2(icon_size, icon_size) + icon_tile.size_flags_vertical = Control.SIZE_SHRINK_CENTER + icon_tile.mouse_filter = Control.MOUSE_FILTER_IGNORE + var tile_style := StyleBoxFlat.new() + tile_style.bg_color = _director_plan_color("action_row_icon_surface", Color("#252B38")) + tile_style.set_corner_radius_all(8) + tile_style.content_margin_left = 0.0 + tile_style.content_margin_right = 0.0 + tile_style.content_margin_top = 0.0 + tile_style.content_margin_bottom = 0.0 + icon_tile.add_theme_stylebox_override("panel", tile_style) + var glyph_kind: String = _action_row_glyph_kind(action_type) + if not glyph_kind.is_empty(): + var glyph: Control = ACTION_SUMMARY_GLYPH.new() as Control + glyph.custom_minimum_size = Vector2(icon_size, icon_size) + glyph.call("configure", glyph_kind, accent) + icon_tile.add_child(glyph) + else: + var fallback_icon: Label = Label.new() + fallback_icon.name = "ActionTypeIcon" + fallback_icon.text = ACTION_REGISTRY.icon(action_type) + fallback_icon.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + fallback_icon.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + fallback_icon.add_theme_font_size_override("font_size", _director_plan_integer("action_row_icon_font_size_px", 20, 14, 32)) + fallback_icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + _apply_font(fallback_icon) + icon_tile.add_child(fallback_icon) + content.add_child(icon_tile) + _add_action_row_spacer(content, summary_spacing) + + var summary: Label = Label.new() + summary.name = "ActionSummaryText_%s" % row_key + summary.text = visible_summary + summary.clip_text = true + summary.size_flags_horizontal = Control.SIZE_EXPAND_FILL | Control.SIZE_SHRINK_END + summary.autowrap_mode = TextServer.AUTOWRAP_OFF + summary.max_lines_visible = 1 + summary.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT + summary.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + summary.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + var summary_font_size: int = _director_plan_integer("action_row_font_size_px", 18, 14, 28) + if compact_row: + summary_font_size = mini(summary_font_size, 16) + summary.add_theme_font_size_override("font_size", summary_font_size) + summary.add_theme_color_override("font_color", _director_plan_color("action_row_text_color", Color("#F5F7FA"))) + summary.mouse_filter = Control.MOUSE_FILTER_IGNORE + _apply_font(summary) + content.add_child(summary) + + var duration_text: String = _action_row_duration(action) + if not duration_text.is_empty(): + var duration: Label = Label.new() + duration.name = "ActionSummaryDuration_%s" % row_key + duration.text = duration_text + duration.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + duration.add_theme_font_size_override("font_size", _director_plan_integer("action_row_duration_font_size_px", 14, 12, 22)) + duration.add_theme_color_override("font_color", _director_plan_color("action_row_duration_color", Color("#AAB2C0"))) + duration.mouse_filter = Control.MOUSE_FILTER_IGNORE + _apply_font(duration) + content.add_child(duration) + if not compact_row: + _add_action_row_spacer(content, 8) + var options: Label = Label.new() + options.name = "ActionSummaryOptions_%s" % row_key + options.text = "..." + options.custom_minimum_size.x = float(_director_plan_integer("action_row_options_width_px", 24, 20, 40)) + options.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + options.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + options.add_theme_font_size_override("font_size", _director_plan_integer("action_row_options_font_size_px", 18, 14, 28)) + options.add_theme_color_override("font_color", _director_plan_color("action_row_options_color", Color("#8E9AAA"))) + options.mouse_filter = Control.MOUSE_FILTER_IGNORE + _apply_font(options) + content.add_child(options) + _add_action_row_spacer(content, 8) + return button + + +func _add_action_row_spacer(container: HBoxContainer, width: float) -> void: + var spacer: Control = Control.new() + spacer.custom_minimum_size.x = width + spacer.mouse_filter = Control.MOUSE_FILTER_IGNORE + container.add_child(spacer) + + +func _action_row_glyph_kind(action_type: String) -> String: + if action_type == ACTION_REGISTRY.MOVE_TO: + return "move" + if action_type == "speak": + return "speak" + if action_type == ACTION_REGISTRY.WAIT: + return "wait" + if action_type in ["ragdoll", "recover", "drop_prop"]: + return action_type + return "" + + +func _action_row_accent(action_type: String) -> Color: + if ACTION_REGISTRY.is_move_action({"type": action_type}): + return _director_plan_color("route_color", Color("#8BD9FF")) + if action_type == "speak": + return _director_plan_color("say_color", Color("#FFD84D")) + if action_type == ACTION_REGISTRY.WAIT: + return _director_plan_color("wait_color", Color("#B69CFF")) + return _director_plan_color("route_color", Color("#8BD9FF")) + + +func _action_row_surface(action_type: String) -> Color: + if ACTION_REGISTRY.is_move_action({"type": action_type}): + return _director_plan_color("action_row_move_surface", Color("#17313D")) + if action_type == "speak": + return _director_plan_color("action_row_say_surface", Color("#2C281C")) + if action_type == ACTION_REGISTRY.WAIT: + return _director_plan_color("action_row_wait_surface", Color("#29243A")) + return _director_plan_color("action_row_other_surface", Color("#202632")) + + +func _action_row_summary(action: Dictionary, step_index: int) -> String: + if ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) == "speak": + var params: Dictionary = action.get("params", {}) + var speech: String = String(action.get("text", params.get("text", ""))) + return "Say “%s”" % speech + return _action_summary(action, step_index) + + +func _action_button_accessible_label(action: Dictionary, step_index: int, full_summary: String) -> String: + if ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) != "speak": + return full_summary + var row_summary: String = _action_row_summary(action, step_index) + if row_summary.length() <= SAY_BUTTON_LABEL_MAX_CHARS: + return row_summary + return row_summary.substr(0, SAY_BUTTON_LABEL_MAX_CHARS - 1) + "…" + + +func _action_row_duration(action: Dictionary) -> String: + if ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) != "speak": + return "" + var params: Dictionary = action.get("params", {}) + var duration: float = float(action.get("duration", params.get("duration", 2.0))) + var value: String = str(snappedf(duration, 0.001)) + while value.ends_with("0"): + value = value.trim_suffix("0") + if value.ends_with("."): + value = value.trim_suffix(".") + return "%ss" % value + + +func _apply_action_summary_style(button: Button, selected: bool) -> void: + var surface: Color = button.get_meta(&"action_row_surface", Color("#17313D")) + var accent: Color = button.get_meta(&"action_row_accent", Color("#8BD9FF")) + var direct: Color = _stage_theme.color("mode_colors.direct", Color("#FFB300")) if _stage_theme != null else Color("#FFB300") + var radius: int = _director_plan_integer("action_row_radius_px", 8, 4, 20) + var border_width: int = _director_plan_integer("action_row_selected_border_width_px", 2, 1, 4) if selected else 0 + button.add_theme_stylebox_override("normal", _action_row_style(surface, direct if selected else Color(0, 0, 0, 0), border_width, radius)) + button.add_theme_stylebox_override("hover", _action_row_style(surface.lightened(0.10), direct if selected else Color(0, 0, 0, 0), border_width, radius)) + button.add_theme_stylebox_override("pressed", _action_row_style(surface.darkened(0.10), direct if selected else accent, 2 if selected else 1, radius)) + button.add_theme_stylebox_override("hover_pressed", _action_row_style(surface.lightened(0.04), direct if selected else accent, 2 if selected else 1, radius)) + button.add_theme_stylebox_override("focus", _action_row_style(surface, Color("#F5F7FA"), 2, radius)) + + +func _action_row_style(surface: Color, border: Color, border_width: int, radius: int) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = surface + style.border_color = border + style.set_border_width_all(border_width) + style.set_corner_radius_all(radius) + style.content_margin_left = 0.0 + style.content_margin_right = 0.0 + style.content_margin_top = 0.0 + style.content_margin_bottom = 0.0 + return style + + +func _style_expanded_action_summary(button: Button) -> void: + _apply_action_summary_style(button, true) + + +func _make_step_focus_outline() -> Panel: + var outline: Panel = Panel.new() + outline.name = "KeyboardFocusOutline" + outline.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + outline.offset_left = 4.0 + outline.offset_top = 4.0 + outline.offset_right = -4.0 + outline.offset_bottom = -4.0 + outline.mouse_filter = Control.MOUSE_FILTER_IGNORE + outline.visible = false + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = Color(0.0, 0.0, 0.0, 0.0) + style.border_color = Color("#F5F7FA") + style.set_border_width_all(1) + style.set_corner_radius_all(maxi(0, _stage_panel_integer("shell.corner_radius", 10, 0, 32) - 4)) + outline.add_theme_stylebox_override("panel", style) + return outline + + +func _on_step_card_focus_changed(card: Control, focused: bool) -> void: + _update_step_card_focus_outline(card, focused) + + +func _update_step_card_focus_outline(card: Control, focused: bool) -> void: + if card == null: + return + var outline: Control = card.get_node_or_null("KeyboardFocusOutline") as Control + if outline != null: + var card_index: int = _step_cards.find(card) + var selected: bool = card_index >= 0 and card_index == _selected_step_index + outline.visible = focused and not selected + + +func _on_step_card_gui_input(event: InputEvent, step_index: int) -> void: + if event is InputEventMouseButton: + var mouse_event: InputEventMouseButton = event as InputEventMouseButton + if mouse_event.button_index == MOUSE_BUTTON_LEFT and mouse_event.pressed: + _select_plan_step(step_index) + accept_event() + return + if event is InputEventKey: + var key_event: InputEventKey = event as InputEventKey + if key_event.pressed and not key_event.echo and key_event.keycode in [KEY_ENTER, KEY_KP_ENTER, KEY_SPACE]: + _select_plan_step(step_index, true) + accept_event() + + +func _open_together_picker(step_index: int, button: Button) -> void: + if _plan_popup == null or _character == null: + return + _select_plan_step(step_index) + var choice_types: Array[String] = [] + for action_type: String in ACTION_REGISTRY.types(): + if not _character.can_add_action_together(step_index, action_type): + continue + choice_types.append(action_type) + if choice_types.is_empty(): + _hide_plan_popup() + return + var current_plan: Array[Dictionary] = _character.get_action_plan() + var source_action: Dictionary = {} + if step_index >= 0 and step_index < current_plan.size(): + var source_actions: Variant = current_plan[step_index].get("actions", []) + if source_actions is Array and not (source_actions as Array).is_empty() and (source_actions as Array)[0] is Dictionary: + source_action = (source_actions as Array)[0] as Dictionary + var source_text: String = "With: %s" % _action_summary(source_action, step_index) if not source_action.is_empty() else "With this step" + _open_plan_action_picker(button, choice_types, "Add to Step %d" % (step_index + 1), source_text, false, step_index) + + +func _open_add_step_picker(button: Button) -> void: + if _plan_popup == null or _character == null or button == null: + return + _open_plan_action_picker(button, ACTION_REGISTRY.types(), "Choose an action", "", true, -1) + + +func _compatible_action_replacements(step_index: int, action_id: String, action: Dictionary) -> Array[String]: + var choices: Array[String] = [] + if _character == null or not is_instance_valid(_character): + return choices + var plan: Array[Dictionary] = _character.get_action_plan() + if step_index < 0 or step_index >= plan.size(): + return choices + var current_type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) + var companion_actions: Array[Dictionary] = [] + var raw_actions: Variant = plan[step_index].get("actions", []) + if raw_actions is Array: + for raw_action: Variant in raw_actions as Array: + if not raw_action is Dictionary: + continue + var candidate: Dictionary = raw_action as Dictionary + if String(candidate.get("action_id", "")) == action_id: + continue + companion_actions.append(candidate) + for candidate_type: String in ACTION_REGISTRY.types(): + var normalized_type: String = ACTION_REGISTRY.canonical_type(candidate_type) + if normalized_type == current_type: + continue + if companion_actions.is_empty() or ACTION_REGISTRY.can_add_authored_action_together(companion_actions, normalized_type): + choices.append(normalized_type) + return choices + + +func _open_change_action_picker(step_index: int, action_id: String, action: Dictionary, button: Button) -> void: + if button == null: + return + var choice_types: Array[String] = _compatible_action_replacements(step_index, action_id, action) + if choice_types.is_empty(): + _hide_plan_popup() + return + var source_text: String = "Replace: %s" % _action_summary(action, step_index) + _open_plan_action_picker(button, choice_types, "Change action", source_text, false, step_index, action_id) + + +func _open_plan_action_picker(button: Button, choice_types: Array[String], title: String, source_text: String, add_step: bool, step_index: int, replace_action_id: String = "") -> void: + if _plan_popup == null or _character == null or button == null: + return + if _plan_popup_tween != null and _plan_popup_tween.is_running(): + _plan_popup_tween.kill() + _plan_popup_closing = false + _plan_popup_origin_button = button + _plan_popup_add_step = add_step + _plan_popup_uses_drawer = true + _plan_popup_step = step_index + _plan_popup_replace_action_id = replace_action_id + _plan_popup_choice_types.clear() + for child: Node in _plan_popup_choices.get_children(): + _plan_popup_choices.remove_child(child) + child.queue_free() + for action_type: String in choice_types: + if not ACTION_REGISTRY.has_type(action_type): + continue + _plan_popup_choice_types.append(action_type) + var choice: Button = Button.new() + choice.name = "PlanActionChoice_%s" % action_type + var action_label: String = ACTION_REGISTRY.label(action_type) + if action_type == ACTION_REGISTRY.MOVE_TO: + action_label = "Move" + elif action_type == "speak": + action_label = "Say" + choice.text = "%s %s" % [ACTION_REGISTRY.icon(action_type), action_label] + choice.tooltip_text = choice.text + choice.custom_minimum_size = Vector2(200.0, 48.0) + choice.size_flags_horizontal = Control.SIZE_EXPAND_FILL + choice.focus_mode = Control.FOCUS_ALL + choice.pressed.connect(_on_plan_action_choice_pressed.bind(action_type)) + _card.style_action_button(choice) + _plan_popup_choices.add_child(choice) + if _plan_popup_choice_types.is_empty(): + _hide_plan_popup() + return + _plan_popup_title.text = title + _plan_popup_source.text = source_text + _plan_popup_source.tooltip_text = source_text + _plan_popup_source.visible = not source_text.is_empty() + _plan_popup_choices.visible = true + var cancel_button: Button = _plan_popup.find_child("CancelTogether", true, false) as Button + if cancel_button != null: + cancel_button.visible = true + # The scroll list is capped to the safe viewport while Cancel stays pinned below it. + var viewport_rect: Rect2 = get_viewport_rect() + var parent_to_viewport: Transform2D = get_global_transform_with_canvas() + var viewport_to_parent: Transform2D = parent_to_viewport.affine_inverse() + var viewport_local_top_left: Vector2 = viewport_to_parent * viewport_rect.position + var viewport_local_bottom_right: Vector2 = viewport_to_parent * viewport_rect.end + var viewport_local_rect: Rect2 = Rect2(viewport_local_top_left, viewport_local_bottom_right - viewport_local_top_left) + var desired_height: float = _plan_action_picker_desired_height() + var safe_top: float = viewport_local_rect.position.y + 72.0 + var safe_bottom: float = maxf(safe_top + 1.0, viewport_local_rect.end.y - 50.0) + var popup_size: Vector2 + if _plan_popup_uses_drawer: + var drawer_width: float = minf(256.0, maxf(180.0, viewport_local_rect.size.x - 16.0)) + var card_local_rect: Rect2 = _control_rect_in_parent(_card, viewport_to_parent) + popup_size = Vector2(drawer_width, minf(maxf(card_local_rect.size.y, desired_height), safe_bottom - safe_top)) + else: + popup_size = Vector2(minf(256.0, maxf(200.0, viewport_local_rect.size.x - 16.0)), minf(desired_height, safe_bottom - safe_top)) + _plan_popup.size = popup_size + _plan_popup.custom_minimum_size = popup_size + if _plan_popup_uses_drawer: + _plan_popup_open_position = _layout_add_step_drawer(viewport_local_rect, safe_top, safe_bottom) + _plan_popup.position = _plan_popup_open_position + Vector2(24.0, 0.0) + _plan_popup.modulate = Color(1.0, 1.0, 1.0, 0.0) + _layout_add_step_drawer_dimmer(viewport_to_parent) + _plan_popup_dimmer.modulate = Color(1.0, 1.0, 1.0, 0.0) + _plan_popup_dimmer.visible = true + _plan_popup.visible = true + _plan_popup_tween = create_tween() + _plan_popup_tween.set_parallel(true) + _plan_popup_tween.tween_property(_plan_popup, "position", _plan_popup_open_position, ADD_STEP_DRAWER_ANIMATION_DURATION_SEC).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) + _plan_popup_tween.tween_property(_plan_popup, "modulate:a", 1.0, ADD_STEP_DRAWER_ANIMATION_DURATION_SEC).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) + _plan_popup_tween.tween_property(_plan_popup_dimmer, "modulate:a", 1.0, ADD_STEP_DRAWER_ANIMATION_DURATION_SEC).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT) + else: + var button_local_rect: Rect2 = _control_rect_in_parent(button, viewport_to_parent) + var popup_x: float = button_local_rect.position.x - popup_size.x - 8.0 + if popup_x < 8.0: + popup_x = button_local_rect.end.x + 8.0 + if popup_x + popup_size.x > viewport_local_rect.end.x - 8.0: + popup_x = button_local_rect.get_center().x - popup_size.x * 0.5 + popup_x = clampf(popup_x, viewport_local_rect.position.x + 8.0, maxf(viewport_local_rect.position.x + 8.0, viewport_local_rect.end.x - popup_size.x - 8.0)) + var popup_pos: Vector2 = Vector2(popup_x, button_local_rect.get_center().y - popup_size.y * 0.45) + popup_pos.y = clampf(popup_pos.y, safe_top, maxf(safe_top, safe_bottom - popup_size.y)) + _plan_popup.position = popup_pos + _plan_popup.modulate = Color.WHITE + _plan_popup.visible = true + var first_choice: Button = _plan_popup_choices.get_child(0) as Button + first_choice.call_deferred("grab_focus") + + +func _on_plan_action_choice_pressed(action_type: String) -> void: + if _character == null: + return + var rig: StickmanRig = _character + var add_step: bool = _plan_popup_add_step + var step_index: int = _plan_popup_step + var replace_action_id: String = _plan_popup_replace_action_id + if _plan_popup_uses_drawer: + _plan_popup_restore_focus_on_close = false + _hide_plan_popup(false) + var closing_tween: Tween = _plan_popup_tween + if closing_tween != null and closing_tween.is_running(): + await closing_tween.finished + else: + _hide_plan_popup(false) + if rig != null and is_instance_valid(rig): + plan_action_requested.emit(rig, replace_action_id, action_type, -1 if add_step else step_index, not add_step and replace_action_id.is_empty()) + + +func _hide_plan_popup(restore_focus: bool = true) -> void: + if _plan_popup == null: + return + if not _plan_popup.visible: + _plan_popup_step = -1 + _plan_popup_add_step = false + _plan_popup_replace_action_id = "" + _plan_popup_uses_drawer = false + _plan_popup_origin_button = null + return + if _plan_popup_uses_drawer: + if _plan_popup_closing: + return + _plan_popup_closing = true + _plan_popup_restore_focus_on_close = restore_focus + if _plan_popup_tween != null and _plan_popup_tween.is_running(): + _plan_popup_tween.kill() + var closed_position: Vector2 = _plan_popup_open_position + Vector2(24.0, 0.0) + _plan_popup_tween = create_tween() + _plan_popup_tween.set_parallel(true) + _plan_popup_tween.tween_property(_plan_popup, "position", closed_position, ADD_STEP_DRAWER_ANIMATION_DURATION_SEC).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT) + _plan_popup_tween.tween_property(_plan_popup, "modulate:a", 0.0, ADD_STEP_DRAWER_ANIMATION_DURATION_SEC).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT) + _plan_popup_tween.tween_property(_plan_popup_dimmer, "modulate:a", 0.0, ADD_STEP_DRAWER_ANIMATION_DURATION_SEC).set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT) + _plan_popup_tween.finished.connect(_finish_add_step_drawer_close) + return + _plan_popup_step = -1 + _plan_popup_add_step = false + _plan_popup_replace_action_id = "" + _plan_popup_uses_drawer = false + _plan_popup.hide() + if _plan_popup_dimmer != null: + _plan_popup_dimmer.hide() + _plan_popup_origin_button = null + + +func _finish_add_step_drawer_close() -> void: + if not _plan_popup_closing: + return + _plan_popup.hide() + _plan_popup.modulate = Color.WHITE + if _plan_popup_dimmer != null: + _plan_popup_dimmer.hide() + _plan_popup_dimmer.modulate = Color.WHITE + _plan_popup_step = -1 + _plan_popup_add_step = false + _plan_popup_replace_action_id = "" + _plan_popup_uses_drawer = false + _plan_popup_closing = false + var should_restore_focus: bool = _plan_popup_restore_focus_on_close + _plan_popup_restore_focus_on_close = false + var focus_target: Control = _plan_popup_origin_button + _plan_popup_origin_button = null + if should_restore_focus and focus_target != null and is_instance_valid(focus_target) and focus_target.visible and _card.visible: + focus_target.call_deferred("grab_focus") + + +func _dismiss_plan_popup_immediately() -> void: + if _plan_popup_tween != null and _plan_popup_tween.is_running(): + _plan_popup_tween.kill() + if _plan_popup != null: + _plan_popup.hide() + _plan_popup.modulate = Color.WHITE + if _plan_popup_dimmer != null: + _plan_popup_dimmer.hide() + _plan_popup_dimmer.modulate = Color.WHITE + _plan_popup_step = -1 + _plan_popup_add_step = false + _plan_popup_replace_action_id = "" + _plan_popup_uses_drawer = false + _plan_popup_closing = false + _plan_popup_restore_focus_on_close = false + _plan_popup_origin_button = null + + +func _layout_add_step_drawer(viewport_local_rect: Rect2, safe_top: float, safe_bottom: float) -> Vector2: + var viewport_transform: Transform2D = get_global_transform_with_canvas() + var viewport_to_parent: Transform2D = viewport_transform.affine_inverse() + var card_local_rect: Rect2 = _control_rect_in_parent(_card, viewport_to_parent) + var width: float = _plan_popup.size.x + var requested_height: float = maxf(card_local_rect.size.y, _plan_action_picker_desired_height()) + var safe_height: float = maxf(1.0, safe_bottom - safe_top) + var top: float = clampf(card_local_rect.position.y, safe_top, maxf(safe_top, safe_bottom - minf(180.0, safe_height))) + var height: float = minf(requested_height, maxf(1.0, safe_bottom - top)) + _plan_popup.custom_minimum_size = Vector2(width, height) + _plan_popup.size = _plan_popup.custom_minimum_size + var left: float = card_local_rect.position.x - width - 8.0 + left = clampf(left, viewport_local_rect.position.x + 8.0, maxf(viewport_local_rect.position.x + 8.0, viewport_local_rect.end.x - width - 8.0)) + return Vector2(left, top) + + +func _plan_action_picker_desired_height() -> float: + var choice_count: int = _plan_popup_choices.get_child_count() if _plan_popup_choices != null else 0 + return 180.0 + float(choice_count) * 48.0 + float(maxi(0, choice_count - 1)) * 6.0 + + +func _layout_add_step_drawer_dimmer(viewport_to_parent: Transform2D) -> void: + var card_local_rect: Rect2 = _control_rect_in_parent(_card, viewport_to_parent) + _plan_popup_dimmer.position = card_local_rect.position + _plan_popup_dimmer.size = card_local_rect.size + _plan_popup_dimmer.custom_minimum_size = card_local_rect.size + + +func _control_rect_in_parent(control: Control, viewport_to_parent: Transform2D) -> Rect2: + var control_rect: Rect2 = control.get_global_rect() + var top_left: Vector2 = viewport_to_parent * control_rect.position + var bottom_right: Vector2 = viewport_to_parent * control_rect.end + return Rect2(top_left, bottom_right - top_left) + + +func _has_together_choice(step_index: int) -> bool: + if _character == null or not is_instance_valid(_character): + return false + for action_type: String in ACTION_REGISTRY.types(): + if _character.can_add_action_together(step_index, action_type): + return true + return false + + +func _select_plan_step(step_index: int, focus_card: bool = false, keep_screen_position: bool = true) -> void: + if _character == null or not is_instance_valid(_character): + return + var plan: Array[Dictionary] = _character.get_action_plan() + if step_index < 0 or step_index >= plan.size(): + return + var changed: bool = step_index != _selected_step_index + var previous_screen_y: float = _step_cards[step_index].global_position.y if step_index < _step_cards.size() else -1.0 + if changed: + if not _expanded_action_id.is_empty(): + var previous_id: String = _expanded_action_id + var previous_options: Control = find_child("ActionOptions_%s" % previous_id, true, false) as Control + if previous_options != null: + previous_options.visible = false + var previous_summary: Button = find_child("ActionSummary_%s" % previous_id, true, false) as Button + if previous_summary != null: + _apply_action_summary_style(previous_summary, false) + _expanded_action_id = "" + _hide_plan_popup() + _selected_step_index = step_index + _selected_step_action_id = _step_identity_action_id(plan[step_index]) + _update_step_card_selection() + if _card != null: + _card.request_fit() + plan_step_focus_changed.emit(_character, _selected_step_index) + else: + _selected_step_action_id = _step_identity_action_id(plan[step_index]) + if focus_card: + if step_index < _step_cards.size(): + _step_cards[step_index].grab_focus() + if changed and keep_screen_position and previous_screen_y >= 0.0: + call_deferred("_restore_step_card_screen_position", step_index, previous_screen_y) + + +func _update_step_card_selection() -> void: + for step_index: int in _step_cards.size(): + var card: Control = _step_cards[step_index] + var selected: bool = step_index == _selected_step_index + var compact_row: Control = card.find_child("CompactStepRow", true, false) as Control + var expanded_header: Control = card.find_child("ExpandedStepHeader", true, false) as Control + var expanded_content: Control = card.find_child("ExpandedStepContent", true, false) as Control + var together_row: Control = card.find_child("AddTogetherRow", true, false) as Control + if compact_row != null: + compact_row.visible = not selected + if expanded_header != null: + expanded_header.visible = selected + if expanded_content != null: + expanded_content.visible = selected + if together_row != null: + together_row.visible = selected + var card_style: StyleBoxFlat = card.get_theme_stylebox("panel") as StyleBoxFlat + if card_style != null: + card_style.border_color = _stage_theme.color("mode_colors.direct", Color("#FFB300")) if selected and _stage_theme != null else _stage_panel_color("global.border_color", Color("#FFFFFF33")) + card_style.set_border_width_all(3 if selected else 1) + _update_step_card_focus_outline(card, card.has_focus()) + + +func _focus_plan_step(step_index: int) -> void: + if step_index < 0 or step_index >= _step_cards.size(): + return + _step_cards[step_index].grab_focus() + call_deferred("_restore_step_card_screen_position", step_index, _step_cards[step_index].global_position.y) + + +func _restore_step_card_screen_position(step_index: int, preferred_screen_y: float) -> void: + await get_tree().process_frame + if _card == null or step_index < 0 or step_index >= _step_cards.size(): + return + var scroll: ScrollContainer = _card.find_child("InspectorBodyScroll", true, false) as ScrollContainer + if scroll == null: + return + var step_rect: Rect2 = _step_cards[step_index].get_global_rect() + if preferred_screen_y >= 0.0: + var displacement: int = roundi(step_rect.position.y - preferred_screen_y) + if displacement != 0: + _card.set_body_scroll_vertical(_card.get_body_scroll_vertical() + displacement) + await get_tree().process_frame + if step_index < 0 or step_index >= _step_cards.size(): + return + step_rect = _step_cards[step_index].get_global_rect() + var scroll_rect: Rect2 = scroll.get_global_rect() + var correction: float = 0.0 + if step_rect.position.y < scroll_rect.position.y: + correction = step_rect.position.y - scroll_rect.position.y + elif step_rect.end.y > scroll_rect.end.y: + correction = step_rect.end.y - scroll_rect.end.y + if not is_zero_approx(correction): + _card.set_body_scroll_vertical(_card.get_body_scroll_vertical() + roundi(correction)) + + +func _toggle_action_options(action_id: String, step_index: int) -> void: + _expanded_action_id = "" if _expanded_action_id == action_id else action_id + _selected_step_index = step_index + _selected_step_action_id = action_id + _refresh_plan() + + +func _action_summary(action: Dictionary, step_index: int) -> String: + var action_type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) + if ACTION_REGISTRY.is_move_action(action): + var destination_value: Variant = action.get("destination", {}) + var destination_label: String = "Choose destination" + if destination_value is Dictionary: + var destination: Dictionary = destination_value + if String(destination.get("kind", "")) == "place": + destination_label = "Place %d" % (step_index + 1) + elif String(destination.get("kind", "")) == "prop": + var prop_id: String = String(destination.get("target_prop_id", "")) + destination_label = String(_plan_target_label_resolver.call(prop_id)) if _plan_target_label_resolver.is_valid() else "Prop" + return "%s to %s" % ["Run" if String(action.get("style", "walk")) == ACTION_REGISTRY.RUN else "Walk", destination_label] + if ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) == ACTION_REGISTRY.WAIT: + var params: Dictionary = action.get("params", {}) + var duration: float = float(action.get("duration", params.get("duration", 0.0))) + var duration_label: String = str(snappedf(duration, 0.001)) + while duration_label.ends_with("0"): + duration_label = duration_label.trim_suffix("0") + if duration_label.ends_with("."): + duration_label = duration_label.trim_suffix(".") + var unit_label: String = "second" if is_equal_approx(duration, 1.0) else "seconds" + return "Wait %s %s" % [duration_label, unit_label] + if action_type == "pick_up_prop": + var pick_params: Dictionary = action.get("params", {}) + var pickup_prop_id: String = String(action.get("target_prop_id", pick_params.get("target_prop_id", ""))) + var pickup_name: String = _resolved_prop_name(pickup_prop_id) + return "Pick up %s" % pickup_name if not pickup_name.is_empty() else "Pick up prop" + if action_type == "use_prop": + var use_params: Dictionary = action.get("params", {}) + var use_prop_id: String = String(action.get("target_prop_id", use_params.get("target_prop_id", ""))) + var use_name: String = _resolved_prop_name(use_prop_id) + return "Use %s" % use_name if not use_name.is_empty() else "Use prop" + if action_type == "set_down_prop": + return "Set down prop" + if action_type == "throw_prop": + return "Throw prop" + if action_type == "drop_prop": + return "Drop prop" + var summary: String = ACTION_REGISTRY.summarize(action, _plan_target_label_resolver) + if action_type == "speak": + if summary.begins_with("Speak "): + summary = "Say %s" % summary.substr(6) + var seconds_unit_index: int = summary.rfind("s)") + if seconds_unit_index >= 0: + summary = summary.substr(0, seconds_unit_index) + " seconds)" + summary.substr(seconds_unit_index + 2) + return summary + return summary + + +func _resolved_prop_name(prop_id: String) -> String: + if prop_id.is_empty() or not _plan_target_label_resolver.is_valid(): + return "" + return String(_plan_target_label_resolver.call(prop_id)).strip_edges() + + +func _status_for_action(action_id: String) -> StringName: + if _character == null: + return &"not_started" + for entry: Dictionary in _character.get_plan_view(): + if String(entry.get("action_id", "")) == action_id: + return StringName(String(entry.get("status", "not_started"))) + return &"not_started" + + +func _can_move_step(step_index: int) -> bool: + if _character == null: + return false + return not _paused_direct or _character.can_reorder_plan_step(step_index) + + func _on_name_edit_input(event: InputEvent) -> void: if event is InputEventKey and (event as InputEventKey).pressed and (event as InputEventKey).keycode == KEY_ESCAPE: _cancel_editing() @@ -202,11 +1745,37 @@ func _on_name_edit_input(event: InputEvent) -> void: func _on_viewport_resized() -> void: + var compact_layout: bool = _is_compact_plan_layout() + if compact_layout and not _compact_layout_active and _direct_authoring: + _pending_selected_step_reveal = true + _compact_layout_active = compact_layout var viewport: Rect2 = get_viewport_rect() var top_offset: float = minf(64.0 + 120.0, maxf(12.0, viewport.size.y - 220.0)) var bottom_limit: float = maxf(top_offset + 72.0, viewport.size.y - 38.0 - 12.0) var inset: float = float(_stage_panel_integer("shell.edge_inset", 12, 0, 48)) update_layout(Rect2(Vector2(0.0, top_offset - inset), Vector2(viewport.size.x, bottom_limit - top_offset + inset * 2.0))) + _sync_plan_header_layout() + if _plan_popup != null and _plan_popup.visible and _plan_popup_uses_drawer and not _plan_popup_closing: + _relayout_open_add_step_drawer() + if _direct_authoring and _character != null and is_instance_valid(_character): + _refresh_plan() + + +func _relayout_open_add_step_drawer() -> void: + var viewport_rect: Rect2 = get_viewport_rect() + var viewport_to_parent: Transform2D = get_global_transform_with_canvas().affine_inverse() + var viewport_local_top_left: Vector2 = viewport_to_parent * viewport_rect.position + var viewport_local_bottom_right: Vector2 = viewport_to_parent * viewport_rect.end + var viewport_local_rect: Rect2 = Rect2(viewport_local_top_left, viewport_local_bottom_right - viewport_local_top_left) + var safe_top: float = viewport_local_rect.position.y + 72.0 + var safe_bottom: float = maxf(safe_top + 1.0, viewport_local_rect.end.y - 50.0) + if _plan_popup_tween != null and _plan_popup_tween.is_running(): + _plan_popup_tween.kill() + _plan_popup_open_position = _layout_add_step_drawer(viewport_local_rect, safe_top, safe_bottom) + _plan_popup.position = _plan_popup_open_position + _plan_popup.modulate = Color.WHITE + _layout_add_step_drawer_dimmer(viewport_to_parent) + _plan_popup_dimmer.modulate = Color.WHITE func _stage_panel_integer(key: String, fallback: int, minimum: int, maximum: int) -> int: @@ -217,6 +1786,14 @@ func _stage_panel_color(key: String, fallback: Color) -> Color: return fallback if _stage_theme == null else _stage_theme.color("stage_panels." + key, fallback) +func _director_plan_integer(key: String, fallback: int, minimum: int, maximum: int) -> int: + return fallback if _stage_theme == null else _stage_theme.integer("overlays.director_plan." + key, fallback, minimum, maximum) + + +func _director_plan_color(key: String, fallback: Color) -> Color: + return fallback if _stage_theme == null else _stage_theme.color("overlays.director_plan." + key, fallback) + + func _apply_font(control: Control) -> void: if _stage_theme == null: return diff --git a/scripts/editors/stage/stage_chrome.gd b/scripts/editors/stage/stage_chrome.gd index efc8d23..3b222cf 100644 --- a/scripts/editors/stage/stage_chrome.gd +++ b/scripts/editors/stage/stage_chrome.gd @@ -27,7 +27,7 @@ signal reset_requested const TOP_BAR_HEIGHT: float = 64.0 const STATUS_BAR_HEIGHT: float = 38.0 -const COMPACT_TOP_BAR_WIDTH: float = 900.0 +const COMPACT_TOP_BAR_WIDTH: float = 1100.0 var _stage_theme: SandboxTheme var _current_mode: int = 0 @@ -39,6 +39,7 @@ var _accent_direct: Color = Color("#FFB300") var _accent_play: Color = Color("#33DD77") var _status_pill_font_size: int = 16 var _tooltip_font_size: int = 18 +var _direct_hint_requested: bool = false var _mode_buttons: Dictionary = {} var _palette_buttons: Dictionary = {} @@ -58,6 +59,7 @@ var _pause_button: Button var _resume_button: Button var _reset_button: Button var _tooltip: Label +var _action_tooltip_avoid_rect: Rect2 = Rect2() var _grid_check: CheckBox var _snap_check: CheckBox var _grid_size_spin: SpinBox @@ -293,21 +295,76 @@ func set_action_tooltip(visible: bool, text_value: String = "") -> void: _tooltip.visible = visible if not visible: return - _tooltip.text = text_value + _tooltip.text = _capture_tooltip_text(text_value) var style: StyleBox = _tooltip.get_theme_stylebox("normal") var extra: Vector2 = style.get_minimum_size() if style != null else Vector2.ZERO - _tooltip.size = _tooltip.get_minimum_size() + extra + var required_size: Vector2 = _tooltip.get_minimum_size() + extra + var tooltip_width: float = clampf(required_size.x, 320.0, 420.0) + _tooltip.size = Vector2(tooltip_width, maxf(required_size.y, 50.0)) var mouse: Vector2 = get_viewport().get_mouse_position() var size: Vector2 = _tooltip.size - var position: Vector2 = mouse + Vector2(20.0, -20.0) var viewport_size: Vector2 = get_viewport().get_visible_rect().size - if position.x + size.x > viewport_size.x: - position.x = mouse.x - size.x - 20.0 - if position.y + size.y > viewport_size.y: - position.y = mouse.y - size.y - 20.0 - if position.y < 0.0: - position.y = mouse.y + 20.0 - _tooltip.position = position + var avoid_rects: Array[Rect2] = [] + if _action_tooltip_avoid_rect.has_area(): + avoid_rects.append(_action_tooltip_avoid_rect) + if _mode_badge != null and _mode_badge.visible: + avoid_rects.append(_mode_badge.get_global_rect().grow(6.0)) + if _timestamp_badge != null and _timestamp_badge.visible: + avoid_rects.append(_timestamp_badge.get_global_rect().grow(6.0)) + if _palette_dock != null and _palette_dock.visible: + avoid_rects.append(_palette_dock.get_global_rect().grow(6.0)) + _tooltip.position = _find_action_tooltip_position(mouse, size, viewport_size, avoid_rects) + + +func _find_action_tooltip_position(mouse: Vector2, size: Vector2, viewport_size: Vector2, avoid_rects: Array[Rect2]) -> Vector2: + var min_x: float = 8.0 + var max_x: float = maxf(min_x, viewport_size.x - size.x - 8.0) + var min_y: float = TOP_BAR_HEIGHT + 8.0 + var max_y: float = maxf(min_y, viewport_size.y - size.y - 8.0) + var candidates: Array[Vector2] = [ + mouse + Vector2(20.0, -20.0), + mouse + Vector2(-size.x - 20.0, -20.0), + mouse + Vector2(20.0, 20.0), + mouse + Vector2(-size.x - 20.0, 20.0), + ] + for avoid_rect: Rect2 in avoid_rects: + candidates.append(Vector2(avoid_rect.position.x - size.x - 12.0, avoid_rect.get_center().y - size.y * 0.5)) + candidates.append(Vector2(avoid_rect.end.x + 12.0, avoid_rect.get_center().y - size.y * 0.5)) + candidates.append(Vector2(avoid_rect.get_center().x - size.x * 0.5, avoid_rect.position.y - size.y - 12.0)) + candidates.append(Vector2(avoid_rect.get_center().x - size.x * 0.5, avoid_rect.end.y + 12.0)) + candidates.append(Vector2(min_x, min_y)) + candidates.append(Vector2(max_x, min_y)) + candidates.append(Vector2(min_x, max_y)) + candidates.append(Vector2(max_x, max_y)) + for candidate: Vector2 in candidates: + var position: Vector2 = Vector2(clampf(candidate.x, min_x, max_x), clampf(candidate.y, min_y, max_y)) + var tooltip_rect: Rect2 = Rect2(position, size) + var overlaps: bool = false + for avoid_rect: Rect2 in avoid_rects: + if tooltip_rect.intersects(avoid_rect): + overlaps = true + break + if not overlaps: + return position + return Vector2(min_x, min_y) + + +func set_action_tooltip_avoid_rect(rect: Rect2) -> void: + _action_tooltip_avoid_rect = rect + + +func _capture_tooltip_text(text_value: String) -> String: + var task: String = text_value.strip_edges() + for old_copy: String in ["(Esc or RMB to cancel)", "(Esc or right-click to cancel)", "Esc cancels."]: + task = task.replace(old_copy, "").strip_edges() + while task.ends_with("\n"): + task = task.substr(0, task.length() - 1).strip_edges() + var cancel_hint: String = "Press Escape or right-click to cancel" + if task.is_empty(): + return cancel_hint + if task.contains(cancel_hint): + return task + return "%s\n%s" % [task, cancel_hint] func set_edit_controls_visible(visible: bool) -> void: @@ -326,8 +383,9 @@ func set_edit_controls_visible(visible: bool) -> void: func set_direct_controls_visible(visible: bool) -> void: + _direct_hint_requested = visible if _direct_hint_label != null: - _direct_hint_label.visible = visible + _direct_hint_label.visible = visible and get_viewport().get_visible_rect().size.x > COMPACT_TOP_BAR_WIDTH func is_stage_frame_position(viewport_pos: Vector2) -> bool: @@ -506,6 +564,8 @@ func _apply_responsive_top_bar_layout() -> void: return var viewport_width: float = get_viewport().get_visible_rect().size.x var compact: bool = viewport_width <= COMPACT_TOP_BAR_WIDTH + if _direct_hint_label != null: + _direct_hint_label.visible = _direct_hint_requested and not compact if compact: _file_menu.custom_minimum_size = Vector2(64.0, 48.0) _file_menu.add_theme_font_size_override("font_size", 18) @@ -656,7 +716,7 @@ func _build_timestamp_badge() -> void: _timestamp_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE _timestamp_badge.offset_left = -220.0 _timestamp_badge.offset_top = TOP_BAR_HEIGHT + 62.0 - _timestamp_badge.offset_right = -12.0 + _timestamp_badge.offset_right = -_theme_number("stage_panels.shell.edge_inset", 12.0, 0.0, 48.0) _timestamp_badge.offset_bottom = TOP_BAR_HEIGHT + 106.0 var style := StyleBoxFlat.new() style.bg_color = _theme_color("chrome.timestamp_badge.bg_color", Color("#171B24F2")) @@ -683,6 +743,8 @@ func _build_action_tooltip() -> void: _tooltip.name = "ActionTooltip" _tooltip.visible = false _tooltip.mouse_filter = Control.MOUSE_FILTER_IGNORE + _tooltip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _tooltip.max_lines_visible = 3 var style := StyleBoxFlat.new() style.bg_color = Color(0.1, 0.1, 0.1, 0.9) style.border_color = _accent_direct diff --git a/scripts/editors/stage/stage_director_controller.gd b/scripts/editors/stage/stage_director_controller.gd index dd11b23..72c39bf 100644 --- a/scripts/editors/stage/stage_director_controller.gd +++ b/scripts/editors/stage/stage_director_controller.gd @@ -75,7 +75,7 @@ func build_ui(parent: Node, sandbox_theme: SandboxTheme, ui_font: Font, emoji_fo _action_popup.add_separator() _action_popup.add_item("⚡ When...", int(action_ids.get("when", 5))) _action_popup.add_separator() - _action_popup.add_item("📋 Edit Queue…", int(action_ids.get("edit_queue", 7))) + _action_popup.add_item("📋 Step Stack…", int(action_ids.get("edit_queue", 7))) _action_popup.add_item("⚡ Edit Rules…", int(action_ids.get("edit_rules", 8))) _action_popup.id_pressed.connect(_on_action_popup_id_pressed) _action_popup.about_to_popup.connect(_on_action_popup_about_to_popup) @@ -308,7 +308,7 @@ func refresh_action_popup_items(rules: Array[Dictionary]) -> void: if _action_popup == null: return var valid: bool = context_rig != null and is_instance_valid(context_rig) - _disable_popup_item(_action_popup, 7, not valid or context_rig.get_queue().is_empty()) + _disable_popup_item(_action_popup, 7, not valid) _disable_popup_item(_action_popup, 8, not valid or not rig_has_rules(context_rig, rules)) @@ -316,7 +316,7 @@ func refresh_rig_context_items(rig: StickmanRig, popup: PopupMenu, rules: Array[ if popup == null: return var valid: bool = rig != null and is_instance_valid(rig) - _disable_popup_item(popup, 0, not valid or rig.get_queue().is_empty()) + _disable_popup_item(popup, 0, not valid) _disable_popup_item(popup, 1, not valid or not rig_has_rules(rig, rules)) @@ -455,10 +455,13 @@ func update_action_overlay( if awaiting: var origin: Vector2 = action_origin(capture_kind, set_down_rig, throw_rig, throw_cache, _action_queue.panel_rig if _action_queue != null else null) var target: Vector2 = _camera.get_global_mouse_position() if _camera != null else Vector2.ZERO + var movement_target_capture: bool = capture_kind == "position" or pending_walk_target or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION) if snap_enabled and (capture_kind in ["position", "throw", "set_down"] or (_rule_authoring != null and _rule_authoring.step == RULE_STEP_ACTION_POSITION)): if grid_size > 0.0: target = Vector2(roundf(target.x / grid_size) * grid_size, roundf(target.y / grid_size) * grid_size) - if capture_kind == "set_down": + if capture_kind in ["plan_place", "plan_prop"]: + _placement_overlay.set_action_marker(target) + elif capture_kind == "set_down": var known: bool = bool(set_down_cache.get("known", false)) var valid: bool = bool(set_down_cache.get("valid", false)) if known: @@ -487,11 +490,15 @@ func update_action_overlay( throw_preview["start_position"], target, throw_preview["landing_position"], target_reachable, trajectory_points, effective_target, collision_position, preview_planned, preview_uncertain ) + elif movement_target_capture: + _placement_overlay.set_grounded_action_trajectory(origin, target, is_target_valid(target)) else: - _placement_overlay.set_action_trajectory(origin, target, is_target_valid(target)) + # Object/actor/rule target selection needs a cursor marker, not a + # movement-looking line from the actor to the hovered target. + _placement_overlay.set_action_marker(target) elif direct_move_target.is_finite() and context_rig != null and is_instance_valid(context_rig): var origin: Vector2 = context_rig.global_position - STICKMAN_LOCOMOTION.FOOT_OFFSET - _placement_overlay.set_action_trajectory(origin, direct_move_target, true) + _placement_overlay.set_grounded_action_trajectory(origin, direct_move_target, true) else: _placement_overlay.clear_action() if _chrome != null: diff --git a/scripts/editors/stage/stage_inspector_capture_overlay.gd b/scripts/editors/stage/stage_inspector_capture_overlay.gd new file mode 100644 index 0000000..bf6a843 --- /dev/null +++ b/scripts/editors/stage/stage_inspector_capture_overlay.gd @@ -0,0 +1,287 @@ +class_name StageInspectorCaptureOverlay +extends Control +## Keeps an active world-target choice visible beside the inspector while +## dimming and blocking the inspector controls underneath it. + +signal capture_action_requested(action_id: StringName) + +const CAPTURE_STRIP_HEIGHT: float = 72.0 +const CAPTURE_MULTI_ACTION_STRIP_HEIGHT: float = 124.0 +const CAPTURE_BUTTON_HEIGHT: float = 48.0 +const CAPTURE_CANCEL_WIDTH: float = 84.0 +const CAPTURE_ACTION_WIDTH: float = 112.0 + +var _stage_theme: SandboxTheme +var _inspector: Control +var _inspector_scrim: Panel +var _capture_strip: PanelContainer +var _content_column: VBoxContainer +var _title_row: HBoxContainer +var _caption: Label +var _caption_base_font_size: int = 17 +var _actions_row: HBoxContainer +var _action_buttons: Array[Button] = [] +var _active_signature: String = "" +var _focus_before_capture: Control + + +func configure(stage_theme: SandboxTheme) -> void: + _stage_theme = stage_theme + + +func _ready() -> void: + name = "StageInspectorCaptureOverlay" + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + mouse_filter = Control.MOUSE_FILTER_IGNORE + z_index = 120 + visible = false + _build_controls() + get_viewport().size_changed.connect(_layout_capture_overlay) + + +func _process(_delta: float) -> void: + _layout_capture_overlay() + + +func show_capture(inspector: Control, prompt: String, actions: Array[Dictionary]) -> bool: + if inspector == null or not is_instance_valid(inspector) or not inspector.is_visible_in_tree(): + hide_capture() + return false + var normalized_actions: Array[Dictionary] = actions.duplicate(true) + if normalized_actions.is_empty(): + normalized_actions.append({"id": "cancel_capture", "label": "Cancel", "primary": true}) + _set_stacked_actions(normalized_actions.size() > 1) + var action_signature: Array[String] = [] + for action: Dictionary in normalized_actions: + action_signature.append("%s:%s" % [String(action.get("id", "")), String(action.get("label", ""))]) + var signature: String = "%d|%s|%s" % [inspector.get_instance_id(), prompt, ";".join(action_signature)] + if _active_signature != signature: + if _active_signature.is_empty(): + _focus_before_capture = get_viewport().gui_get_focus_owner() + _active_signature = signature + _inspector = inspector + _caption.text = prompt + _rebuild_actions(normalized_actions) + visible = true + if not _action_buttons.is_empty(): + _action_buttons[_action_buttons.size() - 1].grab_focus() + _layout_capture_overlay() + return true + + +func hide_capture() -> void: + if _active_signature.is_empty(): + return + _active_signature = "" + _inspector = null + visible = false + for button: Button in _action_buttons: + button.queue_free() + _action_buttons.clear() + if _focus_before_capture != null and is_instance_valid(_focus_before_capture) and _focus_before_capture.is_visible_in_tree(): + _focus_before_capture.grab_focus() + _focus_before_capture = null + + +func has_capture() -> bool: + return not _active_signature.is_empty() + + +func get_inspector_rect() -> Rect2: + if not has_capture() or _inspector == null or not is_instance_valid(_inspector): + return Rect2() + return _inspector.get_global_rect() + + +func _build_controls() -> void: + _inspector_scrim = Panel.new() + _inspector_scrim.name = "InspectorInputBlocker" + _inspector_scrim.mouse_filter = Control.MOUSE_FILTER_STOP + _inspector_scrim.focus_mode = Control.FOCUS_NONE + _inspector_scrim.gui_input.connect(_on_capture_area_gui_input) + var scrim_style: StyleBoxFlat = StyleBoxFlat.new() + scrim_style.bg_color = Color(0.015, 0.02, 0.035, 0.56) + scrim_style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 8, 32)) + _inspector_scrim.add_theme_stylebox_override("panel", scrim_style) + add_child(_inspector_scrim) + + _capture_strip = PanelContainer.new() + _capture_strip.name = "CaptureCancelStrip" + _capture_strip.mouse_filter = Control.MOUSE_FILTER_STOP + _capture_strip.gui_input.connect(_on_capture_area_gui_input) + _capture_strip.add_theme_stylebox_override("panel", _strip_style()) + add_child(_capture_strip) + + var margin: MarginContainer = MarginContainer.new() + margin.add_theme_constant_override("margin_left", 10) + margin.add_theme_constant_override("margin_right", 10) + margin.add_theme_constant_override("margin_top", 7) + margin.add_theme_constant_override("margin_bottom", 7) + margin.mouse_filter = Control.MOUSE_FILTER_IGNORE + _capture_strip.add_child(margin) + + _content_column = VBoxContainer.new() + _content_column.name = "CaptureContent" + _content_column.add_theme_constant_override("separation", 4) + _content_column.mouse_filter = Control.MOUSE_FILTER_IGNORE + margin.add_child(_content_column) + + _title_row = HBoxContainer.new() + _title_row.name = "CaptureTitleRow" + _title_row.add_theme_constant_override("separation", 8) + _title_row.alignment = BoxContainer.ALIGNMENT_CENTER + _title_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _title_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _content_column.add_child(_title_row) + + var marker: Label = Label.new() + marker.text = "⊕" + marker.add_theme_font_size_override("font_size", 25) + marker.add_theme_color_override("font_color", Color("#22C6FF")) + marker.mouse_filter = Control.MOUSE_FILTER_IGNORE + _title_row.add_child(marker) + + _caption = Label.new() + _caption.name = "CapturePrompt" + _caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _caption.text = "Choosing a target" + _caption.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _caption.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _caption.clip_text = true + _caption.custom_minimum_size.y = 48.0 + _caption_base_font_size = _integer("stage_panels.type.body_size", 17, 14, 28) + _caption.add_theme_font_size_override("font_size", _caption_base_font_size) + _caption.add_theme_color_override("font_color", _color("stage_panels.type.text_color", Color("#F5F7FA"))) + _caption.size_flags_horizontal = Control.SIZE_EXPAND_FILL | Control.SIZE_SHRINK_BEGIN + _caption.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _caption.mouse_filter = Control.MOUSE_FILTER_IGNORE + _title_row.add_child(_caption) + + _actions_row = HBoxContainer.new() + _actions_row.name = "CaptureActions" + _actions_row.add_theme_constant_override("separation", 6) + _actions_row.alignment = BoxContainer.ALIGNMENT_END + _actions_row.size_flags_horizontal = Control.SIZE_SHRINK_END + _actions_row.mouse_filter = Control.MOUSE_FILTER_IGNORE + _title_row.add_child(_actions_row) + + +func _set_stacked_actions(stacked: bool) -> void: + if _actions_row == null or _content_column == null or _title_row == null: + return + var target_parent: Node = _content_column if stacked else _title_row + if _actions_row.get_parent() != target_parent: + _actions_row.reparent(target_parent) + _actions_row.size_flags_horizontal = Control.SIZE_EXPAND_FILL if stacked else Control.SIZE_SHRINK_END + _actions_row.alignment = BoxContainer.ALIGNMENT_END + + +func _rebuild_actions(actions: Array[Dictionary]) -> void: + for button: Button in _action_buttons: + button.queue_free() + _action_buttons.clear() + for action: Dictionary in actions: + var action_id: StringName = StringName(String(action.get("id", ""))) + if action_id.is_empty(): + continue + var button: Button = Button.new() + button.name = "Capture_%s" % String(action_id) + button.text = String(action.get("label", "Cancel")) + button.tooltip_text = button.text + button.focus_mode = Control.FOCUS_ALL + button.custom_minimum_size = Vector2(CAPTURE_CANCEL_WIDTH if action_id == &"cancel_capture" else CAPTURE_ACTION_WIDTH, CAPTURE_BUTTON_HEIGHT) + button.add_theme_font_size_override("font_size", _integer("stage_panels.action.label_size", 16, 14, 24)) + _style_action_button(button, action_id == &"cancel_capture") + button.pressed.connect(_on_action_pressed.bind(action_id)) + _actions_row.add_child(button) + _action_buttons.append(button) + _trap_action_focus() + + +func _trap_action_focus() -> void: + for index: int in _action_buttons.size(): + var button: Button = _action_buttons[index] + button.set_focus_neighbor(SIDE_TOP, button.get_path()) + button.set_focus_neighbor(SIDE_BOTTOM, button.get_path()) + button.set_focus_neighbor(SIDE_LEFT, _action_buttons[maxi(0, index - 1)].get_path()) + button.set_focus_neighbor(SIDE_RIGHT, _action_buttons[mini(_action_buttons.size() - 1, index + 1)].get_path()) + + +func _on_action_pressed(action_id: StringName) -> void: + capture_action_requested.emit(action_id) + + +func _on_capture_area_gui_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mouse_button: InputEventMouseButton = event as InputEventMouseButton + if mouse_button.pressed and mouse_button.button_index == MOUSE_BUTTON_RIGHT: + capture_action_requested.emit(&"cancel_capture") + accept_event() + + +func _layout_capture_overlay() -> void: + if not has_capture() or _inspector == null or not is_instance_valid(_inspector) or not _inspector.is_visible_in_tree(): + hide_capture() + return + var viewport_rect: Rect2 = get_viewport().get_visible_rect() + var inspector_rect: Rect2 = _inspector.get_global_rect().intersection(viewport_rect) + if not inspector_rect.has_area(): + hide_capture() + return + var caption_font_size: int = mini(_caption_base_font_size, 16) if inspector_rect.size.x < 340.0 else _caption_base_font_size + _caption.add_theme_font_size_override("font_size", caption_font_size) + _inspector_scrim.position = inspector_rect.position + _inspector_scrim.size = inspector_rect.size + _capture_strip.position = inspector_rect.position + var strip_height: float = CAPTURE_MULTI_ACTION_STRIP_HEIGHT if _actions_row.get_parent() == _content_column else CAPTURE_STRIP_HEIGHT + _capture_strip.size = Vector2(inspector_rect.size.x, strip_height) + _capture_strip.custom_minimum_size = Vector2(inspector_rect.size.x, strip_height) + + +func _strip_style() -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = _color("stage_panels.shell.surface_color", Color("#202634")) + style.border_color = _color("mode_colors.direct", Color("#FFB300")) + style.set_border_width_all(2) + style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 12, 8, 32)) + style.content_margin_left = 8.0 + style.content_margin_right = 8.0 + style.content_margin_top = 4.0 + style.content_margin_bottom = 4.0 + return style + + +func _style_action_button(button: Button, is_cancel: bool) -> void: + var normal: Color = Color("#B83A43") if is_cancel else _color("stage_panels.action.surface_color", Color("#171B24")) + var hover: Color = Color("#CF4B54") if is_cancel else _color("stage_panels.action.hover_surface", Color("#303A4B")) + var pressed: Color = Color("#96323A") if is_cancel else _color("stage_panels.action.selected_surface", Color("#12394A")) + var border: Color = Color("#FF8790") if is_cancel else _color("stage_panels.choice.border_color", Color("#FFFFFF55")) + button.add_theme_stylebox_override("normal", _button_style(normal, border, 2)) + button.add_theme_stylebox_override("hover", _button_style(hover, border.lightened(0.12), 2)) + button.add_theme_stylebox_override("pressed", _button_style(pressed, border, 2)) + button.add_theme_stylebox_override("focus", _button_style(normal, Color("#8BD9FF"), 3)) + button.add_theme_color_override("font_color", Color("#FFFFFF")) + button.add_theme_color_override("font_hover_color", Color("#FFFFFF")) + button.add_theme_color_override("font_pressed_color", Color("#FFFFFF")) + button.add_theme_color_override("font_focus_color", Color("#FFFFFF")) + + +func _button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = background + style.border_color = border + style.set_border_width_all(border_width) + style.set_corner_radius_all(_integer("stage_panels.shell.corner_radius", 10, 0, 32)) + style.content_margin_left = 10.0 + style.content_margin_right = 10.0 + style.content_margin_top = 5.0 + style.content_margin_bottom = 5.0 + return style + + +func _color(path: String, fallback: Color) -> Color: + return _stage_theme.color(path, fallback) if _stage_theme != null else fallback + + +func _integer(path: String, fallback: int, minimum: int, maximum: int) -> int: + return _stage_theme.integer(path, fallback, minimum, maximum) if _stage_theme != null else fallback diff --git a/scripts/editors/stage/stage_inspector_capture_overlay.gd.uid b/scripts/editors/stage/stage_inspector_capture_overlay.gd.uid new file mode 100644 index 0000000..e9bda1a --- /dev/null +++ b/scripts/editors/stage/stage_inspector_capture_overlay.gd.uid @@ -0,0 +1 @@ +uid://71weltw84wr diff --git a/scripts/editors/stage/stage_modal_views.gd b/scripts/editors/stage/stage_modal_views.gd index bda7f7c..0dd2cd3 100644 --- a/scripts/editors/stage/stage_modal_views.gd +++ b/scripts/editors/stage/stage_modal_views.gd @@ -37,6 +37,7 @@ var waypoint_context: PopupMenu var confirm_dialog: ConfirmationDialog var queue_panel: QueuePanel var action_editor: ActionEditor +var action_editor_backdrop: ColorRect var speech_composer: Control var speech_style_toolbar: Control var speech_show_toolbar: Control @@ -181,7 +182,7 @@ func build_context_and_editor_views( action_size: Vector2i ) -> void: rig_context_popup = PopupMenu.new() - rig_context_popup.add_item("📋 Edit Queue…", 0) + rig_context_popup.add_item("📋 Step Stack…", 0) rig_context_popup.add_item("⚡ Edit Rules…", 1) parent.add_child(rig_context_popup) @@ -202,11 +203,29 @@ func build_context_and_editor_views( queue_panel.apply_font(font, emoji_font, font_sizes) queue_panel.min_size = queue_size + action_editor_backdrop = ColorRect.new() + action_editor_backdrop.name = "ActionEditorBackdrop" + action_editor_backdrop.color = _sandbox_theme.color("popups.confirmation.backdrop_color", Color("#080D15B8")) if _sandbox_theme != null else Color("#080D15B8") + action_editor_backdrop.mouse_filter = Control.MOUSE_FILTER_STOP + action_editor_backdrop.z_index = 100 + action_editor_backdrop.z_as_relative = false + action_editor_backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + action_editor_backdrop.visible = false + parent.add_child(action_editor_backdrop) + action_editor = ACTION_EDITOR_SCENE.instantiate() as ActionEditor parent.add_child(action_editor) action_editor.apply_font(font, emoji_font, font_sizes) action_editor.apply_theme(_sandbox_theme) - action_editor.min_size = action_size + action_editor.set_preferred_size(action_size) + action_editor.about_to_popup.connect(func() -> void: + if is_instance_valid(action_editor_backdrop): + action_editor_backdrop.show() + ) + action_editor.popup_hide.connect(func() -> void: + if is_instance_valid(action_editor_backdrop): + action_editor_backdrop.hide() + ) speech_composer = SPEECH_COMPOSER_SCENE.instantiate() as Control parent.add_child(speech_composer) @@ -394,6 +413,16 @@ func show_objects_here( popup.window_input.connect(_on_objects_here_window_input.bind(popup)) popup.position = position popup.popup() + popup.size_changed.connect(_fit_objects_here_popup_after_resize.bind( + popup, + pointer_position, + viewport_size, + avoid_rects.duplicate(), + edge_inset, + safe_top, + safe_bottom + )) + _fit_objects_here_popup_after_resize(popup, pointer_position, viewport_size, avoid_rects, edge_inset, safe_top, safe_bottom) cancel.call_deferred("grab_focus") @@ -463,6 +492,31 @@ func _objects_here_popup_position( return best_position +func _fit_objects_here_popup_after_resize( + popup: PopupPanel, + pointer_position: Vector2, + viewport_size: Vector2, + avoid_rects: Array[Rect2], + edge_inset: int, + safe_top: int, + safe_bottom: int +) -> void: + if popup == null or not is_instance_valid(popup) or not popup.visible: + return + var actual_size: Vector2i = popup.size + var safe_position: Vector2i = _objects_here_popup_position( + pointer_position, + viewport_size, + actual_size, + avoid_rects, + edge_inset, + safe_top, + safe_bottom + ) + if popup.position != safe_position: + popup.position = safe_position + + func _style_objects_here_candidate(button: Button, selected: bool) -> void: if button == null: return diff --git a/scripts/editors/stage/stage_scene_adapter.gd b/scripts/editors/stage/stage_scene_adapter.gd index b4d49ce..573072f 100644 --- a/scripts/editors/stage/stage_scene_adapter.gd +++ b/scripts/editors/stage/stage_scene_adapter.gd @@ -120,8 +120,8 @@ func collect( entry["stage_name"] = rig.stage_name var facing_profile: int = int(authored_state.get("facing_profile", rig.get_facing_profile())) entry["facing_profile"] = StickmanRig.FacingProfile.keys()[facing_profile] - var queue_result: Dictionary = STAGE_SCENE_IO.encode_queue(rig.get_queue()) - entry["action_queue"] = queue_result["actions"] + var plan_result: Dictionary = STAGE_SCENE_IO.encode_plan(rig.get_action_plan()) + entry["action_plan"] = plan_result["steps"] if type == "stickman" and entry.has("stk_path"): entry["stk_path"] = CONTENT_PATHS.canonicalize(String(entry.get("stk_path", ""))) elif type == "prop" and String(entry.get("asset_id", "")).ends_with(".prop"): @@ -284,18 +284,16 @@ func apply(data: Dictionary, world: Node2D, spawner: StageSpawner) -> Dictionary var facing_index: int = facing_names.find(String(entry.get("facing_profile", "FORWARD"))) if facing_index >= 0: (node as StickmanRig).set_facing_profile(facing_index) - var raw_queue: Variant = entry.get("action_queue", []) - if raw_queue is Array: - var queue: Dictionary = STAGE_SCENE_IO.decode_queue(raw_queue as Array) - for warning: String in queue.get("warnings", []): - var character_name: String = String(entry.get("stk_path", "")).get_file() - if character_name.is_empty(): - character_name = file_id - warnings.append("%s: %s Open this character's queue to review the skipped action." % [character_name, warning]) - for action: Dictionary in queue["actions"]: - (node as StickmanRig).queue_action(action) + var raw_plan: Variant = entry.get("action_plan", []) + if raw_plan is Array: + var decoded_plan: Dictionary = STAGE_SCENE_IO.decode_plan(raw_plan as Array) + if bool(decoded_plan.get("ok", false)): + if not (node as StickmanRig).set_action_plan(decoded_plan.get("steps", [])): + warnings.append("%s: the action plan could not be restored." % String(entry.get("stage_name", file_id))) + else: + warnings.append("%s: %s" % [String(entry.get("stage_name", file_id)), String(decoded_plan.get("error", "Invalid action plan."))]) else: - warnings.append("%s: invalid action queue. Open this character's queue and review its actions." % file_id) + warnings.append("%s: invalid action plan." % String(entry.get("stage_name", file_id))) (node as StickmanRig).reset_collision_proxy_velocity_baselines() loaded_nodes.append(node) if node is TerrainBlock: diff --git a/scripts/editors/stage/stage_target_capture_session.gd b/scripts/editors/stage/stage_target_capture_session.gd index 2cb3f88..f727408 100644 --- a/scripts/editors/stage/stage_target_capture_session.gd +++ b/scripts/editors/stage/stage_target_capture_session.gd @@ -4,7 +4,7 @@ extends RefCounted const STICKMAN_ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd") -enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT } +enum Kind { NONE, WAYPOINT, AREA, PROP, STICKMAN, POSITION, THROW_POSITION, SET_DOWN_POSITION, PLAN_PLACE, PLAN_PROP, PICKUP_PROP, SPOT_ADD, SPOT_MOVE, USE_PROP, USE_SPOT } var kind: int = Kind.NONE var hint: String = "" @@ -31,6 +31,14 @@ var use_target_preview_prefix: Array[Dictionary] = [] var use_target_prefix_known: bool = false +static func normalize_plan_destination(value: Variant) -> Dictionary: + if value is Vector2: + return {"kind": "place", "position": value} + if value is Dictionary: + return (value as Dictionary).duplicate(true) + return {} + + func is_stage_custom_capture() -> bool: return kind in [Kind.SPOT_ADD, Kind.SPOT_MOVE, Kind.USE_PROP, Kind.USE_SPOT] @@ -152,10 +160,40 @@ func resolve_target(world_pos: Vector2, selection: StageSelection, director_visu var prop_hit: Node2D = selection.hit_test(world_pos) if prop_hit is PropBlock: value = prop_hit + Kind.PICKUP_PROP: + var pickup_hit: PropBlock = null + var ineligible_hit: PropBlock = null + for candidate: Node2D in selection.hit_test_all(world_pos): + if candidate is PropBlock and (candidate as PropBlock).can_be_picked_up(): + pickup_hit = candidate as PropBlock + break + elif candidate is PropBlock and ineligible_hit == null: + ineligible_hit = candidate as PropBlock + if pickup_hit == null: + var reason: String = "Click a highlighted prop to pick it up." + if ineligible_hit != null: + reason = "This prop needs pickup enabled and a grip in Edit." + return {"valid": false, "value": null, "reason": reason} + value = pickup_hit Kind.STICKMAN: var stickman_hit: Node2D = selection.hit_test(world_pos) if stickman_hit is StickmanRig: value = stickman_hit + Kind.PLAN_PROP: + var plan_prop_hit: Node2D = selection.hit_test(world_pos) + if plan_prop_hit is PropBlock: + var prop: PropBlock = plan_prop_hit as PropBlock + var approach: Vector2 = world_pos + value = { + "kind": "prop", + "target_prop_id": prop.stage_object_id, + "approach_offset": approach - prop.global_position, + "last_known_approach": approach, + } + Kind.PLAN_PLACE: + var place_hit: Node2D = selection.hit_test(world_pos) + if place_hit == null or (not place_hit is PropBlock and not place_hit is StickmanRig and not place_hit is TriggerArea): + value = _snap_to_grid(world_pos, snap_enabled, grid_size) Kind.POSITION, Kind.THROW_POSITION: value = _snap_to_grid(world_pos, snap_enabled, grid_size) Kind.SET_DOWN_POSITION: diff --git a/scripts/editors/stage/stage_use_spot_overlay.gd b/scripts/editors/stage/stage_use_spot_overlay.gd index 59d84c6..688fa00 100644 --- a/scripts/editors/stage/stage_use_spot_overlay.gd +++ b/scripts/editors/stage/stage_use_spot_overlay.gd @@ -302,11 +302,16 @@ func _draw_pill(center: Vector2, text_value: String, accent: Color) -> void: if text_value.is_empty(): return var font: Font = ThemeDB.fallback_font - var text_size: Vector2 = font.get_string_size(text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16) - var rect := Rect2(center + Vector2(-text_size.x * 0.5 - _pill_padding, -13.0), Vector2(text_size.x + _pill_padding * 2.0, 26.0)) + var lines: PackedStringArray = text_value.split("\n", false) + var text_width: float = 0.0 + for line: String in lines: + text_width = maxf(text_width, font.get_string_size(line, HORIZONTAL_ALIGNMENT_LEFT, -1, 16).x) + var rect_size: Vector2 = Vector2(text_width + _pill_padding * 2.0, maxf(26.0, float(lines.size()) * 18.0 + 8.0)) + var rect := Rect2(center - rect_size * 0.5, rect_size) draw_rect(rect, Color("#171B24"), true) draw_rect(rect, accent, false, 2.0) - draw_string(font, rect.position + Vector2(10.0, 18.0), text_value, HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE) + for index: int in lines.size(): + draw_string(font, rect.position + Vector2(10.0, 17.0 + index * 18.0), lines[index], HORIZONTAL_ALIGNMENT_LEFT, -1, 16, Color.WHITE) func _draw_reach_marker(center: Vector2, glyph: String, accent: Color) -> void: diff --git a/scripts/editors/stage/stage_use_target_capture_bar.gd b/scripts/editors/stage/stage_use_target_capture_bar.gd index da33dae..0954b97 100644 --- a/scripts/editors/stage/stage_use_target_capture_bar.gd +++ b/scripts/editors/stage/stage_use_target_capture_bar.gd @@ -7,6 +7,7 @@ signal change_prop_requested signal capture_action_requested(action_id: StringName) const MAX_BAR_WIDTH: float = 800.0 +const PLAN_CAPTURE_MAX_WIDTH: float = 440.0 const SCENERY_CAPTURE_MAX_WIDTH: float = 900.0 const VIEWPORT_WIDTH_RATIO: float = 0.68 const HORIZONTAL_LAYOUT_MIN_WIDTH: float = 760.0 @@ -53,6 +54,8 @@ var _safe_left: float = 0.0 var _safe_right: float = 0.0 var _scenery_edit_active: bool = false var _scenery_capture_active: bool = false +var _plan_capture_active: bool = false +var _plan_target_cancel_only: bool = false func configure(stage_theme: SandboxTheme, top_bar_height: float) -> void: @@ -73,6 +76,9 @@ func _ready() -> void: func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> void: if _prompt == null: return + _plan_capture_active = false + _plan_target_cancel_only = false + _prompt.visible = true _show_standard_capture_layout() _clear_custom_actions() if not prop_selected: @@ -88,10 +94,20 @@ func set_capture_state(prop_selected: bool, prop_has_use_spots: bool = true) -> func set_capture_actions(prompt: String, actions: Array[Dictionary]) -> void: + _plan_capture_active = true + _plan_target_cancel_only = true + _set_capture_actions(prompt, actions, false) + + +func set_target_capture_actions(prompt: String, actions: Array[Dictionary]) -> void: + _plan_capture_active = false + _plan_target_cancel_only = false _set_capture_actions(prompt, actions, false) func set_scenery_capture_actions(prompt: String, actions: Array[Dictionary]) -> void: + _plan_capture_active = false + _plan_target_cancel_only = false _set_capture_actions(prompt, actions, true) @@ -100,6 +116,7 @@ func _set_capture_actions(prompt: String, actions: Array[Dictionary], scenery_ca return _show_standard_capture_layout() _prompt.text = prompt + _prompt.visible = not _plan_target_cancel_only _change_prop_button.visible = false _cancel_button.visible = false _clear_custom_actions() @@ -160,6 +177,9 @@ func set_scenery_edit_state(selected_count: int) -> void: return _scenery_capture_active = false _scenery_edit_active = true + _plan_capture_active = false + _plan_target_cancel_only = false + _prompt.visible = true _clear_custom_actions() if _active_layout.get_parent() == self: _active_layout.visible = false @@ -198,6 +218,9 @@ func clear_horizontal_safe_bounds() -> void: func hide_capture() -> void: visible = false + _plan_capture_active = false + _plan_target_cancel_only = false + _prompt.visible = true _show_standard_capture_layout() @@ -245,7 +268,10 @@ func apply_theme() -> void: if body_font != null: button.add_theme_font_override("font", body_font) button.add_theme_font_size_override("font_size", body_size) - _style_button(button, button.name.ends_with("Keep") or button.name.ends_with("Done")) + if _plan_target_cancel_only: + _style_plan_target_cancel_button(button) + else: + _style_button(button, button.name.ends_with("Keep") or button.name.ends_with("Done")) var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96))) _change_prop_button.custom_minimum_size.y = button_height _cancel_button.custom_minimum_size.y = button_height @@ -628,6 +654,23 @@ func _style_button(button: Button, prominent: bool) -> void: button.add_theme_color_override("font_focus_color", text_color) +func _style_plan_target_cancel_button(button: Button) -> void: + var accent: Color = _color("mode_colors.direct", Color("#FFB300")) + var surface: Color = _color("stage_panels.action.surface_color", Color("#171B24")) + var hover: Color = _color("stage_panels.action.hover_surface", Color("#303A4B")) + var pressed: Color = _color("stage_panels.action.selected_surface", Color("#12394A")) + var text_color: Color = _color("stage_panels.type.text_color", Color("#F5F7FA")) + button.add_theme_stylebox_override("normal", _button_style(surface, accent, 2)) + button.add_theme_stylebox_override("hover", _button_style(hover, accent.lightened(0.1), 2)) + button.add_theme_stylebox_override("pressed", _button_style(pressed, accent, 2)) + button.add_theme_stylebox_override("hover_pressed", _button_style(hover, accent.lightened(0.1), 2)) + button.add_theme_stylebox_override("focus", _button_style(surface, accent, 3)) + button.add_theme_color_override("font_color", text_color) + button.add_theme_color_override("font_hover_color", text_color) + button.add_theme_color_override("font_pressed_color", text_color) + button.add_theme_color_override("font_focus_color", text_color) + + func _button_style(background: Color, border: Color, border_width: int) -> StyleBoxFlat: var style: StyleBoxFlat = StyleBoxFlat.new() style.bg_color = background @@ -651,6 +694,9 @@ func _layout_capture_bar() -> void: if _scenery_capture_active: _layout_scenery_capture_bar(viewport_size) return + if _plan_target_cancel_only: + _layout_plan_target_cancel_button(viewport_size) + return var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48)) var safe_left: float = inset var safe_right: float = viewport_size.x - inset @@ -658,25 +704,59 @@ func _layout_capture_bar() -> void: safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset)) safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset)) var available_width: float = maxf(1.0, safe_right - safe_left) - var width: float = minf(MAX_BAR_WIDTH, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width)) + var maximum_width: float = PLAN_CAPTURE_MAX_WIDTH if _plan_capture_active else MAX_BAR_WIDTH + var width: float = minf(maximum_width, minf(viewport_size.x * VIEWPORT_WIDTH_RATIO, available_width)) var narrow_compact: bool = width < NARROW_COMPACT_WIDTH custom_minimum_size.x = width + if _plan_capture_active: + custom_minimum_size.y = 0.0 size.x = width clip_contents = true - _prompt.add_theme_font_size_override("font_size", 18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34)) - _prompt.max_lines_visible = 2 if narrow_compact else 3 + _prompt.add_theme_font_size_override("font_size", 19 if _plan_capture_active else (18 if narrow_compact else clampi(_integer("stage_panels.type.title_size", 24, 18, 36) + 4, 24, 34))) + _prompt.max_lines_visible = 2 if _plan_capture_active or narrow_compact else 3 _set_compact_panel_style(narrow_compact) + if _plan_capture_active: + _set_plan_capture_panel_style() var left: float = safe_left + (available_width - width) * 0.5 if not _has_horizontal_safe_bounds else safe_left position = Vector2(left, _top_bar_height + 56.0) var many_actions: bool = _custom_action_buttons.size() > 3 - var stack_actions: bool = width < ACTION_ROW_MIN_WIDTH or (many_actions and width < 620.0) - var wrapped: bool = (_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions) - _set_layout(width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped, stack_actions, wrapped) + var stack_actions: bool = false if _plan_capture_active else (width < ACTION_ROW_MIN_WIDTH or (many_actions and width < 620.0)) + var wrapped: bool = false if _plan_capture_active else ((_has_horizontal_safe_bounds and width < HORIZONTAL_LAYOUT_MIN_WIDTH and width >= ACTION_ROW_MIN_WIDTH) or (many_actions and not stack_actions)) + var compact_layout: bool = false if _plan_capture_active else (width < HORIZONTAL_LAYOUT_MIN_WIDTH or wrapped) + _set_layout(compact_layout, stack_actions, wrapped) _fit_custom_action_widths(width, wrapped) _apply_compact_action_labels(narrow_compact) call_deferred("_fit_height_to_content") +func _layout_plan_target_cancel_button(viewport_size: Vector2) -> void: + if _custom_action_buttons.is_empty(): + return + var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48)) + var safe_left: float = inset + var safe_right: float = viewport_size.x - inset + if _has_horizontal_safe_bounds: + safe_left = clampf(_safe_left, inset, maxf(inset, viewport_size.x - inset)) + safe_right = clampf(_safe_right, safe_left + 1.0, maxf(safe_left + 1.0, viewport_size.x - inset)) + var button: Button = _custom_action_buttons[0] + var button_height: float = maxf(DEFAULT_BUTTON_HEIGHT, float(_integer("stage_panels.action.min_target_size", 56, 48, 96))) + var available_width: float = maxf(1.0, safe_right - safe_left) + var button_width: float = minf(148.0, available_width) + button.custom_minimum_size = Vector2(button_width, button_height) + button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN + button.add_theme_font_size_override("font_size", 19) + _prompt.visible = false + _set_layout(false, false) + _actions_row.alignment = BoxContainer.ALIGNMENT_BEGIN + _actions_row.add_theme_constant_override("separation", 0) + custom_minimum_size = Vector2(button_width, 0.0) + size = Vector2(button_width, button_height) + position = Vector2(safe_left, _top_bar_height + 56.0) + clip_contents = false + _set_plan_capture_panel_style() + call_deferred("_fit_height_to_content") + + func _layout_scenery_edit_capture_bar(viewport_size: Vector2) -> void: var inset: float = float(_integer("stage_panels.shell.edge_inset", 16, 12, 48)) var safe_left: float = inset @@ -871,14 +951,19 @@ func _fit_custom_action_widths(width: float, wrapped: bool) -> void: var button_width: float = ACTION_BUTTON_WIDTH var body_size: int = clampi(_integer("stage_panels.type.body_size", 20, 16, 32) + 2, 20, 30) var action_font_size: int = maxi(18, body_size - (4 if wrapped else 0)) + if _plan_capture_active: + button_width = 124.0 + action_font_size = 18 if width < NARROW_COMPACT_WIDTH: - button_width = 0.0 + button_width = 124.0 if _plan_capture_active else 0.0 action_font_size = 16 if wrapped and not _custom_action_buttons.is_empty(): var gaps: float = float(maxi(0, _custom_action_buttons.size() - 1) * 12) button_width = minf(ACTION_BUTTON_WIDTH, maxf(144.0, (width - 44.0 - gaps) / float(_custom_action_buttons.size()))) for button: Button in _custom_action_buttons: button.custom_minimum_size.x = button_width + if _plan_capture_active: + button.custom_minimum_size.y = 48.0 button.add_theme_font_size_override("font_size", action_font_size) button.size_flags_horizontal = Control.SIZE_EXPAND_FILL if width < NARROW_COMPACT_WIDTH else Control.SIZE_FILL @@ -918,9 +1003,32 @@ func _set_compact_panel_style(compact: bool) -> void: panel_style.content_margin_bottom = 8.0 if compact else 14.0 +func _set_plan_capture_panel_style() -> void: + if _plan_target_cancel_only: + var cancel_style: StyleBoxFlat = StyleBoxFlat.new() + cancel_style.bg_color = Color(0.0, 0.0, 0.0, 0.0) + cancel_style.set_border_width_all(0) + cancel_style.content_margin_left = 0.0 + cancel_style.content_margin_right = 0.0 + cancel_style.content_margin_top = 0.0 + cancel_style.content_margin_bottom = 0.0 + add_theme_stylebox_override("panel", cancel_style) + return + var panel_style: StyleBoxFlat = get_theme_stylebox("panel") as StyleBoxFlat + if panel_style == null: + return + panel_style.border_width_left = 4 + panel_style.content_margin_left = 10.0 + panel_style.content_margin_right = 10.0 + panel_style.content_margin_top = 8.0 + panel_style.content_margin_bottom = 8.0 + + func _fit_height_to_content() -> void: if not is_inside_tree() or not visible: return + if _plan_capture_active: + custom_minimum_size.y = 0.0 var target_height: float = _active_layout.get_combined_minimum_size().y var panel_style: StyleBox = get_theme_stylebox("panel") if panel_style != null: diff --git a/scripts/props/prop_block.gd b/scripts/props/prop_block.gd index bc30353..c64151a 100644 --- a/scripts/props/prop_block.gd +++ b/scripts/props/prop_block.gd @@ -60,6 +60,8 @@ const MAX_CONTACTS_REPORTED := 32 const DEFAULT_WOBBLE_DURATION: float = 0.48 const DEFAULT_WOBBLE_ANGLE_DEGREES: float = 7.0 const DEFAULT_WOBBLE_OFFSET_LOCAL: float = 4.0 +const OWNER_COLLISION_GRACE_CLEAR_MARGIN: float = 24.0 +const OWNER_COLLISION_GRACE_CLEAR_FRAMES: int = 2 # --------------------------------------------------------------------------- # Exported properties @@ -129,6 +131,15 @@ var _wobble_center: Vector2 = Vector2.ZERO var _wobble_baselines: Dictionary = {} var _last_physics_velocity: Vector2 = Vector2.ZERO var _has_physics_velocity_sample: bool = false +var _owner_collision_grace_actor_id: int = 0 +var _owner_collision_grace_actor_ref: WeakRef +var _owner_collision_grace_locomotion_ref: WeakRef +var _owner_collision_grace_body_refs: Array[WeakRef] = [] +var _owner_collision_grace_role: RuntimeRole = RuntimeRole.EDIT +var _owner_collision_grace_while_reserved: bool = false +var _owner_collision_grace_clear_frames: int = 0 +var _owner_collision_grace_started_frame: int = -1 +var _owner_collision_grace_last_sample_frame: int = -1 # --------------------------------------------------------------------------- # Signals @@ -178,12 +189,14 @@ func _process(delta: float) -> void: func _physics_process(_delta: float) -> void: + _update_owner_collision_grace() if not freeze and runtime_role in [RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED]: _last_physics_velocity = linear_velocity _has_physics_velocity_sample = _last_physics_velocity.is_finite() func _exit_tree() -> void: + clear_owner_collision_grace() cancel_wobble() @@ -320,6 +333,8 @@ func apply_stage_depth() -> void: func apply_runtime_role(role: RuntimeRole) -> void: runtime_role = role + if _owner_collision_grace_actor_id != 0 and (role != _owner_collision_grace_role or not is_actor_blocking()): + clear_owner_collision_grace() match role: RuntimeRole.EDIT: freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC @@ -369,6 +384,146 @@ func is_actor_blocking() -> bool: return bool(stage_roles.get("blocks_stickmen", false)) +## Temporarily ignores contacts with one stickman. Pickup reservations can keep +## protection active until the reservation ends; Set Down uses exit clearance. +## The prop's authored role and contacts with supports or other stickmen remain. +func begin_owner_collision_grace(actor_rig: Node2D, while_reserved: bool = false) -> void: + clear_owner_collision_grace() + if actor_rig == null or not is_instance_valid(actor_rig) or not is_actor_blocking(): + return + _owner_collision_grace_actor_id = actor_rig.get_instance_id() + _owner_collision_grace_actor_ref = weakref(actor_rig) + _owner_collision_grace_role = runtime_role + _owner_collision_grace_while_reserved = while_reserved + _owner_collision_grace_clear_frames = 0 + _owner_collision_grace_started_frame = Engine.get_physics_frames() + _owner_collision_grace_last_sample_frame = -1 + for body: PhysicsBody2D in _actor_collision_bodies(actor_rig): + body.add_collision_exception_with(self) + add_collision_exception_with(body) + _owner_collision_grace_body_refs.append(weakref(body)) + if actor_rig.has_method("get_locomotion"): + var locomotion: Node = actor_rig.call("get_locomotion") as Node + if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("register_owner_collision_grace_prop"): + locomotion.call("register_owner_collision_grace_prop", self) + _owner_collision_grace_locomotion_ref = weakref(locomotion) + + +func release_owner_collision_grace(actor_rig: Node2D) -> void: + if not is_owner_collision_grace_active_for(actor_rig): + return + _owner_collision_grace_while_reserved = false + _owner_collision_grace_clear_frames = 0 + _owner_collision_grace_started_frame = Engine.get_physics_frames() + _owner_collision_grace_last_sample_frame = -1 + + +func is_owner_collision_grace_active_for(actor: Node) -> bool: + return ( + actor != null + and is_instance_valid(actor) + and _owner_collision_grace_actor_id != 0 + and actor.get_instance_id() == _owner_collision_grace_actor_id + ) + + +func clear_owner_collision_grace() -> void: + for body_ref: WeakRef in _owner_collision_grace_body_refs: + var body: PhysicsBody2D = body_ref.get_ref() as PhysicsBody2D + if body != null and is_instance_valid(body): + body.remove_collision_exception_with(self) + remove_collision_exception_with(body) + _owner_collision_grace_body_refs.clear() + var locomotion: Node = _owner_collision_grace_locomotion_ref.get_ref() as Node if _owner_collision_grace_locomotion_ref != null else null + if locomotion != null and is_instance_valid(locomotion) and locomotion.has_method("unregister_owner_collision_grace_prop"): + locomotion.call("unregister_owner_collision_grace_prop", self) + _owner_collision_grace_locomotion_ref = null + _owner_collision_grace_actor_ref = null + _owner_collision_grace_actor_id = 0 + _owner_collision_grace_role = RuntimeRole.EDIT + _owner_collision_grace_while_reserved = false + _owner_collision_grace_clear_frames = 0 + _owner_collision_grace_started_frame = -1 + _owner_collision_grace_last_sample_frame = -1 + + +func _update_owner_collision_grace() -> void: + if _owner_collision_grace_actor_id == 0: + return + var actor: Node2D = _owner_collision_grace_actor_ref.get_ref() as Node2D if _owner_collision_grace_actor_ref != null else null + if actor == null or not is_instance_valid(actor) or runtime_role != _owner_collision_grace_role or not is_actor_blocking(): + clear_owner_collision_grace() + return + if _owner_collision_grace_while_reserved: + return + var physics_frame: int = Engine.get_physics_frames() + if physics_frame <= _owner_collision_grace_started_frame or physics_frame == _owner_collision_grace_last_sample_frame: + return + _owner_collision_grace_last_sample_frame = physics_frame + if _actor_collision_shapes_overlap(actor): + _owner_collision_grace_clear_frames = 0 + return + _owner_collision_grace_clear_frames += 1 + if _owner_collision_grace_clear_frames >= OWNER_COLLISION_GRACE_CLEAR_FRAMES: + clear_owner_collision_grace() + + +func _actor_collision_shapes_overlap(actor_rig: Node2D) -> bool: + if not is_inside_tree() or get_world_2d() == null or collision_layer == 0: + return false + var space_state: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state + if space_state == null: + return true + var active_shape_count: int = 0 + for body: PhysicsBody2D in _actor_collision_bodies(actor_rig): + if body.collision_layer == 0: + continue + for child: Node in body.get_children(): + var collision_shape: CollisionShape2D = child as CollisionShape2D + if collision_shape == null or collision_shape.disabled or collision_shape.shape == null: + continue + active_shape_count += 1 + var query := PhysicsShapeQueryParameters2D.new() + query.shape = collision_shape.shape + query.transform = collision_shape.global_transform + query.collision_mask = collision_layer + query.margin = OWNER_COLLISION_GRACE_CLEAR_MARGIN + query.exclude = [body.get_rid()] + for hit: Dictionary in space_state.intersect_shape(query, 16): + if hit.get("collider") == self: + return true + return active_shape_count == 0 + + +func _actor_collision_bodies(actor_rig: Node2D) -> Array[PhysicsBody2D]: + var result: Array[PhysicsBody2D] = [] + var seen: Dictionary = {} + if actor_rig == null or not is_instance_valid(actor_rig): + return result + if actor_rig.has_method("get_collision_bodies"): + var collision_bodies: Variant = actor_rig.call("get_collision_bodies") + if collision_bodies is Dictionary: + for raw_body: Variant in (collision_bodies as Dictionary).values(): + _append_actor_collision_body(raw_body, result, seen) + if actor_rig.has_method("get_ragdoll_bodies"): + var ragdoll_bodies: Variant = actor_rig.call("get_ragdoll_bodies") + if ragdoll_bodies is Array: + for raw_body: Variant in ragdoll_bodies as Array: + _append_actor_collision_body(raw_body, result, seen) + return result + + +func _append_actor_collision_body(raw_body: Variant, bodies: Array[PhysicsBody2D], seen: Dictionary) -> void: + var body: PhysicsBody2D = raw_body as PhysicsBody2D + if body == null or not is_instance_valid(body): + return + var body_id: int = body.get_instance_id() + if seen.has(body_id): + return + seen[body_id] = true + bodies.append(body) + + func can_receive_prop_contact() -> bool: if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT: return false diff --git a/scripts/shared/stage_ui/stage_action_summary_glyph.gd b/scripts/shared/stage_ui/stage_action_summary_glyph.gd new file mode 100644 index 0000000..e9a69fd --- /dev/null +++ b/scripts/shared/stage_ui/stage_action_summary_glyph.gd @@ -0,0 +1,198 @@ +class_name StageActionSummaryGlyph +extends Control +## Small, screen-sized glyphs for the Stage action-plan summary rows. + +var _kind: String = "move" +var _tint: Color = Color("#8BD9FF") + + +func _ready() -> void: + mouse_filter = Control.MOUSE_FILTER_IGNORE + focus_mode = Control.FOCUS_NONE + + +func configure(kind: String, tint: Color) -> void: + _kind = kind + _tint = tint + queue_redraw() + + +func _draw() -> void: + draw_in_canvas(self, _kind, Vector2.ZERO, 1.0, _tint) + + +## Draws the same vector glyph into another CanvasItem's active draw callback. +## `origin` is the top-left of the 28 x 30 glyph design space. +static func draw_in_canvas(canvas: CanvasItem, kind: String, origin: Vector2, scale_factor: float, tint: Color) -> void: + if canvas == null or scale_factor <= 0.0: + return + match kind: + "move": + _draw_move(canvas, origin, scale_factor, tint) + "speak": + _draw_speech(canvas, origin, scale_factor, tint) + "wait": + _draw_wait(canvas, origin, scale_factor, tint) + "ragdoll": + _draw_ragdoll(canvas, origin, scale_factor, tint) + "recover": + _draw_recover(canvas, origin, scale_factor, tint) + "drop_prop": + _draw_drop_prop(canvas, origin, scale_factor, tint) + "throw_prop": + _draw_throw_prop(canvas, origin, scale_factor, tint) + "set_down_prop": + _draw_set_down_prop(canvas, origin, scale_factor, tint) + "use_prop": + _draw_use_prop(canvas, origin, scale_factor, tint) + + +static func _draw_move(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + var route := PackedVector2Array([ + origin + Vector2(4.0, 24.0) * scale_factor, + origin + Vector2(8.0, 15.0) * scale_factor, + origin + Vector2(13.0, 19.0) * scale_factor, + origin + Vector2(24.0, 7.0) * scale_factor, + ]) + canvas.draw_polyline(route, tint, 2.8 * scale_factor, true) + canvas.draw_circle(origin + Vector2(4.0, 24.0) * scale_factor, 2.5 * scale_factor, tint, true, -1.0, true) + var arrow_head := PackedVector2Array([ + origin + Vector2(17.0, 7.0) * scale_factor, + origin + Vector2(25.0, 6.0) * scale_factor, + origin + Vector2(24.0, 14.0) * scale_factor, + ]) + canvas.draw_polyline(arrow_head, tint, 2.8 * scale_factor, true) + + +static func _draw_speech(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + var outline := PackedVector2Array([ + origin + Vector2(5.0, 5.0) * scale_factor, + origin + Vector2(22.0, 5.0) * scale_factor, + origin + Vector2(26.0, 9.0) * scale_factor, + origin + Vector2(26.0, 18.0) * scale_factor, + origin + Vector2(22.0, 22.0) * scale_factor, + origin + Vector2(15.0, 22.0) * scale_factor, + origin + Vector2(8.0, 28.0) * scale_factor, + origin + Vector2(8.0, 22.0) * scale_factor, + origin + Vector2(5.0, 22.0) * scale_factor, + origin + Vector2(2.0, 19.0) * scale_factor, + origin + Vector2(2.0, 8.0) * scale_factor, + origin + Vector2(5.0, 5.0) * scale_factor, + ]) + canvas.draw_polyline(outline, tint, 2.2 * scale_factor, true) + + +static func _draw_wait(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + var outline := PackedVector2Array([ + origin + Vector2(6.0, 5.0) * scale_factor, + origin + Vector2(24.0, 5.0) * scale_factor, + origin + Vector2(22.0, 9.0) * scale_factor, + origin + Vector2(17.0, 14.0) * scale_factor, + origin + Vector2(22.0, 19.0) * scale_factor, + origin + Vector2(24.0, 23.0) * scale_factor, + origin + Vector2(6.0, 23.0) * scale_factor, + origin + Vector2(8.0, 19.0) * scale_factor, + origin + Vector2(13.0, 14.0) * scale_factor, + origin + Vector2(8.0, 9.0) * scale_factor, + origin + Vector2(6.0, 5.0) * scale_factor, + ]) + canvas.draw_polyline(outline, tint, 2.2 * scale_factor, true) + canvas.draw_line(origin + Vector2(9.0, 8.0) * scale_factor, origin + Vector2(21.0, 8.0) * scale_factor, tint, 2.0 * scale_factor, true) + canvas.draw_line(origin + Vector2(9.0, 20.0) * scale_factor, origin + Vector2(21.0, 20.0) * scale_factor, tint, 2.0 * scale_factor, true) + + +static func _draw_ragdoll(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + var center: Vector2 = origin + Vector2(14.0, 15.0) * scale_factor + var points := PackedVector2Array([ + center + Vector2(0.0, -13.0) * scale_factor, + center + Vector2(3.0, -5.0) * scale_factor, + center + Vector2(10.0, -10.0) * scale_factor, + center + Vector2(7.0, -2.0) * scale_factor, + center + Vector2(14.0, 1.0) * scale_factor, + center + Vector2(6.0, 4.0) * scale_factor, + center + Vector2(10.0, 11.0) * scale_factor, + center + Vector2(2.0, 7.0) * scale_factor, + center + Vector2(-2.0, 14.0) * scale_factor, + center + Vector2(-5.0, 6.0) * scale_factor, + center + Vector2(-12.0, 10.0) * scale_factor, + center + Vector2(-8.0, 2.0) * scale_factor, + center + Vector2(-14.0, -2.0) * scale_factor, + center + Vector2(-6.0, -5.0) * scale_factor, + center + Vector2(-10.0, -12.0) * scale_factor, + center + Vector2(-2.0, -7.0) * scale_factor, + ]) + canvas.draw_colored_polygon(points, tint) + + +static func _draw_recover(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + var center: Vector2 = origin + Vector2(14.0, 15.0) * scale_factor + var arc := PackedVector2Array() + for index: int in 16: + var angle: float = deg_to_rad(-55.0 + float(index) / 15.0 * 285.0) + arc.append(center + Vector2(cos(angle), sin(angle)) * 10.0 * scale_factor) + canvas.draw_polyline(arc, tint, 2.4 * scale_factor, true) + var end_angle: float = deg_to_rad(230.0) + var tip: Vector2 = center + Vector2(cos(end_angle), sin(end_angle)) * 10.0 * scale_factor + var tangent: Vector2 = Vector2(-sin(end_angle), cos(end_angle)) + var base: Vector2 = tip - tangent * 7.0 * scale_factor + var wing: Vector2 = Vector2(-tangent.y, tangent.x) * 4.0 * scale_factor + canvas.draw_colored_polygon(PackedVector2Array([ + tip, + base + wing, + base - wing, + ]), tint) + + +static func _draw_drop_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + canvas.draw_rect(Rect2(origin + Vector2(4.0, 3.0) * scale_factor, Vector2(20.0, 9.0) * scale_factor), tint, false, 2.2 * scale_factor, true) + canvas.draw_line(origin + Vector2(14.0, 13.0) * scale_factor, origin + Vector2(14.0, 23.0) * scale_factor, tint, 2.5 * scale_factor, true) + canvas.draw_line(origin + Vector2(14.0, 23.0) * scale_factor, origin + Vector2(8.0, 18.0) * scale_factor, tint, 2.5 * scale_factor, true) + canvas.draw_line(origin + Vector2(14.0, 23.0) * scale_factor, origin + Vector2(20.0, 18.0) * scale_factor, tint, 2.5 * scale_factor, true) + canvas.draw_line(origin + Vector2(4.0, 28.0) * scale_factor, origin + Vector2(24.0, 28.0) * scale_factor, tint, 2.0 * scale_factor, true) + + +static func _draw_throw_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + var bow := PackedVector2Array([ + origin + Vector2(17.0, 3.0) * scale_factor, + origin + Vector2(11.0, 5.0) * scale_factor, + origin + Vector2(6.0, 9.0) * scale_factor, + origin + Vector2(4.0, 15.0) * scale_factor, + origin + Vector2(6.0, 21.0) * scale_factor, + origin + Vector2(11.0, 25.0) * scale_factor, + origin + Vector2(17.0, 27.0) * scale_factor, + ]) + canvas.draw_polyline(bow, tint, 2.4 * scale_factor, true) + canvas.draw_line(origin + Vector2(17.0, 3.0) * scale_factor, origin + Vector2(17.0, 27.0) * scale_factor, tint, 1.6 * scale_factor, true) + canvas.draw_line(origin + Vector2(2.0, 15.0) * scale_factor, origin + Vector2(26.0, 15.0) * scale_factor, tint, 2.6 * scale_factor, true) + canvas.draw_polyline(PackedVector2Array([ + origin + Vector2(20.0, 9.0) * scale_factor, + origin + Vector2(27.0, 15.0) * scale_factor, + origin + Vector2(20.0, 21.0) * scale_factor, + ]), tint, 2.6 * scale_factor, true) + + +static func _draw_set_down_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + canvas.draw_line(origin + Vector2(14.0, 3.0) * scale_factor, origin + Vector2(14.0, 12.0) * scale_factor, tint, 2.4 * scale_factor, true) + canvas.draw_polyline(PackedVector2Array([ + origin + Vector2(9.0, 8.0) * scale_factor, + origin + Vector2(14.0, 13.0) * scale_factor, + origin + Vector2(19.0, 8.0) * scale_factor, + ]), tint, 2.4 * scale_factor, true) + canvas.draw_rect(Rect2(origin + Vector2(6.0, 14.0) * scale_factor, Vector2(16.0, 10.0) * scale_factor), tint, false, 2.2 * scale_factor, true) + canvas.draw_line(origin + Vector2(3.0, 27.0) * scale_factor, origin + Vector2(25.0, 27.0) * scale_factor, tint, 2.0 * scale_factor, true) + + +static func _draw_use_prop(canvas: CanvasItem, origin: Vector2, scale_factor: float, tint: Color) -> void: + canvas.draw_rect(Rect2(origin + Vector2(3.0, 12.0) * scale_factor, Vector2(16.0, 13.0) * scale_factor), tint, false, 2.2 * scale_factor, true) + var spark := PackedVector2Array([ + origin + Vector2(22.0, 3.0) * scale_factor, + origin + Vector2(24.0, 9.0) * scale_factor, + origin + Vector2(28.0, 11.0) * scale_factor, + origin + Vector2(23.0, 13.0) * scale_factor, + origin + Vector2(21.0, 18.0) * scale_factor, + origin + Vector2(19.0, 13.0) * scale_factor, + origin + Vector2(15.0, 11.0) * scale_factor, + origin + Vector2(20.0, 9.0) * scale_factor, + origin + Vector2(22.0, 3.0) * scale_factor, + ]) + canvas.draw_polyline(spark, tint, 2.0 * scale_factor, true) diff --git a/scripts/shared/stage_ui/stage_action_summary_glyph.gd.uid b/scripts/shared/stage_ui/stage_action_summary_glyph.gd.uid new file mode 100644 index 0000000..52f4784 --- /dev/null +++ b/scripts/shared/stage_ui/stage_action_summary_glyph.gd.uid @@ -0,0 +1 @@ +uid://jv06t4nihqrb diff --git a/scripts/shared/stage_ui/stage_inspector_panel.gd b/scripts/shared/stage_ui/stage_inspector_panel.gd index c9b0884..46b46ea 100644 --- a/scripts/shared/stage_ui/stage_inspector_panel.gd +++ b/scripts/shared/stage_ui/stage_inspector_panel.gd @@ -18,6 +18,7 @@ var _rename_button: Button var _close_button: Button var _scroll: ScrollContainer var _body: VBoxContainer +var _pinned: VBoxContainer var _collapsible: bool = false var _collapsed: bool = false var _close_visible: bool = true @@ -167,6 +168,10 @@ func body() -> VBoxContainer: return _body +func pinned_content() -> VBoxContainer: + return _pinned + + func set_safe_rect(rect: Rect2) -> void: _safe_rect = rect request_fit() @@ -184,6 +189,22 @@ func reset_scroll() -> void: _scroll.scroll_vertical = 0 +func get_body_scroll_vertical() -> int: + return _scroll.scroll_vertical if _scroll != null else 0 + + +func set_body_scroll_vertical(value: int) -> void: + if _scroll != null: + _scroll.scroll_vertical = maxi(0, value) + + +func scroll_to_control(control: Control) -> void: + if _scroll == null or control == null or not is_instance_valid(control): + return + var content_y: float = control.global_position.y - _scroll.get_global_rect().position.y + _scroll.scroll_vertical + _scroll.scroll_vertical = maxi(0, int(roundf(content_y))) + + func focus_first_control() -> void: if _body == null: return @@ -274,10 +295,10 @@ func _build_shell() -> void: _header.add_child(_icon) _title = Label.new() _title.name = "InspectorTitle" - _title.size_flags_horizontal = Control.SIZE_EXPAND_FILL _title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _title.max_lines_visible = 2 _title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _title.size_flags_horizontal = Control.SIZE_EXPAND_FILL | Control.SIZE_SHRINK_END _title.mouse_filter = Control.MOUSE_FILTER_IGNORE _title.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _header.add_child(_title) @@ -303,6 +324,10 @@ func _build_shell() -> void: _close_button.focus_mode = Control.FOCUS_ALL _close_button.pressed.connect(_on_close_button_pressed) _header.add_child(_close_button) + _pinned = VBoxContainer.new() + _pinned.name = "InspectorPinnedContent" + _pinned.add_theme_constant_override("separation", 8) + _layout.add_child(_pinned) _scroll = ScrollContainer.new() _scroll.name = "InspectorBodyScroll" @@ -326,7 +351,8 @@ func _fit_to_content() -> void: rect = get_viewport_rect() var inset := float(_integer("stage_panels.shell.edge_inset", 12, 0, 48)) var preferred_width := float(_integer("stage_panels.shell.preferred_width", 380, 220, 720)) - var card_width := minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0)) + var max_width := float(_integer("stage_panels.shell.max_width", 380, 220, 720)) + var card_width := minf(minf(preferred_width, max_width), maxf(1.0, rect.size.x - inset * 2.0)) custom_minimum_size.x = card_width size.x = card_width await get_tree().process_frame @@ -338,7 +364,9 @@ func _fit_to_content() -> void: rect = _safe_rect if rect.size.x <= 0.0 or rect.size.y <= 0.0: rect = get_viewport_rect() - card_width = minf(preferred_width, maxf(1.0, rect.size.x - inset * 2.0)) + preferred_width = float(_integer("stage_panels.shell.preferred_width", 380, 220, 720)) + max_width = float(_integer("stage_panels.shell.max_width", 380, 220, 720)) + card_width = minf(minf(preferred_width, max_width), maxf(1.0, rect.size.x - inset * 2.0)) custom_minimum_size.x = card_width size.x = card_width var padding := float(_integer("stage_panels.shell.padding", 12, 4, 28)) @@ -346,7 +374,9 @@ func _fit_to_content() -> void: var header_height := maxf(44.0, _header.get_combined_minimum_size().y) var body_height := _body.get_combined_minimum_size().y var show_body: bool = not _collapsed and _body_visible - var fixed_height := header_height + (gap if show_body else 0.0) + padding * 2.0 + var pinned_visible: bool = _pinned.visible and _pinned.get_child_count() > 0 + var pinned_height: float = _pinned.get_combined_minimum_size().y if pinned_visible else 0.0 + var fixed_height := header_height + (gap if pinned_visible else 0.0) + pinned_height + (gap if show_body else 0.0) + padding * 2.0 var available_height := maxf(72.0, rect.size.y - inset * 2.0) var natural_height := fixed_height + (body_height if show_body else 0.0) var card_height := minf(natural_height, available_height) @@ -355,8 +385,12 @@ func _fit_to_content() -> void: # previous full-content minimum can clamp the new height until another layout. _scroll.custom_minimum_size.y = scroll_height _scroll.update_minimum_size() - position = Vector2(rect.position.x + rect.size.x - inset - card_width, rect.position.y + inset) size = Vector2(card_width, card_height) + # Containers can raise the actual width to fit their children. Anchor from that + # final width so a content minimum cannot consume the right-edge gutter. + var left_limit := rect.position.x + inset + var right_limit := rect.position.x + rect.size.x - inset - size.x + position = Vector2(clampf(right_limit, left_limit, maxf(left_limit, right_limit)), rect.position.y + inset) _fit_pending = false diff --git a/scripts/stage/action_registry.gd b/scripts/stage/action_registry.gd index ac05f29..8ab1771 100644 --- a/scripts/stage/action_registry.gd +++ b/scripts/stage/action_registry.gd @@ -12,6 +12,7 @@ enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER } const MOVE_TO: String = "move_to" const WALK: String = "walk" const RUN: String = "run" +const WAIT: String = "wait" const ACTION_TEMPLATES := { MOVE_TO: { @@ -54,7 +55,7 @@ const ACTION_TEMPLATES := { }, "pick_up_prop": { "label": "Pick Up Prop", - "icon": "🫴", + "icon": "✋", "params": [ { "key": "target_prop_id", "type": "prop", "required": true }, { "key": "hand", "type": "choice", "default": "auto", "choices": ["auto", "left", "right"] }, @@ -108,6 +109,12 @@ static func has_type(type: String) -> bool: return ACTION_TEMPLATES.has(type) +static func has_editable_parameters(type: String) -> bool: + var template: Dictionary = ACTION_TEMPLATES.get(canonical_type(type), {}) + var params: Variant = template.get("params", []) + return params is Array and not (params as Array).is_empty() + + static func canonical_type(type: String) -> String: return MOVE_TO if type == "walk_to" else type @@ -126,6 +133,19 @@ static func is_move_action(action: Dictionary) -> bool: return type == MOVE_TO or type == "walk_to" +static func can_add_authored_action_together(existing_actions: Array[Dictionary], candidate_type: String) -> bool: + var normalized_candidate: String = canonical_type(candidate_type) + if existing_actions.size() != 1 or not has_type(normalized_candidate): + return false + var existing_type: String = canonical_type(String(existing_actions[0].get("type", ""))) + if not has_type(existing_type): + return false + # The runner supports one primary action with one speech overlay. Keep the + # pair explicit: Say may accompany any registered primary action, but two + # primary actions or two Say actions still need separate steps. + return (normalized_candidate == "speak" and existing_type != "speak") or (existing_type == "speak" and normalized_candidate != "speak") + + static func label(type: String) -> String: var tpl: Dictionary = ACTION_TEMPLATES.get(type, {}) return String(tpl.get("label", type)) @@ -219,7 +239,7 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary: ## Human-readable one-line summary for both flat queue actions and rule-action ## shapes (the get() fallbacks tolerate either key layout). -static func summarize(action: Dictionary) -> String: +static func summarize(action: Dictionary, prop_name_resolver: Callable = Callable()) -> String: var type := canonical_type(String(action.get("type", ""))) var params: Dictionary = action.get("params", {}) match type: @@ -243,7 +263,12 @@ static func summarize(action: Dictionary) -> String: "recover": return "Recover" "pick_up_prop": - return "Pick Up Prop (%s)" % String(action.get("target_prop_id", params.get("target_prop_id", ""))) + var prop_id: String = String(action.get("target_prop_id", params.get("target_prop_id", ""))) + if prop_name_resolver.is_valid() and not prop_id.is_empty(): + var prop_name: String = String(prop_name_resolver.call(prop_id)).strip_edges() + if not prop_name.is_empty(): + return "Pick Up Prop · %s" % prop_name + return "Pick Up Prop" "drop_prop": return "Drop Prop" "throw_prop": diff --git a/scripts/stage/sandbox_theme.gd b/scripts/stage/sandbox_theme.gd index 4fa9bbd..f793e8e 100644 --- a/scripts/stage/sandbox_theme.gd +++ b/scripts/stage/sandbox_theme.gd @@ -42,11 +42,11 @@ const DEFAULT_CONFIG: Dictionary = { "mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"}, "effects": {"impact_wobble": {"duration": 0.48, "maximum_angle_degrees": 7.0, "sideways_offset": 4.0}}, "grid": {"snap_size": 15.0}, - "chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[560,300], "font_size":22, "message_font_size":20}}, + "chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "timestamp_badge": {"corner_radius": 10, "padding": 8, "bg_color": "#171B24F2"}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,460], "confirm_size":[560,300], "font_size":22, "message_font_size":20}}, "popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {"surface_color_size":[560,640], "surface_color_preview_size":[420,140], "surface_color_picker_min_size":[320,340], "action_min_target_size":48}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156], "surface_preview_size":[420,132], "look_cue_size":[80,42], "setup_gap":8, "setup_title_size":30, "setup_heading_size":26, "setup_choice_font_size":22, "setup_choice_height":68, "setup_choice_corner_radius":22, "setup_choice_border_width":2, "tab_font_size":22, "tab_height":60, "filter_chip_font_size":20, "filter_chip_height":48, "filter_chip_gap":8, "setup_button_font_size":20, "button_border_width":2, "footer_gap":16, "surface_corner_radius":14, "look_corner_radius":8, "corner_radius":16, "compact_swatch_size":42, "color_group_gap":8, "action_color":"#087DBA", "separator_color":"#AAB2C0A0"}}, "palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3}, "stage_panels": { - "shell": {"preferred_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"}, + "shell": {"preferred_width": 380, "max_width": 380, "edge_inset": 12, "padding": 12, "header_gap": 8, "section_gap": 12, "corner_radius": 10, "surface_color": "#252B38FA", "border_color": "#FFFFFF33"}, "type": {"title_size": 24, "section_size": 20, "body_size": 20, "helper_size": 16, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0"}, "choice": {"minimum_height": 56, "row_gap": 6, "neutral_surface": "#171B24", "hover_surface": "#303A4B", "selected_surface": "#12394A", "disabled_surface": "#252B38", "border_color": "#FFFFFF33"}, "toggle": {"minimum_height": 64, "help_minimum_height": 72, "row_padding": 12, "switch_width": 64, "switch_height": 34, "knob_size": 26, "neutral_surface": "#202633", "hover_surface": "#2B3442", "selected_surface": "#173443", "disabled_surface": "#252B38", "border_color": "#FFFFFF33", "switch_on_color": "#22C6FF", "switch_off_color": "#4A5362", "switch_disabled_color": "#3B414D", "knob_color": "#F5F7FA"}, @@ -55,7 +55,7 @@ const DEFAULT_CONFIG: Dictionary = { "spots": {"row_gap": 8, "pin_visual_size": 30, "pin_hit_size": 44, "pin_outline_width": 2, "focus_ring_width": 3, "name_pill_padding": 10, "pin_surface_color": "#F4F7FB", "pin_outline_color": "#171B24", "completion_duration": 0.6} }, "stage_surface_colors": {"section_gap": 8, "swatch_gap": 8, "swatch_size": 48, "swatch_border": "#FFFFFFB8", "swatch_focus_border": "#FFFFFF", "swatch_corner_radius": 8, "swatch_border_width": 2, "swatch_check_size": 24, "selected_border_width": 4, "focus_border_width": 3}, - "overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36}, "use_preview": {"valid_color": "#42D887", "invalid_color": "#F06464", "uncertain_color": "#FFB300", "neutral_color": "#C5CAD4", "completion_duration": 0.6}}, + "overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "completed_path_alpha": 0.20, "completed_path_color": "#959EAD", "rule_connector_color": "#AAB2C0", "rule_action_color": "#7E57C2", "director_plan": {"route_color": "#8BD9FF", "run_color": "#FFB300", "say_color": "#FFD84D", "wait_color": "#B69CFF", "completed_color": "#959EAD", "failed_color": "#F05B57", "candidate_valid_color": "#4DE666", "candidate_invalid_color": "#FF626B", "flag_size_px": 44, "flag_number_size_px": 18, "label_bg_color": "#101720E8", "label_border_alpha": 0.8, "spotlight_route_width_px": 4, "spotlight_route_shadow_width_px": 6, "spotlight_dash_length_px": 12, "spotlight_dash_gap_px": 7, "spotlight_route_alpha": 0.95, "unfocused_step_alpha": 0.24, "action_badge_font_size_px": 18, "action_badge_padding_px": [10, 6], "action_badge_radius_px": 11, "action_badge_border_width_px": 2, "action_badge_icon_slot_width_px": 22, "action_badge_icon_text_gap_px": 6, "action_badge_icon_scale": 0.72, "action_badge_stack_gap_px": 4, "action_badge_route_gap_px": 6, "actor_name_font_size_px": 20, "actor_name_max_width_px": 260, "actor_name_padding_px": [12, 7], "actor_name_radius_px": 12, "actor_name_border_width_px": 2, "actor_name_offset_px": 26, "route_badge_fraction": 0.32, "actor_name_border_color": "#FFB300", "action_badge_text_color": "#F5F7FA", "route_shadow_color": "#101720D9", "action_row_move_surface": "#17313D", "action_row_say_surface": "#2C281C", "action_row_wait_surface": "#29243A", "action_row_other_surface": "#202632", "action_row_icon_surface": "#252B38", "action_row_text_color": "#F5F7FA", "action_row_duration_color": "#AAB2C0", "action_row_options_color": "#8E9AAA", "action_row_font_size_px": 18, "action_row_duration_font_size_px": 14, "action_row_icon_font_size_px": 20, "action_row_height_px": 48, "action_row_icon_size_px": 32, "action_row_rail_width_px": 4, "action_row_options_width_px": 24, "action_row_options_font_size_px": 18, "action_row_radius_px": 8, "action_row_selected_border_width_px": 2}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}, "set_down": {"unknown_color": "#C5CAD4", "reach_color": "#FFB300", "ghost_alpha": 0.36, "surface_line_width_px": 5, "surface_hover_line_width_px": 7, "surface_alpha": 0.75, "surface_hover_alpha": 0.95}, "use_preview": {"valid_color": "#42D887", "invalid_color": "#F06464", "uncertain_color": "#FFB300", "neutral_color": "#C5CAD4", "completion_duration": 0.6}}, "widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"small", "label":"Small", "value":20}, {"id":"medium", "label":"Medium", "value":28}, {"id":"huge", "label":"HUGE", "value":40}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}, "speech": {"toolbar": {"surface":"#1C2431F7", "border":"#9EADBF", "selection_halo":"#FFD84D", "gap":10, "max_width":480, "min_hit_height":48}, "ui": {"panel_width": 440, "button_min_height": 44, "heading_size": 24}, "layout": {"max_width": 420, "stack_gap": 12, "screen_margin": 18, "bubble_padding": 18}, "animation": {"letter_interval": 0.045, "word_interval": 0.20, "pop_duration": 0.24}, "styles": {"talk": {"fill":"#FFFDF6", "outline":"#202020", "tail":"talk"}, "shout": {"fill":"#FFD642", "outline":"#D92F26", "tail":"shout"}, "think": {"fill":"#E9F5FF", "outline":"#37698B", "tail":"think"}, "whisper": {"fill":"#F2F2F2", "outline":"#6D7885", "tail":"whisper"}, "sfx": {"fill":"#FFDD39", "outline":"#D92F26", "tail":"none"}}, "text_colors": [{"name":"Ink","color":"#202020"},{"name":"Midnight","color":"#111827"},{"name":"Slate","color":"#475569"},{"name":"Snow","color":"#FFFFFF"},{"name":"Cloud","color":"#CBD5E1"},{"name":"Sky","color":"#2196F3"},{"name":"Ocean","color":"#0077B6"},{"name":"Teal","color":"#00897B"},{"name":"Leaf","color":"#2E7D32"},{"name":"Lime","color":"#8BC34A"},{"name":"Sunshine","color":"#FFC107"},{"name":"Orange","color":"#F57C00"},{"name":"Sunset red","color":"#E53935"},{"name":"Rose","color":"#D81B60"},{"name":"Violet","color":"#7E57C2"},{"name":"Brown","color":"#795548"}], "preview_ghost":"#FFFFFF99", "connector":"#FFFFFFB0"} } @@ -230,6 +230,8 @@ func _apply_phase19_defaults() -> void: "candidate_name_pill_border_width": 3.0, "candidate_name_pill_padding_x": 12.0, "candidate_name_pill_padding_y": 8.0, + "selected_name_pill_font_size_px": 20.0, + "selected_name_pill_max_width_px": 260.0, } for key: String in defaults: if not gizmos.has(key): diff --git a/scripts/stage/stage_action_identity.gd b/scripts/stage/stage_action_identity.gd new file mode 100644 index 0000000..73a2dce --- /dev/null +++ b/scripts/stage/stage_action_identity.gd @@ -0,0 +1,83 @@ +class_name StageActionIdentity +extends RefCounted +## Scene-scoped action ID allocation for later event links. + +static var _fallback_counter: int = 1 + + +func allocate(world: Node2D) -> String: + var used: Dictionary = _collect_ids(world) + var candidate: String = "" + for _attempt: int in 16: + var unique_number: int = ResourceUID.create_id() + candidate = "action_%s" % String.num_int64(unique_number, 16) + if not used.has(candidate): + return candidate + while used.has(candidate) or candidate.is_empty(): + candidate = "action_%d_%d" % [Time.get_ticks_usec(), _fallback_counter] + _fallback_counter += 1 + return candidate + + +func validate_scene_ids(objects: Array) -> String: + var seen: Dictionary = {} + for raw_object: Variant in objects: + if not raw_object is Dictionary: + continue + var object: Dictionary = raw_object + if String(object.get("type", "")) != "stickman": + continue + var plan_value: Variant = object.get("action_plan", []) + if not plan_value is Array: + continue + for raw_step: Variant in plan_value: + if not raw_step is Dictionary: + continue + var actions_value: Variant = (raw_step as Dictionary).get("actions", []) + if not actions_value is Array: + continue + for raw_action: Variant in actions_value: + if not raw_action is Dictionary: + continue + var action_id: String = String((raw_action as Dictionary).get("action_id", "")) + if action_id.strip_edges().is_empty() or seen.has(action_id): + return "Action IDs must be unique and non-empty across the scene." + seen[action_id] = true + return "" + + +func _collect_ids(world: Node2D) -> Dictionary: + var used: Dictionary = {} + if world == null or not is_instance_valid(world): + return used + for child: Node in world.get_children(): + if child is StickmanRig: + _collect_plan_ids((child as StickmanRig).get_action_plan(), used) + elif child.has_method("get"): + var original_value: Variant = child.get("original_entry") + if original_value is Dictionary: + _collect_entry_ids(original_value as Dictionary, used) + return used + + +func _collect_entry_ids(entry: Dictionary, used: Dictionary) -> void: + var plan_value: Variant = entry.get("action_plan", []) + if not plan_value is Array: + return + var plan: Array[Dictionary] = [] + for raw_step: Variant in plan_value: + if raw_step is Dictionary: + plan.append(raw_step as Dictionary) + _collect_plan_ids(plan, used) + + +func _collect_plan_ids(plan: Array[Dictionary], used: Dictionary) -> void: + for step: Dictionary in plan: + var actions_value: Variant = step.get("actions", []) + if not actions_value is Array: + continue + for raw_action: Variant in actions_value: + if raw_action is Dictionary: + var action_id: String = String((raw_action as Dictionary).get("action_id", "")) + if not action_id.is_empty(): + used[action_id] = true diff --git a/scripts/stage/stage_action_identity.gd.uid b/scripts/stage/stage_action_identity.gd.uid new file mode 100644 index 0000000..7b2f321 --- /dev/null +++ b/scripts/stage/stage_action_identity.gd.uid @@ -0,0 +1 @@ +uid://dum44yvr5v1k0 diff --git a/scripts/stage/stage_action_plan.gd b/scripts/stage/stage_action_plan.gd new file mode 100644 index 0000000..b7a403b --- /dev/null +++ b/scripts/stage/stage_action_plan.gd @@ -0,0 +1,157 @@ +class_name StageActionPlan +extends RefCounted +## Pure helpers for ordered authored action steps. + +const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") + + +static func validate_steps(steps: Array[Dictionary]) -> String: + var seen_ids: Dictionary = {} + for step_index: int in steps.size(): + var actions_value: Variant = steps[step_index].get("actions", null) + if not actions_value is Array or (actions_value as Array).is_empty(): + return "Step %d must contain an action." % (step_index + 1) + var actions: Array = actions_value + if actions.size() > 2: + return "Step %d has too many actions." % (step_index + 1) + var typed_actions: Array[Dictionary] = [] + for raw_action: Variant in actions: + if not raw_action is Dictionary: + return "Step %d contains an invalid action." % (step_index + 1) + var action: Dictionary = raw_action + var action_id: Variant = action.get("action_id", null) + if not action_id is String or String(action_id).strip_edges().is_empty(): + return "Actions must have non-empty string IDs." + if seen_ids.has(String(action_id)): + return "Action IDs must be unique." + seen_ids[String(action_id)] = true + var action_type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) + if not ACTION_REGISTRY.has_type(action_type): + return "Step %d contains an unknown action." % (step_index + 1) + typed_actions.append(action) + if typed_actions.size() == 2: + if not can_add_together({"actions": [typed_actions[0]]}, String(typed_actions[1].get("type", ""))): + return "Step %d contains actions that cannot run together." % (step_index + 1) + return "" + + +static func can_add_together(step: Dictionary, action_type: String) -> bool: + var actions_value: Variant = step.get("actions", []) + if not actions_value is Array: + return false + var existing_actions: Array[Dictionary] = [] + for raw_action: Variant in actions_value: + if not raw_action is Dictionary: + return false + existing_actions.append(raw_action as Dictionary) + return ACTION_REGISTRY.can_add_authored_action_together(existing_actions, action_type) + + +static func append_step(steps: Array[Dictionary], action: Dictionary) -> Array[Dictionary]: + var result: Array[Dictionary] = steps.duplicate(true) + result.append({"actions": [action.duplicate(true)]}) + return result + + +static func append_together(steps: Array[Dictionary], step_index: int, action: Dictionary) -> Array[Dictionary]: + var result: Array[Dictionary] = steps.duplicate(true) + if step_index < 0 or step_index >= result.size() or not can_add_together(result[step_index], String(action.get("type", ""))): + return [] + var actions: Array = (result[step_index].get("actions", []) as Array).duplicate(true) + actions.append(action.duplicate(true)) + result[step_index]["actions"] = actions + return result + + +static func replace_action(steps: Array[Dictionary], action_id: String, replacement: Dictionary) -> Array[Dictionary]: + var result: Array[Dictionary] = steps.duplicate(true) + var location: Dictionary = find_action(result, action_id) + if location.is_empty(): + return [] + var step_index: int = int(location["step_index"]) + var action_index: int = int(location["action_index"]) + var actions: Array = (result[step_index].get("actions", []) as Array).duplicate(true) + var updated: Dictionary = replacement.duplicate(true) + updated["action_id"] = action_id + actions[action_index] = updated + if actions.size() == 2 and not validate_steps([{"actions": actions}]).is_empty(): + return [] + result[step_index]["actions"] = actions + return result + + +static func flatten(steps: Array[Dictionary]) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for step_index: int in steps.size(): + var raw_actions: Variant = steps[step_index].get("actions", []) + if not raw_actions is Array: + continue + for raw_action: Variant in raw_actions: + if not raw_action is Dictionary: + continue + var action: Dictionary = (raw_action as Dictionary).duplicate(true) + action["step_index"] = step_index + if ACTION_REGISTRY.is_move_action(action): + var destination_value: Variant = action.get("destination", null) + if destination_value is Dictionary: + var destination: Dictionary = destination_value + if String(destination.get("kind", "")) == "place": + action["target"] = _data_to_vector(destination.get("position", {})) + else: + action["target"] = _data_to_vector(destination.get("last_known_approach", {})) + result.append(action) + return result + + +static func find_action(steps: Array[Dictionary], action_id: String) -> Dictionary: + if action_id.is_empty(): + return {} + for step_index: int in steps.size(): + var raw_actions: Variant = steps[step_index].get("actions", []) + if not raw_actions is Array: + continue + for action_index: int in (raw_actions as Array).size(): + var raw_action: Variant = (raw_actions as Array)[action_index] + if raw_action is Dictionary and String((raw_action as Dictionary).get("action_id", "")) == action_id: + return {"step_index": step_index, "action_index": action_index, "action": (raw_action as Dictionary).duplicate(true)} + return {} + + +static func move_step(steps: Array[Dictionary], from_index: int, to_index: int) -> Array[Dictionary]: + var result: Array[Dictionary] = steps.duplicate(true) + if from_index < 0 or from_index >= result.size() or to_index < 0 or to_index >= result.size(): + return result + var step: Dictionary = result.pop_at(from_index) + result.insert(to_index, step) + return result + + +static func remove_action(steps: Array[Dictionary], action_id: String) -> Array[Dictionary]: + var result: Array[Dictionary] = steps.duplicate(true) + for step_index: int in range(result.size() - 1, -1, -1): + var actions_value: Variant = result[step_index].get("actions", []) + if not actions_value is Array: + continue + var actions: Array = actions_value + for action_index: int in range(actions.size() - 1, -1, -1): + var raw_action: Variant = actions[action_index] + if raw_action is Dictionary and String((raw_action as Dictionary).get("action_id", "")) == action_id: + actions.remove_at(action_index) + if actions.is_empty(): + result.remove_at(step_index) + else: + result[step_index]["actions"] = actions + return result + return result + + +static func _data_to_vector(value: Variant) -> Vector2: + if not value is Dictionary: + return Vector2.ZERO + var data: Dictionary = value + var x_value: Variant = data.get("x", null) + var y_value: Variant = data.get("y", null) + if not (x_value is float or x_value is int) or not (y_value is float or y_value is int): + return Vector2.ZERO + var point := Vector2(float(x_value), float(y_value)) + return point if point.is_finite() else Vector2.ZERO diff --git a/scripts/stage/stage_action_plan.gd.uid b/scripts/stage/stage_action_plan.gd.uid new file mode 100644 index 0000000..9d5b229 --- /dev/null +++ b/scripts/stage/stage_action_plan.gd.uid @@ -0,0 +1 @@ +uid://dq1q3dyink3av diff --git a/scripts/stage/stage_director_visuals.gd b/scripts/stage/stage_director_visuals.gd index 54e6eb3..6d3e140 100644 --- a/scripts/stage/stage_director_visuals.gd +++ b/scripts/stage/stage_director_visuals.gd @@ -2,29 +2,58 @@ class_name StageDirectorVisuals extends Node2D ## StageDirectorVisuals - Edit-mode director overlay (Phase 3a). ## -## Draws each stickman's action queue as walk-to waypoint dots with dashed -## connectors plus small badges for speak/wait/ragdoll/recover, with order -## numbers. Pure drawing; no hit-testing. Visible in EDIT, hidden in PLAY. +## Draws the shared action-plan projection as grounded numbered move flags and +## dashed routes, plus small action badges. Route rendering and flag hit-testing +## use the same projected positions. Hidden in EDIT and visible in DIRECT. const STICKMAN_RIG := preload("res://scripts/characters/stickman/stickman_rig.gd") const ACTION_RUNNER := preload("res://scripts/characters/stickman/stickman_action_runner.gd") const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") +const STAGE_SELECTION := preload("res://scripts/stage/stage_selection.gd") +const ACTION_SUMMARY_GLYPH := preload("res://scripts/shared/stage_ui/stage_action_summary_glyph.gd") const WAYPOINT_RADIUS_PX := 6.0 +const PLAN_FLAG_SIZE_PX := 44.0 +const PLAN_FLAG_NUMBER_SIZE_PX := 18.0 const WAYPOINT_COLOR := Color(0.2, 0.5, 1.0) const WAYPOINT_OUTLINE := Color.WHITE const DASH_COLOR := Color(1.0, 1.0, 1.0, 0.6) const DASH_WIDTH_PX := 2.0 const DASH_LENGTH_PX := 6.0 const DASH_GAP_PX := 4.0 +const MAX_ROUTE_DASHES := 1024 +const SPOTLIGHT_ROUTE_WIDTH_PX := 4.0 +const SPOTLIGHT_ROUTE_SHADOW_WIDTH_PX := 6.0 +const SPOTLIGHT_DASH_LENGTH_PX := 12.0 +const SPOTLIGHT_DASH_GAP_PX := 7.0 +const SPOTLIGHT_ROUTE_ALPHA := 0.95 +const UNFOCUSED_STEP_ALPHA := 0.24 +const ACTION_BADGE_FONT_SIZE_PX := 18.0 +const ACTION_BADGE_PADDING_PX := Vector2(10.0, 6.0) +const ACTION_BADGE_RADIUS_PX := 11.0 +const ACTION_BADGE_BORDER_WIDTH_PX := 2.0 +const ACTION_BADGE_ICON_SLOT_WIDTH_PX := 22.0 +const ACTION_BADGE_ICON_TEXT_GAP_PX := 6.0 +const ACTION_BADGE_ICON_SCALE := 0.72 +const ACTION_BADGE_STACK_GAP_PX := 4.0 +const ACTION_BADGE_ROUTE_GAP_PX := 6.0 +const ACTOR_NAME_FONT_SIZE_PX := 20.0 +const ACTOR_NAME_MAX_WIDTH_PX := 260.0 +const ACTOR_NAME_PADDING_PX := Vector2(12.0, 7.0) +const ACTOR_NAME_RADIUS_PX := 12.0 +const ACTOR_NAME_BORDER_WIDTH_PX := 2.0 +const ACTOR_NAME_OFFSET_PX := 26.0 +const ROUTE_BADGE_FRACTION := 0.32 const NUMBER_COLOR := Color.WHITE const NUMBER_FONT_SIZE_PX := 16.0 const ICON_COLOR := Color(1.0, 1.0, 1.0, 0.9) const ICON_SIZE_PX := 12.0 const BADGE_STACK_STEP := Vector2(0.0, -28.0) +const PLAN_SAY_MARKER_OFFSET_PX := Vector2(-112.0, -120.0) +const PLAN_SAY_MARKER_SIZE_PX := Vector2(30.0, 24.0) ## Phase 4 rule rendering constants. -const WAYPOINT_HIT_RADIUS_PX := 14.0 +const WAYPOINT_HIT_RADIUS_PX := 22.0 const RULE_TRIGGER_COLOR := Color(0.2, 0.85, 0.3) const RULE_ACTION_COLOR := Color("#7E57C2") const RULE_CONNECTOR_COLOR := Color("#AAB2C0") @@ -34,6 +63,8 @@ const RULE_LABEL_FONT_SIZE_PX := 14.0 const RULE_BADGE_RADIUS_PX := 7.0 const RULE_DELETE_HIT_PX := 14.0 const RULE_DELETE_COLOR := Color(1.0, 0.3, 0.3, 0.9) +const PICKUP_CANDIDATE_COLOR := Color("#FFB300") +const PICKUP_HOVER_COLOR := Color("#FFE17A") var camera: Camera2D = null var world: Node2D = null @@ -50,6 +81,45 @@ var completed_path_color: Color = Color(0.58, 0.62, 0.68) var rule_label_font_size: float = RULE_LABEL_FONT_SIZE_PX var rule_action_color: Color = RULE_ACTION_COLOR var rule_connector_color: Color = RULE_CONNECTOR_COLOR +var plan_route_color: Color = Color("#8BD9FF") +var plan_run_color: Color = Color("#FFB300") +var plan_say_color: Color = Color("#FFD84D") +var plan_wait_color: Color = Color("#B69CFF") +var plan_completed_color: Color = Color("#959EAD") +var plan_failed_color: Color = Color("#F05B57") +var plan_candidate_valid_color: Color = Color("#4DE666") +var plan_candidate_invalid_color: Color = Color("#FF626B") +var plan_flag_size_px: float = PLAN_FLAG_SIZE_PX +var plan_flag_number_size_px: float = PLAN_FLAG_NUMBER_SIZE_PX +var plan_label_bg_color: Color = Color(0.05, 0.07, 0.10, 0.82) +var plan_label_border_alpha: float = 0.8 +var spotlight_route_width_px: float = SPOTLIGHT_ROUTE_WIDTH_PX +var spotlight_route_shadow_width_px: float = SPOTLIGHT_ROUTE_SHADOW_WIDTH_PX +var spotlight_dash_length_px: float = SPOTLIGHT_DASH_LENGTH_PX +var spotlight_dash_gap_px: float = SPOTLIGHT_DASH_GAP_PX +var spotlight_route_alpha: float = SPOTLIGHT_ROUTE_ALPHA +var unfocused_step_alpha: float = UNFOCUSED_STEP_ALPHA +var action_badge_font_size_px: float = ACTION_BADGE_FONT_SIZE_PX +var action_badge_padding_px: Vector2 = ACTION_BADGE_PADDING_PX +var action_badge_radius_px: float = ACTION_BADGE_RADIUS_PX +var action_badge_border_width_px: float = ACTION_BADGE_BORDER_WIDTH_PX +var action_badge_icon_slot_width_px: float = ACTION_BADGE_ICON_SLOT_WIDTH_PX +var action_badge_icon_text_gap_px: float = ACTION_BADGE_ICON_TEXT_GAP_PX +var action_badge_icon_scale: float = ACTION_BADGE_ICON_SCALE +var action_badge_stack_gap_px: float = ACTION_BADGE_STACK_GAP_PX +var action_badge_route_gap_px: float = ACTION_BADGE_ROUTE_GAP_PX +var actor_name_font_size_px: float = ACTOR_NAME_FONT_SIZE_PX +var actor_name_max_width_px: float = ACTOR_NAME_MAX_WIDTH_PX +var actor_name_padding_px: Vector2 = ACTOR_NAME_PADDING_PX +var actor_name_radius_px: float = ACTOR_NAME_RADIUS_PX +var actor_name_border_width_px: float = ACTOR_NAME_BORDER_WIDTH_PX +var actor_name_offset_px: float = ACTOR_NAME_OFFSET_PX +var route_badge_fraction: float = ROUTE_BADGE_FRACTION +var actor_name_border_color: Color = Color("#FFB300") +var action_badge_text_color: Color = Color("#F5F7FA") +var route_shadow_color: Color = Color("#101720D9") +var _actor_name_badge_obstacles: Array[Rect2] = [] +var _last_actor_name_badge_layouts: Array[Dictionary] = [] var emoji_font: Font = null ## Phase 3c style fonts/flags (set by the stage from sandbox_theme.json). Bold is @@ -69,6 +139,17 @@ var _rule_hit_regions: Array[Dictionary] = [] ## highlight). Vector2.INF when none. var _edit_waypoint: Vector2 = Vector2.INF var _live_session: bool = false +var _plan_candidate_rig: StickmanRig +var _plan_candidate_action_id: String = "" +var _plan_candidate_destination: Dictionary = {} +var _plan_candidate_valid: bool = false +var _plan_candidate_pointer: Vector2 = Vector2.INF +var _plan_candidate_movement_label: String = "Walk" +var _plan_spotlight_rig: StickmanRig +var _plan_spotlight_step_index: int = -1 +var plan_overlay_visible: bool = false +var _pickup_candidate_props: Array[PropBlock] = [] +var _pickup_hovered_prop: PropBlock = null ## Highlights a waypoint while its walk target is being re-placed on the stage. func set_edit_waypoint(pos: Vector2) -> void: @@ -80,6 +161,80 @@ func clear_edit_waypoint() -> void: _edit_waypoint = Vector2.INF mark_dirty() + +func set_plan_candidate(rig: StickmanRig, action_id: String, destination: Dictionary, valid: bool, pointer: Vector2, move_style: String = "walk") -> void: + _plan_candidate_rig = rig if rig != null and is_instance_valid(rig) else null + _plan_candidate_action_id = action_id + _plan_candidate_destination = destination.duplicate(true) + _plan_candidate_valid = valid + _plan_candidate_pointer = pointer + _plan_candidate_movement_label = "Run" if move_style == "run" else "Walk" + mark_dirty() + + +func clear_plan_candidate() -> void: + _plan_candidate_rig = null + _plan_candidate_action_id = "" + _plan_candidate_destination.clear() + _plan_candidate_valid = false + _plan_candidate_pointer = Vector2.INF + _plan_candidate_movement_label = "Walk" + mark_dirty() + + +func set_plan_spotlight(rig: StickmanRig, step_index: int) -> void: + if rig == null or not is_instance_valid(rig): + clear_plan_spotlight() + return + var plan: Array[Dictionary] = rig.get_action_plan() + var bounded_step: int = clampi(step_index, 0, plan.size() - 1) if not plan.is_empty() else -1 + _plan_spotlight_rig = rig + _plan_spotlight_step_index = bounded_step + mark_dirty() + + +func clear_plan_spotlight() -> void: + _plan_spotlight_rig = null + _plan_spotlight_step_index = -1 + mark_dirty() + + +## Shows the props that can be picked up while a pickup target is being chosen. +func set_pickup_candidates(props: Array[PropBlock], hovered: PropBlock = null) -> void: + var valid_props: Array[PropBlock] = [] + for prop: PropBlock in props: + if prop != null and is_instance_valid(prop) and prop.can_be_picked_up(): + valid_props.append(prop) + var valid_hovered: PropBlock = hovered if hovered != null and is_instance_valid(hovered) and valid_props.has(hovered) else null + if _pickup_candidate_props == valid_props and _pickup_hovered_prop == valid_hovered: + return + _pickup_candidate_props = valid_props + _pickup_hovered_prop = valid_hovered + mark_dirty() + + +func clear_pickup_candidates() -> void: + if _pickup_candidate_props.is_empty() and _pickup_hovered_prop == null: + return + _pickup_candidate_props.clear() + _pickup_hovered_prop = null + mark_dirty() + + +func set_actor_name_badge_obstacles(obstacles: Array[Rect2]) -> void: + if _actor_name_badge_obstacles == obstacles: + return + _actor_name_badge_obstacles = obstacles.duplicate() + mark_dirty() + + +func set_plan_overlay_visible(value: bool) -> void: + if plan_overlay_visible == value: + return + plan_overlay_visible = value + mark_dirty() + + func set_enabled(value: bool) -> void: enabled = value visible = value @@ -108,6 +263,66 @@ func set_style(cfg: Dictionary) -> void: mark_dirty() +func apply_plan_style(cfg: Dictionary) -> void: + plan_route_color = _style_color(cfg, "route_color", plan_route_color) + plan_run_color = _style_color(cfg, "run_color", plan_run_color) + plan_say_color = _style_color(cfg, "say_color", plan_say_color) + plan_wait_color = _style_color(cfg, "wait_color", plan_wait_color) + plan_completed_color = _style_color(cfg, "completed_color", plan_completed_color) + plan_failed_color = _style_color(cfg, "failed_color", plan_failed_color) + plan_candidate_valid_color = _style_color(cfg, "candidate_valid_color", plan_candidate_valid_color) + plan_candidate_invalid_color = _style_color(cfg, "candidate_invalid_color", plan_candidate_invalid_color) + plan_flag_size_px = maxf(44.0, float(cfg.get("flag_size_px", plan_flag_size_px))) + plan_flag_number_size_px = maxf(14.0, float(cfg.get("flag_number_size_px", plan_flag_number_size_px))) + plan_label_bg_color = _style_color(cfg, "label_bg_color", plan_label_bg_color) + plan_label_border_alpha = clampf(float(cfg.get("label_border_alpha", plan_label_border_alpha)), 0.0, 1.0) + spotlight_route_width_px = maxf(2.0, float(cfg.get("spotlight_route_width_px", spotlight_route_width_px))) + spotlight_route_shadow_width_px = maxf(spotlight_route_width_px, float(cfg.get("spotlight_route_shadow_width_px", spotlight_route_shadow_width_px))) + spotlight_dash_length_px = maxf(6.0, float(cfg.get("spotlight_dash_length_px", spotlight_dash_length_px))) + spotlight_dash_gap_px = maxf(3.0, float(cfg.get("spotlight_dash_gap_px", spotlight_dash_gap_px))) + spotlight_route_alpha = clampf(float(cfg.get("spotlight_route_alpha", spotlight_route_alpha)), 0.0, 1.0) + unfocused_step_alpha = clampf(float(cfg.get("unfocused_step_alpha", unfocused_step_alpha)), 0.0, 0.6) + action_badge_font_size_px = maxf(16.0, float(cfg.get("action_badge_font_size_px", action_badge_font_size_px))) + action_badge_padding_px = _style_vector2(cfg, "action_badge_padding_px", action_badge_padding_px) + action_badge_radius_px = maxf(8.0, float(cfg.get("action_badge_radius_px", action_badge_radius_px))) + action_badge_border_width_px = maxf(1.0, float(cfg.get("action_badge_border_width_px", action_badge_border_width_px))) + action_badge_icon_slot_width_px = maxf(18.0, float(cfg.get("action_badge_icon_slot_width_px", action_badge_icon_slot_width_px))) + action_badge_icon_text_gap_px = maxf(0.0, float(cfg.get("action_badge_icon_text_gap_px", action_badge_icon_text_gap_px))) + action_badge_icon_scale = clampf(float(cfg.get("action_badge_icon_scale", action_badge_icon_scale)), 0.4, 1.0) + action_badge_stack_gap_px = maxf(0.0, float(cfg.get("action_badge_stack_gap_px", action_badge_stack_gap_px))) + action_badge_route_gap_px = maxf(0.0, float(cfg.get("action_badge_route_gap_px", action_badge_route_gap_px))) + actor_name_font_size_px = maxf(18.0, float(cfg.get("actor_name_font_size_px", actor_name_font_size_px))) + actor_name_max_width_px = maxf(180.0, float(cfg.get("actor_name_max_width_px", actor_name_max_width_px))) + actor_name_padding_px = _style_vector2(cfg, "actor_name_padding_px", actor_name_padding_px) + actor_name_radius_px = maxf(8.0, float(cfg.get("actor_name_radius_px", actor_name_radius_px))) + actor_name_border_width_px = maxf(1.0, float(cfg.get("actor_name_border_width_px", actor_name_border_width_px))) + actor_name_offset_px = maxf(24.0, float(cfg.get("actor_name_offset_px", actor_name_offset_px))) + route_badge_fraction = clampf(float(cfg.get("route_badge_fraction", route_badge_fraction)), 0.15, 0.5) + actor_name_border_color = _style_color(cfg, "actor_name_border_color", actor_name_border_color) + action_badge_text_color = _style_color(cfg, "action_badge_text_color", action_badge_text_color) + route_shadow_color = _style_color(cfg, "route_shadow_color", route_shadow_color) + mark_dirty() + + +func _style_vector2(cfg: Dictionary, key: String, fallback: Vector2) -> Vector2: + var value: Variant = cfg.get(key, null) + if value is Vector2: + return value as Vector2 + if value is Array and (value as Array).size() >= 2: + var components: Array = value as Array + return Vector2(float(components[0]), float(components[1])) + return fallback + + +func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color: + var value: Variant = cfg.get(key, null) + if value is Color: + return value as Color + if value is String: + return Color.from_string(String(value), fallback) + return fallback + + ## Resolved-theme entry point. `set_style` remains for legacy callers/tests. func apply_style(style: Dictionary, fonts: Dictionary = {}) -> void: var legacy: Dictionary = {"fonts": fonts.duplicate(true)} @@ -137,8 +352,8 @@ func hit_test_rule(world_pos: Vector2) -> Dictionary: return {} -## Nearest waypoint dot to `world_pos` within a screen-constant radius, reusing -## the same anchor math as _draw_rig_queue. Returns Vector2.INF on miss. +## Hit-tests the grounded move flag and returns its authored/resolved target +## point for legacy rule links. Returns Vector2.INF on miss. func hit_test_waypoint(world_pos: Vector2) -> Vector2: var hit := hit_test_waypoint_action(world_pos) if hit.is_empty(): @@ -146,29 +361,67 @@ func hit_test_waypoint(world_pos: Vector2) -> Vector2: return hit["pos"] -## Like hit_test_waypoint but also returns the owning rig and queue index: -## {"rig": StickmanRig, "index": int, "pos": Vector2}, or {} on miss. Used by -## the Phase 3c waypoint context menu to locate the exact walk action. +## Like hit_test_waypoint but also returns the owning rig and queue index. +## `pos` is the authored/resolved target for legacy rule links; `display_pos` is +## the grounded flag base used for drawing and hit geometry. func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary: - var radius := WAYPOINT_HIT_RADIUS_PX / _zoom() + var plan_hit: Dictionary = hit_test_plan_action(world_pos) + if plan_hit.is_empty(): + return {} + var rig: StickmanRig = plan_hit.get("rig") as StickmanRig + var action_id: String = String(plan_hit.get("action_id", "")) + var flat_index: int = _flat_index_for_stable_id(rig, action_id) + if rig == null or flat_index < 0: + return {} + return { + "rig": rig, + "index": flat_index, + "action_id": rig.action_id_at(flat_index), + "stable_action_id": action_id, + "status": rig.get_action_status(rig.action_id_at(flat_index)), + "step_index": int(plan_hit.get("step_index", -1)), + "pos": plan_hit.get("pos", Vector2.ZERO), + "display_pos": plan_hit.get("display_pos", Vector2.ZERO), + } + + +func _flat_index_for_stable_id(rig: StickmanRig, stable_id: String) -> int: + if rig == null or not is_instance_valid(rig): + return -1 + var queue: Array[Dictionary] = rig.get_queue() + for index: int in queue.size(): + if String(queue[index].get("action_id", "")) == stable_id: + return index + return -1 + + +func hit_test_plan_action(world_pos: Vector2) -> Dictionary: + if not plan_overlay_visible: + return {} var best: Dictionary = {} - var best_dist := radius + var best_dist: float = INF for rig: StickmanRig in _collect_rigs(): - var queue_view: Array[Dictionary] = rig.get_queue_view() - if queue_view.is_empty(): + if _plan_spotlight_rig != null and rig != _plan_spotlight_rig: continue - var anchor: Dictionary = _queue_anchor(rig) - var current: Vector2 = anchor.get("ground", anchor.get("feet", rig.global_position - StickmanLocomotion.FOOT_OFFSET)) - for i: int in queue_view.size(): - var entry: Dictionary = queue_view[i] - var action: Dictionary = entry.get("action", {}) - if ACTION_REGISTRY.is_move_action(action): - var target: Vector2 = action.get("target", current) - var d := world_pos.distance_to(target) - if d <= best_dist: - best_dist = d - best = { "rig": rig, "index": int(entry.get("authored_index", i)), "action_id": int(entry.get("action_id", -1)), "status": int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED)), "pos": target } - current = target + for projection: Dictionary in _plan_projection(rig): + if String(projection.get("kind", "")) != "move": + continue + var target: Vector2 = projection.get("hit_position", Vector2.ZERO) + var hit_rect: Rect2 = projection.get("hit_rect", _plan_flag_hit_rect(target, _zoom())) + if not hit_rect.has_point(world_pos): + continue + var distance: float = world_pos.distance_to(target) + if distance <= best_dist: + best_dist = distance + best = { + "rig": rig, + "action_id": String(projection.get("action_id", "")), + "step_index": int(projection.get("step_index", -1)), + "pos": projection.get("authored_position", target), + "display_pos": target, + "status": StringName(String(projection.get("status", "not_started"))), + "label": String(projection.get("label", "")), + } return best @@ -194,50 +447,1004 @@ func _collect_rigs() -> Array[StickmanRig]: return result func _draw() -> void: + _last_actor_name_badge_layouts.clear() if not enabled: return _rule_hit_regions.clear() var zoom := _zoom() - for rig: StickmanRig in _collect_rigs(): - _draw_rig_queue(rig, zoom) + if plan_overlay_visible: + for rig: StickmanRig in _collect_rigs(): + var anchor_data: Dictionary = _queue_anchor(rig) + if _live_session and bool(anchor_data.get("airborne", false)) and bool(anchor_data.get("has_ground", false)): + draw_line(anchor_data.get("feet", Vector2.ZERO), anchor_data.get("ground", Vector2.ZERO), Color(0.8, 0.8, 0.8, 0.35), 1.5 / zoom, true) + _draw_rig_queue(rig, zoom) + elif _plan_candidate_rig != null and is_instance_valid(_plan_candidate_rig): + # Target capture remains visible even if the actor selection is temporarily cleared. + _draw_plan_candidate(_plan_candidate_rig, zoom) + _draw_pickup_candidates(zoom) _draw_rules(zoom) -func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void: - var queue_view: Array[Dictionary] = rig.get_queue_view() - if queue_view.is_empty(): + +func _draw_pickup_candidates(zoom: float) -> void: + if _pickup_candidate_props.is_empty(): return - var anchor: Dictionary = _queue_anchor(rig) - var authored_current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET - var has_authored_cursor: bool = not _live_session - var live_current: Vector2 = anchor.get("feet", authored_current) - if _live_session: - live_current = anchor.get("ground", live_current) if bool(anchor.get("has_ground", false)) else anchor.get("feet", live_current) - if bool(anchor.get("airborne", false)) and bool(anchor.get("has_ground", false)): - draw_line(anchor.get("feet", live_current), anchor.get("ground", live_current), Color(0.8, 0.8, 0.8, 0.35), 1.5 / zoom, true) - var stack := 0 - for i: int in queue_view.size(): - var entry: Dictionary = queue_view[i] - var action: Dictionary = entry.get("action", {}) - var status: int = int(entry.get("status", ACTION_RUNNER.QueueStatus.NOT_STARTED)) - var tint: Color = _status_color(status) - var consumed: bool = status == ACTION_RUNNER.QueueStatus.COMPLETED or status == ACTION_RUNNER.QueueStatus.INTERRUPTED - var current: Vector2 = authored_current if _live_session and consumed else live_current if _live_session else authored_current - if ACTION_REGISTRY.is_move_action(action): - var target: Vector2 = action.get("target", current) - if not (_live_session and consumed and not has_authored_cursor): - _draw_dashed(current, target, zoom, tint) - _draw_waypoint(target, zoom, str(i + 1), tint) - if _live_session and consumed: - authored_current = target - has_authored_cursor = true - else: - live_current = target - authored_current = target - stack = 0 + var line_width: float = 3.0 / zoom + for prop: PropBlock in _pickup_candidate_props: + if prop == null or not is_instance_valid(prop): + continue + var world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(prop) + if not world_bounds.has_area(): + continue + world_bounds = world_bounds.grow(5.0 / zoom) + var corners := PackedVector2Array([ + world_bounds.position, + Vector2(world_bounds.end.x, world_bounds.position.y), + world_bounds.end, + Vector2(world_bounds.position.x, world_bounds.end.y), + ]) + var outline := corners.duplicate() + outline.append(corners[0]) + var hovered: bool = prop == _pickup_hovered_prop + var tint: Color = PICKUP_HOVER_COLOR if hovered else PICKUP_CANDIDATE_COLOR + var underlay: Color = Color(0.04, 0.06, 0.09, 0.12 if not hovered else 0.24) + draw_colored_polygon(corners, underlay) + draw_polyline(outline, Color(0.05, 0.07, 0.10, 0.9), line_width + 2.0 / zoom, true) + draw_polyline(outline, tint, line_width if hovered else 2.0 / zoom, true) + var badge_center: Vector2 = corners[1].lerp(corners[2], 0.18) + Vector2(10.0 / zoom, -10.0 / zoom) + var badge_radius: float = 15.0 / zoom + draw_circle(badge_center, badge_radius, Color("#202733")) + draw_arc(badge_center, badge_radius, 0.0, TAU, 24, tint, 2.0 / zoom, true) + var glyph_font: Font = _badge_font() + var glyph: String = "✋" + var glyph_size: int = int(19.0 / zoom) + var glyph_metrics: Vector2 = glyph_font.get_string_size(glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, glyph_size) + draw_string(glyph_font, badge_center + Vector2(-glyph_metrics.x * 0.5, glyph_metrics.y * 0.34), glyph, HORIZONTAL_ALIGNMENT_LEFT, -1, glyph_size, Color.WHITE) + if hovered: + _draw_pickup_hover_label(prop, corners, zoom) + + +func _draw_pickup_hover_label(prop: PropBlock, corners: PackedVector2Array, zoom: float) -> void: + var display_name: String = prop.stage_name.strip_edges() + if display_name.is_empty(): + display_name = "Prop" + var text: String = "Pick up " + display_name + var font: Font = _rule_label_font() + var font_size: int = int(17.0 / zoom) + var max_text_width: float = 250.0 / zoom + while text.length() > 10 and font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size).x > max_text_width: + text = text.left(text.length() - 2).strip_edges() + "…" + var measured: Vector2 = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size) + var padding: Vector2 = Vector2(12.0, 7.0) / zoom + var label_size: Vector2 = measured + padding * 2.0 + var center: Vector2 = (corners[0] + corners[1]) * 0.5 + Vector2(0.0, -label_size.y * 0.8 - 8.0 / zoom) + var label_rect: Rect2 = Rect2(center - Vector2(label_size.x * 0.5, label_size.y * 0.5), label_size) + draw_rect(label_rect, Color("#202733"), true) + draw_rect(label_rect, PICKUP_HOVER_COLOR, false, 2.0 / zoom, true) + draw_string(font, label_rect.position + padding + Vector2(0.0, measured.y * 0.78), text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, Color("#F5F7FA")) + +func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void: + var spotlight_active: bool = _plan_spotlight_rig != null + if spotlight_active and rig != _plan_spotlight_rig: + return + var projections: Array[Dictionary] = _plan_projection(rig) + var paired_move_steps: Dictionary = {} + var paired_say_by_step: Dictionary = {} + var paired_primary_by_step: Dictionary = {} + for entry: Dictionary in projections: + var entry_step_key: int = int(entry.get("step_index", -1)) + match String(entry.get("kind", "")): + "move": + paired_move_steps[entry_step_key] = true + "say": + paired_say_by_step[entry_step_key] = entry + "other": + paired_primary_by_step[entry_step_key] = entry + for projection: Dictionary in projections: + var action: Dictionary = projection.get("action", {}) + var action_type: String = String(action.get("type", "")) + var status_name: StringName = StringName(String(projection.get("status", "not_started"))) + var tint: Color = _plan_status_color(status_name) + if String(projection.get("action_id", "")) == _plan_candidate_action_id: + tint.a *= 0.28 + var step_number: String = str(int(projection.get("step_index", 0)) + 1) + var anchor: Vector2 = projection.get("start", rig.global_position - StickmanLocomotion.FOOT_OFFSET) + var is_focused_step: bool = not spotlight_active or int(projection.get("step_index", -1)) == _plan_spotlight_step_index + if not is_focused_step: + if String(projection.get("kind", "")) == "move": + var ghost_target: Vector2 = projection.get("end", anchor) + var ghost_tint: Color = tint + ghost_tint.a *= unfocused_step_alpha + _draw_plan_destination_flag(ghost_target, zoom, step_number, ghost_tint) + continue + match String(projection.get("kind", "")): + "move": + var target: Vector2 = projection.get("end", anchor) + if bool(projection.get("conditional", false)): + tint.a *= 0.5 + if spotlight_active: + _draw_spotlight_route(anchor, target, zoom, tint) + else: + _draw_dashed(anchor, target, zoom, tint) + _draw_plan_destination_flag(target, zoom, step_number, tint) + var movement_color: Color = plan_run_color if String(action.get("style", "walk")) == "run" else plan_route_color + movement_color.a = tint.a + var movement_label: String = "Run" if String(action.get("style", "walk")) == "run" else "Walk" + var badge_center: Vector2 = anchor.lerp(target, route_badge_fraction) + var projection_step_key: int = int(projection.get("step_index", -1)) + if paired_say_by_step.has(projection_step_key): + var paired_move_tint: Color = plan_route_color + paired_move_tint.a = tint.a + var say_projection: Dictionary = paired_say_by_step[projection_step_key] + var say_status: StringName = StringName(String(say_projection.get("status", "not_started"))) + var say_status_tint: Color = _plan_status_color(say_status) + var paired_say_tint: Color = plan_say_color + paired_say_tint.a = say_status_tint.a + if String(say_projection.get("action_id", "")) == _plan_candidate_action_id: + paired_say_tint.a *= 0.28 + _draw_paired_action_badges(rig, badge_center, target, movement_label, paired_move_tint, paired_say_tint, zoom) + elif spotlight_active: + _draw_action_badge(anchor.lerp(target, route_badge_fraction), movement_label, zoom, movement_color) + else: + _draw_route_label((anchor + target) * 0.5, movement_label, zoom, movement_color) + var destination_value: Variant = action.get("destination", {}) + if destination_value is Dictionary and String((destination_value as Dictionary).get("kind", "")) == "prop": + _draw_route_label(target + Vector2(48.0, -20.0) / zoom, String(projection.get("label", "")), zoom, tint) + "say": + var say_step_key: int = int(projection.get("step_index", -1)) + var paired_primary: Dictionary = paired_primary_by_step.get(say_step_key, {}) + var paired_primary_type: String = ACTION_REGISTRY.canonical_type(String((paired_primary.get("action", {}) as Dictionary).get("type", ""))) + var keep_pickup_cue_at_target: bool = paired_primary_type == "pick_up_prop" + if not paired_move_steps.has(say_step_key) and (not spotlight_active or paired_primary.is_empty() or keep_pickup_cue_at_target): + var say_tint: Color = plan_say_color + say_tint.a = tint.a + if spotlight_active: + _draw_focused_action_badge(rig, anchor, "Say", "speak", zoom, say_tint) + else: + _draw_say_marker(anchor, step_number, zoom, say_tint) + "other": + var canonical_action_type: String = ACTION_REGISTRY.canonical_type(action_type) + var action_tint: Color = plan_wait_color if canonical_action_type == ACTION_REGISTRY.WAIT else tint + action_tint.a = tint.a + var projection_step_key: int = int(projection.get("step_index", -1)) + if canonical_action_type == "pick_up_prop": + _draw_pickup_action_badge(rig, String(action.get("target_prop_id", "")), anchor, zoom, action_tint) + elif spotlight_active and paired_say_by_step.has(projection_step_key): + var say_projection: Dictionary = paired_say_by_step[projection_step_key] + var say_tint: Color = plan_say_color + say_tint.a = _plan_status_color(StringName(String(say_projection.get("status", "not_started")))).a + if String(say_projection.get("action_id", "")) == _plan_candidate_action_id: + say_tint.a *= 0.28 + var primary_tint: Color = plan_wait_color if canonical_action_type == ACTION_REGISTRY.WAIT else plan_route_color + primary_tint.a = tint.a + if String(projection.get("action_id", "")) == _plan_candidate_action_id: + primary_tint.a *= 0.28 + var primary_icon: String = _plan_action_badge_glyph(canonical_action_type) + var primary_label: String = ACTION_REGISTRY.label(canonical_action_type) + var beside_upper_body: bool = canonical_action_type == "throw_prop" + _draw_focused_action_badge_pair(rig, anchor, primary_label, primary_icon, zoom, primary_tint, say_tint, beside_upper_body) + elif canonical_action_type == ACTION_REGISTRY.WAIT and spotlight_active: + _draw_focused_action_badge(rig, anchor, "Wait", "wait", zoom, action_tint) + elif spotlight_active and canonical_action_type in ["ragdoll", "recover", "drop_prop", "set_down_prop"]: + var other_action_tint: Color = plan_route_color + other_action_tint.a = tint.a + _draw_focused_action_badge(rig, anchor, ACTION_REGISTRY.label(canonical_action_type), canonical_action_type, zoom, other_action_tint) + else: + _draw_badge(anchor + BADGE_STACK_STEP / zoom, action_type, zoom, step_number, action_tint) + if _plan_candidate_rig == rig: + _draw_plan_candidate(rig, zoom) + if spotlight_active: + _draw_actor_name_badge(rig, zoom) + + +func _draw_plan_candidate(rig: StickmanRig, zoom: float) -> void: + var old_route: Dictionary = {} + for entry: Dictionary in _plan_projection(rig): + if String(entry.get("action_id", "")) == _plan_candidate_action_id: + old_route = entry + break + var anchor: Vector2 = old_route.get("start", rig.global_position - StickmanLocomotion.FOOT_OFFSET) + if not plan_overlay_visible and not old_route.is_empty(): + var old_endpoint: Vector2 = old_route.get("end", anchor) + var old_tint: Color = _plan_status_color(StringName(String(old_route.get("status", "not_started")))) + old_tint.a *= unfocused_step_alpha + if anchor.distance_to(old_endpoint) > 0.5: + _draw_dashed(anchor, old_endpoint, zoom, old_tint) + _draw_plan_destination_flag(old_endpoint, zoom, str(int(old_route.get("step_index", 0)) + 1), old_tint) + var endpoint: Vector2 = _vector_value(_plan_candidate_destination.get("position", Vector2.INF)) + var candidate_label: String = String(_plan_candidate_destination.get("label", "")) + if String(_plan_candidate_destination.get("kind", "")) == "prop": + var resolved: Dictionary = _move_endpoint({"destination": _plan_candidate_destination}, int(old_route.get("step_index", 0))) + endpoint = resolved.get("position", endpoint) + candidate_label = String(resolved.get("label", "")) + elif _plan_candidate_valid and String(_plan_candidate_destination.get("kind", "")) == "place": + candidate_label = "" + if not endpoint.is_finite(): + endpoint = _plan_candidate_pointer + if not endpoint.is_finite(): + return + endpoint = _project_move_display_position(endpoint, anchor.y) + var candidate_color: Color = plan_candidate_valid_color if _plan_candidate_valid else plan_candidate_invalid_color + if anchor.distance_to(endpoint) > 0.5: + if _plan_spotlight_rig == rig: + _draw_spotlight_route(anchor, endpoint, zoom, candidate_color) else: - var badge_anchor: Vector2 = current + BADGE_STACK_STEP * float(stack) / zoom - _draw_badge(badge_anchor, String(action.get("type", "")), zoom, str(i + 1), tint) - stack += 1 + _draw_dashed(anchor, endpoint, zoom, candidate_color) + var candidate_step: String = str(int(old_route.get("step_index", 0)) + 1) + _draw_plan_destination_flag(endpoint, zoom, candidate_step, candidate_color) + _draw_route_label((anchor + endpoint) * 0.5, _plan_candidate_movement_label, zoom, candidate_color) + if not candidate_label.is_empty(): + _draw_route_label(endpoint + Vector2(48.0, 14.0) / zoom, candidate_label, zoom, candidate_color) + + +## Shared visual projection for routes and action-id flag hits. Move entries keep +## `authored_position` untouched while `end` is grounded for display. +func _plan_projection(rig: StickmanRig) -> Array[Dictionary]: + var projection: Array[Dictionary] = [] + if rig == null or not is_instance_valid(rig): + return projection + var anchor_data: Dictionary = _queue_anchor(rig) + var current: Vector2 = anchor_data.get("ground", anchor_data.get("feet", rig.global_position - StickmanLocomotion.FOOT_OFFSET)) + var uncertain: bool = false + var plan: Array[Dictionary] = rig.get_action_plan() + for step_index: int in plan.size(): + var step: Dictionary = plan[step_index] + var actions_value: Variant = step.get("actions", []) + if not actions_value is Array: + continue + var step_start: Vector2 = current + for raw_action: Variant in actions_value: + if not raw_action is Dictionary: + continue + var action: Dictionary = raw_action as Dictionary + var action_id: String = String(action.get("action_id", "")) + var status: StringName = &"not_started" + for status_entry: Dictionary in rig.get_plan_view(): + if String(status_entry.get("action_id", "")) == action_id: + status = StringName(String(status_entry.get("status", "not_started"))) + break + if ACTION_REGISTRY.is_move_action(action): + var endpoint: Dictionary = _move_endpoint(action, step_index) + var authored_target: Vector2 = endpoint.get("position", current) + var display_target: Vector2 = _project_move_display_position(authored_target, current.y) + projection.append({ + "kind": "move", + "action": action.duplicate(true), + "action_id": action_id, + "step_index": step_index, + "start": current, + "authored_position": authored_target, + "end": display_target, + "hit_position": display_target, + "hit_rect": _plan_flag_hit_rect(display_target, _zoom()), + "hit_radius": WAYPOINT_HIT_RADIUS_PX / _zoom(), + "label": String(endpoint.get("label", "")), + "status": status, + "conditional": uncertain or status in [&"failed", &"cancelled"], + }) + current = display_target + if status in [&"failed", &"cancelled"]: + uncertain = true + elif String(action.get("type", "")) == "speak": + projection.append({ + "kind": "say", + "action": action.duplicate(true), + "action_id": action_id, + "step_index": step_index, + "start": step_start, + "end": step_start, + "hit_position": step_start, + "hit_radius": 0.0, + "label": "Say", + "status": status, + "conditional": uncertain, + }) + else: + projection.append({ + "kind": "other", + "action": action.duplicate(true), + "action_id": action_id, + "step_index": step_index, + "start": current, + "end": current, + "hit_position": current, + "hit_radius": 0.0, + "label": ACTION_REGISTRY.label(String(action.get("type", ""))), + "status": status, + "conditional": uncertain, + }) + if String(action.get("type", "")) in ["ragdoll", "throw_prop", "set_down_prop"]: + uncertain = true + return projection + + +func _project_move_display_position(target: Vector2, ground_y: float) -> Vector2: + return Vector2(target.x, ground_y) + + +func _move_endpoint(action: Dictionary, step_index: int = 0) -> Dictionary: + var destination_value: Variant = action.get("destination", {}) + if not destination_value is Dictionary: + return {"position": action.get("target", Vector2.ZERO), "label": "Place %d" % (step_index + 1)} + var destination: Dictionary = destination_value + if String(destination.get("kind", "")) == "place": + var place: Vector2 = _vector_value(destination.get("position", Vector2.ZERO)) + var near_name: String = _nearby_prop_name(place) + var place_label: String = "Place %d" % (step_index + 1) + if not near_name.is_empty(): + place_label += " · by %s" % near_name + return {"position": place, "label": place_label} + var prop_id: String = String(destination.get("target_prop_id", "")) + var offset: Vector2 = _vector_value(destination.get("approach_offset", Vector2.ZERO)) + var prop: PropBlock = _prop_for_stage_id(prop_id) + if prop != null: + return {"position": prop.global_position + offset, "label": prop.stage_name} + return {"position": _vector_value(destination.get("last_known_approach", Vector2.ZERO)), "label": "Missing prop"} + + +func _vector_value(value: Variant) -> Vector2: + if value is Vector2: + return value + if value is Dictionary: + var vector_data: Dictionary = value + return Vector2(float(vector_data.get("x", 0.0)), float(vector_data.get("y", 0.0))) + return Vector2.ZERO + + +func _prop_for_stage_id(stage_id: String) -> PropBlock: + if world == null or stage_id.is_empty(): + return null + for child: Node in world.get_children(): + if child is PropBlock and String((child as PropBlock).stage_object_id) == stage_id: + return child as PropBlock + return null + + +func _nearby_prop_name(position: Vector2) -> String: + if world == null: + return "" + for child: Node in world.get_children(): + if child is PropBlock and position.distance_to((child as PropBlock).global_position) <= 72.0: + return String((child as PropBlock).stage_name) + return "" + + +func _plan_status_color(status: StringName) -> Color: + match status: + &"active": + return Color("#FFB300") + &"succeeded": + var completed: Color = plan_completed_color + completed.a = 0.28 + return completed + &"failed": + return plan_failed_color + &"cancelled": + var cancelled: Color = plan_completed_color + cancelled.a = 0.45 + return cancelled + _: + return plan_route_color + + +func _draw_route_label(center: Vector2, text: String, zoom: float, tint: Color) -> void: + if text.is_empty(): + return + var font: Font = _badge_font() + var font_size: int = int(rule_label_font_size / zoom) + var text_size: Vector2 = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + var padding: Vector2 = Vector2(5.0, 3.0) / zoom + var box_size: Vector2 = text_size + padding * 2.0 + var box: Rect2 = Rect2(center - box_size * 0.5, box_size) + draw_rect(box, plan_label_bg_color, true) + draw_rect(box, Color(tint.r, tint.g, tint.b, plan_label_border_alpha), false, 1.0 / zoom) + draw_string(font, box.position + padding + Vector2(0.0, font.get_ascent(font_size)), text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, tint) + + +func _draw_action_badge(center: Vector2, text: String, zoom: float, tint: Color) -> void: + if text.is_empty(): + return + var scale: float = 1.0 / maxf(zoom, 0.0001) + var font: Font = _badge_font() + var font_size: int = maxi(16, int(roundf(action_badge_font_size_px * scale))) + var text_size: Vector2 = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + var padding: Vector2 = action_badge_padding_px * scale + var badge_size: Vector2 = text_size + padding * 2.0 + var rect: Rect2 = Rect2(center - badge_size * 0.5, badge_size) + draw_style_box(_rounded_badge_style( + plan_label_bg_color, + Color(tint.r, tint.g, tint.b, maxf(plan_label_border_alpha, tint.a)), + action_badge_radius_px * scale, + action_badge_border_width_px * scale + ), rect) + var baseline: float = rect.position.y + (rect.size.y - font.get_height(font_size)) * 0.5 + font.get_ascent(font_size) + var text_color: Color = Color(action_badge_text_color.r, action_badge_text_color.g, action_badge_text_color.b, tint.a) + draw_string(font, Vector2(rect.position.x + padding.x, baseline), text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, text_color) + + +func _draw_pickup_action_badge(rig: StickmanRig, prop_id: String, actor_anchor: Vector2, zoom: float, tint: Color) -> void: + var prop: PropBlock = _prop_for_stage_id(prop_id) + if prop == null or not is_instance_valid(prop): + var missing_tint: Color = plan_failed_color + missing_tint.a = tint.a + _draw_action_badge(actor_anchor + Vector2(0.0, -56.0) / maxf(zoom, 0.0001), "Prop missing", zoom, missing_tint) + return + var prop_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(prop) + if not prop_bounds.has_area(): + _draw_action_badge(actor_anchor + Vector2(0.0, -56.0) / maxf(zoom, 0.0001), "Pick up", zoom, tint) + return + var canvas_transform: Transform2D = get_viewport().get_canvas_transform() + var prop_screen_bounds: Rect2 = _world_rect_to_screen_rect(prop_bounds, canvas_transform) + var badge_size: Vector2 = _text_action_badge_screen_size("Pick up", zoom) + var viewport_size: Vector2 = get_viewport().get_visible_rect().size + var safe_rect: Rect2 = Rect2(Vector2(8.0, 72.0), Vector2(maxf(0.0, viewport_size.x - 16.0), maxf(0.0, viewport_size.y - 112.0))) + var badge_rect: Rect2 = _pickup_badge_screen_rect(prop_screen_bounds, badge_size, safe_rect, _actor_name_badge_screen_rect(rig, zoom)) + var inverse_canvas_transform: Transform2D = canvas_transform.affine_inverse() + var badge_center: Vector2 = to_local(inverse_canvas_transform * badge_rect.get_center()) + _draw_action_badge(badge_center, "Pick up", zoom, tint) + + +func _text_action_badge_screen_size(text: String, zoom: float) -> Vector2: + var scale: float = 1.0 / maxf(zoom, 0.0001) + var font: Font = _badge_font() + var font_size: int = maxi(16, int(roundf(action_badge_font_size_px * scale))) + var padding: Vector2 = action_badge_padding_px * scale + return (font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + padding * 2.0) * zoom + + +static func _pickup_badge_screen_rect(prop_screen_bounds: Rect2, badge_size: Vector2, safe_rect: Rect2, actor_name_rect: Rect2 = Rect2()) -> Rect2: + var gap: float = 8.0 + var preferred: Rect2 = Rect2( + Vector2(prop_screen_bounds.get_center().x - badge_size.x * 0.5, prop_screen_bounds.position.y - gap - badge_size.y), + badge_size + ) + var prop_center_y: float = prop_screen_bounds.get_center().y + var candidates: Array[Rect2] = [ + preferred, + Rect2(Vector2(preferred.position.x, prop_screen_bounds.end.y + gap), badge_size), + Rect2(Vector2(prop_screen_bounds.position.x - gap - badge_size.x, prop_center_y - badge_size.y * 0.5), badge_size), + Rect2(Vector2(prop_screen_bounds.end.x + gap, prop_center_y - badge_size.y * 0.5), badge_size), + ] + var safe_max: Vector2 = Vector2( + maxf(safe_rect.position.x, safe_rect.end.x - badge_size.x), + maxf(safe_rect.position.y, safe_rect.end.y - badge_size.y) + ) + var best_rect: Rect2 = preferred + var best_score: float = INF + for candidate: Rect2 in candidates: + var clamped_rect: Rect2 = Rect2(Vector2( + clampf(candidate.position.x, safe_rect.position.x, safe_max.x), + clampf(candidate.position.y, safe_rect.position.y, safe_max.y) + ), badge_size) + var score: float = clamped_rect.position.distance_to(preferred.position) * 0.05 + score += _rect_overlap_area(clamped_rect, prop_screen_bounds) * 100000.0 + if actor_name_rect.has_area(): + score += _rect_overlap_area(clamped_rect, actor_name_rect) * 100000.0 + if score < best_score: + best_score = score + best_rect = clamped_rect + return best_rect + + +static func _rect_overlap_area(first: Rect2, second: Rect2) -> float: + var overlap: Rect2 = first.intersection(second) + return maxf(0.0, overlap.size.x) * maxf(0.0, overlap.size.y) + + +func _draw_paired_action_badges( + rig: StickmanRig, + center: Vector2, + route_end: Vector2, + movement_label: String, + movement_tint: Color, + say_tint: Color, + zoom: float +) -> void: + var movement_size: Vector2 = _icon_action_badge_screen_size(movement_label, "", zoom) + var say_size: Vector2 = _icon_action_badge_screen_size("Say", "", zoom) + var group_size: Vector2 = Vector2(maxf(movement_size.x, say_size.x), movement_size.y + action_badge_stack_gap_px + say_size.y) + var group_rect: Rect2 = _route_action_pair_screen_rect(rig, center, route_end, group_size, zoom) + _draw_action_badge_stack_in_rect(group_rect, movement_label, "move", movement_size, movement_tint, say_tint, zoom) + + +func _draw_icon_action_badge(center: Vector2, text: String, icon_kind: String, zoom: float, tint: Color, prefix: String = "") -> void: + if text.is_empty(): + return + var scale: float = 1.0 / maxf(zoom, 0.0001) + var font: Font = _badge_font() + var font_size: int = maxi(16, int(roundf(action_badge_font_size_px * scale))) + var text_size: Vector2 = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + var prefix_size: Vector2 = font.get_string_size(prefix, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) if not prefix.is_empty() else Vector2.ZERO + var padding: Vector2 = action_badge_padding_px * scale + var icon_slot_width: float = action_badge_icon_slot_width_px * scale + var icon_text_gap: float = action_badge_icon_text_gap_px * scale + var prefix_gap: float = action_badge_icon_text_gap_px * scale if not prefix.is_empty() else 0.0 + var icon_height: float = 30.0 * action_badge_icon_scale * scale + var badge_size: Vector2 = _icon_action_badge_screen_size(text, prefix, zoom) * scale + var rect: Rect2 = Rect2(center - badge_size * 0.5, badge_size) + draw_style_box(_rounded_badge_style( + plan_label_bg_color, + Color(tint.r, tint.g, tint.b, maxf(plan_label_border_alpha, tint.a)), + action_badge_radius_px * scale, + action_badge_border_width_px * scale + ), rect) + var icon_origin: Vector2 = rect.position + Vector2( + padding.x + prefix_size.x + prefix_gap + (icon_slot_width - 28.0 * action_badge_icon_scale * scale) * 0.5, + (rect.size.y - icon_height) * 0.5 + ) + var icon_tint: Color = Color(tint.r, tint.g, tint.b, tint.a) + ACTION_SUMMARY_GLYPH.draw_in_canvas(self, icon_kind, icon_origin, action_badge_icon_scale * scale, icon_tint) + var baseline: float = rect.position.y + (rect.size.y - font.get_height(font_size)) * 0.5 + font.get_ascent(font_size) + var text_color: Color = Color(action_badge_text_color.r, action_badge_text_color.g, action_badge_text_color.b, tint.a) + if not prefix.is_empty(): + draw_string(font, Vector2(rect.position.x + padding.x, baseline), prefix, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, text_color) + draw_string(font, Vector2(rect.position.x + padding.x + prefix_size.x + prefix_gap + icon_slot_width + icon_text_gap, baseline), text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, text_color) + + +func _icon_action_badge_screen_size(text: String, prefix: String, zoom: float) -> Vector2: + var scale: float = 1.0 / maxf(zoom, 0.0001) + var font: Font = _badge_font() + var font_size: int = maxi(16, int(roundf(action_badge_font_size_px * scale))) + var text_width: float = font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x * zoom + var prefix_width: float = font.get_string_size(prefix, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x * zoom if not prefix.is_empty() else 0.0 + var prefix_gap: float = action_badge_icon_text_gap_px if not prefix.is_empty() else 0.0 + var content_width: float = prefix_width + prefix_gap + action_badge_icon_slot_width_px + action_badge_icon_text_gap_px + text_width + var content_height: float = maxf(font.get_height(font_size) * zoom, 30.0 * action_badge_icon_scale) + return Vector2(content_width, content_height) + action_badge_padding_px * 2.0 + + +func _draw_focused_action_badge(rig: StickmanRig, start: Vector2, text: String, icon_kind: String, zoom: float, tint: Color, stack_index: int = 0) -> void: + if rig == null or not is_instance_valid(rig): + return + var badge_size_screen: Vector2 = _icon_action_badge_screen_size(text, "", zoom) + var canvas_transform: Transform2D = get_viewport().get_canvas_transform() + var inverse_canvas_transform: Transform2D = canvas_transform.affine_inverse() + var anchor_screen: Vector2 = canvas_transform * start + var flag_top_screen: Vector2 = anchor_screen + Vector2(0.0, -42.0) + var flag_rect: Rect2 = Rect2(flag_top_screen, Vector2(40.0, 22.0)) + var gap_px: float = 10.0 + var stack_offset_px: float = maxf(0.0, float(stack_index)) * (badge_size_screen.y + action_badge_stack_gap_px) + var preferred_rect: Rect2 = Rect2( + Vector2(flag_rect.get_center().x - badge_size_screen.x * 0.5, flag_rect.position.y - gap_px - badge_size_screen.y - stack_offset_px), + badge_size_screen + ) + var left_rect: Rect2 = Rect2( + Vector2(flag_rect.position.x - gap_px - badge_size_screen.x, flag_rect.get_center().y - badge_size_screen.y * 0.5 - stack_offset_px), + badge_size_screen + ) + var right_rect: Rect2 = Rect2( + Vector2(flag_rect.end.x + gap_px, flag_rect.get_center().y - badge_size_screen.y * 0.5 - stack_offset_px), + badge_size_screen + ) + var safe_rect: Rect2 = Rect2( + Vector2(8.0, 72.0), + Vector2( + maxf(0.0, get_viewport().get_visible_rect().size.x - 16.0), + maxf(0.0, get_viewport().get_visible_rect().size.y - 112.0) + ) + ) + var safe_max: Vector2 = Vector2( + maxf(safe_rect.position.x, safe_rect.end.x - badge_size_screen.x), + maxf(safe_rect.position.y, safe_rect.end.y - badge_size_screen.y) + ) + var actor_name_rect: Rect2 = _actor_name_badge_screen_rect(rig, zoom) + var rig_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(rig) + var rig_bounds_screen: Rect2 = _world_rect_to_screen_rect(rig_world_bounds, canvas_transform) if rig_world_bounds.has_area() else Rect2() + var best_rect: Rect2 = preferred_rect + var best_score: float = INF + for candidate: Rect2 in [preferred_rect, left_rect, right_rect]: + var clamped_rect: Rect2 = Rect2(Vector2( + clampf(candidate.position.x, safe_rect.position.x, safe_max.x), + clampf(candidate.position.y, safe_rect.position.y, safe_max.y) + ), badge_size_screen) + var score: float = clamped_rect.position.distance_to(preferred_rect.position) * 0.05 + score += clamped_rect.intersection(flag_rect).get_area() * 100000.0 + if rig_bounds_screen.has_area(): + score += clamped_rect.intersection(rig_bounds_screen).get_area() * 100000.0 + if actor_name_rect.has_area(): + score += clamped_rect.intersection(actor_name_rect).get_area() * 100000.0 + for obstacle: Rect2 in _actor_name_badge_obstacles: + if obstacle.has_area(): + score += clamped_rect.intersection(obstacle).get_area() * 100000.0 + if score < best_score: + best_score = score + best_rect = clamped_rect + var badge_center: Vector2 = to_local(inverse_canvas_transform * best_rect.get_center()) + _draw_icon_action_badge(badge_center, text, icon_kind, zoom, tint) + + +func _plan_action_badge_glyph(action_type: String) -> String: + if ACTION_REGISTRY.is_move_action({"type": action_type}): + return "move" + if action_type == "speak": + return "speak" + if action_type == ACTION_REGISTRY.WAIT: + return "wait" + if action_type in ["ragdoll", "recover", "drop_prop", "throw_prop", "set_down_prop", "use_prop"]: + return action_type + return "" + + +func _route_action_pair_screen_rect(rig: StickmanRig, center: Vector2, route_end: Vector2, group_size: Vector2, zoom: float) -> Rect2: + var canvas_transform: Transform2D = get_viewport().get_canvas_transform() + var desired_center: Vector2 = canvas_transform * center + var end_screen: Vector2 = canvas_transform * route_end + var desired_rect: Rect2 = Rect2( + Vector2(desired_center.x - group_size.x * 0.5, desired_center.y - action_badge_route_gap_px - group_size.y), + group_size + ) + var target_flag_rect: Rect2 = Rect2(end_screen + Vector2(0.0, -42.0), Vector2(40.0, 22.0)) + var viewport_size: Vector2 = get_viewport().get_visible_rect().size + var safe_rect: Rect2 = Rect2(Vector2(8.0, 72.0), Vector2(maxf(0.0, viewport_size.x - 16.0), maxf(0.0, viewport_size.y - 112.0))) + var safe_max: Vector2 = Vector2( + maxf(safe_rect.position.x, safe_rect.end.x - group_size.x), + maxf(safe_rect.position.y, safe_rect.end.y - group_size.y) + ) + var rig_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(rig) + var rig_bounds_screen: Rect2 = _world_rect_to_screen_rect(rig_world_bounds, canvas_transform) if rig_world_bounds.has_area() else Rect2() + var gap: float = 8.0 + var candidates: Array[Rect2] = [desired_rect] + if rig_bounds_screen.has_area(): + var route_y: float = desired_rect.position.y + candidates.append(Rect2(Vector2(rig_bounds_screen.end.x + gap, route_y), group_size)) + candidates.append(Rect2(Vector2(rig_bounds_screen.position.x - gap - group_size.x, route_y), group_size)) + candidates.append(Rect2(Vector2(target_flag_rect.position.x - gap - group_size.x, route_y), group_size)) + var actor_name_rect: Rect2 = _actor_name_badge_screen_rect(rig, zoom) + var best_rect: Rect2 = desired_rect + var best_score: float = INF + for candidate: Rect2 in candidates: + var clamped_rect: Rect2 = Rect2(Vector2( + clampf(candidate.position.x, safe_rect.position.x, safe_max.x), + clampf(candidate.position.y, safe_rect.position.y, safe_max.y) + ), group_size) + var score: float = clamped_rect.position.distance_to(desired_rect.position) * 0.05 + if rig_bounds_screen.has_area(): + score += _rect_overlap_area(clamped_rect, rig_bounds_screen) * 100000.0 + score += _rect_overlap_area(clamped_rect, target_flag_rect) * 100000.0 + if actor_name_rect.has_area(): + score += _rect_overlap_area(clamped_rect, actor_name_rect) * 100000.0 + for obstacle: Rect2 in _actor_name_badge_obstacles: + if obstacle.has_area(): + score += _rect_overlap_area(clamped_rect, obstacle) * 100000.0 + if score < best_score: + best_score = score + best_rect = clamped_rect + return best_rect + + +func _draw_action_badge_stack_in_rect( + group_rect: Rect2, + primary_label: String, + primary_icon: String, + primary_size: Vector2, + primary_tint: Color, + say_tint: Color, + zoom: float +) -> void: + var say_size: Vector2 = _icon_action_badge_screen_size("Say", "", zoom) + var group_center_x: float = group_rect.get_center().x + var primary_center_screen: Vector2 = Vector2(group_center_x, group_rect.position.y + primary_size.y * 0.5) + var say_center_screen: Vector2 = Vector2(group_center_x, group_rect.position.y + primary_size.y + action_badge_stack_gap_px + say_size.y * 0.5) + var inverse_canvas_transform: Transform2D = get_viewport().get_canvas_transform().affine_inverse() + var primary_center: Vector2 = to_local(inverse_canvas_transform * primary_center_screen) + var say_center: Vector2 = to_local(inverse_canvas_transform * say_center_screen) + _draw_icon_action_badge(primary_center, primary_label, primary_icon, zoom, primary_tint) + _draw_icon_action_badge(say_center, "Say", "speak", zoom, say_tint) + + +func _draw_focused_action_badge_pair( + rig: StickmanRig, + anchor: Vector2, + primary_label: String, + primary_icon: String, + zoom: float, + primary_tint: Color, + say_tint: Color, + beside_upper_body: bool = false +) -> void: + if rig == null or not is_instance_valid(rig) or primary_label.is_empty() or primary_icon.is_empty(): + return + var primary_size: Vector2 = _icon_action_badge_screen_size(primary_label, "", zoom) + var say_size: Vector2 = _icon_action_badge_screen_size("Say", "", zoom) + var gap_px: float = action_badge_stack_gap_px + var group_size: Vector2 = Vector2(maxf(primary_size.x, say_size.x), primary_size.y + gap_px + say_size.y) + var group_rect: Rect2 = _focused_action_pair_screen_rect(rig, anchor, group_size, zoom, beside_upper_body) + _draw_action_badge_stack_in_rect(group_rect, primary_label, primary_icon, primary_size, primary_tint, say_tint, zoom) + + +func _focused_action_pair_screen_rect(rig: StickmanRig, anchor: Vector2, group_size: Vector2, zoom: float, beside_upper_body: bool) -> Rect2: + var canvas_transform: Transform2D = get_viewport().get_canvas_transform() + var viewport_size: Vector2 = get_viewport().get_visible_rect().size + var safe_rect: Rect2 = Rect2( + Vector2(8.0, 72.0), + Vector2(maxf(0.0, viewport_size.x - 16.0), maxf(0.0, viewport_size.y - 112.0)) + ) + var candidates: Array[Rect2] = [] + var collision_rects: Array[Rect2] = [] + var rig_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(rig) + var rig_bounds_screen: Rect2 = _world_rect_to_screen_rect(rig_world_bounds, canvas_transform) if rig_world_bounds.has_area() else Rect2() + if rig_bounds_screen.has_area(): + collision_rects.append(rig_bounds_screen) + if beside_upper_body: + var head: Node2D = rig.get_node_or_null(NodePath("Body/Head")) as Node2D + var head_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(head) + var head_bounds_screen: Rect2 + if head_world_bounds.has_area(): + head_bounds_screen = _world_rect_to_screen_rect(head_world_bounds, canvas_transform) + else: + var geometry: Dictionary = rig.speech_head_geometry() + var head_center: Vector2 = canvas_transform * rig.to_global(geometry.get("center", Vector2(0.0, -310.0))) + var radius: float = maxf(1.0, float(geometry.get("radius", 100.0))) * zoom + head_bounds_screen = Rect2(head_center - Vector2.ONE * radius, Vector2.ONE * radius * 2.0) + collision_rects.append(head_bounds_screen) + var middle_body_y: float = head_bounds_screen.end.y + maxf(8.0, head_bounds_screen.size.y * 0.42) + var gap: float = 12.0 + candidates = [ + Rect2(Vector2(rig_bounds_screen.position.x - group_size.x - gap, middle_body_y - group_size.y * 0.5), group_size), + Rect2(Vector2(rig_bounds_screen.end.x + gap, middle_body_y - group_size.y * 0.5), group_size), + Rect2(Vector2(rig_bounds_screen.position.x - group_size.x - gap, head_bounds_screen.end.y + gap), group_size), + Rect2(Vector2(rig_bounds_screen.end.x + gap, head_bounds_screen.end.y + gap), group_size), + ] + else: + var anchor_screen: Vector2 = canvas_transform * anchor + var flag_rect: Rect2 = Rect2(anchor_screen + Vector2(0.0, -42.0), Vector2(40.0, 22.0)) + collision_rects.append(flag_rect) + var gap: float = 10.0 + var preferred: Rect2 = Rect2(Vector2(flag_rect.get_center().x - group_size.x * 0.5, flag_rect.position.y - gap - group_size.y), group_size) + var side_center_y: float = flag_rect.get_center().y - group_size.y * 0.5 + candidates = [ + preferred, + Rect2(Vector2(flag_rect.position.x - gap - group_size.x, flag_rect.get_center().y - group_size.y * 0.5), group_size), + Rect2(Vector2(flag_rect.end.x + gap, flag_rect.get_center().y - group_size.y * 0.5), group_size), + Rect2(Vector2(rig_bounds_screen.position.x - gap - group_size.x, side_center_y), group_size), + Rect2(Vector2(rig_bounds_screen.end.x + gap, side_center_y), group_size), + ] + var preferred_rect: Rect2 = candidates[0] + var safe_max: Vector2 = Vector2( + maxf(safe_rect.position.x, safe_rect.end.x - group_size.x), + maxf(safe_rect.position.y, safe_rect.end.y - group_size.y) + ) + var actor_name_rect: Rect2 = _actor_name_badge_screen_rect(rig, zoom) + var best_rect: Rect2 = preferred_rect + var best_score: float = INF + for candidate: Rect2 in candidates: + var clamped_rect: Rect2 = Rect2(Vector2( + clampf(candidate.position.x, safe_rect.position.x, safe_max.x), + clampf(candidate.position.y, safe_rect.position.y, safe_max.y) + ), group_size) + var score: float = clamped_rect.position.distance_to(preferred_rect.position) * 0.05 + for collision_rect: Rect2 in collision_rects: + if collision_rect.has_area(): + score += _rect_overlap_area(clamped_rect, collision_rect) * 100000.0 + if actor_name_rect.has_area(): + score += _rect_overlap_area(clamped_rect, actor_name_rect) * 100000.0 + for obstacle: Rect2 in _actor_name_badge_obstacles: + if obstacle.has_area(): + score += _rect_overlap_area(clamped_rect, obstacle) * 100000.0 + if score < best_score: + best_score = score + best_rect = clamped_rect + return best_rect + + +func _actor_name_badge_screen_rect(rig: StickmanRig, zoom: float) -> Rect2: + if rig == null or not is_instance_valid(rig): + return Rect2() + var name_text: String = rig.stage_name.strip_edges() + if name_text.is_empty(): + return Rect2() + var scale: float = 1.0 / maxf(zoom, 0.0001) + var font: Font = _badge_font() + var font_size: int = maxi(18, int(roundf(actor_name_font_size_px * scale))) + var padding: Vector2 = actor_name_padding_px * scale + var max_text_width: float = maxf(80.0, actor_name_max_width_px * scale - padding.x * 2.0) + var display_name: String = _elide_badge_text(name_text, font, font_size, max_text_width) + var badge_size: Vector2 = (font.get_string_size(display_name, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + padding * 2.0) * zoom + var canvas_transform: Transform2D = get_viewport().get_canvas_transform() + var head_visual: Node2D = rig.get_node_or_null(NodePath("Body/Head")) as Node2D + var head_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(head_visual) + var head_bounds_screen: Rect2 + if head_world_bounds.has_area(): + head_bounds_screen = _world_rect_to_screen_rect(head_world_bounds, canvas_transform) + else: + var geometry: Dictionary = rig.speech_head_geometry() + var head_center: Vector2 = geometry.get("center", Vector2(0.0, -310.0)) + var head_radius: float = maxf(1.0, float(geometry.get("radius", 100.0))) + var center_screen: Vector2 = canvas_transform * rig.to_global(head_center) + var radius_screen: float = head_radius * zoom + head_bounds_screen = Rect2(center_screen - Vector2.ONE * radius_screen, Vector2.ONE * radius_screen * 2.0) + var rig_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(rig) + if rig_world_bounds.has_area(): + var rig_bounds_screen: Rect2 = _world_rect_to_screen_rect(rig_world_bounds, canvas_transform) + var visible_overhang: float = clampf(head_bounds_screen.position.y - rig_bounds_screen.position.y, 0.0, 48.0) + head_bounds_screen.position.y -= visible_overhang + head_bounds_screen.size.y += visible_overhang + var head_center_screen: Vector2 = head_bounds_screen.get_center() + var preferred_rect: Rect2 = Rect2( + Vector2(head_center_screen.x - badge_size.x * 0.5, head_bounds_screen.position.y - actor_name_offset_px - badge_size.y), + badge_size + ) + var layout: Dictionary = _actor_name_badge_layout(preferred_rect, head_center_screen, head_bounds_screen, badge_size) + return layout.get("screen_rect", preferred_rect) + + +func _draw_actor_name_badge(rig: StickmanRig, zoom: float) -> void: + if rig == null or not is_instance_valid(rig): + return + var name_text: String = rig.stage_name.strip_edges() + if name_text.is_empty(): + return + var scale: float = 1.0 / maxf(zoom, 0.0001) + var font: Font = _badge_font() + var font_size: int = maxi(18, int(roundf(actor_name_font_size_px * scale))) + var padding: Vector2 = actor_name_padding_px * scale + var max_text_width: float = maxf(80.0, actor_name_max_width_px * scale - padding.x * 2.0) + var display_name: String = _elide_badge_text(name_text, font, font_size, max_text_width) + var text_size: Vector2 = font.get_string_size(display_name, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + var badge_size: Vector2 = text_size + padding * 2.0 + var canvas_transform: Transform2D = get_viewport().get_canvas_transform() + var inverse_canvas_transform: Transform2D = canvas_transform.affine_inverse() + var head_visual: Node2D = rig.get_node_or_null(NodePath("Body/Head")) as Node2D + var head_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(head_visual) + var head_bounds_screen: Rect2 + if head_world_bounds.has_area(): + head_bounds_screen = _world_rect_to_screen_rect(head_world_bounds, canvas_transform) + else: + var geometry: Dictionary = rig.speech_head_geometry() + var head_center: Vector2 = geometry.get("center", Vector2(0.0, -310.0)) + var head_radius: float = maxf(1.0, float(geometry.get("radius", 100.0))) + var center_screen: Vector2 = canvas_transform * rig.to_global(head_center) + var radius_screen: float = head_radius * zoom + head_bounds_screen = Rect2(center_screen - Vector2.ONE * radius_screen, Vector2.ONE * radius_screen * 2.0) + # Some mounted headwear is authored outside Body/Head's visible bounds. + # Include the upper silhouette of the complete rig, capped so raised hands + # cannot pull the name badge far away from the head. + var rig_world_bounds: Rect2 = STAGE_SELECTION.get_world_aabb(rig) + if rig_world_bounds.has_area(): + var rig_bounds_screen: Rect2 = _world_rect_to_screen_rect(rig_world_bounds, canvas_transform) + var visible_overhang: float = clampf(head_bounds_screen.position.y - rig_bounds_screen.position.y, 0.0, 48.0) + head_bounds_screen.position.y -= visible_overhang + head_bounds_screen.size.y += visible_overhang + var head_center_screen: Vector2 = head_bounds_screen.get_center() + var badge_screen_size: Vector2 = badge_size * zoom + var gap_px: float = actor_name_offset_px + var preferred_rect: Rect2 = Rect2( + Vector2(head_center_screen.x - badge_screen_size.x * 0.5, head_bounds_screen.position.y - gap_px - badge_screen_size.y), + badge_screen_size + ) + var layout: Dictionary = _actor_name_badge_layout( + preferred_rect, + head_center_screen, + head_bounds_screen, + badge_screen_size + ) + var screen_rect: Rect2 = layout.get("screen_rect", preferred_rect) + var local_position: Vector2 = to_local(inverse_canvas_transform * screen_rect.position) + var rect: Rect2 = Rect2(local_position, badge_size) + var side: String = String(layout.get("side", "above")) + if bool(layout.get("tethered", false)): + var tether_from_screen: Vector2 = _closest_screen_point_on_rect(head_bounds_screen, screen_rect.get_center()) + var tether_to_screen: Vector2 = _closest_screen_point_on_rect(screen_rect, tether_from_screen) + draw_line( + to_local(inverse_canvas_transform * tether_from_screen), + to_local(inverse_canvas_transform * tether_to_screen), + plan_label_bg_color, + 4.0 * scale, + true + ) + draw_line( + to_local(inverse_canvas_transform * tether_from_screen), + to_local(inverse_canvas_transform * tether_to_screen), + actor_name_border_color, + 1.5 * scale, + true + ) + draw_style_box(_rounded_badge_style( + plan_label_bg_color, + actor_name_border_color, + actor_name_radius_px * scale, + actor_name_border_width_px * scale + ), rect) + var baseline: float = rect.position.y + (rect.size.y - font.get_height(font_size)) * 0.5 + font.get_ascent(font_size) + draw_string(font, Vector2(rect.position.x + padding.x, baseline), display_name, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, action_badge_text_color) + _last_actor_name_badge_layouts.append({ + "rig": rig, + "stage_name": name_text, + "display_name": display_name, + "screen_rect": screen_rect, + "head_bounds_screen": head_bounds_screen, + "side": side, + "tethered": bool(layout.get("tethered", false)), + "gap_px": gap_px, + }) + + +func _actor_name_badge_layout(preferred_rect: Rect2, head_center_screen: Vector2, head_bounds_screen: Rect2, badge_size: Vector2) -> Dictionary: + var viewport_size: Vector2 = get_viewport().get_visible_rect().size + var safe_rect: Rect2 = Rect2( + Vector2(8.0, 72.0), + Vector2(maxf(0.0, viewport_size.x - 16.0), maxf(0.0, viewport_size.y - 112.0)) + ) + var gap_px: float = actor_name_offset_px + var candidate_rects: Array[Dictionary] = [ + {"rect": preferred_rect, "side": "above"}, + { + "rect": Rect2(Vector2(head_bounds_screen.position.x - gap_px - badge_size.x, head_center_screen.y - badge_size.y * 0.5), badge_size), + "side": "left", + }, + { + "rect": Rect2(Vector2(head_bounds_screen.end.x + gap_px, head_center_screen.y - badge_size.y * 0.5), badge_size), + "side": "right", + }, + ] + var safe_max: Vector2 = Vector2( + maxf(safe_rect.position.x, safe_rect.end.x - badge_size.x), + maxf(safe_rect.position.y, safe_rect.end.y - badge_size.y) + ) + var best_rect: Rect2 = preferred_rect + var best_side: String = "above" + var best_score: float = INF + for candidate: Dictionary in candidate_rects: + var raw_rect: Rect2 = candidate.get("rect", preferred_rect) + var clamped_rect: Rect2 = Rect2(Vector2( + clampf(raw_rect.position.x, safe_rect.position.x, safe_max.x), + clampf(raw_rect.position.y, safe_rect.position.y, safe_max.y) + ), badge_size) + var score: float = clamped_rect.position.distance_to(preferred_rect.position) * 0.02 + score += clamped_rect.intersection(head_bounds_screen).get_area() * 100000.0 + for obstacle: Rect2 in _actor_name_badge_obstacles: + if obstacle.has_area(): + score += clamped_rect.intersection(obstacle).get_area() * 100000.0 + if score < best_score: + best_score = score + best_rect = clamped_rect + best_side = String(candidate.get("side", "above")) + return { + "screen_rect": best_rect, + "side": best_side, + "tethered": best_side != "above" or best_rect.get_center().distance_to(preferred_rect.get_center()) > 24.0, + } + + +func _closest_screen_point_on_rect(rect: Rect2, point: Vector2) -> Vector2: + return Vector2( + clampf(point.x, rect.position.x, rect.end.x), + clampf(point.y, rect.position.y, rect.end.y) + ) + + +func _world_rect_to_screen_rect(world_rect: Rect2, canvas_transform: Transform2D) -> Rect2: + var corners: Array[Vector2] = [ + world_rect.position, + Vector2(world_rect.end.x, world_rect.position.y), + world_rect.end, + Vector2(world_rect.position.x, world_rect.end.y), + ] + var result: Rect2 = Rect2(canvas_transform * corners[0], Vector2.ZERO) + for corner_index: int in range(1, corners.size()): + result = result.expand(canvas_transform * corners[corner_index]) + return result + + +func _elide_badge_text(value: String, font: Font, font_size: int, max_width: float) -> String: + if font.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x <= max_width: + return value + var end_index: int = value.length() + while end_index > 1: + end_index -= 1 + var candidate: String = value.substr(0, end_index) + "…" + if font.get_string_size(candidate, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x <= max_width: + return candidate + return "…" + + +func _rounded_badge_style(background: Color, border: Color, radius: float, border_width: float) -> StyleBoxFlat: + var style: StyleBoxFlat = StyleBoxFlat.new() + style.bg_color = background + style.border_color = border + style.set_border_width_all(maxi(1, int(roundf(border_width)))) + style.set_corner_radius_all(maxi(1, int(roundf(radius)))) + style.corner_detail = 6 + return style func _queue_anchor(rig: StickmanRig) -> Dictionary: @@ -252,19 +1459,49 @@ func _status_color(status: int) -> Color: return Color(completed_path_color.r, completed_path_color.g, completed_path_color.b, completed_path_alpha) return Color(1.0, 1.0, 1.0, 1.0) + +func _draw_spotlight_route(from: Vector2, to: Vector2, zoom: float, tint: Color) -> void: + if not from.is_finite() or not to.is_finite() or from.distance_to(to) <= 0.5: + return + var route_color: Color = tint + route_color.a *= spotlight_route_alpha + var shadow: Color = route_shadow_color + shadow.a *= route_color.a + _draw_dashed_styled(from, to, zoom, shadow, spotlight_route_shadow_width_px, spotlight_dash_length_px, spotlight_dash_gap_px) + _draw_dashed_styled(from, to, zoom, route_color, spotlight_route_width_px, spotlight_dash_length_px, spotlight_dash_gap_px) + + func _draw_dashed(from: Vector2, to: Vector2, zoom: float, tint: Color = DASH_COLOR) -> void: - var dash := DASH_LENGTH_PX / zoom - var gap := DASH_GAP_PX / zoom + var line_color: Color = DASH_COLOR if tint == Color.WHITE else tint + _draw_dashed_styled(from, to, zoom, line_color, DASH_WIDTH_PX, DASH_LENGTH_PX, DASH_GAP_PX, DASH_COLOR.a) + + +func _draw_dashed_styled( + from: Vector2, + to: Vector2, + zoom: float, + tint: Color, + width_px: float, + dash_length_px: float, + dash_gap_px: float, + alpha_multiplier: float = 1.0 +) -> void: + if not from.is_finite() or not to.is_finite() or zoom <= 0.0: + return + var dash: float = maxf(1.0, dash_length_px) / zoom + var gap: float = maxf(0.0, dash_gap_px) / zoom var dir := from.direction_to(to) var total := from.distance_to(to) var dist := 0.0 - while dist < total: + var dash_count: int = 0 + while dist < total and dash_count < MAX_ROUTE_DASHES: var start := from + dir * dist var len := minf(dash, total - dist) - var line_color: Color = DASH_COLOR if tint == Color.WHITE else tint - line_color.a *= DASH_COLOR.a - draw_line(start, start + dir * len, line_color, DASH_WIDTH_PX / zoom, true) + var line_color: Color = tint + line_color.a *= alpha_multiplier + draw_line(start, start + dir * len, line_color, width_px / zoom, true) dist += dash + gap + dash_count += 1 func _draw_waypoint(pos: Vector2, zoom: float, number: String, tint: Color = WAYPOINT_COLOR) -> void: var radius := WAYPOINT_RADIUS_PX / zoom @@ -278,6 +1515,71 @@ func _draw_waypoint(pos: Vector2, zoom: float, number: String, tint: Color = WAY var pulse := 0.5 + 0.5 * sin(Time.get_ticks_msec() / 150.0) draw_arc(pos, radius + 6.0 / zoom, 0.0, TAU, 32, Color(1.0, 0.8, 0.0, pulse), 3.0 / zoom, true) + +func _draw_plan_destination_flag(pos: Vector2, zoom: float, number: String, tint: Color) -> void: + var scale: float = 1.0 / maxf(zoom, 0.0001) + var flag_top: float = -42.0 * scale + var flag_bottom: float = -20.0 * scale + var pole_bottom: Vector2 = pos + var pole_top: Vector2 = pos + Vector2(0.0, flag_top) + var outline_color: Color = Color.WHITE + outline_color.a = tint.a + draw_line(pole_bottom, pole_top, outline_color, 2.5 * scale, true) + var corners: PackedVector2Array = PackedVector2Array([ + pos + Vector2(0.0, flag_top), + pos + Vector2(31.0 * scale, flag_top), + pos + Vector2(40.0 * scale, -31.0 * scale), + pos + Vector2(31.0 * scale, flag_bottom), + pos + Vector2(0.0, flag_bottom), + ]) + draw_colored_polygon(corners, tint) + var outline: PackedVector2Array = corners.duplicate() + outline.append(corners[0]) + draw_polyline(outline, outline_color, 2.0 * scale, true) + var font: Font = _badge_font() + var font_size: int = maxi(14, int(roundf(plan_flag_number_size_px * scale))) + var text_size: Vector2 = font.get_string_size(number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + var baseline: Vector2 = pos + Vector2(15.5 * scale - text_size.x * 0.5, -28.0 * scale + font.get_ascent(font_size) * 0.34) + draw_string(font, baseline, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size, outline_color) + if _edit_waypoint.is_finite() and pos.distance_to(_edit_waypoint) < 0.5: + var pulse: float = 0.5 + 0.5 * sin(Time.get_ticks_msec() / 150.0) + draw_arc(pos, plan_flag_size_px * 0.62 * scale, 0.0, TAU, 32, Color(1.0, 0.8, 0.0, pulse), 3.0 * scale, true) + + +func _say_marker_center(start: Vector2, zoom: float) -> Vector2: + return start + PLAN_SAY_MARKER_OFFSET_PX / maxf(zoom, 0.0001) + + +func _say_marker_rect(start: Vector2, zoom: float) -> Rect2: + var size: Vector2 = PLAN_SAY_MARKER_SIZE_PX / maxf(zoom, 0.0001) + return Rect2(_say_marker_center(start, zoom) - size * 0.5, size) + + +func _draw_say_marker(start: Vector2, number: String, zoom: float, tint: Color) -> void: + var scale: float = 1.0 / maxf(zoom, 0.0001) + var marker_rect: Rect2 = _say_marker_rect(start, zoom) + var center: Vector2 = marker_rect.get_center() + var tail_tip: Vector2 = marker_rect.end + Vector2(-5.0, 5.0) * scale + var tail_base_a: Vector2 = marker_rect.end + Vector2(-12.0, 0.0) * scale + var tail_base_b: Vector2 = marker_rect.end + Vector2(0.0, -8.0) * scale + var leader_color: Color = tint + leader_color.a *= 0.82 + draw_line(start, tail_tip, leader_color, 1.5 * scale, true) + draw_rect(marker_rect, plan_label_bg_color, true) + draw_rect(marker_rect, tint, false, 2.0 * scale, true) + draw_colored_polygon(PackedVector2Array([tail_base_a, tail_tip, tail_base_b]), plan_label_bg_color) + draw_polyline(PackedVector2Array([tail_base_a, tail_tip, tail_base_b]), tint, 2.0 * scale, true) + draw_line(center + Vector2(-7.0, -2.0) * scale, center + Vector2(7.0, -2.0) * scale, tint, 2.0 * scale, true) + draw_line(center + Vector2(-7.0, 4.0) * scale, center + Vector2(4.0, 4.0) * scale, tint, 2.0 * scale, true) + var font: Font = _badge_font() + draw_string(font, marker_rect.end + Vector2(3.0, -3.0) * scale, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, maxi(16, int(roundf(badge_number_size * scale))), tint) + + +func _plan_flag_hit_rect(pos: Vector2, zoom: float) -> Rect2: + var size: float = plan_flag_size_px / maxf(zoom, 0.0001) + return Rect2(pos - Vector2(size * 0.5, size * 0.5), Vector2(size, size)) + + func _draw_number(pos: Vector2, number: String, zoom: float, tint: Color = NUMBER_COLOR) -> void: draw_string(_badge_font(), pos, number, HORIZONTAL_ALIGNMENT_LEFT, -1.0, int(badge_number_size / zoom), tint) @@ -317,16 +1619,9 @@ func _draw_badge(anchor: Vector2, type: String, zoom: float, number: String, tin func _collect_waypoints() -> Array[Vector2]: var result: Array[Vector2] = [] for rig: StickmanRig in _collect_rigs(): - var queue := rig.get_queue() - if queue.is_empty(): - continue - var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET - for i: int in queue.size(): - var action: Dictionary = queue[i] - if ACTION_REGISTRY.is_move_action(action): - var target: Vector2 = action.get("target", current) - result.append(target) - current = target + for entry: Dictionary in _plan_projection(rig): + if String(entry.get("kind", "")) == "move": + result.append(entry.get("end", Vector2.ZERO)) return result diff --git a/scripts/stage/stage_gizmos.gd b/scripts/stage/stage_gizmos.gd index 45465e9..46ce5ce 100644 --- a/scripts/stage/stage_gizmos.gd +++ b/scripts/stage/stage_gizmos.gd @@ -48,6 +48,7 @@ const ROTATE_HANDLE_OFFSET_PX := 24.0 const ROTATE_HANDLE_HIT_HALF_SIZE_PX := 22.0 const ROTATE_ICON_RADIUS_PX := 8.0 const ROTATE_HANDLE_SAFE_TOP_PX := 112.0 +const NAME_BADGE_OVERLAP_TOLERANCE_PX2 := 128.0 const SCALE_CROSS_SIZE_PX := 5.0 const SCALE_HIT_RADIUS_PX := 12.0 const SCENERY_SCALE_HIT_HALF_SIZE_PX := 22.0 @@ -82,6 +83,12 @@ var _candidate_preview_name_corner_radius: float = 8.0 var _candidate_preview_name_border_width: float = 3.0 var _candidate_preview_name_padding: Vector2 = Vector2(12.0, 8.0) var _candidate_preview_name_style: StyleBoxFlat = StyleBoxFlat.new() +var _selected_name_pill_font_size_px: float = 20.0 +var _selected_name_pill_max_width_px: float = 260.0 +var _selected_name_pill_style: StyleBoxFlat = StyleBoxFlat.new() +var _selected_name_badge_obstacles: Array[Rect2] = [] +var _last_selected_name_badge_layouts: Array[Dictionary] = [] +var _selected_name_badge_slots: Dictionary = {} var _dragging: Handle = Handle.NONE var _drag_last: Vector2 = Vector2.ZERO var _drag_anchor: Node2D = null @@ -102,6 +109,7 @@ var _scale_changed: bool = false func set_targets(nodes: Array[Node2D]) -> void: _targets = nodes.duplicate() + _sync_selected_name_badge_slots() queue_redraw() @@ -120,6 +128,13 @@ func set_candidate_preview_obstacles(obstacles: Array[Rect2]) -> void: queue_redraw() +func set_selected_name_badge_obstacles(obstacles: Array[Rect2]) -> void: + if _selected_name_badge_obstacles == obstacles: + return + _selected_name_badge_obstacles = obstacles.duplicate() + queue_redraw() + + func set_enabled(enabled: bool) -> void: _enabled = enabled visible = enabled @@ -152,6 +167,8 @@ func apply_theme(stage_theme: SandboxTheme) -> void: stage_theme.number("overlays.gizmos.candidate_name_pill_padding_x", 12.0, 4.0, 32.0), stage_theme.number("overlays.gizmos.candidate_name_pill_padding_y", 8.0, 2.0, 24.0) ) + _selected_name_pill_font_size_px = stage_theme.number("overlays.gizmos.selected_name_pill_font_size_px", 20.0, 16.0, 30.0) + _selected_name_pill_max_width_px = stage_theme.number("overlays.gizmos.selected_name_pill_max_width_px", 260.0, 140.0, 420.0) _outline_extra_width = stage_theme.number("overlays.gizmos.backing_extra_width", 3.0, 1.0, 8.0) queue_redraw() @@ -232,6 +249,7 @@ func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void: _drag_anchor = node if not _targets.has(node): _targets = [node] + _sync_selected_name_badge_slots() _dragging = Handle.TRANSLATE _drag_last = world_pos _translate_drag_origin = world_pos @@ -313,6 +331,10 @@ func _draw() -> void: for node: Node2D in _targets: if node != null and is_instance_valid(node): _draw_halo_rect(_target_bounds(node), _selection_core_color, SELECTION_WIDTH / zoom, _selection_backing_color) + if _candidate_preview == null: + _draw_selected_name_badges(zoom) + else: + _last_selected_name_badge_layouts.clear() # A clear rectangle signals selection; the compact handle keeps rotation available. if _transform_handles_visible and _targets.size() == 1 and is_instance_valid(_targets[0]): var rect: Rect2 = _target_bounds(_targets[0]) @@ -453,6 +475,227 @@ func _draw_candidate_preview_name(node: Node2D, zoom: float) -> void: ) +func _draw_selected_name_badges(zoom: float) -> void: + _last_selected_name_badge_layouts.clear() + _sync_selected_name_badge_slots() + if _targets.is_empty(): + return + var font: Font = ThemeDB.fallback_font + if font == null: + return + var viewport: Viewport = get_viewport() + var viewport_size: Vector2 = viewport.get_visible_rect().size + var canvas_transform: Transform2D = viewport.get_canvas_transform() + var inverse_canvas_transform: Transform2D = canvas_transform.affine_inverse() + var safe_rect: Rect2 = Rect2( + Vector2(8.0, 72.0), + Vector2(maxf(0.0, viewport_size.x - 16.0), maxf(0.0, viewport_size.y - 112.0)) + ) + var entries: Array[Dictionary] = [] + var selected_object_bounds: Array[Rect2] = [] + for node: Node2D in _targets: + if node == null or not is_instance_valid(node) or not node.is_visible_in_tree(): + continue + var full_name: String = _stage_name_for_node(node) + if full_name.is_empty(): + continue + var font_size: int = maxi(1, roundi(_selected_name_pill_font_size_px / zoom)) + var padding_world: Vector2 = _candidate_preview_name_padding / zoom + var max_text_width_world: float = maxf(32.0, (_selected_name_pill_max_width_px - _candidate_preview_name_padding.x * 2.0) / zoom) + var display_name: String = _elide_selected_name(full_name, font, font_size, max_text_width_world) + var text_size: Vector2 = font.get_string_size(display_name, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size) + var badge_world_size: Vector2 = Vector2(text_size.x, font.get_height(font_size)) + padding_world * 2.0 + var bounds_screen: Rect2 = _world_rect_to_screen_rect(_target_bounds(node), canvas_transform) + selected_object_bounds.append(bounds_screen) + entries.append({ + "node": node, + "stage_name": full_name, + "display_name": display_name, + "font_size": font_size, + "text_size": text_size, + "badge_world_size": badge_world_size, + "badge_screen_size": badge_world_size * zoom, + "bounds_screen": bounds_screen, + }) + if entries.is_empty(): + return + + var occupied_badges: Array[Rect2] = [] + for entry_index: int in entries.size(): + var entry: Dictionary = entries[entry_index] + var node: Node2D = entry.get("node") as Node2D + var object_bounds: Rect2 = entry.get("bounds_screen", Rect2()) + var badge_size: Vector2 = entry.get("badge_screen_size", Vector2.ZERO) + var object_center: Vector2 = object_bounds.get_center() + var gap: float = 8.0 + var preferred: Rect2 = Rect2(Vector2(object_center.x - badge_size.x * 0.5, object_bounds.position.y - badge_size.y - gap), badge_size) + var candidates: Array[Rect2] = [ + preferred, + Rect2(Vector2(object_bounds.position.x - badge_size.x - gap, object_center.y - badge_size.y * 0.5), badge_size), + Rect2(Vector2(object_bounds.end.x + gap, object_center.y - badge_size.y * 0.5), badge_size), + Rect2(Vector2(object_center.x - badge_size.x * 0.5, object_bounds.end.y + gap), badge_size), + Rect2(Vector2(object_bounds.position.x - badge_size.x - gap, object_bounds.position.y - badge_size.y - gap), badge_size), + Rect2(Vector2(object_bounds.end.x + gap, object_bounds.position.y - badge_size.y - gap), badge_size), + Rect2(Vector2(object_bounds.position.x - badge_size.x - gap, object_bounds.end.y - badge_size.y), badge_size), + Rect2(Vector2(object_bounds.end.x + gap, object_bounds.end.y - badge_size.y), badge_size), + ] + var badge_id: int = node.get_instance_id() + var current_slot: int = int(_selected_name_badge_slots.get(badge_id, -1)) + var best_slot: int = current_slot + var best_rect: Rect2 = _place_selected_name_badge(candidates[current_slot], badge_size, safe_rect) if current_slot >= 0 and current_slot < candidates.size() else Rect2() + var current_obstruction: float = _selected_name_badge_obstruction( + best_rect, + object_bounds, + entry_index, + selected_object_bounds, + occupied_badges, + node, + canvas_transform + ) if current_slot >= 0 and current_slot < candidates.size() else INF + if current_obstruction > 0.01: + var best_score: float = INF + for candidate_index: int in candidates.size(): + var placed: Rect2 = _place_selected_name_badge(candidates[candidate_index], badge_size, safe_rect) + var obstruction: float = _selected_name_badge_obstruction( + placed, + object_bounds, + entry_index, + selected_object_bounds, + occupied_badges, + node, + canvas_transform + ) + var score: float = placed.position.distance_to(preferred.position) * 0.02 + obstruction + if score < best_score: + best_score = score + best_rect = placed + best_slot = candidate_index + _selected_name_badge_slots[badge_id] = best_slot + occupied_badges.append(best_rect) + entry["screen_rect"] = best_rect + entry["anchor_slot"] = best_slot + entry["tethered"] = best_rect.get_center().distance_to(preferred.get_center()) > 24.0 + _last_selected_name_badge_layouts.append(entry) + + for entry: Dictionary in _last_selected_name_badge_layouts: + var screen_rect: Rect2 = entry.get("screen_rect", Rect2()) + var target_rect: Rect2 = entry.get("bounds_screen", Rect2()) + if bool(entry.get("tethered", false)): + var target_point: Vector2 = _closest_point_on_rect(target_rect, screen_rect.get_center()) + var badge_point: Vector2 = _closest_point_on_rect(screen_rect, target_point) + var tether_from: Vector2 = to_local(inverse_canvas_transform * target_point) + var tether_to: Vector2 = to_local(inverse_canvas_transform * badge_point) + draw_line(tether_from, tether_to, _candidate_preview_name_background, 4.0 / zoom, true) + draw_line(tether_from, tether_to, Color(_candidate_preview_color, 0.9), 1.5 / zoom, true) + entry["tether_from_screen"] = target_point + entry["tether_to_screen"] = badge_point + var local_position: Vector2 = to_local(inverse_canvas_transform * screen_rect.position) + var world_size: Vector2 = entry.get("badge_world_size", Vector2.ZERO) + var local_rect: Rect2 = Rect2(local_position, world_size) + _selected_name_pill_style.bg_color = _candidate_preview_name_background + _selected_name_pill_style.border_color = _candidate_preview_color + _selected_name_pill_style.set_corner_radius_all(maxi(1, roundi(_candidate_preview_name_corner_radius / zoom))) + _selected_name_pill_style.set_border_width_all(maxi(1, roundi(_candidate_preview_name_border_width / zoom))) + draw_style_box(_selected_name_pill_style, local_rect) + var font_size: int = int(entry.get("font_size", 20)) + var text: String = String(entry.get("display_name", "")) + var baseline: float = local_rect.position.y + (local_rect.size.y - font.get_height(font_size)) * 0.5 + font.get_ascent(font_size) + draw_string( + font, + Vector2(local_rect.position.x + _candidate_preview_name_padding.x / zoom, baseline), + text, + HORIZONTAL_ALIGNMENT_LEFT, + -1.0, + font_size, + Color.WHITE + ) + + +func _stage_name_for_node(node: Node2D) -> String: + if node == null or not is_instance_valid(node): + return "" + if node is PropBlockScript or node is StickmanRigScript or node.get_script() == MissingPropScript or node.get_script() == MissingStickmanScript: + return String(node.get("stage_name")).strip_edges() + return "" + + +func _sync_selected_name_badge_slots() -> void: + var selected_ids: Dictionary = {} + for node: Node2D in _targets: + if node != null and is_instance_valid(node): + selected_ids[node.get_instance_id()] = true + for id: Variant in _selected_name_badge_slots.keys(): + if not selected_ids.has(id): + _selected_name_badge_slots.erase(id) + + +func _place_selected_name_badge(candidate: Rect2, badge_size: Vector2, safe_rect: Rect2) -> Rect2: + var safe_max: Vector2 = Vector2( + maxf(safe_rect.position.x, safe_rect.end.x - badge_size.x), + maxf(safe_rect.position.y, safe_rect.end.y - badge_size.y) + ) + var clamped_position: Vector2 = Vector2( + clampf(candidate.position.x, safe_rect.position.x, safe_max.x), + clampf(candidate.position.y, safe_rect.position.y, safe_max.y) + ) + return Rect2(clamped_position, badge_size) + + +func _selected_name_badge_obstruction( + badge_rect: Rect2, + object_bounds: Rect2, + entry_index: int, + selected_object_bounds: Array[Rect2], + occupied_badges: Array[Rect2], + node: Node2D, + canvas_transform: Transform2D +) -> float: + var obstruction: float = _selected_name_badge_overlap_penalty(badge_rect, object_bounds, 12000.0) + for other_index: int in selected_object_bounds.size(): + if other_index != entry_index: + obstruction += _selected_name_badge_overlap_penalty(badge_rect, selected_object_bounds[other_index], 8000.0) + for obstacle: Rect2 in _selected_name_badge_obstacles: + obstruction += _selected_name_badge_overlap_penalty(badge_rect, obstacle, 12000.0) + for obstacle: Rect2 in _candidate_preview_obstacles: + obstruction += _selected_name_badge_overlap_penalty(badge_rect, obstacle, 12000.0) + for occupied: Rect2 in occupied_badges: + obstruction += _selected_name_badge_overlap_penalty(badge_rect, occupied, 24000.0) + if _transform_handles_visible and _targets.size() == 1: + var rotate_screen: Vector2 = canvas_transform * _rotate_handle_position(_target_bounds(node)) + var rotate_rect: Rect2 = Rect2(rotate_screen - Vector2(24.0, 24.0), Vector2(48.0, 48.0)) + obstruction += _selected_name_badge_overlap_penalty(badge_rect, rotate_rect, 16000.0) + if node is PropBlockScript: + for corner: Vector2 in _corners(_target_bounds(node)): + var corner_screen: Vector2 = canvas_transform * corner + var scale_rect: Rect2 = Rect2(corner_screen - Vector2(12.0, 12.0), Vector2(24.0, 24.0)) + obstruction += _selected_name_badge_overlap_penalty(badge_rect, scale_rect, 16000.0) + return obstruction + + +func _selected_name_badge_overlap_penalty(badge_rect: Rect2, obstacle_rect: Rect2, weight: float) -> float: + var overlap_area: float = badge_rect.intersection(obstacle_rect).get_area() + return maxf(0.0, overlap_area - NAME_BADGE_OVERLAP_TOLERANCE_PX2) * weight + + +func _elide_selected_name(value: String, font: Font, font_size: int, max_width: float) -> String: + if font.get_string_size(value, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x <= max_width: + return value + var end_index: int = value.length() + while end_index > 1: + end_index -= 1 + var candidate: String = value.substr(0, end_index) + "…" + if font.get_string_size(candidate, HORIZONTAL_ALIGNMENT_LEFT, -1.0, font_size).x <= max_width: + return candidate + return "…" + + +func _closest_point_on_rect(rect: Rect2, point: Vector2) -> Vector2: + return Vector2( + clampf(point.x, rect.position.x, rect.end.x), + clampf(point.y, rect.position.y, rect.end.y) + ) + + func _world_rect_to_screen_rect(world_rect: Rect2, canvas_transform: Transform2D) -> Rect2: var points: Array[Vector2] = [ world_rect.position, diff --git a/scripts/stage/stage_placement_overlay.gd b/scripts/stage/stage_placement_overlay.gd index 7964ba0..9dbdc10 100644 --- a/scripts/stage/stage_placement_overlay.gd +++ b/scripts/stage/stage_placement_overlay.gd @@ -8,6 +8,7 @@ extends Node2D ## Sits above the World and ghost holder, below the CanvasLayer UI. var camera: Camera2D = null +var surface_world: Node2D = null ## Terrain guide-line state. var terrain_guide_visible: bool = false @@ -21,6 +22,8 @@ var action_origin: Vector2 = Vector2.ZERO var action_target: Vector2 = Vector2.ZERO var action_curve_points: PackedVector2Array = PackedVector2Array() var action_valid: bool = true +var action_show_trajectory: bool = true +var action_grounded_trajectory: bool = false var action_requested_target: Vector2 = Vector2.ZERO var action_show_requested_target: bool = false var action_effective_target: Vector2 = Vector2.ZERO @@ -40,6 +43,10 @@ var invalid_color: Color = Color(1.0, 0.3, 0.3) var set_down_unknown_color: Color = Color(0.77, 0.81, 0.87, 0.9) var set_down_reach_color: Color = Color(1.0, 0.7, 0.1, 0.9) var set_down_ghost_alpha: float = 0.36 +var set_down_surface_color: Color = Color(0.13, 0.78, 1.0, 0.5) +var set_down_surface_hover_color: Color = Color(0.13, 0.86, 1.0, 0.95) +var set_down_surface_line_width_px: float = 5.0 +var set_down_surface_hover_line_width_px: float = 7.0 var line_width_px: float = 2.0 var dash_length_px: float = 8.0 var dash_gap_px: float = 5.0 @@ -48,6 +55,9 @@ const DASH_LENGTH_PX := 8.0 const DASH_GAP_PX := 5.0 const LINE_WIDTH_PX := 2.0 const MARKER_RADIUS_PX := 10.0 +const SET_DOWN_SUPPORT_NORMAL_Y := -0.82 +const SET_DOWN_SUPPORT_MIN_EDGE_PX := 18.0 +const SET_DOWN_SURFACE_HOVER_RADIUS_PX := 22.0 func apply_style(style: Dictionary) -> void: guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color) @@ -57,6 +67,12 @@ func apply_style(style: Dictionary) -> void: set_down_unknown_color = Color.from_string(String(set_down_style.get("unknown_color", "")), set_down_unknown_color) set_down_reach_color = Color.from_string(String(set_down_style.get("reach_color", "")), set_down_reach_color) set_down_ghost_alpha = clampf(float(set_down_style.get("ghost_alpha", set_down_ghost_alpha)), 0.0, 1.0) + var set_down_surface_alpha: float = clampf(float(set_down_style.get("surface_alpha", 0.75)), 0.0, 1.0) + var set_down_surface_hover_alpha: float = clampf(float(set_down_style.get("surface_hover_alpha", 0.95)), 0.0, 1.0) + set_down_surface_color = Color(guide_line_color.r, guide_line_color.g, guide_line_color.b, set_down_surface_alpha) + set_down_surface_hover_color = Color(guide_line_color.r, guide_line_color.g, guide_line_color.b, set_down_surface_hover_alpha) + set_down_surface_line_width_px = maxf(1.0, float(set_down_style.get("surface_line_width_px", set_down_surface_line_width_px))) + set_down_surface_hover_line_width_px = maxf(set_down_surface_line_width_px, float(set_down_style.get("surface_hover_line_width_px", set_down_surface_hover_line_width_px))) var defaults: Dictionary = style.get("style_defaults", {}) line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px))) dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px))) @@ -86,6 +102,8 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi action_target = target action_curve_points.clear() action_valid = valid + action_show_trajectory = true + action_grounded_trajectory = false action_show_requested_target = false action_show_collision = false action_preview_unavailable = false @@ -95,12 +113,41 @@ func set_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> voi set_down_prop = null +func set_grounded_action_trajectory(origin: Vector2, target: Vector2, valid: bool) -> void: + ## Shows a flat walk/run route at the actor's ground level and keeps the + ## cursor marker at its actual position. + set_action_trajectory(origin, target, valid) + action_grounded_trajectory = true + queue_redraw() + + +func set_action_marker(target: Vector2, valid: bool = true) -> void: + ## Shows only the cursor marker when this capture already has a route preview. + action_visible = true + action_origin = target + action_target = target + action_curve_points.clear() + action_valid = valid + action_show_trajectory = false + action_grounded_trajectory = false + action_show_requested_target = false + action_show_collision = false + action_preview_unavailable = false + action_preview_planned = false + action_preview_uncertain = false + set_down_preview_visible = false + set_down_prop = null + queue_redraw() + + func set_throw_trajectory(origin: Vector2, requested_target: Vector2, landing_target: Vector2, valid: bool, trajectory_points: PackedVector2Array = PackedVector2Array(), effective_target: Vector2 = Vector2.INF, collision_position: Vector2 = Vector2.INF, planned: bool = false, uncertain: bool = false) -> void: action_visible = true action_origin = origin action_target = landing_target action_curve_points = trajectory_points.duplicate() action_valid = valid + action_show_trajectory = true + action_grounded_trajectory = false action_requested_target = requested_target action_show_requested_target = true action_effective_target = effective_target if effective_target.is_finite() else landing_target @@ -118,6 +165,8 @@ func set_throw_preview_unavailable(requested_target: Vector2) -> void: action_origin = requested_target action_target = requested_target action_curve_points.clear() + action_show_trajectory = false + action_grounded_trajectory = false action_valid = false action_requested_target = requested_target action_show_requested_target = false @@ -133,6 +182,8 @@ func set_set_down_preview(origin: Vector2, target: Vector2, known: bool, valid: action_origin = origin action_target = target action_curve_points.clear() + action_show_trajectory = false + action_grounded_trajectory = false action_valid = valid action_preview_unavailable = not known action_show_requested_target = false @@ -148,6 +199,8 @@ func set_set_down_preview(origin: Vector2, target: Vector2, known: bool, valid: func clear_action() -> void: action_visible = false action_curve_points.clear() + action_show_trajectory = false + action_grounded_trajectory = false action_show_requested_target = false action_show_collision = false action_preview_unavailable = false @@ -176,10 +229,12 @@ func _draw() -> void: color = Color(0.35, 0.8, 1.0, 0.95) elif action_valid and action_preview_uncertain: color = Color(0.8, 0.68, 1.0, 0.95) - if action_curve_points.size() >= 2: - draw_polyline(action_curve_points, color, line_width_px / _zoom(), true) - else: - _draw_dashed(action_origin, action_target, color) + if action_show_trajectory: + if action_curve_points.size() >= 2: + draw_polyline(action_curve_points, color, line_width_px / _zoom(), true) + else: + var route_target: Vector2 = Vector2(action_target.x, action_origin.y) if action_grounded_trajectory else action_target + _draw_dashed(action_origin, route_target, color) if action_show_requested_target: _draw_ghost_marker(action_effective_target, color) var request_color := Color(1.0, 0.85, 0.25, 0.9) @@ -192,40 +247,178 @@ func _draw() -> void: func _draw_set_down_preview() -> void: - var zoom := _zoom() - var color := valid_color if action_valid else invalid_color - if not set_down_preview_known: - color = set_down_unknown_color - _draw_dashed(action_origin, set_down_approach if set_down_approach.is_finite() and set_down_approach != Vector2.ZERO else action_target, color) - if set_down_preview_known and set_down_prop != null and is_instance_valid(set_down_prop): - var polygon_node := set_down_prop.get_node_or_null(NodePath("Polygon2D")) as Polygon2D - if polygon_node != null and polygon_node.polygon.size() >= 3: - var local_transform: Transform2D = set_down_prop.global_transform.affine_inverse() * polygon_node.global_transform - var ghost_transform: Transform2D = set_down_transform * local_transform - var ghost_points := PackedVector2Array() - for point: Vector2 in polygon_node.polygon: - ghost_points.append(ghost_transform * point) - var fill := set_down_prop.fill_color - fill.a = set_down_ghost_alpha - draw_colored_polygon(ghost_points, fill) - var outline_points := ghost_points.duplicate() - outline_points.append(ghost_points[0]) - draw_polyline(outline_points, color, line_width_px / zoom, true) + _draw_set_down_support_surfaces() + + +func _draw_set_down_support_surfaces() -> void: + if surface_world == null or not is_instance_valid(surface_world): + return + var support_surfaces: Array[Dictionary] = [] + var occluders: Array[Dictionary] = [] + for child: Node in surface_world.get_children(): + if child is TerrainBlock: + var terrain: TerrainBlock = child as TerrainBlock + if not terrain.is_visible_in_tree(): + continue + var terrain_points: PackedVector2Array = _terrain_collision_world_points(terrain) + if terrain_points.size() < 3: + continue + var terrain_entry: Dictionary = {"node": terrain, "points": terrain_points} + support_surfaces.append(terrain_entry) + occluders.append(terrain_entry) + elif child is PropBlock: + var prop: PropBlock = child as PropBlock + if not prop.is_visible_in_tree(): + continue + var prop_points: PackedVector2Array = _prop_collision_world_points(prop) + if prop_points.size() < 3: + continue + var prop_entry: Dictionary = {"node": prop, "points": prop_points} + occluders.append(prop_entry) + if prop.runtime_role != PropBlock.RuntimeRole.HELD and bool(prop.stage_roles.get("holds_other_props", false)): + support_surfaces.append(prop_entry) + var hovered_piece: Dictionary = {} + var hovered_distance_px: float = SET_DOWN_SURFACE_HOVER_RADIUS_PX + var zoom: float = _zoom() + for surface: Dictionary in support_surfaces: + var surface_owner: Node = surface.get("node") as Node + var points: PackedVector2Array = surface.get("points", PackedVector2Array()) + for top_edge: PackedVector2Array in _top_facing_edges(points): + var visible_pieces: Array[PackedVector2Array] = _clip_surface_edge(top_edge[0], top_edge[1], surface_owner, occluders) + for piece: PackedVector2Array in visible_pieces: + var start: Vector2 = piece[0] + var finish: Vector2 = piece[1] + draw_line(start, finish, set_down_surface_color, set_down_surface_line_width_px / zoom, true) + var closest: Vector2 = _closest_point_on_segment(action_target, start, finish) + var distance_px: float = action_target.distance_to(closest) * zoom + if distance_px <= hovered_distance_px: + hovered_distance_px = distance_px + hovered_piece = {"start": start, "finish": finish, "point": closest} + if hovered_piece.is_empty(): + return + var hover_start: Vector2 = hovered_piece.get("start", Vector2.ZERO) + var hover_finish: Vector2 = hovered_piece.get("finish", Vector2.ZERO) + var pin_position: Vector2 = hovered_piece.get("point", action_target) + draw_line(hover_start, hover_finish, set_down_surface_hover_color, set_down_surface_hover_line_width_px / zoom, true) + var pin_radius: float = 5.0 / zoom + var pin_center: Vector2 = pin_position - Vector2(0.0, 8.0 / zoom) + draw_line(pin_center + Vector2(0.0, pin_radius), pin_position, set_down_surface_hover_color, line_width_px / zoom, true) + draw_circle(pin_center, pin_radius * 0.55, Color(set_down_surface_hover_color.r, set_down_surface_hover_color.g, set_down_surface_hover_color.b, 0.28)) + draw_arc(pin_center, pin_radius, 0.0, TAU, 20, set_down_surface_hover_color, line_width_px / zoom, true) + + +func _terrain_collision_world_points(terrain: TerrainBlock) -> PackedVector2Array: + var collision: CollisionPolygon2D = terrain.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D + if collision == null or collision.disabled or collision.polygon.size() < 3: + return PackedVector2Array() + return _to_world_points(collision, collision.polygon) + + +func _prop_collision_world_points(prop: PropBlock) -> PackedVector2Array: + var polygon_collision: CollisionPolygon2D = prop.get_node_or_null(NodePath("CollisionPolygon2D")) as CollisionPolygon2D + if polygon_collision != null and not polygon_collision.disabled and polygon_collision.polygon.size() >= 3: + return _to_world_points(polygon_collision, polygon_collision.polygon) + var circle_collision: CollisionShape2D = prop.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D + if circle_collision == null or circle_collision.disabled or not circle_collision.shape is CircleShape2D: + return PackedVector2Array() + var circle: CircleShape2D = circle_collision.shape as CircleShape2D + var points := PackedVector2Array() + for index: int in PropBlock.CIRCLE_SEGMENTS: + var angle: float = TAU * float(index) / float(PropBlock.CIRCLE_SEGMENTS) + points.append(circle_collision.to_global(Vector2(cos(angle), sin(angle)) * circle.radius)) + return points + + +func _to_world_points(transform_node: Node2D, local_points: PackedVector2Array) -> PackedVector2Array: + var points := PackedVector2Array() + for point: Vector2 in local_points: + points.append(transform_node.to_global(point)) + return points + + +func _top_facing_edges(points: PackedVector2Array) -> Array[PackedVector2Array]: + var edges: Array[PackedVector2Array] = [] + if points.size() < 3: + return edges + for index: int in points.size(): + var start: Vector2 = points[index] + var finish: Vector2 = points[(index + 1) % points.size()] + var direction: Vector2 = finish - start + var edge_length: float = direction.length() + if edge_length * _zoom() < SET_DOWN_SUPPORT_MIN_EDGE_PX: + continue + var normal: Vector2 = Vector2(-direction.y, direction.x).normalized() + var midpoint: Vector2 = (start + finish) * 0.5 + var sample_offset: Vector2 = normal * 2.0 + var plus_inside: bool = Geometry2D.is_point_in_polygon(midpoint + sample_offset, points) + var minus_inside: bool = Geometry2D.is_point_in_polygon(midpoint - sample_offset, points) + var outward: Vector2 = Vector2.ZERO + if plus_inside and not minus_inside: + outward = -normal + elif minus_inside and not plus_inside: + outward = normal else: - _draw_ghost_marker(action_target, color) - else: - _draw_ghost_marker(action_target, color) - var release_origin := set_down_transform.origin - var target_adjusted := set_down_preview_known and release_origin.distance_to(action_target) > 1.0 - if target_adjusted: - _draw_dashed(action_target, release_origin, set_down_reach_color) - draw_arc(release_origin, 4.0 / zoom, 0.0, TAU, 16, set_down_reach_color, LINE_WIDTH_PX / zoom, true) - var cross_radius := 9.0 / zoom - var target_color := set_down_reach_color if target_adjusted else color - draw_line(action_target + Vector2(-cross_radius, 0.0), action_target + Vector2(cross_radius, 0.0), target_color, line_width_px / zoom, true) - draw_line(action_target + Vector2(0.0, -cross_radius), action_target + Vector2(0.0, cross_radius), target_color, line_width_px / zoom, true) - if set_down_grip.is_finite() and set_down_grip != Vector2.ZERO: - draw_line(set_down_approach, set_down_grip, set_down_reach_color, line_width_px / zoom, true) + continue + if outward.y > SET_DOWN_SUPPORT_NORMAL_Y: + continue + edges.append(PackedVector2Array([start, finish])) + return edges + + +func _clip_surface_edge(start: Vector2, finish: Vector2, surface_owner: Node, occluders: Array[Dictionary]) -> Array[PackedVector2Array]: + var pieces: Array[PackedVector2Array] = [] + var cuts: Array[float] = [0.0, 1.0] + for occluder: Dictionary in occluders: + if occluder.get("node") == surface_owner: + continue + var polygon: PackedVector2Array = occluder.get("points", PackedVector2Array()) + for index: int in polygon.size(): + var intersection: float = _segment_intersection_factor(start, finish, polygon[index], polygon[(index + 1) % polygon.size()]) + if intersection > 0.0001 and intersection < 0.9999: + cuts.append(intersection) + cuts.sort() + var unique_cuts: Array[float] = [] + for cut: float in cuts: + if unique_cuts.is_empty() or absf(cut - unique_cuts.back()) > 0.0001: + unique_cuts.append(cut) + for index: int in range(unique_cuts.size() - 1): + var from_t: float = unique_cuts[index] + var to_t: float = unique_cuts[index + 1] + if to_t - from_t <= 0.0001: + continue + var midpoint: Vector2 = start.lerp(finish, (from_t + to_t) * 0.5) + var occluded: bool = false + for occluder: Dictionary in occluders: + if occluder.get("node") == surface_owner: + continue + var polygon: PackedVector2Array = occluder.get("points", PackedVector2Array()) + if polygon.size() >= 3 and Geometry2D.is_point_in_polygon(midpoint, polygon): + occluded = true + break + if not occluded: + pieces.append(PackedVector2Array([start.lerp(finish, from_t), start.lerp(finish, to_t)])) + return pieces + + +func _segment_intersection_factor(start: Vector2, finish: Vector2, edge_start: Vector2, edge_finish: Vector2) -> float: + var direction: Vector2 = finish - start + var edge_direction: Vector2 = edge_finish - edge_start + var denominator: float = direction.cross(edge_direction) + if absf(denominator) <= 0.0001: + return -1.0 + var offset: Vector2 = edge_start - start + var factor: float = offset.cross(edge_direction) / denominator + var edge_factor: float = offset.cross(direction) / denominator + return factor if factor >= 0.0 and factor <= 1.0 and edge_factor >= 0.0 and edge_factor <= 1.0 else -1.0 + + +func _closest_point_on_segment(point: Vector2, start: Vector2, finish: Vector2) -> Vector2: + var segment: Vector2 = finish - start + var length_squared: float = segment.length_squared() + if length_squared <= 0.0001: + return start + var factor: float = clampf((point - start).dot(segment) / length_squared, 0.0, 1.0) + return start + segment * factor func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void: diff --git a/scripts/stage/stage_scene_io.gd b/scripts/stage/stage_scene_io.gd index 224cb8b..df46e5d 100644 --- a/scripts/stage/stage_scene_io.gd +++ b/scripts/stage/stage_scene_io.gd @@ -3,7 +3,7 @@ extends RefCounted ## JSON boundary for Sandbox Stage documents. This class deliberately has no ## scene-tree dependencies, which makes rejected documents non-destructive. -const FILE_VERSION: String = "1.8" +const FILE_VERSION: String = "1.9" const DEFAULT_PLAIN_COLOR: String = "#F5EFE0" const SURFACE_RENDERER := preload("res://scripts/stage/scenery/stage_surface_renderer.gd") const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd") @@ -13,6 +13,7 @@ const SPEECH_PAYLOAD := preload("res://scripts/speech/speech_payload.gd") const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd") const STAGE_OBJECT_IDENTITY := preload("res://scripts/stage/stage_object_identity.gd") +const STAGE_ACTION_PLAN := preload("res://scripts/stage/stage_action_plan.gd") const WALL_FINISHES: Array[String] = ["finish_wall_paint", "finish_wall_brick", "finish_wall_wood"] const BACKGROUND_FINISHES: Array[String] = ["finish_background_grass", "finish_background_concrete", "finish_background_carpet"] const GROUND_FINISHES: Array[String] = ["finish_ground_paint", "finish_ground_brick", "finish_ground_wood", "finish_ground_grass", "finish_ground_dirt", "finish_ground_sidewalk", "finish_ground_asphalt", "finish_ground_legacy"] @@ -69,6 +70,51 @@ static func decode_queue(entries: Array) -> Dictionary: out.append(decoded) return {"actions": out, "warnings": warnings} + +static func encode_plan(steps: Array[Dictionary]) -> Dictionary: + var encoded_steps: Array[Dictionary] = [] + var warnings: Array[String] = [] + for step_index: int in steps.size(): + var raw_actions: Variant = steps[step_index].get("actions", []) + if not raw_actions is Array or (raw_actions as Array).is_empty(): + warnings.append("Skipped empty or malformed plan step %d." % (step_index + 1)) + continue + var encoded_actions: Array[Dictionary] = [] + for raw_action: Variant in raw_actions: + if not raw_action is Dictionary: + warnings.append("Skipped malformed action in step %d." % (step_index + 1)) + continue + var encoded: Dictionary = _encode_plan_action(raw_action as Dictionary) + if encoded.is_empty(): + warnings.append("Skipped invalid action in step %d." % (step_index + 1)) + else: + encoded_actions.append(encoded) + if not encoded_actions.is_empty(): + encoded_steps.append({"actions": encoded_actions}) + return {"steps": encoded_steps, "warnings": warnings} + + +static func decode_plan(entries: Array) -> Dictionary: + var decoded_steps: Array[Dictionary] = [] + var warnings: Array[String] = [] + for step_index: int in entries.size(): + var raw_step: Variant = entries[step_index] + if not raw_step is Dictionary: + return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d is malformed." % (step_index + 1)} + var raw_actions: Variant = (raw_step as Dictionary).get("actions", null) + if not raw_actions is Array or (raw_actions as Array).is_empty(): + return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d must contain actions." % (step_index + 1)} + var decoded_actions: Array[Dictionary] = [] + for raw_action: Variant in raw_actions: + if not raw_action is Dictionary: + return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d has a malformed action." % (step_index + 1)} + var decoded: Dictionary = _decode_plan_action(raw_action as Dictionary) + if decoded.is_empty(): + return {"ok": false, "steps": [], "warnings": warnings, "error": "Plan step %d has an invalid action." % (step_index + 1)} + decoded_actions.append(decoded) + decoded_steps.append({"actions": decoded_actions}) + return {"ok": true, "steps": decoded_steps, "warnings": warnings, "error": ""} + static func encode_rules(rules: Array[Dictionary], runtime_to_file: Dictionary) -> Dictionary: var out: Array[Dictionary] = [] var warnings: Array[String] = [] @@ -153,7 +199,13 @@ static func read_file(path: String) -> Dictionary: return {"ok": false, "data": {}, "error": "Scene is missing objects or event_rules arrays.", "warnings": []} var validation: Dictionary = _validate_root(data) if not bool(validation.get("ok", false)): return {"ok": false, "data": {}, "error": String(validation.get("error", "Invalid scene data.")), "warnings": []} - return {"ok": true, "data": data, "error": "", "warnings": []} + var warnings: Array[String] = [] + var migration_warnings: Variant = data.get("_migration_warnings", []) + if migration_warnings is Array: + for warning: Variant in migration_warnings: + warnings.append(String(warning)) + data.erase("_migration_warnings") + return {"ok": true, "data": data, "error": "", "warnings": warnings} static func write_file_safe(path: String, data: Dictionary) -> Dictionary: path = CONTENT_PATHS.canonicalize(path) @@ -175,26 +227,16 @@ static func write_file_safe(path: String, data: Dictionary) -> Dictionary: static func _migrate_to_current(data: Dictionary) -> Dictionary: var version := String(data.get("version", "")) if version == FILE_VERSION: - var current: Dictionary = data.duplicate(true) - for raw: Variant in current.get("objects", []): - if raw is Dictionary and String((raw as Dictionary).get("type", "")) == "prop": - var prop_entry: Dictionary = raw - if not prop_entry.has("roles"): - prop_entry["roles"] = PROP_STAGE_ROLES.legacy_placement(true) - prop_entry["use_spots"] = prop_entry.get("use_spots", []) - prop_entry["next_use_spot_number"] = prop_entry.get("next_use_spot_number", 1) - if not prop_entry.has("wobble_on_impact"): - prop_entry["wobble_on_impact"] = false - _assign_legacy_depths(current.get("objects", [])) - return current - if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"]: + return data.duplicate(true) + if version not in ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8"]: return {} var migrated: Dictionary = data.duplicate(true) - if version != "1.7": - if version not in ["1.5", "1.6"]: - migrated["sky_id"] = "sky_neutral" - migrated["background_kind"] = "sky" - migrated["plain_color"] = DEFAULT_PLAIN_COLOR + if version != "1.8": + if version != "1.7": + if version not in ["1.5", "1.6"]: + migrated["sky_id"] = "sky_neutral" + migrated["background_kind"] = "sky" + migrated["plain_color"] = DEFAULT_PLAIN_COLOR for raw: Variant in migrated.get("objects", []): if not raw is Dictionary: continue @@ -202,22 +244,96 @@ static func _migrate_to_current(data: Dictionary) -> Dictionary: if String(object.get("type", "")) == "prop": if version == "1.0": object["stage_object_id"] = String(object.get("file_id", "")) - if not object.has("roles"): - object["roles"] = PROP_STAGE_ROLES.legacy_placement(true) - object["use_spots"] = object.get("use_spots", []) - object["next_use_spot_number"] = object.get("next_use_spot_number", 1) - if not object.has("wobble_on_impact"): - object["wobble_on_impact"] = false + _ensure_legacy_prop_fields(object) if version not in ["1.5", "1.6", "1.7"] and String(object.get("type", "")) == "terrain" and String(object.get("asset_id", "")) == "ground": object["finish_id"] = "finish_ground_legacy" if version not in ["1.6", "1.7"] and String(object.get("type", "")) == "wall_panel": _migrate_legacy_wall_tint(object) - migrated["version"] = FILE_VERSION + if version != "1.8": + _migrate_stage_object_identity(migrated.get("objects", [])) _assign_legacy_depths(migrated.get("objects", [])) - _migrate_stage_object_identity(migrated.get("objects", [])) + _migrate_legacy_action_plans(migrated) + migrated["version"] = FILE_VERSION return migrated +static func _ensure_legacy_prop_fields(prop_entry: Dictionary) -> void: + if not prop_entry.has("roles"): + prop_entry["roles"] = PROP_STAGE_ROLES.legacy_placement(true) + prop_entry["use_spots"] = prop_entry.get("use_spots", []) + prop_entry["next_use_spot_number"] = prop_entry.get("next_use_spot_number", 1) + if not prop_entry.has("wobble_on_impact"): + prop_entry["wobble_on_impact"] = false + + +static func _migrate_legacy_action_plans(data: Dictionary) -> void: + var objects_value: Variant = data.get("objects", []) + if not objects_value is Array: + return + var warnings: Array[String] = [] + for raw_object: Variant in objects_value: + if not raw_object is Dictionary: + continue + var object: Dictionary = raw_object + if String(object.get("type", "")) != "stickman": + continue + var raw_queue: Variant = object.get("action_queue", []) + var decoded: Dictionary = decode_queue(raw_queue as Array) if raw_queue is Array else {"actions": [], "warnings": ["Invalid action queue."]} + var queue: Array[Dictionary] = decoded.get("actions", []) + for warning: String in decoded.get("warnings", []): + warnings.append("%s: %s" % [String(object.get("stage_name", object.get("file_id", "Character"))), warning]) + var plan: Array[Dictionary] = [] + var queue_index: int = 0 + while queue_index < queue.size(): + var action: Dictionary = queue[queue_index].duplicate(true) + var action_id: String = _legacy_action_id(object, queue_index) + action["action_id"] = action_id + if ACTION_REGISTRY.is_move_action(action): + var target_value: Variant = action.get("target", null) + if target_value is Vector2: + action["destination"] = {"kind": "place", "position": target_value} + else: + warnings.append("%s: Invalid Move target was skipped." % String(object.get("stage_name", "Character"))) + queue_index += 1 + continue + action.erase("target") + var paired: bool = false + if String(action.get("type", "")) == "speak" and bool(action.get("keep_acting", false)) and queue_index + 1 < queue.size(): + var next_action: Dictionary = queue[queue_index + 1] + if ACTION_REGISTRY.is_move_action(next_action) or String(next_action.get("type", "")) == "wait": + var primary: Dictionary = next_action.duplicate(true) + primary["action_id"] = _legacy_action_id(object, queue_index + 1) + if ACTION_REGISTRY.is_move_action(primary): + var primary_target: Variant = primary.get("target", null) + if primary_target is Vector2: + primary["destination"] = {"kind": "place", "position": primary_target} + primary.erase("target") + else: + primary.clear() + if not primary.is_empty(): + action["legacy_nonblocking"] = true + plan.append({"actions": [action, primary]}) + queue_index += 2 + paired = true + if paired: + continue + if String(action.get("type", "")) == "speak" and bool(action.get("keep_acting", false)): + action["legacy_nonblocking"] = true + plan.append({"actions": [action]}) + queue_index += 1 + object["action_plan"] = (encode_plan(plan).get("steps", []) as Array).duplicate(true) + object.erase("action_queue") + if not warnings.is_empty(): + data["_migration_warnings"] = warnings + + +static func _legacy_action_id(object: Dictionary, index: int) -> String: + var owner: String = String(object.get("stage_object_id", object.get("file_id", "actor"))).strip_edges() + if owner.is_empty(): + owner = String(object.get("file_id", "actor")) + return "legacy_%s_action_%d" % [owner, index + 1] + + static func _migrate_stage_object_identity(raw_objects: Variant) -> void: if not raw_objects is Array: return @@ -324,6 +440,7 @@ static func _validate_root(data: Dictionary) -> Dictionary: var seen: Dictionary = {} var seen_stage_ids: Dictionary = {} var seen_stage_names: Dictionary = {} + var seen_action_ids: Dictionary = {} for raw: Variant in data["objects"]: if not raw is Dictionary: continue var entry: Dictionary = raw @@ -331,6 +448,26 @@ static func _validate_root(data: Dictionary) -> Dictionary: if file_id.is_empty() or seen.has(file_id): return {"ok": false, "error": "Object file IDs must be unique and non-empty."} seen[file_id] = true var object_type: String = String(entry.get("type", "")) + if object_type == "stickman": + if entry.has("action_queue"): + return {"ok": false, "error": "Scene v1.9 stickmen must not contain an action_queue."} + var plan_value: Variant = entry.get("action_plan", null) + if not plan_value is Array: + return {"ok": false, "error": "Scene stickman action_plan must be an array."} + var decoded_plan: Dictionary = decode_plan(plan_value as Array) + if not bool(decoded_plan.get("ok", false)): + return {"ok": false, "error": String(decoded_plan.get("error", "Scene action plan is invalid."))} + var plan_steps: Array[Dictionary] = decoded_plan.get("steps", []) + var plan_error: String = STAGE_ACTION_PLAN.validate_steps(plan_steps) + if not plan_error.is_empty(): + return {"ok": false, "error": plan_error} + for step: Dictionary in plan_steps: + var actions: Array = step.get("actions", []) + for action: Dictionary in actions: + var action_id: String = String(action.get("action_id", "")) + if seen_action_ids.has(action_id): + return {"ok": false, "error": "Action IDs must be unique across the scene."} + seen_action_ids[action_id] = true if object_type in ["stickman", "prop"]: var raw_stage_id: Variant = entry.get("stage_object_id", null) if not raw_stage_id is String: @@ -519,6 +656,134 @@ static func _decode_flat_action(action: Dictionary) -> Dictionary: return {"type": type, "target_prop_id": prop_id, "target_spot_id": spot_id} _: return {"type": type} + +static func _encode_plan_action(action: Dictionary) -> Dictionary: + var action_id: Variant = action.get("action_id", null) + if not action_id is String or String(action_id).strip_edges().is_empty(): + return {} + var type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) + if not ACTION_REGISTRY.has_type(type): + return {} + var encoded: Dictionary = {} + if type == ACTION_REGISTRY.MOVE_TO: + var style: String = ACTION_REGISTRY.checked_style(action.get("style", null)) + var destination: Dictionary = _encode_plan_destination(action.get("destination", null)) + if style.is_empty() or destination.is_empty(): + return {} + encoded = {"type": type, "style": style, "destination": destination} + elif type == "speak": + encoded = _encode_speech(action) + elif type == "wait": + if not _finite_number(action.get("duration", null)) or float(action["duration"]) < 0.0: + return {} + encoded = {"type": type, "duration": float(action["duration"])} + else: + encoded = _encode_flat_action(action) + if encoded.is_empty(): + return {} + encoded["action_id"] = String(action_id) + if bool(action.get("legacy_nonblocking", false)): + encoded["legacy_nonblocking"] = true + return encoded + + +static func _decode_plan_action(action: Dictionary) -> Dictionary: + var action_id: Variant = action.get("action_id", null) + if not action_id is String or String(action_id).strip_edges().is_empty(): + return {} + var type: String = ACTION_REGISTRY.canonical_type(String(action.get("type", ""))) + if not ACTION_REGISTRY.has_type(type): + return {} + if action.has("legacy_nonblocking") and (not action["legacy_nonblocking"] is bool or type != "speak"): + return {} + var decoded: Dictionary = {} + if type == ACTION_REGISTRY.MOVE_TO: + var style: String = ACTION_REGISTRY.checked_style(action.get("style", null)) + var destination: Dictionary = _decode_plan_destination(action.get("destination", null)) + if style.is_empty() or destination.is_empty(): + return {} + decoded = {"type": type, "style": style, "destination": destination} + decoded["target"] = destination.get("position", destination.get("last_known_approach", Vector2.ZERO)) + elif type == "speak": + decoded = _decode_speech(action) + elif type == "wait": + if not _finite_number(action.get("duration", null)) or float(action["duration"]) < 0.0: + return {} + decoded = {"type": type, "duration": float(action["duration"])} + else: + decoded = _decode_flat_action(action) + if decoded.is_empty(): + return {} + decoded["action_id"] = String(action_id) + if bool(action.get("legacy_nonblocking", false)): + decoded["legacy_nonblocking"] = true + return decoded + + +static func _encode_plan_destination(value: Variant) -> Dictionary: + if not value is Dictionary: + return {} + var destination: Dictionary = value + var kind: String = String(destination.get("kind", "")) + if kind == "place": + for key: String in destination.keys(): + if key not in ["kind", "position"]: + return {} + var position: Variant = _vector_variant_to_data(destination.get("position", null)) + return {"kind": kind, "position": position} if position is Dictionary else {} + if kind == "prop": + for key: String in destination.keys(): + if key not in ["kind", "target_prop_id", "approach_offset", "last_known_approach"]: + return {} + var prop_id_value: Variant = destination.get("target_prop_id", null) + if not prop_id_value is String or String(prop_id_value).strip_edges().is_empty(): + return {} + var offset: Variant = _vector_variant_to_data(destination.get("approach_offset", null)) + var last_known: Variant = _vector_variant_to_data(destination.get("last_known_approach", null)) + if not offset is Dictionary or not last_known is Dictionary: + return {} + return {"kind": kind, "target_prop_id": String(prop_id_value), "approach_offset": offset, "last_known_approach": last_known} + return {} + + +static func _decode_plan_destination(value: Variant) -> Dictionary: + if not value is Dictionary: + return {} + var destination: Dictionary = value + var kind: String = String(destination.get("kind", "")) + if kind == "place": + for key: String in destination.keys(): + if key not in ["kind", "position"]: + return {} + if not is_vector_data(destination.get("position", null)): + return {} + return {"kind": kind, "position": data_to_vector(destination["position"], Vector2.ZERO)} + if kind == "prop": + for key: String in destination.keys(): + if key not in ["kind", "target_prop_id", "approach_offset", "last_known_approach"]: + return {} + var prop_id_value: Variant = destination.get("target_prop_id", null) + if not prop_id_value is String or String(prop_id_value).strip_edges().is_empty(): + return {} + if not is_vector_data(destination.get("approach_offset", null)) or not is_vector_data(destination.get("last_known_approach", null)): + return {} + return { + "kind": kind, + "target_prop_id": String(prop_id_value), + "approach_offset": data_to_vector(destination["approach_offset"], Vector2.ZERO), + "last_known_approach": data_to_vector(destination["last_known_approach"], Vector2.ZERO), + } + return {} + + +static func _vector_variant_to_data(value: Variant) -> Variant: + if value is Vector2: + return vector_to_data(value as Vector2) if (value as Vector2).is_finite() else null + if is_vector_data(value): + var point: Vector2 = data_to_vector(value, Vector2.ZERO) + return vector_to_data(point) + return null + static func _map_ref(value: Variant, map: Dictionary) -> Variant: if int(value) == -1: return null return map.get(int(value), null) diff --git a/tests/phase20b_action_drawer_full_height.log b/tests/phase20b_action_drawer_full_height.log new file mode 100644 index 0000000..ef52d4c --- /dev/null +++ b/tests/phase20b_action_drawer_full_height.log @@ -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= +PHASE20B_GUI_SLICE_RESULT scenario=action_drawer failures=0 diff --git a/tests/phase20b_action_plan_test_final.log b/tests/phase20b_action_plan_test_final.log new file mode 100644 index 0000000..a34a686 --- /dev/null +++ b/tests/phase20b_action_plan_test_final.log @@ -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 diff --git a/tests/phase20b_capture_overlay_visual_verify.gd b/tests/phase20b_capture_overlay_visual_verify.gd new file mode 100644 index 0000000..3d22408 --- /dev/null +++ b/tests/phase20b_capture_overlay_visual_verify.gd @@ -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) diff --git a/tests/phase20b_capture_overlay_visual_verify.gd.uid b/tests/phase20b_capture_overlay_visual_verify.gd.uid new file mode 100644 index 0000000..d35247c --- /dev/null +++ b/tests/phase20b_capture_overlay_visual_verify.gd.uid @@ -0,0 +1 @@ +uid://w38bcl5nayq0 diff --git a/tests/phase20b_grouping_success_check.log b/tests/phase20b_grouping_success_check.log new file mode 100644 index 0000000..dcfef60 --- /dev/null +++ b/tests/phase20b_grouping_success_check.log @@ -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: +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 diff --git a/tests/phase20b_gui_visual_capture.gd b/tests/phase20b_gui_visual_capture.gd new file mode 100644 index 0000000..ffab676 --- /dev/null +++ b/tests/phase20b_gui_visual_capture.gd @@ -0,0 +1,2542 @@ +extends SceneTree + +const SANDBOX_STAGE: PackedScene = preload("res://scenes/stage/sandbox_stage.tscn") +const STAGE_DIALOG_STYLER := preload("res://scripts/shared/stage_ui/stage_dialog_styler.gd") +const ACTION_REGISTRY := preload("res://scripts/stage/action_registry.gd") +const CAPTURE_DIR: String = "res://tests/phase20b_captures/review_round43" +const PLAN_PLACE: int = 8 +const PLAN_PROP: int = 9 + +var _failures: int = 0 +var _active_capture_dir: String = CAPTURE_DIR +var _debug_stage: SandboxStage +var _capture_after_next_click: bool = false +var _skip_capture_hover: bool = false +var _trace_next_click: bool = false +var _last_click_label: String = "" +var _last_injected_mouse_position: Vector2 = Vector2.ZERO +var _watchdog: Timer + + +func _initialize() -> void: + call_deferred("_run_scenarios") + + +func _run_scenarios() -> void: + _skip_capture_hover = OS.get_environment("PHASE20B_SKIP_CAPTURE_HOVER") == "1" + var scenario: String = OS.get_environment("PHASE20B_GUI_SCENARIO") + var requested_capture_dir: String = OS.get_environment("PHASE20B_GUI_CAPTURE_DIR") + _active_capture_dir = requested_capture_dir if not requested_capture_dir.is_empty() else ("res://tests/phase20b_captures/review_round44" if scenario == "busy_scene_badge_stability" else CAPTURE_DIR) + _watchdog = Timer.new() + _watchdog.one_shot = true + _watchdog.wait_time = 45.0 + _watchdog.timeout.connect(_on_gui_watchdog_timeout) + root.add_child(_watchdog) + _watchdog.start() + root.size = Vector2i(1280, 900) if scenario == "action_drawer" else Vector2i(1280, 720) + root.position = Vector2i.ZERO + root.gui_embed_subwindows = true + root.grab_focus() + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_active_capture_dir)) + var stage: SandboxStage = SANDBOX_STAGE.instantiate() as SandboxStage + _debug_stage = stage + root.add_child(stage) + await _wait_frames(10) + var recovery_dialog: ConfirmationDialog = stage.find_child("AutosaveRecoveryDialog", true, false) as ConfirmationDialog + if recovery_dialog != null and STAGE_DIALOG_STYLER.is_open(recovery_dialog): + # Keep the user's autosave intact while allowing this isolated GUI capture + # to exercise the production inspector underneath the startup prompt. + STAGE_DIALOG_STYLER.hide(recovery_dialog) + if scenario == "busy_scene_badge_stability": + await _run_busy_scene_badge_stability(stage) + print("PHASE20B_GUI_SLICE_RESULT scenario=%s failures=%d captures=%s" % [scenario, _failures, _active_capture_dir]) + await _finish(stage) + quit(1 if _failures > 0 else 0) + return + if scenario == "menu_focused_stack": + await _run_menu_focused_stack_slice(stage) + print("PHASE20B_GUI_SLICE_RESULT scenario=%s failures=%d captures=%s" % [scenario, _failures, _active_capture_dir]) + await _finish(stage) + quit(1 if _failures > 0 else 0) + return + stage.call("_on_grid_toggled", false) + var spawner: StageSpawner = stage.get("_spawner") as StageSpawner + var world: Node2D = stage.get_node("World") as Node2D + var character: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(-120.0, -170.0)) + var prop: PropBlock = spawner.spawn_prop_asset("crate", Vector2(150.0, 0.0)) + _check(character != null and prop != null, "production spawner creates the actor and named-prop fixtures") + if character == null or prop == null: + await _finish(stage) + quit(1) + return + stage.call("_finalize_placed_node", character) + stage.call("_finalize_placed_node", prop) + var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity + var renamed_character: Dictionary = identity.rename(character, "Milo the Magnificent Fetch Director", world) + var renamed_prop: Dictionary = identity.rename(prop, "Blue Fetch Ball", world) + _check(bool(renamed_character.get("ok", false)) and bool(renamed_prop.get("ok", false)), "fixture actors receive long and distinct stage names through production identity") + _make_small_prop(prop) + await _wait_frames(5) + + # Mode and selection are driven through the same visible controls and viewport + # pointer routes used by the editor. + var direct_button: Button = _find_button_containing(stage, "Direct") + _check(direct_button != null, "DIRECT mode button is present in the Stage chrome") + if direct_button != null: + await _click_control(direct_button) + var selection: StageSelection = stage.get("_selection") as StageSelection + await _send_click(_screen_at(stage, character.global_position)) + await _wait_frames(4) + _check(int(stage.get("current_mode")) == SandboxStage.StageMode.DIRECT, "pointer activation switches the production Stage to DIRECT") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "viewport pointer selects the actor in DIRECT") + var character_view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView + var card: StageInspectorPanel = character_view.get_card() as StageInspectorPanel + var stack: Control = character_view.find_child("ActionStepStack", true, false) as Control + var empty_label: Label = character_view.find_child("EmptyActionPlan", true, false) as Label + var quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control + var empty_prompt_rect: Rect2 = empty_label.get_global_rect() if empty_label != null else Rect2() + _check(stack != null and stack.visible and empty_label != null and empty_label.visible and empty_label.text == "Choose an action to start %s’s plan." % character.stage_name and empty_prompt_rect.size.x > 0.0 and stack.get_global_rect().encloses(empty_prompt_rect), "approved empty-plan prompt is visible and fits inside the Step Stack") + _check(quick_row != null and quick_row.visible and _find_button_with_text(quick_row, "🚶 Move") != null and _find_button_with_text(quick_row, "💬 Say") != null and _find_button_with_text(quick_row, "⏳ Wait") != null, "pinned Move, Say, and Wait quick actions are visible") + await _save_capture("wide_empty_step_stack") + if not scenario.is_empty(): + await _run_visual_slice(scenario, stage, character, prop, character_view, card, quick_row) + print("PHASE20B_GUI_SLICE_RESULT scenario=%s failures=%d" % [scenario, _failures]) + await _finish(stage) + quit(1 if _failures > 0 else 0) + return + + # Author a place Move via the Quick Move button, the Action Editor, and the + # transient stage capture bar. The click is sent through SandboxStage input. + stage.call("_set_scene_dirty", false) + var move_button: Button = _find_button_with_text(quick_row, "🚶 Move") + await _click_control(move_button) + var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + var place_button: Button = _find_button_containing(action_editor, "Choose a place") + _check(action_editor != null and action_editor.visible and place_button != null, "Quick Move opens the production Action Editor with a place route") + if place_button == null: + await _finish(stage) + quit(1) + return + var place_press_events: Array[bool] = [] + place_button.pressed.connect(func() -> void: place_press_events.append(true)) + _capture_after_next_click = true + await _click_control(place_button) + _check(place_press_events.size() == 1, "Choose a place button receives the actual injected mouse click") + _check(int(stage.get("_capture_kind")) == PLAN_PLACE, "place button enters the production temporary capture state") + var first_place: Vector2 = Vector2(-20.0, 0.0) + _trace_next_click = true + await _send_click(_screen_at(stage, first_place)) + await _wait_frames(3) + _check(action_editor.visible, "captured place returns to the Action Editor for confirmation") + var move_done: Button = _find_button_with_text(action_editor, "Done") + await _click_control(move_done) + await _wait_frames(4) + var plan: Array[Dictionary] = character.get_action_plan() + if plan.is_empty(): + _check(false, "valid Move is added before continuing GUI scenarios") + await _finish(stage) + quit(1) + return + _check(plan.size() == 1 and (plan[0]["actions"] as Array).size() == 1, "Done adds one ordered Move step through the UI") + var first_move: Dictionary = (plan[0]["actions"] as Array)[0] + var move_id: String = String(first_move.get("action_id", "")) + var move_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Place 1") + _check(move_summary != null and not move_summary.text.contains("(-"), "free-place action summary uses its derived step label without raw coordinates") + var move_row_text: Label = character_view.find_child("ActionSummaryText_%s" % move_id, true, false) as Label + var move_row_rail: Control = character_view.find_child("ActionAccentRail_%s" % move_id, true, false) as Control + var move_icon_tile: Control = character_view.find_child("ActionIconTile", true, false) as Control + var move_row_options: Label = character_view.find_child("ActionSummaryOptions_%s" % move_id, true, false) as Label + var move_row_style: StyleBoxFlat = move_summary.get_theme_stylebox("normal") as StyleBoxFlat if move_summary != null else null + _check(move_row_text != null and move_row_text.text == "Walk to Place 1" and move_row_text.get_theme_font_size("font_size") == 18, "Move summary uses the mockup's bold 18 px action text without a redundant type chip") + _check(move_row_rail != null and move_row_rail.custom_minimum_size.x == 4.0 and move_icon_tile != null and move_icon_tile.custom_minimum_size == Vector2(32.0, 32.0) and move_row_options != null and move_row_options.text == "..." and move_summary.custom_minimum_size.y >= 48.0, "Move row has the twin rail, 32 px icon tile, 48 px target, and trailing options cue") + _check(move_row_style != null and move_row_style.bg_color.is_equal_approx(Color("#17313D")), "Move row uses the themed muted cyan tint from the approved mockup") + await RenderingServer.frame_post_draw + var move_row_image: Image = root.get_texture().get_image() + var move_rail_rect: Rect2 = move_row_rail.get_global_rect() if move_row_rail != null else Rect2() + var move_rail_pixel: Color = move_row_image.get_pixelv(Vector2i(roundi(move_rail_rect.get_center().x), roundi(move_rail_rect.get_center().y))) if move_row_image != null and not move_row_image.is_empty() and move_row_rail != null else Color.TRANSPARENT + _check(move_rail_pixel.is_equal_approx(Color("#8BD9FF")), "rendered Move accent rail uses the cyan route color") + _check(String((first_move.get("destination", {}) as Dictionary).get("kind", "")) == "place", "captured free destination is persisted as kind place") + _check(bool(stage.get("_dirty")), "valid Move commit marks the document dirty") + await _save_capture("wide_step_1_place") + + # Click the rendered numbered flag. It must select/focus the same stable action + # without making a document edit. + stage.call("_set_scene_dirty", false) + await _send_click(_screen_at(stage, first_place)) + await _wait_frames(4) + var move_options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control + _check(move_options != null and move_options.visible, "numbered route flag opens the matching action options by stable ID") + var expanded_card_rect: Rect2 = card.get_global_rect() + var pinned_move_rect: Rect2 = move_button.get_global_rect() + _check(expanded_card_rect.position.x >= 0.0 and expanded_card_rect.end.x <= root.size.x and expanded_card_rect.size.x <= 380.0, "expanded action options stay inside the 380 px inspector shell") + _check(move_button.visible and pinned_move_rect.position.y >= expanded_card_rect.position.y and pinned_move_rect.end.y <= expanded_card_rect.end.y, "quick actions stay visible in the fixed inspector area after expanding action options") + var route_entry: Dictionary = {} + for projection: Dictionary in (stage.get("_director_visuals") as StageDirectorVisuals)._plan_projection(character): + if String(projection.get("action_id", "")) == move_id: + route_entry = projection + break + var route_hit_rect: Rect2 = route_entry.get("hit_rect", Rect2()) + var route_hit_size: Vector2 = route_hit_rect.size * (stage.get("_director_visuals") as StageDirectorVisuals)._zoom() + _check(route_hit_size.x >= 44.0 and route_hit_size.y >= 44.0, "numbered movement flag hit target is at least 44×44 screen pixels") + _check(not bool(stage.get("_dirty")), "opening route flag options does not dirty the scene") + await _save_capture("wide_flag_focused_action") + + # Edit the Move action to choose an exact prop, then a nearby free place. + # The action options should match Say's stacked Edit/Erase layout. + var move_edit_button: Button = _find_button_with_text(move_options, "Edit action") + var move_erase_button: Button = _find_button_with_text(move_options, "Erase action") + var duplicate_change_place: Button = _find_button_with_text(move_options, "Change place") + _check(duplicate_change_place == null and move_edit_button != null and move_erase_button != null, "Move options match Say with only Edit action and Erase action") + _check(move_edit_button != null and move_erase_button != null and move_edit_button.get_parent() == move_options and move_erase_button.get_parent() == move_options and move_edit_button.get_global_rect().size.x >= 44.0 and move_edit_button.get_global_rect().size.x == move_erase_button.get_global_rect().size.x and move_edit_button.get_global_rect().end.y <= move_erase_button.get_global_rect().position.y, "Move Edit and Erase actions are full-width controls stacked vertically") + if move_edit_button != null: + await _click_control(move_edit_button) + var choose_prop_button: Button = _find_button_containing(action_editor, "Choose a prop") + _check(action_editor.visible and choose_prop_button != null, "Edit action opens the Move destination editor") + if choose_prop_button != null: + await _click_control(choose_prop_button) + _check(int(stage.get("_capture_kind")) == PLAN_PROP, "prop choice enters the production prop-only capture route") + await _send_click(_screen_at(stage, prop.global_position)) + await _wait_frames(4) + await _save_capture("wide_prop_candidate_route") + var done_prop: Button = _find_button_with_text(action_editor, "Done") + await _click_control(done_prop) + await _wait_frames(4) + plan = character.get_action_plan() + first_move = (plan[0]["actions"] as Array)[0] + var prop_destination: Dictionary = first_move.get("destination", {}) + _check(String(first_move.get("action_id", "")) == move_id, "retargeting preserves the stable Move action ID") + _check(String(prop_destination.get("kind", "")) == "prop" and String(prop_destination.get("target_prop_id", "")) == prop.stage_object_id, "named-prop target stores the exact selected stage ID") + _check(prop_destination.get("approach_offset", Vector2.ZERO) is Vector2, "captured prop approach retains a world-space offset") + await _save_capture("wide_step_1_exact_prop") + + # Retarget to a free place near the prop, then cancel a second capture with + # Escape and prove the draft and dirty state remain unchanged. + move_options = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control + move_edit_button = _find_button_with_text(move_options, "Edit action") + await _click_control(move_edit_button) + place_button = _find_button_containing(action_editor, "place") + await _click_control(place_button) + var nearby_place: Vector2 = prop.global_position + Vector2(54.0, -42.0) + await _send_click(_screen_at(stage, nearby_place)) + await _wait_frames(3) + await _click_control(_find_button_with_text(action_editor, "Done")) + await _wait_frames(4) + plan = character.get_action_plan() + first_move = (plan[0]["actions"] as Array)[0] + var free_destination: Dictionary = first_move.get("destination", {}) + _check(String(first_move.get("action_id", "")) == move_id and String(free_destination.get("kind", "")) == "place", "nearby free-place intent remains a place and keeps the same action ID") + stage.call("_set_scene_dirty", false) + move_options = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control + await _click_control(_find_button_with_text(move_options, "Edit action")) + await _click_control(_find_button_containing(action_editor, "place")) + _check(int(stage.get("_capture_kind")) == PLAN_PLACE, "second target edit enters capture before cancellation") + await _save_capture("wide_candidate_route_before_cancel") + await _send_key(KEY_ESCAPE) + await _wait_frames(4) + _check(int(stage.get("_capture_kind")) == 0 and not action_editor.visible, "Escape cancels the active destination capture through Stage input") + _check(not bool(stage.get("_dirty")) and character.get_action_plan()[0]["actions"] == plan[0]["actions"], "cancel leaves saved plan content and dirty state unchanged") + await _save_capture("wide_capture_cancelled") + + # The Add together popup is exercised with keyboard input; a Move step offers + # Say and excludes other primary actions. The text is entered through the focused composer. + var together_button: Button = _find_button_with_text(character_view.find_child("ActionStep_1", true, false), "+ Add together") + _check(together_button != null, "singleton Move step offers Add together") + await _click_control(together_button) + var together_popup: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + var together_choices: VBoxContainer = together_popup.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if together_popup != null else null + var together_helper: Label = together_popup.find_child("TogetherCompatibilityHint", true, false) as Label if together_popup != null else null + var together_cancel: Button = _find_button_with_text(together_popup, "Cancel") if together_popup != null else null + _check(together_popup != null and together_popup.visible and together_choices != null and together_choices.get_child_count() == 1 and (together_choices.get_child(0) as Button).text.contains("Say"), "Move compatibility picker offers only Say") + _check(together_choices != null and _find_button_containing(together_choices, "Wait") == null, "Move compatibility picker excludes Wait") + _check(together_helper == null and together_cancel != null and together_cancel.visible and together_cancel.size.x >= 44.0 and together_cancel.size.y >= 44.0, "Add together picker omits redundant compatibility copy and keeps a visible touch-sized Cancel") + await _save_capture("wide_add_together_picker") + await _send_key(KEY_ENTER) + await _wait_frames(5) + var composer: Control = stage.get("_speech_composer") as Control + var message_editor: TextEdit = null + var speech_toolbar: Control = null + var done_speech: Button = null + _check(composer != null and composer.visible, "keyboard selection opens the production speech composer") + if composer != null and composer.visible: + message_editor = composer.find_child("ActiveBubbleText", true, false) as TextEdit + _check(message_editor != null and message_editor.has_focus(), "speech composer gives its message editor keyboard focus") + if message_editor != null: + await _click_control(message_editor) + await _send_key(KEY_A, false, true) + await _type_text("Fetch this ball, Milo! Fetch it now!") + speech_toolbar = stage.get("_speech_show_toolbar") as Control + done_speech = _find_button_with_text(speech_toolbar, "✓ Done speech") + _check(done_speech != null and done_speech.visible, "speech composer exposes its labeled Done speech action") + await _save_capture("wide_grouped_say_composer") + if done_speech != null: + await _click_control(done_speech) + await _wait_frames(5) + plan = character.get_action_plan() + _check((plan[0]["actions"] as Array).size() == 2, "speech Done adds Say to the selected Move step") + _check(_step_has_action_type(plan[0], "speak"), "authored together speech is a real grouped action") + await _save_capture("wide_step_1_together") + + # Add Wait as the next step, group Say with that Wait, and add more future + # steps via the pinned quick action. This also builds the compact-scroll case. + var wait_button: Button = _find_button_with_text(quick_row, "⏳ Wait") + await _click_control(wait_button) + await _wait_frames(2) + await _click_control(_find_button_with_text(action_editor, "Done")) + await _wait_frames(4) + plan = character.get_action_plan() + _check(plan.size() == 2 and String(((plan[1]["actions"] as Array)[0] as Dictionary).get("type", "")) == "wait", "quick Wait appends a separate second step") + var wait_together: Button = _find_button_with_text(character_view.find_child("ActionStep_2", true, false), "+ Add together") + await _click_control(wait_together) + together_popup = character_view.find_child("AddTogetherChoices", true, false) as Panel + together_choices = together_popup.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if together_popup != null else null + _check(together_popup != null and together_popup.visible and together_choices != null and together_choices.get_child_count() == 1 and (together_choices.get_child(0) as Button).text.contains("Say"), "Wait compatibility picker offers Say as its only companion") + await _send_key(KEY_ENTER) + await _wait_frames(5) + composer = stage.get("_speech_composer") as Control + if composer != null and composer.visible: + message_editor = composer.find_child("ActiveBubbleText", true, false) as TextEdit + if message_editor != null: + await _click_control(message_editor) + await _send_key(KEY_A, false, true) + await _type_text("Fetch fetch fetch fetch fetch fetch fetch fetch fetch fetch fetch fetch") + speech_toolbar = stage.get("_speech_show_toolbar") as Control + done_speech = _find_button_with_text(speech_toolbar, "✓ Done speech") + if done_speech != null: + await _click_control(done_speech) + await _wait_frames(4) + plan = character.get_action_plan() + _check((plan[1]["actions"] as Array).size() == 2 and _step_has_action_type(plan[1], "speak"), "Wait+Say group commits to one concurrent step") + + # More singleton waits are added through the quick action and Done button. + for _index: int in range(6): + await _click_control(_find_button_with_text(quick_row, "⏳ Wait")) + await _wait_frames(2) + await _click_control(_find_button_with_text(action_editor, "Done")) + await _wait_frames(3) + plan = character.get_action_plan() + _check(plan.size() == 8, "quick actions create eight ordered steps without bypassing the production UI") + + # Reorder the future second step through its up arrow and back again; + # stable action IDs remain intact. Keyboard focus is checked on the step row. + var original_second_id: String = String(((plan[1]["actions"] as Array)[0] as Dictionary).get("action_id", "")) + var second_card: Control = character_view.find_child("ActionStep_2", true, false) as Control + var earlier: Button = _find_button_with_tooltip(second_card, "Move Step 2 earlier") + _check(earlier != null and earlier.text == "↑" and earlier.size.x >= 48.0 and earlier.size.y >= 48.0 and not earlier.disabled, "future step shows a touch-sized up arrow with a descriptive tooltip") + if earlier != null: + await _click_control(earlier) + await _wait_frames(4) + plan = character.get_action_plan() + _check(String(((plan[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == original_second_id, "up arrow changes authored order while retaining stable action identity") + var first_card: Control = character_view.find_child("ActionStep_1", true, false) as Control + var later: Button = _find_button_with_tooltip(first_card, "Move Step 1 later") + if later != null: + await _click_control(later) + await _wait_frames(4) + plan = character.get_action_plan() + _check(String(((plan[1]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == original_second_id, "down arrow restores the original future-step order") + + # At 920x560, the card keeps its header and pinned quick actions in view and + # scrolls the long plan body. The long actor name exercises header fitting. + root.size = Vector2i(920, 560) + await _wait_frames(8) + var body_scroll: ScrollContainer = card.find_child("InspectorBodyScroll", true, false) as ScrollContainer + _check(card.visible and card.get_global_rect().position.x >= 0.0 and card.get_global_rect().end.x <= 920.0, "small viewport keeps the character card inside the horizontal safe area") + _check(quick_row.visible and quick_row.get_global_rect().position.y >= 0.0 and quick_row.get_global_rect().end.y <= 560.0, "small viewport preserves visible pinned quick actions") + _check(body_scroll != null and body_scroll.get_v_scroll_bar().max_value > 0.0, "eight-step plan scrolls inside the inspector body on the compact viewport") + await _save_capture("compact_920x560_eight_steps") + + # Cancel a speech draft with the visible labeled control; it must not append + # an action or mark an otherwise-clean plan dirty. + stage.call("_set_scene_dirty", false) + await _click_control(_find_button_with_text(quick_row, "💬 Say")) + await _wait_frames(4) + composer = stage.get("_speech_composer") as Control + var speech_cancel: Button = _find_button_with_text(stage.get("_speech_show_toolbar") as Control, "Cancel speech") + _check(composer != null and composer.visible and speech_cancel != null, "new Say draft has a visible Cancel speech action") + if speech_cancel != null: + await _click_control(speech_cancel) + await _wait_frames(4) + _check(not composer.visible and not bool(stage.get("_dirty")) and character.get_action_plan().size() == 8, "Cancel speech closes the draft without a plan mutation") + await _save_capture("compact_speech_cancelled") + + # Playback goes through the real Space shortcut and the runner emits precise + # physics-frame observations for the grouped barrier. + var started: Array[Dictionary] = [] + var ended: Array[Dictionary] = [] + character.planned_action_started.connect(func(action_id: String, action: Dictionary, step_index: int) -> void: + started.append({"id": action_id, "type": String(action.get("type", "")), "step": step_index, "frame": Engine.get_physics_frames()}) + ) + character.planned_action_ended.connect(func(action_id: String, outcome: StringName, step_index: int) -> void: + ended.append({"id": action_id, "outcome": String(outcome), "step": step_index, "frame": Engine.get_physics_frames()}) + ) + await _send_key(KEY_SPACE) + await _wait_frames(4) + _check(int(stage.get("current_mode")) == SandboxStage.StageMode.PLAY, "Space starts playback from DIRECT through the production input path") + await _save_capture("compact_playback_started") + await _wait_until_actions_end(stage, ended, 8.0) + var step_two_wait_id: String = String(((character.get_action_plan()[1]["actions"] as Array)[0] as Dictionary).get("action_id", "")) + var step_two_say_id: String = _action_id_for_type(character.get_action_plan()[1], "speak") + var step_three_start_frame: int = _start_frame_for_step(started, 2) + var group_end_frame: int = maxi(_end_frame_for_id(ended, step_two_wait_id), _end_frame_for_id(ended, step_two_say_id)) + _check(_same_start_frame(started, 0), "Move and Say in the first authored step start on the same runner physics frame") + _check(_same_start_frame(started, 1), "Wait and Say in the second authored step start on the same runner physics frame") + _check(group_end_frame >= 0 and step_three_start_frame >= group_end_frame, "third step waits until both concurrent second-step actions finish") + print("PHASE20B_TIMING second_wait=%d second_say=%d third_start=%d frames" % [_end_frame_for_id(ended, step_two_wait_id), _end_frame_for_id(ended, step_two_say_id), step_three_start_frame]) + await _save_capture("compact_playback_group_barrier") + + # Pause through the visible DIRECT mode segment, verify the live future-only + # structural gate, then resume with Space. Completed plan rows stay erasable. + var direct_button_small: Button = _find_button_containing(stage, "Direct") + if direct_button_small != null: + await _click_control(direct_button_small) + await _wait_frames(4) + _check(int(stage.get("_run_state")) == SandboxStage.RunState.PAUSED, "DIRECT mode switch pauses an active run") + character_view.refresh_plan() + await _save_capture("compact_paused_plan") + var third_card: Control = character_view.find_child("ActionStep_3", true, false) as Control + var third_earlier: Button = _find_button_with_tooltip(third_card, "Move Step 3 earlier") + _check(third_earlier != null and third_earlier.disabled, "consumed run history prevents moving a completed/future-barrier step") + await _send_key(KEY_SPACE) + await _wait_frames(3) + _check(int(stage.get("current_mode")) == SandboxStage.StageMode.PLAY, "Space resumes playback from paused DIRECT") + + print("PHASE20B_GUI_CAPTURE %s; failures=%d" % [_active_capture_dir, _failures]) + await _finish(stage) + quit(1 if _failures > 0 else 0) + + +func _run_visual_slice(scenario: String, stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView, card: StageInspectorPanel, quick_row: Control) -> void: + match scenario: + "move_dialog": + await _run_move_dialog_slice(stage, character, prop, quick_row) + "wait_dialog": + await _run_wait_dialog_slice(stage, character, quick_row) + "move_action_options": + await _run_move_action_options_slice(stage, character, character_view) + "empty": + pass + "say": + await _run_say_slice(stage, character, character_view, quick_row) + "grouping": + await _run_grouping_slice(stage, character, character_view, quick_row) + "capture_entry": + await _run_capture_entry_slice(stage, quick_row) + "flag": + await _run_flag_slice(stage, character, character_view, card) + "step_spotlight": + await _run_step_spotlight_slice(stage, character, character_view) + "mode_overlay_and_names": + await _run_mode_overlay_and_names_slice(stage, character, prop, character_view) + "compact": + await _run_compact_slice(stage, character, character_view, card) + "action_drawer", "action_drawer_short": + await _run_action_drawer_slice(stage, character, character_view, card, quick_row) + "place_capture": + await _run_place_capture_slice(stage, character, prop, character_view, quick_row) + "prop_capture": + await _run_prop_capture_slice(stage, character, prop, character_view, quick_row) + _: + _check(false, "requested GUI scenario name is supported") + + +func _run_menu_focused_stack_slice(stage: SandboxStage) -> void: + root.size = Vector2i(1280, 900) + stage.call("_on_grid_toggled", false) + _check(bool(stage.call("_load_scene_from_path", "res://content/stages/menu.scene")), "the worktree menu.scene loads through the production stage loader") + await _wait_process_frames(8) + var world: Node2D = stage.get_node("World") as Node2D + var bob: StickmanRig + for child: Node in world.get_children(): + if child is StickmanRig and (child as StickmanRig).stage_name == "Bob": + bob = child as StickmanRig + break + _check(bob != null and bob.get_action_plan().size() == 9, "the loaded menu.scene has Bob's nine authored steps") + if bob == null: + return + var direct_button: Button = _find_button_containing(stage, "Direct") + if direct_button != null: + await _click_control(direct_button) + var selection: StageSelection = stage.get("_selection") as StageSelection + selection.select_only(bob) + await _wait_process_frames(6) + var view: StageCharacterSettingsView = stage.get("_character_settings_view") as StageCharacterSettingsView + var card: StageInspectorPanel = view.get_card() as StageInspectorPanel + var body_scroll: ScrollContainer = card.find_child("InspectorBodyScroll", true, false) as ScrollContainer + var heading: Label = view.find_child("ActionPlanHeadingLabel", true, false) as Label + _check(heading != null and heading.text == "Plan · 9 steps", "the focused-stack header shows the plan step count") + var step_one: Control = view.find_child("ActionStep_1", true, false) as Control + var step_two: Control = view.find_child("ActionStep_2", true, false) as Control + var step_three: Control = view.find_child("ActionStep_3", true, false) as Control + var step_four: Control = view.find_child("ActionStep_4", true, false) as Control + var step_five: Control = view.find_child("ActionStep_5", true, false) as Control + var step_nine: Control = view.find_child("ActionStep_9", true, false) as Control + var use_action: Dictionary = (bob.get_action_plan()[0]["actions"] as Array)[0] as Dictionary + var use_summary: Button = view.find_child("ActionSummary_%s" % String(use_action.get("action_id", "")), true, false) as Button + var step_one_style: StyleBoxFlat = step_one.get_theme_stylebox("panel") as StyleBoxFlat if step_one != null else null + var step_one_expanded: Control = step_one.find_child("ExpandedStepContent", true, false) as Control if step_one != null else null + var use_summary_label: Label = view.find_child("ActionSummaryText_%s" % String(use_action.get("action_id", "")), true, false) as Label + _check(step_one != null and step_one_style != null and step_one_style.border_color.is_equal_approx(Color("#FFB300")) and step_one_expanded != null and step_one_expanded.visible, "selected Step 1 remains expanded with the DIRECT amber border") + _check(use_summary != null and use_summary_label != null and use_summary_label.text == "Use Wall Clock", "Use summary shows the current prop name instead of prop and spot IDs") + var step_two_compact_row: Control = step_two.find_child("CompactStepRow", true, false) as Control if step_two != null else null + _check(step_two != null and step_two_compact_row != null and step_two_compact_row.visible and not step_two.find_child("ExpandedStepContent", true, false).visible, "unselected Step 2 is a compact tappable row") + var step_two_label: Label = step_two.find_child("ActionSummaryText_Step2", true, false) as Label if step_two != null else null + var step_two_summary: Button = step_two.find_child("CompactStepSummary_2", true, false) as Button if step_two != null else null + var step_four_label: Label = step_four.find_child("ActionSummaryText_Step4", true, false) as Label if step_four != null else null + var step_nine_label: Label = step_nine.find_child("ActionSummaryText_Step9", true, false) as Label if step_nine != null else null + _check(step_two_label != null and step_two_label.text == "Brown Briefcase" and step_two_summary != null and step_two_summary.tooltip_text == "Pick up Brown Briefcase", "compact pickup summary names the prop while its tooltip states the full action") + var step_three_label: Label = step_three.find_child("ActionSummaryText_Step3", true, false) as Label if step_three != null else null + _check(step_three_label != null and step_three_label.text == "Wait 1 second", "Wait keeps its readable duration") + _check(step_four_label != null and step_four_label.text == "Set down prop", "Set Down summary omits the raw world coordinates") + _check(step_five.find_child("ActionSummaryText_Step5", true, false).text == "Run to Place 5", "Move keeps its friendly destination label") + _check(step_nine_label != null and step_nine_label.text == "Throw prop + Say", "compact Together row names both actions without target coordinates") + var visible_add_together_count: int = 0 + for step_index: int in view.get("_step_cards").size(): + var step_card: Control = (view.get("_step_cards") as Array)[step_index] as Control + var add_together: Control = step_card.find_child("AddTogetherRow", true, false) as Control + if add_together != null and add_together.visible: + visible_add_together_count += 1 + _check(visible_add_together_count == 1, "only the selected step shows Add together") + await _save_capture_now("menu_focused_stack_step1") + if body_scroll != null and step_four != null: + card.scroll_to_control(step_four) + await _wait_process_frames(3) + var compact_step_four: Button = step_four.find_child("CompactStepSummary_4", true, false) as Button if step_four != null else null + var row_y_before: float = step_four.get_global_position().y if step_four != null else -1.0 + if compact_step_four != null: + await _click_control(compact_step_four) + await _wait_process_frames(5) + step_one = view.find_child("ActionStep_1", true, false) as Control + step_four = view.find_child("ActionStep_4", true, false) as Control + var step_four_expanded: Control = step_four.find_child("ExpandedStepContent", true, false) as Control if step_four != null else null + var step_four_summary: Button = step_four.find_child("CompactStepSummary_4", true, false) as Button if step_four != null else null + var step_four_compact_row: Control = step_four.find_child("CompactStepRow", true, false) as Control if step_four != null else null + var row_y_after: float = step_four.get_global_position().y if step_four != null else -1.0 + var set_down_action: Dictionary = (bob.get_action_plan()[3]["actions"] as Array)[0] as Dictionary + _check(int(view.get("_selected_step_index")) == 3 and step_four_expanded != null and step_four_expanded.visible and step_four_summary != null and step_four_compact_row != null and not step_four_compact_row.visible, "tapping a compact step expands and selects it") + _check(step_one != null and not step_one.find_child("ExpandedStepContent", true, false).visible, "the previous step collapses when another step is selected") + _check(absf(row_y_after - row_y_before) <= 2.0, "expanding a compact step keeps its row near the same screen position") + var selected_add_together: Control = step_four.find_child("AddTogetherRow", true, false) as Control if step_four != null else null + _check(selected_add_together != null and selected_add_together.visible, "the selected step exposes its compatible Add together control") + await _save_capture_now("menu_focused_stack_step4_selected") + var set_down_summary: Button = step_four.find_child("ActionSummary_%s" % String(set_down_action.get("action_id", "")), true, false) as Button if step_four != null else null + if set_down_summary != null: + await _click_control(set_down_summary) + await _wait_process_frames(3) + step_four = view.find_child("ActionStep_4", true, false) as Control + var set_down_options: Control = step_four.find_child("ActionOptions_%s" % String(set_down_action.get("action_id", "")), true, false) as Control if step_four != null else null + _check(set_down_options != null and set_down_options.visible, "action options open after the expanded step's action is tapped") + _check(not bool(stage.get("_dirty")), "selecting and expanding steps does not dirty the scene") + + +func _run_action_drawer_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, card: StageInspectorPanel, _quick_row: Control) -> void: + stage.call("_set_scene_dirty", false) + var add_step: Button = _find_button_with_text(character_view, "+ Add step") + _check(add_step != null and add_step.visible, "selected actor exposes the pinned Add step control") + if add_step == null: + return + await _click_control(add_step) + var drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + var dimmer: Panel = character_view.find_child("AddStepDrawerDimmer", true, false) as Panel + var opening_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + _check(drawer != null and drawer.visible and opening_rect.position.x > card.get_global_rect().position.x - drawer.size.x - 5.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "drawer visibly slides left from the inspector while fading in") + await _save_capture_now("action_drawer_opening") + await _wait_process_frames(14) + var title: Label = drawer.find_child("TogetherChoiceTitle", true, false) as Label if drawer != null else null + var choices: VBoxContainer = drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if drawer != null else null + var choices_scroll: ScrollContainer = drawer.find_child("TogetherChoiceScroll", true, false) as ScrollContainer if drawer != null else null + var cancel: Button = _find_button_with_text(drawer, "Cancel") if drawer != null else null + var drawer_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + var card_rect: Rect2 = card.get_global_rect() + var dimmer_rect: Rect2 = dimmer.get_global_rect() if dimmer != null else Rect2() + var drawer_style: StyleBoxFlat = drawer.get_theme_stylebox("panel") as StyleBoxFlat if drawer != null else null + var dimmer_style: StyleBoxFlat = dimmer.get_theme_stylebox("panel") as StyleBoxFlat if dimmer != null else null + var first_choice_rect: Rect2 = choices.get_child(0).get_global_rect() if choices != null and choices.get_child_count() > 0 else Rect2() + var last_choice_rect: Rect2 = choices.get_child(choices.get_child_count() - 1).get_global_rect() if choices != null and choices.get_child_count() > 0 else Rect2() + var choices_scroll_rect: Rect2 = choices_scroll.get_global_rect() if choices_scroll != null else Rect2() + var all_choices_visible: bool = choices_scroll != null and choices_scroll.scroll_vertical == 0 and choices_scroll_rect.encloses(first_choice_rect) and choices_scroll_rect.encloses(last_choice_rect) + _check(drawer != null and drawer.visible and title != null and title.text == "Choose an action", "Add step opens the production Choose an action drawer") + _check(choices != null and choices.get_child_count() >= 8 and cancel != null and cancel.visible and choices.get_global_rect().position.y >= drawer_rect.position.y, "drawer keeps the scrollable action list and pinned Cancel visible") + _check(drawer_rect.size.y > card_rect.size.y and cancel != null and choices_scroll_rect.end.y <= cancel.get_global_rect().position.y, "drawer extends beyond the inspector while keeping Cancel pinned below the list") + if root.size.y >= 900: + _check(all_choices_visible, "tall drawer shows every action without scrolling") + else: + _check(choices_scroll != null and last_choice_rect.end.y > choices_scroll_rect.end.y and cancel != null and cancel.get_global_rect().end.y <= drawer_rect.end.y, "short drawer scrolls the action list while keeping Cancel visible") + _check(drawer_rect.end.y <= float(root.size.y - 50), "drawer stays above the Stage status bar and safe bottom inset") + _check(absf(drawer_rect.position.y - card_rect.position.y) <= 1.0 and absf(drawer_rect.end.x - card_rect.position.x) <= 10.0, "drawer top aligns with inspector and opens from its left edge") + _check(dimmer != null and dimmer.visible and dimmer_rect.position.distance_to(card_rect.position) <= 1.0 and dimmer_rect.size.distance_to(card_rect.size) <= 1.0, "scrim covers the inspector bounds only") + _check(dimmer_style != null and is_equal_approx(dimmer_style.bg_color.a, 0.32) and drawer_style != null and drawer_style.bg_color.a >= 0.95, "inspector scrim is a light 32 percent shade while the drawer stays opaque") + _check(root.gui_get_focus_owner() == (choices.get_child(0) as Button), "opening the drawer moves keyboard focus to the first action choice") + await _save_capture_now("action_drawer_open") + if cancel != null: + await _click_control(cancel) + await _wait_process_frames(6) + var cancel_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + _check(drawer != null and drawer.visible and cancel_motion_rect.position.x > drawer_rect.position.x + 8.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "Cancel reverses the drawer slide and fades the inspector scrim") + await _save_capture_now("action_drawer_canceling") + await _wait_process_frames(12) + _check(drawer != null and not drawer.visible and dimmer != null and not dimmer.visible, "Cancel slides the drawer and inspector scrim away") + _check(root.gui_get_focus_owner() == add_step, "Cancel returns keyboard focus to Add step") + await _save_capture_now("action_drawer_cancelled") + + await _click_control(add_step) + await _wait_process_frames(4) + await _send_click(Vector2(300.0, 300.0)) + await _wait_process_frames(4) + var outside_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + _check(drawer.visible and outside_motion_rect.position.x > drawer_rect.position.x + 8.0 and bool(character_view.get("_plan_popup_closing")), "outside click starts the same reverse slide and fade") + await _save_capture_now("action_drawer_outside_closing") + await _wait_process_frames(12) + var selection: StageSelection = stage.get("_selection") as StageSelection + _check(not drawer.visible and not dimmer.visible, "left click outside slides the drawer closed") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "outside click is consumed without clearing the actor selection on stage") + _check(root.gui_get_focus_owner() == add_step, "outside click returns keyboard focus to Add step") + await _save_capture_now("action_drawer_outside_clicked") + + await _click_control(add_step) + await _wait_process_frames(16) + await _send_click(add_step.get_global_rect().get_center()) + await _wait_process_frames(16) + _check(not drawer.visible and not dimmer.visible, "clicking the dimmed inspector closes the drawer without reopening it") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "inspector click is consumed while the drawer is open") + await _save_capture_now("action_drawer_inspector_clicked") + + await _click_control(add_step) + await _wait_process_frames(4) + await _send_key(KEY_ESCAPE) + await _wait_process_frames(4) + var escape_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + _check(drawer.visible and escape_motion_rect.position.x > drawer_rect.position.x + 8.0 and bool(character_view.get("_plan_popup_closing")), "Escape starts the same reverse slide and fade") + await _save_capture_now("action_drawer_escape_closing") + await _wait_process_frames(12) + _check(not drawer.visible and not dimmer.visible, "Escape slides the drawer closed and removes the scrim") + _check(root.gui_get_focus_owner() == add_step, "Escape returns keyboard focus to Add step") + await _save_capture_now("action_drawer_escape_closed") + + await _click_control(add_step) + await _wait_process_frames(16) + var move_choice: Button = _find_button_containing(drawer, "Move") + _check(move_choice != null, "drawer retains working action choices after each dismissal path") + if move_choice != null: + await _click_control(move_choice) + await _wait_process_frames(16) + var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + _check(not drawer.visible and action_editor != null and action_editor.visible, "choosing an action waits for the drawer to close before opening its editor") + if action_editor != null and action_editor.visible: + await _click_control(_find_button_with_text(action_editor, "Cancel")) + await _wait_process_frames(4) + _check(not bool(stage.get("_dirty")), "canceling the opened action draft leaves the stage unchanged") + await _save_capture_now("action_drawer_action_selected") + + var fixture_say: Dictionary = {"action_id": "drawer-fixture-say", "type": "speak", "text": "We are just walking around and seeing what is going on? Is there anything going on?", "duration": 6.3} + var fixture_plan: Array[Dictionary] = [{"actions": [fixture_say]}] + _check(character.set_action_plan(fixture_plan), "Say-only fixture provides compatible action choices for Add together") + character_view.refresh_plan() + stage.call("_set_scene_dirty", false) + await _wait_process_frames(4) + var say_step: Control = character_view.find_child("ActionStep_1", true, false) as Control + var together_button: Button = _find_button_with_text(say_step, "+ Add together") if say_step != null else null + _check(together_button != null, "Say step exposes its Add together control") + if together_button == null: + return + await _click_control(together_button) + await _wait_process_frames(1) + var together_entering_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + var entering_target_x: float = card.get_global_rect().position.x - drawer.size.x - 8.0 if drawer != null else 0.0 + _check(drawer.visible and together_entering_rect.position.x > entering_target_x + 4.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "Add together uses the same slide-out and fade as Add step") + await _save_capture_now("add_together_drawer_opening") + await _wait_process_frames(14) + var together_title: Label = drawer.find_child("TogetherChoiceTitle", true, false) as Label if drawer != null else null + var together_source: Label = drawer.find_child("TogetherSourceAction", true, false) as Label if drawer != null else null + var together_choices: VBoxContainer = drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if drawer != null else null + var together_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + var say_step_rect: Rect2 = card.get_global_rect() + _check(drawer.visible and together_title != null and together_title.text == "Add to Step 1", "Add together opens the shared drawer with its step title") + _check(together_source != null and together_source.visible and together_source.text.begins_with("With: Say") and together_source.text.contains("We are just walking around"), "drawer preserves the source Say summary and duration context") + _check(together_source != null and together_source.max_lines_visible == 2 and together_source.tooltip_text == together_source.text, "long source summary is limited to two lines and remains available in its tooltip") + var expected_say_companions: Array[String] = ACTION_REGISTRY.types() + expected_say_companions.erase("speak") + var every_say_companion_visible: bool = together_choices != null and together_choices.get_child_count() == expected_say_companions.size() + for action_type: String in expected_say_companions: + var expected_label: String = "Move" if action_type == ACTION_REGISTRY.MOVE_TO else ACTION_REGISTRY.label(action_type) + if together_choices == null or _find_button_containing(together_choices, expected_label) == null: + every_say_companion_visible = false + _check(every_say_companion_visible and _find_button_containing(together_choices, "Say") == null, "Say Add together picker lists every other registered action and omits duplicate Say") + _check(absf(together_rect.position.y - say_step_rect.position.y) <= 1.0 and absf(together_rect.end.x - say_step_rect.position.x) <= 10.0 and dimmer.visible, "Add together drawer aligns with the inspector and dims its shell") + _check(root.gui_get_focus_owner() == together_choices.get_child(0), "Add together drawer gives focus to its first compatible choice") + await _save_capture_now("add_together_say_all_actions") + await _click_control(_find_button_with_text(drawer, "Cancel")) + await _wait_process_frames(6) + var together_cancel_motion_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + _check(drawer.visible and together_cancel_motion_rect.position.x > together_rect.position.x + 8.0 and drawer.modulate.a > 0.0 and drawer.modulate.a < 1.0, "Add together Cancel slides the shared drawer and scrim back") + await _save_capture_now("add_together_drawer_canceling") + await _wait_process_frames(10) + _check(not drawer.visible and not dimmer.visible and root.gui_get_focus_owner() == together_button, "Add together Cancel slides in and restores focus to its trigger") + await _save_capture_now("add_together_drawer_cancelled") + + await _click_control(together_button) + await _wait_process_frames(16) + await _send_click(Vector2(300.0, 300.0)) + await _wait_process_frames(16) + _check(not drawer.visible and not dimmer.visible and root.gui_get_focus_owner() == together_button, "Add together closes on outside click and restores focus") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "Add together outside click is consumed by the drawer") + + await _click_control(together_button) + await _wait_process_frames(16) + await _send_key(KEY_ESCAPE) + await _wait_process_frames(16) + _check(not drawer.visible and not dimmer.visible and root.gui_get_focus_owner() == together_button, "Add together closes on Escape and restores focus") + + await _click_control(together_button) + await _wait_process_frames(16) + await _click_control(_find_button_containing(together_choices, "Move")) + await _wait_process_frames(16) + action_editor = stage.get("_action_editor") as ActionEditor + _check(action_editor != null and action_editor.visible and int(stage.get("_plan_step_index")) == 0 and bool(stage.get("_plan_add_together")), "compatible choice opens its editor for the same step after the drawer closes") + if action_editor != null and action_editor.visible: + await _click_control(_find_button_with_text(action_editor, "Cancel")) + await _wait_process_frames(4) + _check(character.get_action_plan().size() == 1 and (character.get_action_plan()[0]["actions"] as Array).size() == 1 and not bool(stage.get("_dirty")), "Canceling a together action draft leaves the source plan unchanged") + + await _click_control(together_button) + await _wait_process_frames(16) + var ragdoll_choice: Button = _find_button_containing(drawer, "Ragdoll") + _check(ragdoll_choice != null, "Say step offers Ragdoll as a together action") + if ragdoll_choice != null: + await _click_control(ragdoll_choice) + await _wait_process_frames(16) + var say_ragdoll_plan: Array[Dictionary] = character.get_action_plan() + var say_ragdoll_actions: Array = say_ragdoll_plan[0].get("actions", []) if not say_ragdoll_plan.is_empty() else [] + _check(say_ragdoll_actions.size() == 2 and String((say_ragdoll_actions[0] as Dictionary).get("type", "")) == "speak" and String((say_ragdoll_actions[1] as Dictionary).get("type", "")) == "ragdoll", "choosing Ragdoll commits it alongside the existing Say") + stage.call("_refresh_character_settings_card") + await _wait_process_frames(4) + await _save_capture_now("say_ragdoll_together_pills") + print("PHASE20B_ACTION_DRAWER rect=%s card=%s dimmer=%s focus=%s" % [str(drawer_rect), str(card_rect), str(dimmer_rect), str(root.gui_get_focus_owner())]) + + +func _run_move_dialog_slice(stage: SandboxStage, character: StickmanRig, prop_fixture: PropBlock, quick_row: Control) -> void: + stage.call("_set_scene_dirty", false) + var move_button: Button = _find_button_with_text(quick_row, "🚶 Move") + await _click_control(move_button) + var editor: ActionEditor = stage.get("_action_editor") as ActionEditor + var title: Label = editor.find_child("ActionEditorTitle", true, false) as Label if editor != null else null + var type_row: Control = editor.find_child("ActionTypeRow", true, false) as Control if editor != null else null + var move_heading: Label = _find_label_exact_text(editor, "How should Milo move?") if editor != null else null + var destination_heading: Label = _find_label_exact_text(editor, "Where should Milo go?") if editor != null else null + var walk: Button = _find_button_with_text(editor, "🚶 Walk") if editor != null else null + var run: Button = _find_button_with_text(editor, "🏃 Run") if editor != null else null + var place: Button = editor.find_child("ChoosePlanPlace", true, false) as Button if editor != null else null + var choose_prop: Button = editor.find_child("ChoosePlanProp", true, false) as Button if editor != null else null + var done: Button = editor.find_child("ActionEditorDone", true, false) as Button if editor != null else null + var cancel: Button = editor.find_child("ActionEditorCancel", true, false) as Button if editor != null else null + var backdrop: ColorRect = stage.find_child("ActionEditorBackdrop", true, false) as ColorRect + _check(editor != null and editor.visible and title != null and title.text.begins_with("Move ") and title.text.contains("Milo"), "Quick Move opens a titled production Move dialog for the selected character") + _check(type_row != null and not type_row.visible, "fixed Quick Move hides the redundant action-type row") + _check(move_heading != null and destination_heading != null, "movement and destination use separate labeled sections") + _check(walk != null and run != null and walk.text == "🚶 Walk" and run.text == "🏃 Run" and walk.custom_minimum_size.y >= 56.0 and run.custom_minimum_size.y >= 56.0, "Walk and Run are equal large choices with stable labels") + if walk != null: + var selected_walk_color: Color = walk.get_theme_color("font_pressed_color") + var walk_focus: StyleBoxFlat = walk.get_theme_stylebox("focus") as StyleBoxFlat + _check(selected_walk_color.is_equal_approx(Color("#161616")), "selected Walk label stays dark and readable on the amber surface") + _check(walk_focus != null and walk_focus.border_color.is_equal_approx(Color("#8BD9FF")) and walk_focus.border_width_left >= 3, "keyboard focus remains a distinct cyan ring on the selected Walk tile") + _check(place != null and choose_prop != null and place.custom_minimum_size.y >= 52.0 and choose_prop.custom_minimum_size.y >= 52.0, "place and prop destinations use large separate controls") + _check(done != null and done.text == "Done" and done.disabled and cancel != null and cancel.text == "Cancel" and cancel.custom_minimum_size.y >= 52.0, "Done is the disabled primary action until a destination is chosen, beside visible Cancel") + var panel_style: StyleBoxFlat = editor.get_theme_stylebox("panel") as StyleBoxFlat if editor != null else null + _check(panel_style != null and panel_style.corner_radius_top_left >= 16 and panel_style.border_width_left >= 2 and panel_style.bg_color.a >= 0.95, "Move dialog uses the shared opaque rounded shell and clear border") + _check(backdrop != null and backdrop.visible and backdrop.color.a >= 0.5, "Move dialog dims the stage behind its modal card") + if done != null and cancel != null: + var done_style: StyleBoxFlat = done.get_theme_stylebox("normal") as StyleBoxFlat + var cancel_style: StyleBoxFlat = cancel.get_theme_stylebox("normal") as StyleBoxFlat + _check(done_style != null and done_style.bg_color.is_equal_approx(Color("#087DBA")) and cancel_style != null and cancel_style.bg_color != done_style.bg_color, "Done uses the shared blue primary role and Cancel stays secondary") + var viewport_size: Vector2i = root.size + _check(editor != null and editor.size.x <= viewport_size.x - 48 and editor.size.y <= viewport_size.y - 48, "Move dialog fits within the safe viewport") + var destination_hint: Label = _find_label_exact_text(editor, "Choose a place or prop, then tap Done.") if editor != null else null + _check(destination_hint != null, "destination guidance tells the child what to do next") + if walk != null: + walk.grab_focus() + await _wait_process_frames(2) + _check(walk.has_focus(), "Walk focus is visible before choosing a destination") + await _save_capture_now("phase20b_final_move_dialog_standard") + if editor != null and done != null and prop_fixture != null: + editor.set_plan_destination({"kind": "prop", "target_prop_id": prop_fixture.stage_object_id, "approach_offset": Vector2.ZERO, "last_known_approach": prop_fixture.global_position}) + editor.set_plan_destination_summary(prop_fixture.stage_name) + await _wait_process_frames(2) + var selected_destination: Label = _find_label_exact_text(editor, "Destination: %s" % prop_fixture.stage_name) + var primary_style: StyleBoxFlat = done.get_theme_stylebox("normal") as StyleBoxFlat + _check(not done.disabled and selected_destination != null, "selected prop summary enables Done with a readable destination") + _check(primary_style != null and primary_style.bg_color.is_equal_approx(Color("#087DBA")), "enabled Done keeps the shared blue primary fill") + done.grab_focus() + await _wait_process_frames(2) + var done_focus: StyleBoxFlat = done.get_theme_stylebox("focus") as StyleBoxFlat + _check(done.has_focus() and done_focus != null and done_focus.border_color.is_equal_approx(Color("#8BD9FF")), "focused primary action keeps a separate visible focus ring") + await _save_capture_now("phase20b_final_move_dialog_destination_selected") + var anchor_before_return: Vector2i = editor.position + editor.hide_for_plan_target_capture() + await _wait_process_frames(2) + editor.resume_after_plan_target_capture() + await _wait_process_frames(2) + _check(editor.visible and editor.position == anchor_before_return, "returning from destination capture preserves the dialog anchor") + if editor != null and cancel != null: + var cancel_clicks: Array[bool] = [] + cancel.pressed.connect(func() -> void: cancel_clicks.append(true)) + await _click_control(cancel) + await _wait_process_frames(3) + _check(cancel_clicks.size() == 1 and not editor.visible and not bool(stage.get("_dirty")), "visible Cancel closes the Move dialog without changing the plan") + + +func _run_wait_dialog_slice(stage: SandboxStage, character: StickmanRig, quick_row: Control) -> void: + stage.call("_set_scene_dirty", false) + var wait_quick_action: Button = _find_button_with_text(quick_row, "⏳ Wait") + _check(wait_quick_action != null, "pinned Wait action is available to open the production dialog") + if wait_quick_action == null: + return + await _click_control(wait_quick_action) + var editor: ActionEditor = stage.get("_action_editor") as ActionEditor + var value_edit: LineEdit = editor.find_child("WaitDurationValue", true, false) as LineEdit if editor != null else null + var duration_spin: SpinBox = editor.get("_duration_spin") as SpinBox if editor != null else null + var unit_label: Label = editor.find_child("WaitDurationUnit", true, false) as Label if editor != null else null + var minus: Button = editor.find_child("WaitDurationMinus", true, false) as Button if editor != null else null + var plus: Button = editor.find_child("WaitDurationPlus", true, false) as Button if editor != null else null + var one_second: Button = editor.find_child("WaitQuickPick1", true, false) as Button if editor != null else null + var three_seconds: Button = editor.find_child("WaitQuickPick3", true, false) as Button if editor != null else null + var five_seconds: Button = editor.find_child("WaitQuickPick5", true, false) as Button if editor != null else null + var done: Button = editor.find_child("ActionEditorDone", true, false) as Button if editor != null else null + var cancel: Button = editor.find_child("ActionEditorCancel", true, false) as Button if editor != null else null + await _wait_process_frames(2) + _check(editor != null and editor.visible and editor.current_action_type() == "wait", "pinned Wait opens the real plan Wait dialog") + _check(value_edit != null and duration_spin != null and minus != null and plus != null and unit_label != null and one_second != null and three_seconds != null and five_seconds != null, "Wait dialog shows the editable value, large stepper, unit, and three quick picks") + _check(value_edit != null and value_edit.text == "1" and unit_label != null and unit_label.text == "second" and one_second != null and one_second.button_pressed, "default one second is shown without a decimal and selects the matching singular preset") + var value_field: PanelContainer = editor.find_child("WaitDurationValueField", true, false) as PanelContainer if editor != null else null + var value_field_style: StyleBoxFlat = value_field.get_theme_stylebox("panel") as StyleBoxFlat if value_field != null else null + var active_theme: SandboxTheme = stage.get("_sandbox_theme") as SandboxTheme + var expected_focus_color: Color = active_theme.color("popups.confirmation.accent_color", Color("#39BFFF")) if active_theme != null else Color("#39BFFF") + _check(value_edit != null and value_edit.has_focus() and value_field_style != null and value_field_style.border_width_left >= 3 and value_field_style.border_color.is_equal_approx(expected_focus_color), "editable seconds field starts focused with a visible cyan outline") + _check(minus != null and plus != null and minus.custom_minimum_size.x >= 64.0 and minus.custom_minimum_size.y >= 64.0 and plus.custom_minimum_size.x >= 64.0 and plus.custom_minimum_size.y >= 64.0, "plus and minus controls are large touch targets") + _check(editor != null and editor.size.y <= 400 and done != null and cancel != null and done.text == "Done" and cancel.text == "Cancel", "redesigned dialog is compact and keeps visible Done and Cancel actions") + if editor == null or value_edit == null or duration_spin == null or minus == null or plus == null or one_second == null or three_seconds == null or five_seconds == null or done == null or cancel == null: + return + await _save_capture_now("wait_dialog_initial") + + # Cancel a changed draft and confirm that no wait was added to the plan. + await _click_control(five_seconds) + _check(is_equal_approx(duration_spin.value, 5.0) and five_seconds.button_pressed and not one_second.button_pressed and unit_label.text == "seconds", "five-second quick pick updates the value, selected chip, and plural unit") + await _click_control(cancel) + await _wait_process_frames(3) + _check(not editor.visible and character.get_action_plan().is_empty() and not bool(stage.get("_dirty")), "Cancel discards the changed Wait draft without changing the plan") + + await _click_control(wait_quick_action) + await _wait_process_frames(3) + value_edit = editor.find_child("WaitDurationValue", true, false) as LineEdit + duration_spin = editor.get("_duration_spin") as SpinBox + unit_label = editor.find_child("WaitDurationUnit", true, false) as Label + minus = editor.find_child("WaitDurationMinus", true, false) as Button + plus = editor.find_child("WaitDurationPlus", true, false) as Button + one_second = editor.find_child("WaitQuickPick1", true, false) as Button + three_seconds = editor.find_child("WaitQuickPick3", true, false) as Button + five_seconds = editor.find_child("WaitQuickPick5", true, false) as Button + done = editor.find_child("ActionEditorDone", true, false) as Button + if value_edit == null or duration_spin == null or unit_label == null or minus == null or plus == null or one_second == null or three_seconds == null or five_seconds == null or done == null: + _check(false, "Wait controls are rebuilt after cancelling the first draft") + return + + await _click_control(minus) + _check(is_equal_approx(duration_spin.value, 0.5) and value_edit.text == "0.5" and unit_label.text == "seconds", "minus steps down by half a second") + await _click_control(plus) + _check(is_equal_approx(duration_spin.value, 1.0) and value_edit.text == "1" and unit_label.text == "second", "plus steps up by half a second and restores the singular unit") + + await _click_control(value_edit) + await _send_key(KEY_A, false, true) + await _type_text("1.2") + _check(value_edit.text == "1.2", "duration field accepts typed decimal seconds") + await _click_control(plus) + _check(is_equal_approx(duration_spin.value, 1.7) and value_edit.text == "1.7", "stepper adds 0.5 to a typed decimal without snapping it first") + await _click_control(minus) + _check(is_equal_approx(duration_spin.value, 1.2) and value_edit.text == "1.2", "stepper subtracts 0.5 and preserves tenths") + + await _click_control(three_seconds) + _check(is_equal_approx(duration_spin.value, 3.0) and three_seconds.button_pressed and not one_second.button_pressed and unit_label.text == "seconds", "three-second preset selects itself and updates the value") + await _save_capture_now("wait_dialog_preset_three_seconds") + await _click_control(one_second) + _check(is_equal_approx(duration_spin.value, 1.0) and one_second.button_pressed and not three_seconds.button_pressed and unit_label.text == "second", "one-second preset returns to singular wording and updates the selected chip") + + await _click_control(value_edit) + await _send_key(KEY_A, false, true) + await _type_text("3600") + await _send_key(KEY_ENTER) + _check(editor.visible and is_equal_approx(duration_spin.value, 3600.0) and value_edit.text == "3600" and plus.disabled, "Enter commits typed maximum seconds without accepting or closing the dialog") + await _click_control(value_edit) + await _send_key(KEY_A, false, true) + await _type_text("0.1") + await _send_key(KEY_ENTER) + _check(is_equal_approx(duration_spin.value, 0.1) and minus.disabled and unit_label.text == "seconds", "typed minimum is clamped to one tenth and disables the lower step") + await _click_control(plus) + _check(is_equal_approx(duration_spin.value, 0.6) and not plus.disabled and not minus.disabled, "plus steps up from the minimum by half a second") + await _click_control(five_seconds) + await _click_control(one_second) + _check(is_equal_approx(duration_spin.value, 1.0) and one_second.button_pressed and not five_seconds.button_pressed, "preset selection follows each choice instead of leaving stale highlights") + await _save_capture_now("wait_dialog_final_one_second") + await _click_control(done) + await _wait_process_frames(4) + var plan: Array[Dictionary] = character.get_action_plan() + var committed_wait: Dictionary = {} + if plan.size() == 1 and plan[0].get("actions", []) is Array and not (plan[0]["actions"] as Array).is_empty(): + committed_wait = (plan[0]["actions"] as Array)[0] as Dictionary + _check(not editor.visible and plan.size() == 1 and String(committed_wait.get("type", "")) == "wait" and is_equal_approx(float(committed_wait.get("duration", -1.0)), 1.0), "Done commits the selected duration as the Wait action in the plan") + + +func _run_move_action_options_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView) -> void: + var move_action: Dictionary = { + "action_id": "visual-options-move", + "type": "move_to", + "style": "walk", + "destination": {"kind": "place", "position": Vector2(-20.0, 0.0)}, + } + var say_action: Dictionary = {"action_id": "visual-options-say", "type": "speak", "text": "Hello there!", "duration": 2.0} + var fixture_plan: Array[Dictionary] = [{"actions": [move_action]}, {"actions": [say_action]}] + _check(character.set_action_plan(fixture_plan), "visual comparison plan contains one Move and one Say action") + stage.call("_set_scene_dirty", false) + character_view.refresh_plan() + var move_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Walk to Place 1") + _check(move_summary != null, "Move action summary is visible for the options comparison") + if move_summary == null: + return + await _click_control(move_summary) + await _wait_process_frames(2) + var move_options: Control = character_view.find_child("ActionOptions_visual-options-move", true, false) as Control + var move_edit: Button = _find_button_with_text(move_options, "Edit action") + var move_change: Button = _find_button_with_text(move_options, "Change action") + var move_erase: Button = _find_button_with_text(move_options, "Erase action") + var move_edit_rect: Rect2 = move_edit.get_global_rect() if move_edit != null else Rect2() + var move_change_rect: Rect2 = move_change.get_global_rect() if move_change != null else Rect2() + var move_erase_rect: Rect2 = move_erase.get_global_rect() if move_erase != null else Rect2() + var move_layout_valid: bool = move_options is VBoxContainer and move_options.visible and move_edit != null and move_change != null and move_erase != null and move_edit.get_parent() == move_options and move_change.get_parent() == move_options and move_erase.get_parent() == move_options and is_equal_approx(move_edit_rect.size.x, move_change_rect.size.x) and is_equal_approx(move_edit_rect.size.x, move_erase_rect.size.x) and move_edit_rect.end.y <= move_change_rect.position.y and move_change_rect.end.y <= move_erase_rect.position.y and move_edit_rect.size.y >= 44.0 and move_change_rect.size.y >= 44.0 and move_erase_rect.size.y >= 44.0 + _check(move_layout_valid, "Move has stacked, full-width Edit action, Change action, and Erase action controls in that order") + if move_layout_valid: + await _save_capture_now("move_action_options_stacked") + await _click_control(move_edit) + var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + var choose_place: Button = _find_button_containing(action_editor, "place") + var choose_prop: Button = _find_button_containing(action_editor, "prop") + var action_type_row: Control = action_editor.find_child("ActionTypeRow", true, false) as Control if action_editor != null else null + _check(action_editor != null and action_editor.visible and action_editor.current_action_type() == "move_to" and choose_place != null and choose_prop != null and action_type_row != null and not action_type_row.visible, "Edit action opens the Move destination editor without an action-type dropdown") + var cancel_edit: Button = _find_button_with_text(action_editor, "Cancel") + if cancel_edit != null: + await _click_control(cancel_edit) + await _wait_process_frames(2) + await _click_control(move_change) + await _wait_process_frames(16) + var drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + var dimmer: Panel = character_view.find_child("AddStepDrawerDimmer", true, false) as Panel + var drawer_title: Label = drawer.find_child("TogetherChoiceTitle", true, false) as Label if drawer != null else null + var drawer_source: Label = drawer.find_child("TogetherSourceAction", true, false) as Label if drawer != null else null + var drawer_choices: VBoxContainer = drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if drawer != null else null + var change_drawer_rect: Rect2 = drawer.get_global_rect() if drawer != null else Rect2() + _check(drawer != null and drawer.visible and drawer_title != null and drawer_title.text == "Change action" and drawer_source != null and drawer_source.text == "Replace: Walk to Place 1", "Change action opens the shared drawer with the original action called out") + _check(drawer_choices != null and drawer_choices.get_child_count() == ACTION_REGISTRY.types().size() - 1 and _find_button_containing(drawer_choices, "Move") == null and dimmer != null and dimmer.visible, "standalone replacement picker lists all other action types and dims the inspector") + _check(absf(change_drawer_rect.position.y - character_view.get_card().get_global_rect().position.y) <= 1.0, "Change action drawer aligns to the inspector top") + await _save_capture_now("move_change_action_drawer_open") + var cancel_change: Button = _find_button_with_text(drawer, "Cancel") if drawer != null else null + if cancel_change != null: + await _click_control(cancel_change) + await _wait_process_frames(16) + var after_drawer_cancel: Array[Dictionary] = character.get_action_plan() + var drawer_cancel_preserved: bool = after_drawer_cancel.size() == 2 and String((after_drawer_cancel[0]["actions"] as Array)[0].get("type", "")) == "move_to" and String((after_drawer_cancel[0]["actions"] as Array)[0].get("action_id", "")) == "visual-options-move" + _check(drawer != null and not drawer.visible and dimmer != null and not dimmer.visible and drawer_cancel_preserved and not bool(stage.get("_dirty")), "Cancel closes the Change action drawer without modifying the plan") + + await _click_control(move_change) + await _wait_process_frames(16) + var wait_choice: Button = _find_button_containing(drawer_choices, "Wait") if drawer_choices != null else null + if wait_choice != null: + await _click_control(wait_choice) + await _wait_process_frames(16) + action_editor = stage.get("_action_editor") as ActionEditor + action_type_row = action_editor.find_child("ActionTypeRow", true, false) as Control if action_editor != null else null + _check(action_editor != null and action_editor.visible and action_editor.current_action_type() == "wait" and action_type_row != null and not action_type_row.visible and String((character.get_action_plan()[0]["actions"] as Array)[0].get("action_id", "")) == "visual-options-move", "Choosing Wait opens its editor while the original Move and action ID remain until Done") + await _save_capture_now("change_action_wait_editor_open") + var cancel_replacement: Button = _find_button_with_text(action_editor, "Cancel") if action_editor != null else null + if cancel_replacement != null: + await _click_control(cancel_replacement) + await _wait_process_frames(4) + var after_editor_cancel: Array[Dictionary] = character.get_action_plan() + var editor_cancel_preserved: bool = after_editor_cancel.size() == 2 and String((after_editor_cancel[0]["actions"] as Array)[0].get("type", "")) == "move_to" and String((after_editor_cancel[0]["actions"] as Array)[0].get("action_id", "")) == "visual-options-move" + _check(editor_cancel_preserved and not bool(stage.get("_dirty")), "Canceling a type replacement leaves the original action and clean scene intact") + + await _click_control(move_change) + await _wait_process_frames(16) + wait_choice = _find_button_containing(drawer_choices, "Wait") if drawer_choices != null else null + if wait_choice != null: + await _click_control(wait_choice) + await _wait_process_frames(16) + action_editor = stage.get("_action_editor") as ActionEditor + var replace_done: Button = _find_button_with_text(action_editor, "Done") if action_editor != null else null + if replace_done != null: + await _click_control(replace_done) + await _wait_process_frames(5) + var replaced_plan: Array[Dictionary] = character.get_action_plan() + var replaced_action: Dictionary = (replaced_plan[0]["actions"] as Array)[0] as Dictionary if not replaced_plan.is_empty() else {} + _check(String(replaced_action.get("type", "")) == "wait" and String(replaced_action.get("action_id", "")) == "visual-options-move" and bool(stage.get("_dirty")), "Done replaces the Move with Wait under the same action ID and dirties the scene once") + await _save_capture_now("move_replaced_by_wait") + + var wait_for_say: Dictionary = replaced_action.duplicate(true) + var change_to_say_plan: Array[Dictionary] = [{"actions": [wait_for_say]}] + _check(character.set_action_plan(change_to_say_plan), "standalone Wait fixture is ready for changing to Say") + stage.call("_set_scene_dirty", false) + character_view.refresh_plan() + await _wait_process_frames(8) + var wait_to_say_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Wait") + var wait_to_say_options: Control = character_view.find_child("ActionOptions_visual-options-move", true, false) as Control + if wait_to_say_summary != null and (wait_to_say_options == null or not wait_to_say_options.visible): + await _click_control(wait_to_say_summary) + await _wait_process_frames(8) + wait_to_say_options = character_view.find_child("ActionOptions_visual-options-move", true, false) as Control + var wait_to_say_change: Button = _find_button_with_text(wait_to_say_options, "Change action") + if wait_to_say_change != null: + var inspector_for_say_change: StageInspectorPanel = character_view.get_card() as StageInspectorPanel + if inspector_for_say_change != null: + inspector_for_say_change.scroll_to_control(wait_to_say_change) + await _wait_process_frames(4) + await _click_control(wait_to_say_change) + await _wait_process_frames(16) + var say_change_drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + var say_change_choices: VBoxContainer = say_change_drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if say_change_drawer != null else null + var say_choice: Button = _find_button_containing(say_change_choices, "Say") if say_change_choices != null else null + _check(say_change_drawer != null and say_change_drawer.visible and say_choice != null, "standalone Wait can be changed to Say from the same action drawer") + if say_choice != null: + await _click_control(say_choice) + await _wait_process_frames(16) + var replacement_speech_composer: Control = stage.get("_speech_composer") as Control + _check(replacement_speech_composer != null and replacement_speech_composer.visible and String(stage.get("_plan_action_id")) == "visual-options-move", "choosing Say opens the production speech composer for the existing action ID") + if replacement_speech_composer != null and replacement_speech_composer.visible: + await _save_capture_now("change_action_say_composer_open") + var replacement_speech_ready: bool = await _focus_speech_for_message(stage, "change_action_say_message_editor") + var replacement_text: TextEdit = replacement_speech_composer.find_child("ActiveBubbleText", true, false) as TextEdit + if replacement_speech_ready and replacement_text != null and replacement_text.has_focus(): + await _send_key(KEY_A, false, true) + await _type_text("Changed into speech!") + await _click_control(_find_button_with_text(replacement_speech_composer.find_child("MessageDialog", true, false), "✓ Done editing")) + await _wait_process_frames(3) + await _click_control(_find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✓ Done styling")) + await _wait_process_frames(3) + await _click_control(_find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✓ Done speech")) + await _wait_process_frames(6) + var changed_to_say_plan: Array[Dictionary] = character.get_action_plan() + var changed_to_say: Dictionary = (changed_to_say_plan[0]["actions"] as Array)[0] as Dictionary if not changed_to_say_plan.is_empty() else {} + _check(String(changed_to_say.get("type", "")) == "speak" and String(changed_to_say.get("action_id", "")) == "visual-options-move" and _first_speech_text(changed_to_say) == "Changed into speech!", "Done speech replaces Wait with Say, saves its text, and preserves the action ID") + + var grouped_move: Dictionary = move_action.duplicate(true) + var grouped_say: Dictionary = say_action.duplicate(true) + grouped_move["action_id"] = "visual-options-group-move" + grouped_say["action_id"] = "visual-options-group-say" + var grouped_plan: Array[Dictionary] = [{"actions": [grouped_move, grouped_say]}] + _check(character.set_action_plan(grouped_plan), "Together compatibility fixture contains Move plus Say") + stage.call("_set_scene_dirty", false) + character_view.refresh_plan() + await _wait_process_frames(8) + var grouped_move_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Walk to Place 1") + if grouped_move_summary != null: + await _click_control(grouped_move_summary) + await _wait_process_frames(8) + var grouped_move_options: Control = character_view.find_child("ActionOptions_visual-options-group-move", true, false) as Control + var grouped_change: Button = _find_button_with_text(grouped_move_options, "Change action") + _check(grouped_change != null, "Move in a Together block has a compatible Change action option") + if grouped_change != null: + await _wait_process_frames(8) + var inspector_for_grouped_move: StageInspectorPanel = character_view.get_card() as StageInspectorPanel + if inspector_for_grouped_move != null: + inspector_for_grouped_move.scroll_to_control(grouped_change) + await _wait_process_frames(8) + await _save_capture_now("together_move_action_options_open") + await _click_control(grouped_change) + await _wait_process_frames(16) + var grouped_drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + var grouped_choices: VBoxContainer = grouped_drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if grouped_drawer != null else null + _check(grouped_drawer != null and grouped_drawer.visible and grouped_choices != null and grouped_choices.get_child_count() == 1 and _find_button_containing(grouped_choices, "Wait") != null and _find_button_containing(grouped_choices, "Move") == null and _find_button_containing(grouped_choices, "Say") == null, "Move plus Say offers only Wait as a valid replacement") + await _save_capture_now("together_move_change_action_compatible_only") + var grouped_cancel: Button = _find_button_with_text(grouped_drawer, "Cancel") if grouped_drawer != null else null + if grouped_cancel != null: + await _click_control(grouped_cancel) + await _wait_process_frames(16) + var say_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Say") + _check(say_summary != null, "Say action summary is visible for comparison") + if say_summary == null: + return + var inspector: StageInspectorPanel = character_view.get_card() as StageInspectorPanel + if inspector != null: + inspector.scroll_to_control(say_summary) + await _wait_process_frames(8) + await _click_control(say_summary) + await _wait_process_frames(8) + var say_options: Control = character_view.find_child("ActionOptions_visual-options-group-say", true, false) as Control + if inspector != null and say_options != null: + inspector.scroll_to_control(say_options) + await _wait_process_frames(8) + var say_edit: Button = _find_button_with_text(say_options, "Edit action") + var say_erase: Button = _find_button_with_text(say_options, "Erase action") + var say_edit_rect: Rect2 = say_edit.get_global_rect() if say_edit != null else Rect2() + var say_erase_rect: Rect2 = say_erase.get_global_rect() if say_erase != null else Rect2() + var say_change: Button = _find_button_with_text(say_options, "Change action") + var say_layout_valid: bool = say_options is VBoxContainer and say_options.visible and say_edit != null and say_erase != null and say_change == null and is_equal_approx(say_edit_rect.size.x, say_erase_rect.size.x) and say_edit_rect.end.y <= say_erase_rect.position.y + _check(say_layout_valid and character_view.get_selected_step_index() == 0, "Say inside Move plus Say keeps Edit and Erase because no other type fits the Together block") + if say_layout_valid: + await _save_capture_now("together_say_has_no_change_option") + + var grouped_wait: Dictionary = {"action_id": "visual-options-group-wait", "type": "wait", "duration": 1.0} + var wait_say_plan: Array[Dictionary] = [{"actions": [grouped_wait, grouped_say]}] + _check(character.set_action_plan(wait_say_plan), "Together compatibility fixture contains Wait plus Say") + stage.call("_set_scene_dirty", false) + character_view.refresh_plan() + await _wait_process_frames(8) + var wait_summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Wait") + if wait_summary != null: + await _click_control(wait_summary) + await _wait_process_frames(8) + var wait_options: Control = character_view.find_child("ActionOptions_visual-options-group-wait", true, false) as Control + var wait_change: Button = _find_button_with_text(wait_options, "Change action") + _check(wait_change != null, "Wait in a Together block has a compatible Change action option") + if wait_change != null: + await _wait_process_frames(8) + var inspector_for_wait: StageInspectorPanel = character_view.get_card() as StageInspectorPanel + if inspector_for_wait != null: + inspector_for_wait.scroll_to_control(wait_change) + await _wait_process_frames(8) + await _click_control(wait_change) + await _wait_process_frames(16) + var wait_change_drawer: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + var wait_change_choices: VBoxContainer = wait_change_drawer.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if wait_change_drawer != null else null + _check(wait_change_drawer != null and wait_change_drawer.visible and wait_change_choices != null and wait_change_choices.get_child_count() == 1 and _find_button_containing(wait_change_choices, "Move") != null and _find_button_containing(wait_change_choices, "Wait") == null and _find_button_containing(wait_change_choices, "Say") == null, "Wait plus Say offers only Move as a valid replacement") + await _save_capture_now("together_wait_change_action_compatible_only") + var wait_drawer_cancel: Button = _find_button_with_text(wait_change_drawer, "Cancel") if wait_change_drawer != null else null + if wait_drawer_cancel != null: + await _click_control(wait_drawer_cancel) + await _wait_process_frames(16) + var say_summary_in_wait_group: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Say") + if say_summary_in_wait_group != null: + await _click_control(say_summary_in_wait_group) + await _wait_process_frames(8) + var say_options_in_wait_group: Control = character_view.find_child("ActionOptions_visual-options-group-say", true, false) as Control + _check(_find_button_with_text(say_options_in_wait_group, "Change action") == null, "Say in Wait plus Say also has no incompatible type replacement") + + +func _find_label_exact_text(parent: Node, expected: String) -> Label: + if parent == null: + return null + for child: Node in parent.find_children("*", "Label", true, false): + var label: Label = child as Label + if label != null and label.text == expected: + return label + return null + + +func _run_grouping_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, quick_row: Control) -> void: + var move_action: Dictionary = {"action_id": "visual-fixture-fetch-move", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(-20.0, 0.0)}} + var fixture_plan: Array[Dictionary] = [{"actions": [move_action]}] + _check(character.set_action_plan(fixture_plan), "visual-only fixture supplies one committed Walk step for Add together authoring") + character_view.refresh_plan() + if character.get_action_plan().is_empty(): + return + await _save_capture_now("phase20b_visual_fixture_move_step") + var step_card: Control = character_view.find_child("ActionStep_1", true, false) as Control + var step_heading: Label = _find_label_exact_text(step_card, "Step 1") if step_card != null else null + _check(step_heading != null and step_heading.name == "StepHeading" and step_heading.tooltip_text.is_empty() and step_heading.mouse_filter == Control.MOUSE_FILTER_PASS and step_heading.custom_minimum_size.y >= 44.0, "fixture Move shows plain Step 1 text with no tooltip") + _check(step_card != null and step_card.focus_mode == Control.FOCUS_ALL, "whole step card is keyboard-focusable") + if step_card == null or step_heading == null: + return + step_card.grab_focus() + await _send_key(KEY_ENTER) + await _wait_process_frames(3) + _check(character_view.get_selected_step_index() == 0 and step_card.has_focus(), "Enter activates the focused step card") + step_card = character_view.find_child("ActionStep_1", true, false) as Control + var together_button: Button = _find_button_with_text(step_card, "+ Add together") + await _click_control(together_button) + var popup: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + var helper: Label = popup.find_child("TogetherCompatibilityHint", true, false) as Label if popup != null else null + var choice_column: VBoxContainer = popup.find_child("TogetherChoiceButtons", true, false) as VBoxContainer if popup != null else null + var cancel: Button = _find_button_with_text(popup, "Cancel") if popup != null else null + var picker_checks_before: int = _failures + _check(popup != null and popup.visible and helper == null, "Add together chooser omits the compatibility helper copy") + var popup_title: Label = popup.find_child("TogetherChoiceTitle", true, false) as Label if popup != null else null + var source_context: Label = popup.find_child("TogetherSourceAction", true, false) as Label if popup != null else null + var popup_scroll: ScrollContainer = popup.find_child("TogetherChoiceScroll", true, false) as ScrollContainer if popup != null else null + var picker_rect: Rect2 = popup.get_global_rect() if popup != null else Rect2() + var trigger_rect: Rect2 = together_button.get_global_rect() if together_button != null else Rect2() + var pinned_quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control + print("PHASE20B_GUI_SLICE together_popup rect=%s trigger=%s custom=%s title=%s scroll=%s choices=%d cancel=%s focus=%s" % [str(picker_rect), str(trigger_rect), str(popup.custom_minimum_size) if popup != null else "missing", popup_title.text if popup_title != null else "missing", str(popup_scroll.get_global_rect()) if popup_scroll != null else "missing", choice_column.get_child_count() if choice_column != null else -1, str(cancel.size) if cancel != null else "missing", str(root.gui_get_focus_owner())]) + var selection: StageSelection = stage.get("_selection") as StageSelection + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "Add together interaction keeps the plan actor selected") + _check(choice_column != null and choice_column.get_child_count() == 1 and (choice_column.get_child(0) as Button).text.contains("Say") and cancel != null and cancel.visible and cancel.size.x >= 44.0 and cancel.size.y >= 44.0, "Move step offers one Say choice and a touch-sized visible Cancel") + _check(source_context != null and source_context.visible and source_context.text == "With: Walk to Place 1", "Add together chooser repeats the source action while covering the Step Stack row") + _check(popup_title != null and popup_title.text == "Add to Step 1" and popup != null and popup.size.y <= 288.0 and picker_rect.end.x <= root.size.x - 8.0 and picker_rect.end.y <= root.size.y - 38.0, "Add together chooser names its step and stays inside the safe viewport") + _check(together_button != null and popup != null and picker_rect.intersects(trigger_rect.grow(12.0)) and pinned_quick_row != null and not picker_rect.intersects(pinned_quick_row.get_global_rect()), "Add together chooser stays visually attached to its trigger without covering the pinned actions") + _check(root.gui_get_focus_owner() == choice_column.get_child(0), "keyboard focus moves to the compatible Say choice") + if _failures == picker_checks_before: + await _save_capture_now("phase20b_final_add_together_picker") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_add_together_picker reason=picker_state_assertions_failed") + return + await _click_control(choice_column.get_child(0) as Button) + await _wait_process_frames(3) + var composer: Control = stage.get("_speech_composer") as Control + _check(composer != null and composer.visible, "visible Say choice opens the production speech composer") + if composer == null or not composer.visible: + return + var style_ready: bool = await _focus_speech_for_message(stage, "phase20b_final_grouped_say_message_dialog") + var message_button: Button = _find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✏ Message") + _check(message_button != null and message_button.is_visible_in_tree() and message_button.get_global_rect().size.x > 0.0, "validation focus exposes the production Message control") + if not style_ready or message_button == null or not message_button.is_visible_in_tree() or message_button.get_global_rect().size.x <= 0.0: + await _click_control(_find_button_with_text(stage.get("_speech_show_toolbar") as Control, "Cancel speech")) + return + var text_edit: TextEdit = composer.find_child("ActiveBubbleText", true, false) as TextEdit if composer != null else null + _check(text_edit != null and text_edit.visible and text_edit.has_focus(), "production validation opens and focuses the speech text editor") + if text_edit != null and text_edit.has_focus(): + await _send_key(KEY_A, false, true) + await _type_text("Fetch the ball, Milo!") + _check(text_edit.text == "Fetch the ball, Milo!", "Message text is entered through the focused production TextEdit") + await _save_capture_now("phase20b_final_grouped_say_composer") + await _click_control(_find_button_with_text(composer.find_child("MessageDialog", true, false), "✓ Done editing")) + await _wait_process_frames(3) + await _click_control(_find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✓ Done styling")) + await _wait_process_frames(3) + var done: Button = _find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✓ Done speech") + await _click_control(done) + await _wait_process_frames(6) + var plan: Array[Dictionary] = character.get_action_plan() + var grouped_commit_valid: bool = plan.size() == 1 and (plan[0]["actions"] as Array).size() == 2 and _step_has_action_type(plan[0], "speak") + _check(grouped_commit_valid, "real Add together and Done speech interactions commit exactly one Say alongside the Move fixture") + if not grouped_commit_valid: + return + var say_action: Dictionary = _find_action_type(plan[0], "speak") + _check(_first_speech_text(say_action) == "Fetch the ball, Milo!", "committed grouped Say preserves the entered message") + character_view.refresh_plan() + var grouped_step_card: Control = character_view.find_child("ActionStep_1", true, false) as Control + var grouped_say_summary: Button = _find_button_containing(grouped_step_card, "Say") if grouped_step_card != null else null + _check(grouped_say_summary != null and grouped_say_summary.text.begins_with("Say ") and grouped_say_summary.tooltip_text.contains("Fetch the ball, Milo!") and grouped_say_summary.tooltip_text.contains("seconds)"), "Say action keeps a concise control label and exposes the full message and duration by tooltip") + var say_id: String = String(say_action.get("action_id", "")) + var say_row_text: Label = character_view.find_child("ActionSummaryText_%s" % say_id, true, false) as Label + var say_duration: Label = character_view.find_child("ActionSummaryDuration_%s" % say_id, true, false) as Label + var say_rail: Control = character_view.find_child("ActionAccentRail_%s" % say_id, true, false) as Control + var say_row_options: Label = character_view.find_child("ActionSummaryOptions_%s" % say_id, true, false) as Label + var say_row_style: StyleBoxFlat = grouped_say_summary.get_theme_stylebox("normal") as StyleBoxFlat if grouped_say_summary != null else null + _check(say_row_text != null and say_row_text.text == "Say “Fetch the ball, Milo!”" and say_duration != null and say_duration.text.ends_with("s"), "Say row separates its spoken text and secondary duration like the mockup") + _check(say_rail != null and say_rail.custom_minimum_size.x == 4.0 and say_row_options != null and say_row_options.text == "..." and grouped_say_summary.custom_minimum_size.y >= 48.0, "Say row uses the same 48 px twin-rail layout and options cue as Move") + _check(say_row_style != null and say_row_style.bg_color.is_equal_approx(Color("#2C281C")), "Say row uses the matching warm tint from the approved mockup") + var projections: Array[Dictionary] = (stage.get("_director_visuals") as StageDirectorVisuals)._plan_projection(character) + var move_projection: Dictionary = _projection_for_action(projections, String(move_action.get("action_id", ""))) + var say_projection: Dictionary = _projection_for_action(projections, String(say_action.get("action_id", ""))) + var grouped_projection_valid: bool = say_projection.get("kind", "") == "say" and move_projection.get("kind", "") == "move" and say_projection.get("start", Vector2.INF) == move_projection.get("start", Vector2.ZERO) + _check(grouped_projection_valid, "grouped Say marker projects once at the same starting point as Move") + if not grouped_projection_valid: + return + await _wait_for_status_toast_clear(stage) + await _wait_process_frames(2) + await RenderingServer.frame_post_draw + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + var marker_image: Image = root.get_texture().get_image() + var say_marker_visible: bool = _say_marker_has_rendered_pixels(visuals, say_projection, marker_image) + if say_marker_visible: + await _save_capture_now("phase20b_final_walk_say_committed") + else: + await _save_capture_now("phase20b_diagnostics_say_marker") + _check(say_marker_visible, "grouped Say marker is visibly rendered at the projected Step 1 start") + await _click_control(_find_button_with_text(quick_row, "⏳ Wait")) + await _wait_process_frames(2) + var wait_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + var wait_done_button: Button = _find_button_with_text(wait_editor, "Done") if wait_editor != null else null + var wait_editor_rect: Rect2 = Rect2(Vector2(wait_editor.position), Vector2(wait_editor.size)) if wait_editor != null else Rect2() + var wait_done_rect: Rect2 = wait_done_button.get_global_rect() if wait_done_button != null else Rect2() + var wait_editor_local_rect: Rect2 = Rect2(Vector2.ZERO, Vector2(wait_editor.size)) if wait_editor != null else Rect2() + _check(wait_editor != null and wait_done_button != null and wait_editor_rect.position.x >= 0.0 and wait_editor_rect.position.y >= 0.0 and wait_editor_rect.end.x <= root.size.x and wait_editor_rect.end.y <= root.size.y and wait_editor_local_rect.encloses(wait_done_rect), "Wait dialog and Done footer remain inside the production popup and viewport") + if wait_editor != null and wait_done_button != null: + await _save_capture_now("phase20b_wait_dialog_footer_visible") + await _click_control(wait_done_button) + await _wait_process_frames(4) + plan = character.get_action_plan() + _check(plan.size() == 2 and _step_has_action_type(plan[1], "wait"), "quick Wait appends a separate second step after the committed Walk+Say") + character_view.refresh_plan() + var wait_step_card: Control = character_view.find_child("ActionStep_2", true, false) as Control + var wait_summary: Button = _find_button_containing(wait_step_card, "Wait") if wait_step_card != null else null + _check(wait_summary != null and wait_summary.text == "Wait 1 second", "one-second Wait summary uses singular grammar") + var wait_id: String = String(_find_action_type(plan[1], "wait").get("action_id", "")) if plan.size() > 1 else "" + var wait_options: Label = character_view.find_child("ActionSummaryOptions_%s" % wait_id, true, false) as Label + _check(wait_options != null and wait_options.text == "...", "Step 2 uses the same trailing action-options cue as Step 1") + await _save_capture_now("phase20b_final_walk_say_then_wait") + _check(bool(stage.get("_dirty")), "valid production commits mark the scene dirty") + + +func _run_capture_entry_slice(stage: SandboxStage, quick_row: Control) -> void: + await _click_control(_find_button_with_text(quick_row, "🚶 Move")) + var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + var choose_place: Button = _find_button_containing(action_editor, "Choose a place") + _check(action_editor != null and action_editor.visible and choose_place != null, "Quick Move opens its production draft before place capture") + if choose_place == null: + return + if action_editor.visible and choose_place.is_visible_in_tree(): + await _save_capture_now("action_editor_choose_place_before_click") + else: + print("PHASE20B_CAPTURE_SKIPPED name=action_editor_choose_place_before_click reason=draft_or_choice_not_visible") + _capture_after_next_click = true + _trace_next_click = true + await _click_control(choose_place) + _check(int(stage.get("_capture_kind")) == PLAN_PLACE and not action_editor.visible, "Choose a place activates capture and closes the production draft") + var capture_rig: StickmanRig = stage.get("_action_editor_rig") as StickmanRig + _check(capture_rig != null and capture_rig.get_action_plan().is_empty(), "entering place capture leaves the Move draft uncommitted until Done") + var bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar + _check(bar != null and bar.visible, "place capture prompt is rendered after the production click") + + +func _run_say_slice(stage: SandboxStage, character: StickmanRig, _character_view: StageCharacterSettingsView, quick_row: Control) -> void: + await _click_control(_find_button_with_text(quick_row, "💬 Say")) + await _wait_process_frames(4) + var composer: Control = stage.get("_speech_composer") as Control + _check(composer != null and composer.visible, "quick Say opens the production speech composer") + if composer != null and composer.visible: + await _save_capture_now("phase20b_final_standalone_say_toolbar") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_standalone_say_toolbar reason=composer_not_open") + var style_ready: bool = await _focus_speech_for_message(stage, "phase20b_final_standalone_say_message_dialog") + var message_button: Button = _find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✏ Message") + _check(message_button != null and message_button.is_visible_in_tree() and message_button.get_global_rect().size.x > 0.0, "focused speech preview exposes the production Message control") + if not style_ready or message_button == null or not message_button.is_visible_in_tree() or message_button.get_global_rect().size.x <= 0.0: + await _click_control(_find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✕ Cancel speech")) + return + var text_edit: TextEdit = composer.find_child("ActiveBubbleText", true, false) as TextEdit if composer != null else null + var message_dialog: Control = composer.find_child("MessageDialog", true, false) as Control if composer != null else null + _check(message_button != null and message_dialog != null and message_dialog.visible and text_edit != null and text_edit.visible, "production validation opens the message editor for the empty Say bubble") + if text_edit != null and text_edit.visible and not text_edit.has_focus(): + await _click_control(text_edit) + await _wait_process_frames(2) + _check(text_edit != null and text_edit.visible and text_edit.has_focus(), "production Message editor accepts pointer focus") + if text_edit != null and text_edit.has_focus(): + await _send_key(KEY_A, false, true) + await _type_text("Hello from the stage!") + await _click_control(_find_button_with_text(composer.find_child("MessageDialog", true, false), "✓ Done editing")) + await _wait_process_frames(3) + await _click_control(_find_button_with_text(stage.get("_speech_style_toolbar") as Control, "✓ Done styling")) + await _wait_process_frames(3) + var done: Button = _find_button_with_text(stage.get("_speech_show_toolbar") as Control, "✓ Done speech") + _check(done != null and done.is_visible_in_tree() and done.get_global_rect().size.x > 0.0, "speech action exposes its labeled Done speech commit after styling closes") + if done != null: + await _click_control(done) + await _wait_process_frames(6) + var plan: Array[Dictionary] = character.get_action_plan() + _check(plan.size() == 1 and _step_has_action_type(plan[0], "speak"), "quick Say commits as its own first plan step through the production composer") + if plan.is_empty(): + return + var say_action: Dictionary = _find_action_type(plan[0], "speak") + _check(_first_speech_text(say_action) == "Hello from the stage!", "standalone Say retains the committed message") + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + var say_projection: Dictionary = _projection_for_action(visuals._plan_projection(character), String(say_action.get("action_id", ""))) + _check(say_projection.get("kind", "") == "say", "standalone Say has one projected preview marker") + await _wait_for_status_toast_clear(stage) + await _wait_process_frames(2) + await RenderingServer.frame_post_draw + var marker_image: Image = root.get_texture().get_image() + var say_marker_visible: bool = _say_marker_has_rendered_pixels(visuals, say_projection, marker_image) + if say_marker_visible: + await _save_capture_now("phase20b_final_standalone_say_committed") + else: + await _save_capture_now("phase20b_diagnostics_standalone_say_marker") + _check(say_marker_visible, "standalone Say marker is visibly rendered at the Step 1 start") + + +func _focus_speech_for_message(stage: SandboxStage, capture_name: String) -> bool: + var preview: SpeechBubble = stage.get("_speech_preview") as SpeechBubble + _check(preview != null and preview.visible, "speech draft exposes its live bubble preview") + if preview == null or not preview.visible: + return false + var composer: SpeechComposer = stage.get("_speech_composer") as SpeechComposer + var show_toolbar: Control = stage.get("_speech_show_toolbar") as Control + var done_speech: Button = _find_button_with_text(show_toolbar, "✓ Done speech") + var validation_events: Array[Dictionary] = [] + if composer != null: + composer.validation_failed.connect(func(message: String, bubble_index: int) -> void: validation_events.append({"message": message, "bubble_index": bubble_index}), CONNECT_ONE_SHOT) + _check(done_speech != null and done_speech.is_visible_in_tree(), "speech draft exposes its labeled Done speech control") + if done_speech == null or not done_speech.is_visible_in_tree(): + return false + await _click_control(done_speech) + await _wait_process_frames(4) + var style_toolbar: Control = stage.get("_speech_style_toolbar") as Control + var message_button: Button = _find_button_with_text(style_toolbar, "✏ Message") + var focus_index: int = int(composer.call("stage_focus_bubble_index")) if composer != null else -1 + var controls_ready: bool = focus_index == 0 and message_button != null and message_button.is_visible_in_tree() and message_button.get_global_rect().size.x > 0.0 and message_button.get_global_rect().size.y > 0.0 + var message_dialog: Control = composer.find_child("MessageDialog", true, false) as Control if composer != null else null + var text_edit: TextEdit = composer.find_child("ActiveBubbleText", true, false) as TextEdit if composer != null else null + print("PHASE20B_GUI_SPEECH_VALIDATION focus=%d events=%s message_rect=%s" % [focus_index, str(validation_events), str(message_button.get_global_rect()) if message_button != null else "missing"]) + _check(validation_events.size() == 1 and int(validation_events[0].get("bubble_index", -1)) == 0, "Done speech validation guides the author to the first empty bubble") + _check(controls_ready, "validation focus exposes the production Message control for the bubble") + _check(composer != null and bool(composer.call("is_message_dialog_open")) and message_dialog != null and message_dialog.visible and text_edit != null and text_edit.visible and text_edit.has_focus(), "production validation opens and focuses the bubble's Message editor") + if controls_ready: + await _save_capture_now(capture_name) + return controls_ready + + +func _run_flag_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, card: StageInspectorPanel) -> void: + var quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control + var move: Dictionary = {"action_id": "gui-flag-move", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(-20.0, 0.0)}} + var wait_action: Dictionary = {"action_id": "gui-flag-wait", "type": "wait", "duration": 0.5} + var fixture_plan: Array[Dictionary] = [{"actions": [move]}, {"actions": [wait_action]}] + _check(character.set_action_plan(fixture_plan), "visual-only flag fixture seeds ordered Move and Wait steps through the rig plan API") + character_view.refresh_plan() + var move_id: String = String(move.get("action_id", "")) + stage.call("_set_scene_dirty", false) + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + var projection: Dictionary = {} + for entry: Dictionary in visuals._plan_projection(character): + if String(entry.get("action_id", "")) == move_id: + projection = entry + break + var hit_rect: Rect2 = projection.get("hit_rect", Rect2()) + var screen_hit_size: Vector2 = hit_rect.size * visuals._zoom() + _check(String(projection.get("label", "")) == "Place 1" and screen_hit_size.x >= 44.0 and screen_hit_size.y >= 44.0, "projection carries Place 1 and a 44×44 screen hit rect") + var place_button: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Place 1") + _check(place_button != null and not place_button.text.contains("(-"), "action row uses Place 1 without exposing raw coordinates") + await _send_click(_screen_at(stage, Vector2(-20.0, 0.0))) + await _wait_process_frames(2) + var options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control + _check(options != null and options.visible and not bool(stage.get("_dirty")), "production route flag click selects and opens options without dirtying the scene") + var card_rect: Rect2 = card.get_global_rect() + _check(card_rect.position.x >= 0.0 and card_rect.end.x <= root.size.x and quick_row != null and quick_row.visible and _find_button_with_text(quick_row, "⏳ Wait") != null, "expanded action inspector retains its shell and pinned actions") + var later: Button = _find_button_with_tooltip(character_view.find_child("ActionStep_1", true, false), "Move Step 1 later") + var earlier: Button = _find_button_with_tooltip(character_view.find_child("ActionStep_1", true, false), "Move Step 1 earlier") + _check(later != null and later.text == "↓" and later.size.x >= 48.0 and later.size.y >= 48.0 and earlier != null and earlier.text == "↑" and earlier.size.x >= 48.0 and earlier.size.y >= 48.0, "reorder controls use touch-sized directional arrows with descriptive tooltips") + await _save_capture_now("phase20b_final_flag_options") + _check(not bool(stage.get("_dirty")), "flag selection and opening its options do not dirty the scene") + character_view.call("_select_plan_step", 1) + if earlier != null: + await _click_control(earlier) + await _wait_process_frames(2) + var first_step_style: StyleBoxFlat = (character_view.find_child("ActionStep_1", true, false) as Control).get_theme_stylebox("panel") as StyleBoxFlat + _check(character_view.get_selected_step_index() == 0 and first_step_style.border_color.is_equal_approx(Color("#FFB300")), "clicking the disabled boundary arrow still selects its card") + if later != null: + await _click_control(later) + await _wait_process_frames(2) + _check(String(((character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == String(wait_action.get("action_id", "")), "real down arrow reorders only the future step") + var moved_move_card: Control = character_view.find_child("ActionStep_2", true, false) as Control + var restore_move_earlier: Button = _find_button_with_tooltip(moved_move_card, "Move Step 2 earlier") if moved_move_card != null else null + _check(restore_move_earlier != null and restore_move_earlier.text == "↑" and not restore_move_earlier.disabled, "reordered Move exposes an enabled up arrow on Step 2") + await _click_control(restore_move_earlier) + await _wait_process_frames(2) + _check(String(((character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == move_id, "real up arrow restores the initial order") + + +func _run_step_spotlight_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView) -> void: + var first_move: Dictionary = { + "action_id": "spotlight-milo-step-1", + "type": "move_to", + "style": "walk", + "destination": {"kind": "place", "position": Vector2(80.0, 0.0)}, + } + var first_say: Dictionary = {"action_id": "spotlight-milo-say", "type": "speak", "text": "Ready!", "duration": 1.0} + var second_move: Dictionary = { + "action_id": "spotlight-milo-step-2", + "type": "move_to", + "style": "run", + "destination": {"kind": "place", "position": Vector2(-300.0, -100.0)}, + } + var fixture_plan: Array[Dictionary] = [ + {"actions": [first_move, first_say]}, + {"actions": [second_move]}, + ] + _check(character.set_action_plan(fixture_plan), "visual fixture seeds a grouped Walk+Say step followed by a Run step") + character_view.refresh_plan() + var spawner: StageSpawner = stage.get("_spawner") as StageSpawner + var ava: StickmanRig = spawner.spawn_stickman_asset("res://content/stickmen/break.stk", Vector2(420.0, -170.0)) + _check(ava != null, "production spawner creates a second actor for Step Spotlight visibility checks") + if ava != null: + stage.call("_finalize_placed_node", ava) + var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity + var renamed: Dictionary = identity.rename(ava, "Ava", stage.get_node("World")) + _check(bool(renamed.get("ok", false)), "second actor receives its distinct stage name through production identity") + var ava_move: Dictionary = { + "action_id": "spotlight-ava-step-1", + "type": "move_to", + "style": "run", + "destination": {"kind": "place", "position": Vector2(500.0, 100.0)}, + } + _check(ava.set_action_plan([{"actions": [ava_move]}]), "second actor receives its own visual-only Run route") + var selection: StageSelection = stage.get("_selection") as StageSelection + if selection != null and (selection.get_selected().size() != 1 or selection.get_selected()[0] != character): + selection.select_only(character) + character_view.refresh_plan() + stage.call("_set_scene_dirty", false) + await _wait_process_frames(4) + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + _check(character_view.get_selected_step_index() == 0 and visuals.get("_plan_spotlight_rig") == character and int(visuals.get("_plan_spotlight_step_index")) == 0, "DIRECT defaults Step Spotlight to the selected actor's first pending step") + _check(visuals.spotlight_route_width_px >= 4.0 and visuals.spotlight_route_shadow_width_px >= 6.0 and visuals.spotlight_dash_length_px >= 12.0 and visuals.spotlight_dash_gap_px >= 7.0 and visuals.spotlight_route_alpha >= 0.9, "theme loads the thicker, brighter route and contrast edge") + _check(visuals.action_badge_font_size_px >= 18.0 and visuals.action_badge_radius_px >= 10.0 and visuals.actor_name_font_size_px >= 20.0 and visuals.actor_name_radius_px >= 12.0 and visuals.actor_name_offset_px >= 24.0, "theme loads prominent rounded action badges and clear headwear clearance") + var name_layouts: Array = visuals.get("_last_actor_name_badge_layouts") + _check(name_layouts.size() == 1 and (name_layouts[0] as Dictionary).get("rig") == character, "DIRECT Step Spotlight draws the selected actor's name badge") + if name_layouts.size() == 1: + var name_layout: Dictionary = name_layouts[0] + var name_rect: Rect2 = name_layout.get("screen_rect", Rect2()) + var head_rect: Rect2 = name_layout.get("head_bounds_screen", Rect2()) + _check(String(name_layout.get("side", "")) == "above" and not name_rect.intersects(head_rect) and head_rect.position.y - name_rect.end.y >= 24.0, "DIRECT name badge stays above visible headwear with at least 24 screen pixels of clearance") + await _save_capture_now("phase20b_direct_name_badge_clearance") + var ava_target: Vector2 = Vector2(500.0, 100.0) + _check(ava != null and visuals.hit_test_plan_action(ava_target).is_empty(), "unselected actors' hidden routes do not retain invisible hit targets") + await _save_capture_now("phase20b_step_spotlight_step1_walk_say") + var step_two_card: Control = character_view.find_child("ActionStep_2", true, false) as Control + var step_two_action: Button = _find_button_with_text(step_two_card, "Run to Place 2") + var step_one_card: Control = character_view.find_child("ActionStep_1", true, false) as Control + var step_one: Label = _find_label_exact_text(step_one_card, "Step 1") + _check(step_two_action != null and step_one != null and step_one.tooltip_text.is_empty(), "action summary and plain Step 1 title are selectable without a redundant tooltip") + if step_two_action == null or step_one == null or step_one_card == null: + return + var heading_rect_before_hover: Rect2 = step_one.get_global_rect() + await _send_mouse_motion(step_one.get_screen_position() + step_one.size * 0.5) + await _wait_process_frames(3) + var heading_rect_after_hover: Rect2 = step_one.get_global_rect() + _check(heading_rect_before_hover.position.is_equal_approx(heading_rect_after_hover.position) and heading_rect_before_hover.size.is_equal_approx(heading_rect_after_hover.size), "hovering the plain step title does not shift or resize it") + var second_card_rect: Rect2 = step_two_card.get_global_rect() + await _send_click(Vector2(second_card_rect.position.x + 5.0, second_card_rect.get_center().y)) + await _wait_process_frames(2) + var second_card_style: StyleBoxFlat = step_two_card.get_theme_stylebox("panel") as StyleBoxFlat + _check(character_view.get_selected_step_index() == 1 and second_card_style != null and second_card_style.border_color.is_equal_approx(Color("#FFB300")), "clicking blank Step 2 card space selects it with the amber border") + var step_two_add_together: Button = _find_button_with_text(step_two_card, "+ Add together") + character_view.call("_select_plan_step", 0) + var inspector: StageInspectorPanel = character_view.get_card() as StageInspectorPanel + if inspector != null and step_two_add_together != null: + inspector.scroll_to_control(step_two_add_together) + await _wait_process_frames(3) + await _click_control(step_two_add_together) + var together_popup: Panel = character_view.find_child("AddTogetherChoices", true, false) as Panel + _check(character_view.get_selected_step_index() == 1 and together_popup != null and together_popup.visible, "Add together selects its parent step while opening the choice picker") + var together_cancel: Button = _find_button_with_text(together_popup, "Cancel") if together_popup != null else null + await _click_control(together_cancel) + await _wait_process_frames(2) + await _click_control(step_two_action) + await _wait_process_frames(3) + var second_options: Control = character_view.find_child("ActionOptions_%s" % second_move.get("action_id", ""), true, false) as Control + var expanded_summary: Button = character_view.find_child("ActionSummary_%s" % second_move.get("action_id", ""), true, false) as Button + var expanded_style: StyleBoxFlat = expanded_summary.get_theme_stylebox("normal") as StyleBoxFlat if expanded_summary != null else null + _check(second_options != null and second_options.visible and expanded_style != null and expanded_style.border_color.is_equal_approx(Color("#FFB300")), "selecting Step 2 action opens its options and amber action highlight") + var step_one_heading: Label = _find_label_exact_text(character_view.find_child("ActionStep_1", true, false), "Step 1") + if inspector != null and step_one_heading != null: + inspector.scroll_to_control(step_one_heading) + await _wait_process_frames(3) + step_one_heading = _find_label_exact_text(character_view.find_child("ActionStep_1", true, false), "Step 1") + await _click_control(step_one_heading) + await _wait_process_frames(3) + var closed_options: Control = character_view.find_child("ActionOptions_%s" % second_move.get("action_id", ""), true, false) as Control + var collapsed_summary: Button = character_view.find_child("ActionSummary_%s" % second_move.get("action_id", ""), true, false) as Button + var collapsed_style: StyleBoxFlat = collapsed_summary.get_theme_stylebox("normal") as StyleBoxFlat if collapsed_summary != null else null + var selected_step_style: StyleBoxFlat = (character_view.find_child("ActionStep_1", true, false) as Control).get_theme_stylebox("panel") as StyleBoxFlat + step_one_heading = _find_label_exact_text(character_view.find_child("ActionStep_1", true, false), "Step 1") + _check(character_view.get_selected_step_index() == 0 and String(character_view.get("_expanded_action_id")) == "" and closed_options != null and not closed_options.visible, "selecting Step 1 heading closes the previously expanded Step 2 action") + _check(collapsed_style != null and not collapsed_style.border_color.is_equal_approx(Color("#FFB300")) and selected_step_style != null and selected_step_style.border_color.is_equal_approx(Color("#FFB300")), "step selection clears the old action highlight and leaves the amber border on its card") + _check(step_one_heading != null and step_one_heading.name == "StepHeading" and step_one_heading.tooltip_text.is_empty(), "selected step title remains plain text without a tooltip") + await _save_capture_now("phase20b_step_spotlight_step1_heading_selected") + var step_two: Label = _find_label_exact_text(character_view.find_child("ActionStep_2", true, false), "Step 2") + await _click_control(step_two) + await _wait_process_frames(3) + _check(character_view.get_selected_step_index() == 1 and int(visuals.get("_plan_spotlight_step_index")) == 1, "clicking the Step 2 heading switches the Step Spotlight") + var ghost_hit: Dictionary = visuals.hit_test_plan_action(Vector2(80.0, 0.0)) + _check(String(ghost_hit.get("action_id", "")) == String(first_move.get("action_id", "")) and int(ghost_hit.get("step_index", -1)) == 0, "faint prior-step flag keeps its action-ID hit target") + await _save_capture_now("phase20b_step_spotlight_step2_run_ghost_flag") + await _send_click(_screen_at(stage, Vector2(80.0, 0.0))) + await _wait_process_frames(3) + _check(character_view.get_selected_step_index() == 0 and int(visuals.get("_plan_spotlight_step_index")) == 0, "clicking a ghost flag returns focus to its authored step") + _check(not bool(stage.get("_dirty")), "switching Step Spotlight by card or flag does not dirty the scene") + await _save_capture_now("phase20b_step_spotlight_ghost_flag_focus") + var original_character_position: Vector2 = character.global_position + var head_geometry: Dictionary = character.speech_head_geometry() + var edge_head_center: Vector2 = character.to_global(head_geometry.get("center", Vector2.ZERO)) + var edge_head_radius: float = float(head_geometry.get("radius", 100.0)) * visuals._zoom() + var initial_head_top: float = (stage.get_viewport().get_canvas_transform() * edge_head_center).y - edge_head_radius + character.global_position.y += (84.0 - initial_head_top) / visuals._zoom() + visuals.mark_dirty() + await _wait_process_frames(4) + name_layouts = visuals.get("_last_actor_name_badge_layouts") + if name_layouts.size() == 1: + var edge_layout: Dictionary = name_layouts[0] + var edge_badge: Rect2 = edge_layout.get("screen_rect", Rect2()) + var edge_head: Rect2 = edge_layout.get("head_bounds_screen", Rect2()) + var safe_rect: Rect2 = Rect2(Vector2(8.0, 72.0), Vector2(float(root.size.x) - 16.0, float(root.size.y) - 112.0)) + _check(String(edge_layout.get("side", "")) in ["left", "right"] and not edge_badge.intersects(edge_head), "DIRECT name badge moves beside a head near the top toolbar") + _check(safe_rect.encloses(edge_badge), "top-edge name badge stays inside the safe viewport") + else: + _check(false, "top-edge DIRECT state retains one actor-name badge layout") + await _save_capture_now("phase20b_direct_name_badge_top_edge") + character.global_position = original_character_position + visuals.mark_dirty() + await _wait_process_frames(3) + + +func _run_busy_scene_badge_stability(stage: SandboxStage) -> void: + var loaded: bool = bool(stage.call("_load_scene_from_path", "res://content/stages/busy.scene")) + _check(loaded, "production scene loader opens the user's busy.scene") + if not loaded: + return + await _wait_frames(12) + if int(stage.get("current_mode")) != SandboxStage.StageMode.EDIT: + await _click_control(_find_button_containing(stage, "Edit")) + var world: Node2D = stage.get_node("World") as Node2D + var target: StickmanRig + for child: Node in world.get_children(): + if child is StickmanRig and String((child as StickmanRig).stage_name) == "break 2": + target = child as StickmanRig + break + _check(target != null, "busy.scene contains the named stickman shown in the user's report") + if target == null: + return + var camera: Camera2D = stage.get("_camera") as Camera2D + # Center only the test camera so the full production drag and badge remain in + # view; the scene itself is never saved or modified by this visual harness. + camera.position = target.global_position + var selection: StageSelection = stage.get("_selection") as StageSelection + var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos + var actor_screen: Vector2 = _screen_at(stage, target.global_position) + _check(Rect2(Vector2.ZERO, Vector2(root.size)).has_point(actor_screen), "reported stickman is visible in the rendered stage viewport") + await _send_click(actor_screen) + await _wait_frames(4) + var layouts: Array = gizmos.get("_last_selected_name_badge_layouts") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == target, "production click selects break 2 in EDIT") + _check(layouts.size() == 1, "selected break 2 has one visible EDIT name badge") + if layouts.size() != 1: + return + var initial_slot: int = int((layouts[0] as Dictionary).get("anchor_slot", -1)) + _check(initial_slot >= 0, "selected EDIT badge has a latched anchor slot") + await _save_capture_now("busy_scene_badge_selected") + + # Move the actor through the production stage drag path and verify the badge + # stays on its chosen side both during the gesture and after release. + var initial_world_position: Vector2 = target.global_position + var drag_origin: Vector2 = _screen_at(stage, initial_world_position) + await _send_mouse_button(drag_origin, true, root.get_window_id(), drag_origin) + for delta: Vector2 in [Vector2(-16.0, 8.0), Vector2(-34.0, 14.0), Vector2(-52.0, 18.0)]: + var drag_position: Vector2 = drag_origin + delta + _send_mouse_motion(drag_position, root.get_window_id(), drag_position, MOUSE_BUTTON_MASK_LEFT) + await _wait_process_frames(2) + layouts = gizmos.get("_last_selected_name_badge_layouts") + _check(layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge keeps its anchor while the busy.scene actor moves") + var drag_end: Vector2 = drag_origin + Vector2(-52.0, 18.0) + await _send_mouse_button(drag_end, false, root.get_window_id(), drag_end) + await _wait_frames(4) + layouts = gizmos.get("_last_selected_name_badge_layouts") + _check(gizmos.is_dragging() == false and layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge keeps its anchor after releasing the actor") + _check(not target.global_position.is_equal_approx(initial_world_position), "production drag actually moves the busy.scene actor") + await _save_capture_now("busy_scene_badge_after_move_release") + + # Exercise layout recomputation at another camera zoom while keeping the + # selected actor centered, then use a real middle-button drag to pan. + var original_zoom: Vector2 = camera.zoom + camera.zoom = original_zoom * 1.1 + await _wait_frames(4) + layouts = gizmos.get("_last_selected_name_badge_layouts") + _check(layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge anchor survives camera zoom") + _check(Rect2(Vector2.ZERO, Vector2(root.size)).has_point(_screen_at(stage, target.global_position)), "zoomed busy.scene actor stays in view") + camera.zoom = original_zoom + await _wait_frames(4) + await _save_capture_now("busy_scene_badge_after_zoom") + + var camera_before_pan: Vector2 = camera.position + var pan_origin: Vector2 = Vector2(float(root.size.x) * 0.48, float(root.size.y) * 0.55) + var pan_end: Vector2 = pan_origin + Vector2(32.0, 16.0) + await _send_middle_button(pan_origin, true) + _send_mouse_motion(pan_end, root.get_window_id(), pan_end, MOUSE_BUTTON_MASK_MIDDLE) + await _wait_process_frames(3) + await _send_middle_button(pan_end, false) + await _wait_frames(4) + layouts = gizmos.get("_last_selected_name_badge_layouts") + _check(not camera.position.is_equal_approx(camera_before_pan), "middle-button drag pans the production Stage camera") + _check(layouts.size() == 1 and int((layouts[0] as Dictionary).get("anchor_slot", -2)) == initial_slot, "EDIT name badge keeps its anchor after camera pan") + _check(Rect2(Vector2.ZERO, Vector2(root.size)).has_point(_screen_at(stage, target.global_position)), "panned busy.scene actor stays in view") + await _save_capture_now("busy_scene_badge_after_pan") + + +func _run_mode_overlay_and_names_slice(stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView) -> void: + var move_action: Dictionary = { + "action_id": "edit-name-badge-move", + "type": "move_to", + "style": "walk", + "destination": {"kind": "place", "position": Vector2(260.0, -100.0)}, + } + _check(character.set_action_plan([{"actions": [move_action]}]), "visual fixture seeds an authored Move route for mode visibility checks") + character_view.refresh_plan() + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + var gizmos: StageGizmos = stage.get("_gizmos") as StageGizmos + var selection: StageSelection = stage.get("_selection") as StageSelection + stage.call("_set_scene_dirty", false) + var edit_button: Button = _find_button_containing(stage, "Edit") + _check(edit_button != null, "EDIT mode control is available for the visibility slice") + if edit_button == null: + return + await _click_control(edit_button) + await _wait_frames(4) + _check(int(stage.get("current_mode")) == SandboxStage.StageMode.EDIT, "pointer activation switches from DIRECT to EDIT") + _check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "EDIT hides plan overlays and their hit targets when nothing is selected") + await _save_capture_now("phase20b_edit_empty_plan_hidden") + + await _send_click(_screen_at(stage, character.global_position)) + await _wait_frames(4) + var single_layouts: Array = gizmos.get("_last_selected_name_badge_layouts") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "production stage click selects the named stickman in EDIT") + _check(single_layouts.size() == 1 and (single_layouts[0] as Dictionary).get("node") == character, "one selected stickman receives one stage-name badge") + _check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "selected EDIT actor shows its name badge without revealing its plan route") + if single_layouts.size() == 1: + var character_badge: Dictionary = single_layouts[0] + var badge_rect: Rect2 = character_badge.get("screen_rect", Rect2()) + var actor_bounds: Rect2 = character_badge.get("bounds_screen", Rect2()) + _check(String(character_badge.get("stage_name", "")) == character.stage_name and String(character_badge.get("display_name", "")).ends_with("…"), "long actor stage name is elided while retaining its full source value") + _check(not badge_rect.intersects(actor_bounds), "actor badge stays clear of the actor's visible bounds") + await _save_capture_now("phase20b_edit_single_name_badge") + selection.clear_selection() + await _wait_frames(3) + var drag_origin: Vector2 = _screen_at(stage, character.global_position) + var drag_origin_world: Vector2 = character.global_position + await _send_mouse_button(drag_origin, true, root.get_window_id(), drag_origin) + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character and gizmos.is_dragging(), "pressing an unselected actor selects it and begins the production translate gesture") + await _wait_frames(2) + var drag_layouts: Array = gizmos.get("_last_selected_name_badge_layouts") + var drag_offsets_stable: bool = drag_layouts.size() == 1 + var initial_badge_offset: Vector2 = Vector2.INF + if drag_layouts.size() == 1: + var initial_badge_layout: Dictionary = drag_layouts[0] + var initial_badge_rect: Rect2 = initial_badge_layout.get("screen_rect", Rect2()) + var initial_actor_bounds: Rect2 = initial_badge_layout.get("bounds_screen", Rect2()) + initial_badge_offset = initial_badge_rect.position - initial_actor_bounds.position + for drag_delta: Vector2 in [Vector2(-24.0, 8.0), Vector2(-52.0, 16.0), Vector2(-80.0, 4.0), Vector2(-44.0, -8.0)]: + var drag_position: Vector2 = drag_origin + drag_delta + _send_mouse_motion(drag_position, root.get_window_id(), drag_position, MOUSE_BUTTON_MASK_LEFT) + await _wait_frames(2) + drag_layouts = gizmos.get("_last_selected_name_badge_layouts") + if drag_layouts.size() != 1: + drag_offsets_stable = false + continue + var drag_layout: Dictionary = drag_layouts[0] + var current_badge_rect: Rect2 = drag_layout.get("screen_rect", Rect2()) + var current_actor_bounds: Rect2 = drag_layout.get("bounds_screen", Rect2()) + var current_offset: Vector2 = current_badge_rect.position - current_actor_bounds.position + if not current_offset.is_equal_approx(initial_badge_offset): + drag_offsets_stable = false + _check(gizmos.is_dragging() and not character.global_position.is_equal_approx(drag_origin_world), "production pointer motion moves the actor while the gesture stays active") + _check(drag_offsets_stable, "EDIT name badge keeps its starting side and offset throughout the move") + await _save_capture_now("phase20b_edit_drag_name_badge_stable") + var drag_end: Vector2 = drag_origin + Vector2(-44.0, -8.0) + await _send_mouse_button(drag_end, false, root.get_window_id(), drag_end) + await _wait_frames(3) + _check(not gizmos.is_dragging(), "releasing the pointer ends the translate gesture") + character.global_position = drag_origin_world + stage.call("_rebuild_grid_cells") + await _wait_frames(2) + + await _send_shift_click(_screen_at(stage, prop.global_position)) + await _wait_frames(4) + var multi_layouts: Array = gizmos.get("_last_selected_name_badge_layouts") + _check(selection.get_selected().size() == 2 and selection.get_selected().has(character) and selection.get_selected().has(prop), "Shift-click creates a mixed stickman-and-prop selection") + _check(multi_layouts.size() == 2, "multi-selection shows a name badge for every selected named object") + if multi_layouts.size() == 2: + var first_badge: Rect2 = (multi_layouts[0] as Dictionary).get("screen_rect", Rect2()) + var second_badge: Rect2 = (multi_layouts[1] as Dictionary).get("screen_rect", Rect2()) + _check(not first_badge.intersects(second_badge), "multi-selection name badges move apart to avoid overlap") + var badge_obstacles: Array = gizmos.get("_selected_name_badge_obstacles") + var badges_clear_of_ui: bool = true + for layout_value: Variant in multi_layouts: + var layout: Dictionary = layout_value as Dictionary + var layout_rect: Rect2 = layout.get("screen_rect", Rect2()) + for obstacle_value: Variant in badge_obstacles: + if layout_rect.intersects(obstacle_value as Rect2): + badges_clear_of_ui = false + _check(badges_clear_of_ui, "multi-selection badges stay clear of the inspector and Stage chrome") + _check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "selected EDIT objects still show no action-plan routes or hits") + await _save_capture_now("phase20b_edit_multi_name_badges") + + gizmos.set_candidate_preview(prop) + await _wait_frames(4) + _check((gizmos.get("_last_selected_name_badge_layouts") as Array).is_empty(), "Objects here candidate preview suppresses persistent selection name badges") + await _save_capture_now("phase20b_edit_candidate_preview_badge_suppressed") + gizmos.set_candidate_preview(null) + await _wait_frames(3) + + var direct_button: Button = _find_button_containing(stage, "Direct") + _check(direct_button != null, "DIRECT mode control is available after the EDIT badge checks") + if direct_button == null: + return + await _click_control(direct_button) + await _wait_frames(4) + _check(selection.get_selected().is_empty(), "switching to DIRECT clears EDIT multi-selection") + _check(not visuals.plan_overlay_visible and visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "DIRECT with no actor selected hides plan markers and their hit targets") + await _save_capture_now("phase20b_direct_empty_plan_hidden") + + await _send_click(_screen_at(stage, character.global_position)) + await _wait_frames(4) + _check(visuals.plan_overlay_visible and visuals.get("_plan_spotlight_rig") == character, "selecting one actor in DIRECT restores its Step Spotlight") + var candidate_destination: Dictionary = {"kind": "place", "position": Vector2(360.0, -60.0), "label": "Place 2"} + visuals.set_plan_candidate(character, String(move_action.get("action_id", "")), candidate_destination, true, Vector2(360.0, -60.0)) + selection.clear_selection() + await _wait_frames(4) + _check(not visuals.plan_overlay_visible and visuals.get("_plan_candidate_rig") == character, "clearing selection during retarget capture hides normal plans but keeps the candidate attached") + _check(visuals.hit_test_plan_action(Vector2(260.0, -100.0)).is_empty(), "faint capture routes remain non-interactive while selection is absent") + await _save_capture_now("phase20b_direct_capture_without_selection") + visuals.clear_plan_candidate() + + +func _run_compact_slice(stage: SandboxStage, character: StickmanRig, character_view: StageCharacterSettingsView, card: StageInspectorPanel) -> void: + var plan: Array[Dictionary] = [] + for index: int in 8: + if index == 0: + plan.append({"actions": [ + {"action_id": "fixture-move-0", "type": "move_to", "style": "walk", "destination": {"kind": "place", "position": Vector2(180.0, -50.0)}}, + {"action_id": "fixture-say-0", "type": "speak", "text": "Fetch the ball!", "duration": 1.8} + ]}) + elif index == 2: + plan.append({"actions": [{"action_id": "fixture-say-long", "type": "speak", "text": "We are just walking around to see what happens. Who knows!?", "duration": 5.1}]}) + else: + plan.append({"actions": [{"action_id": "fixture-wait-%d" % index, "type": "wait", "duration": 0.5}]}) + _check(character.set_action_plan(plan), "compact visual-only fixture seeds eight plan steps through the rig plan API") + character_view.refresh_plan() + root.size = Vector2i(920, 560) + await _wait_process_frames(6) + var quick_row: Control = character_view.find_child("QuickActionButtons", true, false) as Control + var body_scroll: ScrollContainer = card.find_child("InspectorBodyScroll", true, false) as ScrollContainer + var card_rect: Rect2 = card.get_global_rect() + var selection: StageSelection = stage.get("_selection") as StageSelection + root.size = Vector2i(547, 626) + await _wait_process_frames(6) + var screenshot_size_rect: Rect2 = card.get_global_rect() + var stage_chrome: StageChrome = stage.get("_chrome") as StageChrome + var timestamp_badge: Control = stage_chrome.get_timestamp_badge() if stage_chrome != null else null + var timestamp_right: float = timestamp_badge.get_global_rect().end.x if timestamp_badge != null else -1.0 + print("PHASE20B_GUI_LAYOUT_547 card=%s min=%s quick_min=%s title_min=%s" % [str(screenshot_size_rect), str(card.get_combined_minimum_size()), str(quick_row.get_combined_minimum_size()) if quick_row != null else "missing", str(card.find_child("InspectorTitle", true, false).get_combined_minimum_size()) if card.find_child("InspectorTitle", true, false) != null else "missing"]) + _check(card.visible and screenshot_size_rect.position.x >= 0.0 and screenshot_size_rect.end.x <= 547.0 - 12.0 and screenshot_size_rect.size.x <= 380.0 and absf(screenshot_size_rect.end.x - timestamp_right) <= 1.0, "547 px resize aligns the inspector with the clock and leaves the 12 px frame margin") + await _save_capture_now("inspector_margin_at_547x626") + root.size = Vector2i(920, 560) + await _wait_process_frames(6) + card_rect = card.get_global_rect() + _check(card.visible and card_rect.position.x >= 0.0 and card_rect.end.x <= 920.0 and card_rect.end.y <= 560.0, "920×560 keeps the selected inspector within the viewport") + var resized_timestamp_right: float = timestamp_badge.get_global_rect().end.x if timestamp_badge != null else -1.0 + _check(card_rect.size.x <= 380.0 and absf(card_rect.end.x - resized_timestamp_right) <= 1.0, "920×560 resize keeps the inspector's right edge aligned with the clock") + _check(quick_row != null and quick_row.get_global_rect().position.y >= card_rect.position.y and quick_row.get_global_rect().end.y < card_rect.end.y, "920×560 keeps pinned quick actions reachable") + _check(body_scroll != null and body_scroll.get_v_scroll_bar().max_value > 0.0, "eight-step plan scrolls within the inspector body") + var compact_step_one: Control = character_view.find_child("ActionStep_1", true, false) as Control + var compact_move: Button = _find_button_containing(compact_step_one, "Walk to Place 1") + var compact_say: Button = _find_button_containing(compact_step_one, "Say") + var compact_step_style: StyleBoxFlat = compact_step_one.get_theme_stylebox("panel") as StyleBoxFlat if compact_step_one != null else null + var compact_grouped_step_visible: bool = compact_step_one != null and body_scroll != null and body_scroll.get_global_rect().encloses(compact_step_one.get_global_rect()) and compact_move != null and compact_say != null and body_scroll.get_global_rect().encloses(compact_move.get_global_rect()) and body_scroll.get_global_rect().encloses(compact_say.get_global_rect()) and compact_step_style != null and compact_step_style.border_color.is_equal_approx(Color("#FFB300")) + _check(compact_grouped_step_visible, "compact DIRECT selection reveals the full selected grouped step with both twin-rail action rows") + _check(compact_move != null and compact_say != null and compact_move.custom_minimum_size.y == 48.0 and compact_say.custom_minimum_size.y == 48.0, "compact grouped action rows retain the mockup's 48 px targets") + var long_say_step: Control = character_view.find_child("ActionStep_3", true, false) as Control + var long_say_button: Button = character_view.find_child("ActionSummary_fixture-say-long", true, false) as Button + var long_say_label: Label = character_view.find_child("ActionSummaryText_fixture-say-long", true, false) as Label + _check(long_say_label != null and long_say_label.autowrap_mode == TextServer.AUTOWRAP_OFF and long_say_label.max_lines_visible == 1 and long_say_button != null and long_say_button.text.length() <= 24 and long_say_button.tooltip_text.contains("We are just walking around to see what happens. Who knows!?"), "long Say stays one line with ellipsis while its row tooltip retains the full speech") + _check(long_say_step != null and long_say_button != null and is_equal_approx(long_say_button.size.y, 48.0), "long Say does not make its action row taller") + var later_wait_summary: Button = _find_button_with_text(character_view.find_child("ActionStep_2", true, false), "Wait 0.5 seconds") + _check(later_wait_summary != null, "plan summaries spell out wait durations in seconds") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "viewport resize preserves the selected actor") + var compact_layout_valid: bool = card.visible and card_rect.position.x >= 0.0 and card_rect.end.x <= 920.0 and card_rect.end.y <= 560.0 and quick_row != null and quick_row.visible and quick_row.get_global_rect().position.y >= card_rect.position.y and quick_row.get_global_rect().end.y < card_rect.end.y and body_scroll != null and body_scroll.get_v_scroll_bar().max_value > 0.0 and compact_grouped_step_visible and later_wait_summary != null and selection.get_selected().size() == 1 and selection.get_selected()[0] == character + if compact_layout_valid: + await _save_capture_now("phase20b_final_compact_920x560_eight_steps") + await _save_capture_now("phase20b_final_compact_920x560_grouped_step_one") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_compact_920x560_eight_steps reason=compact_layout_assertions_failed") + var fourth_step_heading: Label = _find_label_exact_text(character_view.find_child("ActionStep_4", true, false), "Step 4") + var fourth_step_card: Control = character_view.find_child("ActionStep_4", true, false) as Control + if body_scroll != null and fourth_step_heading != null and fourth_step_card != null: + for tick: int in 40: + var step_rect: Rect2 = fourth_step_card.get_global_rect() + var scroll_rect: Rect2 = body_scroll.get_global_rect() + if scroll_rect.encloses(step_rect): + break + await _send_wheel_down(scroll_rect.get_center()) + await _wait_process_frames(1) + fourth_step_heading = _find_label_exact_text(character_view.find_child("ActionStep_4", true, false), "Step 4") + fourth_step_card = character_view.find_child("ActionStep_4", true, false) as Control + if tick % 4 == 3: + print("PHASE20B_GUI_SCROLL tick=%d value=%.1f max=%.1f step4_card=%s viewport=%s" % [tick + 1, body_scroll.scroll_vertical, body_scroll.get_v_scroll_bar().max_value, str(fourth_step_card.get_global_rect()) if fourth_step_card != null else "missing", str(body_scroll.get_global_rect())]) + var final_step_rect: Rect2 = fourth_step_card.get_global_rect() if fourth_step_card != null else Rect2() + _check(fourth_step_heading != null and fourth_step_card != null and body_scroll.get_global_rect().encloses(final_step_rect), "production mouse-wheel scrolling brings the full Step 4 card into the 920×560 body viewport") + if fourth_step_heading != null: + root.grab_focus() + await _wait_process_frames(1) + var heading_rect_before_hover: Rect2 = fourth_step_heading.get_global_rect() + await _send_mouse_motion(fourth_step_heading.get_screen_position() + fourth_step_heading.size * 0.5) + await _wait_process_frames(2) + var hovered: Control = root.gui_get_hovered_control() + var heading_rect_after_hover: Rect2 = fourth_step_heading.get_global_rect() + print("PHASE20B_GUI_STEP4_HOVER target=%s hovered=%s" % [str(heading_rect_after_hover), hovered.name if hovered != null else "none"]) + _check(fourth_step_heading.tooltip_text.is_empty() and heading_rect_before_hover.position.is_equal_approx(heading_rect_after_hover.position) and heading_rect_before_hover.size.is_equal_approx(heading_rect_after_hover.size), "hovering Step 4 text leaves it stationary and shows no tooltip") + await _wait_process_frames(4) + if fourth_step_card != null: + fourth_step_card.grab_focus() + await _send_key(KEY_ENTER) + await _wait_process_frames(4) + var selected_step_four: Label = _find_label_exact_text(character_view.find_child("ActionStep_4", true, false), "Step 4") + var selected_step_card: Control = character_view.find_child("ActionStep_4", true, false) as Control + var selected_step_rect: Rect2 = selected_step_card.get_global_rect() if selected_step_card != null else Rect2() + var selected_step_index: int = int(character_view.get("_selected_step_index")) + var focus_owner: Control = root.gui_get_focus_owner() as Control + var focus_text: String = focus_owner.name if focus_owner != null else "no focus" + var step_four_focused: bool = selected_step_card != null and selected_step_card.has_focus() and selected_step_index == 3 + var selected_style: StyleBoxFlat = selected_step_card.get_theme_stylebox("panel") as StyleBoxFlat if selected_step_card != null else null + var direct_selected: bool = selected_style != null and selected_style.border_color.is_equal_approx(Color("#FFB300")) and selected_style.border_width_left == 3 + var later_arrow: Button = selected_step_card.find_child("MoveStepLater_4", true, false) as Button if selected_step_card != null else null + var later_arrow_rect: Rect2 = later_arrow.get_global_rect() if later_arrow != null else Rect2() + print("PHASE20B_GUI_SCROLL selected_step=%s focused=%s selected_index=%d direct_amber=%s value=%.1f" % [str(selected_step_rect), focus_text, selected_step_index, str(direct_selected), body_scroll.scroll_vertical if body_scroll != null else -1.0]) + _check(selected_step_four != null and selected_step_card != null and body_scroll.get_global_rect().encloses(selected_step_rect) and step_four_focused and direct_selected, "full Step 4 card stays visible with its DIRECT amber selection and keyboard focus") + _check(later_arrow != null and later_arrow_rect.end.x <= selected_step_rect.end.x - 8.0, "compact Step 4 down arrow stays inset from the selected card border") + _check(selection.get_selected().size() == 1 and selection.get_selected()[0] == character, "scrolling and selecting Step 4 do not dismiss the plan actor") + if step_four_focused and direct_selected and body_scroll.get_global_rect().encloses(selected_step_rect) and selection.get_selected().size() == 1 and selection.get_selected()[0] == character: + await _save_capture_now("phase20b_final_compact_920x560_step_four_selected") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_compact_920x560_step_four_selected reason=step_focus_assertion_failed") + var focused_long_say_card: Control = character_view.find_child("ActionStep_3", true, false) as Control + var focused_long_say_button: Button = character_view.find_child("ActionSummary_fixture-say-long", true, false) as Button + if focused_long_say_card != null: + character_view.call("_select_plan_step", 2, true) + await _wait_process_frames(4) + var focused_long_say_row_visible: bool = focused_long_say_button != null and body_scroll != null and body_scroll.get_global_rect().encloses(focused_long_say_button.get_global_rect()) + _check(int(character_view.get_selected_step_index()) == 2 and focused_long_say_row_visible and is_equal_approx(focused_long_say_button.size.y, 48.0), "selecting a long Say step keeps its row visible at one line") + if int(character_view.get_selected_step_index()) == 2 and focused_long_say_row_visible: + await _save_capture_now("phase20b_long_say_single_line") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_long_say_single_line reason=selected_long_say_visibility_failed") + + +func _run_place_capture_slice(stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView, quick_row: Control) -> void: + var initial_place: Vector2 = Vector2(-500.0, 0.0) + var move: Dictionary = await _author_move_through_ui(stage, character, quick_row, "place", initial_place) + _check(not move.is_empty(), "place capture slice starts from a production-authored Move") + if move.is_empty(): + return + var move_id: String = String(move.get("action_id", "")) + stage.call("_set_scene_dirty", false) + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + var committed_projection: Dictionary = _projection_for_action(visuals._plan_projection(character), move_id) if visuals != null else {} + var committed_flag_position: Vector2 = committed_projection.get("hit_position", Vector2.INF) + await _send_click(_screen_at(stage, committed_flag_position)) + await _wait_process_frames(2) + var options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control + await _click_control(_find_button_with_text(options, "Edit action")) + var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + await _click_control(_find_button_containing(action_editor, "place")) + var bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar + var prompt: Label = bar.find_child("Prompt", true, false) as Label if bar != null else null + var cancel: Button = bar.find_child("Action_cancel_plan_capture", true, false) as Button if bar != null else null + var candidate_world: Vector2 = Vector2(-400.0, 0.0) + var candidate_screen: Vector2 = _screen_at(stage, candidate_world) + root.grab_focus() + _send_mouse_motion(candidate_screen) + await _wait_process_frames(3) + var cursor_prompt: Label = stage.get("_tooltip") as Label + var placement_overlay: StagePlacementOverlay = stage.get("_placement_overlay") as StagePlacementOverlay + var retarget_capture_valid: bool = int(stage.get("_capture_kind")) == PLAN_PLACE and bar != null and bar.visible and prompt != null and not prompt.visible and cancel != null and cancel.visible and cancel.size.x >= 44.0 and cancel.size.y >= 44.0 and action_editor != null and not action_editor.visible and cursor_prompt != null and cursor_prompt.visible and cursor_prompt.text == "Choose a new place or prop for Milo." and placement_overlay != null and placement_overlay.action_visible and not placement_overlay.action_show_trajectory + _check(retarget_capture_valid, "retarget capture keeps the cursor marker and instruction while showing a fixed Cancel-only control") + _check(prompt != null and not prompt.visible and cancel != null and cancel.visible and cancel.size.x >= 44.0 and cancel.size.y >= 44.0, "capture control removes the duplicate prompt and keeps Cancel touch-sized") + var capture_shell: StyleBoxFlat = bar.get_theme_stylebox("panel") as StyleBoxFlat if bar != null else null + _check(capture_shell != null and is_zero_approx(capture_shell.bg_color.a) and capture_shell.get_border_width(SIDE_LEFT) == 0, "Cancel button is shown without the old instruction-panel shell") + if retarget_capture_valid: + await _save_capture_marker_preview(stage, placement_overlay, cursor_prompt, candidate_world, candidate_screen, "phase20b_final_place_capture_preview") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_place_capture_preview reason=production_capture_state_invalid") + var cancel_position_before: Vector2 = cancel.get_global_rect().position if cancel != null else Vector2.ZERO + var candidate_world_after_move: Vector2 = Vector2(-300.0, 0.0) + var candidate_screen_after_move: Vector2 = _screen_at(stage, candidate_world_after_move) + _send_mouse_motion(candidate_screen_after_move) + await _wait_process_frames(3) + var candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer") + var candidate_destination: Dictionary = visuals.get("_plan_candidate_destination") + var candidate_label: String = String(visuals.get("_plan_candidate_movement_label")) + var candidate_target: Variant = candidate_destination.get("position", Vector2.INF) + var candidate_anchor_data: Dictionary = visuals.call("_queue_anchor", character) + var candidate_anchor: Vector2 = candidate_anchor_data.get("ground", candidate_anchor_data.get("feet", Vector2.INF)) + var grounded_candidate: Vector2 = visuals.call("_project_move_display_position", candidate_target, candidate_anchor.y) if candidate_target is Vector2 and candidate_anchor.is_finite() else Vector2.INF + var retarget_candidate_valid: bool = retarget_capture_valid and candidate_pointer.is_finite() and candidate_pointer.distance_to(candidate_world_after_move) < 0.1 and bool(visuals.get("_plan_candidate_valid")) + _check(retarget_candidate_valid, "production pointer motion previews a valid new place while the saved route remains intact") + _check(candidate_label == "Walk" and grounded_candidate.is_finite() and is_equal_approx(grounded_candidate.y, candidate_anchor.y), "candidate route uses the Walk label and a grounded horizontal endpoint") + _check(cancel != null and cancel.get_global_rect().position.is_equal_approx(cancel_position_before), "Cancel remains fixed while the cursor moves") + _check(String(((character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary).get("action_id", "")) == move_id and not bool(stage.get("_dirty")), "candidate preview keeps the old action ID and does not dirty the authored destination") + if retarget_candidate_valid: + await _save_capture_marker_preview(stage, placement_overlay, cursor_prompt, candidate_world_after_move, candidate_screen_after_move, "phase20b_final_grounded_walk_candidate") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_grounded_walk_candidate reason=valid_candidate_state_not_reached") + if cancel != null: + await _click_control(cancel) + await _wait_process_frames(3) + var current_action: Dictionary = (character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary + _check(not bar.visible and not action_editor.visible and int(stage.get("_capture_kind")) == 0 and current_action == move and String(current_action.get("action_id", "")) == move_id and not bool(stage.get("_dirty")), "visible Cancel discards the retarget draft without changing the plan") + await _save_capture_now("phase20b_final_cancel_button_closed") + options = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control + await _click_control(_find_button_with_text(options, "Edit action")) + await _click_control(_find_button_containing(action_editor, "place")) + await _wait_process_frames(2) + await _send_key(KEY_ESCAPE) + await _wait_process_frames(2) + current_action = (character.get_action_plan()[0]["actions"] as Array)[0] as Dictionary + _check(not bar.visible and int(stage.get("_capture_kind")) == 0 and current_action == move and not bool(stage.get("_dirty")), "Escape remains a working shortcut for place-capture cancellation") + stage.set("_context_rig", character) + stage.call("_on_action_popup_id_pressed", SandboxStage.ACT_PICK_UP_PROP) + await _wait_process_frames(3) + var pickup_overlay: StagePlacementOverlay = stage.get("_placement_overlay") as StagePlacementOverlay + var pickup_target_preview_valid: bool = int(stage.get("_capture_kind")) == SandboxStage.CaptureKind.PROP and pickup_overlay != null and pickup_overlay.action_visible and not pickup_overlay.action_show_trajectory + _check(pickup_target_preview_valid, "Pick Up Prop capture shows only a cursor marker, without a movement-looking trajectory") + if pickup_target_preview_valid and prop != null: + var pickup_prompt: Label = stage.get("_tooltip") as Label + await _save_capture_marker_preview(stage, pickup_overlay, pickup_prompt, prop.global_position, _screen_at(stage, prop.global_position), "phase20b_final_pickup_prop_marker_only") + await _send_key(KEY_ESCAPE) + await _wait_process_frames(2) + _check(int(stage.get("_capture_kind")) == SandboxStage.CaptureKind.NONE and not pickup_overlay.action_visible, "Escape clears the Pick Up Prop target marker") + + +func _run_prop_capture_slice(stage: SandboxStage, character: StickmanRig, prop: PropBlock, character_view: StageCharacterSettingsView, quick_row: Control) -> void: + var spawner: StageSpawner = stage.get("_spawner") as StageSpawner + var second_prop: PropBlock = spawner.spawn_prop_asset("crate", prop.global_position + Vector2(130.0, -90.0)) + _check(second_prop != null, "prop capture scenario places a second instance of the same prop asset") + if second_prop == null: + return + stage.call("_finalize_placed_node", second_prop) + _make_small_prop(second_prop) + var identity: StageObjectIdentity = stage.get("_object_identity") as StageObjectIdentity + var renamed: Dictionary = identity.rename(second_prop, "Green Fetch Ball", stage.get_node("World") as Node2D) + _check(bool(renamed.get("ok", false)), "second prop receives a distinct visible stage name") + await _wait_process_frames(4) + var move: Dictionary = await _author_move_through_ui(stage, character, quick_row, "prop", second_prop.global_position) + if move.is_empty(): + return + var exact_prop_target: bool = String((move.get("destination", {}) as Dictionary).get("target_prop_id", "")) == second_prop.stage_object_id + _check(exact_prop_target, "production target capture stores the exact selected second prop ID") + var move_id: String = String(move.get("action_id", "")) + var summary: Button = _find_button_containing(character_view.find_child("ActionStep_1", true, false), "Green Fetch Ball") + var prop_move_valid: bool = exact_prop_target and summary != null and summary.is_visible_in_tree() and summary.text.contains("Green Fetch Ball") + _check(prop_move_valid, "Step Stack summary displays the selected prop's current stage name") + if prop_move_valid: + await _save_capture_now("phase20b_final_exact_prop_move") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_exact_prop_move reason=prop_route_or_summary_invalid") + stage.call("_set_scene_dirty", false) + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + var projection: Dictionary = _projection_for_action(visuals._plan_projection(character), move_id) + _check(String(projection.get("label", "")) == "Green Fetch Ball", "route projection uses the exact selected prop's stage name") + var prop_endpoint: Vector2 = projection.get("end", second_prop.global_position) + await _send_click(_screen_at(stage, prop_endpoint)) + await _wait_process_frames(3) + var options: Control = character_view.find_child("ActionOptions_%s" % move_id, true, false) as Control + await _click_control(_find_button_with_text(options, "Edit action")) + var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + await _click_control(_find_button_containing(action_editor, "place")) + var bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar + var prompt: Label = bar.find_child("Prompt", true, false) as Label if bar != null else null + var free_place: Vector2 = prop.global_position + Vector2(54.0, 0.0) + var free_place_screen: Vector2 = _screen_at(stage, free_place) + root.grab_focus() + _send_mouse_motion(free_place_screen) + await _wait_process_frames(3) + var free_place_prompt_valid: bool = int(stage.get("_capture_kind")) == PLAN_PLACE and bar != null and bar.visible and prompt != null and prompt.text == "Choose a new place or prop for Milo." and not action_editor.visible + _check(free_place_prompt_valid, "Edit action exposes the production place capture prompt") + var candidate: Vector2 = visuals.get("_plan_candidate_pointer") + var free_place_candidate_valid: bool = free_place_prompt_valid and candidate.is_finite() and candidate.distance_to(free_place) < 0.1 and bool(visuals.get("_plan_candidate_valid")) + _check(free_place_candidate_valid, "candidate pointer reaches a valid free place beside the named prop") + if free_place_candidate_valid: + await _save_capture_now("phase20b_final_free_place_near_prop_candidate") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_free_place_near_prop_candidate reason=valid_candidate_state_not_reached") + await _send_click(free_place_screen) + await _wait_process_frames(4) + await _click_control(_find_button_with_text(action_editor, "Done")) + await _wait_process_frames(5) + var plan: Array[Dictionary] = character.get_action_plan() + var retargeted: Dictionary = (plan[0]["actions"] as Array)[0] as Dictionary + var destination: Dictionary = retargeted.get("destination", {}) + _check(String(retargeted.get("action_id", "")) == move_id and String(destination.get("kind", "")) == "place", "Move Edit action to a nearby free point preserves the action ID and place intent") + var place_projection: Dictionary = _projection_for_action(visuals._plan_projection(character), move_id) + _check(String(place_projection.get("label", "")).contains("Place 1 · by Blue Fetch Ball"), "nearby free endpoint stays Place 1 with prop context in the route label") + await _save_capture_now("phase20b_final_free_place_by_prop_committed") + + +func _author_move_through_ui(stage: SandboxStage, character: StickmanRig, quick_row: Control, target_kind: String, target_world: Vector2) -> Dictionary: + stage.call("_set_scene_dirty", false) + var move_button: Button = _find_button_with_text(quick_row, "🚶 Move") + await _click_control(move_button) + var action_editor: ActionEditor = stage.get("_action_editor") as ActionEditor + var target_choice_text: String = "Choose a prop" if target_kind == "prop" else "Choose a place" + var target_choice: Button = _find_button_containing(action_editor, target_choice_text) + _check(action_editor != null and action_editor.visible and target_choice != null, "Quick Move opens the production action editor with %s" % target_choice_text) + if target_choice == null: + return {} + await _click_control(target_choice) + var expected_capture: int = PLAN_PROP if target_kind == "prop" else PLAN_PLACE + _check(int(stage.get("_capture_kind")) == expected_capture, "visible %s choice enters its production stage capture" % target_kind) + var target_screen: Vector2 = _screen_at(stage, target_world) + root.grab_focus() + _send_mouse_motion(target_screen) + await _wait_process_frames(2) + var capture_bar: StageUseTargetCaptureBar = stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar + var capture_prompt: Label = capture_bar.find_child("Prompt", true, false) as Label if capture_bar != null else null + var capture_cancel: Button = capture_bar.find_child("Action_cancel_plan_capture", true, false) as Button if capture_bar != null else null + var cursor_prompt: Label = stage.get("_tooltip") as Label + var expected_cursor_prompt: String = "Choose a place for Milo." if target_kind == "place" else "Choose the prop Milo will walk to." + var placement_overlay: StagePlacementOverlay = stage.get("_placement_overlay") as StagePlacementOverlay + var capture_layout_valid: bool = int(stage.get("_capture_kind")) == expected_capture and action_editor != null and not action_editor.visible and capture_bar != null and capture_bar.visible and capture_prompt != null and not capture_prompt.visible and capture_cancel != null and capture_cancel.visible and capture_cancel.size.x >= 44.0 and capture_cancel.size.y >= 44.0 and cursor_prompt != null and cursor_prompt.visible and cursor_prompt.text == expected_cursor_prompt and placement_overlay != null and placement_overlay.action_visible and not placement_overlay.action_show_trajectory + _check(capture_layout_valid, "production %s capture shows a cursor instruction, marker-only preview, and touch-sized Cancel" % target_kind) + if not capture_layout_valid: + return {} + var visuals: StageDirectorVisuals = stage.get("_director_visuals") as StageDirectorVisuals + var candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer") if visuals != null else Vector2.INF + var candidate_valid: bool = visuals != null and candidate_pointer.is_finite() and candidate_pointer.distance_to(target_world) < 0.1 and bool(visuals.get("_plan_candidate_valid")) + _check(candidate_valid, "actual pointer motion previews a valid requested %s target" % target_kind) + if candidate_valid: + await _save_capture_marker_preview(stage, placement_overlay, cursor_prompt, target_world, target_screen, "phase20b_final_%s_candidate_preview" % target_kind) + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_final_%s_candidate_preview reason=valid_candidate_state_not_reached" % target_kind) + await _send_click(target_screen) + await _wait_process_frames(5) + _check(action_editor.visible, "target selection returns to the production action editor for Done") + var done_button: Button = _find_button_with_text(action_editor, "Done") + _check(done_button != null and done_button.visible, "production Move draft exposes its labeled Done action") + if done_button == null: + return {} + await _click_control(done_button) + await _wait_process_frames(5) + var plan: Array[Dictionary] = character.get_action_plan() + if plan.is_empty(): + _check(false, "production Move commit creates an ordered plan step") + return {} + var action_values: Array = plan[plan.size() - 1].get("actions", []) + if action_values.is_empty() or not action_values[0] is Dictionary: + _check(false, "production Move commit contains its action payload") + return {} + var action: Dictionary = (action_values[0] as Dictionary).duplicate(true) + _check(String(action.get("type", "")) == "move_to" and String((action.get("destination", {}) as Dictionary).get("kind", "")) == target_kind, "production Move commit preserves the selected %s destination kind" % target_kind) + _check(bool(stage.get("_dirty")), "valid production Move commit marks the scene dirty") + return action + + +func _make_small_prop(prop: PropBlock) -> void: + var points: PackedVector2Array = PackedVector2Array([Vector2(-20.0, -20.0), Vector2(20.0, -20.0), Vector2(20.0, 20.0), Vector2(-20.0, 20.0)]) + var shapes: Array[Dictionary] = [{ + "shape_type": "polygon", + "points": points, + "closed": true, + "fill_color": "#E6A73B", + "outline_color": "#3A2A11", + "outline_width": 3.0, + }] + prop.set_custom_shapes(shapes, points) + + +func _step_has_action_type(step: Dictionary, action_type: String) -> bool: + for action_value: Variant in step.get("actions", []): + if action_value is Dictionary and String((action_value as Dictionary).get("type", "")) == action_type: + return true + return false + + +func _find_action_type(step: Dictionary, action_type: String) -> Dictionary: + for action_value: Variant in step.get("actions", []): + if action_value is Dictionary and String((action_value as Dictionary).get("type", "")) == action_type: + return (action_value as Dictionary).duplicate(true) + return {} + + +func _first_speech_text(action: Dictionary) -> String: + var bubbles: Variant = action.get("bubbles", []) + if not bubbles is Array or (bubbles as Array).is_empty() or not (bubbles as Array)[0] is Dictionary: + return "" + return String(((bubbles as Array)[0] as Dictionary).get("text", "")) + + +func _projection_for_action(projections: Array[Dictionary], action_id: String) -> Dictionary: + for projection: Dictionary in projections: + if String(projection.get("action_id", "")) == action_id: + return projection + return {} + + +func _action_id_for_type(step: Dictionary, action_type: String) -> String: + for action_value: Variant in step.get("actions", []): + if action_value is Dictionary and String((action_value as Dictionary).get("type", "")) == action_type: + return String((action_value as Dictionary).get("action_id", "")) + return "" + + +func _same_start_frame(events: Array[Dictionary], step_index: int) -> bool: + var frames: Array[int] = [] + for event: Dictionary in events: + if int(event.get("step", -1)) == step_index: + frames.append(int(event.get("frame", -1))) + return frames.size() == 2 and frames[0] == frames[1] + + +func _start_frame_for_step(events: Array[Dictionary], step_index: int) -> int: + for event: Dictionary in events: + if int(event.get("step", -1)) == step_index: + return int(event.get("frame", -1)) + return -1 + + +func _end_frame_for_id(events: Array[Dictionary], action_id: String) -> int: + for event: Dictionary in events: + if String(event.get("id", "")) == action_id: + return int(event.get("frame", -1)) + return -1 + + +func _wait_until_actions_end(stage: SandboxStage, events: Array[Dictionary], timeout: float) -> void: + var elapsed: float = 0.0 + while elapsed < timeout and events.size() < 4 and int(stage.get("_run_state")) == SandboxStage.RunState.RUNNING: + await create_timer(0.05).timeout + elapsed += 0.05 + await _wait_frames(3) + + +func _find_button_with_text(parent: Node, value: String) -> Button: + if parent == null: + return null + for child: Node in parent.find_children("*", "Button", true, false): + var button: Button = child as Button + if button != null and button.text == value: + return button + return null + + +func _find_button_containing(parent: Node, value: String) -> Button: + if parent == null: + return null + for child: Node in parent.find_children("*", "Button", true, false): + var button: Button = child as Button + if button != null and button.text.contains(value): + return button + return null + + +func _find_button_with_tooltip(parent: Node, value: String) -> Button: + if parent == null: + return null + for child: Node in parent.find_children("*", "Button", true, false): + var button: Button = child as Button + if button != null and button.tooltip_text == value: + return button + return null + + +func _screen_at(stage: SandboxStage, world_position: Vector2) -> Vector2: + return stage.get_viewport().get_canvas_transform() * world_position + + +func _click_control(control: Control) -> void: + if control == null: + _check(false, "requested production UI control exists for pointer click") + return + if not control.is_visible_in_tree() or control.get_global_rect().size.x <= 0.0 or control.get_global_rect().size.y <= 0.0: + _check(false, "requested production UI control is visible and has a rendered hit area") + return + var viewport_position: Vector2 = control.get_global_rect().get_center() + var owner_window: Window = control.get_window() + var window_id: int = owner_window.get_window_id() + var screen_position: Vector2 = control.get_screen_position() + control.size * 0.5 + var input_position: Vector2 = screen_position if owner_window.position != Vector2i.ZERO else viewport_position + _last_click_label = control.text if control is Button else control.name + _send_mouse_motion(input_position, window_id, screen_position) + await _wait_process_frames(1) + var owner_viewport: Viewport = control.get_viewport() + var hovered_control: Control = owner_viewport.gui_get_hovered_control() as Control + print("PHASE20B_GUI_HOVER target=%s viewport_position=%s screen_position=%s window=%d window_position=%s hovered=%s" % [control.name, str(input_position), str(screen_position), window_id, str(owner_window.position), hovered_control.name if hovered_control != null else "none"]) + if hovered_control != control: + print("PHASE20B_GUI_HOVER_MISMATCH target=%s hovered=%s; button activation is checked after press/release" % [control.name, hovered_control.name if hovered_control != null else "none"]) + if control is Button and ((control as Button).text.contains("Message") or (control as Button).text.contains("Choose a place") or (control as Button).text == "Step 4"): + print("PHASE20B_GUI_CONTROL_CLICK text=%s rect=%s viewport_position=%s screen_position=%s window_id=%d window_pos=%s root_window_id=%d" % [(control as Button).text, str(control.get_global_rect()), str(viewport_position), str(screen_position), window_id, str(owner_window.position), root.get_window_id()]) + print("PHASE20B_GUI_CONTROL_STATE visible=%s inside=%s focus=%s hovered=%s mouse_filter=%d disabled=%s" % [str(control.is_visible_in_tree()), str(control.is_inside_tree()), str(control.has_focus()), str(root.gui_get_hovered_control()), control.mouse_filter, str((control as Button).disabled)]) + await _send_click(input_position, window_id, screen_position) + _last_click_label = "" + + +func _send_click(position: Vector2, window_id: int = 0, global_position: Vector2 = Vector2.INF) -> void: + var trace_this_click: bool = _trace_next_click + _trace_next_click = false + var event_global_position: Vector2 = global_position if global_position.is_finite() else position + await _send_mouse_button(position, true, window_id, event_global_position) + if trace_this_click: + print("PHASE20B_TARGET_CLICK after_press capture=%d speech_pending=%s speech_hits=%s hovered=%s focus=%s" % [int(_debug_stage.get("_capture_kind")), str(_debug_stage.get("_speech_bubble_pointer_pending")), str(_debug_stage.get("_speech_bubble_pointer_hits")), str(root.gui_get_hovered_control()), str(root.gui_get_focus_owner())]) + await _wait_process_frames(1) + if _capture_after_next_click: + _capture_after_next_click = false + var target_viewport_position: Vector2 = _screen_at(_debug_stage, Vector2(-20.0, 0.0)) + var stage_window: Window = _debug_stage.get_window() + var target_screen_position: Vector2 = target_viewport_position + Vector2(stage_window.position) + await _send_mouse_button(position, false, window_id, event_global_position, target_viewport_position, target_screen_position) + var frame_started_msec: int = Time.get_ticks_msec() + var editor: ActionEditor = _debug_stage.get("_action_editor") as ActionEditor + var capture_bar: StageUseTargetCaptureBar = _debug_stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar + var prompt: Label = capture_bar.find_child("Prompt", true, false) as Label if capture_bar != null else null + var cancel_button: Button = capture_bar.find_child("Action_cancel_plan_capture", true, false) as Button if capture_bar != null else null + var visuals: StageDirectorVisuals = _debug_stage.get("_director_visuals") as StageDirectorVisuals + var character_view: StageCharacterSettingsView = _debug_stage.get("_character_settings_view") as StageCharacterSettingsView + var inspector: StageInspectorPanel = character_view.get_card() as StageInspectorPanel if character_view != null else null + var capture_kind_matches: bool = not paused and int(_debug_stage.get("_capture_kind")) == PLAN_PLACE and editor != null and not editor.visible + _check(capture_kind_matches, "post-click state enters place capture and closes the draft editor") + _check(capture_bar != null and capture_bar.visible and prompt != null and prompt.text == "Choose a place for Milo.", "production capture bar shows the concise place prompt") + _check(cancel_button != null and cancel_button.visible and cancel_button.size.x >= 44.0 and cancel_button.size.y >= 44.0, "capture Cancel remains visible at a touch-sized target") + _check(visuals != null and visuals.get("_plan_candidate_rig") != null, "live route candidate remains attached to the selected actor") + var inspector_clear: bool = capture_bar != null and inspector != null and not capture_bar.get_global_rect().intersects(inspector.get_global_rect()) + if capture_bar != null and inspector != null: + _check(inspector_clear, "capture bar stays clear of the selected Step Stack inspector") + var prompt_layout_valid: bool = capture_bar != null and capture_bar.visible and prompt != null and prompt.text == "Choose a place for Milo." and cancel_button != null and cancel_button.visible and cancel_button.size.x >= 44.0 and cancel_button.size.y >= 44.0 and capture_bar.size.x <= 440.0 and capture_bar.size.y <= 160.0 and capture_bar.get_global_rect().position.y >= 64.0 and inspector_clear + if capture_bar != null: + var capture_rect: Rect2 = capture_bar.get_global_rect() + print("PHASE20B_GUI_CAPTURE_BAR rect=%s size=%s prompt=%s" % [str(capture_rect), str(capture_rect.size), prompt.text if prompt != null else "missing"]) + _check(capture_rect.size.x <= 440.0 and capture_rect.size.y <= 160.0 and capture_rect.position.y >= 64.0, "place capture prompt stays compact and below the top toolbar") + if capture_kind_matches and prompt_layout_valid: + if OS.get_environment("PHASE20B_SETTLE_CAPTURE_FRAME") == "1": + await _wait_process_frames(2) + _save_capture_current_frame("phase20b_place_capture_entry_current_frame") + else: + print("PHASE20B_CAPTURE_SKIPPED name=phase20b_place_capture_entry_current_frame reason=capture_ui_assertions_failed") + if _skip_capture_hover: + print("PHASE20B_GUI_CAPTURE_ENTRY_ONLY: capture state, draft closure, prompt, Cancel, and layout assertions complete; candidate pointer remains unverified") + return + await _wait_process_frames(2) + var release_candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer") if visuals != null else Vector2.INF + var candidate_matches: bool = release_candidate_pointer.is_finite() and release_candidate_pointer.distance_to(Vector2(-20.0, 0.0)) < 0.1 and visuals != null and bool(visuals.get("_plan_candidate_valid")) + _check(candidate_matches, "post-release viewport motion updates the valid open-ground candidate before preview") + if capture_kind_matches and prompt_layout_valid and candidate_matches and visuals != null and visuals.get("_plan_candidate_rig") != null: + _save_capture_current_frame("wide_place_capture_prompt") + else: + print("PHASE20B_CAPTURE_SKIPPED name=wide_place_capture_prompt reason=production_capture_state_invalid") + var candidate_screen_position: Vector2 = _screen_at(_debug_stage, Vector2(-20.0, 0.0)) + root.grab_focus() + _send_mouse_motion(candidate_screen_position) + print("PHASE20B_GUI_FRAME mouse_motion_sent window_focused=%s" % root.has_focus()) + await process_frame + print("PHASE20B_GUI_FRAME after_motion_process_frame") + await _wait_process_frames(2) + print("PHASE20B_GUI_FRAME after_motion_process_frames") + var candidate_pointer: Vector2 = visuals.get("_plan_candidate_pointer") if visuals != null else Vector2.INF + var candidate_position_valid: bool = candidate_pointer.is_finite() and candidate_pointer.distance_to(Vector2(-20.0, 0.0)) < 0.1 + var candidate_is_valid: bool = visuals != null and bool(visuals.get("_plan_candidate_valid")) + _check(candidate_position_valid, "actual viewport motion updates the live destination candidate") + _check(candidate_is_valid, "free open ground previews as a valid destination") + print("PHASE20B_RENDERED_STATE elapsed_msec=%d capture=%d bar_rect=%s inspector_rect=%s candidate=%s valid=%s focus=%s" % [Time.get_ticks_msec() - frame_started_msec, int(_debug_stage.get("_capture_kind")), str(capture_bar.get_global_rect()) if capture_bar != null else "missing", str(inspector.get_global_rect()) if inspector != null else "missing", str(candidate_pointer), bool(visuals.get("_plan_candidate_valid")) if visuals != null else false, str(root.gui_get_focus_owner())]) + if capture_kind_matches and prompt_layout_valid and candidate_position_valid and candidate_is_valid: + await _save_capture_after_process_frames("wide_place_capture_bar") + else: + print("PHASE20B_CAPTURE_SKIPPED name=wide_place_capture_bar reason=valid_candidate_state_not_reached") + else: + await _send_mouse_button(position, false, window_id, event_global_position) + await _wait_process_frames(2) + if trace_this_click: + print("PHASE20B_TARGET_CLICK after_release capture=%d speech_pending=%s speech_hits=%s focus=%s" % [int(_debug_stage.get("_capture_kind")), str(_debug_stage.get("_speech_bubble_pointer_pending")), str(_debug_stage.get("_speech_bubble_pointer_hits")), str(root.gui_get_focus_owner())]) + + +func _send_shift_click(position: Vector2) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.position = position + event.global_position = position + event.window_id = root.get_window_id() + event.shift_pressed = true + event.pressed = true + Input.parse_input_event(event) + await process_frame + event.pressed = false + Input.parse_input_event(event) + await process_frame + await _wait_frames(2) + + +func _send_mouse_button(position: Vector2, pressed: bool, window_id: int = 0, global_position: Vector2 = Vector2.INF, post_release_viewport_position: Vector2 = Vector2.INF, post_release_global_position: Vector2 = Vector2.INF) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_LEFT + event.position = position + event.global_position = global_position if global_position.is_finite() else position + event.window_id = window_id + event.pressed = pressed + var trace_input: bool = _last_click_label.contains("Choose a place") + if trace_input: + print("PHASE20B_INPUT before_parse label=%s pressed=%s position=%s window_id=%d" % [_last_click_label, str(pressed), str(position), window_id]) + Input.parse_input_event(event) + _last_injected_mouse_position = position + if not pressed and post_release_viewport_position.is_finite(): + var event_global_position: Vector2 = post_release_global_position if post_release_global_position.is_finite() else post_release_viewport_position + _send_mouse_motion(post_release_viewport_position, window_id, event_global_position) + if trace_input: + print("PHASE20B_INPUT post_release_viewport_motion label=%s position=%s global_position=%s window_position=%s" % [_last_click_label, str(post_release_viewport_position), str(event_global_position), str(_debug_stage.get_window().position)]) + if trace_input: + print("PHASE20B_INPUT after_parse label=%s pressed=%s capture=%d" % [_last_click_label, str(pressed), int(_debug_stage.get("_capture_kind"))]) + print("PHASE20B_INPUT before_process_frame label=%s pressed=%s" % [_last_click_label, str(pressed)]) + await process_frame + if trace_input: + var bar: StageUseTargetCaptureBar = _debug_stage.find_child("UseTargetCaptureBar", true, false) as StageUseTargetCaptureBar + print("PHASE20B_INPUT after_process_frame label=%s pressed=%s capture=%d bar_visible=%s" % [_last_click_label, str(pressed), int(_debug_stage.get("_capture_kind")), str(bar != null and bar.visible)]) + + +func _send_mouse_motion(position: Vector2, window_id: int = 0, global_position: Vector2 = Vector2.INF, button_mask: int = 0) -> void: + var motion: InputEventMouseMotion = InputEventMouseMotion.new() + motion.position = position + motion.global_position = global_position if global_position.is_finite() else position + motion.relative = position - _last_injected_mouse_position + motion.button_mask = button_mask + motion.window_id = window_id + if _last_click_label.contains("Choose a place"): + print("PHASE20B_INPUT motion_event position=%s global_position=%s relative=%s mask=%d window_id=%d" % [str(motion.position), str(motion.global_position), str(motion.relative), int(motion.button_mask), window_id]) + Input.parse_input_event(motion) + _last_injected_mouse_position = position + + +func _send_wheel_down(position: Vector2) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_WHEEL_DOWN + event.position = position + event.global_position = position + event.factor = 1.0 + event.window_id = root.get_window_id() + event.pressed = true + Input.parse_input_event(event) + await process_frame + + +func _send_middle_button(position: Vector2, pressed: bool) -> void: + var event: InputEventMouseButton = InputEventMouseButton.new() + event.button_index = MOUSE_BUTTON_MIDDLE + event.position = position + event.global_position = position + event.window_id = root.get_window_id() + event.pressed = pressed + Input.parse_input_event(event) + _last_injected_mouse_position = position + await process_frame + + +func _send_key(keycode: Key, with_shift: bool = false, with_ctrl: bool = false) -> void: + var down: InputEventKey = InputEventKey.new() + down.keycode = keycode + down.shift_pressed = with_shift + down.ctrl_pressed = with_ctrl + down.pressed = true + Input.parse_input_event(down) + await process_frame + var up: InputEventKey = InputEventKey.new() + up.keycode = keycode + up.shift_pressed = with_shift + up.ctrl_pressed = with_ctrl + up.pressed = false + Input.parse_input_event(up) + await process_frame + + +func _type_text(value: String) -> void: + for index: int in value.length(): + var character: String = value.substr(index, 1) + var event: InputEventKey = InputEventKey.new() + event.keycode = OS.find_keycode_from_string(character.to_upper()) + event.unicode = value.unicode_at(index) + event.shift_pressed = character != character.to_lower() + event.pressed = true + Input.parse_input_event(event) + await process_frame + var release: InputEventKey = event.duplicate() as InputEventKey + release.pressed = false + Input.parse_input_event(release) + await process_frame + + +func _wait_frames(count: int) -> void: + for _index: int in count: + await process_frame + await RenderingServer.frame_post_draw + + +func _wait_process_frames(count: int) -> void: + for _index: int in count: + await process_frame + + +func _wait_for_status_toast_clear(stage: SandboxStage) -> void: + for _frame: int in 180: + if float(stage.get("_toast_timer")) <= 0.0: + break + await process_frame + _check(float(stage.get("_toast_timer")) <= 0.0, "transient validation status clears before the committed-plan capture") + + +func _say_marker_has_rendered_pixels(visuals: StageDirectorVisuals, projection: Dictionary, image: Image) -> bool: + if visuals == null or image == null or image.is_empty(): + return false + var start: Vector2 = projection.get("start", Vector2.INF) + if not start.is_finite(): + return false + var marker_center_world: Vector2 = visuals._say_marker_center(start, visuals._zoom()) + var marker_center_screen: Vector2 = visuals.get_global_transform_with_canvas() * marker_center_world + var sample_rect: Rect2 = Rect2(marker_center_screen - Vector2(24.0, 20.0), Vector2(48.0, 40.0)) + var stage_view_rect: Rect2 = Rect2(Vector2(0.0, 64.0), Vector2(float(root.size.x), float(root.size.y) - 104.0)) + if not stage_view_rect.encloses(sample_rect): + print("PHASE20B_SAY_MARKER_OUTSIDE_STAGE center_screen=%s sample=%s stage_view=%s" % [str(marker_center_screen), str(sample_rect), str(stage_view_rect)]) + return false + var yellow_pixels: int = 0 + var start_x: int = maxi(0, int(floorf(sample_rect.position.x))) + var end_x: int = mini(image.get_width(), int(ceilf(sample_rect.end.x))) + var start_y: int = maxi(0, int(floorf(sample_rect.position.y))) + var end_y: int = mini(image.get_height(), int(ceilf(sample_rect.end.y))) + for y: int in range(start_y, end_y): + for x: int in range(start_x, end_x): + var pixel: Color = image.get_pixel(x, y) + if pixel.r > 0.72 and pixel.g > 0.48 and pixel.b < 0.52: + yellow_pixels += 1 + print("PHASE20B_SAY_MARKER center_world=%s center_screen=%s sample=%s yellow_pixels=%d" % [str(marker_center_world), str(marker_center_screen), str(sample_rect), yellow_pixels]) + return yellow_pixels >= 20 + + +func _save_capture(name: String) -> void: + await RenderingServer.frame_post_draw + var image: Image = root.get_texture().get_image() + var path: String = _active_capture_dir.path_join("%s.png" % name) + var error: Error = image.save_png(ProjectSettings.globalize_path(path)) + print("PHASE20B_CAPTURE path=%s size=%s error=%d" % [path, image.get_size(), error]) + _check(error == OK and image.get_size() == root.size, "rendered capture %s is saved at viewport size" % name) + + +func _save_capture_now(name: String) -> void: + await process_frame + await RenderingServer.frame_post_draw + var image: Image = root.get_texture().get_image() + var path: String = _active_capture_dir.path_join("%s.png" % name) + var error: Error = image.save_png(ProjectSettings.globalize_path(path)) + print("PHASE20B_CAPTURE_STABLE path=%s size=%s error=%d" % [path, image.get_size(), error]) + _check(error == OK and image.get_size() == root.size, "settled rendered capture %s is saved at viewport size" % name) + + +func _save_capture_marker_preview(stage: SandboxStage, placement_overlay: StagePlacementOverlay, cursor_prompt: Label, world_position: Vector2, screen_position: Vector2, name: String) -> void: + # Synthetic pointer input updates the production capture state but does not + # move the desktop cursor. Freeze the Stage for this frame and place the + # cursor marker and its prompt at the injected pointer for a representative + # rendered screenshot. + stage.set_process(false) + placement_overlay.set_action_marker(world_position) + cursor_prompt.position = screen_position + Vector2(20.0, -20.0) + await _save_capture_now(name) + stage.set_process(true) + + +func _save_capture_after_process_frames(name: String) -> void: + await _wait_process_frames(2) + var image: Image = root.get_texture().get_image() + var path: String = _active_capture_dir.path_join("%s.png" % name) + var error: Error = image.save_png(ProjectSettings.globalize_path(path)) + print("PHASE20B_CAPTURE_PROCESS_FRAME path=%s size=%s error=%d" % [path, image.get_size(), error]) + _check(error == OK and image.get_size() == root.size, "process-settled capture %s is saved at viewport size" % name) + + +func _save_capture_current_frame(name: String) -> void: + var image: Image = root.get_texture().get_image() + var path: String = _active_capture_dir.path_join("%s.png" % name) + var error: Error = image.save_png(ProjectSettings.globalize_path(path)) + print("PHASE20B_CAPTURE_CURRENT_FRAME path=%s size=%s error=%d" % [path, image.get_size(), error]) + _check(error == OK and image.get_size() == root.size, "current rendered capture %s is saved at viewport size" % name) + + +func _check(condition: bool, label: String) -> void: + if condition: + print("PHASE20B_GUI_CHECK PASS: %s" % label) + else: + _failures += 1 + push_error("PHASE20B_GUI_CHECK FAIL: %s" % label) + + +func _finish(stage: SandboxStage) -> void: + if _watchdog != null: + _watchdog.stop() + if stage != null and is_instance_valid(stage): + stage.queue_free() + await _wait_frames(2) + + +func _on_gui_watchdog_timeout() -> void: + push_error("Phase 20b GUI harness exceeded its 45-second frame watchdog") + print("PHASE20B_GUI_WATCHDOG mode=%s capture=%s paused=%s" % [str(_debug_stage.get("current_mode")) if _debug_stage != null and is_instance_valid(_debug_stage) else "missing", str(_debug_stage.get("_capture_kind")) if _debug_stage != null and is_instance_valid(_debug_stage) else "missing", paused]) + quit(2) diff --git a/tests/phase20b_gui_visual_capture.gd.uid b/tests/phase20b_gui_visual_capture.gd.uid new file mode 100644 index 0000000..d588278 --- /dev/null +++ b/tests/phase20b_gui_visual_capture.gd.uid @@ -0,0 +1 @@ +uid://bhqgkkobko7o0 diff --git a/tests/phase20b_step_spotlight_final.log b/tests/phase20b_step_spotlight_final.log new file mode 100644 index 0000000..e925620 --- /dev/null +++ b/tests/phase20b_step_spotlight_final.log @@ -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 diff --git a/tests/test_phase17a_prop_roles.gd b/tests/test_phase17a_prop_roles.gd index df11692..fbe7adc 100644 --- a/tests/test_phase17a_prop_roles.gd +++ b/tests/test_phase17a_prop_roles.gd @@ -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) diff --git a/tests/test_phase20a_identity.gd b/tests/test_phase20a_identity.gd index 140bf6b..4e56fb8 100644 --- a/tests/test_phase20a_identity.gd +++ b/tests/test_phase20a_identity.gd @@ -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", diff --git a/tests/test_phase20b_action_plan.gd b/tests/test_phase20b_action_plan.gd new file mode 100644 index 0000000..b6d97fa --- /dev/null +++ b/tests/test_phase20b_action_plan.gd @@ -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), + } diff --git a/tests/test_phase20b_action_plan.gd.uid b/tests/test_phase20b_action_plan.gd.uid new file mode 100644 index 0000000..25b9749 --- /dev/null +++ b/tests/test_phase20b_action_plan.gd.uid @@ -0,0 +1 @@ +uid://dbf2n56l7bjk7 diff --git a/tests/test_phase20b_pickup_action_marker_visual.gd b/tests/test_phase20b_pickup_action_marker_visual.gd new file mode 100644 index 0000000..d83908a --- /dev/null +++ b/tests/test_phase20b_pickup_action_marker_visual.gd @@ -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 diff --git a/tests/test_phase20b_pickup_action_marker_visual.gd.uid b/tests/test_phase20b_pickup_action_marker_visual.gd.uid new file mode 100644 index 0000000..a5a0bf1 --- /dev/null +++ b/tests/test_phase20b_pickup_action_marker_visual.gd.uid @@ -0,0 +1 @@ +uid://stxib3pjfl3i diff --git a/tests/test_phase20b_pickup_prop_stage_capture.gd b/tests/test_phase20b_pickup_prop_stage_capture.gd new file mode 100644 index 0000000..53e232d --- /dev/null +++ b/tests/test_phase20b_pickup_prop_stage_capture.gd @@ -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 diff --git a/tests/test_phase20b_pickup_prop_stage_capture.gd.uid b/tests/test_phase20b_pickup_prop_stage_capture.gd.uid new file mode 100644 index 0000000..3449083 --- /dev/null +++ b/tests/test_phase20b_pickup_prop_stage_capture.gd.uid @@ -0,0 +1 @@ +uid://dlfqvtmghrm0w diff --git a/tests/test_phase20b_pickup_prop_ui.gd b/tests/test_phase20b_pickup_prop_ui.gd new file mode 100644 index 0000000..9a88f15 --- /dev/null +++ b/tests/test_phase20b_pickup_prop_ui.gd @@ -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]) diff --git a/tests/test_phase20b_pickup_prop_ui.gd.uid b/tests/test_phase20b_pickup_prop_ui.gd.uid new file mode 100644 index 0000000..a276d9b --- /dev/null +++ b/tests/test_phase20b_pickup_prop_ui.gd.uid @@ -0,0 +1 @@ +uid://cyrngnepw6vvi diff --git a/tests/test_phase20b_set_down_surface_highlights.gd b/tests/test_phase20b_set_down_surface_highlights.gd new file mode 100644 index 0000000..58124c6 --- /dev/null +++ b/tests/test_phase20b_set_down_surface_highlights.gd @@ -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