Add Phase 4 Final Bugs Specification and Tests

- Introduced a new architecture specification for Phase 4 final bugs, detailing scope, decisions, runtime data contracts, action roles, execution lanes, speech ownership, and signal flow.
- Implemented changes in `scripts/action_registry.gd`, `scripts/action_editor.gd`, `scripts/sandbox_stage.gd`, `scripts/stage_gizmos.gd`, `scripts/stickman_factory.gd`, and `scripts/stickman_rig.gd` to address identified bugs.
- Created a new test suite `tests/test_phase4_final_bugs.gd` to validate the implementation, covering action roles, grid snapping, prop manipulation, reactive runtime lane, spawn pose preservation, and more.
- Added a unique identifier for the test file to ensure proper tracking and management.
This commit is contained in:
2026-09-13 23:20:01 -04:00
parent ce6bcecd00
commit 3a1d522ec9
11 changed files with 1281 additions and 79 deletions
+34 -12
View File
@@ -383,8 +383,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
and restores them for RECOVERING/ANIMATED. `apply_collision_reaction()` uses contact position and restores them for RECOVERING/ANIMATED. `apply_collision_reaction()` uses contact position
plus local force quadrant to choose mirrored side/upward/downward flinch direction, applies a plus local force quadrant to choose mirrored side/upward/downward flinch direction, applies a
capped `REACTION_MAX_ANGLE` offset from a captured base rotation, and decays without cumulative capped `REACTION_MAX_ANGLE` offset from a captured base rotation, and decays without cumulative
full rotations. PLAY→DIRECT restoration also resets ANIMATED rigs' authored transform, facing, full rotations. `snap_to_standing()` kills reaction/recovery tweens and restores captured base
IK markers, and torso rotation. Null-guards + `push_warning` prefixed `StickmanRig:` throughout. rotations synchronously; the stage gates collision dispatch to PLAY so delayed contacts cannot
recreate a flinch after STOP. PLAY→DIRECT restoration also resets ANIMATED rigs' authored
transform, facing, IK markers, and torso rotation. Null-guards + `push_warning` prefixed
`StickmanRig:` throughout.
- **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a - **Phase 3a director functionality:** adds navigation/walking, speech, an action queue, and a
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE, queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)` WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }`. Constants `FOOT_OFFSET := (0, -385)`
@@ -406,8 +409,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
countdown; `ragdoll` → `set_ragdoll(true)` then wait `is_ragdoll_at_rest()`; `recover` → countdown; `ragdoll` → `set_ragdoll(true)` then wait `is_ragdoll_at_rest()`; `recover` →
`request_recovery()` then wait `state == ANIMATED`). `is_ragdoll_at_rest()` is a new public `request_recovery()` then wait `state == ANIMATED`). `is_ragdoll_at_rest()` is a new public
query exposing the rest result **regardless of `auto_recover`** (the runner polls it; auto-recovery query exposing the rest result **regardless of `auto_recover`** (the runner polls it; auto-recovery
logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking()` (no stale walk/path logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking(false)` (no stale
state) and resets `_ragdoll_at_rest = false`. **Walk fix (2026-08-29, hybrid policy):** walk/path state while preserving the current solved pose for instant ragdoll handoff) and resets
`_ragdoll_at_rest = false`. **Walk fix (2026-08-29, hybrid policy):**
`_update_walking` defers all nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0` `_update_walking` defers all nav reads until `NavigationServer2D.map_get_iteration_id(...) != 0`
(map-sync guard), forces the path query via `get_next_path_position()` **before** any empty-path / (map-sync guard), forces the path query via `get_next_path_position()` **before** any empty-path /
finished check (the read-only `get_current_navigation_path()` alone never triggers a finished check (the read-only `get_current_navigation_path()` alone never triggers a
@@ -423,12 +427,18 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
(`global_position.move_toward`); movement composes with the canonical `walk_right` in-place limb (`global_position.move_toward`); movement composes with the canonical `walk_right` in-place limb
animation, played for every direction — X-mirrored by the rig root for LEFT (facing is set animation, played for every direction — X-mirrored by the rig root for LEFT (facing is set
explicitly by `walk_to()`/`set_facing_profile()`; `walk_left` is no longer used at runtime and explicitly by `walk_to()`/`set_facing_profile()`; `walk_left` is no longer used at runtime and
the animation `.:facing_profile` tracks are neutralized/removed). the animation `.:facing_profile` tracks are neutralized/removed). `_finish_walk()` captures the
eight current limb-bone rotations, calls `AnimationPlayer.stop(true)`, and leaves all six IK
marker properties at their current values; no marker reset occurs at queue completion. LEFT idle
disables mirrored TwoBoneIK while preserving that stopped marker/bone frame; it does not restore
the factory pose. `snap_to_standing()` is the explicit stage-reset path that restores the
factory-captured spawn pose.
- **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits - **Phase 4 triggers:** the `arrived` signal gained a `target: Vector2` payload (emits
`_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new `_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new
`enqueue_reactive(actions: Array[Dictionary]) -> void` appends reactive actions to the action `enqueue_reactive(actions: Array[Dictionary]) -> void` dispatches reactive actions into a
queue and, if the runner is `IDLE`, resumes at the **first newly-appended action** (no replay of private runtime lane without mutating `action_queue`: Overlay speech runs concurrently in one
the already-consumed queue prefix). Sequential Phase 3a queues are untouched. replaceable slot, Sequential actions cancel the current primary action while retaining authored
future work, and Takeover actions cancel both primary and overlay work before taking control.
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static - `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
`.stk` file into a live, rigged `master_rig.tscn` instance: `.stk` file into a live, rigged `master_rig.tscn` instance:
@@ -695,7 +705,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
**Grid/snap:** an optional `StageGrid` overlay (drawn behind `World`) plus `Grid`/`Snap` **Grid/snap:** an optional `StageGrid` overlay (drawn behind `World`) plus `Grid`/`Snap`
`CheckBox` toggles and a `Size` `SpinBox`, persisted to `user://sandbox_settings.json` `CheckBox` toggles and a `Size` `SpinBox`, persisted to `user://sandbox_settings.json`
(`grid_size`/`snap_to_grid`/`show_grid`); snapping rounds the placement cursor and translate (`grid_size`/`snap_to_grid`/`show_grid`); snapping rounds the placement cursor and translate
drags via `_snap_to_grid()` / `StageGizmos.snap_size`. The grid is visible only in EDIT mode drags via `_snap_to_grid()` / `StageGizmos.snap_size`. Gizmo input uses the exact mouse-event
coordinates converted by `_viewport_to_world()` for press, motion, and release. Frozen props
are synchronized to the edited `global_transform` through `PhysicsServer2D` during drag and
for three post-release settle frames. The grid is visible only in EDIT mode
(`_apply_grid_settings()` sets `_grid.visible = _show_grid and current_mode == EDIT`, re-applied (`_apply_grid_settings()` sets `_grid.visible = _show_grid and current_mode == EDIT`, re-applied
on mode change). **Touchpad:** `_input` handles `InputEventMagnifyGesture` (pinch zoom ×factor) on mode change). **Touchpad:** `_input` handles `InputEventMagnifyGesture` (pinch zoom ×factor)
and `InputEventPanGesture` (two-finger pan, `delta × 3 / zoom`) alongside the mouse wheel/middle and `InputEventPanGesture` (two-finger pan, `delta × 3 / zoom`) alongside the mouse wheel/middle
@@ -747,7 +760,12 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
so cycling the menus never walks down the screen at the live cursor. The anchor is cleared on so cycling the menus never walks down the screen at the live cursor. The anchor is cleared on
confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit confirm (`_finalize_rule`), cancel (`_cancel_rule_build`), or Direct-mode/flow exit
(`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path (`_clear_director_pending`), but **not** by `_reset_rule_builder()` (the Back-to-actions path
intentionally reuses it). intentionally reuses it). **Phase 4 final-bugs runtime:** rule actions are dispatched into
`StickmanRig`'s private runtime lane: Overlay speech runs concurrently in one replaceable
slot, Sequential actions interrupt the current primary action while retaining authored future
work, and Takeover actions cancel both before taking control. Collision callbacks are PLAY-only,
so delayed contacts after STOP cannot recreate a flinch. Walk To summaries read the waypoint
from `params.target`, separate from the top-level actor instance id.
- **Phase 4c area-entry reliability:** `entered_area` matching requires both the source movable - **Phase 4c area-entry reliability:** `entered_area` matching requires both the source movable
instance ID and target area instance ID; `source == -1` remains the explicit wildcard. Area instance ID and target area instance ID; `source == -1` remains the explicit wildcard. Area
entry uses visible world-AABB overlap for ANIMATED stickmen and unfrozen props, so an object can entry uses visible world-AABB overlap for ANIMATED stickmen and unfrozen props, so an object can
@@ -928,7 +946,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
directly by the root; `set_targets(nodes: Array[Node2D])` holds the current multi-selection, directly by the root; `set_targets(nodes: Array[Node2D])` holds the current multi-selection,
and `begin_translate_drag(node, world_pos)` starts a TRANSLATE drag that moves **all** selected and `begin_translate_drag(node, world_pos)` starts a TRANSLATE drag that moves **all** selected
nodes together (`drag_to` drives `global_position` with optional grid snap via `snap_size`, nodes together (`drag_to` drives `global_position` with optional grid snap via `snap_size`,
snapping the drag anchor and applying the same delta to the rest). The **rotate ring** is drawn snapping the drag anchor and applying the same delta to the rest). Translation accumulates from
the drag origin, so sub-grid mouse events remain smooth; snapping uses only `snap_size`, never
object dimensions. `transform_started(nodes)` fires at drag start and
`transform_updated(nodes)` fires on every drag update so the stage can synchronize frozen
physics bodies. The **rotate ring** is drawn
and hit-tested only when exactly one node is selected (`hit_test` → `Handle.ROTATE`). and hit-tested only when exactly one node is selected (`hit_test` → `Handle.ROTATE`).
The rotate drag is **absolute-from-start** — `drag_to()` sets `global_rotation` to The rotate drag is **absolute-from-start** — `drag_to()` sets `global_rotation` to
`_rotate_start_rotation + (current − start angle)`, with holding **Ctrl** snapping to `_rotate_start_rotation + (current − start angle)`, with holding **Ctrl** snapping to
+19
View File
@@ -192,6 +192,25 @@ Bug 1 is last because it is the largest and touches the most systems. It also be
- Bugs 2–8 are diagnosed defects with localized fixes. - Bugs 2–8 are diagnosed defects with localized fixes.
- Suggested order minimizes risk: local fixes first, cross-cutting refactor last. - Suggested order minimizes risk: local fixes first, cross-cutting refactor last.
## 14. Final implementation record
The approved implementation is complete. The runtime behavior is:
| Area | Final behavior |
| --- | --- |
| Reactive actions | `StickmanRig` keeps authored actions in the editor queue and runs rule actions in a private lane. Overlay speech runs concurrently in one replaceable slot; Sequential actions cancel the current primary action and continue with the authored future; Takeover actions cancel primary and overlay work before taking control. |
| Prop transforms | `SandboxStage` routes the exact mouse event position through `_viewport_to_world()`. `StageGizmos` writes translation/rotation continuously, and frozen `RigidBody2D` props are synchronized through `PhysicsServer2D` during the drag and for three post-release settle frames. |
| Grid snapping | Translation snap is based only on the configured grid size. The drag delta is accumulated from the original press, so sub-grid mouse events remain smooth instead of requiring an object-length drag. |
| Walk completion | LEFT-facing walks stop on the exact current animation marker/bone frame when the queue reaches the end; no factory-pose restore occurs at arrival. Explicit stage reset (`stop_queue()` + `snap_to_standing()`) restores the captured factory spawn pose. |
| Stop/flinch cleanup | Mode exit cancels reaction and recovery tweens, restores captured base rotations immediately, and collision dispatch is PLAY-only so delayed contacts cannot recreate a flinch after STOP. |
| Walk To summary | Rule summaries read the waypoint from `params.target` and keep the actor instance id in the top-level `target`, with type guards for malformed/legacy payloads. |
Validation recorded for this hand-off:
- Focused Phase 4 final-bugs regression: **81/81 passed**.
- Grid regression: **7/7 passed**.
- `scenes/sandbox_stage.tscn` headless smoke test: **passed**.
--- ---
**End of Phase 4 Bug Fixes — High-Level Plan** **End of Phase 4 Bug Fixes — High-Level Plan**
+14 -2
View File
@@ -22,6 +22,8 @@ var _text_edit: LineEdit = null
var _duration_spin: SpinBox = null var _duration_spin: SpinBox = null
var _target_label: Label = null var _target_label: Label = null
var _set_target_btn: Button = null var _set_target_btn: Button = null
var _stop_current_check: CheckBox = null
var _show_reactive_options: bool = false
var _has_target: bool = false var _has_target: bool = false
var _target_pos: Vector2 = Vector2.ZERO var _target_pos: Vector2 = Vector2.ZERO
@@ -45,7 +47,8 @@ func _unhandled_input(event: InputEvent) -> void:
## Opens the editor in "add" mode (empty, default type walk_to). ## Opens the editor in "add" mode (empty, default type walk_to).
func open_new() -> void: func open_new(show_reactive_options: bool = false) -> void:
_show_reactive_options = show_reactive_options
_mode = "new" _mode = "new"
_initial = {} _initial = {}
_has_target = false _has_target = false
@@ -57,7 +60,8 @@ func open_new() -> void:
## Opens the editor pre-filled from a flat queue action. ## Opens the editor pre-filled from a flat queue action.
func open_edit(action: Dictionary) -> void: func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void:
_show_reactive_options = show_reactive_options
_mode = "edit" _mode = "edit"
_initial = action.duplicate(true) _initial = action.duplicate(true)
var type := String(action.get("type", "walk_to")) var type := String(action.get("type", "walk_to"))
@@ -177,6 +181,7 @@ func _rebuild_params(type: String) -> void:
_duration_spin = null _duration_spin = null
_target_label = null _target_label = null
_set_target_btn = null _set_target_btn = null
_stop_current_check = null
match type: match type:
"walk_to": "walk_to":
@@ -203,6 +208,11 @@ func _rebuild_params(type: String) -> void:
_params_box.add_child(dlabel) _params_box.add_child(dlabel)
_duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0))) _duration_spin = _make_duration_spin(float(_initial.get("duration", 2.0)))
_params_box.add_child(_duration_spin) _params_box.add_child(_duration_spin)
if _show_reactive_options and ACTION_REGISTRY.is_overlay_eligible(type):
_stop_current_check = CheckBox.new()
_stop_current_check.text = "Stop what he's doing?"
_stop_current_check.button_pressed = bool(_initial.get("stop_current", false))
_params_box.add_child(_stop_current_check)
"wait": "wait":
var wlabel := Label.new() var wlabel := Label.new()
wlabel.text = "Duration (s):" wlabel.text = "Duration (s):"
@@ -247,6 +257,8 @@ func _on_ok_pressed() -> void:
"speak": "speak":
action["text"] = _text_edit.text if _text_edit != null else "" action["text"] = _text_edit.text if _text_edit != null else ""
action["duration"] = _duration_spin.value if _duration_spin != null else 2.0 action["duration"] = _duration_spin.value if _duration_spin != null else 2.0
if _show_reactive_options and _stop_current_check != null:
action["stop_current"] = _stop_current_check.button_pressed
"wait": "wait":
action["duration"] = _duration_spin.value if _duration_spin != null else 1.0 action["duration"] = _duration_spin.value if _duration_spin != null else 1.0
committed.emit(action) committed.emit(action)
+35 -3
View File
@@ -7,6 +7,8 @@ extends RefCounted
## the ActionEditor and the panels generate their UI from this registry, so no ## the ActionEditor and the panels generate their UI from this registry, so no
## other code changes are required. ## other code changes are required.
enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER }
const ACTION_TEMPLATES := { const ACTION_TEMPLATES := {
"walk_to": { "walk_to": {
"label": "Walk To", "label": "Walk To",
@@ -14,6 +16,7 @@ const ACTION_TEMPLATES := {
"params": [ "params": [
{ "key": "target", "type": "position", "required": true }, { "key": "target", "type": "position", "required": true },
], ],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
}, },
"speak": { "speak": {
"label": "Speak", "label": "Speak",
@@ -22,6 +25,7 @@ const ACTION_TEMPLATES := {
{ "key": "text", "type": "text", "required": true }, { "key": "text", "type": "text", "required": true },
{ "key": "duration", "type": "float", "default": 2.0 }, { "key": "duration", "type": "float", "default": 2.0 },
], ],
"reactive_role": ReactiveRole.OVERLAY, "overlay_eligible": true,
}, },
"wait": { "wait": {
"label": "Wait", "label": "Wait",
@@ -29,16 +33,19 @@ const ACTION_TEMPLATES := {
"params": [ "params": [
{ "key": "duration", "type": "float", "required": true }, { "key": "duration", "type": "float", "required": true },
], ],
"reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false,
}, },
"ragdoll": { "ragdoll": {
"label": "Ragdoll", "label": "Ragdoll",
"icon": "💥", "icon": "💥",
"params": [], "params": [],
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
}, },
"recover": { "recover": {
"label": "Recover", "label": "Recover",
"icon": "🔄", "icon": "🔄",
"params": [], "params": [],
"reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false,
}, },
} }
@@ -66,6 +73,21 @@ static func icon(type: String) -> String:
return String(tpl.get("icon", "")) return String(tpl.get("icon", ""))
static func is_overlay_eligible(type: String) -> bool:
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
return bool(tpl.get("overlay_eligible", false))
static func reactive_role(action: Dictionary) -> ReactiveRole:
var type: String = String(action.get("type", ""))
var tpl: Dictionary = ACTION_TEMPLATES.get(type, {})
var role: ReactiveRole = int(tpl.get("reactive_role", ReactiveRole.SEQUENTIAL))
var params: Dictionary = action.get("params", {})
if type == "speak" and bool(params.get("stop_current", action.get("stop_current", false))):
return ReactiveRole.SEQUENTIAL
return role
## Converts a flat queue action into a rule-action shape (adds `target` + nests ## Converts a flat queue action into a rule-action shape (adds `target` + nests
## params). `target_id` is the acting stickman's instance id. ## params). `target_id` is the acting stickman's instance id.
static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary: static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
@@ -76,6 +98,8 @@ static func to_rule_action(action: Dictionary, target_id: int) -> Dictionary:
"speak": "speak":
params["text"] = String(action.get("text", "")) params["text"] = String(action.get("text", ""))
params["duration"] = float(action.get("duration", 2.0)) params["duration"] = float(action.get("duration", 2.0))
if bool(action.get("stop_current", false)):
params["stop_current"] = true
"wait": "wait":
params["duration"] = float(action.get("duration", 0.0)) params["duration"] = float(action.get("duration", 0.0))
return { return {
@@ -92,11 +116,14 @@ static func from_rule_action(rule_action: Dictionary) -> Dictionary:
"walk_to": "walk_to":
return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) } return { "type": "walk_to", "target": params.get("target", Vector2.ZERO) }
"speak": "speak":
return { var speak: Dictionary = {
"type": "speak", "type": "speak",
"text": String(params.get("text", "")), "text": String(params.get("text", "")),
"duration": float(params.get("duration", 2.0)), "duration": float(params.get("duration", 2.0)),
} }
if bool(params.get("stop_current", false)):
speak["stop_current"] = true
return speak
"wait": "wait":
return { "type": "wait", "duration": float(params.get("duration", 0.0)) } return { "type": "wait", "duration": float(params.get("duration", 0.0)) }
_: _:
@@ -110,12 +137,17 @@ static func summarize(action: Dictionary) -> String:
var params: Dictionary = action.get("params", {}) var params: Dictionary = action.get("params", {})
match type: match type:
"walk_to": "walk_to":
var t: Vector2 = action.get("target", params.get("target", Vector2.ZERO)) # Rule actions use top-level `target` for the actor instance id and
# nested `params.target` for the waypoint. Prefer the nested value and
# type-guard legacy/malformed payloads so summaries never crash.
var raw_target: Variant = params.get("target", action.get("target", Vector2.ZERO))
var t: Vector2 = raw_target as Vector2 if raw_target is Vector2 else Vector2.ZERO
return "Walk To (%d, %d)" % [int(roundf(t.x)), int(roundf(t.y))] return "Walk To (%d, %d)" % [int(roundf(t.x)), int(roundf(t.y))]
"speak": "speak":
var text := String(action.get("text", params.get("text", ""))) var text := String(action.get("text", params.get("text", "")))
var dur := float(action.get("duration", params.get("duration", 2.0))) var dur := float(action.get("duration", params.get("duration", 2.0)))
return "Speak \"%s\" (%ss)" % [text, _fmt_duration(dur)] var suffix: String = " — stops current" if bool(params.get("stop_current", action.get("stop_current", false))) else ""
return "Speak \"%s\" (%ss)%s" % [text, _fmt_duration(dur), suffix]
"wait": "wait":
var wd := float(action.get("duration", params.get("duration", 0.0))) var wd := float(action.get("duration", params.get("duration", 0.0)))
return "Wait %ss" % _fmt_duration(wd) return "Wait %ss" % _fmt_duration(wd)
+122 -10
View File
@@ -188,6 +188,9 @@ var _ghost_holder: Node2D = null
## Phase 4b terrain drag-painting state. ## Phase 4b terrain drag-painting state.
var _terrain_dragging: bool = false var _terrain_dragging: bool = false
const TERRAIN_DRAG_THRESHOLD_PX: float = 6.0
var _terrain_press_world: Vector2 = Vector2.ZERO
var _terrain_painting: bool = false
var _terrain_anchor_cell: Vector2i = Vector2i.ZERO var _terrain_anchor_cell: Vector2i = Vector2i.ZERO
var _terrain_target_cell: Vector2i = Vector2i.ZERO var _terrain_target_cell: Vector2i = Vector2i.ZERO
var _drag_cells: Array[Vector2i] = [] var _drag_cells: Array[Vector2i] = []
@@ -210,6 +213,8 @@ var _authored: Dictionary = {}
## Physics frames remaining before re-asserting the authored restore (a safety ## Physics frames remaining before re-asserting the authored restore (a safety
## net for engines where a same-frame freeze + teleport does not stick). ## net for engines where a same-frame freeze + teleport does not stick).
var _restore_frames_left: int = 0 var _restore_frames_left: int = 0
var _edit_physics_targets: Dictionary = {} # { RigidBody2D: Transform2D }
var _edit_physics_settle_frames: int = 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Director state (Phase 3a) # Director state (Phase 3a)
@@ -220,6 +225,7 @@ var _context_rig: StickmanRig = null
var _pending_walk_target: bool = false var _pending_walk_target: bool = false
var _speak_dialog: AcceptDialog = null var _speak_dialog: AcceptDialog = null
var _speak_edit: LineEdit = null var _speak_edit: LineEdit = null
var _speak_stop_current_check: CheckBox = null
var _wait_dialog: AcceptDialog = null var _wait_dialog: AcceptDialog = null
var _wait_spin: SpinBox = null var _wait_spin: SpinBox = null
var _director_visuals = null # StageDirectorVisuals (preloaded) var _director_visuals = null # StageDirectorVisuals (preloaded)
@@ -414,6 +420,7 @@ func _process(delta: float) -> void:
func _physics_process(_delta: float) -> void: func _physics_process(_delta: float) -> void:
_sync_pending_editable_physics_transforms()
if _restore_frames_left > 0: if _restore_frames_left > 0:
_restore_frames_left -= 1 _restore_frames_left -= 1
_restore_authored_state() _restore_authored_state()
@@ -495,7 +502,10 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
return return
if mb.button_index != MOUSE_BUTTON_LEFT: if mb.button_index != MOUSE_BUTTON_LEFT:
return return
var world_pos := _camera.get_global_mouse_position() # Use the position carried by this exact event. Polling the global cursor
# here can lag behind an unhandled press/release, leaving the gizmo with an
# unchanged drag origin even though the pointer has moved.
var world_pos := _viewport_to_world(mb.position)
# Rule-builder click routing is highest priority (Phase 4). # Rule-builder click routing is highest priority (Phase 4).
if _rule_step != RuleStep.IDLE: if _rule_step != RuleStep.IDLE:
if mb.pressed: if mb.pressed:
@@ -548,16 +558,22 @@ func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
return return
if current_mode == StageMode.PLAY: if current_mode == StageMode.PLAY:
return return
var world_pos := _camera.get_global_mouse_position() var world_pos := _viewport_to_world(mm.position)
if _gizmos.is_dragging(): if _gizmos.is_dragging():
_gizmos.drag_to(world_pos) _gizmos.drag_to(world_pos)
_director_visuals.mark_dirty() _director_visuals.mark_dirty()
elif _terrain_dragging:
_update_terrain_drag(world_pos)
elif _box_selecting: elif _box_selecting:
_box_rect = Rect2(_box_start, world_pos - _box_start) _box_rect = Rect2(_box_start, world_pos - _box_start)
_gizmos.set_box_rect(_box_rect) _gizmos.set_box_rect(_box_rect)
elif not _is_mouse_over_ui() and current_mode == StageMode.EDIT: elif not _is_mouse_over_ui() and current_mode == StageMode.EDIT:
_selection.update_hover(world_pos) _selection.update_hover(world_pos)
func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
return get_viewport().get_canvas_transform().affine_inverse() * viewport_pos
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Mode management # Mode management
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -657,6 +673,8 @@ func _enter_direct_mode() -> void:
func _enter_play_mode() -> void: func _enter_play_mode() -> void:
_edit_physics_targets.clear()
_edit_physics_settle_frames = 0
_gizmos.set_enabled(false) _gizmos.set_enabled(false)
_selection.clear_selection() _selection.clear_selection()
set_placement_mode("") set_placement_mode("")
@@ -817,7 +835,7 @@ func _update_ghost_position() -> void:
return return
var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id) var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id)
if _is_terrain_placement(): if _is_terrain_placement():
pos = _terrain_cell_center(_world_to_terrain_cell(pos)) pos = _snap_to_grid(pos) if _snap_enabled else pos
elif _snap_enabled: elif _snap_enabled:
pos = _snap_to_grid(pos) pos = _snap_to_grid(pos)
_ghost.position = pos _ghost.position = pos
@@ -868,14 +886,20 @@ func _begin_terrain_drag(world_pos: Vector2) -> void:
_free_ghost() _free_ghost()
_drag_painted.clear() _drag_painted.clear()
_terrain_dragging = true _terrain_dragging = true
_terrain_painting = false
_terrain_press_world = world_pos
_terrain_anchor_cell = _world_to_terrain_cell(world_pos) _terrain_anchor_cell = _world_to_terrain_cell(world_pos)
_terrain_target_cell = _terrain_anchor_cell _terrain_target_cell = _terrain_anchor_cell
_update_terrain_drag(world_pos)
func _update_terrain_drag(world_pos: Vector2) -> void: func _update_terrain_drag(world_pos: Vector2) -> void:
if not _terrain_dragging: if not _terrain_dragging:
return return
if not _terrain_painting:
var zoom: float = _camera.zoom.x if _camera != null else 1.0
if (world_pos - _terrain_press_world).length() * zoom < TERRAIN_DRAG_THRESHOLD_PX:
return
_terrain_painting = true
var target := _world_to_terrain_cell(world_pos) var target := _world_to_terrain_cell(world_pos)
if Input.is_key_pressed(KEY_SHIFT): if Input.is_key_pressed(KEY_SHIFT):
var d := target - _terrain_anchor_cell var d := target - _terrain_anchor_cell
@@ -989,6 +1013,13 @@ func _free_terrain_ghosts() -> void:
func _commit_terrain_drag() -> void: func _commit_terrain_drag() -> void:
var placed: Array[Node2D] = [] var placed: Array[Node2D] = []
if not _terrain_painting:
var click_pos: Vector2 = _snap_to_grid(_terrain_press_world) if _snap_enabled else _terrain_press_world
var click_node: Node2D = _spawner.spawn(_placement_id, click_pos)
if click_node != null:
_finalize_placed_node(click_node)
object_placed.emit(click_node)
placed.append(click_node)
for cell: Vector2i in _drag_cells: for cell: Vector2i in _drag_cells:
if _classify_cell(cell) != 1: if _classify_cell(cell) != 1:
continue continue
@@ -1012,6 +1043,8 @@ func _cancel_terrain_drag() -> void:
func _end_terrain_drag() -> void: func _end_terrain_drag() -> void:
_terrain_dragging = false _terrain_dragging = false
_terrain_painting = false
_terrain_press_world = Vector2.ZERO
_drag_cells.clear() _drag_cells.clear()
_last_drag_cells.clear() _last_drag_cells.clear()
_drag_painted.clear() _drag_painted.clear()
@@ -1461,10 +1494,16 @@ func _build_ui() -> void:
_speak_dialog = AcceptDialog.new() _speak_dialog = AcceptDialog.new()
_speak_dialog.title = "Speak" _speak_dialog.title = "Speak"
_speak_dialog.confirmed.connect(_on_speak_confirmed) _speak_dialog.confirmed.connect(_on_speak_confirmed)
var speak_box := VBoxContainer.new()
_speak_dialog.add_child(speak_box)
_speak_edit = LineEdit.new() _speak_edit = LineEdit.new()
_speak_edit.placeholder_text = "Say something…" _speak_edit.placeholder_text = "Say something…"
_speak_edit.custom_minimum_size = Vector2(240.0, 0.0) _speak_edit.custom_minimum_size = Vector2(240.0, 0.0)
_speak_dialog.add_child(_speak_edit) speak_box.add_child(_speak_edit)
_speak_stop_current_check = CheckBox.new()
_speak_stop_current_check.text = "Stop what he's doing?"
_speak_stop_current_check.visible = false
speak_box.add_child(_speak_stop_current_check)
_speak_dialog.register_text_enter(_speak_edit) _speak_dialog.register_text_enter(_speak_edit)
ui.add_child(_speak_dialog) ui.add_child(_speak_dialog)
@@ -1568,6 +1607,8 @@ func _build_gizmo_layer() -> void:
_gizmos.name = "GizmoLayer" _gizmos.name = "GizmoLayer"
_gizmos.camera = _camera _gizmos.camera = _camera
_gizmos.transform_committed.connect(_on_transform_committed) _gizmos.transform_committed.connect(_on_transform_committed)
_gizmos.transform_started.connect(_on_gizmo_transform_started)
_gizmos.transform_updated.connect(_on_gizmo_transform_updated)
add_child(_gizmos) add_child(_gizmos)
@@ -1817,11 +1858,59 @@ func _on_hover_changed(node: Node2D) -> void:
func _on_transform_committed(nodes: Array[Node2D]) -> void: func _on_transform_committed(nodes: Array[Node2D]) -> void:
for node: Node2D in nodes: for node: Node2D in nodes:
if node is RigidBody2D:
_edit_physics_targets[node] = node.global_transform
_sync_editable_physics_transform(node)
_save_object_state(node) _save_object_state(node)
if node is TerrainBlock: if node is TerrainBlock:
_nav_dirty = true _nav_dirty = true
_rebuild_grid_cells() _rebuild_grid_cells()
_director_visuals.mark_dirty() _director_visuals.mark_dirty()
_edit_physics_settle_frames = 3
func _on_gizmo_transform_started(_nodes: Array[Node2D]) -> void:
_restore_frames_left = 0
_edit_physics_targets.clear()
_edit_physics_settle_frames = 0
func _on_gizmo_transform_updated(nodes: Array[Node2D]) -> void:
for node: Node2D in nodes:
if node is RigidBody2D:
_edit_physics_targets[node] = node.global_transform
_sync_editable_physics_transform(node)
func _sync_pending_editable_physics_transforms() -> void:
if _edit_physics_targets.is_empty() or current_mode != StageMode.EDIT:
return
for node: RigidBody2D in _edit_physics_targets.keys().duplicate():
if node == null or not is_instance_valid(node):
_edit_physics_targets.erase(node)
continue
node.global_transform = _edit_physics_targets[node]
_sync_editable_physics_transform(node)
if not _gizmos.is_dragging() and _edit_physics_settle_frames > 0:
_edit_physics_settle_frames -= 1
if _edit_physics_settle_frames == 0:
_edit_physics_targets.clear()
func _sync_editable_physics_transform(node: Node2D) -> void:
if not (node is RigidBody2D):
return
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
body.linear_velocity = Vector2.ZERO
body.angular_velocity = 0.0
var target_transform: Transform2D = body.global_transform
var rid := body.get_rid()
if rid.is_valid():
PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, target_transform)
body.global_transform = target_transform
body.reset_physics_interpolation()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Director tool (Phase 3a) # Director tool (Phase 3a)
@@ -1897,6 +1986,8 @@ func _on_action_popup_id_pressed(id: int) -> void:
_refresh_status() _refresh_status()
ACT_SPEAK: ACT_SPEAK:
_speak_edit.text = "" _speak_edit.text = ""
_speak_stop_current_check.visible = false
_speak_stop_current_check.button_pressed = false
_speak_dialog.popup_centered() _speak_dialog.popup_centered()
_speak_edit.grab_focus() _speak_edit.grab_focus()
ACT_WAIT: ACT_WAIT:
@@ -2020,7 +2111,7 @@ func _update_tooltip_position() -> void:
func _on_speak_confirmed() -> void: func _on_speak_confirmed() -> void:
if _rule_step == RuleStep.PARAMS: if _rule_step == RuleStep.PARAMS:
_set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0 }) _set_rule_action_params({ "text": _speak_edit.text, "duration": 2.0, "stop_current": _speak_stop_current_check.button_pressed })
_open_rule_more_popup() _open_rule_more_popup()
return return
if _context_rig == null or not is_instance_valid(_context_rig): if _context_rig == null or not is_instance_valid(_context_rig):
@@ -2264,6 +2355,8 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
"speak": "speak":
_rule_step = RuleStep.PARAMS _rule_step = RuleStep.PARAMS
_speak_edit.text = "" _speak_edit.text = ""
_speak_stop_current_check.visible = true
_speak_stop_current_check.button_pressed = false
_speak_dialog.popup_centered() _speak_dialog.popup_centered()
_speak_edit.grab_focus() _speak_edit.grab_focus()
"wait": "wait":
@@ -2345,6 +2438,9 @@ func _cancel_rule_build() -> void:
func _reset_rule_builder() -> void: func _reset_rule_builder() -> void:
if _speak_stop_current_check != null:
_speak_stop_current_check.visible = false
_speak_stop_current_check.button_pressed = false
_rule_step = RuleStep.IDLE _rule_step = RuleStep.IDLE
_rule_builder = {} _rule_builder = {}
_rule_context_rig = null _rule_context_rig = null
@@ -2409,6 +2505,11 @@ func _on_rig_speech_finished(rig: StickmanRig) -> void:
func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void: func _on_prop_collided(other: Node, normal: Vector2, position: Vector2, prop: PropBlock) -> void:
# Physics contacts can be delivered one frame after PLAY is stopped. Never
# recreate a flinch or dispatch a reactive rule in EDIT/DIRECT after the
# authoritative snap_to_standing() cleanup has run.
if current_mode != StageMode.PLAY:
return
if other == null or not is_instance_valid(other): if other == null or not is_instance_valid(other):
return return
if bool(other.get_meta("stickman_ragdoll_body", false)): if bool(other.get_meta("stickman_ragdoll_body", false)):
@@ -2521,11 +2622,21 @@ func _ids_match(a: int, b: Node2D) -> bool:
func _execute_rule_actions(rule: Dictionary) -> void: func _execute_rule_actions(rule: Dictionary) -> void:
var batches: Dictionary = {}
for a: Dictionary in rule.get("actions", []): for a: Dictionary in rule.get("actions", []):
var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig var rig := instance_from_id(int(a.get("target", -1))) as StickmanRig
if rig == null or not is_instance_valid(rig): if rig == null or not is_instance_valid(rig):
continue continue
rig.enqueue_reactive([_action_for_rig(a)]) var id: int = rig.get_instance_id()
if not batches.has(id):
batches[id] = { "rig": rig, "actions": [] }
(batches[id]["actions"] as Array).append(_action_for_rig(a))
for batch: Dictionary in batches.values():
var target_rig := batch.get("rig") as StickmanRig
if target_rig != null and is_instance_valid(target_rig):
var batch_actions: Array[Dictionary] = []
batch_actions.assign(batch.get("actions", []))
target_rig.enqueue_reactive(batch_actions)
## Converts a rule action into the queue-action shape the runner understands. ## Converts a rule action into the queue-action shape the runner understands.
@@ -2538,6 +2649,7 @@ func _action_for_rig(a: Dictionary) -> Dictionary:
"speak": "speak":
out["text"] = String(params.get("text", "")) out["text"] = String(params.get("text", ""))
out["duration"] = float(params.get("duration", 2.0)) out["duration"] = float(params.get("duration", 2.0))
out["stop_current"] = bool(params.get("stop_current", false))
"wait": "wait":
out["duration"] = float(params.get("duration", 0.0)) out["duration"] = float(params.get("duration", 0.0))
return out return out
@@ -3082,9 +3194,9 @@ func _open_action_editor(kind: String, flat: Dictionary, index: int, rig: Stickm
elif (kind == "rule_add" or kind == "rule_edit") and _rule_editor != null: elif (kind == "rule_add" or kind == "rule_edit") and _rule_editor != null:
_rule_editor.hide() _rule_editor.hide()
if flat.is_empty(): if flat.is_empty():
_action_editor.open_new() _action_editor.open_new(kind == "rule_add" or kind == "rule_edit")
else: else:
_action_editor.open_edit(flat) _action_editor.open_edit(flat, kind == "rule_add" or kind == "rule_edit")
func _on_action_editor_committed(flat: Dictionary) -> void: func _on_action_editor_committed(flat: Dictionary) -> void:
@@ -3204,7 +3316,7 @@ func _apply_grid_settings() -> void:
_grid.enabled = _show_grid _grid.enabled = _show_grid
_grid.visible = _show_grid and current_mode == StageMode.EDIT _grid.visible = _show_grid and current_mode == StageMode.EDIT
if _gizmos != null: if _gizmos != null:
_gizmos.snap_size = _grid_size if _snap_enabled else 0.0 _gizmos.set_snap_size(_grid_size if _snap_enabled else 0.0)
func _set_edit_controls_visible(visible: bool) -> void: func _set_edit_controls_visible(visible: bool) -> void:
+26 -6
View File
@@ -22,6 +22,8 @@ enum Handle { NONE, TRANSLATE, ROTATE }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
signal transform_committed(nodes: Array[Node2D]) signal transform_committed(nodes: Array[Node2D])
signal transform_started(nodes: Array[Node2D])
signal transform_updated(nodes: Array[Node2D])
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Style constants (screen-space px; divided by zoom for world-space drawing) # Style constants (screen-space px; divided by zoom for world-space drawing)
@@ -53,6 +55,8 @@ var _hovered: Node2D = null
var _dragging: Handle = Handle.NONE var _dragging: Handle = Handle.NONE
var _drag_last: Vector2 = Vector2.ZERO var _drag_last: Vector2 = Vector2.ZERO
var _drag_anchor: Node2D = null var _drag_anchor: Node2D = null
var _translate_drag_origin: Vector2 = Vector2.ZERO
var _translate_start_positions: Dictionary = {}
var _rotate_start_rotation: float = 0.0 var _rotate_start_rotation: float = 0.0
var _rotate_start_angle: float = 0.0 var _rotate_start_angle: float = 0.0
var _rotate_center: Vector2 = Vector2.ZERO var _rotate_center: Vector2 = Vector2.ZERO
@@ -78,6 +82,10 @@ func set_enabled(enabled: bool) -> void:
queue_redraw() queue_redraw()
func set_snap_size(size: float) -> void:
snap_size = maxf(size, 0.0)
func set_box_rect(rect: Rect2) -> void: func set_box_rect(rect: Rect2) -> void:
_box_rect = rect _box_rect = rect
queue_redraw() queue_redraw()
@@ -105,31 +113,39 @@ func begin_drag(world_pos: Vector2) -> void:
_rotate_center = STAGE_SELECTION.get_world_aabb(_targets[0]).get_center() _rotate_center = STAGE_SELECTION.get_world_aabb(_targets[0]).get_center()
_rotate_start_rotation = _targets[0].global_rotation _rotate_start_rotation = _targets[0].global_rotation
_rotate_start_angle = (world_pos - _rotate_center).angle() _rotate_start_angle = (world_pos - _rotate_center).angle()
transform_started.emit(_targets.duplicate())
## Begins a translate drag anchored on `node` (used when an object body is ## Begins a translate drag anchored on `node` (used when an object body is
## clicked). The drag moves the whole current selection (`_targets`); if `node` ## clicked). The drag moves the whole current selection (`_targets`); if `node`
## is not currently selected, the drag targets just `node`. ## is not currently selected, the drag targets just `node`.
func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void: func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void:
if node == null or not is_instance_valid(node):
return
_drag_anchor = node _drag_anchor = node
if not _targets.has(node): if not _targets.has(node):
_targets = [node] _targets = [node]
_dragging = Handle.TRANSLATE _dragging = Handle.TRANSLATE
_drag_last = world_pos _drag_last = world_pos
_translate_drag_origin = world_pos
_translate_start_positions.clear()
for target: Node2D in _targets:
if target != null and is_instance_valid(target):
_translate_start_positions[target] = target.global_position
transform_started.emit(_targets.duplicate())
func drag_to(world_pos: Vector2) -> void: func drag_to(world_pos: Vector2) -> void:
if _dragging == Handle.NONE or _targets.is_empty(): if _dragging == Handle.NONE or _targets.is_empty():
return return
var delta := world_pos - _drag_last
if _dragging == Handle.TRANSLATE: if _dragging == Handle.TRANSLATE:
var actual := delta var actual := world_pos - _translate_drag_origin
if snap_size > 0.0 and _drag_anchor != null and is_instance_valid(_drag_anchor): if snap_size > 0.0 and _drag_anchor != null and is_instance_valid(_drag_anchor):
var old := _drag_anchor.global_position var anchor_start: Vector2 = _translate_start_positions.get(_drag_anchor, _drag_anchor.global_position)
actual = _snap(old + delta) - old actual = _snap(anchor_start + actual) - anchor_start
for node: Node2D in _targets: for node: Node2D in _targets:
if is_instance_valid(node): if is_instance_valid(node) and _translate_start_positions.has(node):
node.global_position += actual node.global_position = (_translate_start_positions[node] as Vector2) + actual
elif _dragging == Handle.ROTATE: elif _dragging == Handle.ROTATE:
if _targets.size() == 1 and is_instance_valid(_targets[0]): if _targets.size() == 1 and is_instance_valid(_targets[0]):
var target := _targets[0] var target := _targets[0]
@@ -140,6 +156,7 @@ func drag_to(world_pos: Vector2) -> void:
new_rotation = snappedf(new_rotation, deg_to_rad(15.0)) new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
target.global_rotation = new_rotation target.global_rotation = new_rotation
_drag_last = world_pos _drag_last = world_pos
transform_updated.emit(_targets.duplicate())
queue_redraw() queue_redraw()
@@ -148,6 +165,7 @@ func end_drag() -> void:
transform_committed.emit(_targets.duplicate()) transform_committed.emit(_targets.duplicate())
_dragging = Handle.NONE _dragging = Handle.NONE
_drag_anchor = null _drag_anchor = null
_translate_start_positions.clear()
func is_dragging() -> bool: func is_dragging() -> bool:
@@ -194,4 +212,6 @@ func _rotate_ring_radius(rect: Rect2) -> float:
func _snap(v: Vector2) -> Vector2: func _snap(v: Vector2) -> Vector2:
if snap_size <= 0.0:
return v
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size) return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
+1
View File
@@ -37,6 +37,7 @@ static func load_stk(path: String) -> Dictionary:
static func spawn_from_data(stk_data: Dictionary) -> StickmanRig: static func spawn_from_data(stk_data: Dictionary) -> StickmanRig:
var rig := RIG_SCENE.instantiate() as StickmanRig var rig := RIG_SCENE.instantiate() as StickmanRig
StkRigAdapter.apply(stk_data, rig) StkRigAdapter.apply(stk_data, rig)
rig.initialize_spawn_pose_from_skeleton()
return rig return rig
+261 -46
View File
@@ -8,6 +8,8 @@ extends Node2D
## Body/* draw order. Non-@tool: node resolution, flag writes and z-order ## Body/* draw order. Non-@tool: node resolution, flag writes and z-order
## reordering run only at runtime (_ready + setters on a live instance). ## reordering run only at runtime (_ready + setters on a live instance).
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Enums # Enums
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -455,6 +457,7 @@ var _captured_pose: Dictionary = {} # { String : {pos, rot, half} } (
var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture var _captured_landing_center: Vector2 = Vector2.ZERO # ragdoll torso's world center at capture
var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius) var _captured_ground_y: float = 0.0 # torso's ground-contact line (center.y + radius)
var _stand_up_tween: Tween = null var _stand_up_tween: Tween = null
var _stand_up_generation: int = 0
var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } } var _limb_bones: Dictionary = {} # { String: { "upper": Bone2D, "lower": Bone2D } }
var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up var _recovery_bones: Array[Dictionary] = [] # [{ "bone": Bone2D, "target": float }] bones tweened in stand-up
var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target var _pre_ragdoll_state: Dictionary = {} # captured at _enter_ragdoll; recovery's END target
@@ -466,6 +469,8 @@ var _collision_lengths: Dictionary = {}
var _collision_visuals: Dictionary = {} var _collision_visuals: Dictionary = {}
var _reaction_bones: Dictionary = {} var _reaction_bones: Dictionary = {}
var _impact_reactions: Dictionary = {} var _impact_reactions: Dictionary = {}
var _spawn_pose: Dictionary = {}
var _spawn_limb_rotations: Dictionary = {}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Director / navigation state (Phase 3a) # Director / navigation state (Phase 3a)
@@ -476,6 +481,9 @@ var action_queue: Array[Dictionary] = []
var _runner_state: RunnerState = RunnerState.IDLE var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1 var _current_index: int = -1
var _run_queue: Array[Dictionary] = []
var _current_run_item: Dictionary = {}
var _run_cursor: int = 0
var _stop_requested: bool = false var _stop_requested: bool = false
var _nav_agent: NavigationAgent2D = null var _nav_agent: NavigationAgent2D = null
@@ -499,6 +507,8 @@ var _ragdoll_at_rest: bool = false
var _speech_bubble = null # SpeechBubble (preloaded script) var _speech_bubble = null # SpeechBubble (preloaded script)
var _speech_active: bool = false var _speech_active: bool = false
var _speech_time_left: float = 0.0 var _speech_time_left: float = 0.0
var _primary_speech: Dictionary = {}
var _overlay_speech: Dictionary = {}
var _phase_timer: float = 0.0 var _phase_timer: float = 0.0
@@ -737,6 +747,7 @@ func snap_to_standing() -> void:
elif state == RigState.RECOVERING: elif state == RigState.RECOVERING:
_cancel_recovery() _cancel_recovery()
_clear_impact_reactions(true) _clear_impact_reactions(true)
_hide_speech()
_restore_standing_markers() _restore_standing_markers()
# Re-show the kinematic puppet and re-enable IK. # Re-show the kinematic puppet and re-enable IK.
if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null: if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null:
@@ -747,28 +758,93 @@ func snap_to_standing() -> void:
else: else:
_restore_head_lookat_state() _restore_head_lookat_state()
_recovery_bones.clear() _recovery_bones.clear()
_rest_timer = 0.0
_stabilize_timer = 0.0
_ragdoll_at_rest = false
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
_pre_ragdoll_state.clear()
_probe_top_y = 0.0
_probe_bottom_y = 0.0
_probe_exclude.clear()
_set_collision_bodies_enabled(true) _set_collision_bodies_enabled(true)
if _body_container != null and is_instance_valid(_body_container): if _body_container != null and is_instance_valid(_body_container):
_body_container.visible = true _body_container.visible = true
_body_container.modulate.a = 1.0 _body_container.modulate.a = 1.0
state = RigState.ANIMATED state = RigState.ANIMATED
_apply_idle_limb_solver_mode(_spawn_limb_rotations)
if previous_state != RigState.ANIMATED: if previous_state != RigState.ANIMATED:
state_changed.emit(int(state)) state_changed.emit(int(state))
## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change). ## Writes STAND_POSE onto the 6 IK-target markers (no tween, no state change).
func _restore_standing_markers() -> void: func _restore_standing_markers() -> void:
for marker_name: String in STAND_POSE: var pose: Dictionary = _spawn_pose if not _spawn_pose.is_empty() else STAND_POSE
for marker_name: String in pose:
var marker := _get_ik_marker(marker_name) var marker := _get_ik_marker(marker_name)
if marker == null: if marker == null:
continue continue
var target: Dictionary = STAND_POSE[marker_name] var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position) marker.position = target.get("pos", marker.position)
if marker_name == "Torso": if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation) marker.rotation = target.get("rot", marker.rotation)
## Captures adapted skeleton endpoints as the initial IK marker pose. Called by
## StickmanFactory before the instance enters the tree; safe for factory bypasses.
func initialize_spawn_pose_from_skeleton() -> void:
var skeleton := get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
if skeleton == null:
push_warning("StickmanRig: cannot initialize spawn pose; missing Skeleton2D.")
return
var pose: Dictionary = {}
var torso := skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
if torso != null:
pose["Torso"] = { "pos": to_local(torso.global_position), "rot": torso.rotation }
var head := skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head != null:
var head_tip: Vector2 = head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
pose["Head"] = { "pos": to_local(head_tip), "rot": 0.0 }
var paths: Dictionary = {
"Left_Hand": "Torso/LeftUpperArm/LeftLowerArm",
"Right_Hand": "Torso/RightUpperArm/RightLowerArm",
"Left_Leg": "Torso/LeftUpperLeg/LeftLowerLeg",
"Right_Leg": "Torso/RightUpperLeg/RightLowerLeg",
}
for marker_name: String in paths:
var bone := skeleton.get_node_or_null(NodePath(String(paths[marker_name]))) as Bone2D
if bone == null:
push_warning("StickmanRig: missing spawn-pose bone '%s'." % String(paths[marker_name]))
continue
var endpoint: Vector2 = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
pose[marker_name] = { "pos": to_local(endpoint), "rot": 0.0 }
if pose.size() == IK_TARGET_PATHS.size():
# The targets must be written before the rig enters the tree; merely
# caching them would let the modification stack solve once against the
# adapter's generic targets and change the placed silhouette.
for marker_name: String in pose:
var marker := get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
if marker == null:
push_warning("StickmanRig: missing spawn-pose marker '%s'." % marker_name)
return
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
marker.rotation = target.get("rot", marker.rotation)
_spawn_pose = pose.duplicate(true)
_spawn_limb_rotations.clear()
for joint: String in LIMB_BONE_PATHS:
var pair: Array = LIMB_BONE_PATHS[joint]
var upper := skeleton.get_node_or_null(NodePath(String(pair[0]))) as Bone2D
var lower := skeleton.get_node_or_null(NodePath(String(pair[1]))) as Bone2D
if upper != null and lower != null:
_spawn_limb_rotations[joint] = {
"upper": upper.rotation,
"lower": lower.rotation,
}
## Applies the same velocity delta to every ragdoll body via a mass-scaled ## Applies the same velocity delta to every ragdoll body via a mass-scaled
## central impulse, preserving the ragdoll's internal structure. No-op outside ## central impulse, preserving the ragdoll's internal structure. No-op outside
## RAGDOLL mode. Used by the physics harness "Knock Up" button. ## RAGDOLL mode. Used by the physics harness "Knock Up" button.
@@ -1063,10 +1139,48 @@ func _apply_profile() -> void:
# scale. An X-mirror does not affect depth (draw order), so # scale. An X-mirror does not affect depth (draw order), so
# Z_ORDER_BY_PROFILE is unchanged. # Z_ORDER_BY_PROFILE is unchanged.
scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0) scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2(1.0, 1.0)
_sync_speech_bubble_mirror()
_apply_head_lookat_mirror_mode() _apply_head_lookat_mirror_mode()
_apply_idle_limb_solver_mode()
facing_profile_changed.emit(int(facing_profile)) facing_profile_changed.emit(int(facing_profile))
## Godot's TwoBoneIK solver is not mirror-invariant under a negative X scale.
## While a LEFT-facing walk is active the animation must keep driving IK, but
## once the walk stops an enabled solver can settle into a pose that never
## existed in the clip. Pin the limbs to the exact frame captured at stop.
func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void:
var pin_mirrored_idle := facing_profile == FacingProfile.LEFT \
and state == RigState.ANIMATED and not _walking
var source: Dictionary = rotations.duplicate(true) if not rotations.is_empty() else _capture_limb_rotations()
_set_limb_mods_enabled(not pin_mirrored_idle)
if not pin_mirrored_idle:
return
for joint: String in source:
var bones: Dictionary = _limb_bones.get(joint, {})
var saved: Dictionary = source[joint]
var upper := bones.get("upper") as Bone2D
var lower := bones.get("lower") as Bone2D
if upper != null and is_instance_valid(upper):
upper.rotation = float(saved.get("upper", upper.rotation))
if lower != null and is_instance_valid(lower):
lower.rotation = float(saved.get("lower", lower.rotation))
func _capture_limb_rotations() -> Dictionary:
var rotations: Dictionary = {}
for joint: String in _limb_bones:
var bones: Dictionary = _limb_bones[joint]
var upper := bones.get("upper") as Bone2D
var lower := bones.get("lower") as Bone2D
if upper != null and lower != null and is_instance_valid(upper) and is_instance_valid(lower):
rotations[joint] = {
"upper": upper.rotation,
"lower": lower.rotation,
}
return rotations
## The SkeletonModification2DLookAt that aims the Head bone is NOT ## The SkeletonModification2DLookAt that aims the Head bone is NOT
## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the ## mirror-invariant: under the whole-rig Y-axis mirror (FacingProfile.LEFT) the
## root's negative X scale reflects the head-bone frame, and the LookAt writes a ## root's negative X scale reflects the head-bone frame, and the LookAt writes a
@@ -1195,7 +1309,9 @@ func _enter_ragdoll() -> void:
_capture_pre_ragdoll_state() _capture_pre_ragdoll_state()
_impact_reactions.clear() _impact_reactions.clear()
_set_collision_bodies_enabled(false) _set_collision_bodies_enabled(false)
_cancel_walking() # Preserve the live solved pose for the instant ragdoll handoff. A normal
# queue cancellation restores the idle markers/limbs, which would pop here.
_cancel_walking(false)
_ragdoll_at_rest = false _ragdoll_at_rest = false
# Instant handoff: stop the player without resetting it (keep_state) and # Instant handoff: stop the player without resetting it (keep_state) and
# build the ragdoll from the CURRENT solved bone positions while the IK # build the ragdoll from the CURRENT solved bone positions while the IK
@@ -1335,6 +1451,7 @@ func _reanchor_root_to_landing() -> void:
## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a ## Kills any in-flight stand-up tween so a re-entry into RAGDOLL starts from a
## clean slate. ## clean slate.
func _cancel_recovery() -> void: func _cancel_recovery() -> void:
_stand_up_generation += 1
if _stand_up_tween != null and _stand_up_tween.is_valid(): if _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.kill() _stand_up_tween.kill()
_stand_up_tween = null _stand_up_tween = null
@@ -1700,6 +1817,8 @@ func _get_ik_marker(name: String) -> Marker2D:
## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only ## stored in `_recovery_bones` by `_snap_skeleton_to_pose()`; this function only
## starts the marker tween and appends one bone-rotation tween per entry. ## starts the marker tween and appends one bone-rotation tween per entry.
func _play_stand_up() -> void: func _play_stand_up() -> void:
_stand_up_generation += 1
var generation: int = _stand_up_generation
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE) var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION) _stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
if _stand_up_tween != null: if _stand_up_tween != null:
@@ -1708,7 +1827,7 @@ func _play_stand_up() -> void:
if bone == null or not is_instance_valid(bone): if bone == null or not is_instance_valid(bone):
continue continue
_stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION) _stand_up_tween.tween_property(bone, "rotation", float(entry.get("target", bone.rotation)), STAND_UP_DURATION)
_stand_up_tween.finished.connect(_on_stand_up_finished) _stand_up_tween.finished.connect(_on_stand_up_finished.bind(generation))
## Tweens the 6 IK markers from their current (captured) values to the target ## Tweens the 6 IK markers from their current (captured) values to the target
@@ -1730,7 +1849,10 @@ func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
## Stand-up tween complete: settle into ANIMATED. ## Stand-up tween complete: settle into ANIMATED.
func _on_stand_up_finished() -> void: func _on_stand_up_finished(generation: int) -> void:
if generation != _stand_up_generation or state != RigState.RECOVERING:
return
var recovered_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
# Restore the pre-ragdoll facing profile + per-joint bend flags so the # Restore the pre-ragdoll facing profile + per-joint bend flags so the
# re-enabled IK holds the exact pre-ragdoll pose. # re-enabled IK holds the exact pre-ragdoll pose.
set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile)))) set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(facing_profile))))
@@ -1748,6 +1870,7 @@ func _on_stand_up_finished() -> void:
_body_container.modulate.a = 1.0 _body_container.modulate.a = 1.0
_set_collision_bodies_enabled(true) _set_collision_bodies_enabled(true)
state = RigState.ANIMATED state = RigState.ANIMATED
_apply_idle_limb_solver_mode(recovered_limb_rotations)
state_changed.emit(int(state)) state_changed.emit(int(state))
@@ -2017,6 +2140,9 @@ func walk_to(target: Vector2, speed: float = -1.0) -> void:
set_facing_profile(FacingProfile.LEFT) set_facing_profile(FacingProfile.LEFT)
elif dx > 0.5: elif dx > 0.5:
set_facing_profile(FacingProfile.RIGHT) set_facing_profile(FacingProfile.RIGHT)
# LEFT idle pins the captured limb pose because mirrored TwoBoneIK is not
# stable. Re-enable the solvers before the walk animation starts driving.
_set_limb_mods_enabled(true)
# Always play the canonical walk_right clip. For LEFT the rig root is # Always play the canonical walk_right clip. For LEFT the rig root is
# X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored; # X-mirrored (whole-rig Y-axis mirror) and the same clip plays mirrored;
# walk_left is no longer used at runtime. # walk_left is no longer used at runtime.
@@ -2041,7 +2167,7 @@ func _update_walking(delta: float) -> void:
if not _walking: if not _walking:
return return
if state != RigState.ANIMATED: if state != RigState.ANIMATED:
_cancel_walking() _cancel_walking(false)
return return
# Defer all nav reads until the map has actually synchronized. An unsynced # Defer all nav reads until the map has actually synchronized. An unsynced
# agent reports an empty, finished path (map iteration id == 0), which would # agent reports an empty, finished path (map iteration id == 0), which would
@@ -2123,9 +2249,8 @@ func _update_walking(delta: float) -> void:
]) ])
## Re-asserts the standing pose one extra physics frame after a walk ends, so ## Legacy deferred marker reset hook. Current walk completion deliberately
## any residual walk-animation body bob (a ±12.5 px torso keyframe) is not left ## leaves this at zero because the final animation frame must remain visible.
## on the markers when the animation stop and marker restore race.
func _settle_walk_markers() -> void: func _settle_walk_markers() -> void:
if _walk_settle_frames > 0: if _walk_settle_frames > 0:
_walk_settle_frames -= 1 _walk_settle_frames -= 1
@@ -2143,19 +2268,24 @@ func _finish_walk(reason: String = "") -> void:
global_position.distance_to(_walk_target_feet + FOOT_OFFSET), global_position.distance_to(_walk_target_feet + FOOT_OFFSET),
map_iter, map_iter,
]) ])
# Freeze on the exact solved animation frame at arrival. LEFT cannot leave
# TwoBoneIK running under the root mirror, so capture the bone rotations
# before stopping and pin that same frame after disabling the solvers.
var final_limb_rotations: Dictionary = _capture_limb_rotations()
if _anim_player != null and is_instance_valid(_anim_player): if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop() _anim_player.stop(true)
_restore_standing_markers()
_walk_settle_frames = 1
_walk_done = true
_walking = false _walking = false
_apply_idle_limb_solver_mode(final_limb_rotations)
_walk_settle_frames = 0
_walk_done = true
arrived.emit(_walk_target_feet) arrived.emit(_walk_target_feet)
func _cancel_walking() -> void: func _cancel_walking(restore_idle_pose: bool = true) -> void:
_walk_dbg("cancel (state=%s)" % RigState.keys()[state]) _walk_dbg("cancel (state=%s)" % RigState.keys()[state])
var current_limb_rotations: Dictionary = _capture_limb_rotations()
if _anim_player != null and is_instance_valid(_anim_player): if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop() _anim_player.stop(true)
if _nav_agent != null and _nav_anchor != null: if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position _nav_agent.target_position = _nav_anchor.global_position
_walking = false _walking = false
@@ -2163,6 +2293,8 @@ func _cancel_walking() -> void:
_walk_mode_latched = false _walk_mode_latched = false
_walk_latch_probe_frames = 0 _walk_latch_probe_frames = 0
_walk_settle_frames = 0 _walk_settle_frames = 0
if restore_idle_pose:
_apply_idle_limb_solver_mode(current_limb_rotations)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2172,32 +2304,65 @@ func _cancel_walking() -> void:
## Show the speech bubble with `text` for `duration` seconds; auto-hides and ## Show the speech bubble with `text` for `duration` seconds; auto-hides and
## emits speech_finished. Lazily creates the SpeechBubble child on first use. ## emits speech_finished. Lazily creates the SpeechBubble child on first use.
func speak(text: String, duration: float) -> void: func speak(text: String, duration: float) -> void:
_primary_speech = { "text": text, "time_left": maxf(duration, 0.0) }
_speech_active = true
_speech_time_left = maxf(duration, 0.0)
_show_visible_speech()
func _speak_overlay(text: String, duration: float) -> void:
_overlay_speech = { "text": text, "time_left": maxf(duration, 0.0) }
_show_visible_speech()
func _show_visible_speech() -> void:
if _speech_bubble == null or not is_instance_valid(_speech_bubble): if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() _speech_bubble = SPEECH_BUBBLE_SCRIPT.new()
_speech_bubble.name = "SpeechBubble" _speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET _speech_bubble.position = SPEECH_BUBBLE_OFFSET
add_child(_speech_bubble) add_child(_speech_bubble)
_speech_bubble.show_text(text) _sync_speech_bubble_mirror()
_speech_active = true var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
_speech_time_left = maxf(duration, 0.0) if not visible_speech.is_empty():
_speech_bubble.show_text(String(visible_speech.get("text", "")))
func _update_speech(delta: float) -> void: func _update_speech(delta: float) -> void:
if not _speech_active: var visible_owner_changed := false
return if not _primary_speech.is_empty():
_speech_time_left -= delta _primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
if _speech_time_left <= 0.0: _speech_time_left = float(_primary_speech["time_left"])
_hide_speech() if _speech_time_left <= 0.0:
speech_finished.emit() _primary_speech.clear()
_speech_active = false
speech_finished.emit()
if not _overlay_speech.is_empty():
_overlay_speech["time_left"] = float(_overlay_speech.get("time_left", 0.0)) - delta
if float(_overlay_speech["time_left"]) <= 0.0:
_overlay_speech.clear()
visible_owner_changed = true
speech_finished.emit()
if _overlay_speech.is_empty() and _primary_speech.is_empty():
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
elif visible_owner_changed and _overlay_speech.is_empty():
_show_visible_speech()
func _hide_speech() -> void: func _hide_speech() -> void:
_speech_active = false _speech_active = false
_speech_time_left = 0.0 _speech_time_left = 0.0
_primary_speech.clear()
_overlay_speech.clear()
if _speech_bubble != null and is_instance_valid(_speech_bubble): if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble() _speech_bubble.hide_bubble()
func _sync_speech_bubble_mirror() -> void:
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.scale = Vector2(-1.0, 1.0) if facing_profile == FacingProfile.LEFT else Vector2.ONE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Action queue (Phase 3a) # Action queue (Phase 3a)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2239,26 +2404,52 @@ func queue_size() -> int:
func enqueue_reactive(actions: Array[Dictionary]) -> void: func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty(): if actions.is_empty():
return return
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
for i: int in action_queue.size():
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
_run_cursor = 0
var primary: Array[Dictionary] = []
for action: Dictionary in actions: for action: Dictionary in actions:
action["reactive"] = true var role: int = ACTION_REGISTRY.reactive_role(action)
var start := action_queue.size() if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
action_queue.append_array(actions) _speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
queue_changed.emit() continue
if _runner_state == RunnerState.IDLE: if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
# Resume at the first appended action (not at index 0), so any queued _cancel_current_primary()
# sequential actions are skipped over rather than replayed. _overlay_speech.clear()
_current_index = start - 1 _hide_speech()
_runner_state = RunnerState.EXECUTING var kept: Array[Dictionary] = []
_action_phase = ActionPhase.NONE for item: Dictionary in _run_queue.slice(_run_cursor):
_stop_requested = false if not bool(item.get("reactive", false)):
kept.append(item)
_run_queue = kept
_run_cursor = 0
primary.clear()
primary.append(action.duplicate(true))
else:
primary.append(action.duplicate(true))
if primary.is_empty():
return
_cancel_current_primary()
var injected: Array[Dictionary] = []
for action: Dictionary in primary:
injected.append({ "action": action, "queue_index": -1, "reactive": true })
_run_queue = injected + _run_queue.slice(_run_cursor)
_run_cursor = 0
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
## Drops rule-injected ("reactive") actions from the queue, restoring the ## Drops rule-injected ("reactive") actions from the queue, restoring the
## authored sequential queue after a Play session. No-op while the runner is ## authored sequential queue after a Play session. No-op while the runner is
## executing (callers invoke it on mode exit, after stop_queue()). ## executing (callers invoke it on mode exit, after stop_queue()).
func clear_reactive_actions() -> void: func clear_reactive_actions() -> void:
if _runner_state == RunnerState.EXECUTING: var runtime_kept: Array[Dictionary] = []
return for item: Dictionary in _run_queue:
if not bool(item.get("reactive", false)):
runtime_kept.append(item)
_run_queue = runtime_kept
_run_cursor = mini(_run_cursor, _run_queue.size())
var kept: Array[Dictionary] = [] var kept: Array[Dictionary] = []
for action: Dictionary in action_queue: for action: Dictionary in action_queue:
if not bool(action.get("reactive", false)): if not bool(action.get("reactive", false)):
@@ -2284,14 +2475,21 @@ func start_queue() -> void:
_runner_state = RunnerState.EXECUTING _runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE _action_phase = ActionPhase.NONE
_current_index = -1 _current_index = -1
_run_queue.clear()
for i: int in action_queue.size():
_run_queue.append({ "action": action_queue[i].duplicate(true), "queue_index": i, "reactive": false })
_run_cursor = 0
_current_run_item.clear()
_stop_requested = false _stop_requested = false
## Abort the current action and return to IDLE. Does not emit queue_finished. ## Abort the current action and return to IDLE. Does not emit queue_finished.
func stop_queue() -> void: func stop_queue() -> void:
_stop_requested = true _stop_requested = true
_cancel_walking() _cancel_current_primary()
_hide_speech() _hide_speech()
_run_queue.clear()
_current_run_item.clear()
_runner_state = RunnerState.IDLE _runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE _action_phase = ActionPhase.NONE
_current_index = -1 _current_index = -1
@@ -2315,7 +2513,7 @@ func _update_runner(delta: float) -> void:
if _walk_done: if _walk_done:
_finish_action() _finish_action()
ActionPhase.SPEAKING: ActionPhase.SPEAKING:
if not _speech_active: if _primary_speech.is_empty():
_finish_action() _finish_action()
ActionPhase.WAITING: ActionPhase.WAITING:
_phase_timer -= delta _phase_timer -= delta
@@ -2330,14 +2528,16 @@ func _update_runner(delta: float) -> void:
func _advance_to_next_action() -> void: func _advance_to_next_action() -> void:
_current_index += 1 if _run_cursor >= _run_queue.size():
if _current_index >= action_queue.size():
_runner_state = RunnerState.IDLE _runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE _action_phase = ActionPhase.NONE
_current_index = -1 _current_index = -1
queue_finished.emit() queue_finished.emit()
return return
var action := action_queue[_current_index] _current_run_item = _run_queue[_run_cursor]
_run_cursor += 1
var action: Dictionary = _current_run_item.get("action", {})
_current_index = int(_current_run_item.get("queue_index", -1))
if DEBUG_WALK: if DEBUG_WALK:
print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))]) print("[runner] start idx=%d type=%s" % [_current_index, String(action.get("type", ""))])
action_started.emit(action, _current_index) action_started.emit(action, _current_index)
@@ -2372,8 +2572,23 @@ func _begin_action(action: Dictionary) -> void:
func _finish_action() -> void: func _finish_action() -> void:
if DEBUG_WALK and _current_index >= 0 and _current_index < action_queue.size(): var finished: Dictionary = _current_run_item.get("action", {})
print("[runner] finish idx=%d type=%s" % [_current_index, String(action_queue[_current_index].get("type", ""))]) var index: int = int(_current_run_item.get("queue_index", -1))
if _current_index >= 0 and _current_index < action_queue.size():
action_finished.emit(action_queue[_current_index], _current_index)
_action_phase = ActionPhase.NONE _action_phase = ActionPhase.NONE
_current_run_item.clear()
_current_index = -1
if not finished.is_empty():
action_finished.emit(finished, index)
func _cancel_current_primary() -> void:
_cancel_walking()
_primary_speech.clear()
_speech_active = false
_speech_time_left = 0.0
_phase_timer = 0.0
_action_phase = ActionPhase.NONE
_current_run_item.clear()
_current_index = -1
if _overlay_speech.is_empty() and _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
+437
View File
@@ -0,0 +1,437 @@
# Phase 4 Final Bugs — Architecture Specification
Source: `plans/PHASE_4_FINAL_BUGS.md`
## Scope and resolved decisions
This phase fixes the eight reported Sandbox Stage defects without changing the `.stk` format,
persisting stages/rules, or changing the master rig scene.
The four open questions are resolved as follows:
1. **One overlay slot per rig.** Do not generalize speech to an overlay list. A newer overlay
speak replaces the prior overlay immediately; replacement/cancellation does not emit the old
speech's completion signals.
2. **Mixed rule actions execute in authored order.** For `[overlay speak, sequential walk]`, the
bubble starts first, then the walk interrupts the current *primary* action. Sequential actions
do not cancel overlays, so the speak and walk continue together. Only a Takeover cancels an
overlay. This interpretation is required by the plan's “say and walk away” acceptance case.
3. **The ghost stays static.** A live ghost would create ten animated collision proxies,
navigation state, IK work, and animation work for a disposable preview. Instead, initialize the
placed rig's IK targets from the same pre-solve skeleton pose displayed by the static ghost.
4. **Prop freeze remains kinematic.** `StageGizmos` already routes both translation and rotation
to `global_position` / `global_rotation`, and `FREEZE_MODE_KINEMATIC` is the correct editable
physics state. The competing writer is the stage's three-frame `_restore_authored_state()`
pass. An edit drag must cancel that deferred restore before its first transform write, and the
final transform must be synchronized to both the Node2D and physics body before it is saved.
## Runtime data contracts
### Action roles
Add to `ActionRegistry`:
```gdscript
enum ReactiveRole { SEQUENTIAL, OVERLAY, TAKEOVER }
static func reactive_role(action: Dictionary) -> ReactiveRole
static func is_overlay_eligible(type: String) -> bool
```
`ACTION_TEMPLATES` gains typed semantic metadata:
```gdscript
"walk_to": { ..., "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false }
"wait": { ..., "reactive_role": ReactiveRole.SEQUENTIAL, "overlay_eligible": false }
"speak": { ..., "reactive_role": ReactiveRole.OVERLAY, "overlay_eligible": true }
"ragdoll": { ..., "reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false }
"recover": { ..., "reactive_role": ReactiveRole.TAKEOVER, "overlay_eligible": false }
```
For a reactive `speak`, `params.stop_current == true` promotes the resolved role from OVERLAY to
SEQUENTIAL. Missing/false is OVERLAY. The flag is ignored for every other type. Authored queue
actions remain primary sequential actions; roles govern only actions injected by a rule.
The transient rule-action payload remains:
```gdscript
{
"type": String,
"target": int,
"params": {
# existing type-specific fields,
"stop_current": bool, # speak only; default false
},
}
```
`ActionRegistry.to_rule_action()` / `from_rule_action()` / `summarize()` preserve this flag for
speak. The summary adds a short suffix such as `— stops current` only when true. Queue speak
actions do not retain the flag.
### Primary execution lane
`StickmanRig.action_queue` stays the authored, editor-visible queue. Reactive commands must no
longer be appended to its tail. Add a private runtime deque:
```gdscript
var _run_queue: Array[Dictionary] = []
# item: { "action": Dictionary, "queue_index": int, "reactive": bool }
var _current_run_item: Dictionary = {}
var _run_cursor: int = 0
```
At `start_queue()`, rebuild `_run_queue` from deep duplicates of `action_queue`; authored items
carry their original `queue_index`, reactive items use `-1`. This snapshot prevents editor
mutation from invalidating a running cursor. Existing `action_started` / `action_finished`
signals keep their signatures; reactive actions emit index `-1`. Canceled actions emit neither
`action_finished` nor `arrived`/`speech_finished` as a cancellation side effect.
Keep the public name and signature used by the stage:
```gdscript
func enqueue_reactive(actions: Array[Dictionary]) -> void
func clear_reactive_actions() -> void
```
`enqueue_reactive()` processes one rig's rule actions in array order:
- **Overlay:** start/replace the overlay slot immediately; do not mutate `_run_queue`, cursor,
primary phase, walk, wait timer, or current primary speech.
- **Sequential:** cancel the active primary action without completion signals, prepend this and
subsequent same-batch primary actions to the runtime deque, and start the first immediately.
Already pending authored actions remain in order; the canceled current action is abandoned,
not restarted. After injected actions finish, execution continues at the next authored item.
- **Takeover:** cancel the active primary action, clear pending reactive primary items, cancel the
overlay slot, prepend the Takeover, and start it immediately. Pending authored items are retained
and remain paused until the Takeover phase resolves. `ragdoll` resolves at the existing
`is_ragdoll_at_rest()` boundary; `recover` resolves when state returns to `ANIMATED` (and remains
a safe immediate completion if invoked while already animated).
If a batch contains an earlier primary followed by a Takeover, the Takeover discards/cancels that
earlier primary per its role. Actions after the Takeover are retained in authored order. An
overlay after a Takeover may start alongside it; an overlay before it is canceled on Takeover
entry. Later matching rules are processed after earlier rules and therefore may supersede them.
`_cancel_current_primary()` owns phase cleanup:
```gdscript
func _cancel_current_primary() -> void
```
It stops walk/animation without emitting `arrived`, clears wait state, cancels a primary speech
without emitting `speech_finished`, resets `_action_phase`, and clears `_current_run_item`. It
does not touch authored/pending actions or the overlay slot. `_finish_action()` must first clear
the current phase/item, then emit its natural completion signals; this prevents an event handler
that injects a new action synchronously from being overwritten by stale post-signal cleanup.
`stop_queue()` cancels the primary lane and overlay, clears `_run_queue`, and returns to IDLE.
`clear_reactive_actions()` clears any remaining runtime reactive items and defensively removes
legacy `{reactive=true}` entries from `action_queue`; it is safe/idempotent after `stop_queue()`.
Thus the existing Edit-mode sequence still guarantees that no reactive work survives PLAY.
### Speech ownership
Keep one rendered `SpeechBubble`, but separate logical ownership:
```gdscript
var _primary_speech: Dictionary = {} # {text, time_left}; only while SPEAKING
var _overlay_speech: Dictionary = {} # {text, time_left}; at most one
```
The overlay is the visible owner while present. If it expires while a primary speech remains,
redraw the primary text for its remaining duration; otherwise hide. Both timers may advance, but
there is never more than one overlay or one visible bubble. A new overlay replaces only
`_overlay_speech`. A new primary speech replaces/cancels only the current primary through normal
primary-interrupt rules. Natural expiry emits `speech_finished` for that speech; replacement,
interrupt, mode exit, and Takeover cleanup emit no false completion event.
## Target files and changes
### `scripts/action_registry.gd` — Bug 1
- Add `ReactiveRole`, template metadata, `reactive_role()`, and `is_overlay_eligible()`.
- Preserve `params.stop_current` through rule/flat conversion for speak only.
- `summarize()` reads a Walk To waypoint from `params.target` (the top-level `target` is the
actor instance id) and type-guards malformed/legacy values before formatting the Vector2.
- Make all missing metadata/default flags backward compatible: old rules load as overlay speak;
unknown action types resolve as SEQUENTIAL and are still skipped safely by the runner.
### `scripts/action_editor.gd` — Bug 1
Change the opening API to tell the shared editor whether it is editing a rule consequence:
```gdscript
func open_new(show_reactive_options: bool = false) -> void
func open_edit(action: Dictionary, show_reactive_options: bool = false) -> void
```
Add `_stop_current_check: CheckBox`. Create it only in the speak parameter section when
`show_reactive_options` and `ActionRegistry.is_overlay_eligible("speak")` are true. Label it
exactly **“Stop what he's doing?”**, default OFF, and prefill from `action.stop_current`.
`_on_ok_pressed()` writes `stop_current` only in this context. No checkbox appears for queue
actions or any non-speak type.
### `scripts/sandbox_stage.gd` — Bugs 1, 3, 4, 7, 8
**Reactive dispatch and UI**
- `_execute_rule_actions()` groups rule actions by target rig while preserving each rig's action
order, normalizes them with `_action_for_rig()`, then calls `rig.enqueue_reactive(batch)` once
per rig. This prevents multiple sequential actions from interrupting one another merely because
the stage delivered the batch one item at a time.
- `_action_for_rig()` copies `params.stop_current` for speak.
- Call `ActionEditor.open_new/open_edit(show_reactive_options)` with true only for `rule_add` /
`rule_edit`.
- The legacy quick rule-builder speak dialog gets `_speak_stop_current_check: CheckBox` beneath
its LineEdit. It is visible only when `_rule_step == RuleStep.PARAMS`, resets OFF for a new rule
action, and writes `params.stop_current`. Direct queue Speak continues to hide/ignore it.
**Immediate mode-exit restoration**
- Preserve `_enter_edit_shared()` order: `stop_queue()` then `snap_to_standing()` for every rig,
then restore authored root transforms.
- Set `_restore_frames_left = 0` before starting any gizmo translation/rotation drag. Deferred
physics-frame restoration is a mode-entry safety net, never an edit-time competing writer.
- `snap_to_standing()` (specified below) is the sole rig pose-cleanup call; do not start a recovery
or decay tween on EDIT/DIRECT entry.
- `_on_prop_collided()` returns immediately outside `StageMode.PLAY`; a contact delivered after
STOP cannot recreate a flinch or dispatch a reactive rule.
**Prop manipulation**
- Keep props `freeze_mode = FREEZE_MODE_KINEMATIC` and `freeze = true` throughout EDIT/DIRECT.
- Use the exact `InputEventMouseButton.position` / `InputEventMouseMotion.position`, converted by
`_viewport_to_world()`, for gizmo press, drag, and release coordinates; do not poll a lagging
global cursor position.
- On gizmo start, cancel deferred authored restoration. During drag, let `StageGizmos` continue to
write the generic Node2D transform and synchronize frozen `RigidBody2D` nodes immediately.
- At `_on_transform_committed()`, for a frozen `RigidBody2D`, reassign its final
`global_transform` and use `PhysicsServer2D.body_set_state(node.get_rid(),
PhysicsServer2D.BODY_STATE_TRANSFORM, node.global_transform)` to synchronize the physics body.
`_sync_pending_editable_physics_transforms()` reasserts that transform during physics and for
three settle frames after release, then `_save_object_state()` records the committed transform.
Terrain/navigation/grid/director dirty behavior remains unchanged.
- A null/freed RID is guarded; other Node2D types retain the current path.
**Snapping split**
- `_grid_size` is the only source for object placement and `StageGizmos` translation snap.
`_terrain_stride()` is private to drag-paint cell generation/AABB classification.
- Add screen-stable press state:
```gdscript
const TERRAIN_DRAG_THRESHOLD_PX := 6.0
var _terrain_press_world: Vector2 = Vector2.ZERO
var _terrain_painting: bool = false
```
- On terrain LMB press, record raw cursor world position but do not quantize/commit. Enter paint
mode only after `(current_world - press_world).length() * camera.zoom.x >= threshold`.
- If released below threshold, place exactly once through standard placement semantics:
Snap ON -> `_snap_to_grid(_terrain_press_world)`; Snap OFF -> raw press position. Do not call
`_world_to_terrain_cell()` for this path.
- If threshold is crossed, initialize and update the existing Bresenham/block-stride drag path;
drag-paint stays block-unit aligned independent of Snap.
- Idle terrain ghost position follows the same single-placement rule (grid when Snap ON, raw when
OFF). Once painting begins, `_ghost_array` continues to preview block-stride cells.
- `_end_terrain_drag()` clears press/painting state and creates only one replacement cursor ghost.
### `scripts/stage_gizmos.gd` — Bugs 4 and 7
Add narrow APIs and keep all snap calculation scalar/grid based:
```gdscript
signal transform_started(nodes: Array[Node2D])
signal transform_updated(nodes: Array[Node2D])
func set_snap_size(size: float) -> void
```
Emit `transform_started` after either drag type is successfully initialized; the stage uses it to
zero `_restore_frames_left`. `set_snap_size()` clamps non-positive values to `0.0`. `_snap()` must
use only this value. `SandboxStage._apply_grid_settings()` passes `_grid_size` when Snap is ON and
`0.0` otherwise. Translation stores the press origin and all starting node positions, quantizes
the anchor's accumulated displacement from that origin, and applies the same delta to the rest of
the selection. This preserves smooth movement across sub-grid mouse events and prevents object
dimensions from affecting snap distance. `transform_updated` is emitted on every drag update.
No spawner/template/AABB dependency may be added to `StageGizmos`.
### `scripts/stickman_factory.gd` and `scripts/stickman_rig.gd` — Bugs 2 and 5
After `StkRigAdapter.apply()` and before the rig enters the scene tree, call:
```gdscript
func initialize_spawn_pose_from_skeleton() -> void
```
This method resolves the torso/head and four lower limb bones directly and computes the six
rig-local IK marker targets from the current adapted skeleton:
- Torso marker = current torso bone rig-local position/rotation.
- Hand/leg marker = corresponding lower bone's far endpoint. Compute the endpoint in global/rig
space using the bone transform, `Bone2D.length`, and `bone_angle`; do not use hardcoded limb
lengths or the pre-adapter scene dimensions.
- Head target = a point on the current head aim direction, preserving the authored head rotation.
Write those marker values before the first skeleton-modification solve, then deep-copy them into:
```gdscript
var _spawn_pose: Dictionary = {} # marker name -> {pos: Vector2, rot: float}
```
`_restore_standing_markers()` uses `_spawn_pose`, with existing `STAND_POSE` only as a malformed
rig/factory-bypass fallback. The method is idempotent and null-guarded with `StickmanRig:` warnings.
It does not enable/disable the stack or play animation.
The ghost remains configured exactly as today (IK stack disabled, animation stopped, collision
proxies disabled). Because the live rig's targets now preserve that same adapted skeleton pose,
its first solved frame, placement frame, later EDIT/DIRECT reset, and ghost silhouette match.
`master_rig.tscn` and `StkRigAdapter` are not modified.
### `scripts/stickman_rig.gd` — Bugs 1, 3, and 6
In addition to the queue/speech changes above:
**Pose cleanup**
- Expand `snap_to_standing()` into the authoritative no-animation restoration boundary. It kills
`_stand_up_tween`, restores every active impact bone to its stored `base_rotation` before
clearing `_impact_reactions`, restores `_spawn_pose` marker values directly, clears recovery
caches/timers, restores IK/head modification state, shows the body, enables collision proxies,
and publishes `ANIMATED` only after all writes are complete.
- `_cancel_recovery()` remains a kill (never `custom_step()`/finish). All mode-exit cancellation
paths are silent: no `action_finished`, `speech_finished`, `arrived`, or `queue_finished`.
- Guard `_on_stand_up_finished()` against a killed/stale tween callback by checking that state is
still `RECOVERING` and that the callback corresponds to the current tween generation/reference.
- `_finish_walk()` captures the current eight limb-bone rotations, calls `AnimationPlayer.stop(true)`,
and leaves all six IK marker properties at their current values; no marker reset occurs at queue
completion. A LEFT-facing idle disables mirrored TwoBoneIK while preserving that stopped
marker/bone frame, so queue completion never restores the factory pose. The explicit stage reset
path (`stop_queue()` followed by `snap_to_standing()`) restores the factory-captured `_spawn_pose`
and limb rotations.
**LEFT speech mirror**
Add:
```gdscript
func _sync_speech_bubble_mirror() -> void
```
The bubble stays under the rig and at `SPEECH_BUBBLE_OFFSET`. Counter-mirror its local X scale:
`Vector2(-1, 1)` for LEFT and `Vector2.ONE` for RIGHT/FORWARD. Call the helper after lazy bubble
creation and from `_apply_profile()`. Do not move the bubble under `Body`, a bone, or a physics
node. This cancels only the root X reflection; the bubble still follows the rig and camera as
before. `stickman_speech_bubble.gd` needs no drawing/layout change.
## Signal flow
```text
collision/arrival/speech/action event
-> SandboxStage._handle_event (all matching rules, stored order)
-> SandboxStage._execute_rule_actions (ordered batches per target rig)
-> StickmanRig.enqueue_reactive
OVERLAY -> replace/start overlay slot; primary untouched
SEQUENTIAL -> cancel primary; prepend/start; authored future retained
TAKEOVER -> cancel primary + overlays + pending reactive; start now
-> natural completion only
-> existing action_finished / speech_finished / queue_finished signals
```
No existing public signal signature changes. `StageGizmos.transform_started` and
`StageGizmos.transform_updated` are the new gizmo signals.
## Cleanup and failure behavior
- Mode exit is idempotent even during walk, wait, primary/overlay speech, reaction decay,
ragdoll, or recovery.
- Takeover and mode exit hide speech immediately without producing a `speech_finished` rule edge.
- A deleted/stale target rig is skipped by the stage. Unknown actions warn/complete safely.
- Reactive work never mutates the authored queue and cannot appear in the Queue Panel.
- Ghosts free their navigation/IK/collision children with the rig; no live-ghost processing is
introduced.
- Frozen props remain physical objects with zero velocities; their committed transform becomes
the new authored transform and survives later PLAY -> EDIT restoration.
- Terrain click cancel/Escape frees all previews and places nothing. A click places one block;
a paint drag never also places the raw-click block.
## Layout and theme
The only UI addition is **“Stop what he's doing?”** under speak parameters in rule contexts:
both the shared `ActionEditor` and the legacy quick rule-builder dialog. Default is unchecked.
No scene-node, theme-token, font, cursor, grid-drawing, or speech-bubble style changes are needed;
the affected UI is built dynamically in scripts.
## Developer validation after approval
Add `tests/test_phase4_final_bugs.gd` (or split by subsystem if needed) and run Godot 4.7.1
`--check-only` on every changed script plus the test. Then run the new suite and the existing
Phase 3c/4b/4c/4.5 suites.
Required automated cases:
1. Registry role lookup, old-rule default overlay, speak promotion, payload round-trip, and
checkbox visibility/default/prefill in queue vs rule contexts.
2. Walk + overlay speak keeps walking; replacing overlay leaves one slot; promoted speak cancels
walk; after it finishes the next authored action runs.
3. Sequential reactive action during primary speak cancels it without `speech_finished`; mixed
`[overlay speak, walk]` runs concurrently and in order.
4. Ragdoll Takeover is entered during the collision callback/update (not after walk), clears the
overlay and pending reactive work, preserves authored future items, and resumes only after the
Takeover boundary. Recover follows the same policy.
5. Natural completions emit once; all interrupt/Takeover/mode-exit cancellations emit none.
`clear_reactive_actions()` leaves only authored queue actions and no runtime commands.
6. Factory-created rig pre-solve pose, static ghost pose, placed first-solved pose, and a later
`snap_to_standing()` pose have matching hand/ankle/head/torso transforms within tolerance.
7. Collision reaction followed immediately by EDIT/DIRECT restores the exact captured base bone
rotation in one frame; recovery tween is killed and a stale completion cannot mutate state.
8. A frozen crate/plank translates and rotates through gizmos, survives three physics frames,
updates authored state on commit, and restores to that new transform after PLAY.
9. LEFT speech text has a positive world-transform determinant/readable orientation; toggling
LEFT/RIGHT while visible updates immediately and does not move the anchor.
10. Object translate snap uses exactly configured `_grid_size` for terrain, prop, area, and rig,
independent of each object's AABB. Snap OFF preserves drag delta.
11. Single terrain click: Snap OFF equals raw cursor; Snap ON equals stage grid; neither uses the
block width/height. Idle ghost matches the committed position.
12. Terrain drag above threshold retains block-unit Bresenham tiling and existing occupancy
classification with Snap both ON and OFF; click/drag paths each place exactly once.
Headless checks should include the exact skill command form, e.g.:
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_rig.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script "res://tests/test_phase4_final_bugs.gd"
```
## User verification in the Godot editor
Run `scenes/sandbox_stage.tscn` with F6 and verify:
1. The placement ghost and placed stickman have the same arms, bent legs, head, and silhouette;
switching EDIT -> DIRECT does not change that pose.
2. Place and manipulate a crate/plank and a Step in EDIT: body drag and rotate ring both work;
PLAY -> EDIT returns them to the newly committed transforms.
3. Set grid size to a visibly distinct value (for example 25): all selected objects translate on
25 px increments, not their widths/heights. With Snap OFF, motion is continuous.
4. Single-click Ground/Ramp/Step with Snap OFF at an off-grid point, then with Snap ON; verify raw
vs 25 px placement. Drag-paint still tiles by block footprint in both modes.
5. LEFT-facing speech remains readable and switches orientation correctly while the bubble is
already visible.
6. Exercise every Bug 1 acceptance sequence from the source plan, including collision-triggered
immediate ragdoll, overlay and promoted speech, speak interrupted by walk, mixed speak+walk,
Takeover cleanup, and authored-queue continuation.
7. Trigger a visible collision flinch and immediately switch to DIRECT/EDIT: the rig snaps to its
base pose with no visible head return tween.
## Final implementation and validation record
The feature was verified and the implementation is complete. The focused regression suite reports
**81/81 passed**, the grid regression suite reports **7/7 passed**, and the headless
`scenes/sandbox_stage.tscn` smoke test **passed**.
The final behavior includes exact event-coordinate prop translation/rotation, continuous frozen
body synchronization during drag plus three post-release settle frames, origin-based accumulated
grid snapping, current-frame preservation at LEFT walk queue completion, explicit factory-pose
restoration on stage reset, immediate silent flinch/recovery cleanup with PLAY-only collision
dispatch, reactive Overlay/Sequential/Takeover execution lanes, and type-safe Walk To summaries
that separate actor ids from waypoint vectors.
+331
View File
@@ -0,0 +1,331 @@
extends SceneTree
## Focused Phase 4 Final Bugs regression checks. Run with the project console
## executable and `--headless --script res://tests/test_phase4_final_bugs.gd`.
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const GIZMOS := preload("res://scripts/stage_gizmos.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const ACTION_EDITOR_SCENE := preload("res://scenes/action_editor.tscn")
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_action_roles_and_payloads()
await _test_action_editor_reactive_option()
_test_grid_snap_api()
await _test_stage_prop_drag_and_rotation()
await _test_reactive_runtime_lane()
await _test_spawn_pose_survives_first_solve()
await _test_left_walk_finish_pose()
await _test_stop_clears_head_reaction()
_test_source_contracts()
if _failures == 0:
print("RESULT: ALL PASSED (%d checks)" % _checks)
quit(0)
else:
print("RESULT: %d failures of %d checks" % [_failures, _checks])
quit(1)
func _test_action_roles_and_payloads() -> void:
_check(ACTION_REGISTRY.reactive_role({ "type": "walk_to" }) == ACTION_REGISTRY.ReactiveRole.SEQUENTIAL, "walk is sequential")
_check(ACTION_REGISTRY.reactive_role({ "type": "speak", "params": {} }) == ACTION_REGISTRY.ReactiveRole.OVERLAY, "old speak rules default overlay")
_check(ACTION_REGISTRY.reactive_role({ "type": "speak", "params": { "stop_current": true } }) == ACTION_REGISTRY.ReactiveRole.SEQUENTIAL, "promoted speak is sequential")
_check(ACTION_REGISTRY.reactive_role({ "type": "ragdoll" }) == ACTION_REGISTRY.ReactiveRole.TAKEOVER, "ragdoll is takeover")
var rule: Dictionary = ACTION_REGISTRY.to_rule_action({ "type": "speak", "text": "Ouch", "duration": 1.0, "stop_current": true }, 42)
_check(bool((rule.get("params", {}) as Dictionary).get("stop_current", false)), "stop-current survives rule conversion")
var flat: Dictionary = ACTION_REGISTRY.from_rule_action(rule)
_check(bool(flat.get("stop_current", false)), "stop-current survives flat conversion")
_check(ACTION_REGISTRY.summarize(rule).contains("stops current"), "summary identifies promoted speech")
var walk_rule: Dictionary = ACTION_REGISTRY.to_rule_action({ "type": "walk_to", "target": Vector2(123.0, -45.0) }, 42)
_check(ACTION_REGISTRY.summarize(walk_rule) == "Walk To (123, -45)",
"rule walk summary uses params.target instead of the actor instance id")
func _test_grid_snap_api() -> void:
var gizmos := GIZMOS.new()
gizmos.set_snap_size(-4.0)
_check(is_zero_approx(gizmos.snap_size), "negative snap size clamps to zero")
gizmos.set_snap_size(35.0)
_check(is_equal_approx(gizmos.snap_size, 35.0), "gizmo stores stage grid snap size")
var target := Node2D.new()
root.add_child(target)
var targets: Array[Node2D] = [target]
gizmos.set_targets(targets)
gizmos.begin_translate_drag(target, Vector2.ZERO)
gizmos.drag_to(Vector2(5.0, 5.0))
gizmos.drag_to(Vector2(10.0, 10.0))
gizmos.drag_to(Vector2(15.0, 15.0))
_check(target.global_position == Vector2.ZERO, "sub-grid drag events accumulate without premature movement")
gizmos.drag_to(Vector2(20.0, 20.0))
_check(target.global_position == Vector2(35.0, 35.0), "translation snap uses configured grid, not object bounds")
target.free()
gizmos.free()
func _test_stage_prop_drag_and_rotation() -> void:
root.size = Vector2i(1280, 720)
var stage := STAGE_SCENE.instantiate()
root.add_child(stage)
await process_frame
var prop := stage._spawner.spawn("prop", stage._camera.get_screen_center_position()) as PropBlock
_check(prop != null, "stage spawns an editable prop")
if prop == null:
stage.free()
return
stage._finalize_placed_node(prop)
await physics_frame
stage._gizmos.set_snap_size(0.0)
var canvas: Transform2D = stage.get_viewport().get_canvas_transform()
var press := InputEventMouseButton.new()
press.button_index = MOUSE_BUTTON_LEFT
press.pressed = true
press.position = canvas * prop.global_position
var hover_motion := InputEventMouseMotion.new()
hover_motion.position = press.position
Input.parse_input_event(hover_motion)
await process_frame
Input.parse_input_event(press)
await process_frame
_check(stage._gizmos.is_dragging(), "ordinary prop click begins a translate drag")
var translated := prop.global_position + Vector2(48.0, 27.0)
var motion := InputEventMouseMotion.new()
motion.position = canvas * translated
motion.relative = motion.position - press.position
stage._input(motion)
var release := InputEventMouseButton.new()
release.button_index = MOUSE_BUTTON_LEFT
release.pressed = false
release.position = motion.position
stage._handle_world_click(release)
_check(prop.global_position.is_equal_approx(translated), "prop follows the translate gizmo immediately")
var saved_after_drag: Dictionary = stage._authored[prop.get_instance_id()]
_check((saved_after_drag["position"] as Vector2).is_equal_approx(translated), "drag commit updates authored prop position")
_check(stage._restore_frames_left == 0, "drag start cancels deferred authored restoration")
await physics_frame
await process_frame
_check(prop.global_position.is_equal_approx(translated), "frozen prop keeps its dragged physics transform (got %s expected %s)" % [prop.global_position, translated])
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
var center: Vector2 = rect.get_center()
var radius: float = stage._gizmos._rotate_ring_radius(rect)
press.position = canvas * (center + Vector2(radius, 0.0))
stage._handle_world_click(press)
_check(stage._gizmos.is_dragging(), "prop rotation ring begins a rotate drag")
motion.position = canvas * (center + Vector2(0.0, radius))
motion.relative = motion.position - press.position
stage._input(motion)
release.position = motion.position
stage._handle_world_click(release)
_check(absf(angle_difference(prop.global_rotation, PI * 0.5)) < 0.01, "prop follows the rotation gizmo")
await physics_frame
await process_frame
_check(absf(angle_difference(prop.global_rotation, PI * 0.5)) < 0.01, "frozen prop keeps its rotated physics transform")
stage.free()
await process_frame
func _test_action_editor_reactive_option() -> void:
var editor: ActionEditor = ACTION_EDITOR_SCENE.instantiate()
root.add_child(editor)
await process_frame
editor.open_new(false)
editor._select_type("speak")
editor._rebuild_params("speak")
_check(editor._stop_current_check == null, "queue speak has no stop-current checkbox")
editor.open_edit({ "type": "speak", "text": "Ouch", "duration": 1.0, "stop_current": true }, true)
_check(editor._stop_current_check != null, "rule speak shows stop-current checkbox")
_check(editor._stop_current_check.button_pressed, "rule speak checkbox pre-fills saved value")
editor.free()
func _test_reactive_runtime_lane() -> void:
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({})
root.add_child(rig)
await process_frame
rig.action_queue = [
{ "type": "wait", "duration": 10.0 },
{ "type": "wait", "duration": 20.0 },
]
var finished: Array[Dictionary] = []
rig.action_finished.connect(func(action: Dictionary, index: int):
finished.append({ "action": action, "index": index })
)
rig.start_queue()
rig._update_runner(0.0)
_check(rig._action_phase == rig.ActionPhase.WAITING, "authored primary wait starts")
rig.enqueue_reactive([{ "type": "speak", "text": "One", "duration": 5.0 }])
_check(rig._action_phase == rig.ActionPhase.WAITING, "overlay speak leaves primary wait running")
_check(String(rig._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot")
rig.enqueue_reactive([{ "type": "speak", "text": "Two", "duration": 5.0 }])
_check(String(rig._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay")
_check(finished.is_empty(), "overlay replacement emits no primary completion")
rig.enqueue_reactive([{ "type": "wait", "duration": 1.0 }])
_check(rig._action_phase == rig.ActionPhase.NONE, "sequential reactive action cancels the active primary")
_check(finished.is_empty(), "sequential interruption emits no false action_finished")
_check(rig._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future")
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait"
and bool(rig._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane")
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
"canceled authored action is abandoned and the next authored item is retained")
# A takeover must discard the canceled/current item and any pending reactive
# primary work, while retaining only the authored future.
rig.stop_queue()
rig.action_queue = [
{ "type": "wait", "duration": 10.0 },
{ "type": "wait", "duration": 20.0 },
]
rig.start_queue()
rig._update_runner(0.0)
rig.enqueue_reactive([
{ "type": "wait", "duration": 1.0 },
{ "type": "ragdoll" },
])
_check(rig._run_queue.size() == 2, "takeover retains itself plus authored future only")
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll",
"takeover supersedes earlier same-batch primary work")
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
"takeover does not replay the completed or canceled authored item")
_check(rig._overlay_speech.is_empty(), "takeover cancels the active overlay")
_check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue")
rig.free()
func _test_spawn_pose_survives_first_solve() -> void:
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({})
var skeleton := rig.get_node("Skeleton2D") as Skeleton2D
var bone_paths: Array[String] = [
"Torso",
"Torso/Head",
"Torso/LeftUpperArm/LeftLowerArm",
"Torso/RightUpperArm/RightLowerArm",
"Torso/LeftUpperLeg/LeftLowerLeg",
"Torso/RightUpperLeg/RightLowerLeg",
]
var before: Dictionary = {}
for path: String in bone_paths:
var bone := skeleton.get_node(path) as Bone2D
before[path] = { "pos": bone.global_position, "rot": bone.global_rotation }
root.add_child(rig)
await process_frame
await physics_frame
await physics_frame
for path: String in bone_paths:
var solved := skeleton.get_node(path) as Bone2D
var expected: Dictionary = before[path]
var position_delta: float = solved.global_position.distance_to(expected["pos"])
var rotation_delta: float = absf(angle_difference(solved.global_rotation, float(expected["rot"])))
_check(position_delta <= 1.0,
"spawn solve preserves ghost joint position: %s (delta %.3f)" % [path, position_delta])
_check(rotation_delta <= 0.02,
"spawn solve preserves ghost bone rotation: %s (delta %.4f)" % [path, rotation_delta])
_check(rig._spawn_pose.size() == rig.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers")
rig.free()
func _test_left_walk_finish_pose() -> void:
var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data({})
root.add_child(rig)
await process_frame
rig.set_facing_profile(rig.FacingProfile.LEFT)
rig._walking = true
rig._set_limb_mods_enabled(true)
var stopped_frame: Dictionary = {}
for joint: String in rig._limb_bones:
var bones: Dictionary = rig._limb_bones[joint]
(bones["upper"] as Bone2D).rotation += 0.7
(bones["lower"] as Bone2D).rotation -= 0.9
stopped_frame[joint] = {
"upper": (bones["upper"] as Bone2D).rotation,
"lower": (bones["lower"] as Bone2D).rotation,
}
var hand_marker := rig.get_node("IK_Targets/Left_Hand") as Marker2D
var stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0)
hand_marker.position = stopped_marker_position
rig._finish_walk("regression")
_check(hand_marker.position.is_equal_approx(stopped_marker_position),
"walk finish preserves the current animation marker frame")
for joint: String in stopped_frame:
var bones: Dictionary = rig._limb_bones[joint]
var expected: Dictionary = stopped_frame[joint]
_check(absf(angle_difference((bones["upper"] as Bone2D).rotation, float(expected["upper"]))) < 0.001,
"LEFT walk finish preserves current upper-limb frame: %s" % joint)
_check(absf(angle_difference((bones["lower"] as Bone2D).rotation, float(expected["lower"]))) < 0.001,
"LEFT walk finish preserves current lower-limb frame: %s" % joint)
for modification: SkeletonModification2DTwoBoneIK in rig._bend_modifications.values():
_check(not modification.enabled, "LEFT idle disables mirrored limb solver")
var left_arm: Dictionary = rig._limb_bones["LeftArm"]
var live_upper := left_arm["upper"] as Bone2D
var live_rotation: float = live_upper.rotation + 0.33
live_upper.rotation = live_rotation
rig._walking = true
rig._set_limb_mods_enabled(true)
rig._cancel_walking(false)
_check(absf(angle_difference(live_upper.rotation, live_rotation)) < 0.001,
"ragdoll-style walk cancellation preserves the current solved limb pose")
_check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled,
"ragdoll-style walk cancellation keeps IK live for pose capture")
rig.free()
func _test_stop_clears_head_reaction() -> void:
var stage := STAGE_SCENE.instantiate()
root.add_child(stage)
await process_frame
var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig
stage._finalize_placed_node(rig)
var head := rig._reaction_bones.get("Head") as Bone2D
var base_rotation: float = head.rotation
rig.apply_collision_reaction("head", Vector2.DOWN, 500.0)
rig._apply_impact_reactions(0.01)
_check(not rig._impact_reactions.is_empty(), "head collision starts a flinch reaction")
stage.set_mode(stage.StageMode.PLAY)
stage.set_mode(stage.StageMode.EDIT)
_check(rig._impact_reactions.is_empty(), "STOP clears the active head reaction")
_check(absf(angle_difference(head.rotation, base_rotation)) < 0.001, "STOP restores the head without a recovery tween")
var stale_prop := PropBlock.new()
var stale_proxy := AnimatableBody2D.new()
stale_proxy.set_meta("stickman_rig", rig)
stale_proxy.set_meta("part_name", "head")
stage._on_prop_collided(stale_proxy, Vector2.DOWN, Vector2.ZERO, stale_prop)
_check(rig._impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch")
stale_prop.free()
stale_proxy.free()
stage.free()
await process_frame
func _test_source_contracts() -> void:
var stage_source: String = FileAccess.get_file_as_string("res://scripts/sandbox_stage.gd")
var rig_source: String = FileAccess.get_file_as_string("res://scripts/stickman_rig.gd")
_check(stage_source.contains("TERRAIN_DRAG_THRESHOLD_PX"), "terrain uses a drag threshold")
_check(stage_source.contains("PhysicsServer2D.body_set_state"), "frozen prop commits synchronize physics")
_check(stage_source.contains("transform_started.connect"), "gizmo start cancels deferred restoration")
_check(stage_source.contains("if current_mode != StageMode.PLAY:"), "collision callbacks are gated to PLAY mode")
_check(stage_source.contains("_speak_stop_current_check") and stage_source.contains("\"stop_current\": _speak_stop_current_check.button_pressed"), "legacy rule speak persists stop-current")
_check(rig_source.contains("_primary_speech") and rig_source.contains("_overlay_speech"), "rig separates primary and overlay speech")
_check(rig_source.contains("_sync_speech_bubble_mirror"), "rig counter-mirrors LEFT speech")
_check(rig_source.contains("initialize_spawn_pose_from_skeleton"), "rig captures factory spawn pose")
_check(rig_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle")
_check(rig_source.contains("_stand_up_generation") and rig_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded")
func _check(condition: bool, label: String) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
+1
View File
@@ -0,0 +1 @@
uid://ctl5jpr1dvjep