diff --git a/game_scenes/bend.scene b/game_scenes/bend.scene new file mode 100644 index 0000000..abe1f6e --- /dev/null +++ b/game_scenes/bend.scene @@ -0,0 +1,206 @@ +{ + "version": "1.0", + "scene_name": "Untitled", + "camera": { + "position": { + "x": 328.366180419922, + "y": -711.875732421875 + }, + "zoom": 0.375657439231873 + }, + "objects": [ + { + "file_id": "stickman_1", + "type": "stickman", + "position": { + "x": -336.0, + "y": -972.0 + }, + "rotation": 0.0, + "stk_path": "res://stickmen/break.stk", + "facing_profile": "FORWARD", + "action_queue": [ + { + "type": "walk_to", + "target": { + "x": 696.0, + "y": -612.0 + } + }, + { + "type": "ragdoll" + }, + { + "type": "recover" + }, + { + "type": "walk_to", + "target": { + "x": -167.4599609375, + "y": -596.639892578125 + } + }, + { + "type": "ragdoll" + }, + { + "type": "recover" + }, + { + "type": "walk_to", + "target": { + "x": -792.1015625, + "y": -615.075927734375 + } + } + ] + }, + { + "file_id": "terrain_1", + "type": "terrain", + "position": { + "x": -384.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_2", + "type": "terrain", + "position": { + "x": -192.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_3", + "type": "terrain", + "position": { + "x": 0.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_4", + "type": "terrain", + "position": { + "x": 192.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_5", + "type": "terrain", + "position": { + "x": 384.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_6", + "type": "terrain", + "position": { + "x": 576.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_7", + "type": "terrain", + "position": { + "x": 768.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_8", + "type": "terrain", + "position": { + "x": 960.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_9", + "type": "terrain", + "position": { + "x": 1152.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + }, + { + "file_id": "terrain_10", + "type": "terrain", + "position": { + "x": 1344.0, + "y": -576.0 + }, + "rotation": 0.0, + "asset_id": "ground", + "scale": { + "x": 1.0, + "y": 1.0 + } + } + ], + "event_rules": [], + "metadata": { + "created_unix": 1789757195, + "modified_unix": 1789779264.018, + "autosave": false, + "source_path": "" + } +} \ No newline at end of file diff --git a/plans/PHASE_8_RIG_MODULARIZATION.md b/plans/PHASE_8_RIG_MODULARIZATION.md index 4d0237a..3493f01 100644 --- a/plans/PHASE_8_RIG_MODULARIZATION.md +++ b/plans/PHASE_8_RIG_MODULARIZATION.md @@ -77,3 +77,122 @@ Update path references across project markdown files: 3. **Test Harness Verification**: Run `res://scenes/test_harness.tscn` via **F6** to ensure walk, speak, wait, and ragdoll actions execute cleanly. 4. **Automated Suite**: Run the full project unit test suite in `tests/` to confirm all assertions pass. + +--- + +### **Completion Record (verified implementation)** + +Phase 8 is implemented. The relocated scene is `res://scenes/stickman_rig.tscn`; its root `Master` node is a `StickmanRig` facade over three authored component children. The facade remains the compatibility boundary for callers while each component owns one subsystem: + +```text +Master (StickmanRig) +├── RigComponents +│ ├── Locomotion (StickmanLocomotion) +│ │ ├── terrain probing / ground following +│ │ ├── walking and airborne motion +│ │ └── collision proxies and landing reactions +│ ├── Ragdoll (StickmanRagdoll) +│ │ ├── procedural RigidBody2D + PinJoint2D graph +│ │ ├── rest detection and momentum handoff +│ │ └── capture, re-anchor, and stand-up recovery +│ └── ActionRunner (StickmanActionRunner) +│ ├── queued walk / speak / wait actions +│ └── ragdoll and recovery requests +├── Body/* mounted visuals, driven by RemoteTransform2D nodes +├── Skeleton2D/Torso/* Bone2D chain and IK modifications +├── IK_Targets/* six animation/recovery markers +└── AnimationPlayer canonical walk clips and authored animation data +``` + +`StickmanRig` owns cross-cutting state (`ANIMATED`, `RAGDOLL`, `RECOVERING`), facing profiles, bend flags, body z-order, and signal/API forwarding. `StickmanRagdoll` owns physics construction and recovery; `StickmanLocomotion` owns terrain/motion; `StickmanActionRunner` owns director queue execution. Component references are resolved and configured in `StickmanRig._ready()` and are null-guarded with warnings. + +#### Ragdoll handoff and recovery + +The runtime handoff preserves the current solved IK pose before physics takes ownership: + +```text +ANIMATED + │ set_ragdoll(true) + ▼ +capture pre-ragdoll markers, bone rotations, facing, bend flags, knee sides + │ build bodies from current bone endpoints; copy mounted Body/* visuals + ▼ +RAGDOLL ── rest + timeout ──▶ capture body pose + probe terrain + │ destroy physics graph + │ re-anchor root at captured landing feet + ▼ +RECOVERING ── tween markers/bones toward captured entry pose ──▶ ANIMATED +``` + +The important dictionaries have these shapes (vector values are runtime `Vector2`s): + +```gdscript +_spawn_pose = { + "Torso": {"pos": captured_torso_pos, "rot": captured_torso_rotation}, + "Head": {"pos": captured_head_tip, "rot": 0.0}, + "Left_Hand": {"pos": captured_left_wrist, "rot": 0.0}, + "Right_Hand": {"pos": captured_right_wrist, "rot": 0.0}, + "Left_Leg": {"pos": captured_left_ankle, "rot": 0.0}, + "Right_Leg": {"pos": captured_right_ankle, "rot": 0.0}, +} + +_captured_pose["left_lower_leg"] = { + "pos": rig_local_body_position, + "rot": body_global_rotation - rig_global_rotation, + "half": capsule_half_length, +} +``` + +`_spawn_pose` is initialized from the adapted skeleton by `StickmanFactory.spawn_from_data()` and is the placement ghost's authored pose; the values therefore depend on the loaded `.stk`, rather than on `STAND_POSE`. `_pre_ragdoll_state` is the recovery endpoint: it stores the six markers, limb/head rotations, facing profile, bend flags, head LookAt state, knee-side information, and chain-space offsets. `_captured_pose` is the fallen physics pose used to reconstruct the skeleton and landing position. + +#### Stage mode flow and pose preservation + +EDIT and DIRECT are two views of the same frozen, non-simulating world. The mode transition receives `previous_mode` so it can avoid an unnecessary reset: + +```text + EDIT ◄──────────────► DIRECT + │ │ + └──────────┬───────────┘ + ▼ + PLAY + │ + set_mode(EDIT or DIRECT) + │ + ▼ + cleanup + restore spawn pose +``` + +| Transition | Stage behavior | Rig pose behavior | +|---|---|---| +| EDIT → DIRECT | Hide edit controls; keep world frozen | Preserve exact live pose and IK markers | +| DIRECT → EDIT | Restore edit controls; keep world frozen | Preserve exact live pose and IK markers | +| EDIT/DIRECT → PLAY | Enable simulation, terrain following, and action queues | Begin from the current authored/editor pose | +| PLAY → EDIT/DIRECT | Stop queues, disable terrain following, freeze physics, restore object transforms | `snap_to_standing()` restores the factory-captured `_spawn_pose` | + +The root cause of the verified regression was that `snap_to_standing()` restored the legacy hardcoded `STAND_POSE`. That generic pose lowered the arms and rotated the head whenever PLAY cleanup ran. The central fix is to restore `_spawn_pose` when it exists, with `STAND_POSE` retained only as a compatibility fallback for rigs that have no captured spawn pose. Ragdoll recovery is separate: its stand-up tween still targets `_pre_ragdoll_state`, so recovery returns to the pose captured at ragdoll entry rather than the factory pose. + +#### Facing, walk-phase bend stabilization, and head tracking + +`FacingProfile.LEFT` is a whole-rig X mirror (`Master.scale.x = -1`); RIGHT and FORWARD use identity scale. Runtime walking always plays the canonical `walk_right` clip and mirrors it for LEFT. The profile still applies provisional per-joint bend flags and z-order tables. For side profiles, a walk keyframe can place a leg exactly straight, leaving PinJoint2D with no geometric knee side. The ragdoll component therefore: + +- permits the straight hinge boundary (`0°`) and the intended fold range (`±150°`) for LEFT/RIGHT profiles; +- adds only a small 3° profile-relative bias at the near-straight ragdoll handoff; and +- checks each rendered RAGDOLL frame, correcting only an actual wrong-side knee crossing while preserving normal physical motion. + +Head tracking is owned by the `SkeletonModification2DLookAt` targeting `IK_Targets/Head`. RIGHT/FORWARD enable it. LEFT disables LookAt and pins the canonical head rotation because the whole-rig mirror must remain mirror-invariant; this is intentional rather than a missing target update. The scene's authored head constraint is disabled so the active LookAt can follow moved markers without the old constraint interpretation pinning it. + +#### Regression coverage + +The completed implementation was checked with focused headless tests and the existing suites: + +| Test | Result | Coverage | +|---|---:|---| +| `tests/test_phase8_rig_modularization.gd` | 23/23 | relocated scene, component ownership, head tracking, facing flags, ragdoll facade | +| `tests/test_phase8_edit_direct_pose.gd` | 45/45 | EDIT↔DIRECT round trip and EDIT→PLAY→DIRECT pose/marker restoration | +| `tests/test_phase8_ragdoll_walk_handoff.gd` | 80/80 | LEFT/RIGHT profiles across multiple walk animation phases and physics frames | +| `tests/test_phase45_collision_bodies.gd` | 223/223 | collision proxies, reactions, and PLAY cleanup to authored pose | +| `tests/test_phase3c_walk_recovery.gd` | 185/185 | walk, ragdoll, recovery, and action integration | +| `tests/test_phase6a_airborne_landing.gd` | 24/24 | airborne pose, landing, and ragdoll cleanup | +| `tests/test_phase4b_stage.gd` | 53/53 | stage mode UI/state transitions and pending-action cleanup | + +The plan's original acceptance criteria remain the historical intent; this record captures the as-built ownership and the follow-up fixes verified after user testing. diff --git a/scenes/stickman_rig.tscn b/scenes/stickman_rig.tscn index 5db6277..2bd87d6 100644 --- a/scenes/stickman_rig.tscn +++ b/scenes/stickman_rig.tscn @@ -57,11 +57,7 @@ joint_two_bone2d_node = NodePath("Torso/LeftUpperLeg/LeftLowerLeg") bone_index = 1 bone2d_node = NodePath("Torso/Head") target_nodepath = NodePath("../IK_Targets/Head") -enable_constraint = true -constraint_angle_min = -180.0 -constraint_angle_max = 180.0 -constraint_angle_invert = false -constraint_in_localspace = false +enable_constraint = false [sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"] resource_local_to_scene = true diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd index b171f47..7a76cd5 100644 --- a/scripts/sandbox_stage.gd +++ b/scripts/sandbox_stage.gd @@ -644,6 +644,7 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2: func set_mode(mode: StageMode) -> void: if mode == current_mode: return + var previous_mode: StageMode = current_mode if mode == StageMode.PLAY and not _cli_startup: if not _save_scene_to_path(AUTOSAVE_PATH, true): push_warning("SandboxStage: autosave before Play failed.") @@ -652,9 +653,9 @@ func set_mode(mode: StageMode) -> void: _cancel_palette_drag() match mode: StageMode.EDIT: - _enter_edit_mode() + _enter_edit_mode(previous_mode) StageMode.DIRECT: - _enter_direct_mode() + _enter_direct_mode(previous_mode) StageMode.PLAY: _enter_play_mode() _sync_mode_buttons() @@ -667,8 +668,8 @@ func set_mode(mode: StageMode) -> void: func _enter_edit_shared() -> void: - # Snap stickmen straight back to their standing pose/position (no stand-up - # tween glide), stopping any director queue first. + # Restore stickmen to their authored spawn/ghost pose (no stand-up tween + # glide), stopping any director queue first. for node: Node2D in _world_children_selectable(): if node is STICKMAN_RIG: var rig := node as STICKMAN_RIG @@ -697,8 +698,11 @@ func _enter_edit_shared() -> void: _collision_pairs.clear() -func _enter_edit_mode() -> void: - _enter_edit_shared() +func _enter_edit_mode(previous_mode: StageMode) -> void: + # EDIT and DIRECT share the same frozen world. PLAY still needs the complete + # simulation cleanup and authored-pose restore before editing resumes. + if previous_mode != StageMode.DIRECT: + _enter_edit_shared() _clear_director_pending() _set_edit_controls_visible(true) _set_direct_controls_visible(false) @@ -730,11 +734,15 @@ func _clear_director_pending() -> void: _director_visuals.clear_edit_waypoint() -## Direct is internally an "edit-with-direct" state: same freeze/stand/gizmo -## side effects, but with the spawner/grid controls hidden, the director hint -## shown, and any placement/selection/pending-target cleared. -func _enter_direct_mode() -> void: - _enter_edit_shared() +## Direct is internally an "edit-with-direct" state: the same frozen world, +## but with the spawner/grid controls hidden, the director hint shown, and any +## placement/selection/pending-target cleared. +func _enter_direct_mode(previous_mode: StageMode) -> void: + # EDIT and DIRECT share the same non-simulating world. Preserve the exact + # placed stickman pose between them; a transition from PLAY still needs the + # full cleanup and authored-pose restore. + if previous_mode != StageMode.EDIT: + _enter_edit_shared() set_placement_mode("") _selection.clear_selection() _clear_director_pending() diff --git a/scripts/stickman_ragdoll.gd b/scripts/stickman_ragdoll.gd index 7ed7600..437efe8 100644 --- a/scripts/stickman_ragdoll.gd +++ b/scripts/stickman_ragdoll.gd @@ -16,6 +16,8 @@ const REST_LINEAR_THRESHOLD: float = 5.0 const REST_ANGULAR_THRESHOLD: float = 0.1 const STABILIZATION_DELAY: float = 0.1 const TARGET_SOFTNESS: float = 0.2 +const SIDE_PROFILE_STRAIGHT_THRESHOLD := deg_to_rad(5.0) +const SIDE_PROFILE_KNEE_BIAS := deg_to_rad(3.0) const GROUND_PROBE_START_OFFSET := 50.0 const GROUND_PROBE_END_OFFSET := 1500.0 const GROUND_PROBE_TOLERANCE := 200.0 @@ -65,8 +67,10 @@ const JOINTS: Array[Dictionary] = [ { "child": "head", "parent": "torso", "pin": "Torso/Head", "limit": "neck" }, { "child": "left_upper_arm", "parent": "torso", "pin": "Torso/LeftUpperArm", "limit": "shoulder" }, { "child": "left_lower_arm", "parent": "left_upper_arm", "pin": "Torso/LeftUpperArm/LeftLowerArm", "limit": "cw" }, { "child": "right_upper_arm", "parent": "torso", "pin": "Torso/RightUpperArm", "limit": "shoulder" }, { "child": "right_lower_arm", "parent": "right_upper_arm", "pin": "Torso/RightUpperArm/RightLowerArm", "limit": "ccw" }, { "child": "left_upper_leg", "parent": "torso", "pin": "Torso/LeftUpperLeg", "limit": "shoulder" }, { "child": "left_lower_leg", "parent": "left_upper_leg", "pin": "Torso/LeftUpperLeg/LeftLowerLeg", "limit": "ccw" }, { "child": "right_upper_leg", "parent": "torso", "pin": "Torso/RightUpperLeg", "limit": "shoulder" }, { "child": "right_lower_leg", "parent": "right_upper_leg", "pin": "Torso/RightUpperLeg/RightLowerLeg", "limit": "cw" }, ] -## Per-profile elbow/knee limits retained from the monolithic rig. The component -## owns these construction settings because it creates the PinJoint2D network. +## Per-profile elbow/knee limits. Side-profile leg hinges include the straight +## pose (0 degrees) and allow both knees to fold toward the same profile-relative +## side, avoiding an instant forced +/-90-degree fold at ragdoll handoff. +## The component owns these settings because it creates the PinJoint2D network. const JOINT_BAND_LIMITS: Dictionary = { StickmanRig.FacingProfile.FORWARD: { "left_lower_arm": { "lower": -150.0, "upper": 5.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, @@ -74,11 +78,11 @@ const JOINT_BAND_LIMITS: Dictionary = { }, StickmanRig.FacingProfile.LEFT: { "left_lower_arm": { "lower": 60.0, "upper": 100.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, - "left_lower_leg": { "lower": -100.0, "upper": -90.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, + "left_lower_leg": { "lower": -150.0, "upper": 0.0 }, "right_lower_leg": { "lower": -150.0, "upper": 0.0 }, }, StickmanRig.FacingProfile.RIGHT: { "left_lower_arm": { "lower": -100.0, "upper": -60.0 }, "right_lower_arm": { "lower": -150.0, "upper": 5.0 }, - "left_lower_leg": { "lower": 90.0, "upper": 100.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 }, + "left_lower_leg": { "lower": 0.0, "upper": 150.0 }, "right_lower_leg": { "lower": 0.0, "upper": 150.0 }, }, } @@ -141,6 +145,14 @@ func initialize(skeleton: Skeleton2D) -> void: _previous_position = _rig.global_position _previous_rotation = _rig.global_rotation + +## Runs after the physics step and before drawing. Godot's PinJoint2D can let a +## straight knee cross its angular limit during the step; correcting here keeps +## that invalid intermediate pose from ever reaching the rendered frame. +func _process(_delta: float) -> void: + if _rig != null and _rig.state == StickmanRig.RigState.RAGDOLL: + _enforce_side_profile_leg_bends() + func _resolve_limb_bones() -> void: _limb_bones.clear() for joint: String in LIMB_BONE_PATHS: @@ -176,12 +188,75 @@ func build() -> bool: if parent == null: return false _root = Node2D.new(); _root.name = CONTAINER_NAME; parent.add_child(_root); _bodies.clear() for entry: Dictionary in BODIES: _build_body(entry) + _stabilize_side_profile_leg_bends() for entry: Dictionary in JOINTS: _build_joint(entry) var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D if torso != null: torso.linear_velocity = _linear_velocity; torso.angular_velocity = _angular_velocity reset_rest_state() return torso != null + +## A walk keyframe can put a leg exactly straight. At that singularity the +## physics solver has no geometric knee side to preserve, so floating-point +## noise can make either knee fold as if the opposite facing profile were in +## use. Give only near-straight side-profile legs a small world-space bend +## toward the profile's established rear side before the joints are created. +func _stabilize_side_profile_leg_bends() -> void: + if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \ + and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT): + return + var desired_delta := SIDE_PROFILE_KNEE_BIAS \ + if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT \ + else -SIDE_PROFILE_KNEE_BIAS + for prefix: String in ["left", "right"]: + var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D + var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D + if upper == null or lower == null: + continue + var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI) + if absf(current_delta) > SIDE_PROFILE_STRAIGHT_THRESHOLD: + continue + var upper_half := float(upper.get_meta("half_height", 0.0)) + var lower_half := float(lower.get_meta("half_height", 0.0)) + var upper_dir := Vector2.from_angle(upper.global_rotation) + var knee := upper.global_position + upper_dir * upper_half + var lower_dir := upper_dir.rotated(desired_delta) + lower.global_rotation = lower_dir.angle() + lower.global_position = knee + lower_dir * lower_half + + +## PinJoint2D can cross its angular limit near a perfectly straight chain in +## Godot 4.7.1. Correct only an actual side crossing so a knee cannot select +## the opposite solution. Normal motion on the intended side remains physical. +func _enforce_side_profile_leg_bends() -> void: + if _rig == null or (_rig.facing_profile != StickmanRig.FacingProfile.LEFT \ + and _rig.facing_profile != StickmanRig.FacingProfile.RIGHT): + return + var desired_side := -1.0 if _rig.facing_profile == StickmanRig.FacingProfile.RIGHT else 1.0 + var desired_delta_sign := -desired_side + for prefix: String in ["left", "right"]: + var upper := _bodies.get(prefix + "_upper_leg") as RigidBody2D + var lower := _bodies.get(prefix + "_lower_leg") as RigidBody2D + if upper == null or lower == null or not is_instance_valid(upper) or not is_instance_valid(lower): + continue + var upper_half := float(upper.get_meta("half_height", 0.0)) + var lower_half := float(lower.get_meta("half_height", 0.0)) + var upper_dir := Vector2.from_angle(upper.global_rotation) + var knee := upper.global_position + upper_dir * upper_half + var hip := upper.global_position - upper_dir * upper_half + var lower_dir_current := Vector2.from_angle(lower.global_rotation) + var ankle := lower.global_position + lower_dir_current * lower_half + var current_side := (ankle - hip).cross(knee - hip) + if current_side * desired_side > 0.0: + continue + var current_delta := wrapf(lower.global_rotation - upper.global_rotation, -PI, PI) + var corrected_delta := desired_delta_sign * minf( + maxf(absf(current_delta), SIDE_PROFILE_KNEE_BIAS), deg_to_rad(150.0)) + var lower_dir := upper_dir.rotated(corrected_delta) + lower.global_rotation = lower_dir.angle() + lower.global_position = knee + lower_dir * lower_half + lower.angular_velocity = upper.angular_velocity + func destroy() -> void: if _root != null and is_instance_valid(_root): _root.name = StringName(str(CONTAINER_NAME) + "_retired"); _root.queue_free() _root = null; _bodies.clear(); reset_rest_state() @@ -242,7 +317,10 @@ func snap_to_standing(previous_state: int) -> void: if _locomotion != null: _locomotion.clear_impact_reactions(true) _rig._hide_speech() - _restore_standing_markers(false) + # The factory captures the adapted skeleton's real authored spawn pose. That + # is also the pose shown by the placement ghost, so every forced reset must + # restore it instead of the legacy, one-size-fits-all STAND_POSE fallback. + _restore_standing_markers(true) _rearm_ik_stack() _set_limb_mods_enabled(true) if previous_state == StickmanRig.RigState.ANIMATED: @@ -311,6 +389,9 @@ func initialize_spawn_pose_from_skeleton() -> void: func get_spawn_limb_rotations() -> Dictionary: return _spawn_limb_rotations +func get_spawn_pose() -> Dictionary: + return _spawn_pose.duplicate(true) + func get_captured_pose() -> Dictionary: return _captured_pose @@ -568,13 +649,10 @@ func _snap_skeleton_to_pose() -> void: var raw_head := _chain_angle_from_captured(float(head_pose.get("rot", 0.0))) - head_chain_offset - theta_t _head_bone.rotation = wrapf(_wrap_toward(raw_head, pre_head_rotation), -PI, PI) _recovery_bones.append({"bone": _head_bone, "target": _head_bone.rotation + wrapf(pre_head_rotation - _head_bone.rotation, -PI, PI)}) - if _body_container != null: - _body_container.visible = true - _body_container.modulate.a = 1.0 - _rearm_ik_stack() _set_limb_mods_enabled(false) if _head_look_at != null: _head_look_at.enabled = false + _rearm_ik_stack() for leg_joint: String in ["LeftLeg", "RightLeg"]: var modification := _bend_modifications.get(leg_joint) as SkeletonModification2DTwoBoneIK if modification == null: @@ -598,6 +676,9 @@ func _snap_skeleton_to_pose() -> void: var captured_side := 1.0 if (ankle - hip).cross(knee - hip) >= 0.0 else -1.0 modification.flip_bend_direction = bool(entry.get("flag", false)) if captured_side == float(entry.get("side", captured_side)) else not bool(entry.get("flag", false)) modification.enabled = true + if _body_container != null: + _body_container.visible = true + _body_container.modulate.a = 1.0 func _play_stand_up() -> void: _stand_up_generation += 1 @@ -641,8 +722,8 @@ func _on_stand_up_finished(generation: int) -> void: _body_container.modulate.a = 1.0 if _locomotion != null: _locomotion.set_collision_bodies_enabled(true) - _rig._apply_idle_limb_solver_mode(recovered_rotations) state_transition_requested.emit(StickmanRig.RigState.ANIMATED) + _rig._apply_idle_limb_solver_mode(recovered_rotations) func _build_body(entry: Dictionary) -> void: var key: String = str(entry["key"]); var body := RigidBody2D.new(); body.name = "Ragdoll_" + key diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index 41773fa..d537b68 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -479,9 +479,9 @@ func request_recovery() -> void: _ragdoll_component.request_recovery() -## Instantly snaps the rig back to its authored standing pose — no stand-up -## tween. Used by the sandbox stage so a stickman "reappears" at its starting -## position/state on return to EDIT (instead of animating the recovery glide). +## Instantly restores the rig's factory-captured spawn/ghost pose — no stand-up +## tween. Used by the sandbox stage so a stickman reappears in its authored +## starting pose when leaving PLAY (instead of using the generic fallback pose). func snap_to_standing() -> void: if _ragdoll_component != null: _ragdoll_component.snap_to_standing(int(state)) @@ -620,7 +620,10 @@ func _apply_idle_limb_solver_mode(rotations: Dictionary = {}) -> void: and state == RigState.ANIMATED and not is_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: + # Recovery supplies the exact pre-ragdoll rotations. Write them immediately + # even for profiles that keep IK enabled so the completion frame cannot show + # the recovery-side knee solution while the solver waits for its next tick. + if not pin_mirrored_idle and rotations.is_empty(): return for joint: String in source: var bones: Dictionary = _limb_bones.get(joint, {}) @@ -658,18 +661,15 @@ func _capture_limb_rotations() -> Dictionary: ## Fix: when facing LEFT, disable the LookAt and pin the head bone to the ## authored canonical local rotation (0), so the head is a rigid mirror of the ## FORWARD pose; RIGHT/FORWARD re-enable the LookAt. The pin is re-asserted -## every physics frame while the kinematic puppet is live (ANIMATED/RECOVERING) -## because re-enabling the IK stack during recovery would otherwise let the -## LookAt flip the bone again. +## every physics frame while the kinematic puppet is ANIMATED; recovery disables +## LookAt separately while its stand-up tween drives the head bone. func _apply_head_lookat_mirror_mode() -> void: if _head_look_at == null or not is_instance_valid(_head_look_at): return var mirrored := facing_profile == FacingProfile.LEFT - _head_look_at.enabled = false#not mirrored + _head_look_at.enabled = not mirrored if mirrored and _head_bone != null and is_instance_valid(_head_bone): _head_bone.rotation = 0.0 - elif not mirrored: - _head_bone.rotation = 0 ## Per-joint setter notify: updates the resolved TwoBoneIK mod's ## flip_bend_direction and emits bend_flag_changed. No-op before _ready (the diff --git a/tests/test_phase45_collision_bodies.gd b/tests/test_phase45_collision_bodies.gd index be83938..cee264d 100644 --- a/tests/test_phase45_collision_bodies.gd +++ b/tests/test_phase45_collision_bodies.gd @@ -183,6 +183,8 @@ func _test_reactions(rig: StickmanRig) -> void: func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void: var authored: Dictionary = stage._authored[rig.get_instance_id()] + var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll + var authored_markers := ragdoll.get_spawn_pose() stage.set_mode(stage.StageMode.PLAY) rig.set_facing_profile(StickmanRig.FacingProfile.LEFT) rig.global_position += Vector2(220.0, -90.0) @@ -201,9 +203,9 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi _check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile") for marker_name: String in StickmanRagdoll.STAND_POSE: var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D - var expected: Dictionary = StickmanRagdoll.STAND_POSE[marker_name] - _check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % marker_name) - _check(absf(angle_difference(torso_marker.rotation, float(StickmanRagdoll.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame") + var expected: Dictionary = authored_markers[marker_name] + _check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored spawn pose after PLAY" % marker_name) + _check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "torso marker restores its authored rotation after PLAY") func _test_ghost_collision(stage: Node2D) -> void: diff --git a/tests/test_phase8_edit_direct_pose.gd b/tests/test_phase8_edit_direct_pose.gd new file mode 100644 index 0000000..8e23004 --- /dev/null +++ b/tests/test_phase8_edit_direct_pose.gd @@ -0,0 +1,122 @@ +extends SceneTree + +const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") +const BONE_PATHS: Array[String] = [ + "Skeleton2D/Torso/Head", + "Skeleton2D/Torso/LeftUpperArm", + "Skeleton2D/Torso/LeftUpperArm/LeftLowerArm", + "Skeleton2D/Torso/RightUpperArm", + "Skeleton2D/Torso/RightUpperArm/RightLowerArm", +] +const MARKER_NAMES: Array[String] = ["Head", "Left_Hand", "Right_Hand", "Torso"] +const ROTATION_TOLERANCE := 0.0001 +const POSITION_TOLERANCE := 0.01 + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var stage := STAGE_SCENE.instantiate() as Node2D + root.add_child(stage) + stage._snap_enabled = false + var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2.ZERO) + stage._finalize_placed_node(rig) + for frame in 3: + await process_frame + var pose_before := _capture_pose(rig) + var markers_before := _capture_markers(rig) + stage.set_mode(stage.StageMode.DIRECT) + for frame in 3: + await process_frame + var pose_after := _capture_pose(rig) + var markers_after := _capture_markers(rig) + _check(stage.current_mode == stage.StageMode.DIRECT, "stage enters DIRECT") + for path: String in BONE_PATHS: + var before: Dictionary = pose_before[path] + var after: Dictionary = pose_after[path] + _check(absf(angle_difference(float(before["rotation"]), float(after["rotation"]))) <= ROTATION_TOLERANCE, + "EDIT to DIRECT preserves %s rotation" % path.get_file()) + _check((before["position"] as Vector2).distance_to(after["position"] as Vector2) <= POSITION_TOLERANCE, + "EDIT to DIRECT preserves %s position" % path.get_file()) + for marker_name: String in MARKER_NAMES: + var before: Dictionary = markers_before[marker_name] + var after: Dictionary = markers_after[marker_name] + _check((before["position"] as Vector2).distance_to(after["position"] as Vector2) <= POSITION_TOLERANCE, + "EDIT to DIRECT preserves %s IK marker" % marker_name) + stage.set_mode(stage.StageMode.EDIT) + for frame in 3: + await process_frame + var pose_round_trip := _capture_pose(rig) + var markers_round_trip := _capture_markers(rig) + _check(stage.current_mode == stage.StageMode.EDIT, "stage returns to EDIT") + for path: String in BONE_PATHS: + var before: Dictionary = pose_before[path] + var round_trip: Dictionary = pose_round_trip[path] + _check(absf(angle_difference(float(before["rotation"]), float(round_trip["rotation"]))) <= ROTATION_TOLERANCE, + "EDIT to DIRECT to EDIT preserves %s rotation" % path.get_file()) + _check((before["position"] as Vector2).distance_to(round_trip["position"] as Vector2) <= POSITION_TOLERANCE, + "EDIT to DIRECT to EDIT preserves %s position" % path.get_file()) + for marker_name: String in MARKER_NAMES: + var before: Dictionary = markers_before[marker_name] + var round_trip: Dictionary = markers_round_trip[marker_name] + _check((before["position"] as Vector2).distance_to(round_trip["position"] as Vector2) <= POSITION_TOLERANCE, + "EDIT to DIRECT to EDIT preserves %s IK marker" % marker_name) + stage.set_mode(stage.StageMode.PLAY) + for frame in 3: + await physics_frame + stage.set_mode(stage.StageMode.DIRECT) + for frame in 4: + await physics_frame + var pose_after_play := _capture_pose(rig) + var markers_after_play := _capture_markers(rig) + _check(stage.current_mode == stage.StageMode.DIRECT, "stage enters DIRECT after PLAY") + for path: String in BONE_PATHS: + var before: Dictionary = pose_before[path] + var after_play: Dictionary = pose_after_play[path] + _check(absf(angle_difference(float(before["rotation"]), float(after_play["rotation"]))) <= ROTATION_TOLERANCE, + "EDIT to PLAY to DIRECT restores %s rotation" % path.get_file()) + _check((before["position"] as Vector2).distance_to(after_play["position"] as Vector2) <= POSITION_TOLERANCE, + "EDIT to PLAY to DIRECT restores %s position" % path.get_file()) + for marker_name: String in MARKER_NAMES: + var before: Dictionary = markers_before[marker_name] + var after_play: Dictionary = markers_after_play[marker_name] + _check((before["position"] as Vector2).distance_to(after_play["position"] as Vector2) <= POSITION_TOLERANCE, + "EDIT to PLAY to DIRECT restores %s IK marker" % marker_name) + stage.queue_free() + await process_frame + if _failures == 0: + print("RESULT: ALL PASSED (%d assertions)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) + quit(1) + + +func _capture_pose(rig: StickmanRig) -> Dictionary: + var result := {} + for path: String in BONE_PATHS: + var bone := rig.get_node(path) as Bone2D + result[path] = {"rotation": bone.global_rotation, "position": bone.global_position} + return result + + +func _capture_markers(rig: StickmanRig) -> Dictionary: + var result := {} + for marker_name: String in MARKER_NAMES: + var marker := rig.get_node("IK_Targets/" + marker_name) as Marker2D + result[marker_name] = {"position": marker.position, "rotation": marker.rotation} + return result + + +func _check(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print("PASS: %s" % label) + else: + _failures += 1 + push_error("FAIL: %s" % label) diff --git a/tests/test_phase8_edit_direct_pose.gd.uid b/tests/test_phase8_edit_direct_pose.gd.uid new file mode 100644 index 0000000..e9b3560 --- /dev/null +++ b/tests/test_phase8_edit_direct_pose.gd.uid @@ -0,0 +1 @@ +uid://d4q7x1m8n2vkp diff --git a/tests/test_phase8_ragdoll_walk_handoff.gd b/tests/test_phase8_ragdoll_walk_handoff.gd new file mode 100644 index 0000000..092ff20 --- /dev/null +++ b/tests/test_phase8_ragdoll_walk_handoff.gd @@ -0,0 +1,90 @@ +extends SceneTree + +const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") +const WALK_PHASES: Array[float] = [0.0, 0.08, 0.16, 0.24, 0.32, 0.40, 0.48, 0.56, 0.64, 0.72] +const SETTLE_FRAMES := 30 + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + for profile: int in [StickmanRig.FacingProfile.RIGHT, StickmanRig.FacingProfile.LEFT]: + for walk_phase: float in WALK_PHASES: + await _check_handoff(profile, walk_phase) + if _failures == 0: + print("RESULT: ALL PASSED (%d assertions)" % _checks) + quit(0) + else: + print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks]) + quit(1) + + +func _check_handoff(profile: int, walk_phase: float) -> void: + var stage := STAGE_SCENE.instantiate() as Node2D + root.add_child(stage) + stage._snap_enabled = false + var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2.ZERO) + rig.auto_recover = false + var destination_x := 500.0 if profile == StickmanRig.FacingProfile.RIGHT else -500.0 + rig.walk_to(Vector2(destination_x, 0.0)) + var animation := rig.get_node("AnimationPlayer") as AnimationPlayer + animation.seek(walk_phase, true) + await process_frame + await physics_frame + var label := "%s @ %.2fs" % [StickmanRig.FacingProfile.keys()[profile], walk_phase] + _check(rig.get_facing_profile() == profile, "%s keeps its facing profile before ragdoll" % label) + rig.set_ragdoll(true) + var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll + var expected_side := -1.0 if profile == StickmanRig.FacingProfile.RIGHT else 1.0 + _check(_both_legs_match(_ragdoll_leg_sides(ragdoll), expected_side), + "%s enters ragdoll with both knees on the profile side" % label) + for frame in SETTLE_FRAMES: + await physics_frame + await process_frame + # SceneTree.process_frame is emitted before Node._process callbacks. Wait one + # more idle frame so the rendered pose includes the post-physics knee guard. + await process_frame + var settled_sides := _ragdoll_leg_sides(ragdoll) + _check(_both_legs_match(settled_sides, expected_side), + "%s keeps both knees on the profile side for %d frames (sides %s)" + % [label, SETTLE_FRAMES, str(settled_sides)]) + _check(rig.get_facing_profile() == profile, "%s never changes facing profile" % label) + stage.queue_free() + await process_frame + + +func _ragdoll_leg_sides(ragdoll: StickmanRagdoll) -> Dictionary: + var bodies := ragdoll.get_bodies() + var result := {} + for prefix: String in ["left", "right"]: + var upper := bodies.get(prefix + "_upper_leg") as RigidBody2D + var lower := bodies.get(prefix + "_lower_leg") as RigidBody2D + var upper_half := float(upper.get_meta("half_height", 0.0)) + var lower_half := float(lower.get_meta("half_height", 0.0)) + var upper_dir := Vector2.from_angle(upper.global_rotation) + var lower_dir := Vector2.from_angle(lower.global_rotation) + var hip := upper.global_position - upper_dir * upper_half + var knee := upper.global_position + upper_dir * upper_half + var ankle := lower.global_position + lower_dir * lower_half + result["LeftLeg" if prefix == "left" else "RightLeg"] = \ + signf((ankle - hip).cross(knee - hip)) + return result + + +func _both_legs_match(sides: Dictionary, expected_side: float) -> bool: + return float(sides.get("LeftLeg", 0.0)) == expected_side \ + and float(sides.get("RightLeg", 0.0)) == expected_side + + +func _check(condition: bool, label: String) -> void: + _checks += 1 + if condition: + print("PASS: %s" % label) + else: + _failures += 1 + push_error("FAIL: %s" % label) diff --git a/tests/test_phase8_ragdoll_walk_handoff.gd.uid b/tests/test_phase8_ragdoll_walk_handoff.gd.uid new file mode 100644 index 0000000..6b532b7 --- /dev/null +++ b/tests/test_phase8_ragdoll_walk_handoff.gd.uid @@ -0,0 +1 @@ +uid://dsuireu51jue0 diff --git a/tests/test_phase8_rig_modularization.gd b/tests/test_phase8_rig_modularization.gd index 791d138..2257509 100644 --- a/tests/test_phase8_rig_modularization.gd +++ b/tests/test_phase8_rig_modularization.gd @@ -32,6 +32,33 @@ func _run() -> void: _check(runner != null, "typed ActionRunner component resolves") _check(rig.get_locomotion() == locomotion, "root locomotion facade exposes authored component") + var head_look_at := rig._head_look_at as SkeletonModification2DLookAt + var head_bone := rig.get_node("Skeleton2D/Torso/Head") as Bone2D + var head_target := rig.get_node("IK_Targets/Head") as Marker2D + _check(head_look_at != null and head_look_at.enabled, + "FORWARD profile enables head marker tracking") + var head_rotation_before := head_bone.rotation + head_target.position += Vector2(-400.0, 180.0) + for frame in 4: + await process_frame + _check(absf(wrapf(head_bone.rotation - head_rotation_before, -PI, PI)) > 0.1, + "head bone follows a moved Head marker (before %.3f after %.3f target %s)" + % [head_rotation_before, head_bone.rotation, str(head_target.global_position)]) + rig.set_facing_profile(StickmanRig.FacingProfile.LEFT) + _check(not head_look_at.enabled, "LEFT mirror disables non-mirror-invariant head LookAt") + rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT) + _check(head_look_at.enabled, "RIGHT profile re-enables head marker tracking") + + var left_bands: Dictionary = StickmanRagdoll.JOINT_BAND_LIMITS[StickmanRig.FacingProfile.LEFT] + var right_bands: Dictionary = StickmanRagdoll.JOINT_BAND_LIMITS[StickmanRig.FacingProfile.RIGHT] + for leg_key: String in ["left_lower_leg", "right_lower_leg"]: + var left_band: Dictionary = left_bands[leg_key] + var right_band: Dictionary = right_bands[leg_key] + _check(float(left_band["lower"]) < 0.0 and float(left_band["upper"]) >= 0.0, + "LEFT %s hinge includes straight and bends backward" % leg_key) + _check(float(right_band["lower"]) <= 0.0 and float(right_band["upper"]) > 0.0, + "RIGHT %s hinge includes straight and bends backward" % leg_key) + var assigned: Array[Dictionary] = [{"type": "wait", "duration": 0.25}] rig.action_queue = assigned _check(runner.action_queue.size() == 1, "direct root action_queue assignment delegates")