Add Phase 4 Final Bugs Specification and Tests

- Introduced a new architecture specification for Phase 4 final bugs, detailing scope, decisions, runtime data contracts, action roles, execution lanes, speech ownership, and signal flow.
- Implemented changes in `scripts/action_registry.gd`, `scripts/action_editor.gd`, `scripts/sandbox_stage.gd`, `scripts/stage_gizmos.gd`, `scripts/stickman_factory.gd`, and `scripts/stickman_rig.gd` to address identified bugs.
- Created a new test suite `tests/test_phase4_final_bugs.gd` to validate the implementation, covering action roles, grid snapping, prop manipulation, reactive runtime lane, spawn pose preservation, and more.
- Added a unique identifier for the test file to ensure proper tracking and management.
This commit is contained in:
2026-09-13 23:20:01 -04:00
parent ce6bcecd00
commit 3a1d522ec9
11 changed files with 1281 additions and 79 deletions
+34 -12
View File
@@ -383,8 +383,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
and restores them for RECOVERING/ANIMATED. `apply_collision_reaction()` uses contact position
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
full rotations. PLAY→DIRECT restoration also resets ANIMATED rigs' authored transform, facing,
IK markers, and torso rotation. Null-guards + `push_warning` prefixed `StickmanRig:` throughout.
full rotations. `snap_to_standing()` kills reaction/recovery tweens and restores captured base
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
queue-runner state machine. Enums `RunnerState { IDLE, EXECUTING }` and `ActionPhase { NONE,
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` →
`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
logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking()` (no stale walk/path
state) and resets `_ragdoll_at_rest = false`. **Walk fix (2026-08-29, hybrid policy):**
logic is unchanged). `_enter_ragdoll()` additionally calls `_cancel_walking(false)` (no stale
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`
(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
@@ -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
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
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
`_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
queue and, if the runner is `IDLE`, resumes at the **first newly-appended action** (no replay of
the already-consumed queue prefix). Sequential Phase 3a queues are untouched.
`_walk_target_feet`) so the stage can match waypoints for `arrived_at_waypoint` rules; new
`enqueue_reactive(actions: Array[Dictionary]) -> void` dispatches reactive actions into a
private runtime lane without mutating `action_queue`: Overlay speech runs concurrently in one
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
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:
@@ -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`
`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
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
on mode change). **Touchpad:** `_input` handles `InputEventMagnifyGesture` (pinch zoom ×factor)
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
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
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
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
@@ -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,
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`,
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`).
The rotate drag is **absolute-from-start** — `drag_to()` sets `global_rotation` to
`_rotate_start_rotation + (current − start angle)`, with holding **Ctrl** snapping to