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:
@@ -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
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ctl5jpr1dvjep
|
||||||
Reference in New Issue
Block a user