Author SHA1 Message Date
ryan fbce638bc5 Refactor stickman rig and ragdoll mechanics; enhance pose restoration and stability
- Disabled constraints for the stickman rig's head bone to allow for smoother animations.
- Improved mode transitions in the sandbox stage to preserve stickman poses between EDIT and DIRECT modes.
- Added side profile stabilization for knee bends in the ragdoll component to prevent unnatural poses during physics interactions.
- Enhanced the snap-to-standing functionality to restore the stickman's original pose accurately.
- Introduced new tests for validating pose preservation during mode transitions and ragdoll behavior during walking animations.
- Created a new scene for testing stickman bending and ragdoll transitions.
2026-09-18 22:44:48 -04:00
ryan db4ff8d939 Implement Phase 8: Rig Relocation and Component Modularization
- Moved the rig scene from `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn`, preserving its UID.
- Split the `StickmanRig` controller into three components: `StickmanLocomotion`, `StickmanRagdoll`, and `StickmanActionRunner`.
- Updated the architecture to ensure `StickmanRig` acts as the public facade for the new components.
- Created a new test suite `test_phase8_rig_modularization.gd` to verify the integrity of the relocated rig and its components.
- Added checks for scene loading, component resolution, action queue delegation, and state authority.
2026-09-18 14:11:53 -04:00
ryan 9a22322dba feat: Add architecture plan for rig relocation and component modularization 2026-09-18 09:57:06 -04:00
ryan 1a2a43c805 Add reusable UI components for Phase 7 UI Polish
- Introduced `EmojiQuickGrid`, `FontPresetPicker`, and `PresetSizeToggle` classes for emoji selection, font presets, and size toggles respectively.
- Implemented configuration methods for each component to handle dynamic content and theming.
- Created tests for the new components to ensure functionality and integration with the existing UI.
- Added a comprehensive architecture specification for Phase 7 UI Polish detailing scope, implementation, and validation strategies.
- Updated existing theme handling to support new UI elements and ensure consistent styling across the application.
2026-09-18 09:42:48 -04:00
ryan baa19a9341 Add initial draft for Phase 7 UI Polish plan 2026-09-17 22:17:42 -04:00
ryan 2071ee03b7 feat: Implement Phase 6a Airborne Mechanics and Landing Absorption
- Added new scene file `test_fall.scene` for testing airborne dynamics.
- Created architecture proposal in `PHASE_6a_AIRBORNE_LANDING.md` detailing airborne momentum, procedural air pose, and landing impact absorption mechanics.
- Developed specifications in `phase_6a_airborne_landing.md` outlining implementation decisions, targets, and static contracts for the airborne system.
- Introduced focused regression tests in `test_phase6a_airborne_landing.gd` to validate airborne pose, momentum, landing timeline, and queue behavior.
- Ensured all tests pass with 100% assertion success.
2026-09-17 19:09:54 -04:00
ryan 3e39fd9287 Merge remote-tracking branch 'origin/feature/command-line-scene' 2026-09-16 09:36:01 -04:00
ryan 08247eadef Add ramp scene and implement Phase 6 for dynamic IK and terrain-following locomotion
- Created a new scene file `ramp.scene` with terrain and stickman objects, including event rules for ragdoll and recovery actions.
- Documented Phase 6 implementation in `PHASE_6_GROUND_SNAPPING.md`, detailing the continuous dynamic IK, terrain-following locomotion, and constraints for stickman rigs.
- Defined scene structure, layered IK ownership, probe contracts, and per-frame solve order for terrain interaction.
- Verified functionality for terrain snapping, stance/swing blending, pelvis/root compensation, and navigation synchronization.
2026-09-16 09:33:07 -04:00
77 changed files with 7975 additions and 2567 deletions
+9 -5
View File
@@ -1,5 +1,5 @@
name = "architect" name = "architect"
description = "Defines system requirements and technical specifications for Godot features." description = "Defines system requirements, evaluates architectural choices, and suggests technical improvements for Godot features."
model = "gpt-5.6-sol" model = "gpt-5.6-sol"
model_reasoning_effort = "high" model_reasoning_effort = "high"
sandbox_mode = "read-only" sandbox_mode = "read-only"
@@ -10,15 +10,19 @@ You are the Lead Systems Architect. Refer to `AGENTS.md` for project directory s
## Responsibilities ## Responsibilities
1. **Analyze:** Read existing code patterns before designing. 1. **Analyze:** Read existing code patterns before designing.
2. **Design:** Define API payload shapes, GDScript type hints, and signal flows. 2. **Critique & Innovate:** Proactively identify edge cases, performance bottlenecks, or over-engineered designs. Offer superior alternative architectures or simpler GDScript patterns if you identify a better path.
3. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`. 3. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
4. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
## Workflow ## Workflow
1. **Scope & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous. 1. **Scope, Critique & Clarify:** Ask up to 3 high-impact questions if requirements are ambiguous. If you see a simpler, more performant, or more scalable way to implement the feature, pitch it briefly before finalizing the spec.
2. **Spec Creation:** Output a minimal specification containing: 2. **Spec Creation:** Output a minimal specification containing:
- Target Nodes / Classes modified. - Target Nodes / Classes modified.
- Signal interfaces & static type contracts. - Signal interfaces & static type contracts.
- Layout/Theme updates required. - Layout/Theme updates required.
3. **Handoff:** Save to `specs/[feature-name].md` and stop for user approval. - **Visual Diagrams & Schematics:** Include ASCII diagrams or Mermaid charts illustrating node hierarchies, state transitions, or signal flow interactions.
- **Concrete Examples & Data Flows:** Provide code/data examples demonstrating practical usage, sample dictionary shapes, or step-by-step state transformations.
- **Architectural Suggestions & Alternatives:** (Optional/Required if applicable) Highlight alternative approaches, potential pitfalls, or future-proofing ideas that improve the feature.
3. **Handoff:** Save to `specs/[feature-name].md` and display a summary including key diagrams and practical examples, then stop for user approval.
""" """
+76 -27
View File
@@ -1,11 +1,13 @@
# AGENTS.md — stickman (Godot 4.4) # AGENTS.md — stickman (Godot 4.7.1)
## Project type ## Project type
- **Godot 4.4** 2D/GUI project (Forward Plus renderer)
- **Godot 4.7.1** 2D/GUI project (Forward Plus renderer)
- Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`. - Headless tests + command-line scene playback exist (see "Command-line testing & debug playback" below); the GUI editor runs via `run/main_scene`.
- **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`) - **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`)
## Command-line testing & debug playback ## Command-line testing & debug playback
- **Godot binary (4.7.1) — resolve per platform.** Commands below use `<godot>` as shorthand: - **Godot binary (4.7.1) — resolve per platform.** Commands below use `<godot>` as shorthand:
- macOS: `/Applications/Godot.app/Contents/MacOS/Godot` - macOS: `/Applications/Godot.app/Contents/MacOS/Godot`
- Windows (PowerShell): `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe"` - Windows (PowerShell): `& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe"`
@@ -21,17 +23,28 @@
- **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines. - **Richer debug logs:** append `--verbose` to the scene-playback (or headless) command to surface engine warnings/errors alongside your own `print()` lines.
## Project overview ## Project overview
Stickman Studio is an editor tool for drawing and assembling stick figures. It is a
`Control`-based GUI (not physics/animation) in its current phase. Body-part vector shapes Stickman Studio is a kid-friendly 2D scene-building and directing project—not only a
are authored per-panel (each panel supports **multiple shapes** with Z-ordering) and stick-figure editor. Its broader product direction is an approachable moviemaker where
assembled in a "Whole Stickman" preview that supports translation, rotation, and scale. children can create and customize stickmen, build sets with floors/terrain and props,
arrange characters and objects, direct behavior with queued actions and event-triggered
rules, and play the resulting scenes.
The codebase currently has two primary workflows. The main `Control`-based stickman editor
authors body-part vector shapes and assembles them in the "Whole Stickman" preview. The
Sandbox Stage provides the wider scene workflow through EDIT/DIRECT/PLAY modes, terrain,
props, live stickman rigs, action queues, and events. The editor remains the configured
`run/main_scene`; the Sandbox Stage and its runtime systems are separate scenes and tools
that form the foundation of the broader moviemaking experience.
## Required addon ## Required addon
- **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/` - **Scalable Vector Shapes 2D** (v2.27.7) at `addons/curved_lines_2d/`
- Declared dependency for the project. The current editor UI does not instantiate it directly, - Declared dependency for the project. The current editor UI does not instantiate it directly,
but keep it present — it is required for the legacy `stick.tscn` rig. but keep it present — it is required for the legacy `stick.tscn` rig.
## Architecture ## Architecture
- `scripts/stickman_editor.gd` — `extends Control`; the main controller. Owns the menu bar, - `scripts/stickman_editor.gd` — `extends Control`; the main controller. Owns the menu bar,
save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels. save/load/clear flow, JSON (de)serialization, and populates the 10 body-part panels.
Writes `FILE_VERSION "1.5"`; auto-migrates `"1.0"`–`"1.4"` files on load. Coordinates cross-panel Writes `FILE_VERSION "1.5"`; auto-migrates `"1.0"`–`"1.4"` files on load. Coordinates cross-panel
@@ -329,16 +342,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below
`REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` (or it is sleeping) it accumulates `REST_LINEAR_THRESHOLD`/`REST_ANGULAR_THRESHOLD` (or it is sleeping) it accumulates
`_rest_timer`; after `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover` `_rest_timer`; after `rest_timeout` (and a `STABILIZATION_DELAY` hold) with `auto_recover`
on it calls `_start_recovery()`. on it calls `_start_recovery()`. - **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`,
- **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`,
which snapshots, into `_pre_ragdoll_state`, the 6 IK marker values, the 8 limb bone which snapshots, into `_pre_ragdoll_state`, the 6 IK marker values, the 8 limb bone
rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head
LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry LookAt enabled state, per-leg knee side + flag (`leg_sides`), and the chain-space entry
offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs offsets `torso_chain_offset` / `head_chain_offset` — captured at entry so the snap needs
**no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation` **no authored geometry constants** and the LEFT mirror's ambiguous `global_rotation`
decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a
mid-walk pose; the animation is not resumed). mid-walk pose; the animation is not resumed). - **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
- **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local unless in RAGDOLL) calls `_capture_ragdoll_pose()` — records the 10 bodies' rig-local
`{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and `{pos, rot, half}` (`half` = each capsule's half-length from build-time metadata) and
probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()` probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()`
@@ -350,8 +361,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet + landing X — `feet = (_captured_landing_center.x, _captured_ground_y)`, `new_root = feet +
FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local FOOT_OFFSET` — and re-bases the captured rig-local positions with a **mirror-aware local
shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives shift** `local_shift = to_local(shift) − to_local(Vector2.ZERO)`, so the re-base survives
the LEFT X-mirror), sets `state = RECOVERING` + emits. the LEFT X-mirror), sets `state = RECOVERING` + emits. - **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
- **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
`to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`; `to_local(center_world − spine_dir·half)` with rotation `chain(spine) − torso_chain_offset`;
the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and the limb bones `rot_u = chain(u) − θ_t − bone_angle_u` and
`rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via `rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l`; the head via
@@ -365,8 +375,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's `Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's
`RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head `RemoteTransform2D` update flags), then **disables** the 4 arm TwoBoneIK mods + the head
LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee LookAt and **enables** the 2 leg mods (bend side matched to the captured fall pose's knee
side vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). side vs the entry side recorded in `leg_sides`, or forced via `RECOVERY_LEG_SIDE_FORCE`). - **Stand-up:** `_play_stand_up()` starts a 2.0 s sine-in-out tween (`STAND_UP_DURATION`)
- **Stand-up:** `_play_stand_up()` starts a 2.0 s sine-in-out tween (`STAND_UP_DURATION`)
of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the of the 6 markers → their **pre-ragdoll** values and of the arm + head bones to the
`_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use `_recovery_bones` targets — upper arm bones take the shortest path, lower arm bones use
`_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower − `_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower −
@@ -374,13 +383,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven** and a `"shortest"` entry in `RECOVERY_JOINT_PATH` bypasses it). The **legs are IK-driven**
toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay toward their tweening, ground-clamped ankle markers. The arm mods + head LookAt stay
disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed
first keyframe can never match an arbitrary ragdoll rest pose). first keyframe can never match an arbitrary ragdoll rest pose). - **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint
- **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint
bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods, bend flags + head LookAt state (`_restore_head_lookat_state()`), re-enables the arm mods,
re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the re-shows `Body/*`, sets `state = ANIMATED`, and emits. The resumed IK solve reproduces the
pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk pre-ragdoll pose exactly (the end pose matches the pre-ragdoll frame, including mid-walk
poses). poses). - `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible:
- `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible:
`set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll;
`set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays `set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
@@ -403,6 +410,32 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
recreate a flinch after STOP. PLAY→DIRECT restoration also resets ANIMATED rigs' authored 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 transform, facing, IK markers, and torso rotation. Null-guards + `push_warning` prefixed
`StickmanRig:` throughout. `StickmanRig:` throughout.
- **Phase 6 continuous terrain locomotion:** terrain following is PLAY-only. `begin_terrain_fall()`
arms a kinematic gravity fall for unsupported rigs; `snap_to_terrain()` is retained as a
compatibility alias, and `set_terrain_following_enabled(false)` clears contacts and restores
live leg targets from `AnimationTargets/*_Base`. The center support uses a three-ray fan
(`[-24, 0, +24]`); each lower-leg endpoint uses an independent three-ray fan (`[-4, 0, +4]`).
Rays accept only `TerrainBlock` colliders with upward-facing normals, exclude the rig's own
collision-proxy RIDs, and choose the highest valid surface. Runtime root elevation accumulates
across consecutive ramps/steps; unsupported rigs free-fall with bounded gravity, and walking
animation pauses until landing. The terrain controller writes final `IK_Targets/Left_Leg` /
`Right_Leg` positions from layered animation base markers, complementary continuous stance/swing
weights, world-space stance locks, and a predictive 24 px foot-penetration guard. It also filters
lower floors beneath overlapping ramps and applies centerline pelvis compensation. `DEBUG_TERRAIN`
is `false` in the shipped build; temporary `[terrain]` traces are opt-in.
- **Phase 6a airborne/landing:** extends the PLAY-only terrain controller with internal
`TerrainMotion.AIRBORNE` and `LANDING_IMPACT` phases while keeping `RigState.ANIMATED`. Ledge
departures inherit signed grounded X velocity and apply exponential drag (`AIR_DRAG_X = 0.75`);
airborne horizontal travel uses a swept query against each of the ten synchronized collision
proxies and stops at the first safe fraction. The captured live hand/foot pose receives additive
flail, with hands reaching canonical local `+X` before lifting; the LEFT root mirror supplies the
opposite screen-facing direction. Falls below `HARD_LANDING_MIN_FALL_HEIGHT = 80` px land softly;
fall distance through 900 px scales torso compression `32..160` px and downward hand drop
`270..520` px across the 0.08 s compress + 0.22 s recover timeline. Both feet lock to
independent terrain samples, the upright root remains unrotated, and `LANDING_IMPACT` blocks
primary queue progression while overlay speech continues. Direct ragdoll and terrain disable
cancel the procedural pose cleanly. `tests/test_phase6a_airborne_landing.gd` passes 24/24
assertions; the implementation is user-verified (2026-09-17).
- **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)`
@@ -413,7 +446,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
(any queue mutation), `speech_finished`. Exports `walk_speed` (300.0). Public API: `walk_to( (any queue mutation), `speech_finished`. Exports `walk_speed` (300.0). Public API: `walk_to(
target, speed = -1.0)` (feet/ground destination; no-op unless `state == ANIMATED`), `is_walking()`, target, speed = -1.0)` (feet/ground destination; no-op unless `state == ANIMATED`), `is_walking()`,
`speak(text, duration)` (lazily creates a `SpeechBubble` child at `SPEECH_BUBBLE_OFFSET`, auto-hides `speak(text, duration)` (lazily creates a `SpeechBubble` child at `SPEECH_BUBBLE_OFFSET`, auto-hides
+ emits `speech_finished`), the queue API `queue_action` / `clear_queue` / `get_queue` / - emits `speech_finished`), the queue API `queue_action` / `clear_queue` / `get_queue` /
`remove_action` / `insert_action` / `queue_size` (all mutations emit `queue_changed`), and the `remove_action` / `insert_action` / `queue_size` (all mutations emit `queue_changed`), and the
runner API `start_queue` / `stop_queue` / `is_queue_running`. `_ready()` builds a runner API `start_queue` / `stop_queue` / `is_queue_running`. `_ready()` builds a
`NavigationAgent2D` child at `NAV_AGENT_LOCAL_POS` (feet, on the ground-level nav mesh; `NavigationAgent2D` child at `NAV_AGENT_LOCAL_POS` (feet, on the ground-level nav mesh;
@@ -689,13 +722,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
` is_in_ragdoll()` is false). ` is_in_ragdoll()` is false).
- `scripts/sandbox_stage.gd` — `class_name SandboxStage`, `extends Node2D`; the **Sandbox Stage - `scripts/sandbox_stage.gd` — `class_name SandboxStage`, `extends Node2D`; the **Sandbox Stage
Builder** root controller (Phase 2, **not wired into the editor**; run via **F6** on Builder** root controller (Phase 2, **not wired into the editor**; run via **F6** on
`res://scenes/sandbox_stage.tscn`). Owns the EDIT/PLAY mode state machine, placement mode, `res://scenes/sandbox_stage.tscn`). Owns the EDIT/DIRECT/PLAY mode state machine, placement mode,
camera pan/zoom, deletion, status bar, and signal fan-out; instantiates `StageSpawner` / camera pan/zoom, deletion, status bar, and signal fan-out; instantiates `StageSpawner` /
`StageSelection` / `StageGizmos` (via `preload` consts). `enum StageMode { EDIT, PLAY }` `StageSelection` / `StageGizmos` (via `preload` consts). `enum StageMode { EDIT, DIRECT, PLAY }`
(default EDIT). EDIT freezes `RigidBody2D` props with `freeze = true` + (default EDIT). EDIT freezes `RigidBody2D` props with `freeze = true` +
`freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs `freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs
KINEMATIC, not STATIC) and keeps `StickmanRig`s ANIMATED (`set_ragdoll(false)` runs first); KINEMATIC, not STATIC) and keeps `StickmanRig`s ANIMATED (`set_ragdoll(false)` runs first);
PLAY unfreezes props and ragdolls stickmen with `auto_recover = false`. Signals PLAY unfreezes props, enables PLAY-only terrain following/free-fall, and starts stickman queues
with `auto_recover = false`; ragdoll/recover remain explicit actions. Signals
`mode_changed(mode)`, `object_placed(node)`, `object_selected(nodes)`, `object_deselected()`, `mode_changed(mode)`, `object_placed(node)`, `object_selected(nodes)`, `object_deselected()`,
`object_deleted(nodes)`. Camera: middle-mouse pan, wheel zoom clamped to `@export min_zoom` / `object_deleted(nodes)`. Camera: middle-mouse pan, wheel zoom clamped to `@export min_zoom` /
`max_zoom` (0.1 / 6.0); mouse→world via `_camera.get_global_mouse_position()` (no SubViewport). `max_zoom` (0.1 / 6.0); mouse→world via `_camera.get_global_mouse_position()` (no SubViewport).
@@ -742,12 +776,13 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc next stage click appends `{"type":"walk_to","target":world_pos}` and which **Esc** cancels (Esc
priority: pending target → exit direct mode → existing placement clears). Builds a code-built priority: pending target → exit direct mode → existing placement clears). Builds a code-built
`NavigationRegion2D` (`_build_navigation()`, child of the stage **not** `World` so it is never `NavigationRegion2D` (`_build_navigation()`, child of the stage **not** `World` so it is never
hit-tested) carrying a procedural `NavigationPolygon` from per-`TerrainBlock` convex decomposition hit-tested) carrying a procedural `NavigationPolygon` from exposed upward-surface ribbons and
(`_rebake_navigation()`, `Geometry2D.decompose_polygon_in_convex` + fan triangulation, world-space short step bridges (`_rebake_navigation()`, world-space via `block.transform * p`); a `_nav_dirty`
via `block.transform * p`); a `_nav_dirty` flag re-bakes once per frame (`_process`) on terrain flag re-bakes once per frame (`_process`) on terrain
place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals` place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals`
overlay (`_build_director_visuals()`). **Play mode change (D3):** `_enter_play_mode()` no longer overlay (`_build_director_visuals()`). **Play mode change (D3):** `_enter_play_mode()` no longer
auto-ragdolls stickmen — it now sets each rig `auto_recover = false` and calls `start_queue()`; auto-ragdolls stickmen — it sets each rig `auto_recover = false`, calls `begin_terrain_fall()`,
then calls `start_queue()`;
`ragdoll`/`recover` are explicit queue actions; props still unfreeze. `_enter_edit_mode()` `ragdoll`/`recover` are explicit queue actions; props still unfreeze. `_enter_edit_mode()`
`stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires `stop_queue()`s then `snap_to_standing()`s stickmen and re-enables the visuals. Wires
`node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` → `node.queue_changed → _director_visuals.mark_dirty` when a stickman is placed; `object_deleted` →
@@ -789,6 +824,16 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop outside→overlap, does not repeat while overlapping, and re-arms after exit. The area loop's prop
enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions, enumeration is correctly scoped. Verified by `tests/test_phase4c_area.gd` (19 assertions,
all passed) and manual editor verification of A entering an area causing B's speech bubble. all passed) and manual editor verification of A entering an area causing B's speech bubble.
- **Phase 6 runtime terrain/events:** `_enter_play_mode()` calls `begin_terrain_fall()` before
`start_queue()`. EDIT/DIRECT disable terrain following and restore authored transforms. The
navigation bake uses shallow exposed upward-surface ribbons and connects neighboring surface
endpoints only within 64 px horizontal / 96 px vertical step limits; the `NavigationAgent2D`
remains at `(0, 385)` and follows the selected walk speed while terrain owns vertical root motion.
`_update_area_entry()` unions each visible stickman's current and previous world AABB so fast
free-fall still emits `entered_area`. In a reactive batch, the first takeover cancels existing
work once while later takeover actions remain ordered; `[ragdoll, recover]` therefore ragdolls,
waits for rest, then recovers. Terrain diagnostics are gated by `StickmanRig.DEBUG_TERRAIN` and
are off by default.
- **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no - **Phase 3b asset-library selector integration:** the **Stickman** and **Prop** palette buttons no
longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`, longer place directly — pressing them opens a modal **selector grid** (`_selector: AssetSelector`,
an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in an instantiated `scenes/asset_selector.tscn` `PopupPanel`, added to the UI `CanvasLayer` in
@@ -976,7 +1021,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
disables in PLAY. Also draws the box-select marquee via `set_box_rect(rect)`. disables in PLAY. Also draws the box-select marquee via `set_box_rect(rect)`.
- `scripts/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space - `scripts/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space
grid overlay (Phase 2). Pure `_draw()`: grid lines pan/zoom with the camera (`camera.get_screen_center_position()` grid overlay (Phase 2). Pure `_draw()`: grid lines pan/zoom with the camera (`camera.get_screen_center_position()`
+ viewport extent / zoom), with a heavier **major line every 5 cells**; `grid_size` / `enabled` - viewport extent / zoom), with a heavier **major line every 5 cells**; `grid_size` / `enabled`
are set by `SandboxStage`. Added as the first child of the stage root so it renders behind are set by `SandboxStage`. Added as the first child of the stage root so it renders behind
`World`; no hit-testing. `World`; no hit-testing.
- Scenes: - Scenes:
@@ -1023,6 +1068,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
palette-button selector grids. palette-button selector grids.
### Body-part data model ### Body-part data model
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`, - 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
`right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`, `right_upper_arm`, `right_lower_arm`, `left_upper_leg`, `left_lower_leg`,
`right_upper_leg`, `right_lower_leg`. `right_upper_leg`, `right_lower_leg`.
@@ -1048,6 +1094,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
`"1.0"`–`"1.4"` files auto-migrate on load). `"1.0"`–`"1.4"` files auto-migrate on load).
### settings.json (Phase 6) ### settings.json (Phase 6)
- Persisted editor preferences written to `settings.json` via `_save_settings()` and - Persisted editor preferences written to `settings.json` via `_save_settings()` and
loaded in `_load_settings()`. loaded in `_load_settings()`.
- Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`, `show_pose_guide`. - Keys: `version`, `grid_size`, `snap_to_grid`, `recent_colors`, `show_pose_guide`.
@@ -1058,12 +1105,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
via `_broadcast_settings()`. via `_broadcast_settings()`.
### Legacy scene (do not delete) ### Legacy scene (do not delete)
- `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets + - `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets +
`RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head `RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head
circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for
future animation/rigging phases. future animation/rigging phases.
## Editor conventions ## Editor conventions
- `.godot/` is gitignored; never edit it manually. - `.godot/` is gitignored; never edit it manually.
- Scene files (`*.tscn`) and `.import` files are text-based; use Godot's editor for complex changes. - Scene files (`*.tscn`) and `.import` files are text-based; use Godot's editor for complex changes.
- UID references (Godot 4 native) exist in scenes; do not change them by hand. - UID references (Godot 4 native) exist in scenes; do not change them by hand.
+26 -62
View File
@@ -510,12 +510,19 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
**Play execution:** **Play execution:**
- **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery). - **Play mode now runs the director script** — stickmen stay **ANIMATED** and each rig's `start_queue()` is called (previously they auto-ragdolled on Play). `ragdoll` / `recover` are now **explicit queue actions**; a stickman only falls when directed. Props still unfreeze and tumble (and can knock a *directed* ragdoll). `auto_recover = false` in Play (the director owns recovery).
- On **return to Edit** (or Direct): each stickman `stop_queue()` then `snap_to_standing()`, and `clear_reactive_actions()` strips any rule-injected (`reactive`) actions appended during the Play session — so reactive waypoint/badge markers do not accumulate across runs, while the authored sequential queue still replays. The waypoint overlay is then re-enabled. - On **return to Edit** (or Direct): each stickman `stop_queue()`, disables terrain following, then `snap_to_standing()`, and `clear_reactive_actions()` strips any rule-injected (`reactive`) actions appended during the Play session — so reactive waypoint/badge markers do not accumulate across runs, while the authored sequential queue still replays. The waypoint overlay is then re-enabled.
- Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state). - Multiple stickmen act simultaneously and independently (per-rig queues + per-rig runners, no shared state).
**Continuous terrain following (Phase 6):**
- Terrain adaptation is enabled only in **Play**. `_enter_play_mode()` calls `begin_terrain_fall()` before `start_queue()`. An unsupported animated rig free-falls kinematically with bounded gravity; walking animation pauses while airborne and resumes on landing. EDIT and DIRECT disable the controller and restore the authored transform/standing pose.
- The center support sample uses three downward rays at horizontal offsets `[-24, 0, +24]`; each lower-leg endpoint uses an independent three-ray fan at `[-4, 0, +4]`. A hit must be a `TerrainBlock` with an upward-facing normal, and the rig's own collision-proxy RIDs are excluded. The highest valid surface wins, preventing a lower floor under a ramp from pulling a leg through the ramp.
- `master_rig.tscn` separates animated `AnimationTargets/Left_Leg_Base` and `Right_Leg_Base` markers from live `IK_Targets/Left_Leg` and `Right_Leg` targets. The rig blends complementary continuous stance/swing weights, holds stance contacts in world space, and applies a predictive 24 px foot guard for smooth contact transitions. Root elevation accumulates across ramps and steps, with centerline pelvis compensation; no foot/ankle bones are added.
- Navigation uses shallow ribbons over exposed upward-facing TerrainBlock edges, plus bridges between surface endpoints within 64 px horizontally and 96 px vertically for valid steps. The `NavigationAgent2D` remains at local `(0, +385)` and uses the selected walk speed; navigation supplies horizontal progress while terrain following owns vertical root motion and arrival avoids Y-correction jitter.
**Nav-mesh behavior:** **Nav-mesh behavior:**
- A code-built `NavigationRegion2D` (child of the stage, not `World`, so it is never hit-tested) carries a procedural `NavigationPolygon` generated by per-`TerrainBlock` convex decomposition (`Geometry2D.decompose_polygon_in_convex` + fan triangulation) of each block's world-space polygon — robust to concavity and rotation. - A code-built `NavigationRegion2D` (child of the stage, not `World`, so it is never hit-tested) carries a procedural `NavigationPolygon` generated from exposed upward-facing TerrainBlock edges. Each surface becomes a shallow ribbon; neighboring endpoints are bridged only within the 64 px horizontal / 96 px vertical step limits, so solid interiors, walls, undersides, and large gaps are not route surfaces.
- **Auto re-bake** on any terrain edit: the nav mesh is marked dirty on terrain place / move / rotate (`transform_committed`) / delete and re-baked once per frame (coalescing bursts) in `_process`. - **Auto re-bake** on any terrain edit: the nav mesh is marked dirty on terrain place / move / rotate (`transform_committed`) / delete and re-baked once per frame (coalescing bursts) in `_process`.
- Each stickman's `NavigationAgent2D` is a child of the rig **at the feet** (local `(0, +385)` = `-FOOT_OFFSET`), so it sits on the ground-level mesh; agent and region share the default navigation map, layer 1. - Each stickman's `NavigationAgent2D` is a child of the rig **at the feet** (local `(0, +385)` = `-FOOT_OFFSET`), so it sits on the ground-level mesh; agent and region share the default navigation map, layer 1.
- `walk_to(target)` treats `target` as a **feet/ground destination**; the rig converts ground-level path points back to root positions with `FOOT_OFFSET := (0, -385)`. Walking is **hybrid**: an **on-mesh target** follows the nav path (`_walk_mode = "nav"`), while an **off-mesh / unreachable target** (empty path **or** `is_target_reachable() == false`) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`) — it is **not** rejected with a warning and the rig does **not** stand still. Nav reads are deferred until the map has synced (`map_get_iteration_id(...) != 0`) and the path query is forced via `get_next_path_position()` before any reachability/finished check, so a fresh target is never misjudged as finished/unreachable after a single step. - `walk_to(target)` treats `target` as a **feet/ground destination**; the rig converts ground-level path points back to root positions with `FOOT_OFFSET := (0, -385)`. Walking is **hybrid**: an **on-mesh target** follows the nav path (`_walk_mode = "nav"`), while an **off-mesh / unreachable target** (empty path **or** `is_target_reachable() == false`) switches to **direct straight-line steering** toward the clicked waypoint (`_walk_mode = "direct"`, root target = waypoint + `FOOT_OFFSET`) — it is **not** rejected with a warning and the rig does **not** stand still. Nav reads are deferred until the map has synced (`map_get_iteration_id(...) != 0`) and the path query is forced via `get_next_path_position()` before any reachability/finished check, so a fresh target is never misjudged as finished/unreachable after a single step.
@@ -593,10 +600,12 @@ All rule-builder context menus are **session-anchored**: the first popup in a fl
### 20.1 Phase 4c — Trigger Area Reliability ### 20.1 Phase 4c — Trigger Area Reliability
Phase 4c fixes `entered_area` source matching and visible overlap detection. In PLAY, `_update_area_entry()` enumerates committed `TriggerArea` nodes and ANIMATED stickmen plus unfrozen props. Each movable is tested against the area's current transformed world AABB, using the visible object's world AABB (with a point fallback only for a future movable with no bounds). This means a stickman's leg or another visible part can enter an area even when its feet anchor remains outside; the area itself still supports move/rotate/scale transforms. Phase 4c fixes `entered_area` source matching and visible overlap detection. In PLAY, `_update_area_entry()` enumerates committed `TriggerArea` nodes and ANIMATED stickmen plus unfrozen props. Each movable is tested against the area's current transformed world AABB, using the visible object's world AABB (with a point fallback only for a future movable with no bounds). For Phase 6, the current bound is unioned with the previous physics-frame bound, so a fast-falling stickman cannot tunnel through an area between samples. This means a stickman's leg or another visible part can enter an area even when its feet anchor remains outside; the area itself still supports move/rotate/scale transforms.
The event remains edge-triggered per `(area instance ID, movable instance ID)` pair: one `entered_area` event is emitted when that pair changes from outside to overlapping, no event repeats while it remains overlapping, and leaving then re-entering emits again. Rule matching requires both the trigger's source ID and target area ID; `source = -1` remains the explicit wildcard for any movable. The stage's prop enumeration is correctly scoped so unfrozen props participate without accidentally changing the area loop. The event remains edge-triggered per `(area instance ID, movable instance ID)` pair: one `entered_area` event is emitted when that pair changes from outside to overlapping, no event repeats while it remains overlapping, and leaving then re-entering emits again. Rule matching requires both the trigger's source ID and target area ID; `source = -1` remains the explicit wildcard for any movable. The stage's prop enumeration is correctly scoped so unfrozen props participate without accidentally changing the area loop.
Reactive takeover batches preserve action order. The first takeover cancels existing primary/overlay work once; later takeover actions in the same batch remain queued. Thus a rule containing `ragdoll` followed by `recover` executes the ragdoll, waits for rest, and then recovers instead of allowing the later action to erase the earlier one.
Verified coverage in `tests/test_phase4c_area.gd`: **19 assertions passed**, including source-specific and wildcard matching, one-shot/re-entry edges, independent area/movable pairs, transformed areas, visible leg-level overlap with feet outside, speak queue normalization and visible speech execution, no-rule no-op behavior, stale action targets, and deletion cleanup. The editor verification also confirmed that `When Stickman A enters Area → Stickman B speaks` fires when A walks through the area. Verified coverage in `tests/test_phase4c_area.gd`: **19 assertions passed**, including source-specific and wildcard matching, one-shot/re-entry edges, independent area/movable pairs, transformed areas, visible leg-level overlap with feet outside, speak queue normalization and visible speech execution, no-rule no-op behavior, stale action targets, and deletion cleanup. The editor verification also confirmed that `When Stickman A enters Area → Stickman B speaks` fires when A walks through the area.
**Prop collision detection:** Phase 4.5 replaces the old stickman feet-point test with physics contacts between `PropBlock` and the rig's per-part collision bodies. `PropBlock` still reports prop-vs-prop contacts through its physics signal; see §20.2 for routing, debouncing, and reaction details. **Prop collision detection:** Phase 4.5 replaces the old stickman feet-point test with physics contacts between `PropBlock` and the rig's per-part collision bodies. `PropBlock` still reports prop-vs-prop contacts through its physics signal; see §20.2 for routing, debouncing, and reaction details.
@@ -658,65 +667,18 @@ While a click-awaiting director step is active — a pending **Walk To** target,
#### 21.3 `res://sandbox_theme.json` (styling defaults) #### 21.3 `res://sandbox_theme.json` (styling defaults)
A single committed, hand-editable JSON config drives sandbox font/size/color/grid defaults. It loads in `_ready()` before `_build_ui()`; a missing or malformed file logs one `push_warning` and uses all built-in constants (never crashes); unknown extra keys are ignored; a referenced font that does not exist falls back to `ThemeDB.fallback_font` with a warning. The **live** grid size/snap/grid-visibility remain persisted in `user://sandbox_settings.json` (runtime source of truth); the theme supplies the **initial default** grid size on first run. Schema: The Sandbox theme is schema `2.0` and now covers the code-built chrome,
palette, selectors, dialogs, overlays, reusable widget data, fonts, colors,
and grid defaults. See the complete current key reference, examples, legacy
compatibility notes, and troubleshooting guide in
[docs/SANDBOX_THEME.md](docs/SANDBOX_THEME.md).
```json The short version: edit `res://sandbox_theme.json`, restart the Sandbox scene
{ to reload it, and remember that the live grid size/snap/grid-visibility remain
"version": "1.0", in `user://sandbox_settings.json`. Missing or malformed theme data falls back
"fonts": { to built-in defaults; missing fonts fall back to `ThemeDB.fallback_font` with a
"ui_font": "", warning. Speech font/size/emoji lists are reusable-widget data reserved for a
"emoji_font": "", future speech-customization UI and do not yet alter saved speech actions.
"ui_font_bold": "",
"ui_font_italic": "",
"action_popup_font_size": 24,
"action_popup_emoji_size": 22,
"assignment_badge_font_size": 20,
"assignment_badge_radius": 9,
"rule_label_font_size": 16,
"status_pill_font_size": 16,
"tooltip_font_size": 18,
"queue_panel_font_size": 18,
"rule_panel_font_size": 18,
"action_editor_font_size": 18,
"rule_editor_font_size": 18,
"panel_row_font_size": 16,
"panel_title_font_size": 18,
"panel_title_bold": true,
"rule_label_bold": false,
"badge_bold": true,
"action_popup": { "size": 24, "bold": false, "italic": false }
},
"grid": {
"snap_size": 15.0
},
"mode_colors": {
"edit_accent": "#22c6ff",
"direct_accent": "#ffb300",
"play_accent": "#33dd77",
"guide_line": "#22c6ff"
}
}
```
| Key | Default | Consumed by |
|---|---|---|
| `fonts.ui_font` / `fonts.emoji_font` | `""` (fallback font) | `res://` font paths; empty/missing → `ThemeDB.fallback_font`. `emoji_font` is also pushed to `StageDirectorVisuals.emoji_font` and the director popups. |
| `fonts.ui_font_bold` / `fonts.ui_font_italic` | `""` (fallback to `ui_font`) | **Phase 3c** style-variant font paths for bold/italic; empty → `ui_font`. Bold/italic are realised via a dedicated `FontVariation` (e.g. `variation_embolden`, OpenType slant) when no separate file is configured. |
| `fonts.action_popup_font_size` / `action_popup_emoji_size` | 24 / 22 | Font size override on the director action/trigger/rule popups. **Phase 3c:** `action_popup_emoji_size` is now **consumed** — applied as the popup menu's emoji-glyph font size when a popup font is configured (`_apply_popup_theme()`). |
| `fonts.action_popup` (optional object) | `{ "size": 24, "bold": false, "italic": false }` | **Phase 3c** per-widget object form for the popups; when present, overrides the flat `action_popup_font_size` / bold keys for that widget. |
| `fonts.assignment_badge_font_size` / `assignment_badge_radius` | 20 / 9 | Replaces `StageDirectorVisuals` `ICON_SIZE_PX` / `RULE_BADGE_RADIUS_PX` (and order-number size) via `set_style(cfg)`. |
| `fonts.rule_label_font_size` | 16 | Replaces `StageDirectorVisuals.RULE_LABEL_FONT_SIZE_PX`. **Phase 3c** `fonts.rule_label_bold` (default `false`) adds the bold flag; `fonts.badge_bold` (default `true`) bolds the action/trigger badges. |
| `fonts.status_pill_font_size` / `tooltip_font_size` | 16 / 18 | The mode badge pill and the cursor-attached action tooltip. |
| `fonts.queue_panel_font_size` / `rule_panel_font_size` | 18 / 18 | **Phase 3c** font sizes for the Queue / Rule panel bodies; fall back to `action_popup_font_size`. |
| `fonts.action_editor_font_size` / `rule_editor_font_size` | 18 / 18 | **Phase 3c** font sizes for the Action / Rule editors; fall back to `action_popup_font_size`. |
| `fonts.panel_row_font_size` / `panel_title_font_size` | 16 / 18 | **Phase 3c** per-row summary/number label and panel title label sizes; `fonts.panel_title_bold` (default `true`) bolds panel titles. |
| `grid.snap_size` | 15.0 | Initial default grid size for the Size spinbox (first run). |
| `mode_colors.edit_accent` / `direct_accent` / `play_accent` | `#22c6ff` / `#ffb300` / `#33dd77` | Mode badge pill bg, the Direct viewfinder frame, the active mode-segment text, and tooltip border. |
| `mode_colors.guide_line` | `#22c6ff` | The terrain drag-painting dashed guide line (`StagePlacementOverlay.guide_line_color`). |
`StageDirectorVisuals.set_style(cfg)` applies the `fonts` keys onto instance vars (`badge_icon_size`, `badge_number_size`, `badge_radius`, `rule_label_font_size`) whose defaults equal the old constants, so behavior is unchanged when no theme is present.
**Phase 3c theming (font styles/sizes):** the Phase 3c widgets now accept an `apply_font(ui_font, emoji_font, sizes)` call — `QueuePanel`, `RulePanel`, `ActionEditor`, and `RuleEditor` (see §23) each expose it (mirroring `AssetSelector.apply_font`), and `SandboxStage._build_ui()` invokes it **after** `add_child(...)`. Per-widget sizes come from the `*_panel_font_size` / `*_editor_font_size` / `panel_row_font_size` / `panel_title_font_size` keys; bold/italic style flags (`panel_title_bold`, `rule_label_bold`, `badge_bold`) and a widget's optional `{size, bold, italic}` object form are applied via the `ui_font_bold` / `ui_font_italic` `FontVariation`-derived font. The `PopupMenu`s (rig/waypoint context) keep `_apply_popup_theme()`, extended to honour `action_popup_emoji_size` and the `action_popup` object form. Authoritative schema: `docs/phase_3c_editor_spec.md` §14.
**Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`. **Director-context rule-connector refresh (bugfix):** `SandboxStage._on_transform_committed()` now calls `StageDirectorVisuals.mark_dirty()` after a move/rotate, so **translating a `TriggerArea` (or any rule-anchoring object) moves its dashed connector and ⚡/→ badges** to the new position on drag end. (Rule anchors were already computed live each `_draw()`; the missing `mark_dirty()` was leaving them stale because a `_draw()` never ran.) Deleting a referenced area already triggers `_cleanup_rules_for_nodes → set_rules → mark_dirty`.
@@ -959,7 +921,7 @@ Behavior:
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. | | `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. | | `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** | | `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. The recovery cycle is **pre-capture → ragdoll pose capture (7-ray ground probe) → mirror-aware re-anchor → snap (entry-captured chain offsets, `(−π, π]`-normalized bones, ankle clamp) → 2.0 s tween to the pre-ragdoll pose (arms/head bone-driven with `_natural_joint_target` for elbows; legs IK-driven) → finish (profile + bend flags + LookAt restored)**, with every per-joint bend decision editable in the `RAGDOLL_JOINT_BAND_LIMITS` / `RECOVERY_LEG_SIDE_FORCE` / `RECOVERY_JOINT_PATH` tables (Joint bend configuration (EDIT ME)). Diagnostics are gated behind `DEBUG_RECOVERY := false`. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. (Phase 4.5) builds ten top-level `AnimatableBody2D` collision proxies, explicitly synchronizes them to the rendered `Body/*` transforms each physics frame, derives the head circle center/radius from mounted geometry (90 px minimum), and applies bounded, contact-quadrant-aware bone flinches that decay to captured base rotations. `snap_to_standing()` and the stage's PLAY→DIRECT reset restore authored transforms and pose state. Null-guarded (`push_warning` + skip). **Not used by the editor.** | | `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. The recovery cycle is **pre-capture → ragdoll pose capture (7-ray ground probe) → mirror-aware re-anchor → snap (entry-captured chain offsets, `(−π, π]`-normalized bones, ankle clamp) → 2.0 s tween to the pre-ragdoll pose (arms/head bone-driven with `_natural_joint_target` for elbows; legs IK-driven) → finish (profile + bend flags + LookAt restored)**, with every per-joint bend decision editable in the `RAGDOLL_JOINT_BAND_LIMITS` / `RECOVERY_LEG_SIDE_FORCE` / `RECOVERY_JOINT_PATH` tables (Joint bend configuration (EDIT ME)). Diagnostics are gated behind `DEBUG_RECOVERY := false`. **Phase 6** adds PLAY-only `begin_terrain_fall()` / `set_terrain_following_enabled()`, center/per-foot TerrainBlock ray fans with self-proxy filtering, layered leg base markers vs live IK targets, continuous stance/swing blending with a predictive foot guard, accumulated root elevation/pelvis compensation across ramps/steps, and bounded free-fall. (Phase 4b) `_update_walking()` latches `_walk_mode` once per walk (`LATCH_PROBE_MAX_FRAMES` 6), unifies arrival on the final target at `ARRIVE_DISTANCE` (snap-on-arrive), steers to the final target when close, and re-asserts standing markers one frame after stop — fixing the walk-waypoint arrival jitter. (Phase 4.5) builds ten top-level `AnimatableBody2D` collision proxies, explicitly synchronizes them to the rendered `Body/*` transforms each physics frame, derives the head circle center/radius from mounted geometry (90 px minimum), and applies bounded, contact-quadrant-aware bone flinches that decay to captured base rotations. `snap_to_standing()` and the stage's PLAY→DIRECT reset restore authored transforms and pose state. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
| `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). | | `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 2.0, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). `walk_right` is the **canonical walk** clip; `walk_left` remains baked in the library but is **not used at runtime** (facing is set explicitly and the rig root X-mirrors for LEFT). |
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). | | `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). | | `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
@@ -1071,6 +1033,8 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
> **Phase 5:** Adds drawing-surface clipping, shape dragging, an editor-wide shape clipboard (Copy/Paste across panels), shape Mirror X/Y (vertex recompute), and preview object Z-ordering (Send Back/Bring Forward) with Mirror X/Y (scale negation). The format evolved to v1.3: a new top-level `part_order` array stores preview Z-order; scale may be negative for mirrored parts; shape mirroring stores no new fields because it rewrites `points`. The default grid size changed from 5 px to 15 px, and the Snap to Grid checkmark now renders correctly when toggled. v1.0–v1.2 files remain backward compatible and are migrated on load. > **Phase 5:** Adds drawing-surface clipping, shape dragging, an editor-wide shape clipboard (Copy/Paste across panels), shape Mirror X/Y (vertex recompute), and preview object Z-ordering (Send Back/Bring Forward) with Mirror X/Y (scale negation). The format evolved to v1.3: a new top-level `part_order` array stores preview Z-order; scale may be negative for mirrored parts; shape mirroring stores no new fields because it rewrites `points`. The default grid size changed from 5 px to 15 px, and the Snap to Grid checkmark now renders correctly when toggled. v1.0–v1.2 files remain backward compatible and are migrated on load.
> **Phase 6 — Continuous Dynamic IK & Terrain-Following Locomotion (implemented and editor-verified):** PLAY enables a kinematic terrain controller. Unsupported rigs free-fall under bounded gravity; walking animation pauses while airborne and resumes on landing. The centerline uses a three-ray terrain fan and each lower-leg endpoint uses an independent three-ray fan; only upward-facing `TerrainBlock` hits are accepted, with self collision proxies excluded. `AnimationTargets/*_Base` markers hold the animation pose while live leg IK targets receive continuous stance/swing blending, world-space stance locks, a predictive foot guard, and centerline pelvis/root compensation. Runtime root elevation accumulates over ramps and steps. EDIT/DIRECT disable terrain following and restore authored transforms. The Sandbox navigation bake uses exposed upward-surface ribbons with short step bridges; `NavigationAgent2D` remains at `(0, +385)` and terrain owns vertical motion while arrival remains baseline-aligned. Trigger areas use swept visible bounds across physics frames to detect fast-fall entries. Reactive takeover batches preserve order, so `ragdoll` followed by `recover` executes both in sequence. `DEBUG_TERRAIN` is off by default. No foot/ankle bones were added.
> **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`. > **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`.
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0–v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline. > **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0–v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
+362
View File
@@ -0,0 +1,362 @@
# Sandbox theme reference
`res://sandbox_theme.json` is the hand-editable presentation configuration for
the Sandbox Stage (`res://scenes/sandbox_stage.tscn`). It is loaded by
`SandboxStage` during `_ready()`, before the code-built UI is created. The
loader is `res://scripts/sandbox_theme.gd` (`class_name SandboxTheme`).
The committed file uses schema `"2.0"`. The theme controls presentation
defaults only; it does not save a scene, placement, or action data.
## Load, merge, and validation rules
The load path is:
```text
res://sandbox_theme.json
│
▼
SandboxTheme.DEFAULT_CONFIG
│ deep-merge file values
▼
SandboxStage._build_ui() and visual overlays
```
- Missing, unreadable, malformed, or non-object JSON uses the built-in default
configuration and emits one warning. The stage continues to run.
- Missing nested keys inherit the built-in value. Unknown keys are retained in
the raw dictionary but have no effect until a consumer is added.
- `stylebox(component)` resolves styles in this order:
`global` → `<section>.style_defaults` → the component object. For example,
`palette` is resolved from `global`, then `palette.style_defaults`, then
`palette`.
- Colors must be strings in `#RRGGBB` or `#RRGGBBAA` form. Invalid colors use
the requested fallback and emit a warning.
- Numeric accessors accept finite integers/floats and clamp them to the range
requested by the consumer. Invalid or non-finite numbers use that consumer's
fallback. Size arrays are accepted when they contain two numeric values;
individual controls apply their own minimum sizes.
- Font paths are `res://` resource paths. A missing resource, or a resource
that is not a `Font`, uses `ThemeDB.fallback_font` and emits a warning.
- The theme is loaded once per Sandbox Stage instance. Restart the scene (F6)
to see edits; there is no live file watcher or reload button.
The live grid size, snap toggle, and grid visibility are stored in
`user://sandbox_settings.json`. `grid.snap_size` supplies the initial grid-size
default; it does not overwrite a value already saved in user settings.
## Schema overview
Every current top-level block is listed below. The values shown are the
defaults in the committed `sandbox_theme.json` (not necessarily the fallback
used by an older caller).
### Current consumer status
The schema intentionally includes forward-looking values for the reusable
widget and speech work. The current Sandbox runtime applies these groups:
- `global` style values used by resolved styleboxes and the selector control
theme (background/border/radius/padding); `global.text_color` and
`global.muted_text_color` are available presentation values but are not
applied to every existing label automatically.
- `fonts.ui_font`, `emoji_font`, bold/italic variants, the nested `sizes`
entries for action popups, tooltips, status pills, queue/rule/action editors,
panel rows/titles, `file_menu`, `file_menu_popup`, and `mode_switcher`, plus
the related `flags`.
- `mode_colors.edit`, `direct`, `play`, and `guide`; `grid.snap_size` as the
first-run grid default; dialog sizes/fonts; `chrome.mode_switcher` width;
palette width/card size/drag threshold/style; selector cell and thumbnail
sizes; and all `overlays` values.
These declared values currently remain reserved or require a legacy adapter:
- `chrome.top_bar.height`, `status_bar.height`, `mode_frame`, `mode_badge`,
and `tooltip` describe the intended chrome contract, but the corresponding
layout/style constants are still authored in `SandboxStage`.
- `popups.action_menu.font_size` and `popups.asset_selector.backdrop_alpha`
are reserved component overrides. The active action-menu size comes from
`fonts.sizes.action_popup`; selector `cell_size` and `thumbnail_size` are
active.
- `palette.icon_size` and `palette.selected_border_width` are reserved for
the palette card renderer; the current card has its own glyph/border values.
- `fonts.sizes.assignment_badge`, `rule_label`, `asset_label`, and
`asset_badge` are part of the resolved schema, but the current direct call
into `StageDirectorVisuals` still reads the legacy flat badge/label names.
Use the legacy names documented below when changing those director visuals.
- `fonts.presets` and every `widgets` list are exposed through reusable
controls, but the current Sandbox Speak action does not instantiate those
controls yet. They are future speech-customization data.
### `version`
| Key | Format | Current value | Notes |
|---|---|---:|---|
| `version` | string | `"2.0"` | Use `"2.0"` when using the nested schema below. |
### `global`
Global style values are inherited by `SandboxTheme.stylebox()` and the default
control theme.
| Key | Format | Default | Purpose |
|---|---|---:|---|
| `font_preset` | string | `"ui"` | Named base-font selection for future/general UI use. |
| `font_size` | integer | `18` | Default `Control` font size. |
| `text_color`, `muted_text_color` | color string | `#F5F7FA`, `#AAB2C0` | Primary and secondary text colors. |
| `bg_color`, `surface_color` | color string | `#171B24F2`, `#252B38FA` | Global and raised-surface backgrounds. |
| `border_color` | color string | `#FFFFFF33` | Default border color. |
| `border_width` | integer | `1` | Default border width. |
| `corner_radius` | integer | `10` | Default corner radius. |
| `padding`, `spacing` | integer | `8`, `8` | Default style padding and layout spacing. |
### `fonts`
Font paths may be empty (`""`) to use the engine fallback. `presets` is an
object of arbitrary preset IDs; each entry has a display `label` and a `path`.
The committed file provides `ui`, `comic`, `pixel`, and `scary` entries.
| Key | Format | Default | Consumer |
|---|---|---:|---|
| `ui_font`, `emoji_font` | `res://` path or empty | `""` | Base UI and emoji-capable stage/director controls. |
| `ui_font_bold`, `ui_font_italic` | `res://` path or empty | `""` | Optional style variants; bold can be generated from the base font when no dedicated path is supplied. |
| `presets` | object `{id:{label,path}}` | see JSON | `font_presets()` and future font selectors. |
| `sizes.action_popup` | integer | `24` | Director/action popup text. |
| `sizes.action_popup_emoji` | integer | `22` | Effective popup size when a popup font is active (Godot popup menus have one font size). |
| `sizes.assignment_badge` | integer | `20` | Reserved resolved-theme value; current director visuals use legacy `fonts.assignment_badge_font_size`. |
| `sizes.rule_label` | integer | `18` | Reserved resolved-theme value; current director visuals use legacy `fonts.rule_label_font_size`. |
| `sizes.status_pill` | integer | `18` | Mode badge/pill. |
| `sizes.tooltip` | integer | `18` | Cursor-attached targeting tooltip. |
| `sizes.file_menu` | integer | `22` | `File` button in the top bar. |
| `sizes.file_menu_popup` | integer | `26` | File menu and Open Recent popup rows. |
| `sizes.mode_switcher` | integer | `24` | Edit/Direct/Play buttons. |
| `sizes.queue_panel`, `sizes.rule_panel` | integer | `22`, `22` | Queue and rule editor panels. |
| `sizes.action_editor`, `sizes.rule_editor` | integer | `22`, `22` | Action and rule editor controls. |
| `sizes.panel_row`, `sizes.panel_title` | integer | `20`, `24` | Editor row text and panel titles. |
| `sizes.asset_label`, `sizes.asset_badge` | integer | `18`, `14` | Reserved asset-card text sizing. |
| `flags.panel_title_bold` | boolean | `true` | Bold queue/rule panel titles. |
| `flags.rule_label_bold` | boolean | `false` | Bold director rule labels. |
| `flags.badge_bold` | boolean | `true` | Bold director badges. |
| `flags.action_popup_bold`, `flags.action_popup_italic` | boolean | `false`, `false` | Popup style flags. |
### `mode_colors`
Each value is a color string. These accents are used by the mode badge/frame,
active mode controls, targeting feedback, and related stage chrome.
| Key | Default | Purpose |
|---|---|---|
| `edit` | `#22C6FF` | Edit-mode accent. |
| `direct` | `#FFB300` | Direct-mode accent. |
| `play` | `#33DD77` | Play-mode accent. |
| `guide` | `#22C6FF` | Terrain painting guide line. |
### `grid`
| Key | Format | Default | Purpose |
|---|---|---:|---|
| `snap_size` | finite number | `15.0` | Initial grid size, clamped to the stage's supported range. |
### `chrome`
`style_defaults` is the optional common style object for chrome components. Its
style keys follow the `global` style formats (`bg_color`, `border_color`,
`border_width`, `corner_radius`, `padding`).
| Key | Format | Default | Purpose |
|---|---|---:|---|
| `style_defaults` | style object | `{ "bg_color":"#171B24F2", "corner_radius":10 }` | Shared chrome style base. |
| `top_bar.height` | number | `64` | Top navigation bar height. |
| `status_bar.height` | number | `38` | Bottom status bar height. |
| `mode_switcher.segment_min_width` | number | `124` | Minimum width of each Edit/Direct/Play segment. |
| `mode_frame.border_width` | integer | `3` | Colored stage frame width. |
| `mode_badge.corner_radius` | integer | `12` | Mode badge corner radius. |
| `mode_badge.padding` | number | `10` | Mode badge padding. |
| `tooltip.bg_color` | color | `#101010E6` | Targeting tooltip background. |
| `tooltip.border_width` | integer | `1` | Targeting tooltip border. |
| `dialogs.queue_size` | `[width,height]` | `[680,520]` | Queue editor window minimum size. |
| `dialogs.rule_size` | `[width,height]` | `[720,560]` | Rule editor window minimum size. |
| `dialogs.action_size` | `[width,height]` | `[560,390]` | Action editor window minimum size. |
| `dialogs.confirm_size` | `[width,height]` | `[620,300]` | Rename, unsaved, missing-assets, and other confirmation/error windows. |
| `dialogs.font_size` | integer | `22` | Dialog controls/buttons. |
| `dialogs.message_font_size` | integer | `26` | Centered AcceptDialog message text. |
### `popups`
`style_defaults` accepts the normal style keys and is inherited by popup
components.
| Key | Format | Default | Purpose |
|---|---|---:|---|
| `style_defaults` | style object | bg `#202633FA`, border `#FFFFFF33`, radius `12`, padding `12` | Common popup surface. |
| `action_menu.font_size` | integer | `24` | Reserved action-menu component override; use `fonts.sizes.action_popup` for the active menu. |
| `dialog` | object | `{}` | Reserved per-dialog popup overrides. |
| `asset_selector.backdrop_alpha` | finite number | `0.55` | Reserved selector backdrop opacity. |
| `asset_selector.cell_size` | `[width,height]` | `[190,220]` | Prop/stickman selector cell size. |
| `asset_selector.thumbnail_size` | `[width,height]` | `[156,156]` | Prop/stickman selector thumbnail size. |
### `palette`
`style_defaults` uses normal style keys and styles the stage placement palette.
| Key | Format | Default | Purpose |
|---|---|---:|---|
| `style_defaults` | style object | bg `#252B38FA`, radius `10`, padding `6` | Palette surface/card base. |
| `dock_width` | number | `116` | Palette width in Edit mode and stage-frame inset. |
| `card_size` | `[width,height]` | `[96,94]` | Placement-card minimum size; the card keeps room for its label. |
| `icon_size` | number | `30` | Reserved palette icon/glyph size. |
| `drag_threshold` | number | `8` | Pointer distance before a card becomes a drag. |
| `selected_border_width` | integer | `3` | Reserved selected palette card border. |
### `overlays`
`style_defaults` controls dashed overlay geometry in world pixels; colors are
RGBA strings.
| Key | Format | Default | Purpose |
|---|---|---:|---|
| `style_defaults.line_width` | number | `2` | Dashed trajectory/guide width. |
| `style_defaults.dash_length` | number | `8` | Dash length. |
| `style_defaults.dash_gap` | number | `5` | Gap length. |
| `terrain_guide.color` | color | `#22C6FF` | Terrain paint guide. |
| `valid.color` | color | `#4DE66680` | Valid director target/placement feedback. |
| `same_type.color` | color | `#FFFFFF26` | Same-type placement feedback. |
| `invalid.color` | color | `#FF4D4D80` | Invalid target/placement feedback. |
### `widgets`
These lists feed reusable controls (`EmojiQuickGrid`, `FontPresetPicker`, and
`PresetSizeToggle`). They are data sources, not speech-action persistence.
| Key | Format | Default | Purpose |
|---|---|---|---|
| `swatches` | array of color strings | white, black, cyan, amber, green, red | Color selector entries. |
| `size_presets` | array of `{id,label,value}` objects | Small/Medium/HUGE (`20/28/40`) | Size selector entries; `value` is an integer-like size. |
| `emoji` | array of strings | eight emoji entries | Emoji quick-grid entries. |
## Practical edits
Make the top navigation easier to read:
```json
{
"fonts": {
"sizes": {
"file_menu": 26,
"file_menu_popup": 30,
"mode_switcher": 28
}
}
}
```
Change the mode frame and make the stage colors more distinct:
```json
{
"mode_colors": {
"edit": "#4DD9FF",
"direct": "#FF8A3D",
"play": "#65E68A",
"guide": "#4DD9FF"
},
"chrome": { "mode_frame": { "border_width": 4 } }
}
```
Give selectors larger cards and thumbnails:
```json
{
"popups": {
"asset_selector": {
"cell_size": [220, 260],
"thumbnail_size": [190, 190]
}
}
}
```
Make kid-facing editors and confirmations larger:
```json
{
"chrome": {
"dialogs": {
"queue_size": [760, 600],
"rule_size": [800, 640],
"font_size": 26,
"message_font_size": 30
}
},
"fonts": {
"sizes": {
"queue_panel": 24,
"rule_panel": 24,
"action_editor": 24,
"rule_editor": 24,
"panel_row": 22,
"panel_title": 28
}
}
}
```
## Legacy v1 files
Older Phase 4b/3c theme files used flat keys such as
`fonts.action_popup_font_size`, `fonts.queue_panel_font_size`,
`fonts.panel_title_font_size`, and mode keys such as
`mode_colors.edit_accent` and `mode_colors.guide_line`. They remain accepted.
The loader aliases the legacy mode names and selected flat font-size names into
the nested structure, while `SandboxStage` retains the old precedence for a
file that does not declare version `"2.0"`.
The complete legacy names still read by the current runtime are:
```text
fonts.action_popup_font_size fonts.action_popup_emoji_size
fonts.assignment_badge_font_size fonts.assignment_badge_radius
fonts.rule_label_font_size fonts.status_pill_font_size
fonts.tooltip_font_size fonts.queue_panel_font_size
fonts.rule_panel_font_size fonts.action_editor_font_size
fonts.rule_editor_font_size fonts.panel_row_font_size
fonts.panel_title_font_size fonts.panel_title_bold
fonts.rule_label_bold fonts.badge_bold
fonts.action_popup = { size, bold, italic }
mode_colors.edit_accent mode_colors.direct_accent
mode_colors.play_accent mode_colors.guide_line
```
For a migration, copy the values into the nested keys in this document and set
`"version": "2.0"`. Keep only one spelling of each setting so the result is
easy to maintain.
## Fonts, emoji, and troubleshooting
- Use project-relative paths such as `res://fonts/kids_font.ttf`; do not use a
machine-specific absolute path.
- If text falls back to the Godot default, check the path, import status, and
that the resource is actually a `Font`/font file. Restart the Sandbox after
correcting it.
- Emoji availability depends on the selected font and platform glyph support.
A missing `emoji_font` is non-fatal; the normal UI fallback remains usable.
- If a color or number is ignored, check JSON commas/quotes, use a supported
color form, and keep numbers finite. The warning identifies the affected key.
- If a selector or dialog looks cramped, increase its size array and its
related font size together; restart F6 to reload the file.
## Speech customization status
`fonts.presets` (`ui`, `comic`, `pixel`, `scary`), `widgets.size_presets`,
`widgets.swatches`, and `widgets.emoji` are the reusable data contract for a
future speech-customization UI. They are intentionally available now so a
future Speak dialog can consume the same theme.
The current Sandbox speech action still stores and renders text through the
existing `StickmanSpeechBubble`; these theme lists do not yet change saved
speech actions, bubble shape, bubble colors, or bubble font at runtime. Do not
assume that changing a preset changes an existing speech action. Treat
speech-specific fields under `popups.dialog` or future bubble sections as
reserved until that feature is implemented.
+2
View File
@@ -37,6 +37,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 25 | **Contact direct-state timing fallback** — `PropBlock` may receive `body_entered` before a matching direct-state contact row is available. | Low | Open | `_on_body_entered()` uses the documented center-direction / prop-position fallback when the row is absent, so the signal remains safe but the normal and position are less precise on that edge. (2026-09-12) | | 25 | **Contact direct-state timing fallback** — `PropBlock` may receive `body_entered` before a matching direct-state contact row is available. | Low | Open | `_on_body_entered()` uses the documented center-direction / prop-position fallback when the row is absent, so the signal remains safe but the normal and position are less precise on that edge. (2026-09-12) |
| 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) | | 26 | **Impact speed is a coarse magnitude** — collision reactions currently scale from `prop.linear_velocity.length()`, not normal-relative velocity or an impulse computed from contact mass. | Low | Open | This keeps the signal contract simple and deterministic. Use the normal component and/or contact impulse if reaction strength needs to reflect glancing impacts accurately. (2026-09-12) |
| 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) | | 27 | **Post-IK reaction timing is engine-order-sensitive** — bounded flinch offsets are applied at the end of `StickmanRig._physics_process()`, after the IK/head-pin work. | Low | Open | The current slot is verified and prevents cumulative rotation, but future changes to Skeleton2D modification ordering could overwrite or delay the visible reaction. Revisit only with a targeted engine-order regression. (2026-09-12) |
| 28 | **Airborne collision sweep cost scales per proxy** — Phase 6a tests the horizontal movement against each of the rig's ten synchronized collision proxies every airborne physics frame. | Low | Open | The per-proxy `PhysicsShapeQueryParameters2D.cast_motion()` checks reuse the existing proxy shapes and avoid a second body-size approximation; this is acceptable for the current sandbox scale. With many simultaneously falling rigs, the cost grows with `rigs × 10` casts per physics frame. Consider a combined query shape or broadphase candidate filter if airborne crowds become a target. (2026-09-17) |
--- ---
@@ -79,6 +80,7 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. | | 2026-09-04 | Phase 3c (Editor Tools — Action & Rule Editing) implemented: `scripts/action_registry.gd` / `trigger_registry.gd` (extensible action/trigger templates), `scripts/action_editor.gd` / `rule_editor.gd` / `queue_panel.gd` / `rule_panel.gd` + `scenes/*.tscn` shells, `scripts/waypoint_context.gd`, plus integration in `sandbox_stage.gd` (unified `CaptureKind` target-capture, "Edit Queue…/Edit Rules…" + right-click stickman/waypoint context menus, confirmation dialog, consequence-only rule editor) and `stage_director_visuals.gd` (`hit_test_waypoint_action`, waypoint edit highlight). Logged #19 (ad-hoc popup host-visibility discipline), #20 ("Add Rule" has no source in waypoint-filtered panels), #21 (drag-reorder nearest-row-center), #22 (`--check-only --script` needs a prior `--editor` rescan), and later the same day #23 (Phase 3c confirmation dialog popped over an exclusive panel — the delete/clear-all confirm paths do not hide the `QueuePanel`/`RulePanel` first). Documented the plan-§11.2 deviation (paramless `ActionEditor` pre-fill instead of a separate ragdoll/recover confirmation dialog) in `docs/phase_3c_editor_spec.md` §2 decision 5 — a design decision, so no debt row. Static-verified via headless `--editor --quit` + per-script `--check-only`; runtime F6 flows (queue/rule panel edit-delete-reorder, waypoint context menu, visual walk edit, full vs consequence-only rule editor) still need manual verification. |
| 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). | | 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). |
| 2026-09-12 | Phase 4.5 collision bodies verified: ten explicitly synchronized `AnimatableBody2D` proxies replace the old feet-point stickman collision poll; `PropBlock` supplies world-space contact data with shape-cast CCD; stage routing preserves rig→prop rules with per-part pair debouncing; bounded quadrant-aware flinches and PLAY→DIRECT authored-pose restoration were verified. `tests/test_phase45_collision_bodies.gd` passed 223 assertions; Phase 4b (53), Phase 4c (19), and Phase 3c (96) remained green. Reconciled #1/#5 and retained #25–#27 as intentional implementation limitations. | | 2026-09-12 | Phase 4.5 collision bodies verified: ten explicitly synchronized `AnimatableBody2D` proxies replace the old feet-point stickman collision poll; `PropBlock` supplies world-space contact data with shape-cast CCD; stage routing preserves rig→prop rules with per-part pair debouncing; bounded quadrant-aware flinches and PLAY→DIRECT authored-pose restoration were verified. `tests/test_phase45_collision_bodies.gd` passed 223 assertions; Phase 4b (53), Phase 4c (19), and Phase 3c (96) remained green. Reconciled #1/#5 and retained #25–#27 as intentional implementation limitations. |
| 2026-09-17 | Phase 6a (Airborne Momentum, Procedural Air Pose, and Landing Absorption) implemented and user-verified. `StickmanRig` now carries signed ledge momentum with tuned exponential drag (`AIR_DRAG_X = 0.75`), swept horizontal proxy collision, additive free-fall pose with forward-before-lift arms, and height-driven landing absorption: falls below 80 px are soft; 80..900 px maps to 32..160 px torso compression and 270..520 px hand drop over 0.30 s. Independent foot contacts preserve ramps/stairs, while `LANDING_IMPACT` blocks primary queue progression and direct ragdoll remains an immediate preemption. `tests/test_phase6a_airborne_landing.gd` passed 24 assertions; `tests/test_phase4b_terrain.gd` passed 8 assertions. Logged #28 (per-proxy airborne sweep scaling). |
--- ---
+206
View File
@@ -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": ""
}
}
File diff suppressed because it is too large Load Diff
+407
View File
@@ -0,0 +1,407 @@
{
"version": "1.0",
"scene_name": "Untitled",
"camera": {
"position": {
"x": 976.030700683594,
"y": 725.191955566406
},
"zoom": 0.282236993312836
},
"objects": [
{
"file_id": "terrain_1",
"type": "terrain",
"position": {
"x": -960.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_2",
"type": "terrain",
"position": {
"x": -768.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_3",
"type": "terrain",
"position": {
"x": -576.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_4",
"type": "terrain",
"position": {
"x": -384.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_5",
"type": "terrain",
"position": {
"x": -192.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_6",
"type": "terrain",
"position": {
"x": 0.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_7",
"type": "terrain",
"position": {
"x": 192.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_8",
"type": "terrain",
"position": {
"x": 384.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_9",
"type": "terrain",
"position": {
"x": 576.0,
"y": -288.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "stickman_1",
"type": "stickman",
"position": {
"x": -222.0,
"y": -959.0
},
"rotation": 0.0,
"stk_path": "res://stickmen/break.stk",
"facing_profile": "FORWARD",
"action_queue": [
{
"type": "walk_to",
"target": {
"x": -1302.19653320313,
"y": -305.856567382813
}
},
{
"type": "ragdoll"
}
]
},
{
"file_id": "terrain_10",
"type": "terrain",
"position": {
"x": 768.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_11",
"type": "terrain",
"position": {
"x": 960.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_12",
"type": "terrain",
"position": {
"x": 1152.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_13",
"type": "terrain",
"position": {
"x": 1344.0,
"y": 2048.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_14",
"type": "terrain",
"position": {
"x": 1536.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_15",
"type": "terrain",
"position": {
"x": 1728.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_16",
"type": "terrain",
"position": {
"x": 1920.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_17",
"type": "terrain",
"position": {
"x": 2112.0,
"y": 2080.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_18",
"type": "terrain",
"position": {
"x": -2112.0,
"y": 2016.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_19",
"type": "terrain",
"position": {
"x": -1920.0,
"y": 2016.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_20",
"type": "terrain",
"position": {
"x": -1728.0,
"y": 2016.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_21",
"type": "terrain",
"position": {
"x": -1536.0,
"y": 2016.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_22",
"type": "terrain",
"position": {
"x": -1344.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_23",
"type": "terrain",
"position": {
"x": -1152.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_24",
"type": "terrain",
"position": {
"x": -960.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_25",
"type": "terrain",
"position": {
"x": -768.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
},
{
"file_id": "terrain_26",
"type": "terrain",
"position": {
"x": -576.0,
"y": 1984.0
},
"rotation": 0.0,
"asset_id": "ground",
"scale": {
"x": 1.0,
"y": 1.0
}
}
],
"event_rules": [],
"metadata": {
"created_unix": 1789666501,
"modified_unix": 1789666824.739,
"autosave": false,
"source_path": ""
}
}
+190
View File
@@ -0,0 +1,190 @@
# Phase 6 — Continuous Dynamic IK & Terrain-Following Locomotion
Status: implemented and verified in the Godot editor. This document records the shipped runtime behavior and supersedes the rejected authoring-time normalization design.
## 1. Goal and scope
During PLAY, every animated stickman continuously conforms its feet and pelvis to TerrainBlock ground. An unsupported rig enters a kinematic, gravity-driven free-fall; supported rigs independently sample both lower-leg endpoints, hold stance contacts, preserve lifted swing arcs, and compensate the root for ramps and steps. The lower-leg endpoints are the foot contacts: Phase 6 adds no foot/ankle bones or new visual geometry.
This is runtime locomotion. The phase does not change palette placement or stored walk-action coordinates; both remain existing feet-space values. Ragdoll physics and recovery stay independent from animated terrain contacts.
## 2. Constraints
- StickmanRig owns live IK_Targets and runs physics processing; SandboxStage owns the EDIT-to-PLAY handoff.
- TerrainBlock is a solid StaticBody2D. Terrain, props, and rig collision proxies share layer 1, so probes accept only TerrainBlock hits with upward-facing normals.
- The walk_right and legacy walk_left clips write the layered `AnimationTargets/*_Base` markers; terrain code is the sole runtime writer of the live leg IK targets.
- NavigationAgentAnchor remains at (0, 385) and FOOT_OFFSET remains the sole feet-space to root-space conversion.
- Navigation is baked from exposed upward-facing terrain edges as ribbons, with bounded bridges for valid steps; solid terrain interiors are not route surfaces.
## 3. Layered IK ownership
### 3.1 Scene structure
`master_rig.tscn` contains base-pose markers which are not read by the Skeleton modification stack:
Master (StickmanRig)
├── IK_Targets
│ ├── Left_Leg # final target; TwoBoneIK reads this
│ └── Right_Leg # final target; TwoBoneIK reads this
└── AnimationTargets
├── Left_Leg_Base # authored walk animation writes here
└── Right_Leg_Base # authored walk animation writes here
The two leg-position tracks in the walk clips write the `AnimationTargets` markers. Existing hand, torso, and head tracks keep their current targets. The rig is then the sole runtime writer of the live `IK_Targets/Left_Leg` and `IK_Targets/Right_Leg` positions.
### 3.2 Rig controller API and state
StickmanRig owns a private controller with these constants/state:
TERRAIN_PROBE_UP = 96
TERRAIN_PROBE_DOWN = 700
TERRAIN_NORMAL_MIN_Y = -0.15
FOOT_CONTACT_EPSILON = 2
FOOT_BLEND_RESPONSE = 14
TERRAIN_SUPPORT_RANGE = 160
TERRAIN_MAX_FOOT_SURFACE_DROP = 72
TERRAIN_FOOT_GUARD_HEIGHT = 24
PELVIS_MAX_OFFSET = 240
PELVIS_RESPONSE = 10
WALK_CYCLE_DURATION = 0.8
_leg_base_markers # Left_Leg/Right_Leg -> Marker2D
_foot_contacts # leg -> {valid, world_pos, normal, locked}
_terrain_pelvis_offset
New methods:
begin_terrain_fall() -> void
snap_to_terrain() -> void # compatibility alias; arms the PLAY controller
set_terrain_following_enabled(enabled: bool) -> void
_update_terrain_following(delta) -> void
_probe_terrain_below(world_point) -> Dictionary
`begin_terrain_fall()` is called on PLAY entry and is safe/idempotent: it enables the controller, clears contacts, resets fall velocity, and starts unsupported rigs in free-fall. `snap_to_terrain()` remains a compatibility alias. `set_terrain_following_enabled(false)` clears contacts, resets terrain state, and copies base markers back to live targets. Missing nodes/world state warn with a `StickmanRig:` prefix and skip safely.
### 3.3 Probe contract
The center support sample uses a three-ray horizontal fan at offsets `[-24, 0, +24]`. Each lower-leg endpoint uses its own three-ray fan at `[-4, 0, +4]`; each ray starts 96 px above its world X and ends 700 px below. A result is accepted only when:
1. its collider is a TerrainBlock;
2. its normal has y <= TERRAIN_NORMAL_MIN_Y; and
3. it is in the stated probe span.
The fan selects the highest valid surface (smallest world Y). Queries use layer 1, reject non-`TerrainBlock` colliders, reject normals below the upward-facing threshold, and exclude the rig's own collision-proxy RIDs. Invalid rays never invent a floor. World/global transforms preserve LEFT root mirroring.
## 4. Per-frame solve order
In `StickmanRig._physics_process()`, terrain following runs only while terrain following is enabled and `state == ANIMATED`, after walking moves the root and before collision proxies synchronize:
AnimationPlayer updates AnimationTargets base pose
↓
walking moves root along nav/direct baseline
↓
terrain controller:
sample contacts → gait weights → foot locks → pelvis offset → final targets
↓
Skeleton TwoBoneIK solves final targets
↓
collision proxies synchronize
The shipped ordering reads the base markers, applies walking/root motion, solves terrain contacts, then synchronizes proxies. `RAGDOLL` and `RECOVERING` do not run the animated terrain controller or write the live leg targets.
RECOVERING and RAGDOLL are excluded because they own or hide the same targets; the terrain controller does not write them in those states.
## 5. Stance/swing blending
The current walk clips are 0.8 seconds and alternate legs. The controller computes complementary continuous cosine weights over that cycle:
| Cycle interval | Right leg | Left leg |
| --- | --- | --- |
| 0.00–0.40 | stance weight 1 → 0 | swing weight 0 → 1 |
| 0.40–0.80 | swing weight 0 → 1 | stance weight 1 → 0 |
The complementary cosine weights provide a continuous transition; idle uses stance weight 1.0 for both legs.
- At stance entry, capture the valid terrain point as a world-space foot lock. While planted, transform the stable world point back to rig-local space; this prevents horizontal foot skating as the root moves.
- In swing, retain the animation base target and apply only the terrain influence needed to prevent penetration. A 24 px predictive foot guard ramps that correction in before the endpoint reaches the surface.
- Between phases, blend base and lock/contact targets with response limiting to avoid knee pops at step edges.
- At idle both legs are stance. They can independently lock to a ramp or two different stair heights; the root does not rotate.
## 6. Pelvis/root compensation
Calculate a desired vertical correction from the centerline terrain fan, clamp it to `PELVIS_MAX_OFFSET` (240 px), and ease it with `PELVIS_RESPONSE`.
runtime root Y += terrain pelvis correction
Runtime root elevation accumulates directly as the controller advances across consecutive ramps and steps; the last-frame correction variable is cleared each frame and is not subtracted from the root. If the centerline has no supported TerrainBlock, the root free-falls with `TERRAIN_GRAVITY` up to `TERRAIN_MAX_FALL_SPEED`, and walk animation pauses until landing. Landing applies the full support correction to prevent tunneling. Feet too far below the center support surface are ignored so an overlapping lower floor cannot pull a leg through a ramp.
## 7. Play entry and walkable-surface navigation
On PLAY entry, after props unfreeze, `SandboxStage` calls `begin_terrain_fall()` for every rig before `start_queue()`. This is the only mode that enables terrain free-fall/following. EDIT and DIRECT call `set_terrain_following_enabled(false)` and restore the captured authored transform/standing pose; terrain following never mutates authored placement.
### 7.1 Walkable-surface ribbon
SandboxStage replaces the full-solid NavigationPolygon bake with a derived walkable-surface ribbon:
1. Transform every TerrainBlock polygon into stage-world coordinates.
2. Extract only upward-facing boundary edges from the clockwise TerrainBlock polygons; discard vertical edges and undersides.
3. Expand each accepted edge into a narrow, configurable navigation strip just above the collision surface.
4. Bridge neighboring surface endpoints only when the horizontal gap is at most 64 px and the height difference is at most 96 px, connecting authored stair treads without making cliffs walkable.
5. Bake the resulting strips as the NavigationPolygon. Large cliffs, terrain interiors, walls, undersides, and unsupported gaps remain disconnected.
The ribbon follows ramps continuously and connects valid stair treads while deliberately refusing impossible steps. Terrain collision remains authoritative for the final ankle probe; the ribbon supplies only route topology.
### 7.2 Walking alignment
Walking retains `NavigationAgentAnchor` at `(0, 385)` and synchronizes `NavigationAgent2D.max_speed` with the selected walk speed. Navigation supplies horizontal progress; terrain following owns vertical root motion. Arrival compares horizontal/root baseline distance and does not let terrain Y correction create waypoint jitter. On-mesh paths follow the ribbon; unreachable/off-mesh targets use latched direct steering.
### 7.3 Trigger and reactive-action behavior
`SandboxStage._update_area_entry()` tests the visible stickman's world AABB and unions it with the previous physics-frame AABB. This swept bound catches `entered_area` events during fast PLAY-only free-fall instead of relying on a single sampled pose. Rules still dispatch while the rig is `ANIMATED` and airborne.
Reactive takeover actions preserve their authored order within one rule batch. The first takeover cancels existing primary/overlay work; later takeover actions remain queued, so a `[ragdoll, recover]` rule executes ragdoll first, waits for rest, then recovers. The runtime terrain trace is gated by `DEBUG_TERRAIN := false` and is off by default.
## 8. Files and ownership
| File | Implemented responsibility |
| --- | --- |
| scripts/stickman_rig.gd | Base-marker resolution, PLAY-only kinematic free-fall, center/per-foot terrain fans, contact locks, gait weights, predictive foot guard, accumulated root elevation, pelvis controller, and terrain-state cleanup. |
| master_rig.tscn | `AnimationTargets/Left_Leg_Base` and `Right_Leg_Base` markers plus redirected leg animation tracks. |
| scripts/sandbox_stage.gd | PLAY terrain-controller handoff; EDIT/DIRECT authored-state restoration; exposed-surface ribbon baking; swept trigger-area bounds; and reactive action dispatch. |
| scripts/terrain_utils.gd | Add pure world-polygon edge classification/strip helpers if extracting that geometry keeps SandboxStage focused on lifecycle and orchestration. |
| scripts/terrain_block.gd, scripts/stage_spawner.gd | No behavioral change. |
## 9. Milestones and acceptance
### 1. Play Snap
- [x] PLAY-only hovering rigs free-fall onto flat ground, ramps, and stair treads before/while queues run.
- [x] EDIT/DIRECT disable terrain following and restore the authored transform.
### 2. Idle Slope IK
- [x] Independent lower-leg endpoint contacts visibly flex knees across ramps/stair edges.
- [x] Probes accept only TerrainBlock upward surfaces and exclude rig proxies; RAGDOLL/RECOVERING are excluded.
### 3. Walk Cycle Blending
- [x] Stance feet remain planted while opposite feet follow lifted swing arcs with continuous weights and a predictive guard.
- [x] Uphill/downhill and stairs avoid dragging, penetration, and one-frame knee/pelvis pops in editor verification.
- [x] Cancel, ragdoll, and recovery safely clear controller state.
### 4. Navigation Sync
- [x] The navigation anchor stays (0, 385); navigation speed follows the selected walk speed and terrain code owns vertical motion.
- [x] The baked navigation mesh covers exposed ribbons and valid connected steps, not solid terrain interiors or undersides.
- [x] Flat, ramps, stairs, and off-mesh arrival remain deterministic without terrain-Y waypoint jitter.
- [x] Collision proxies synchronize after final terrain IK.
- [x] Swept visible bounds detect trigger-area entry during fast free-fall; reactive `[ragdoll, recover]` batches execute in order.
## 10. Verification record
Editor verification covered hovering PLAY entry, flat ground, ramps, consecutive ramps, steps, walking climbs, falling/landing, trigger-area entry during free-fall, ragdoll/recover reactive rules, and EDIT/DIRECT restoration. The shipped design confirms:
- the two-layer animation/final-IK target structure;
- world-space stance locks with phase-weighted swing blending;
- additive, clamped runtime pelvis correction without authored/nav-offset changes;
- terrain-only contacts with center/per-foot fans and PLAY-only free-fall; and
- a derived walkable-surface ribbon that replaces full-solid navigation baking.
The temporary terrain diagnostics remain available behind `DEBUG_TERRAIN`, but the gate is `false` in the shipped build.
+66
View File
@@ -0,0 +1,66 @@
# Phase 6a Architecture Proposal: Airborne Momentum, Dynamic Air-Pose & Landing Impact Absorption
## 1. Executive Summary
Phase 6a extends the shipped Phase 6 continuous terrain locomotion system by replacing rigid vertical free-falls and instant landings with dynamic, momentum-driven airborne physics. It introduces horizontal air momentum decay, procedural wind-drag pose flailing during fall, and velocity-proportional landing impact compression that temporarily holds action queue execution.
---
## 2. Technical Mechanics & State Integration
### 2.1 Airborne Horizontal Momentum Decay
- **Momentum Snapshot**: Upon detecting a loss of ground support during `ANIMATED` mode, `StickmanRig` caches the last horizontal velocity ($v_x$) from navigation or manual walking.
- **Parabolic Arc**: During free-fall frames, $v_x$ continues to shift root $X$ while applying exponential air drag ($\text{AIR\_DRAG\_X} = 4.0$). Vertical gravity (`TERRAIN_GRAVITY`) accelerates $Y$ down to `TERRAIN_MAX_FALL_SPEED`.
### 2.2 Procedural Air Pose & Wind-Drag Flail
- **Limb Displacement**: As downward velocity ($v_y$) increases, upper limb `IK_Targets` blend away from `AnimationTargets/*_Base` markers, pulling hands above the head to simulate wind resistance.
- **High-Frequency Flail**: Layer anti-phased sine-wave offsets ($\sim 12\text{ Hz}$) onto hand and foot IK targets, scaling amplitude with $v_y$ to create a realistic airborne fluttering effect.
### 2.3 Landing Impact Compression & Queue Hold
- **Velocity Capture**: On `_probe_ground_y()` contact detection, record the final impact fall speed ($v_{\text{impact}}$).
- **Impact Absorption**: If $v_{\text{impact}} \ge \text{MIN\_IMPACT\_VELOCITY}$, trigger a 2-phase pelvis squash tween:
1. **Compress**: Rapidly ease pelvis Y downward ($\sim 0.08\text{s}$) into a deep crouch, forcing leg `TwoBoneIK` solvers to flex while feet stay locked to ground contacts.
2. **Recover**: Spring pelvis Y back up to standing terrain height ($\sim 0.22\text{s}$) using a sine-out easing curve.
- **Queue Synchronization**: Set an internal `_is_landing_impact` flag during absorption to pause `action_queue` processing, resuming automatically once standing recovery completes.
---
## 3. Component & API Specification
| Component | Target System | Implementation Details |
| --------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Momentum Tracker** | `StickmanRig._physics_process()` | Snapshot $v_x$ at fall transition; apply $v_x \cdot \Delta t$ with exponential decay to root $X$ during free-fall. |
| **Air Pose Solver** | `StickmanRig` IK Stack | Offset live arm/leg `IK_Targets` dynamically relative to base markers based on $v_y$. |
| **Impact Absorption** | `StickmanRig` Terrain Controller | Run pelvis squash/spring tween on ground contact; hold `action_queue` until complete. |
---
## 4. Verification & Acceptance Criteria
- **Ledge Ejection**: Walking off an elevated ledge projects the rig outward in a parabolic curve rather than dropping straight down.
- **Air Flail**: Arms flex upward and hands/feet flutter rhythmically during high-speed free-fall.
- **Impact Cushion**: Drops exceeding minimum velocity threshold exhibit a visible knee bend and pelvis compression before standing up.
- **Queue Order**: Action queue execution halts during landing impact recovery and resumes cleanly without dropping or skipping pending actions.
## 5. Open Questions and Edge Cases
To ensure the implementation runs smoothly, here are the key edge cases and technical questions your architect should clarify before finalizing Phase 6a:
- **Ragdoll Escalation Threshold**: If the fall velocity ($v_{\text{impact}}$) exceeds a extreme safety limit, should the stickman escalate into a full dynamic ragdoll collapse instead of executing a soft crouch-and-recover animation?
- **Mid-Air Wall Collisions**: How should horizontal air momentum ($v_x$) react if the stickman drifts laterally into a vertical wall or prop before touching the floor? Should horizontal velocity immediately zero out on wall contact to prevent body clipping?
- **Sloped Landing Compression**: How does pelvic squash behave when landing on an angled ramp or stair tread? We need to confirm whether left and right foot IK targets remain independently anchored to unequal terrain elevations while the pelvis squashes downward.
- **Action Queue Preemption**: If a dynamic trigger fires or a user issues a new direct action while the stickman is airborne or mid-landing crouch, should the incoming action cancel the landing recovery sequence or wait at the front of the queue?
- **Custom Pose Overrides**: How should wind-drag flailing blend if the character goes airborne while performing a non-locomotion action (like holding an item or attacking)? Should flailing override all hand keyframes, or only apply during default walk/idle states?
+134
View File
@@ -0,0 +1,134 @@
# High-Level Architectural Plan: Graphical UI & Palette Polish Phase
## Objective
To overhaul the `SandboxStage` user interface into a polished, kid-friendly graphical experience, while establishing a flexible, schema-driven theme system that seamlessly supports present UI elements and future feature additions (such as dynamic speech bubble styling, sound customization, and extended rule editors).
---
## 1. Centralized Theme & Resource Infrastructure
### 1.1 JSON Theme Schema Expansion (`sandbox_theme.json`)
- **Unified Theme Specification**: Expand the `sandbox_theme.json` contract to serve as the single source of truth for typography, spatial grids, mode accent colors, and component-specific visual presets.
- **Subsystem Category Blocks**: Structure the schema into modular sub-dictionaries (e.g., `fonts`, `mode_colors`, `grid`, `popups`, `speech_bubbles`).
- **Graceful Fallback Pipeline**:
1. Specific component override value.
2. Subsystem block default (e.g., `speech_bubbles.style_defaults`).
3. Global theme default (`fonts.ui_font`).
4. System fallback (`ThemeDB.fallback_font`).
- **Resource Preloading & Validation**: Ensure the theme loader validates paths, gracefully handles missing assets with non-blocking warnings, and caches loaded `Font` and `StyleBox` objects.
---
## 2. Graphical Stage Chrome & Mode Navigation
### 2.1 Mode Switcher & Viewport Frame
- **3-Segment Graphical Switcher**: Replace standard text toggles with a stylized 3-segment mode control (`[✏️ Edit | 🎬 Direct | ▶️ Play]`).
- **Viewport Frame Accent**: Render a screen-constant border frame (`_mode_frame`) around the viewport that dynamically tints according to the active mode accent color.
- **Mode Badge Pill**: Maintain a rounded, high-contrast status badge in the viewport corner displaying the active state with mode-specific icons and accent backgrounds.
### 2.2 Status Bar & Dynamic Cursor System
- **Status & Coordinate Bar**: Polish the bottom bar for scannability, hosting contextual operational hints and live spatial coordinates.
- **Contextual Cursors & Floating Tooltips**:
- Swap between arrow/crosshair, placement cursors, and custom action reticles depending on mode and pending operations.
- Attach floating, high-contrast tooltip pills offset from the cursor during target selection workflows (e.g., direct mode commands or trigger mapping).
---
## 3. Icon-Based Palette & Thumbnail Engine
### 3.1 Draggable / Clickable Card Palette
- **Visual Asset Cards**: Transition spawner controls from text buttons to visual card tiles displaying item icons, labels, and category badges.
- **Placement Integration**: Support both click-to-select active placement and direct drag-and-drop actions onto the stage.
### 3.2 Modal Asset Selector & Thumbnail Pipeline
- **Asset Selector Modal**: Refine the modal popup and its dimming background backdrop for browsing extended stickmen and prop libraries.
- **Asynchronous Thumbnail Cache**:
- Streamline off-screen thumbnail generation for custom props and stickmen.
- Cache generated preview textures to prevent UI stuttering during library scans.
---
## 4. Visual Overlay & Placement Feedback Engine
### 4.1 Terrain Drag-Painting & Ghosting Feedback
- **Block-Unit Stride Alignment**: Align placement ghosts and Bresenham line painting to the active terrain template's AABB extent.
- **3-State Ghost Tinting**:
- **Green**: Valid, empty space ready for placement.
- **Faint / Neutral**: Same-type block (skipped to avoid double-stamping).
- **Red**: Conflicting entity or invalid geometry.
### 4.2 Guidance & Target Trajectories
- **Axis-Aligned Guides**: Render high-contrast dashed lines along cardinal axes during Shift-locked terrain drags.
- **Rubber-Band Trajectories**: Draw contextual connection lines and target reticles from character origins to mouse targets during Direct mode actions.
---
## 5. Reusable Control Widgets & Forward-Compatible Systems
### 5.1 Standardized Kid-Friendly UI Components
Develop a suite of reusable, theme-aware control widgets that can be embedded across dialogs, action popups, and future parameter editors:
- **Color Swatch Selector**: Clickable circle palettes populated from theme swatch arrays or editor history.
- **Preset Size Toggles**: Segmented buttons for coarse adjustments (e.g., **Small**, **Medium**, **HUGE**) mapping to backend integer/scale metrics.
- **Preset Style / Font Pickers**: Visual icon or text buttons allowing selection between preset fonts (e.g., Standard, Comic, Pixel, Scary).
- **Emoji Quick Grid**: A dedicated grid widget for inserting expressive graphics directly into text fields.
### 5.2 Context Menus & Action Popups
- **Uniform Popup Styling**: Apply standardized theme rules, font sizes, and emoji scaling across action context menus, trigger submenus, and dialogs.
- **Clean Action Dialogs**: Align text input (Speak), duration (Wait), and rename popups with dark-mode sandbox styling.
### 5.3 Extension Blueprint: Speech Bubble Customization System
Architect the theme and component pipeline so that future speech bubble upgrades plug directly into the visual foundation without refactoring:
- **Data Contract**: Ensure the `speak` action payload accepts visual parameters (`font_preset`, `size_preset`, `color`, `bubble_style`) alongside text.
- **Style Engine Integration**: Speech bubble nodes consume geometry styles (Standard, Yell, Thought, Whisper) and theme font presets directly from `sandbox_theme.json`.
- **UI Reusability**: The standard Color Swatch, Size Toggle, Font Picker, and Emoji Grid widgets drop directly into the Direct mode `Speak` parameter dialog.
---
## Recommended Execution Sequence
1. **Phase 1: Theme Engine & Core Schema**: Extend `sandbox_theme.json` schema, build centralized theme loading/fallback pipeline, and integrate theme application hooks across stage UI.
2. **Phase 2: Chrome & Mode Navigation Polish**: Implement the 3-segment mode control, viewport frame accents, status badges, and cursor/tooltip overlay controllers.
3. **Phase 3: Reusable UI Component Library**: Build the Color Swatch, Size Toggle, Font Picker, and Emoji Grid widgets.
4. **Phase 4: Graphical Palette & Modal Overhaul**: Implement icon-based asset cards, refine the `AssetSelector` modal, and optimize asynchronous thumbnail caching.
5. **Phase 5: Context Menus & Overlay Polish**: Update action popups with uniform theme sizing and polish drag-painting ghost/guide overlays.
+198
View File
@@ -0,0 +1,198 @@
# Architecture Plan: Rig Relocation & Component Modularization
### **Objective**
Relocate `master_rig.tscn` to `res://scenes/stickman_rig.tscn` and decouple `stickman_rig.gd` into dedicated, single-responsibility sub-components to improve maintainability, testability, and concern separation.
---
### **Phase 1: File Operations & Scene Relocation**
1. Move `res://master_rig.tscn` to `res://scenes/stickman_rig.tscn` within Godot to maintain internal UID mappings and resource links.
2. Verify sub-resource integrity after relocation.
---
### **Phase 2: Script Modularization (`stickman_rig.gd`)**
Extract key responsibilities into sub-nodes or dedicated helper scripts attached to `stickman_rig.tscn`:
- **Ragdoll & Recovery Component (`StickmanRagdoll`)**
- Move ragdoll body/joint tables (`RAGDOLL_BODIES`, `RAGDOLL_JOINTS`) and joint limit bands.
- Move procedural physics node instantiation (`RagdollBodyContainer`), pin joint setups, and impulse application (`apply_ragdoll_velocity_boost`).
- Extract rest detection polling (`REST_LINEAR_THRESHOLD`, `REST_ANGULAR_THRESHOLD`), rest timers, and stand-up recovery tweens (`request_recovery`, `snap_to_standing`).
- **Locomotion & Terrain Subsystem (`StickmanLocomotion`)**
- Extract surface raycast probing (`_probe_ground_y`, `_probe_terrain_fan`) and root elevation adjustments (`_reanchor_root_to_landing`).
- Move airborne physics, horizontal drag calculations (`AIR_DRAG_X`), downward gravity acceleration, and procedural wind-drag pose flailing.
- Extract landing impact velocity capture and knee-compression crouch/spring recovery tweens.
- **Director & Action Runner (`StickmanActionRunner`)**
- Extract the action queue data structure (`action_queue`) and execution state machines (`RunnerState`, `ActionPhase`).
- Move pathfinding coordination (`NavigationAgent2D`), kinematic `walk_to` execution, and `FOOT_OFFSET` alignment.
- Handle speech bubble triggers (`speak`), wait timers, and rule-injected reactive action clearing (`clear_reactive_actions`).
- **Core Rig Coordinator (`StickmanRig`)**
- Retain core rig state management (`ANIMATED`, `RAGDOLL`, `RECOVERING`).
- Focus strictly on facing profiles (`FacingProfile`), TwoBoneIK bend direction toggles (`BendDirection`), and draw-order sorting (`Z_ORDER_BY_PROFILE`).
- Serve as the root coordinator relaying signals and state transitions to sub-components.
---
### **Phase 3: Reference & Script Updates**
Update hardcoded string references from `"res://master_rig.tscn"` to `"res://scenes/stickman_rig.tscn"`:
- **`scripts/stickman_factory.gd`**: Update `spawn_from_data()` instantiation target.
- **`scripts/test_harness.gd`**, **`scripts/stk_rig_adapter.gd`**, **`scripts/create_animations.gd`**: Update script comments, tool headers, and path constants.
- **`tests/` Suite**: Search and replace hardcoded scene path string literals across all unit tests.
---
### **Phase 4: Documentation Updates**
Update path references across project markdown files:
- `README.md`, `AGENTS.md`, `RIGGING.md`, and relevant files in `specs/` and `plans/`.
---
### **Phase 5: Verification & Acceptance Criteria**
1. **Separation & Testability**: Confirm that locomotion raycasting and action runner execution can be tested independently without instantiating ragdoll physics.
2. **Factory Instantiation**: Verify `StickmanFactory.spawn()` loads `res://scenes/stickman_rig.tscn` cleanly without path errors.
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.
+9 -33
View File
@@ -1,35 +1,11 @@
{ {
"version": "1.0", "version": "2.0",
"fonts": { "global": { "font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8 },
"ui_font": "", "fonts": { "ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": { "ui": {"label":"Standard","path":""}, "comic":{"label":"Comic","path":""}, "pixel":{"label":"Pixel","path":""}, "scary":{"label":"Scary","path":""} }, "sizes": { "action_popup":24, "action_popup_emoji":22, "assignment_badge":20, "rule_label":18, "status_pill":18, "tooltip":18, "file_menu":22, "file_menu_popup":26, "mode_switcher":24, "queue_panel":22, "rule_panel":22, "action_editor":22, "rule_editor":22, "panel_row":20, "panel_title":24, "asset_label":18, "asset_badge":14 }, "flags": { "panel_title_bold":true, "rule_label_bold":false, "badge_bold":true, "action_popup_bold":false, "action_popup_italic":false } },
"emoji_font": "", "mode_colors": { "edit":"#22C6FF", "direct":"#FFB300", "play":"#33DD77", "guide":"#22C6FF" }, "grid": { "snap_size":15.0 },
"action_popup_font_size": 24, "chrome": { "style_defaults": {"bg_color":"#171B24F2","corner_radius":10}, "top_bar":{"height":64}, "status_bar":{"height":38}, "mode_switcher":{"segment_min_width":124}, "mode_frame":{"border_width":3}, "mode_badge":{"corner_radius":12,"padding":10}, "tooltip":{"bg_color":"#101010E6","border_width":1}, "dialogs":{"queue_size":[680,520],"rule_size":[720,560],"action_size":[560,390],"confirm_size":[620,300],"font_size":22,"message_font_size":26} },
"action_popup_emoji_size": 22, "popups": { "style_defaults":{"bg_color":"#202633FA","border_color":"#FFFFFF33","corner_radius":12,"padding":12}, "action_menu":{"font_size":24}, "dialog":{}, "asset_selector":{"backdrop_alpha":0.55,"cell_size":[190,220],"thumbnail_size":[156,156]} },
"assignment_badge_font_size": 20, "palette": { "style_defaults":{"bg_color":"#252B38FA","corner_radius":10,"padding":6}, "dock_width":116, "card_size":[96,94], "icon_size":30, "drag_threshold":8, "selected_border_width":3 },
"assignment_badge_radius": 9, "overlays": { "style_defaults":{"line_width":2,"dash_length":8,"dash_gap":5}, "terrain_guide":{"color":"#22C6FF"}, "valid":{"color":"#4DE66680"}, "same_type":{"color":"#FFFFFF26"}, "invalid":{"color":"#FF4D4D80"} },
"rule_label_font_size": 16, "widgets": { "swatches":["#FFFFFF","#111111","#22C6FF","#FFB300","#33DD77","#FF4D4D"], "size_presets":[{"id":"small","label":"Small","value":20},{"id":"medium","label":"Medium","value":28},{"id":"huge","label":"HUGE","value":40}], "emoji":["😀","😂","😍","😮","😢","😡","👍","❤️"] }
"status_pill_font_size": 16,
"tooltip_font_size": 18,
"ui_font_bold": "",
"ui_font_italic": "",
"queue_panel_font_size": 18,
"rule_panel_font_size": 18,
"action_editor_font_size": 18,
"rule_editor_font_size": 18,
"panel_row_font_size": 16,
"panel_title_font_size": 18,
"panel_title_bold": true,
"rule_label_bold": false,
"badge_bold": true,
"action_popup": { "size": 24, "bold": false, "italic": false }
},
"grid": {
"snap_size": 15.0
},
"mode_colors": {
"edit_accent": "#22c6ff",
"direct_accent": "#ffb300",
"play_accent": "#33dd77",
"guide_line": "#22c6ff"
}
} }
+27 -9
View File
@@ -1,6 +1,9 @@
[gd_scene format=3 uid="uid://dr5ef3l2var2s"] [gd_scene format=3 uid="uid://dr5ef3l2var2s"]
[ext_resource type="Script" uid="uid://b4rsae8suaxj7" path="res://scripts/stickman_rig.gd" id="1_rig_script"] [ext_resource type="Script" uid="uid://b4rsae8suaxj7" path="res://scripts/stickman_rig.gd" id="1_rig_script"]
[ext_resource type="Script" path="res://scripts/stickman_locomotion.gd" id="2_locomotion"]
[ext_resource type="Script" path="res://scripts/stickman_ragdoll.gd" id="3_ragdoll"]
[ext_resource type="Script" path="res://scripts/stickman_action_runner.gd" id="4_runner"]
[sub_resource type="GDScript" id="GDScript_f0s26"] [sub_resource type="GDScript" id="GDScript_f0s26"]
script/source = "@tool script/source = "@tool
@@ -54,11 +57,7 @@ joint_two_bone2d_node = NodePath("Torso/LeftUpperLeg/LeftLowerLeg")
bone_index = 1 bone_index = 1
bone2d_node = NodePath("Torso/Head") bone2d_node = NodePath("Torso/Head")
target_nodepath = NodePath("../IK_Targets/Head") target_nodepath = NodePath("../IK_Targets/Head")
enable_constraint = true enable_constraint = false
constraint_angle_min = -180.0
constraint_angle_max = 180.0
constraint_angle_invert = false
constraint_in_localspace = false
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"] [sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
resource_local_to_scene = true resource_local_to_scene = true
@@ -190,7 +189,7 @@ tracks/1/keys = {
tracks/2/type = "value" tracks/2/type = "value"
tracks/2/imported = false tracks/2/imported = false
tracks/2/enabled = true tracks/2/enabled = true
tracks/2/path = NodePath("IK_Targets/Right_Leg:position") tracks/2/path = NodePath("AnimationTargets/Right_Leg_Base:position")
tracks/2/interp = 2 tracks/2/interp = 2
tracks/2/loop_wrap = true tracks/2/loop_wrap = true
tracks/2/keys = { tracks/2/keys = {
@@ -202,7 +201,7 @@ tracks/2/keys = {
tracks/3/type = "value" tracks/3/type = "value"
tracks/3/imported = false tracks/3/imported = false
tracks/3/enabled = true tracks/3/enabled = true
tracks/3/path = NodePath("IK_Targets/Left_Leg:position") tracks/3/path = NodePath("AnimationTargets/Left_Leg_Base:position")
tracks/3/interp = 2 tracks/3/interp = 2
tracks/3/loop_wrap = true tracks/3/loop_wrap = true
tracks/3/keys = { tracks/3/keys = {
@@ -266,7 +265,7 @@ tracks/1/keys = {
tracks/2/type = "value" tracks/2/type = "value"
tracks/2/imported = false tracks/2/imported = false
tracks/2/enabled = true tracks/2/enabled = true
tracks/2/path = NodePath("IK_Targets/Right_Leg:position") tracks/2/path = NodePath("AnimationTargets/Right_Leg_Base:position")
tracks/2/interp = 2 tracks/2/interp = 2
tracks/2/loop_wrap = true tracks/2/loop_wrap = true
tracks/2/keys = { tracks/2/keys = {
@@ -278,7 +277,7 @@ tracks/2/keys = {
tracks/3/type = "value" tracks/3/type = "value"
tracks/3/imported = false tracks/3/imported = false
tracks/3/enabled = true tracks/3/enabled = true
tracks/3/path = NodePath("IK_Targets/Left_Leg:position") tracks/3/path = NodePath("AnimationTargets/Left_Leg_Base:position")
tracks/3/interp = 2 tracks/3/interp = 2
tracks/3/loop_wrap = true tracks/3/loop_wrap = true
tracks/3/keys = { tracks/3/keys = {
@@ -325,6 +324,17 @@ _data = {
[node name="Master" type="Node2D" unique_id=319103476] [node name="Master" type="Node2D" unique_id=319103476]
script = ExtResource("1_rig_script") script = ExtResource("1_rig_script")
[node name="RigComponents" type="Node" parent="."]
[node name="Locomotion" type="Node" parent="RigComponents"]
script = ExtResource("2_locomotion")
[node name="Ragdoll" type="Node" parent="RigComponents"]
script = ExtResource("3_ragdoll")
[node name="ActionRunner" type="Node" parent="RigComponents"]
script = ExtResource("4_runner")
[node name="Body" type="Node2D" parent="." unique_id=327811712] [node name="Body" type="Node2D" parent="." unique_id=327811712]
[node name="Body" type="Line2D" parent="Body" unique_id=1290443463] [node name="Body" type="Line2D" parent="Body" unique_id=1290443463]
@@ -555,6 +565,14 @@ position = Vector2(110, 390)
[node name="Left_Leg" type="Marker2D" parent="IK_Targets" unique_id=63598929] [node name="Left_Leg" type="Marker2D" parent="IK_Targets" unique_id=63598929]
position = Vector2(-110, 380) position = Vector2(-110, 380)
[node name="AnimationTargets" type="Node2D" parent="."]
[node name="Right_Leg_Base" type="Marker2D" parent="AnimationTargets"]
position = Vector2(110, 390)
[node name="Left_Leg_Base" type="Marker2D" parent="AnimationTargets"]
position = Vector2(-110, 380)
[node name="RayCast_LeftLeg" type="RayCast2D" parent="IK_Targets" unique_id=2000000001] [node name="RayCast_LeftLeg" type="RayCast2D" parent="IK_Targets" unique_id=2000000001]
position = Vector2(-96, 200) position = Vector2(-96, 200)
target_position = Vector2(0, 200) target_position = Vector2(0, 200)
+3
View File
@@ -135,6 +135,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
_base_font_size = int(sizes.get("action_editor", 18)) _base_font_size = int(sizes.get("action_editor", 18))
_apply_font_recursive(self, _base_font_size) _apply_font_recursive(self, _base_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void: func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children(): for child: Node in node.get_children():
+28 -41
View File
@@ -10,8 +10,9 @@ const COLUMNS := 4
const ROWS := 3 const ROWS := 3
const PAGE_SIZE := COLUMNS * ROWS const PAGE_SIZE := COLUMNS * ROWS
const CELL_MIN_SIZE := Vector2(140.0, 168.0) const CELL_MIN_SIZE := Vector2(190.0, 220.0)
const THUMB_SIZE := Vector2(120.0, 120.0) const THUMB_SIZE := Vector2(156.0, 156.0)
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
var kind: String = "" var kind: String = ""
@@ -22,6 +23,7 @@ var _cell_texrects: Dictionary = {}
var _placeholder_tex: ImageTexture = null var _placeholder_tex: ImageTexture = null
var _ui_font: Font = null var _ui_font: Font = null
var _emoji_font: Font = null var _emoji_font: Font = null
var _sandbox_theme: Variant = null
@onready var _title_label: Label = %TitleLabel @onready var _title_label: Label = %TitleLabel
@onready var _grid: GridContainer = %GridContainer @onready var _grid: GridContainer = %GridContainer
@@ -87,6 +89,21 @@ func apply_font(ui_font: Font, emoji_font: Font) -> void:
for c: Control in controls: for c: Control in controls:
_apply_font_to(c) _apply_font_to(c)
func apply_theme(theme: Variant) -> void:
_sandbox_theme = theme
if theme != null:
self.theme = theme.control_theme()
add_theme_stylebox_override("panel", theme.stylebox("popups.asset_selector"))
var cell_size: Variant = theme.value("popups.asset_selector.cell_size", CELL_MIN_SIZE)
if cell_size is Array and (cell_size as Array).size() == 2:
_cell_min_size = Vector2(float(cell_size[0]), float(cell_size[1]))
var thumbnail_size: Variant = theme.value("popups.asset_selector.thumbnail_size", THUMB_SIZE)
if thumbnail_size is Array and (thumbnail_size as Array).size() == 2:
_thumbnail_size = Vector2(float(thumbnail_size[0]), float(thumbnail_size[1]))
var _cell_min_size: Vector2 = CELL_MIN_SIZE
var _thumbnail_size: Vector2 = THUMB_SIZE
func _unhandled_input(event: InputEvent) -> void: func _unhandled_input(event: InputEvent) -> void:
if visible and event is InputEventKey: if visible and event is InputEventKey:
@@ -152,46 +169,16 @@ func _rebuild() -> void:
func _build_cell(entry: Dictionary) -> Control: func _build_cell(entry: Dictionary) -> Control:
var key := _entry_key(entry) var key := _entry_key(entry)
var cell := Button.new() var cell: Variant = ASSET_CARD.new()
cell.custom_minimum_size = CELL_MIN_SIZE # Selector cells inherit the popup theme. Avoid a per-card state override so
cell.toggle_mode = false # opening a selector never implies that one asset is pre-selected.
cell.configure({"placement_id": _entry_key(entry), "label": String(entry.get("name", entry.get("id", ""))), "icon": "", "category": String(entry.get("material_label", "")), "thumbnail_size": _thumbnail_size}, _thumbnails.get(key, null), null)
cell.custom_minimum_size = _cell_min_size
cell.focus_mode = Control.FOCUS_NONE cell.focus_mode = Control.FOCUS_NONE
cell.pressed.connect(_on_cell_pressed.bind(entry)) cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry))
var vbox := VBoxContainer.new()
vbox.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
vbox.mouse_filter = Control.MOUSE_FILTER_IGNORE
vbox.add_theme_constant_override("separation", 2)
cell.add_child(vbox)
var tex_rect := TextureRect.new()
tex_rect.custom_minimum_size = THUMB_SIZE
tex_rect.size_flags_horizontal = Control.SIZE_SHRINK_CENTER
tex_rect.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
tex_rect.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
tex_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
var tex: Texture2D = _thumbnails.get(key, null) var tex: Texture2D = _thumbnails.get(key, null)
tex_rect.texture = tex if tex != null else _get_placeholder() cell.set_thumbnail(tex if tex != null else _get_placeholder())
vbox.add_child(tex_rect) _cell_texrects[key] = cell.thumbnail_rect()
_cell_texrects[key] = tex_rect
var name_label := Label.new()
name_label.text = String(entry.get("name", ""))
name_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
name_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
name_label.add_theme_font_size_override("font_size", 14)
_apply_font_to(name_label)
vbox.add_child(name_label)
if kind == "prop":
var badge := Label.new()
badge.text = String(entry.get("material_label", ""))
badge.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
badge.add_theme_font_size_override("font_size", 12)
badge.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
_apply_font_to(badge)
vbox.add_child(badge)
return cell return cell
@@ -211,7 +198,7 @@ func _apply_font_to(c: Control) -> void:
func _get_placeholder() -> ImageTexture: func _get_placeholder() -> ImageTexture:
if _placeholder_tex == null: if _placeholder_tex == null:
var img := Image.create(THUMB_SIZE.x, THUMB_SIZE.y, false, Image.FORMAT_RGBA8) var img := Image.create(int(_thumbnail_size.x), int(_thumbnail_size.y), false, Image.FORMAT_RGBA8)
img.fill(Color(0.16, 0.16, 0.16, 1.0)) img.fill(Color(0.16, 0.16, 0.16, 1.0))
_placeholder_tex = ImageTexture.create_from_image(img) _placeholder_tex = ImageTexture.create_from_image(img)
return _placeholder_tex return _placeholder_tex
+1 -1
View File
@@ -25,7 +25,7 @@ const POSE_PATHS: Dictionary = {
const POSE_MARKERS: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"] const POSE_MARKERS: Array[String] = ["Torso", "Head", "Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]
## Pose templates (rig-local). POSE_DOWN is a generic "lying on back" rest pose; ## Pose templates (rig-local). POSE_DOWN is a generic "lying on back" rest pose;
## POSE_STANDING matches master_rig.tscn's authored IK-target defaults. The ## POSE_STANDING matches scenes/stickman_rig.tscn's authored IK-target defaults. The
## baked stand_up is an authored reference only — runtime recovery tweens the ## baked stand_up is an authored reference only — runtime recovery tweens the
## IK targets directly from the captured ragdoll pose instead. ## IK targets directly from the captured ragdoll pose instead.
const POSE_DOWN: Dictionary = { const POSE_DOWN: Dictionary = {
+3 -3
View File
@@ -3,7 +3,7 @@ extends Node2D
## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2). ## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2).
## ##
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils ## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
## factory, spawns a master_rig.tscn instance standing on the flat ground, and ## factory, spawns a scenes/stickman_rig.tscn instance standing on the flat ground, and
## provides camera zoom/pan input plus a top-bar UI (spawn buttons, a ## provides camera zoom/pan input plus a top-bar UI (spawn buttons, a
## Stickman/Ragdoll mode toggle, and a "Knock Up" force button). NOT wired into ## Stickman/Ragdoll mode toggle, and a "Knock Up" force button). NOT wired into
## the editor — run standalone via F6 on res://scenes/physics_test_harness.tscn. ## the editor — run standalone via F6 on res://scenes/physics_test_harness.tscn.
@@ -16,7 +16,7 @@ const MIN_ZOOM: float = 0.25
const MAX_ZOOM: float = 3.0 const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10 const ZOOM_STEP: float = 1.10
const RIG_SCENE := preload("res://master_rig.tscn") const RIG_SCENE := preload("res://scenes/stickman_rig.tscn")
## Preloaded prop scripts: resolved via preload (not the global class registry) ## Preloaded prop scripts: resolved via preload (not the global class registry)
## so the harness compiles even when the editor's class cache is stale. ## so the harness compiles even when the editor's class cache is stale.
@@ -251,7 +251,7 @@ func _build_environment() -> void:
func _spawn_rig() -> void: func _spawn_rig() -> void:
var rig := RIG_SCENE.instantiate() as StickmanRig var rig := RIG_SCENE.instantiate() as StickmanRig
if rig == null: if rig == null:
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.") push_warning("PhysicsTestHarness: failed to instantiate scenes/stickman_rig.tscn.")
return return
rig.position = RIG_SPAWN_POSITION rig.position = RIG_SPAWN_POSITION
# Manual recovery only ("Recover Now"): auto-recover is left off so the # Manual recovery only ("Recover Now"): auto-recover is left off so the
+3
View File
@@ -191,6 +191,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
if _empty_label != null: if _empty_label != null:
_apply_font_to(_empty_label, _row_font_size) _apply_font_to(_empty_label, _row_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void: func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children(): for child: Node in node.get_children():
+3
View File
@@ -211,6 +211,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
_base_font_size = int(sizes.get("rule_editor", 18)) _base_font_size = int(sizes.get("rule_editor", 18))
_apply_font_recursive(self, _base_font_size) _apply_font_recursive(self, _base_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void: func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children(): for child: Node in node.get_children():
+3
View File
@@ -196,6 +196,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
if _empty_label != null: if _empty_label != null:
_apply_font_to(_empty_label, _row_font_size) _apply_font_to(_empty_label, _row_font_size)
func apply_theme(sandbox_theme: Variant) -> void:
if sandbox_theme != null: self.theme = sandbox_theme.control_theme()
func _apply_font_recursive(node: Node, size: int) -> void: func _apply_font_recursive(node: Node, size: int) -> void:
for child: Node in node.get_children(): for child: Node in node.get_children():
+412 -87
View File
@@ -24,6 +24,8 @@ const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd") const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
const PROP_LIBRARY := preload("res://scripts/prop_library.gd") const PROP_LIBRARY := preload("res://scripts/prop_library.gd")
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd") const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const SANDBOX_THEME := preload("res://scripts/sandbox_theme.gd")
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd") const STICKMAN_THUMBNAIL := preload("res://scripts/thumbnails/stickman_thumbnail.gd")
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd") const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
@@ -69,6 +71,8 @@ const ZOOM_STEP: float = 1.10
const MIN_BOX_AREA: float = 16.0 const MIN_BOX_AREA: float = 16.0
const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer" const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
const SELECTOR_DIM_ALPHA := 0.5 const SELECTOR_DIM_ALPHA := 0.5
const TOP_BAR_HEIGHT: float = 64.0
const STATUS_BAR_HEIGHT: float = 38.0
const SETTINGS_PATH := "user://sandbox_settings.json" const SETTINGS_PATH := "user://sandbox_settings.json"
const THEME_PATH := "res://sandbox_theme.json" const THEME_PATH := "res://sandbox_theme.json"
@@ -165,9 +169,13 @@ var _selector_dim: ColorRect = null
var _browse_dialog: FileDialog = null var _browse_dialog: FileDialog = null
var _thumbnail_queue: Array[Dictionary] = [] var _thumbnail_queue: Array[Dictionary] = []
var _thumbnail_busy: bool = false var _thumbnail_busy: bool = false
var _thumbnail_generation: int = 0
var _mode_buttons: Dictionary = {} # int (StageMode) -> Button var _mode_buttons: Dictionary = {} # int (StageMode) -> Button
var _palette_buttons: Dictionary = {} var _palette_buttons: Dictionary = {}
var _palette_dock: PanelContainer = null
var _palette_drag_payload: Dictionary = {}
var _palette_drag_previous_id: String = ""
var _status_label: Label var _status_label: Label
var _status_cursor_coords: Label var _status_cursor_coords: Label
var _direct_hint_label: Label var _direct_hint_label: Label
@@ -264,6 +272,7 @@ var _nav_dirty: bool = true
## Parsed sandbox_theme.json (or {} if missing/malformed). ## Parsed sandbox_theme.json (or {} if missing/malformed).
var _theme: Dictionary = {} var _theme: Dictionary = {}
var _sandbox_theme = null
var _theme_grid_default: float = DEFAULT_GRID_SIZE var _theme_grid_default: float = DEFAULT_GRID_SIZE
var _accent_edit: Color = Color("#22c6ff") var _accent_edit: Color = Color("#22c6ff")
var _accent_direct: Color = Color("#ffb300") var _accent_direct: Color = Color("#ffb300")
@@ -329,6 +338,7 @@ var _rule_more_popup: PopupMenu = null
## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>" ## Edge-trigger bookkeeping for the geometric engine, keyed "<area_id>:<node_id>"
## and "<rig_id>:<prop_id>". ## and "<rig_id>:<prop_id>".
var _area_overlap: Dictionary = {} var _area_overlap: Dictionary = {}
var _area_previous_bounds: Dictionary = {}
var _collision_pairs: Dictionary = {} var _collision_pairs: Dictionary = {}
## Lightweight toast text + countdown (cleared in _process on expiry). ## Lightweight toast text + countdown (cleared in _process on expiry).
@@ -418,6 +428,7 @@ func _ready() -> void:
_apply_theme_to_visuals() _apply_theme_to_visuals()
_rebuild_grid_cells() _rebuild_grid_cells()
_apply_grid_settings() _apply_grid_settings()
_update_stage_chrome_layout()
_apply_mode_frame() _apply_mode_frame()
_sync_mode_buttons() _sync_mode_buttons()
_refresh_mode_badge() _refresh_mode_badge()
@@ -460,6 +471,18 @@ func _physics_process(_delta: float) -> void:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if not _palette_drag_payload.is_empty():
if event is InputEventMouseMotion:
_update_palette_drag((event as InputEventMouseMotion).position)
return
if event is InputEventMouseButton:
var drag_button := event as InputEventMouseButton
if drag_button.button_index == MOUSE_BUTTON_LEFT and not drag_button.pressed:
_finish_palette_drag(drag_button.position)
return
if drag_button.button_index == MOUSE_BUTTON_RIGHT and drag_button.pressed:
_cancel_palette_drag()
return
if event is InputEventMagnifyGesture: if event is InputEventMagnifyGesture:
# Touchpad pinch zoom. # Touchpad pinch zoom.
_set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor) _set_zoom(_camera.zoom.x * (event as InputEventMagnifyGesture).factor)
@@ -485,7 +508,9 @@ func _unhandled_key_input(event: InputEvent) -> void:
if current_mode == StageMode.EDIT: if current_mode == StageMode.EDIT:
delete_selected() delete_selected()
KEY_ESCAPE: KEY_ESCAPE:
if _capture_kind != CaptureKind.NONE: if not _palette_drag_payload.is_empty():
_cancel_palette_drag()
elif _capture_kind != CaptureKind.NONE:
_cancel_capture() _cancel_capture()
elif _rule_step != RuleStep.IDLE: elif _rule_step != RuleStep.IDLE:
_cancel_rule_build() _cancel_rule_build()
@@ -526,6 +551,16 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
return return
if mb.button_index == MOUSE_BUTTON_RIGHT: if mb.button_index == MOUSE_BUTTON_RIGHT:
if mb.pressed: if mb.pressed:
if _capture_kind != CaptureKind.NONE:
_cancel_capture()
elif _rule_step != RuleStep.IDLE:
_cancel_rule_build()
elif _pending_walk_target:
_pending_walk_target = false
_context_rig = null
_apply_cursor()
_refresh_status()
else:
_handle_right_click() _handle_right_click()
return return
if mb.button_index != MOUSE_BUTTON_LEFT: if mb.button_index != MOUSE_BUTTON_LEFT:
@@ -609,15 +644,18 @@ func _viewport_to_world(viewport_pos: Vector2) -> Vector2:
func set_mode(mode: StageMode) -> void: func set_mode(mode: StageMode) -> void:
if mode == current_mode: if mode == current_mode:
return return
var previous_mode: StageMode = current_mode
if mode == StageMode.PLAY and not _cli_startup: if mode == StageMode.PLAY and not _cli_startup:
if not _save_scene_to_path(AUTOSAVE_PATH, true): if not _save_scene_to_path(AUTOSAVE_PATH, true):
push_warning("SandboxStage: autosave before Play failed.") push_warning("SandboxStage: autosave before Play failed.")
current_mode = mode current_mode = mode
if not _palette_drag_payload.is_empty():
_cancel_palette_drag()
match mode: match mode:
StageMode.EDIT: StageMode.EDIT:
_enter_edit_mode() _enter_edit_mode(previous_mode)
StageMode.DIRECT: StageMode.DIRECT:
_enter_direct_mode() _enter_direct_mode(previous_mode)
StageMode.PLAY: StageMode.PLAY:
_enter_play_mode() _enter_play_mode()
_sync_mode_buttons() _sync_mode_buttons()
@@ -630,12 +668,13 @@ func set_mode(mode: StageMode) -> void:
func _enter_edit_shared() -> void: func _enter_edit_shared() -> void:
# Snap stickmen straight back to their standing pose/position (no stand-up # Restore stickmen to their authored spawn/ghost pose (no stand-up tween
# tween glide), stopping any director queue first. # glide), stopping any director queue first.
for node: Node2D in _world_children_selectable(): for node: Node2D in _world_children_selectable():
if node is STICKMAN_RIG: if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG var rig := node as STICKMAN_RIG
rig.stop_queue() rig.stop_queue()
rig.set_terrain_following_enabled(false)
rig.snap_to_standing() rig.snap_to_standing()
rig.clear_reactive_actions() rig.clear_reactive_actions()
# Freeze every prop (kinematic) so nothing keeps falling in EDIT — including # Freeze every prop (kinematic) so nothing keeps falling in EDIT — including
@@ -655,10 +694,14 @@ func _enter_edit_shared() -> void:
_director_visuals.set_enabled(true) _director_visuals.set_enabled(true)
# Clear edge-trigger state so PLAY overlaps do not leak stale results. # Clear edge-trigger state so PLAY overlaps do not leak stale results.
_area_overlap.clear() _area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear() _collision_pairs.clear()
func _enter_edit_mode() -> void: 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() _enter_edit_shared()
_clear_director_pending() _clear_director_pending()
_set_edit_controls_visible(true) _set_edit_controls_visible(true)
@@ -691,10 +734,14 @@ func _clear_director_pending() -> void:
_director_visuals.clear_edit_waypoint() _director_visuals.clear_edit_waypoint()
## Direct is internally an "edit-with-direct" state: same freeze/stand/gizmo ## Direct is internally an "edit-with-direct" state: the same frozen world,
## side effects, but with the spawner/grid controls hidden, the director hint ## but with the spawner/grid controls hidden, the director hint shown, and any
## shown, and any placement/selection/pending-target cleared. ## placement/selection/pending-target cleared.
func _enter_direct_mode() -> void: 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() _enter_edit_shared()
set_placement_mode("") set_placement_mode("")
_selection.clear_selection() _selection.clear_selection()
@@ -715,6 +762,7 @@ func _enter_play_mode() -> void:
_set_direct_controls_visible(false) _set_direct_controls_visible(false)
# Clear edge-trigger state so PLAY overlaps start from a clean slate. # Clear edge-trigger state so PLAY overlaps start from a clean slate.
_area_overlap.clear() _area_overlap.clear()
_area_previous_bounds.clear()
_collision_pairs.clear() _collision_pairs.clear()
for node: Node2D in _world_children_selectable(): for node: Node2D in _world_children_selectable():
if node is RigidBody2D: if node is RigidBody2D:
@@ -724,6 +772,7 @@ func _enter_play_mode() -> void:
if node is STICKMAN_RIG: if node is STICKMAN_RIG:
var rig := node as STICKMAN_RIG var rig := node as STICKMAN_RIG
rig.auto_recover = false rig.auto_recover = false
rig.begin_terrain_fall()
rig.start_queue() rig.start_queue()
rig_count += 1 rig_count += 1
_stage_dbg("PLAY rigs=%d" % rig_count) _stage_dbg("PLAY rigs=%d" % rig_count)
@@ -922,6 +971,8 @@ func _block_aabb(cell: Vector2i) -> Rect2:
func _begin_terrain_drag(world_pos: Vector2) -> void: func _begin_terrain_drag(world_pos: Vector2) -> void:
if _snap_enabled:
world_pos = _snap_to_grid(world_pos)
_free_ghost() _free_ghost()
_drag_painted.clear() _drag_painted.clear()
_terrain_dragging = true _terrain_dragging = true
@@ -1453,7 +1504,7 @@ func _check_autosave_recovery() -> void:
dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?" dialog.name = "AutosaveRecoveryDialog"; dialog.title = "Recover Sandbox Scene?"; dialog.dialog_text = "A previous Play session was not closed cleanly. Recover it?"
dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free()) dialog.confirmed.connect(func() -> void: _apply_scene_data(read["data"], AUTOSAVE_PATH, true); dialog.queue_free())
dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free()) dialog.canceled.connect(func() -> void: DirAccess.remove_absolute(AUTOSAVE_PATH); dialog.queue_free())
get_node(NodePath("UI")).add_child(dialog); dialog.popup_centered() get_node(NodePath("UI")).add_child(dialog); _apply_dialog_theme(dialog); dialog.popup_centered()
func _handle_cli_startup(args: Array) -> void: func _handle_cli_startup(args: Array) -> void:
@@ -1512,7 +1563,7 @@ func _refresh_status() -> void:
sel_text = "%d objects" % sel.size() sel_text = "%d objects" % sel.size()
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text] var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
if _pending_walk_target: if _pending_walk_target:
text += " | Click stage for walk target (Esc to cancel)" text += " | Click stage for walk target (Esc or RMB to cancel)"
if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty(): if _rule_step != RuleStep.IDLE and not _rule_hint.is_empty():
text += " | " + _rule_hint text += " | " + _rule_hint
if not _toast_text.is_empty(): if not _toast_text.is_empty():
@@ -1549,9 +1600,10 @@ func _sync_mode_buttons() -> void:
func _apply_mode_frame() -> void: func _apply_mode_frame() -> void:
if _mode_frame == null: if _mode_frame == null:
return return
_mode_frame.visible = current_mode == StageMode.DIRECT _update_stage_chrome_layout()
_mode_frame.visible = true
var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel") var sb: StyleBoxFlat = _mode_frame.get_theme_stylebox("panel")
sb.border_color = _accent_for_mode(current_mode) if sb != null: sb.border_color = _accent_for_mode(current_mode)
func _refresh_mode_badge() -> void: func _refresh_mode_badge() -> void:
@@ -1638,15 +1690,96 @@ func _apply_ui_font(control: Control) -> void:
control.add_theme_font_override("font", _ui_font) control.add_theme_font_override("font", _ui_font)
func _theme_dialog_size(key: String, fallback: Vector2i) -> Vector2i:
if _sandbox_theme == null:
return fallback
var raw: Variant = _sandbox_theme.value("chrome.dialogs." + key, [])
if raw is Array and (raw as Array).size() == 2:
return Vector2i(maxi(320, int(raw[0])), maxi(180, int(raw[1])))
return fallback
## Dialogs are separate native/embedded windows, so they do not reliably
## inherit the CanvasLayer's visual defaults. Apply only local overrides.
func _apply_dialog_theme(dialog: Window) -> void:
if dialog == null:
return
var size: int = _sandbox_theme.integer("chrome.dialogs.font_size", 22, 12, 48) if _sandbox_theme != null else 22
var message_size: int = _sandbox_theme.integer("chrome.dialogs.message_font_size", 26, 12, 64) if _sandbox_theme != null else 26
dialog.add_theme_font_size_override("font_size", size)
if _ui_font != null:
dialog.add_theme_font_override("font", _ui_font)
if dialog is AcceptDialog:
var message: Label = (dialog as AcceptDialog).get_label()
if message != null:
message.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
message.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
message.add_theme_font_size_override("font_size", message_size)
if _ui_font != null: message.add_theme_font_override("font", _ui_font)
var ok: Button = (dialog as AcceptDialog).get_ok_button()
if ok != null:
ok.add_theme_font_size_override("font_size", size)
if _ui_font != null: ok.add_theme_font_override("font", _ui_font)
if dialog is ConfirmationDialog:
var cancel: Button = (dialog as ConfirmationDialog).get_cancel_button()
if cancel != null:
cancel.add_theme_font_size_override("font_size", size)
if _ui_font != null: cancel.add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(dialog, size)
func _apply_dialog_font_to_children(node: Node, size: int) -> void:
for child: Node in node.get_children():
if child is Control:
(child as Control).add_theme_font_size_override("font_size", size)
if _ui_font != null: (child as Control).add_theme_font_override("font", _ui_font)
_apply_dialog_font_to_children(child, size)
func _update_cursor_coords() -> void: func _update_cursor_coords() -> void:
if _status_cursor_coords == null: if _status_cursor_coords == null:
return return
if _camera == null or not is_instance_valid(_camera): if _camera == null or not is_instance_valid(_camera):
return return
var viewport_pos: Vector2 = get_viewport().get_mouse_position()
if not _is_stage_frame_position(viewport_pos):
_status_cursor_coords.text = "X: — Y: —"
return
var world_pos := _camera.get_global_mouse_position() var world_pos := _camera.get_global_mouse_position()
_status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))] _status_cursor_coords.text = "X: %d Y: %d" % [int(round(world_pos.x)), int(round(world_pos.y))]
func _is_stage_frame_position(viewport_pos: Vector2) -> bool:
if _mode_frame == null:
return false
var viewport_size: Vector2 = get_window().size
if viewport_size.x <= 0.0 or viewport_size.y <= 0.0:
viewport_size = Vector2(1280.0, 720.0)
var left: float = _mode_frame.offset_left
var top: float = _mode_frame.offset_top
var right: float = maxf(left + 1.0, viewport_size.x)
var bottom: float = maxf(top + 1.0, viewport_size.y + _mode_frame.offset_bottom)
return is_point_in_stage_frame(viewport_pos, Rect2(Vector2(left, top), Vector2(right - left, bottom - top)))
static func is_point_in_stage_frame(viewport_pos: Vector2, frame_rect: Rect2) -> bool:
return frame_rect.abs().has_point(viewport_pos)
func _update_stage_chrome_layout() -> void:
if _mode_frame == null:
return
var palette_width: float = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
var left_inset: float = palette_width if current_mode == StageMode.EDIT else 0.0
_mode_frame.offset_left = left_inset
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_right = 0.0
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
if _mode_badge != null:
_mode_badge.offset_left = left_inset + 12.0
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
func _sync_awaiting_cursor() -> void: func _sync_awaiting_cursor() -> void:
if _is_awaiting_click() != _last_awaiting_click: if _is_awaiting_click() != _last_awaiting_click:
_apply_cursor() _apply_cursor()
@@ -1662,6 +1795,9 @@ func _build_ui() -> void:
_mode_frame.name = "ModeFrame" _mode_frame.name = "ModeFrame"
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE _mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) _mode_frame.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_mode_frame.offset_left = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
_mode_frame.offset_top = TOP_BAR_HEIGHT
_mode_frame.offset_bottom = -STATUS_BAR_HEIGHT
var frame_style := StyleBoxFlat.new() var frame_style := StyleBoxFlat.new()
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0) frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
frame_style.border_color = _accent_direct frame_style.border_color = _accent_direct
@@ -1671,22 +1807,43 @@ func _build_ui() -> void:
var top_bar := PanelContainer.new() var top_bar := PanelContainer.new()
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
top_bar.offset_bottom = 40.0 top_bar.offset_bottom = TOP_BAR_HEIGHT
ui.add_child(top_bar) ui.add_child(top_bar)
var hbox := HBoxContainer.new() var chrome := Control.new()
hbox.name = "Content" chrome.name = "Content"
hbox.add_theme_constant_override("separation", 8) chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
top_bar.add_child(hbox) chrome.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
top_bar.add_child(chrome)
var left_group := HBoxContainer.new()
left_group.name = "LeftControls"
left_group.mouse_filter = Control.MOUSE_FILTER_PASS
left_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
left_group.offset_left = 8.0
left_group.offset_top = 8.0
chrome.add_child(left_group)
var right_group := HBoxContainer.new()
right_group.name = "RightControls"
right_group.mouse_filter = Control.MOUSE_FILTER_PASS
right_group.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT)
right_group.offset_right = -8.0
right_group.offset_top = 8.0
right_group.add_theme_constant_override("separation", 8)
chrome.add_child(right_group)
_file_menu = MenuButton.new() _file_menu = MenuButton.new()
_file_menu.name = "FileMenu" _file_menu.name = "FileMenu"
_file_menu.text = "File" _file_menu.text = "File"
_file_menu.custom_minimum_size = Vector2(78.0, 48.0)
_file_menu.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.file_menu", 22, 12, 48) if _sandbox_theme != null else 22)
var file_popup := _file_menu.get_popup() var file_popup := _file_menu.get_popup()
var file_popup_font_size: int = _sandbox_theme.integer("fonts.sizes.file_menu_popup", 26, 12, 64) if _sandbox_theme != null else 26
file_popup.add_theme_font_size_override("font_size", file_popup_font_size)
file_popup.add_item("New", 0) file_popup.add_item("New", 0)
file_popup.add_item("Open…", 1) file_popup.add_item("Open…", 1)
var recent_popup := PopupMenu.new() var recent_popup := PopupMenu.new()
recent_popup.name = "OpenRecent" recent_popup.name = "OpenRecent"
recent_popup.add_theme_font_size_override("font_size", file_popup_font_size)
recent_popup.id_pressed.connect(_on_recent_scene_selected) recent_popup.id_pressed.connect(_on_recent_scene_selected)
file_popup.add_child(recent_popup) file_popup.add_child(recent_popup)
file_popup.add_submenu_item("Open Recent", "OpenRecent") file_popup.add_submenu_item("Open Recent", "OpenRecent")
@@ -1695,9 +1852,22 @@ func _build_ui() -> void:
file_popup.add_item("Rename Scene…", 4) file_popup.add_item("Rename Scene…", 4)
file_popup.id_pressed.connect(_on_file_menu_id_pressed) file_popup.id_pressed.connect(_on_file_menu_id_pressed)
file_popup.about_to_popup.connect(_update_recent_menu) file_popup.about_to_popup.connect(_update_recent_menu)
hbox.add_child(_file_menu) left_group.add_child(_file_menu)
# 3-segment mode switcher. # This centered group is independent of left/right controls, preserving its
# window-center position at every supported stage width.
var mode_center := CenterContainer.new()
mode_center.name = "ModeCenter"
# This full-width layout wrapper must not shield File or utility controls.
mode_center.mouse_filter = Control.MOUSE_FILTER_IGNORE
mode_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
mode_center.offset_bottom = TOP_BAR_HEIGHT
chrome.add_child(mode_center)
var mode_switcher := HBoxContainer.new()
mode_switcher.name = "ModeSwitcher"
mode_switcher.mouse_filter = Control.MOUSE_FILTER_PASS
mode_switcher.add_theme_constant_override("separation", 4)
mode_center.add_child(mode_switcher)
var mode_defs: Array = [ var mode_defs: Array = [
[StageMode.EDIT, "✏️ Edit"], [StageMode.EDIT, "✏️ Edit"],
[StageMode.DIRECT, "🎬 Direct"], [StageMode.DIRECT, "🎬 Direct"],
@@ -1708,39 +1878,32 @@ func _build_ui() -> void:
var btn := Button.new() var btn := Button.new()
btn.text = String(def[1]) btn.text = String(def[1])
btn.toggle_mode = true btn.toggle_mode = true
btn.custom_minimum_size = Vector2(_sandbox_theme.number("chrome.mode_switcher.segment_min_width", 124.0, 90.0, 220.0) if _sandbox_theme != null else 124.0, 48.0)
btn.add_theme_font_size_override("font_size", _sandbox_theme.integer("fonts.sizes.mode_switcher", 24, 12, 48) if _sandbox_theme != null else 24)
btn.toggled.connect(_on_mode_segment_toggled.bind(mid)) btn.toggled.connect(_on_mode_segment_toggled.bind(mid))
hbox.add_child(btn) mode_switcher.add_child(btn)
_mode_buttons[int(mid)] = btn _mode_buttons[int(mid)] = btn
for id: String in _spawner.get_spawnable_ids():
var btn := Button.new()
btn.text = _spawner.get_label(id)
btn.toggle_mode = true
btn.toggled.connect(_on_palette_toggled.bind(id))
hbox.add_child(btn)
_palette_buttons[id] = btn
_direct_hint_label = Label.new() _direct_hint_label = Label.new()
_direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules" _direct_hint_label.text = "Click a stickman to direct, or ⚡ When… for rules"
_direct_hint_label.visible = false _direct_hint_label.visible = false
_apply_ui_font(_direct_hint_label) _apply_ui_font(_direct_hint_label)
hbox.add_child(_direct_hint_label) right_group.add_child(_direct_hint_label)
_grid_check = CheckBox.new() _grid_check = CheckBox.new()
_grid_check.text = "Grid" _grid_check.text = "Grid"
_grid_check.button_pressed = _show_grid _grid_check.button_pressed = _show_grid
_grid_check.toggled.connect(_on_grid_toggled) _grid_check.toggled.connect(_on_grid_toggled)
hbox.add_child(_grid_check) right_group.add_child(_grid_check)
_snap_check = CheckBox.new() _snap_check = CheckBox.new()
_snap_check.text = "Snap" _snap_check.text = "Snap"
_snap_check.button_pressed = _snap_enabled _snap_check.button_pressed = _snap_enabled
_snap_check.toggled.connect(_on_snap_toggled) _snap_check.toggled.connect(_on_snap_toggled)
hbox.add_child(_snap_check) right_group.add_child(_snap_check)
_grid_size_label = Label.new() _grid_size_label = Label.new()
_grid_size_label.text = "Size" _grid_size_label.text = "Size"
hbox.add_child(_grid_size_label) right_group.add_child(_grid_size_label)
_grid_size_spin = SpinBox.new() _grid_size_spin = SpinBox.new()
_grid_size_spin.min_value = MIN_GRID_SIZE _grid_size_spin.min_value = MIN_GRID_SIZE
@@ -1749,12 +1912,32 @@ func _build_ui() -> void:
_grid_size_spin.value = _grid_size _grid_size_spin.value = _grid_size
_grid_size_spin.rounded = true _grid_size_spin.rounded = true
_grid_size_spin.value_changed.connect(_on_grid_size_changed) _grid_size_spin.value_changed.connect(_on_grid_size_changed)
hbox.add_child(_grid_size_spin) right_group.add_child(_grid_size_spin)
# Graphical asset palette lives below chrome so stage tools keep their row.
_palette_dock = PanelContainer.new()
_palette_dock.name = "PaletteDock"
_palette_dock.set_anchors_and_offsets_preset(Control.PRESET_LEFT_WIDE)
_palette_dock.offset_top = TOP_BAR_HEIGHT
_palette_dock.offset_bottom = -STATUS_BAR_HEIGHT
_palette_dock.offset_right = _sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0
if _sandbox_theme != null: _palette_dock.add_theme_stylebox_override("panel", _sandbox_theme.stylebox("palette"))
var scroll := ScrollContainer.new(); scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED; _palette_dock.add_child(scroll)
var cards := VBoxContainer.new(); cards.name = "PaletteCards"; cards.size_flags_horizontal = Control.SIZE_EXPAND_FILL; scroll.add_child(cards)
for entry: Dictionary in _spawner.get_palette_entries():
var card: Variant = ASSET_CARD.new()
card.configure(entry, null, _sandbox_theme)
card.toggle_mode = true
card.activated.connect(_on_palette_card_activated)
card.drag_started.connect(_begin_palette_drag)
cards.add_child(card)
_palette_buttons[String(entry["placement_id"])] = card
ui.add_child(_palette_dock)
# Bottom status bar (left = status, right = cursor world coords). # Bottom status bar (left = status, right = cursor world coords).
var bottom_bar := PanelContainer.new() var bottom_bar := PanelContainer.new()
bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE) bottom_bar.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_WIDE)
bottom_bar.offset_top = -28.0 bottom_bar.offset_top = -STATUS_BAR_HEIGHT
ui.add_child(bottom_bar) ui.add_child(bottom_bar)
var status_hbox := HBoxContainer.new() var status_hbox := HBoxContainer.new()
@@ -1775,8 +1958,8 @@ func _build_ui() -> void:
_mode_badge.name = "ModeBadge" _mode_badge.name = "ModeBadge"
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE _mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) _mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
_mode_badge.offset_top = 48.0 _mode_badge.offset_top = TOP_BAR_HEIGHT + 12.0
_mode_badge.offset_left = 8.0 _mode_badge.offset_left = (_sandbox_theme.number("palette.dock_width", 116.0, 80.0, 260.0) if _sandbox_theme != null else 116.0) + 12.0
var badge_style := StyleBoxFlat.new() var badge_style := StyleBoxFlat.new()
badge_style.bg_color = _accent_edit badge_style.bg_color = _accent_edit
badge_style.set_corner_radius_all(12) badge_style.set_corner_radius_all(12)
@@ -1867,6 +2050,7 @@ func _build_ui() -> void:
speak_box.add_child(_speak_stop_current_check) 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)
_apply_dialog_theme(_speak_dialog)
_wait_dialog = AcceptDialog.new() _wait_dialog = AcceptDialog.new()
_wait_dialog.title = "Wait" _wait_dialog.title = "Wait"
@@ -1878,6 +2062,7 @@ func _build_ui() -> void:
_wait_spin.value = 1.0 _wait_spin.value = 1.0
_wait_dialog.add_child(_wait_spin) _wait_dialog.add_child(_wait_spin)
ui.add_child(_wait_dialog) ui.add_child(_wait_dialog)
_apply_dialog_theme(_wait_dialog)
_apply_popup_theme(_action_popup) _apply_popup_theme(_action_popup)
_apply_popup_theme(_trigger_popup) _apply_popup_theme(_trigger_popup)
@@ -1901,6 +2086,7 @@ func _build_ui() -> void:
ui.add_child(_selector) ui.add_child(_selector)
# apply_font AFTER add_child so the selector's @onready nodes resolve first. # apply_font AFTER add_child so the selector's @onready nodes resolve first.
_selector.apply_font(_ui_font, _emoji_font) _selector.apply_font(_ui_font, _emoji_font)
_selector.apply_theme(_sandbox_theme)
_browse_dialog = FileDialog.new() _browse_dialog = FileDialog.new()
_browse_dialog.title = "Open .stk" _browse_dialog.title = "Open .stk"
@@ -1930,7 +2116,7 @@ func _build_ui() -> void:
_rename_scene_dialog.name = "RenameSceneDialog" _rename_scene_dialog.name = "RenameSceneDialog"
_rename_scene_dialog.title = "Rename Scene" _rename_scene_dialog.title = "Rename Scene"
_rename_scene_dialog.cancel_button_text = "Cancel" _rename_scene_dialog.cancel_button_text = "Cancel"
_rename_scene_dialog.min_size = Vector2i(540, 0) _rename_scene_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_scene_name_edit = LineEdit.new() _scene_name_edit = LineEdit.new()
_scene_name_edit.name = "SceneNameEdit" _scene_name_edit.name = "SceneNameEdit"
_scene_name_edit.placeholder_text = "Scene name" _scene_name_edit.placeholder_text = "Scene name"
@@ -1940,24 +2126,29 @@ func _build_ui() -> void:
var scene_title := _scene_name_edit.text.strip_edges() var scene_title := _scene_name_edit.text.strip_edges()
if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty()) if not scene_title.is_empty() and scene_title != _scene_name: _scene_name = scene_title; _mark_scene_dirty())
ui.add_child(_rename_scene_dialog) ui.add_child(_rename_scene_dialog)
_apply_dialog_theme(_rename_scene_dialog)
_missing_assets_dialog = AcceptDialog.new() _missing_assets_dialog = AcceptDialog.new()
_missing_assets_dialog.name = "MissingAssetsDialog" _missing_assets_dialog.name = "MissingAssetsDialog"
_missing_assets_dialog.title = "Missing Scene Assets" _missing_assets_dialog.title = "Missing Scene Assets"
_missing_assets_dialog.min_size = Vector2i(560, 0) _missing_assets_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
ui.add_child(_missing_assets_dialog) ui.add_child(_missing_assets_dialog)
_apply_dialog_theme(_missing_assets_dialog)
_cli_error_dialog = AcceptDialog.new() _cli_error_dialog = AcceptDialog.new()
_cli_error_dialog.name = "CliErrorDialog" _cli_error_dialog.name = "CliErrorDialog"
_cli_error_dialog.title = "Could Not Load Scene" _cli_error_dialog.title = "Could Not Load Scene"
_cli_error_dialog.min_size = Vector2i(560, 0) _cli_error_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
ui.add_child(_cli_error_dialog) ui.add_child(_cli_error_dialog)
_apply_dialog_theme(_cli_error_dialog)
_unsaved_changes_dialog = ConfirmationDialog.new() _unsaved_changes_dialog = ConfirmationDialog.new()
_unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Save changes before continuing?"; _unsaved_changes_dialog.ok_button_text = "Save" _unsaved_changes_dialog.name = "UnsavedChangesDialog"; _unsaved_changes_dialog.title = "⚠️ Unsaved Changes"; _unsaved_changes_dialog.dialog_text = "Would you like to save your scene before continuing?"; _unsaved_changes_dialog.ok_button_text = "💾 Save"
_unsaved_changes_dialog.add_button("Discard", false, "discard") _unsaved_changes_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_unsaved_changes_dialog.add_button("🗑️ Discard", false, "discard")
_unsaved_changes_dialog.confirmed.connect(_on_unsaved_save) _unsaved_changes_dialog.confirmed.connect(_on_unsaved_save)
_unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard()) _unsaved_changes_dialog.custom_action.connect(func(action: StringName) -> void: if action == &"discard": _on_unsaved_discard())
_unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE) _unsaved_changes_dialog.canceled.connect(func() -> void: _pending_file_action = PendingFileAction.NONE)
ui.add_child(_unsaved_changes_dialog) ui.add_child(_unsaved_changes_dialog)
_apply_dialog_theme(_unsaved_changes_dialog)
# ---- Phase 3c editor tools ---- # ---- Phase 3c editor tools ----
@@ -1975,8 +2166,10 @@ func _build_ui() -> void:
_apply_popup_theme(_waypoint_context) _apply_popup_theme(_waypoint_context)
_confirm_dialog = ConfirmationDialog.new() _confirm_dialog = ConfirmationDialog.new()
_confirm_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
_confirm_dialog.confirmed.connect(_on_confirm_confirmed) _confirm_dialog.confirmed.connect(_on_confirm_confirmed)
ui.add_child(_confirm_dialog) ui.add_child(_confirm_dialog)
_apply_dialog_theme(_confirm_dialog)
_queue_panel = QUEUE_PANEL.instantiate() as QueuePanel _queue_panel = QUEUE_PANEL.instantiate() as QueuePanel
_queue_panel.edit_requested.connect(_on_queue_panel_edit_requested) _queue_panel.edit_requested.connect(_on_queue_panel_edit_requested)
@@ -1985,6 +2178,7 @@ func _build_ui() -> void:
_queue_panel.clear_requested.connect(_on_queue_panel_clear_requested) _queue_panel.clear_requested.connect(_on_queue_panel_clear_requested)
ui.add_child(_queue_panel) ui.add_child(_queue_panel)
_queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes) _queue_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
_queue_panel.min_size = _theme_dialog_size("queue_size", Vector2i(680, 520))
_rule_panel = RULE_PANEL.instantiate() as RulePanel _rule_panel = RULE_PANEL.instantiate() as RulePanel
_rule_panel.edit_requested.connect(_on_rule_panel_edit_requested) _rule_panel.edit_requested.connect(_on_rule_panel_edit_requested)
@@ -1994,6 +2188,7 @@ func _build_ui() -> void:
_rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested) _rule_panel.reorder_requested.connect(_on_rule_panel_reorder_requested)
ui.add_child(_rule_panel) ui.add_child(_rule_panel)
_rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes) _rule_panel.apply_font(_ui_font, _emoji_font, _font_sizes)
_rule_panel.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
_action_editor = ACTION_EDITOR.instantiate() as ActionEditor _action_editor = ACTION_EDITOR.instantiate() as ActionEditor
_action_editor.committed.connect(_on_action_editor_committed) _action_editor.committed.connect(_on_action_editor_committed)
@@ -2001,6 +2196,7 @@ func _build_ui() -> void:
_action_editor.target_requested.connect(_on_action_editor_target_requested) _action_editor.target_requested.connect(_on_action_editor_target_requested)
ui.add_child(_action_editor) ui.add_child(_action_editor)
_action_editor.apply_font(_ui_font, _emoji_font, _font_sizes) _action_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_action_editor.min_size = _theme_dialog_size("action_size", Vector2i(560, 390))
_rule_editor = RULE_EDITOR.instantiate() as RuleEditor _rule_editor = RULE_EDITOR.instantiate() as RuleEditor
_rule_editor.committed.connect(_on_rule_editor_committed) _rule_editor.committed.connect(_on_rule_editor_committed)
@@ -2010,6 +2206,7 @@ func _build_ui() -> void:
_rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested) _rule_editor.action_edit_requested.connect(_on_rule_editor_action_edit_requested)
ui.add_child(_rule_editor) ui.add_child(_rule_editor)
_rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes) _rule_editor.apply_font(_ui_font, _emoji_font, _font_sizes)
_rule_editor.min_size = _theme_dialog_size("rule_size", Vector2i(720, 560))
func _build_gizmo_layer() -> void: func _build_gizmo_layer() -> void:
@@ -2061,14 +2258,15 @@ func _build_navigation() -> void:
_rebake_navigation() _rebake_navigation()
## Rebuild the navigation mesh from every TerrainBlock child of World: each ## Rebuild navigation from exposed top edges only. Each qualifying terrain edge
## block's world-space polygon is convex-decomposed and fan-triangulated into ## becomes a shallow ribbon just above its collision surface, so agents route
## one manual NavigationPolygon. ## over floors/ramps rather than through solid terrain polygons.
func _rebake_navigation() -> void: func _rebake_navigation() -> void:
if _nav_region == null or not is_instance_valid(_nav_region): if _nav_region == null or not is_instance_valid(_nav_region):
return return
var verts := PackedVector2Array() var verts := PackedVector2Array()
var triangles: Array[PackedInt32Array] = [] var triangles: Array[PackedInt32Array] = []
var surfaces: Array[Dictionary] = []
for child: Node in _world.get_children(): for child: Node in _world.get_children():
var block := child as TerrainBlock var block := child as TerrainBlock
if block == null: if block == null:
@@ -2076,14 +2274,28 @@ func _rebake_navigation() -> void:
var pts := PackedVector2Array() var pts := PackedVector2Array()
for p: Vector2 in block.polygon_points: for p: Vector2 in block.polygon_points:
pts.append(block.transform * p) pts.append(block.transform * p)
var pieces := Geometry2D.decompose_polygon_in_convex(pts) for i: int in pts.size():
if pieces.is_empty(): var a := pts[i]
pieces = [pts] var b := pts[(i + 1) % pts.size()]
for piece: PackedVector2Array in pieces: var edge := b - a
var base: int = verts.size() if edge.length_squared() <= 0.001:
verts.append_array(piece) continue
for i: int in range(1, piece.size() - 1): # TerrainUtils stores clockwise polygons; this right normal is
triangles.append(PackedInt32Array([base, base + i, base + i + 1])) # outward, so only its upward-facing edges are walkable.
var outward := Vector2(edge.y, -edge.x).normalized()
if outward.y > -0.15:
continue
surfaces.append({"a": a, "b": b, "normal": outward})
for surface: Dictionary in surfaces:
_append_navigation_ribbon(verts, triangles, surface["a"], surface["b"], surface["normal"])
# Join only short, climbable gaps between exposed surface ends. This connects
# authored stair treads without making cliffs or terrain interiors walkable.
for i: int in surfaces.size():
for j: int in range(i + 1, surfaces.size()):
for a: Vector2 in [surfaces[i]["a"], surfaces[i]["b"]]:
for b: Vector2 in [surfaces[j]["a"], surfaces[j]["b"]]:
if absf(a.x - b.x) <= 64.0 and absf(a.y - b.y) <= 96.0:
_append_navigation_ribbon(verts, triangles, a, b, Vector2.UP)
var poly := NavigationPolygon.new() var poly := NavigationPolygon.new()
poly.set_vertices(verts) poly.set_vertices(verts)
for tri: PackedInt32Array in triangles: for tri: PackedInt32Array in triangles:
@@ -2092,6 +2304,24 @@ func _rebake_navigation() -> void:
if DEBUG_STAGE: if DEBUG_STAGE:
print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()]) print("[nav] baked verts=%d polys=%d" % [verts.size(), triangles.size()])
func _append_navigation_ribbon(
verts: PackedVector2Array,
triangles: Array[PackedInt32Array],
a: Vector2,
b: Vector2,
normal: Vector2
) -> void:
if a.distance_squared_to(b) <= 0.001:
return
var base: int = verts.size()
verts.append_array(PackedVector2Array([
a + normal * 4.0, b + normal * 4.0,
b - normal * 12.0, a - normal * 12.0,
]))
triangles.append(PackedInt32Array([base, base + 1, base + 2]))
triangles.append(PackedInt32Array([base, base + 2, base + 3]))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Signal handlers # Signal handlers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2117,6 +2347,57 @@ func _on_palette_toggled(pressed: bool, id: String) -> void:
set_placement_mode("") set_placement_mode("")
func _on_palette_card_activated(payload: Dictionary) -> void:
var id: String = String(payload.get("placement_id", ""))
if id.is_empty(): return
if bool(payload.get("opens_selector", false)):
_open_selector(id)
else:
set_placement_mode(id)
func _begin_palette_drag(payload: Dictionary) -> void:
if current_mode != StageMode.EDIT or _selector_open: return
var id: String = String(payload.get("placement_id", ""))
if id.is_empty(): return
_palette_drag_payload = payload.duplicate(true)
_palette_drag_previous_id = _placement_id
_placement_id = id
_spawn_ghost()
func _update_palette_drag(viewport_pos: Vector2) -> void:
if _ghost != null and is_instance_valid(_ghost):
_ghost.global_position = _viewport_to_world(viewport_pos) + _spawner.get_spawn_offset(_placement_id)
func _finish_palette_drag(viewport_pos: Vector2) -> void:
var id: String = _placement_id
if _is_stage_drop_position(viewport_pos):
_place_at(_viewport_to_world(viewport_pos))
_palette_drag_payload.clear()
_placement_id = _palette_drag_previous_id
_palette_drag_previous_id = ""
if _placement_id.is_empty(): _free_ghost()
else: _spawn_ghost()
for pid: String in _palette_buttons: (_palette_buttons[pid] as Button).set_pressed_no_signal(pid == _placement_id)
func _cancel_palette_drag() -> void:
_palette_drag_payload.clear()
_placement_id = _palette_drag_previous_id
_palette_drag_previous_id = ""
if _placement_id.is_empty(): _free_ghost()
else: _spawn_ghost()
func _is_stage_drop_position(viewport_pos: Vector2) -> bool:
var rect: Rect2 = get_viewport().get_visible_rect()
if not rect.has_point(viewport_pos): return false
var hovered: Control = get_viewport().gui_get_hovered_control()
return hovered == null or hovered.mouse_filter == Control.MOUSE_FILTER_IGNORE
func _open_selector(kind: String) -> void: func _open_selector(kind: String) -> void:
for pid: String in _palette_buttons: for pid: String in _palette_buttons:
(_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind) (_palette_buttons[pid] as Button).set_pressed_no_signal(pid == kind)
@@ -2151,6 +2432,7 @@ func _on_asset_selected(entry: Dictionary) -> void:
func _close_selector() -> void: func _close_selector() -> void:
_selector_open = false _selector_open = false
_selector_kind = "" _selector_kind = ""
_thumbnail_generation += 1
_thumbnail_queue.clear() _thumbnail_queue.clear()
if _selector_dim != null: if _selector_dim != null:
_selector_dim.visible = false _selector_dim.visible = false
@@ -2188,26 +2470,28 @@ func _on_refresh_requested() -> void:
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void: func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
_thumbnail_queue.clear() _thumbnail_queue.clear()
_thumbnail_generation += 1
var generation: int = _thumbnail_generation
for entry: Dictionary in entries: for entry: Dictionary in entries:
if kind == "stickman": if kind == "stickman":
var path := String(entry.get("path", "")) var path := String(entry.get("path", ""))
var key := _thumbnail_cache.stickman_key(path) var key := _thumbnail_cache.stickman_key(path)
var png := _thumbnail_cache.stickman_png(key) var png := _thumbnail_cache.stickman_png(key)
if FileAccess.file_exists(png): var tex := _thumbnail_cache.get_texture(png)
var tex := _thumbnail_cache.load_png(png) if tex != null:
if tex != null and _selector != null: if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex) _selector.set_thumbnail(entry, tex)
else: else:
_thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key }) _thumbnail_queue.append({ "kind": kind, "entry": entry, "key": key, "cache_path": png, "generation": generation })
else: else:
var id := String(entry.get("id", "")) var id := String(entry.get("id", ""))
var png := _thumbnail_cache.prop_png(id) var png := _thumbnail_cache.prop_png(id)
if FileAccess.file_exists(png): var tex := _thumbnail_cache.get_texture(png)
var tex := _thumbnail_cache.load_png(png) if tex != null:
if tex != null and _selector != null: if tex != null and _selector != null:
_selector.set_thumbnail(entry, tex) _selector.set_thumbnail(entry, tex)
else: else:
_thumbnail_queue.append({ "kind": kind, "entry": entry }) _thumbnail_queue.append({ "kind": kind, "entry": entry, "cache_path": png, "generation": generation })
func _drain_thumbnail_queue() -> void: func _drain_thumbnail_queue() -> void:
@@ -2223,15 +2507,15 @@ func _drain_thumbnail_queue() -> void:
var data: Dictionary = entry["data"] var data: Dictionary = entry["data"]
tex = await _stickman_thumb.render(data) tex = await _stickman_thumb.render(data)
if tex != null: if tex != null:
_thumbnail_cache.save_png(tex, _thumbnail_cache.stickman_png(key)) _thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.stickman_png(key))), tex)
else: else:
var payload: Dictionary = entry["payload"] var payload: Dictionary = entry["payload"]
var preset: int = int(entry["material_preset"]) var preset: int = int(entry["material_preset"])
tex = await _prop_thumb.render(payload, preset) tex = await _prop_thumb.render(payload, preset)
if tex != null: if tex != null:
_thumbnail_cache.save_png(tex, _thumbnail_cache.prop_png(String(entry["id"]))) _thumbnail_cache.store_texture(String(job.get("cache_path", _thumbnail_cache.prop_png(String(entry["id"])))), tex)
_thumbnail_busy = false _thumbnail_busy = false
if _selector != null and _selector_open and tex != null: if _selector != null and _selector_open and int(job.get("generation", -1)) == _thumbnail_generation and tex != null:
_selector.set_thumbnail(entry, tex) _selector.set_thumbnail(entry, tex)
@@ -2527,17 +2811,17 @@ func _action_hint_text() -> String:
func _action_origin() -> Vector2: func _action_origin() -> Vector2:
if _pending_walk_target: if _pending_walk_target:
if _context_rig != null and is_instance_valid(_context_rig): if _context_rig != null and is_instance_valid(_context_rig):
return _context_rig.global_position - STICKMAN_RIG.FOOT_OFFSET return _context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
return Vector2.ZERO return Vector2.ZERO
if _capture_kind != CaptureKind.NONE: if _capture_kind != CaptureKind.NONE:
if _walk_edit_rig != null and is_instance_valid(_walk_edit_rig): if _walk_edit_rig != null and is_instance_valid(_walk_edit_rig):
return _walk_edit_rig.global_position - STICKMAN_RIG.FOOT_OFFSET return _walk_edit_rig.global_position - StickmanLocomotion.FOOT_OFFSET
if _panel_rig != null and is_instance_valid(_panel_rig): if _panel_rig != null and is_instance_valid(_panel_rig):
return _panel_rig.global_position - STICKMAN_RIG.FOOT_OFFSET return _panel_rig.global_position - StickmanLocomotion.FOOT_OFFSET
return Vector2.ZERO return Vector2.ZERO
if _rule_step != RuleStep.IDLE: if _rule_step != RuleStep.IDLE:
if _rule_context_rig != null and is_instance_valid(_rule_context_rig): if _rule_context_rig != null and is_instance_valid(_rule_context_rig):
return _rule_context_rig.global_position - STICKMAN_RIG.FOOT_OFFSET return _rule_context_rig.global_position - StickmanLocomotion.FOOT_OFFSET
var trigger: Dictionary = _rule_builder.get("trigger", {}) var trigger: Dictionary = _rule_builder.get("trigger", {})
var src := instance_from_id(int(trigger.get("source", -1))) var src := instance_from_id(int(trigger.get("source", -1)))
if src is Node2D and is_instance_valid(src): if src is Node2D and is_instance_valid(src):
@@ -2689,11 +2973,11 @@ func _on_trigger_popup_id_pressed(id: int) -> void:
_rule_step = RuleStep.TRIGGER_TARGET _rule_step = RuleStep.TRIGGER_TARGET
match id: match id:
TRIG_ARRIVED: TRIG_ARRIVED:
_rule_hint = "Click the waypoint to trigger on (Esc to cancel)" _rule_hint = "Click the waypoint to trigger on (Esc or RMB to cancel)"
TRIG_ENTERED_AREA: TRIG_ENTERED_AREA:
_rule_hint = "Click the trigger area to watch (Esc to cancel)" _rule_hint = "Click the trigger area to watch (Esc or RMB to cancel)"
TRIG_COLLIDED: TRIG_COLLIDED:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)" _rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
_refresh_status() _refresh_status()
else: else:
_rule_step = RuleStep.SELECT_ACTION _rule_step = RuleStep.SELECT_ACTION
@@ -2709,7 +2993,7 @@ func _on_rule_action_popup_id_pressed(id: int) -> void:
actions.append(action) actions.append(action)
_rule_builder["actions"] = actions _rule_builder["actions"] = actions
_rule_step = RuleStep.ACTION_TARGET _rule_step = RuleStep.ACTION_TARGET
_rule_hint = "Click the stickman who will %s (Esc to cancel)" % _action_verb(id) _rule_hint = "Click the stickman who will %s (Esc or RMB to cancel)" % _action_verb(id)
_refresh_status() _refresh_status()
@@ -2792,7 +3076,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION _rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup() _open_rule_action_popup()
else: else:
_rule_hint = "Click a waypoint dot (Esc to cancel)" _rule_hint = "Click a waypoint dot (Esc or RMB to cancel)"
_refresh_status() _refresh_status()
"entered_area": "entered_area":
var hit := _selection.hit_test(world_pos) var hit := _selection.hit_test(world_pos)
@@ -2802,7 +3086,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION _rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup() _open_rule_action_popup()
else: else:
_rule_hint = "Click a trigger area (Esc to cancel)" _rule_hint = "Click a trigger area (Esc or RMB to cancel)"
_refresh_status() _refresh_status()
"collided": "collided":
var hit2 := _selection.hit_test(world_pos) var hit2 := _selection.hit_test(world_pos)
@@ -2812,7 +3096,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
_rule_step = RuleStep.SELECT_ACTION _rule_step = RuleStep.SELECT_ACTION
_open_rule_action_popup() _open_rule_action_popup()
else: else:
_rule_hint = "Click the prop that will be collided with (Esc to cancel)" _rule_hint = "Click the prop that will be collided with (Esc or RMB to cancel)"
_refresh_status() _refresh_status()
@@ -2831,7 +3115,7 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
match String(action.get("type", "")): match String(action.get("type", "")):
"walk_to": "walk_to":
_rule_step = RuleStep.ACTION_POSITION _rule_step = RuleStep.ACTION_POSITION
_rule_hint = "Click where %s should walk (Esc to cancel)" % String(hit.name) _rule_hint = "Click where %s should walk (Esc or RMB to cancel)" % String(hit.name)
_refresh_status() _refresh_status()
"speak": "speak":
_rule_step = RuleStep.PARAMS _rule_step = RuleStep.PARAMS
@@ -3155,12 +3439,31 @@ func _update_area_entry() -> void:
movables.append(rig) movables.append(rig)
elif node is PropBlock and not (node as PropBlock).freeze: elif node is PropBlock and not (node as PropBlock).freeze:
movables.append(node) movables.append(node)
# Build one swept bound per movable before iterating areas. The stage parent
# polls before child rigs apply this frame's free-fall displacement, so the
# previous->current union prevents fast airborne motion from tunneling
# through a trigger between two observed poses.
var swept_bounds: Dictionary = {}
var live_ids: Dictionary = {}
for node: Node2D in movables:
var node_id := node.get_instance_id()
var current_bounds := STAGE_SELECTION.get_world_aabb(node).abs()
var swept := current_bounds
if _area_previous_bounds.has(node_id):
swept = swept.merge((_area_previous_bounds[node_id] as Rect2).abs())
swept_bounds[node_id] = swept
_area_previous_bounds[node_id] = current_bounds
live_ids[node_id] = true
for old_id: int in _area_previous_bounds.keys().duplicate():
if not live_ids.has(old_id):
_area_previous_bounds.erase(old_id)
for area: TriggerArea in areas: for area: TriggerArea in areas:
var area_rect := (area.global_transform * area.get_area_rect()).abs() var area_rect := (area.global_transform * area.get_area_rect()).abs()
for node: Node2D in movables: for node: Node2D in movables:
var point := _movable_point(node) var point := _movable_point(node)
var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()] var key := "%d:%d" % [area.get_instance_id(), node.get_instance_id()]
var inside: bool = _movable_overlaps_area(node, area_rect) var inside: bool = (swept_bounds.get(
node.get_instance_id(), Rect2()) as Rect2).intersects(area_rect, true)
var was: bool = bool(_area_overlap.get(key, false)) var was: bool = bool(_area_overlap.get(key, false))
if inside and not was: if inside and not was:
_area_overlap[key] = true _area_overlap[key] = true
@@ -3172,7 +3475,7 @@ func _update_area_entry() -> void:
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position. ## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
func _movable_point(node: Node2D) -> Vector2: func _movable_point(node: Node2D) -> Vector2:
if node is STICKMAN_RIG: if node is STICKMAN_RIG:
return node.global_position - StickmanRig.FOOT_OFFSET return node.global_position - StickmanLocomotion.FOOT_OFFSET
return node.global_position return node.global_position
@@ -3341,9 +3644,9 @@ func _begin_walk_edit(rig: StickmanRig, index: int, from_panel: bool) -> void:
_walk_edit_index = index _walk_edit_index = index
_walk_edit_from_panel = from_panel _walk_edit_from_panel = from_panel
var queue := rig.get_queue() var queue := rig.get_queue()
var pos: Vector2 = queue[index].get("target", rig.global_position - STICKMAN_RIG.FOOT_OFFSET) var pos: Vector2 = queue[index].get("target", rig.global_position - StickmanLocomotion.FOOT_OFFSET)
_director_visuals.set_edit_waypoint(pos) _director_visuals.set_edit_waypoint(pos)
_begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel) _begin_capture(CaptureKind.POSITION, "🚩 Click the new waypoint position (Esc or RMB to cancel)", _on_walk_edit_captured, _cb_walk_edit_cancel)
func _on_walk_edit_captured(value: Vector2) -> void: func _on_walk_edit_captured(value: Vector2) -> void:
@@ -3610,11 +3913,11 @@ func _on_rule_editor_trigger_target_requested(type: String) -> void:
_rule_editor.hide() _rule_editor.hide()
match TRIGGER_REGISTRY.target_type(type): match TRIGGER_REGISTRY.target_type(type):
"waypoint": "waypoint":
_begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel) _begin_capture(CaptureKind.WAYPOINT, "📍 Click the waypoint (Esc or RMB to cancel)", _cb_rule_trigger_waypoint, _cb_rule_trigger_cancel)
"area": "area":
_begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel) _begin_capture(CaptureKind.AREA, "🎯 Click the trigger area (Esc or RMB to cancel)", _cb_rule_trigger_area, _cb_rule_trigger_cancel)
"prop": "prop":
_begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel) _begin_capture(CaptureKind.PROP, "💥 Click the prop (Esc or RMB to cancel)", _cb_rule_trigger_prop, _cb_rule_trigger_cancel)
func _on_rule_editor_action_add_requested() -> void: func _on_rule_editor_action_add_requested() -> void:
@@ -3706,7 +4009,7 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
_show_toast("Action inserted") _show_toast("Action inserted")
"rule_add": "rule_add":
_pending_rule_action_flat = flat _pending_rule_action_flat = flat
_begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc to cancel)", _cb_rule_actor, _cb_rule_actor_cancel) _begin_capture(CaptureKind.STICKMAN, "🎯 Click the stickman who will act (Esc or RMB to cancel)", _cb_rule_actor, _cb_rule_actor_cancel)
return return
"rule_edit": "rule_edit":
if _rule_editor != null: if _rule_editor != null:
@@ -3728,7 +4031,7 @@ func _on_action_editor_cancelled() -> void:
func _on_action_editor_target_requested() -> void: func _on_action_editor_target_requested() -> void:
_action_editor.hide() _action_editor.hide()
_begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel) _begin_capture(CaptureKind.POSITION, "🚩 Click where to walk (Esc or RMB to cancel)", _cb_editor_walk_target, _cb_editor_walk_target_cancel)
func _cb_editor_walk_target(value: Vector2) -> void: func _cb_editor_walk_target(value: Vector2) -> void:
@@ -3808,6 +4111,8 @@ func _apply_grid_settings() -> void:
func _set_edit_controls_visible(visible: bool) -> void: func _set_edit_controls_visible(visible: bool) -> void:
for btn: Button in _palette_buttons.values(): for btn: Button in _palette_buttons.values():
btn.visible = visible btn.visible = visible
if _palette_dock != null:
_palette_dock.visible = visible
if _grid_check != null: if _grid_check != null:
_grid_check.visible = visible _grid_check.visible = visible
if _snap_check != null: if _snap_check != null:
@@ -3842,6 +4147,8 @@ func _world_children_selectable() -> Array[Node2D]:
## Loads the theme JSON. `source_path` is overridable for tests (missing / ## Loads the theme JSON. `source_path` is overridable for tests (missing /
## malformed fallback); defaults to the committed res:// theme. ## malformed fallback); defaults to the committed res:// theme.
func _load_theme(source_path: String = THEME_PATH) -> void: func _load_theme(source_path: String = THEME_PATH) -> void:
_sandbox_theme = SANDBOX_THEME.new()
_sandbox_theme.load_from(source_path)
_theme = {} _theme = {}
_build_font_sizes() _build_font_sizes()
if not FileAccess.file_exists(source_path): if not FileAccess.file_exists(source_path):
@@ -3855,7 +4162,7 @@ func _load_theme(source_path: String = THEME_PATH) -> void:
if not json is Dictionary: if not json is Dictionary:
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path) push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
return return
_theme = json as Dictionary _theme = _sandbox_theme.raw_config()
var fonts: Dictionary = _theme.get("fonts", {}) var fonts: Dictionary = _theme.get("fonts", {})
_action_popup_font_size = int(fonts.get("action_popup_font_size", 24)) _action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
_action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22)) _action_popup_emoji_size = int(fonts.get("action_popup_emoji_size", 22))
@@ -3894,6 +4201,24 @@ func _load_theme(source_path: String = THEME_PATH) -> void:
_accent_direct = _parse_color(String(colors.get("direct_accent", "")), _accent_direct) _accent_direct = _parse_color(String(colors.get("direct_accent", "")), _accent_direct)
_accent_play = _parse_color(String(colors.get("play_accent", "")), _accent_play) _accent_play = _parse_color(String(colors.get("play_accent", "")), _accent_play)
_guide_line_color = _parse_color(String(colors.get("guide_line", "")), _guide_line_color) _guide_line_color = _parse_color(String(colors.get("guide_line", "")), _guide_line_color)
# Schema v2 wins only when the source itself opts into v2. Legacy ad-hoc
# theme files retain their Phase 3 flat-key precedence for compatibility.
if String((json as Dictionary).get("version", "")) == SANDBOX_THEME.SCHEMA_VERSION:
_action_popup_font_size = _sandbox_theme.integer("fonts.sizes.action_popup", _action_popup_font_size, 8, 96)
_action_popup_emoji_size = _sandbox_theme.integer("fonts.sizes.action_popup_emoji", _action_popup_emoji_size, 8, 96)
_tooltip_font_size = _sandbox_theme.integer("fonts.sizes.tooltip", _tooltip_font_size, 8, 96)
_status_pill_font_size = _sandbox_theme.integer("fonts.sizes.status_pill", _status_pill_font_size, 8, 96)
_queue_panel_font_size = _sandbox_theme.integer("fonts.sizes.queue_panel", _queue_panel_font_size, 8, 96)
_rule_panel_font_size = _sandbox_theme.integer("fonts.sizes.rule_panel", _rule_panel_font_size, 8, 96)
_action_editor_font_size = _sandbox_theme.integer("fonts.sizes.action_editor", _action_editor_font_size, 8, 96)
_rule_editor_font_size = _sandbox_theme.integer("fonts.sizes.rule_editor", _rule_editor_font_size, 8, 96)
_panel_row_font_size = _sandbox_theme.integer("fonts.sizes.panel_row", _panel_row_font_size, 8, 96)
_panel_title_font_size = _sandbox_theme.integer("fonts.sizes.panel_title", _panel_title_font_size, 8, 96)
_accent_edit = _sandbox_theme.color("mode_colors.edit", _accent_edit)
_accent_direct = _sandbox_theme.color("mode_colors.direct", _accent_direct)
_accent_play = _sandbox_theme.color("mode_colors.play", _accent_play)
_guide_line_color = _sandbox_theme.color("mode_colors.guide", _guide_line_color)
_theme_grid_default = _sandbox_theme.number("grid.snap_size", _theme_grid_default, MIN_GRID_SIZE, MAX_GRID_SIZE)
_build_font_sizes() _build_font_sizes()
@@ -3961,7 +4286,7 @@ func _apply_theme_to_visuals() -> void:
_director_visuals.bold_font = _ui_font_bold _director_visuals.bold_font = _ui_font_bold
_director_visuals.italic_font = _ui_font_italic _director_visuals.italic_font = _ui_font_italic
if _placement_overlay != null: if _placement_overlay != null:
_placement_overlay.guide_line_color = _guide_line_color _placement_overlay.apply_style(_sandbox_theme.value("overlays", {}) if _sandbox_theme != null else {})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Settings persistence # Settings persistence
+180
View File
@@ -0,0 +1,180 @@
class_name SandboxTheme
extends RefCounted
## Validated, cached presentation data for the Sandbox Stage.
const SCHEMA_VERSION: String = "2.0"
const DEFAULT_PATH: String = "res://sandbox_theme.json"
const DEFAULT_CONFIG: Dictionary = {
"version": SCHEMA_VERSION,
"global": {"font_preset": "ui", "font_size": 18, "text_color": "#F5F7FA", "muted_text_color": "#AAB2C0", "bg_color": "#171B24F2", "surface_color": "#252B38FA", "border_color": "#FFFFFF33", "border_width": 1, "corner_radius": 10, "padding": 8, "spacing": 8},
"fonts": {"ui_font": "", "emoji_font": "", "ui_font_bold": "", "ui_font_italic": "", "presets": {"ui": {"label": "Standard", "path": ""}}, "sizes": {"action_popup": 24, "action_popup_emoji": 22, "assignment_badge": 20, "rule_label": 18, "status_pill": 18, "tooltip": 18, "file_menu": 22, "file_menu_popup": 26, "mode_switcher": 24, "queue_panel": 22, "rule_panel": 22, "action_editor": 22, "rule_editor": 22, "panel_row": 20, "panel_title": 24, "asset_label": 18, "asset_badge": 14}, "flags": {"panel_title_bold": true, "rule_label_bold": false, "badge_bold": true, "action_popup_bold": false, "action_popup_italic": false}},
"mode_colors": {"edit": "#22C6FF", "direct": "#FFB300", "play": "#33DD77", "guide": "#22C6FF"},
"grid": {"snap_size": 15.0},
"chrome": {"style_defaults": {}, "top_bar": {"height": 64}, "status_bar": {"height": 38}, "mode_switcher": {"segment_min_width": 124}, "mode_frame": {"border_width": 3}, "mode_badge": {"corner_radius": 12, "padding": 10}, "tooltip": {"bg_color": "#101010E6", "border_width": 1}, "dialogs": {"queue_size":[680,520], "rule_size":[720,560], "action_size":[560,390], "confirm_size":[620,300], "font_size":22, "message_font_size":26}},
"popups": {"style_defaults": {"bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12}, "action_menu": {"font_size": 24}, "dialog": {}, "asset_selector": {"backdrop_alpha": 0.55, "cell_size":[190,220], "thumbnail_size":[156,156]}},
"palette": {"style_defaults": {}, "dock_width": 116, "card_size": [96, 94], "icon_size": 30, "drag_threshold": 8, "selected_border_width": 3},
"overlays": {"style_defaults": {"line_width": 2, "dash_length": 8, "dash_gap": 5}, "terrain_guide": {"color": "#22C6FF"}, "valid": {"color": "#4DE66680"}, "same_type": {"color": "#FFFFFF26"}, "invalid": {"color": "#FF4D4D80"}},
"widgets": {"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"], "size_presets": [{"id":"medium", "label":"Medium", "value":28}], "emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]}
}
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
var _font_cache: Dictionary = {}
var _style_cache: Dictionary = {}
var _control_theme: Theme = null
var _warned: Dictionary = {}
func load_from(path: String = DEFAULT_PATH) -> bool:
invalidate_caches()
_config = DEFAULT_CONFIG.duplicate(true)
if not FileAccess.file_exists(path):
_warn_once(path, "SandboxTheme: '%s' was not found; using defaults." % path)
return false
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
if file == null:
_warn_once(path, "SandboxTheme: '%s' could not be read; using defaults." % path)
return false
var parsed: Variant = JSON.parse_string(file.get_as_text())
file.close()
if not parsed is Dictionary:
_warn_once(path, "SandboxTheme: '%s' is malformed; using defaults." % path)
return false
_apply_legacy_aliases(parsed as Dictionary)
_config = _deep_merge(_config, parsed as Dictionary)
return true
func raw_config() -> Dictionary:
return _config.duplicate(true)
func value(path: String, fallback: Variant = null) -> Variant:
var cursor: Variant = _config
for piece: String in path.split("."):
if not cursor is Dictionary or not (cursor as Dictionary).has(piece):
return fallback
cursor = (cursor as Dictionary)[piece]
return cursor
func color(path: String, fallback: Color) -> Color:
var raw: Variant = value(path, null)
if not raw is String:
return fallback
var text: String = String(raw)
if not text.begins_with("#") or (text.length() != 7 and text.length() != 9):
_warn_once(path, "SandboxTheme: invalid color at '%s'." % path)
return fallback
return Color.from_string(text, fallback)
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float:
var raw: Variant = value(path, fallback)
if not raw is int and not raw is float:
_warn_once(path, "SandboxTheme: invalid number at '%s'." % path)
return fallback
var n: float = float(raw)
if not is_finite(n): return fallback
return clampf(n, minimum, maximum)
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int:
return clampi(int(roundi(number(path, float(fallback), float(minimum), float(maximum)))), minimum, maximum)
func font(component_path: String = "", preset_id: String = "ui") -> Font:
var path: String = ""
if not component_path.is_empty(): path = String(value(component_path + ".font_path", ""))
if path.is_empty(): path = String(value("fonts.presets.%s.path" % preset_id, ""))
if path.is_empty(): path = String(value("fonts.ui_font", ""))
if path.is_empty(): return ThemeDB.fallback_font
if _font_cache.has(path): return _font_cache[path]
var resource: Resource = ResourceLoader.load(path) if ResourceLoader.exists(path) else null
if resource is Font:
_font_cache[path] = resource
return resource as Font
_warn_once("font." + path, "SandboxTheme: font '%s' is unavailable; using fallback." % path)
return ThemeDB.fallback_font
func emoji_font() -> Font:
var path: String = String(value("fonts.emoji_font", ""))
if path.is_empty(): return font()
return font("", "emoji") if ResourceLoader.exists(path) else font()
func style(component_path: String) -> Dictionary:
var result: Dictionary = {}
var section: String = component_path.get_slice(".", 0)
result = _deep_merge(result, value("global", {}))
result = _deep_merge(result, value(section + ".style_defaults", {}))
result = _deep_merge(result, value(component_path, {}))
return result
func stylebox(component_path: String) -> StyleBoxFlat:
var cfg: Dictionary = style(component_path)
var key: String = JSON.stringify(cfg)
if not _style_cache.has(key):
var box := StyleBoxFlat.new()
box.bg_color = _style_color(cfg, "bg_color", color("global.surface_color", Color("#252B38")))
box.border_color = _style_color(cfg, "border_color", color("global.border_color", Color("#FFFFFF33")))
box.set_border_width_all(int(cfg.get("border_width", integer("global.border_width", 1, 0, 16))))
box.set_corner_radius_all(int(cfg.get("corner_radius", integer("global.corner_radius", 10, 0, 64))))
var padding: int = int(cfg.get("padding", integer("global.padding", 8, 0, 64)))
box.content_margin_left = padding; box.content_margin_right = padding
box.content_margin_top = padding; box.content_margin_bottom = padding
_style_cache[key] = box
return (_style_cache[key] as StyleBoxFlat).duplicate()
func control_theme() -> Theme:
if _control_theme == null:
_control_theme = Theme.new()
_control_theme.default_font = font()
_control_theme.default_font_size = integer("global.font_size", 18, 8, 96)
return _control_theme
func swatches() -> Array[Color]:
var result: Array[Color] = []
for item: Variant in value("widgets.swatches", []):
if item is String: result.append(Color.from_string(String(item), Color.WHITE))
return result
func size_presets() -> Array[Dictionary]:
var result: Array[Dictionary] = []
for item: Variant in value("widgets.size_presets", []):
if item is Dictionary and not String((item as Dictionary).get("id", "")).is_empty(): result.append((item as Dictionary).duplicate(true))
if result.is_empty(): result.append({"id":"medium", "label":"Medium", "value":28})
return result
func font_presets() -> Array[Dictionary]:
var result: Array[Dictionary] = []
var presets: Dictionary = value("fonts.presets", {})
for id: String in presets:
var entry: Dictionary = presets[id] if presets[id] is Dictionary else {}
result.append({"id": id, "label": String(entry.get("label", id.capitalize())), "font": font("", id)})
if result.is_empty(): result.append({"id":"ui", "label":"Standard", "font":font()})
return result
func emoji_items() -> Array[String]:
var result: Array[String] = []
for item: Variant in value("widgets.emoji", []): if item is String: result.append(String(item))
return result
func invalidate_caches() -> void:
_font_cache.clear(); _style_cache.clear(); _control_theme = null; _warned.clear()
func _apply_legacy_aliases(source: Dictionary) -> void:
var modes: Dictionary = source.get("mode_colors", {})
for pair: Array in [["edit_accent","edit"], ["direct_accent","direct"], ["play_accent","play"], ["guide_line","guide"]]:
if modes.has(pair[0]) and not modes.has(pair[1]): modes[pair[1]] = modes[pair[0]]
var fonts: Dictionary = source.get("fonts", {})
var sizes: Dictionary = fonts.get("sizes", {})
for name: String in ["action_popup", "action_popup_emoji", "assignment_badge", "rule_label", "status_pill", "tooltip", "queue_panel", "rule_panel", "action_editor", "rule_editor", "panel_row", "panel_title"]:
var legacy: String = name + "_font_size"
if fonts.has(legacy) and not sizes.has(name): sizes[name] = fonts[legacy]
fonts["sizes"] = sizes; source["fonts"] = fonts; source["mode_colors"] = modes
func _deep_merge(base: Dictionary, overrides: Dictionary) -> Dictionary:
var merged: Dictionary = base.duplicate(true)
for key: Variant in overrides:
if merged.get(key) is Dictionary and overrides[key] is Dictionary: merged[key] = _deep_merge(merged[key], overrides[key])
else: merged[key] = overrides[key]
return merged
func _style_color(cfg: Dictionary, key: String, fallback: Color) -> Color:
return Color.from_string(String(cfg.get(key, "")), fallback)
func _warn_once(key: String, message: String) -> void:
if not _warned.has(key): _warned[key] = true; push_warning(message)
+1
View File
@@ -0,0 +1 @@
uid://02tfgtwtgfcw
+18 -3
View File
@@ -95,6 +95,21 @@ func set_style(cfg: Dictionary) -> void:
mark_dirty() mark_dirty()
## Resolved-theme entry point. `set_style` remains for legacy callers/tests.
func apply_style(style: Dictionary, fonts: Dictionary = {}) -> void:
var legacy: Dictionary = {"fonts": fonts.duplicate(true)}
if not fonts.is_empty():
var sizes: Dictionary = fonts.get("sizes", fonts)
legacy["fonts"] = {
"assignment_badge_font_size": sizes.get("assignment_badge", badge_icon_size),
"assignment_badge_radius": style.get("badge_radius", badge_radius),
"rule_label_font_size": sizes.get("rule_label", rule_label_font_size),
"rule_label_bold": fonts.get("flags", {}).get("rule_label_bold", rule_label_bold),
"badge_bold": fonts.get("flags", {}).get("badge_bold", badge_bold),
}
set_style(legacy)
func set_rules(r: Array[Dictionary]) -> void: func set_rules(r: Array[Dictionary]) -> void:
rules = r rules = r
mark_dirty() mark_dirty()
@@ -129,7 +144,7 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
var queue := rig.get_queue() var queue := rig.get_queue()
if queue.is_empty(): if queue.is_empty():
continue continue
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
for i: int in queue.size(): for i: int in queue.size():
var action: Dictionary = queue[i] var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to": if String(action.get("type", "")) == "walk_to":
@@ -177,7 +192,7 @@ func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
if queue.is_empty(): if queue.is_empty():
return return
# Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root). # Feet position = rig root - FOOT_OFFSET (FOOT_OFFSET maps feet -> root).
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
var stack := 0 var stack := 0
for i: int in queue.size(): for i: int in queue.size():
var action: Dictionary = queue[i] var action: Dictionary = queue[i]
@@ -256,7 +271,7 @@ func _collect_waypoints() -> Array[Vector2]:
var queue := rig.get_queue() var queue := rig.get_queue()
if queue.is_empty(): if queue.is_empty():
continue continue
var current := rig.global_position - STICKMAN_RIG.FOOT_OFFSET var current: Vector2 = rig.global_position - StickmanLocomotion.FOOT_OFFSET
for i: int in queue.size(): for i: int in queue.size():
var action: Dictionary = queue[i] var action: Dictionary = queue[i]
if String(action.get("type", "")) == "walk_to": if String(action.get("type", "")) == "walk_to":
+16 -3
View File
@@ -22,12 +22,25 @@ var action_target: Vector2 = Vector2.ZERO
var action_valid: bool = true var action_valid: bool = true
var valid_color: Color = Color(0.3, 0.9, 0.4) var valid_color: Color = Color(0.3, 0.9, 0.4)
var invalid_color: Color = Color(1.0, 0.3, 0.3) var invalid_color: Color = Color(1.0, 0.3, 0.3)
var line_width_px: float = 2.0
var dash_length_px: float = 8.0
var dash_gap_px: float = 5.0
const DASH_LENGTH_PX := 8.0 const DASH_LENGTH_PX := 8.0
const DASH_GAP_PX := 5.0 const DASH_GAP_PX := 5.0
const LINE_WIDTH_PX := 2.0 const LINE_WIDTH_PX := 2.0
const MARKER_RADIUS_PX := 10.0 const MARKER_RADIUS_PX := 10.0
func apply_style(style: Dictionary) -> void:
guide_line_color = Color.from_string(String((style.get("terrain_guide", {}) as Dictionary).get("color", "")), guide_line_color)
valid_color = Color.from_string(String((style.get("valid", {}) as Dictionary).get("color", "")), valid_color)
invalid_color = Color.from_string(String((style.get("invalid", {}) as Dictionary).get("color", "")), invalid_color)
var defaults: Dictionary = style.get("style_defaults", {})
line_width_px = maxf(0.5, float(defaults.get("line_width", line_width_px)))
dash_length_px = maxf(1.0, float(defaults.get("dash_length", dash_length_px)))
dash_gap_px = maxf(0.0, float(defaults.get("dash_gap", dash_gap_px)))
queue_redraw()
func _process(_delta: float) -> void: func _process(_delta: float) -> void:
if terrain_guide_visible or action_visible: if terrain_guide_visible or action_visible:
@@ -70,15 +83,15 @@ func _draw() -> void:
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void: func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
var zoom := _zoom() var zoom := _zoom()
var dash := DASH_LENGTH_PX / zoom var dash := dash_length_px / zoom
var gap := DASH_GAP_PX / zoom var gap := dash_gap_px / zoom
var dir := from.direction_to(to) var dir := from.direction_to(to)
var total := from.distance_to(to) var total := from.distance_to(to)
var dist := 0.0 var dist := 0.0
while dist < total: while dist < total:
var start := from + dir * dist var start := from + dir * dist
var len := minf(dash, total - dist) var len := minf(dash, total - dist)
draw_line(start, start + dir * len, color, LINE_WIDTH_PX / zoom, true) draw_line(start, start + dir * len, color, line_width_px / zoom, true)
dist += dash + gap dist += dash + gap
+12
View File
@@ -63,6 +63,18 @@ func get_spawnable_ids() -> Array[String]:
return ids return ids
## UI-safe registry view. Callers receive no templates or mutable registry state.
func get_palette_entries() -> Array[Dictionary]:
var icons: Dictionary = {"ground":"🧱", "ramp":"📐", "step":"🪜", "prop":"📦", "stickman":"🧍", "area":"🎯"}
var categories: Dictionary = {"terrain":"Terrain", "prop":"Props", "stickman":"Cast", "area":"Events"}
var result: Array[Dictionary] = []
for entry: Dictionary in _registry:
var id: String = String(entry.get("id", ""))
var kind: String = String(entry.get("kind", ""))
result.append({"placement_id":id, "kind":kind, "label":String(entry.get("label", id)), "icon":String(icons.get(id, "")), "category":String(categories.get(kind, "")), "opens_selector":kind == "prop" or kind == "stickman"})
return result
func get_label(id: String) -> String: func get_label(id: String) -> String:
var entry := _find_entry(id) var entry := _find_entry(id)
return String(entry.get("label", id)) if not entry.is_empty() else id return String(entry.get("label", id)) if not entry.is_empty() else id
+306
View File
@@ -0,0 +1,306 @@
class_name StickmanActionRunner
extends Node
## Owns authored and reactive action execution plus the shared speech bubble.
## StickmanRig remains the public facade; this node only calls down into
## locomotion and signals requests back to the facade.
const ACTION_REGISTRY := preload("res://scripts/action_registry.gd")
const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/stickman_speech_bubble.gd")
const SPEECH_BUBBLE_OFFSET := Vector2(0.0, -640.0)
enum RunnerState { IDLE, EXECUTING }
enum ActionPhase { NONE, WALKING, SPEAKING, WAITING, RAGDOLLING, RECOVERING }
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished
signal queue_changed
signal speech_finished
signal ragdoll_requested(enabled: bool)
signal recovery_requested
var action_queue: Array[Dictionary] = []
var _rig: StickmanRig = null
var _locomotion: StickmanLocomotion = null
var _runner_state: RunnerState = RunnerState.IDLE
var _action_phase: ActionPhase = ActionPhase.NONE
var _current_index: int = -1
var _run_queue: Array[Dictionary] = []
var _current_run_item: Dictionary = {}
var _run_cursor: int = 0
var _phase_timer: float = 0.0
var _primary_speech: Dictionary = {}
var _overlay_speech: Dictionary = {}
var _speech_bubble: Node2D = null
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
_rig = rig
_locomotion = locomotion
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void:
_update_speech(delta)
if _runner_state != RunnerState.EXECUTING or _is_blocked():
return
match _action_phase:
ActionPhase.NONE:
_advance_to_next_action()
ActionPhase.WALKING:
if _locomotion != null and _locomotion.consume_walk_completed():
_finish_action()
ActionPhase.SPEAKING:
if _primary_speech.is_empty():
_finish_action()
ActionPhase.WAITING:
_phase_timer -= delta
if _phase_timer <= 0.0:
_finish_action()
ActionPhase.RAGDOLLING:
if ragdoll_at_rest:
_finish_action()
ActionPhase.RECOVERING:
if rig_state == StickmanRig.RigState.ANIMATED:
_finish_action()
func speak(text: String, duration: float) -> void:
_primary_speech = {"text": text, "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 hide_speech() -> void:
_primary_speech.clear()
_overlay_speech.clear()
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.hide_bubble()
func sync_facing(profile: int) -> void:
if _speech_bubble != null and is_instance_valid(_speech_bubble):
_speech_bubble.scale = Vector2(-1.0, 1.0) if profile == StickmanRig.FacingProfile.LEFT else Vector2.ONE
func queue_action(action: Dictionary) -> void:
action_queue.append(action)
queue_changed.emit()
func clear_queue() -> void:
action_queue.clear()
queue_changed.emit()
func get_queue() -> Array[Dictionary]:
return action_queue.duplicate(true)
func remove_action(index: int) -> void:
if index < 0 or index >= action_queue.size():
push_warning("StickmanActionRunner: remove_action index %d out of range." % index)
return
action_queue.remove_at(index)
queue_changed.emit()
func insert_action(index: int, action: Dictionary) -> void:
action_queue.insert(clampi(index, 0, action_queue.size()), action)
queue_changed.emit()
func queue_size() -> int:
return action_queue.size()
func enqueue_reactive(actions: Array[Dictionary]) -> void:
if actions.is_empty():
return
if _run_queue.is_empty() and _runner_state == RunnerState.IDLE:
for index: int in action_queue.size():
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
_run_cursor = 0
var primary: Array[Dictionary] = []
var takeover_seen: bool = false
for action: Dictionary in actions:
var role: int = ACTION_REGISTRY.reactive_role(action)
if role == ACTION_REGISTRY.ReactiveRole.OVERLAY:
speak_overlay(String(action.get("text", "")), float(action.get("duration", 2.0)))
continue
if role == ACTION_REGISTRY.ReactiveRole.TAKEOVER:
if not takeover_seen:
takeover_seen = true
_cancel_current_primary()
_overlay_speech.clear()
hide_speech()
var kept: Array[Dictionary] = []
for item: Dictionary in _run_queue.slice(_run_cursor):
if not bool(item.get("reactive", false)):
kept.append(item)
_run_queue = kept
_run_cursor = 0
primary.clear()
primary.append(action.duplicate(true))
if primary.is_empty():
return
if not takeover_seen:
_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
func clear_reactive_actions() -> void:
var runtime_kept: Array[Dictionary] = []
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] = []
for action: Dictionary in action_queue:
if not bool(action.get("reactive", false)):
kept.append(action)
if kept.size() != action_queue.size():
action_queue = kept
queue_changed.emit()
func start_queue() -> void:
if _runner_state == RunnerState.EXECUTING:
return
if action_queue.is_empty():
queue_finished.emit()
return
_runner_state = RunnerState.EXECUTING
_action_phase = ActionPhase.NONE
_current_index = -1
_run_queue.clear()
for index: int in action_queue.size():
_run_queue.append({"action": action_queue[index].duplicate(true), "queue_index": index, "reactive": false})
_run_cursor = 0
_current_run_item.clear()
func stop_queue() -> void:
_cancel_current_primary()
hide_speech()
_run_queue.clear()
_current_run_item.clear()
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
func is_queue_running() -> bool:
return _runner_state == RunnerState.EXECUTING
func get_current_index() -> int:
return _current_index
func _advance_to_next_action() -> void:
if _run_cursor >= _run_queue.size():
_runner_state = RunnerState.IDLE
_action_phase = ActionPhase.NONE
_current_index = -1
queue_finished.emit()
return
_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))
action_started.emit(action, _current_index)
_begin_action(action)
func _begin_action(action: Dictionary) -> void:
match String(action.get("type", "")):
"walk_to":
_action_phase = ActionPhase.WALKING
if _locomotion == null or not _locomotion.walk_to(action.get("target", Vector2.ZERO), float(action.get("speed", -1.0))):
_finish_action()
"speak":
_action_phase = ActionPhase.SPEAKING
speak(String(action.get("text", "")), float(action.get("duration", 2.0)))
"wait":
_phase_timer = float(action.get("duration", 0.0))
_action_phase = ActionPhase.WAITING
"ragdoll":
_action_phase = ActionPhase.RAGDOLLING
ragdoll_requested.emit(true)
"recover":
_action_phase = ActionPhase.RECOVERING
recovery_requested.emit()
_:
push_warning("StickmanActionRunner: unknown action type '%s'; skipped." % String(action.get("type", "")))
_finish_action()
func _finish_action() -> void:
var finished: Dictionary = _current_run_item.get("action", {})
var index: int = int(_current_run_item.get("queue_index", -1))
_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:
if _locomotion != null:
_locomotion.cancel_walk(not _is_blocked())
_primary_speech.clear()
_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()
func _is_blocked() -> bool:
return _locomotion != null and _locomotion.is_action_blocked()
func _update_speech(delta: float) -> void:
var visible_owner_changed: bool = false
if not _primary_speech.is_empty() and not _is_blocked():
_primary_speech["time_left"] = float(_primary_speech.get("time_left", 0.0)) - delta
if float(_primary_speech["time_left"]) <= 0.0:
_primary_speech.clear()
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 _show_visible_speech() -> void:
if _rig == null:
return
if _speech_bubble == null or not is_instance_valid(_speech_bubble):
_speech_bubble = SPEECH_BUBBLE_SCRIPT.new() as Node2D
_speech_bubble.name = "SpeechBubble"
_speech_bubble.position = SPEECH_BUBBLE_OFFSET
_rig.add_child(_speech_bubble)
sync_facing(_rig.facing_profile)
var visible_speech: Dictionary = _overlay_speech if not _overlay_speech.is_empty() else _primary_speech
if not visible_speech.is_empty():
_speech_bubble.show_text(String(visible_speech.get("text", "")))
+1
View File
@@ -0,0 +1 @@
uid://bd0gqgr5oyu3g
+1 -1
View File
@@ -53,7 +53,7 @@ const FILE_FILTER := "*.stk ; Stickman Files"
const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"] const SUPPORTED_VERSIONS: Array[String] = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]
# Phase 8: rig proportions (master_rig.tscn rest pose — see spec §2). # Phase 8: rig proportions (scenes/stickman_rig.tscn rest pose — see spec §2).
const PROPORTIONS: Dictionary = { const PROPORTIONS: Dictionary = {
"upper_arm_length": 168.0, "upper_arm_length": 168.0,
"lower_arm_length": 200.0, "lower_arm_length": 200.0,
+2 -2
View File
@@ -3,7 +3,7 @@ extends RefCounted
## StickmanFactory - Runtime factory for spawning rig instances from .stk files ## StickmanFactory - Runtime factory for spawning rig instances from .stk files
## (Phase 9). ## (Phase 9).
## ##
## Loads a versioned .stk JSON dictionary and instantiates a master_rig.tscn, ## Loads a versioned .stk JSON dictionary and instantiates scenes/stickman_rig.tscn,
## adapting it via StkRigAdapter. Consumed by the standalone test harness, not ## adapting it via StkRigAdapter. Consumed by the standalone test harness, not
## referenced by the editor. ## referenced by the editor.
@@ -11,7 +11,7 @@ extends RefCounted
# Constants # Constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
const RIG_SCENE := preload("res://master_rig.tscn") const RIG_SCENE := preload("res://scenes/stickman_rig.tscn")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Public API # Public API
+763
View File
@@ -0,0 +1,763 @@
class_name StickmanLocomotion
extends Node
## Owns navigation, walking/gait, terrain following, animated falls, and landings.
## StickmanRig schedules this component and exposes its public facade.
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
const TERRAIN_PROBE_UP: float = 96.0
const TERRAIN_PROBE_DOWN: float = 700.0
const TERRAIN_NORMAL_MIN_Y: float = -0.15
const TERRAIN_SUPPORT_RANGE: float = 160.0
const TERRAIN_MAX_FOOT_SURFACE_DROP: float = 72.0
const TERRAIN_FOOT_GUARD_HEIGHT: float = 24.0
const TERRAIN_CENTER_PROBE_OFFSETS: Array[float] = [-24.0, 0.0, 24.0]
const TERRAIN_FOOT_PROBE_OFFSETS: Array[float] = [-4.0, 0.0, 4.0]
const TERRAIN_GRAVITY: float = 1400.0
const TERRAIN_MAX_FALL_SPEED: float = 1800.0
const RECOVERY_PROBE_START_OFFSET: float = 50.0
const RECOVERY_PROBE_END_OFFSET: float = 1500.0
const RECOVERY_PROBE_TOLERANCE: float = 200.0
const RECOVERY_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
const AIR_DRAG_X: float = 0.75
const AIR_POSE_START_SPEED: float = 240.0
const AIR_POSE_FULL_SPEED: float = 1200.0
const AIR_POSE_RESPONSE: float = 10.0
const AIR_HAND_FORWARD: float = 220.0
const AIR_HAND_FORWARD_START_SPEED: float = 80.0
const AIR_HAND_FORWARD_FULL_SPEED: float = 520.0
const AIR_HAND_LIFT: float = 680.0
const AIR_HAND_FLAIL_AMPLITUDE: Vector2 = Vector2(20.0, 16.0)
const AIR_FOOT_FLAIL_AMPLITUDE: Vector2 = Vector2(14.0, 10.0)
const AIR_FLAIL_HZ: float = 12.0
const HARD_LANDING_MIN_FALL_HEIGHT: float = 80.0
const HARD_LANDING_MAX_FALL_HEIGHT: float = 900.0
const LANDING_COMPRESSION_MIN: float = 32.0
const LANDING_COMPRESSION_MAX: float = 160.0
const LANDING_HAND_DROP_MIN: float = 270.0
const LANDING_HAND_DROP_MAX: float = 520.0
const LANDING_COMPRESS_DURATION: float = 0.08
const LANDING_RECOVER_DURATION: float = 0.22
const PELVIS_MAX_OFFSET: float = 240.0
const PELVIS_RESPONSE: float = 10.0
const FOOT_OFFSET: Vector2 = Vector2(0.0, -385.0)
const NAV_AGENT_LOCAL_POS: Vector2 = Vector2(0.0, 385.0)
const ARRIVE_DISTANCE: float = 8.0
const NAV_PATH_DESIRED_DISTANCE: float = 8.0
const NAV_TARGET_DESIRED_DISTANCE: float = 12.0
const LATCH_PROBE_MAX_FRAMES: int = 6
const WALK_CYCLE_DURATION: float = 0.8
const HEAD_COLLISION_RADIUS: float = 90.0
const TORSO_COLLISION_RADIUS: float = 20.0
const ARM_COLLISION_RADIUS: float = 12.0
const LEG_COLLISION_RADIUS: float = 14.0
const HEAD_COLLISION_FALLBACK_CENTER: Vector2 = Vector2(0.0, -72.0)
const COLLISION_LAYER: int = 1
const COLLISION_PARTS: Dictionary = {
"head": {"visual": "Head", "bone": "Torso/Head", "shape": "circle", "radius": HEAD_COLLISION_RADIUS},
"torso": {"visual": "Body", "bone": "Torso", "shape": "capsule", "radius": TORSO_COLLISION_RADIUS},
"left_upper_arm": {"visual": "LeftUpperArm", "bone": "Torso/LeftUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"left_lower_arm": {"visual": "LeftLowerArm", "bone": "Torso/LeftUpperArm/LeftLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"right_upper_arm": {"visual": "RightUpperArm", "bone": "Torso/RightUpperArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"right_lower_arm": {"visual": "RightLowerArm", "bone": "Torso/RightUpperArm/RightLowerArm", "shape": "capsule", "radius": ARM_COLLISION_RADIUS},
"left_upper_leg": {"visual": "LeftUpperLeg", "bone": "Torso/LeftUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"left_lower_leg": {"visual": "LeftLowerLeg", "bone": "Torso/LeftUpperLeg/LeftLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"right_upper_leg": {"visual": "RightUpperLeg", "bone": "Torso/RightUpperLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
"right_lower_leg": {"visual": "RightLowerLeg", "bone": "Torso/RightUpperLeg/RightLowerLeg", "shape": "capsule", "radius": LEG_COLLISION_RADIUS},
}
const REACTION_BASE_ANGLE: float = 0.35
const REACTION_REFERENCE_SPEED: float = 400.0
const REACTION_MAX_ANGLE: float = 0.6
const REACTION_LOCAL_X_EPSILON: float = 0.05
const REACTION_TARGETS: Dictionary = {
"head": [{"joint": "Head", "weight": 1.0, "decay": 0.50}],
"torso": [{"joint": "Torso", "weight": 0.5, "decay": 0.40}],
"left_upper_arm": [{"joint": "LeftUpperArm", "weight": 1.0, "decay": 0.40}],
"left_lower_arm": [{"joint": "LeftLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperArm", "weight": 0.4, "decay": 0.25}],
"right_upper_arm": [{"joint": "RightUpperArm", "weight": 1.0, "decay": 0.40}],
"right_lower_arm": [{"joint": "RightLowerArm", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperArm", "weight": 0.4, "decay": 0.25}],
"left_upper_leg": [{"joint": "LeftUpperLeg", "weight": 1.0, "decay": 0.40}],
"left_lower_leg": [{"joint": "LeftLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "LeftUpperLeg", "weight": 0.4, "decay": 0.25}],
"right_upper_leg": [{"joint": "RightUpperLeg", "weight": 1.0, "decay": 0.40}],
"right_lower_leg": [{"joint": "RightLowerLeg", "weight": 1.0, "decay": 0.35}, {"joint": "RightUpperLeg", "weight": 0.4, "decay": 0.25}],
}
signal arrived(target: Vector2)
var _rig: StickmanRig = null
var foot_contacts: Dictionary = {"Left_Leg": {"valid": false, "locked": false}, "Right_Leg": {"valid": false, "locked": false}}
var terrain_pelvis_offset: float = 0.0
var terrain_air_velocity: Vector2 = Vector2.ZERO
var terrain_motion: TerrainMotion = TerrainMotion.DISABLED
var terrain_following_enabled: bool = false
var _air_time: float = 0.0
var air_pose_weight: float = 0.0
var air_pose_base: Dictionary = {}
var _air_entry_root_y: float = 0.0
var air_fall_distance: float = 0.0
var _landing_elapsed: float = 0.0
var landing_compression: float = 0.0
var _landing_hand_drop: float = 0.0
var _landing_torso_base: Vector2 = Vector2.ZERO
var _landing_hand_start: Dictionary = {}
var _landing_contacts: Dictionary = {}
var _terrain_paused_walk_animation: bool = false
var _nav_agent: NavigationAgent2D = null
var _nav_anchor: Node2D = null
var _walking: bool = false
var _walk_target_feet: Vector2 = Vector2.ZERO
var _walk_speed_current: float = 300.0
var _walk_mode: String = "nav"
var _walk_mode_latched: bool = false
var _walk_latch_probe_frames: int = 0
var _walk_settle_frames: int = 0
var _walk_done: bool = false
var _leg_base_markers: Dictionary = {}
var _last_grounded_walk_velocity_x: float = 0.0
var collision_bodies: Dictionary = {}
var collision_lengths: Dictionary = {}
var _collision_visuals: Dictionary = {}
var reaction_bones: Dictionary = {}
var impact_reactions: Dictionary = {}
func configure(rig: StickmanRig) -> void:
_rig = rig
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % leg_name)) as Marker2D
if base == null:
push_warning("StickmanLocomotion: missing AnimationTargets/%s_Base." % leg_name)
else:
_leg_base_markers[leg_name] = base
_nav_anchor = Node2D.new()
_nav_anchor.name = "NavigationAgentAnchor"
_nav_anchor.position = NAV_AGENT_LOCAL_POS
_rig.add_child(_nav_anchor)
_nav_agent = NavigationAgent2D.new()
_nav_agent.name = "NavigationAgent2D"
_nav_agent.path_desired_distance = NAV_PATH_DESIRED_DISTANCE
_nav_agent.target_desired_distance = NAV_TARGET_DESIRED_DISTANCE
_nav_agent.path_max_distance = 100.0
_nav_agent.max_speed = _rig.walk_speed
_nav_agent.avoidance_enabled = false
_nav_anchor.add_child(_nav_agent)
func setup_collision_bodies() -> void:
collision_bodies.clear()
collision_lengths.clear()
_collision_visuals.clear()
reaction_bones.clear()
if _rig == null or _rig._body_container == null or _rig._skeleton == null:
return
for key: String in COLLISION_PARTS:
var definition: Dictionary = COLLISION_PARTS[key]
var bone: Bone2D = _rig._skeleton.get_node_or_null(NodePath(str(definition["bone"]))) as Bone2D
if bone == null or not is_instance_valid(bone):
push_warning("StickmanLocomotion: missing reaction bone '%s'." % str(definition["bone"]))
continue
var joint_name: String = "Torso" if str(definition["visual"]) == "Body" else str(definition["visual"])
reaction_bones[joint_name] = bone
var visual: Node2D = _rig._body_container.get_node_or_null(NodePath(str(definition["visual"]))) as Node2D
if visual == null:
push_warning("StickmanLocomotion: missing collision visual for '%s'." % key)
continue
var proxy := AnimatableBody2D.new()
proxy.name = "CollisionBody"
proxy.collision_layer = COLLISION_LAYER
proxy.collision_mask = COLLISION_LAYER
proxy.sync_to_physics = false
proxy.set_meta("stickman_rig", _rig)
proxy.set_meta("part_name", key)
var shape_node := CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
if str(definition["shape"]) == "circle":
var circle := CircleShape2D.new()
var bounds: Dictionary = mounted_visual_bounds_local(visual)
if bool(bounds["valid"]):
var rect: Rect2 = bounds["rect"] as Rect2
shape_node.position = rect.get_center()
circle.radius = maxf(float(definition["radius"]), maxf(rect.size.x, rect.size.y) * 0.5)
else:
shape_node.position = HEAD_COLLISION_FALLBACK_CENTER
circle.radius = float(definition["radius"])
shape_node.shape = circle
else:
var length: float = absf(_rig._head_bone.position.y) if key == "torso" and _rig._head_bone != null else bone.length
collision_lengths[key] = length
var capsule := CapsuleShape2D.new()
capsule.height = length
capsule.radius = float(definition["radius"])
shape_node.shape = capsule
shape_node.position = Vector2(0.0, length * 0.5)
proxy.add_child(shape_node)
visual.add_child(proxy)
proxy.top_level = true
proxy.global_transform = visual.global_transform
collision_bodies[key] = proxy
_collision_visuals[key] = visual
func sync_collision_bodies() -> void:
for key: String in collision_bodies:
var proxy: AnimatableBody2D = collision_bodies.get(key) as AnimatableBody2D
var visual: Node2D = _collision_visuals.get(key) as Node2D
if proxy != null and visual != null and is_instance_valid(proxy) and is_instance_valid(visual):
proxy.global_transform = visual.global_transform
func mounted_visual_bounds_local(part: Node2D) -> Dictionary:
var points: Array[Vector2] = []
var nodes: Array[Node] = [part]
while not nodes.is_empty():
var child: Node = nodes.pop_back()
if child is Line2D:
for point: Vector2 in (child as Line2D).points:
points.append(part.to_local((child as Node2D).to_global(point)))
elif child is Polygon2D:
for point: Vector2 in (child as Polygon2D).polygon:
points.append(part.to_local((child as Node2D).to_global(point)))
for grandchild: Node in child.get_children():
nodes.append(grandchild)
if points.is_empty():
return {"valid": false, "rect": Rect2()}
var rect := Rect2(points[0], Vector2.ZERO)
for point: Vector2 in points:
rect = rect.expand(point)
return {"valid": true, "rect": rect}
func set_collision_bodies_enabled(enabled: bool) -> void:
var layer: int = COLLISION_LAYER if enabled else 0
for proxy: AnimatableBody2D in collision_bodies.values():
if proxy != null and is_instance_valid(proxy):
proxy.collision_layer = layer
proxy.collision_mask = layer
func apply_collision_reaction(part_name: String, impact_direction: Vector2, impact_speed: float, impact_position: Vector2 = Vector2.INF) -> void:
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED or impact_direction.is_zero_approx() or impact_speed <= 0.0:
return
for target: Dictionary in REACTION_TARGETS.get(part_name, []):
var joint: String = str(target["joint"])
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
continue
var direction: float = collision_contact_direction(part_name, impact_position, impact_direction)
if is_zero_approx(direction):
var local_dir: Vector2 = bone.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
direction = signf(local_dir.x) if absf(local_dir.x) > REACTION_LOCAL_X_EPSILON else 0.0
if is_zero_approx(direction): direction = -1.0 if joint.begins_with("Left") else 1.0
var angle: float = direction * minf(REACTION_MAX_ANGLE, REACTION_BASE_ANGLE * impact_speed / REACTION_REFERENCE_SPEED) * float(target["weight"])
var old: Dictionary = impact_reactions.get(joint, {})
if old.is_empty():
impact_reactions[joint] = {"angle": angle, "strength": 1.0, "decay": float(target["decay"]), "base_rotation": bone.rotation}
else:
if absf(angle) >= absf(float(old.get("angle", 0.0))):
old["angle"] = angle
old["decay"] = float(target["decay"])
old["strength"] = 1.0
impact_reactions[joint] = old
func collision_contact_direction(part_name: String, impact_position: Vector2, impact_direction: Vector2) -> float:
if not impact_position.is_finite(): return 0.0
var proxy: AnimatableBody2D = collision_bodies.get(part_name) as AnimatableBody2D
if proxy == null or not is_instance_valid(proxy): return 0.0
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null: return 0.0
var side: float = proxy.to_local(impact_position).x - shape_node.position.x
if absf(side) <= REACTION_LOCAL_X_EPSILON: return 0.0
var local_force: Vector2 = proxy.global_transform.affine_inverse().basis_xform(impact_direction).normalized()
return signf(side * local_force.y) if absf(local_force.y) > REACTION_LOCAL_X_EPSILON else signf(side)
func clear_impact_reactions(restore_bones: bool = false) -> void:
if restore_bones:
for joint: String in impact_reactions:
var reaction: Dictionary = impact_reactions[joint]
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
if bone != null and is_instance_valid(bone) and reaction.has("base_rotation"):
bone.rotation = float(reaction["base_rotation"])
impact_reactions.clear()
func apply_impact_reactions(delta: float) -> void:
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
clear_impact_reactions()
return
for joint: String in impact_reactions.keys().duplicate():
var reaction: Dictionary = impact_reactions[joint]
var bone: Bone2D = reaction_bones.get(joint) as Bone2D
if bone == null or not is_instance_valid(bone):
impact_reactions.erase(joint)
continue
var base_rotation: float = float(reaction.get("base_rotation", bone.rotation))
bone.rotation = base_rotation + float(reaction["angle"]) * float(reaction["strength"])
reaction["strength"] = float(reaction["strength"]) - delta / maxf(float(reaction["decay"]), 0.001)
if float(reaction["strength"]) <= 0.0:
bone.rotation = base_rotation
impact_reactions.erase(joint)
else:
impact_reactions[joint] = reaction
func tick_movement(delta: float, _rig_state: int) -> void:
if _rig == null:
return
terrain_pelvis_offset = 0.0
if terrain_following_enabled and terrain_motion == TerrainMotion.GROUNDED:
_last_grounded_walk_velocity_x = 0.0
_update_walking(delta)
_update_terrain_following(delta)
_settle_walk_markers()
func tick_post_skeleton(delta: float, _rig_state: int) -> void:
if _rig != null:
apply_impact_reactions(delta)
sync_collision_bodies()
func walk_to(target: Vector2, speed: float = -1.0) -> bool:
if _rig == null or _rig.state != StickmanRig.RigState.ANIMATED:
push_warning("StickmanRig: walk_to ignored; not ANIMATED.")
return false
if _rig._is_action_queue_blocked():
push_warning("StickmanRig: walk_to ignored during landing impact.")
return false
if _nav_agent == null or _nav_anchor == null:
push_warning("StickmanLocomotion: walk_to ignored; navigation is unavailable.")
return false
_walk_target_feet = target
_walk_speed_current = speed if speed > 0.0 else _rig.walk_speed
_nav_agent.max_speed = _walk_speed_current
_nav_agent.target_position = target
var dx: float = target.x - _rig.global_position.x
if dx < -0.5:
_rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
elif dx > 0.5:
_rig.set_facing_profile(StickmanRig.FacingProfile.RIGHT)
_rig._set_limb_mods_enabled(true)
if _rig._anim_player != null and is_instance_valid(_rig._anim_player) and _rig._anim_player.has_animation("walk_right"):
_rig._anim_player.play("walk_right")
_walking = true
_walk_done = false
_walk_mode = "nav"
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
return true
func cancel_walk(restore_idle_pose: bool = true) -> void:
if _rig == null:
return
var current_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
_rig._anim_player.stop(true)
if _nav_agent != null and _nav_anchor != null:
_nav_agent.target_position = _nav_anchor.global_position
_walking = false
_walk_done = false
_walk_mode_latched = false
_walk_latch_probe_frames = 0
_walk_settle_frames = 0
if restore_idle_pose:
_rig._apply_idle_limb_solver_mode(current_limb_rotations)
func is_walking() -> bool:
return _walking
func get_foot_offset() -> Vector2:
return FOOT_OFFSET
func consume_walk_completed() -> bool:
if not _walk_done:
return false
_walk_done = false
return true
func _update_walking(delta: float) -> void:
if not _walking:
return
if _rig.state != StickmanRig.RigState.ANIMATED:
cancel_walk(false)
return
if blocks_walking() or _nav_agent == null:
return
if NavigationServer2D.map_get_iteration_id(_nav_agent.get_navigation_map()) == 0:
return
if not _walk_mode_latched:
if _walk_latch_probe_frames < LATCH_PROBE_MAX_FRAMES:
_walk_latch_probe_frames += 1
_nav_agent.get_next_path_position()
if _nav_agent.is_target_reachable():
_walk_mode_latched = true
_walk_mode = "nav"
else:
return
else:
_walk_mode_latched = true
_walk_mode = "direct"
var next_feet: Vector2 = _nav_agent.get_next_path_position()
var final_root: Vector2 = _walk_target_feet + FOOT_OFFSET
var dist_to_final: float = absf(_rig.global_position.x - final_root.x)
var root_target: Vector2
if _walk_mode == "nav":
if _nav_agent.is_navigation_finished() and dist_to_final <= 2.0 * ARRIVE_DISTANCE:
_rig.global_position.x = final_root.x
_finish_walk()
return
# A freshly synchronized agent may yield its own feet as the first
# waypoint. Treat that zero-progress waypoint as the final segment.
var next_progress: float = next_feet.x - _rig.global_position.x
var remaining_progress: float = final_root.x - _rig.global_position.x
if dist_to_final <= 2.0 * ARRIVE_DISTANCE or remaining_progress * next_progress <= 0.0:
root_target = Vector2(final_root.x, _rig.global_position.y)
else:
root_target = Vector2(next_feet.x, _rig.global_position.y)
else:
root_target = Vector2(final_root.x, _rig.global_position.y)
var before_x: float = _rig.global_position.x
_rig.global_position = _rig.global_position.move_toward(root_target, _walk_speed_current * delta)
_last_grounded_walk_velocity_x = (_rig.global_position.x - before_x) / delta if delta > 0.0 else 0.0
if dist_to_final <= ARRIVE_DISTANCE or absf(_rig.global_position.x - final_root.x) <= ARRIVE_DISTANCE:
_rig.global_position.x = final_root.x
_finish_walk()
func _settle_walk_markers() -> void:
if _walk_settle_frames > 0:
_walk_settle_frames -= 1
_rig._restore_standing_markers()
func _finish_walk() -> void:
var final_limb_rotations: Dictionary = _rig._capture_limb_rotations()
if _rig._anim_player != null and is_instance_valid(_rig._anim_player):
_rig._anim_player.stop(true)
_walking = false
_rig._apply_idle_limb_solver_mode(final_limb_rotations)
_walk_settle_frames = 0
_walk_done = true
arrived.emit(_walk_target_feet)
func begin_terrain_fall() -> void:
if _rig == null:
return
terrain_following_enabled = true
terrain_pelvis_offset = 0.0
_terrain_paused_walk_animation = false
_enter_airborne(0.0)
_reset_foot_contacts()
func set_terrain_following_enabled(enabled: bool) -> void:
if enabled:
if terrain_motion == TerrainMotion.DISABLED:
begin_terrain_fall()
return
terrain_following_enabled = false
_cancel_landing_impact(true)
_restore_air_pose()
terrain_pelvis_offset = 0.0
terrain_air_velocity = Vector2.ZERO
terrain_motion = TerrainMotion.DISABLED
_terrain_paused_walk_animation = false
_reset_foot_contacts()
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
var target: Marker2D = _rig._get_ik_marker(leg_name)
if base != null and target != null:
target.position = base.position
func prepare_for_ragdoll() -> void:
if terrain_motion != TerrainMotion.DISABLED:
set_terrain_following_enabled(false)
func reset_for_ragdoll_handoff() -> void:
_cancel_landing_impact(false)
air_pose_base.clear()
air_pose_weight = 0.0
_air_time = 0.0
_air_entry_root_y = 0.0
air_fall_distance = 0.0
terrain_motion = TerrainMotion.DISABLED
terrain_air_velocity = Vector2.ZERO
func is_action_blocked() -> bool:
return terrain_motion == TerrainMotion.LANDING_IMPACT
func blocks_walking() -> bool:
return terrain_following_enabled and terrain_motion != TerrainMotion.GROUNDED
func _reset_foot_contacts() -> void:
for leg_name: String in foot_contacts:
foot_contacts[leg_name] = {"valid": false, "locked": false}
func _probe_terrain_below(world_point: Vector2, probe_down: float = TERRAIN_PROBE_DOWN) -> Dictionary:
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null:
return {"valid": false}
var query := PhysicsRayQueryParameters2D.create(world_point + Vector2.UP * TERRAIN_PROBE_UP, world_point + Vector2.DOWN * probe_down)
query.collision_mask = 1
query.collide_with_areas = false
query.exclude = _rig.get_collision_body_rids()
var hit: Dictionary = space_state.intersect_ray(query)
if hit.is_empty() or not (hit.get("collider") is TerrainBlock):
return {"valid": false}
var normal: Vector2 = hit.get("normal", Vector2.ZERO)
if normal.y > TERRAIN_NORMAL_MIN_Y:
return {"valid": false}
return {"valid": true, "world_pos": hit.get("position", world_point), "normal": normal}
func _probe_terrain_fan(world_point: Vector2, offsets: Array[float]) -> Dictionary:
var best: Dictionary = {"valid": false}
for x_offset: float in offsets:
var hit: Dictionary = _probe_terrain_below(world_point + Vector2(x_offset, 0.0))
if bool(hit.get("valid", false)) and (not bool(best.get("valid", false)) or (hit["world_pos"] as Vector2).y < (best["world_pos"] as Vector2).y):
best = hit
return best
func probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
var best_y := bottom_y
var best_distance := INF
if _rig == null:
return best_y
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null:
return best_y
for offset: float in RECOVERY_PROBE_X_OFFSETS:
var query := PhysicsRayQueryParameters2D.create(
Vector2(world_x + offset, top_y - RECOVERY_PROBE_START_OFFSET),
Vector2(world_x + offset, bottom_y + RECOVERY_PROBE_END_OFFSET))
query.collision_mask = 1
query.collide_with_areas = false
query.collide_with_bodies = true
query.exclude = exclude
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var hit_position: Vector2 = hit.get("position", Vector2.ZERO)
if hit_position.y < top_y - RECOVERY_PROBE_TOLERANCE or hit_position.y > bottom_y + RECOVERY_PROBE_TOLERANCE:
continue
var distance := absf(hit_position.y - bottom_y)
if distance < best_distance:
best_distance = distance
best_y = hit_position.y
return best_y
func _stance_weight(leg_name: String) -> float:
if not _walking or _rig._anim_player == null:
return 1.0
var phase: float = fmod(_rig._anim_player.get_current_animation_position(), WALK_CYCLE_DURATION)
var right_weight: float = 0.5 + 0.5 * cos(TAU * phase / WALK_CYCLE_DURATION)
return right_weight if leg_name == "Right_Leg" else 1.0 - right_weight
func _update_terrain_following(delta: float) -> void:
if not terrain_following_enabled or _rig.state != StickmanRig.RigState.ANIMATED or _leg_base_markers.size() != 2:
terrain_pelvis_offset = 0.0
return
_rig._set_limb_mods_enabled(true)
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
var center_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
var center_supported: bool = bool(center_probe.get("valid", false)) and absf(center_delta) <= TERRAIN_SUPPORT_RANGE
var center_surface_y: float = (center_probe["world_pos"] as Vector2).y if bool(center_probe.get("valid", false)) else INF
var solved: Dictionary = {}
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var base: Marker2D = _leg_base_markers[leg_name] as Marker2D
var target: Marker2D = _rig._get_ik_marker(leg_name)
if base == null or target == null:
continue
var base_world: Vector2 = base.global_position
var probe: Dictionary = _probe_terrain_fan(base_world, TERRAIN_FOOT_PROBE_OFFSETS)
var probe_delta: float = (probe["world_pos"] as Vector2).y - base_world.y if bool(probe.get("valid", false)) else INF
var within_reach: bool = absf(probe_delta) <= PELVIS_MAX_OFFSET
if within_reach and center_supported:
within_reach = (probe["world_pos"] as Vector2).y <= center_surface_y + TERRAIN_MAX_FOOT_SURFACE_DROP
var stance: float = _stance_weight(leg_name)
var contact: Dictionary = foot_contacts[leg_name]
if stance >= 0.5 and bool(probe.get("valid", false)) and within_reach:
if not bool(contact.get("locked", false)):
contact = {"valid": true, "locked": true, "world_pos": probe["world_pos"], "normal": probe["normal"]}
elif (contact.get("world_pos", base_world) as Vector2).distance_to(probe["world_pos"]) > 2.0:
contact["world_pos"] = probe["world_pos"]
elif stance < 0.5 or not within_reach:
contact = {"valid": bool(probe.get("valid", false)), "locked": false}
foot_contacts[leg_name] = contact
var floor_world: Vector2 = contact.get("world_pos", base_world) as Vector2 if bool(contact.get("locked", false)) else probe.get("world_pos", base_world) as Vector2
solved[leg_name] = {"base_marker": base, "floor": floor_world, "surface": probe.get("world_pos", base_world) if within_reach else base_world, "surface_valid": bool(probe.get("valid", false)) and within_reach, "weight": stance, "target": target}
if terrain_motion == TerrainMotion.LANDING_IMPACT:
_update_landing_impact(delta)
return
if terrain_motion == TerrainMotion.AIRBORNE:
_update_airborne(delta, solved)
return
if not center_supported:
_enter_airborne(_last_grounded_walk_velocity_x)
_update_airborne(delta, solved)
return
terrain_pelvis_offset = lerpf(0.0, clampf(center_delta, -PELVIS_MAX_OFFSET, PELVIS_MAX_OFFSET), 1.0 - exp(-PELVIS_RESPONSE * delta))
terrain_motion = TerrainMotion.GROUNDED
terrain_air_velocity = Vector2.ZERO
_resume_walk_animation()
_rig.global_position.y += terrain_pelvis_offset
for leg_name: String in solved:
var item: Dictionary = solved[leg_name]
var base_after: Vector2 = (item["base_marker"] as Marker2D).global_position
var weight: float = float(item["weight"])
var surface: Vector2 = item["surface"] as Vector2
if bool(item["surface_valid"]):
weight = maxf(weight, smoothstep(0.0, 1.0, 1.0 - clampf((surface.y - base_after.y) / TERRAIN_FOOT_GUARD_HEIGHT, 0.0, 1.0)))
var wanted: Vector2 = base_after
wanted.y = lerpf(base_after.y, (item["floor"] as Vector2).y, weight)
if bool(item["surface_valid"]) and wanted.y > surface.y:
wanted.y = surface.y
(item["target"] as Marker2D).global_position = wanted
func _enter_airborne(horizontal_velocity: float) -> void:
if terrain_motion != TerrainMotion.AIRBORNE:
air_pose_base.clear()
for marker_name: String in ["Left_Hand", "Right_Hand", "Left_Leg", "Right_Leg"]:
var marker: Marker2D = _rig._get_ik_marker(marker_name)
if marker != null:
air_pose_base[marker_name] = marker.position
_air_time = 0.0
air_pose_weight = 0.0
_air_entry_root_y = _rig.global_position.y
air_fall_distance = 0.0
terrain_motion = TerrainMotion.AIRBORNE
terrain_air_velocity = Vector2(horizontal_velocity, 0.0)
if _walking and _rig._anim_player != null and _rig._anim_player.is_playing():
_rig._anim_player.pause()
_terrain_paused_walk_animation = true
_reset_foot_contacts()
func _update_airborne(delta: float, solved: Dictionary) -> void:
terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
terrain_air_velocity.y = minf(terrain_air_velocity.y + TERRAIN_GRAVITY * delta, TERRAIN_MAX_FALL_SPEED)
_rig.global_position += _sweep_airborne_horizontal_motion(Vector2(terrain_air_velocity.x * delta, 0.0))
_rig.global_position.y += terrain_air_velocity.y * delta
air_fall_distance = maxf(air_fall_distance, _rig.global_position.y - _air_entry_root_y)
_air_time += delta
_apply_air_pose(delta)
var center_world: Vector2 = _rig.to_global(NAV_AGENT_LOCAL_POS)
var center_probe: Dictionary = _probe_terrain_fan(center_world, TERRAIN_CENTER_PROBE_OFFSETS)
var landing_delta: float = (center_probe["world_pos"] as Vector2).y - center_world.y if bool(center_probe.get("valid", false)) else INF
if bool(center_probe.get("valid", false)) and terrain_air_velocity.y >= 0.0 and landing_delta >= -TERRAIN_SUPPORT_RANGE and landing_delta <= TERRAIN_SUPPORT_RANGE + terrain_air_velocity.y * delta:
_rig.global_position.y += landing_delta
var speed: float = maxf(terrain_air_velocity.y, 0.0)
terrain_air_velocity = Vector2.ZERO
if air_fall_distance >= HARD_LANDING_MIN_FALL_HEIGHT:
_begin_landing(speed, center_probe, solved)
else:
_restore_air_pose()
terrain_motion = TerrainMotion.GROUNDED
_resume_walk_animation()
func _apply_air_pose(delta: float) -> void:
if air_pose_base.is_empty():
return
var speed: float = maxf(terrain_air_velocity.y, 0.0)
air_pose_weight = lerpf(air_pose_weight, smoothstep(AIR_POSE_START_SPEED, AIR_POSE_FULL_SPEED, speed), 1.0 - exp(-AIR_POSE_RESPONSE * delta))
var forward_weight: float = smoothstep(AIR_HAND_FORWARD_START_SPEED, AIR_HAND_FORWARD_FULL_SPEED, speed)
var phase: float = TAU * AIR_FLAIL_HZ * _air_time
for marker_name: String in air_pose_base:
var marker: Marker2D = _rig._get_ik_marker(marker_name)
if marker == null:
continue
var base: Vector2 = air_pose_base[marker_name]
var local_phase: float = phase + (PI if marker_name.begins_with("Right") else 0.0)
var offset: Vector2
if marker_name.ends_with("Hand"):
offset = Vector2(AIR_HAND_FORWARD * forward_weight, -AIR_HAND_LIFT * air_pose_weight) + Vector2(sin(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_HAND_FLAIL_AMPLITUDE.y) * air_pose_weight
marker.position = base + offset
else:
offset = Vector2(sin(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.x, cos(local_phase) * AIR_FOOT_FLAIL_AMPLITUDE.y)
marker.position = base + offset * air_pose_weight
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2:
if is_zero_approx(motion.x):
return motion
var space_state: PhysicsDirectSpaceState2D = _rig.get_world_2d().direct_space_state
if space_state == null:
return motion
var safe_fraction: float = 1.0
var excluded: Array[RID] = []
for own_proxy: AnimatableBody2D in collision_bodies.values():
if own_proxy != null and is_instance_valid(own_proxy):
var own_rid: RID = own_proxy.get_rid()
if own_rid.is_valid():
excluded.append(own_rid)
for proxy: AnimatableBody2D in collision_bodies.values():
if proxy == null or not is_instance_valid(proxy):
continue
var shape_node: CollisionShape2D = proxy.get_node_or_null(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node == null or shape_node.shape == null:
continue
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape_node.shape
query.transform = shape_node.global_transform
query.motion = motion
query.collision_mask = COLLISION_LAYER
query.exclude = excluded
var cast: PackedFloat32Array = space_state.cast_motion(query)
if cast.size() >= 1:
safe_fraction = minf(safe_fraction, cast[0])
if safe_fraction < 0.9999:
terrain_air_velocity.x = 0.0
return motion * safe_fraction
func _begin_landing(_impact_speed: float, center_probe: Dictionary, _solved: Dictionary) -> void:
terrain_motion = TerrainMotion.LANDING_IMPACT
_landing_elapsed = 0.0
var weight: float = clampf(inverse_lerp(HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT, air_fall_distance), 0.0, 1.0)
landing_compression = lerpf(LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX, weight)
_landing_hand_drop = lerpf(LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX, weight)
var torso: Marker2D = _rig._get_ik_marker("Torso")
if torso != null: _landing_torso_base = torso.position
_landing_hand_start.clear(); _landing_contacts.clear()
for hand_name: String in ["Left_Hand", "Right_Hand"]:
var hand: Marker2D = _rig._get_ik_marker(hand_name)
if hand != null: _landing_hand_start[hand_name] = hand.position
var center_y: float = (center_probe.get("world_pos", _rig.global_position) as Vector2).y
for leg_name: String in ["Left_Leg", "Right_Leg"]:
var target: Marker2D = _rig._get_ik_marker(leg_name)
if target == null: continue
var base: Marker2D = _leg_base_markers.get(leg_name) as Marker2D
var probe: Dictionary = _probe_terrain_fan(base.global_position if base != null else target.global_position, TERRAIN_FOOT_PROBE_OFFSETS)
var contact: Vector2 = probe["world_pos"] as Vector2 if bool(probe.get("valid", false)) else Vector2(target.global_position.x, center_y)
_landing_contacts[leg_name] = contact; target.global_position = contact
func _update_landing_impact(delta: float) -> void:
_landing_elapsed += delta
var total: float = LANDING_COMPRESS_DURATION + LANDING_RECOVER_DURATION
var offset: float = landing_compression * (1.0 - pow(1.0 - clampf(_landing_elapsed / LANDING_COMPRESS_DURATION, 0.0, 1.0), 2.0)) if _landing_elapsed <= LANDING_COMPRESS_DURATION else landing_compression * cos(clampf((_landing_elapsed - LANDING_COMPRESS_DURATION) / LANDING_RECOVER_DURATION, 0.0, 1.0) * PI * 0.5)
var torso: Marker2D = _rig._get_ik_marker("Torso")
if torso != null: torso.position = _landing_torso_base + Vector2.DOWN * offset
for leg_name: String in _landing_contacts:
var target: Marker2D = _rig._get_ik_marker(leg_name)
if target != null: target.global_position = _landing_contacts[leg_name]
for hand_name: String in _landing_hand_start:
var hand: Marker2D = _rig._get_ik_marker(hand_name)
if hand != null: hand.position = (_landing_hand_start[hand_name] as Vector2).lerp(air_pose_base.get(hand_name, _landing_hand_start[hand_name]), clampf(_landing_elapsed / total, 0.0, 1.0)) + Vector2.DOWN * _landing_hand_drop * offset / maxf(landing_compression, 0.0001)
if _landing_elapsed >= total: _finish_landing_impact()
func _finish_landing_impact() -> void:
var torso: Marker2D = _rig._get_ik_marker("Torso")
if torso != null: torso.position = _landing_torso_base
_restore_air_pose(); _landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0; terrain_motion = TerrainMotion.GROUNDED
_resume_walk_animation()
func _cancel_landing_impact(restore_pose: bool) -> void:
if terrain_motion == TerrainMotion.LANDING_IMPACT and restore_pose:
var torso: Marker2D = _rig._get_ik_marker("Torso")
if torso != null: torso.position = _landing_torso_base
_landing_contacts.clear(); _landing_hand_start.clear(); _landing_elapsed = 0.0; landing_compression = 0.0; _landing_hand_drop = 0.0
func _restore_air_pose() -> void:
for marker_name: String in air_pose_base:
var marker: Marker2D = _rig._get_ik_marker(marker_name)
if marker != null: marker.position = air_pose_base[marker_name]
air_pose_base.clear(); air_pose_weight = 0.0; _air_time = 0.0; _air_entry_root_y = 0.0; air_fall_distance = 0.0
func _resume_walk_animation() -> void:
if _terrain_paused_walk_animation and _walking and _rig._anim_player != null:
_rig._anim_player.play()
_terrain_paused_walk_animation = false
+1
View File
@@ -0,0 +1 @@
uid://bxyogpn8rv8xy
+787
View File
@@ -0,0 +1,787 @@
class_name StickmanRagdoll
extends Node
## Owns procedural ragdoll physics. StickmanRig remains the public state facade.
signal recovery_requested()
signal rest_state_changed(at_rest: bool)
signal state_transition_requested(next_state: int)
const CONTAINER_NAME: StringName = &"RagdollBodyContainer"
const LIMB_RADIUS: float = 8.0
const TORSO_RADIUS: float = 12.0
const HEAD_RADIUS: float = 100.0
const VISUAL_COLOR: Color = Color(0.445488, 0.445488, 0.445488)
const CIRCLE_SEGMENTS: int = 32
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
const GROUND_PROBE_X_OFFSETS: Array[float] = [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]
const STAND_UP_DURATION := 2.0
const STAND_POSE: Dictionary = {
"Torso": {"pos": Vector2(0, 10), "rot": 0.0},
"Head": {"pos": Vector2(100, -614), "rot": 0.0},
"Left_Hand": {"pos": Vector2(90, 110), "rot": 0.0},
"Right_Hand": {"pos": Vector2(-90, 110), "rot": 0.0},
"Left_Leg": {"pos": Vector2(-110, 380), "rot": 0.0},
"Right_Leg": {"pos": Vector2(110, 390), "rot": 0.0},
}
const IK_TARGET_PATHS: Dictionary = {
"Torso": "IK_Targets/Torso", "Head": "IK_Targets/Head",
"Left_Hand": "IK_Targets/Left_Hand", "Right_Hand": "IK_Targets/Right_Hand",
"Left_Leg": "IK_Targets/Left_Leg", "Right_Leg": "IK_Targets/Right_Leg",
}
const LIMB_BONE_PATHS: Dictionary = {
"LeftArm": ["Torso/LeftUpperArm", "Torso/LeftUpperArm/LeftLowerArm"],
"RightArm": ["Torso/RightUpperArm", "Torso/RightUpperArm/RightLowerArm"],
"LeftLeg": ["Torso/LeftUpperLeg", "Torso/LeftUpperLeg/LeftLowerLeg"],
"RightLeg": ["Torso/RightUpperLeg", "Torso/RightUpperLeg/RightLowerLeg"],
}
const LIMB_BODY_KEYS: Dictionary = {
"LeftArm": ["left_upper_arm", "left_lower_arm"],
"RightArm": ["right_upper_arm", "right_lower_arm"],
"LeftLeg": ["left_upper_leg", "left_lower_leg"],
"RightLeg": ["right_upper_leg", "right_lower_leg"],
}
const RECOVERY_LEG_SIDE_FORCE: Dictionary = {
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
}
const RECOVERY_JOINT_PATH: Dictionary = {
StickmanRig.FacingProfile.FORWARD: {}, StickmanRig.FacingProfile.LEFT: {}, StickmanRig.FacingProfile.RIGHT: {},
}
const BODIES: Array[Dictionary] = [
{ "key": "torso", "kind": "bone", "node_path": "Torso", "visual_path": "Body/Body", "radius": TORSO_RADIUS, "mass": 8.0, "linear_damp": 1.0, "angular_damp": 4.0 },
{ "key": "head", "kind": "visual", "node_path": "Body/Head", "visual_path": "Body/Head", "radius": HEAD_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 2.0 },
{ "key": "left_upper_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm", "visual_path": "Body/LeftUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_arm", "kind": "bone", "node_path": "Torso/LeftUpperArm/LeftLowerArm", "visual_path": "Body/LeftLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_upper_arm", "kind": "bone", "node_path": "Torso/RightUpperArm", "visual_path": "Body/RightUpperArm", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_arm", "kind": "bone", "node_path": "Torso/RightUpperArm/RightLowerArm", "visual_path": "Body/RightLowerArm", "radius": LIMB_RADIUS, "mass": 1.0, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "left_upper_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg", "visual_path": "Body/LeftUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "left_lower_leg", "kind": "bone", "node_path": "Torso/LeftUpperLeg/LeftLowerLeg", "visual_path": "Body/LeftLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
{ "key": "right_upper_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg", "visual_path": "Body/RightUpperLeg", "radius": LIMB_RADIUS, "mass": 2.0, "linear_damp": 0.5, "angular_damp": 3.0 }, { "key": "right_lower_leg", "kind": "bone", "node_path": "Torso/RightUpperLeg/RightLowerLeg", "visual_path": "Body/RightLowerLeg", "radius": LIMB_RADIUS, "mass": 1.5, "linear_damp": 0.5, "angular_damp": 3.0 },
]
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. 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 },
"left_lower_leg": { "lower": -5.0, "upper": 150.0 }, "right_lower_leg": { "lower": -5.0, "upper": 150.0 },
},
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": -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": 0.0, "upper": 150.0 }, "right_lower_leg": { "lower": 0.0, "upper": 150.0 },
},
}
var _rig: StickmanRig = null
var _locomotion: StickmanLocomotion = null
var _skeleton: Skeleton2D = null
var _body_container: Node2D = null
var _anim_player: AnimationPlayer = null
var _torso_bone: Bone2D = null
var _head_bone: Bone2D = null
var _head_look_at: SkeletonModification2DLookAt = null
var _limb_bones: Dictionary = {}
var _bend_modifications: Dictionary = {}
var _root: Node2D = null
var _bodies: Dictionary = {}
var _previous_position: Vector2 = Vector2.ZERO
var _previous_rotation: float = 0.0
var _linear_velocity: Vector2 = Vector2.ZERO
var _angular_velocity: float = 0.0
var _rest_timer: float = 0.0
var _stabilize_timer: float = 0.0
var _at_rest: bool = false
var _captured_pose: Dictionary = {}
var _captured_landing_center := Vector2.ZERO
var _captured_ground_y := 0.0
var _stand_up_tween: Tween = null
var _stand_up_generation := 0
var _recovery_bones: Array[Dictionary] = []
var _pre_ragdoll_state: Dictionary = {}
var _probe_top_y := 0.0
var _probe_bottom_y := 0.0
var _probe_exclude: Array[RID] = []
var _spawn_pose: Dictionary = {}
var _spawn_limb_rotations: Dictionary = {}
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void:
_rig = rig
_locomotion = locomotion
func initialize(skeleton: Skeleton2D) -> void:
_skeleton = skeleton
_body_container = _rig.get_node_or_null(NodePath("Body")) as Node2D
_anim_player = _rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer
if _skeleton != null:
_torso_bone = _skeleton.get_node_or_null(NodePath("Torso")) as Bone2D
_head_bone = _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
_resolve_limb_bones()
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
for index: int in stack.modification_count:
var modification: SkeletonModification2D = stack.get_modification(index)
if modification is SkeletonModification2DLookAt:
_head_look_at = modification as SkeletonModification2DLookAt
elif modification is SkeletonModification2DTwoBoneIK:
var ik := modification as SkeletonModification2DTwoBoneIK
for joint: String in LIMB_BONE_PATHS:
if ik.joint_two_bone2d_node == NodePath(String((LIMB_BONE_PATHS[joint] as Array)[1])):
_bend_modifications[joint] = ik
break
_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:
var paths: Array = LIMB_BONE_PATHS[joint]
var upper := _skeleton.get_node_or_null(NodePath(String(paths[0]))) as Bone2D
var lower := _skeleton.get_node_or_null(NodePath(String(paths[1]))) as Bone2D
if upper != null and lower != null:
_limb_bones[joint] = {"upper": upper, "lower": lower}
func tick_pre_movement(delta: float, rig_state: int, rest_timeout: float, auto_recover: bool) -> void:
if _rig == null: return
if delta > 0.0:
_linear_velocity = (_rig.global_position - _previous_position) / delta
_angular_velocity = wrapf(_rig.global_rotation - _previous_rotation, -PI, PI) / delta
_previous_position = _rig.global_position
_previous_rotation = _rig.global_rotation
if rig_state != StickmanRig.RigState.RAGDOLL: return
var torso: RigidBody2D = _bodies.get("torso") as RigidBody2D
if torso == null or not is_instance_valid(torso): return
var resting: bool = torso.sleeping or (torso.linear_velocity.length() <= REST_LINEAR_THRESHOLD and absf(torso.angular_velocity) <= REST_ANGULAR_THRESHOLD)
if resting != _at_rest: _at_rest = resting; rest_state_changed.emit(resting)
if not resting: _rest_timer = 0.0; _stabilize_timer = 0.0; return
if not auto_recover: _rest_timer = 0.0; return
_rest_timer += delta
if _rest_timer >= rest_timeout:
_stabilize_timer += delta
if _stabilize_timer >= STABILIZATION_DELAY: recovery_requested.emit()
func build() -> bool:
if _rig == null or _skeleton == null: return false
var parent: Node = _rig.get_parent()
if parent == null: parent = _rig.get_tree().current_scene
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()
func get_bodies() -> Dictionary: return _bodies
func is_at_rest() -> bool: return _at_rest
func reset_rest_state() -> void: _at_rest = false; _rest_timer = 0.0; _stabilize_timer = 0.0
func apply_velocity_boost(velocity: Vector2) -> void:
for body: RigidBody2D in _bodies.values():
if body != null and is_instance_valid(body): body.apply_central_impulse(velocity * body.mass)
func enter_ragdoll() -> bool:
if _rig == null or _skeleton == null or _body_container == null:
push_warning("StickmanRagdoll: cannot enter ragdoll; missing rig nodes.")
return false
_capture_pre_ragdoll_state()
if _locomotion != null:
_locomotion.clear_impact_reactions()
_locomotion.set_collision_bodies_enabled(false)
_locomotion.cancel_walk(false)
if _anim_player != null and is_instance_valid(_anim_player):
_anim_player.stop(true)
if not build():
return false
_body_container.visible = false
_body_container.modulate.a = 1.0
if _skeleton.modification_stack != null:
_skeleton.modification_stack.enabled = false
reset_rest_state()
return true
func request_recovery() -> bool:
if _rig == null or _rig.state != StickmanRig.RigState.RAGDOLL:
return false
_capture_ragdoll_pose()
destroy()
_reanchor_root_to_landing()
state_transition_requested.emit(StickmanRig.RigState.RECOVERING)
_snap_skeleton_to_pose()
if _locomotion != null:
_locomotion.set_collision_bodies_enabled(true)
_play_stand_up()
return true
func cancel_recovery() -> void:
_stand_up_generation += 1
if _stand_up_tween != null and _stand_up_tween.is_valid():
_stand_up_tween.kill()
_stand_up_tween = null
func snap_to_standing(previous_state: int) -> void:
if _locomotion != null:
_locomotion.set_terrain_following_enabled(false)
if previous_state == StickmanRig.RigState.RAGDOLL:
destroy()
elif previous_state == StickmanRig.RigState.RECOVERING:
cancel_recovery()
if _locomotion != null:
_locomotion.clear_impact_reactions(true)
_rig._hide_speech()
# 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:
_rig._apply_head_lookat_mirror_mode()
else:
_restore_head_lookat_state()
_recovery_bones.clear()
reset_rest_state()
_clear_recovery_capture()
if _locomotion != null:
_locomotion.set_collision_bodies_enabled(true)
if _body_container != null:
_body_container.visible = true
_body_container.modulate.a = 1.0
_rig._apply_idle_limb_solver_mode(_spawn_limb_rotations)
if previous_state != StickmanRig.RigState.ANIMATED:
state_transition_requested.emit(StickmanRig.RigState.ANIMATED)
func initialize_spawn_pose_from_skeleton() -> void:
if _rig == null:
return
var skeleton := _rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
if skeleton == null:
push_warning("StickmanRagdoll: 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": _rig.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 := head.to_global(Vector2(maxf(head.length, 1.0), 0.0).rotated(deg_to_rad(head.bone_angle)))
pose["Head"] = {"pos": _rig.to_local(head_tip), "rot": 0.0}
var endpoints: 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 endpoints:
var bone := skeleton.get_node_or_null(NodePath(String(endpoints[marker_name]))) as Bone2D
if bone == null:
return
var endpoint := bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
pose[marker_name] = {"pos": _rig.to_local(endpoint), "rot": 0.0}
if pose.size() != IK_TARGET_PATHS.size():
return
for marker_name: String in pose:
var marker := _rig.get_node_or_null(NodePath(String(IK_TARGET_PATHS[marker_name]))) as Marker2D
if marker == null:
return
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
marker.rotation = target.get("rot", marker.rotation)
if marker_name == "Left_Leg" or marker_name == "Right_Leg":
var base_marker := _rig.get_node_or_null(NodePath("AnimationTargets/%s_Base" % marker_name)) as Marker2D
if base_marker != null:
base_marker.position = marker.position
_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}
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
func _capture_pre_ragdoll_state() -> void:
_pre_ragdoll_state.clear()
var markers: Dictionary = {}
for marker_name: String in STAND_POSE:
var marker := _get_ik_marker(marker_name)
if marker != null:
markers[marker_name] = {"pos": marker.position, "rot": marker.rotation}
_pre_ragdoll_state["markers"] = markers
var rotations: Dictionary = {}
for joint: String in _limb_bones:
var pair: Dictionary = _limb_bones[joint]
var upper := pair.get("upper") as Bone2D
var lower := pair.get("lower") as Bone2D
rotations[joint] = {"upper": upper.rotation if upper != null else 0.0, "lower": lower.rotation if lower != null else 0.0}
_pre_ragdoll_state["limb_rotations"] = rotations
_pre_ragdoll_state["head_rotation"] = _head_bone.rotation if _head_bone != null else 0.0
_pre_ragdoll_state["facing_profile"] = int(_rig.facing_profile)
var bends: Dictionary = {}
for joint: String in StickmanRig.BEND_JOINTS:
bends[joint] = _rig.get_joint_bend_flipped(joint)
_pre_ragdoll_state["bends"] = bends
_pre_ragdoll_state["head_lookat_enabled"] = _head_look_at.enabled if _head_look_at != null else false
var leg_sides: Dictionary = {}
for leg_joint: String in ["LeftLeg", "RightLeg"]:
var pair: Dictionary = _limb_bones.get(leg_joint, {})
var upper := pair.get("upper") as Bone2D
var lower := pair.get("lower") as Bone2D
var marker := _get_ik_marker("Left_Leg" if leg_joint == "LeftLeg" else "Right_Leg")
if upper == null or lower == null or marker == null:
continue
var side := (marker.global_position - upper.global_position).cross(lower.global_position - upper.global_position)
leg_sides[leg_joint] = {"side": 1.0 if side >= 0.0 else -1.0, "flag": _rig.get_joint_bend_flipped(leg_joint)}
_pre_ragdoll_state["leg_sides"] = leg_sides
var torso_entry_rot := 0.0
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
torso_entry_rot = torso_marker.rotation
var spine_entry_rel := 0.0
if _head_bone != null and _torso_bone != null:
spine_entry_rel = (_head_bone.global_position - _torso_bone.global_position).angle() - _rig.global_rotation
_pre_ragdoll_state["torso_chain_offset"] = _chain_angle_from_captured(spine_entry_rel) - torso_entry_rot
var head_entry_chain := torso_entry_rot + float(_pre_ragdoll_state.get("head_rotation", 0.0))
_pre_ragdoll_state["head_chain_offset"] = _chain_angle_from_captured(-_rig.global_rotation) - head_entry_chain
func _capture_ragdoll_pose() -> void:
_captured_pose.clear()
_captured_landing_center = Vector2.ZERO
_captured_ground_y = 0.0
var top_y := INF
var bottom_y := -INF
var exclude_rids: Array[RID] = []
for key: String in _bodies:
var body := _bodies[key] as RigidBody2D
if body == null or not is_instance_valid(body):
continue
var half := float(body.get_meta("half_height", 0.0))
var radius := float(body.get_meta("radius", 0.0))
var extent := half + radius
top_y = minf(top_y, body.global_position.y - extent)
bottom_y = maxf(bottom_y, body.global_position.y + extent)
exclude_rids.append(body.get_rid())
_captured_pose[key] = {"pos": _rig.to_local(body.global_position), "rot": body.global_rotation - _rig.global_rotation, "half": half}
var torso := _bodies.get("torso") as RigidBody2D
if torso != null and is_instance_valid(torso):
_captured_landing_center = torso.global_position
_captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids)
if is_equal_approx(_captured_ground_y, bottom_y):
_captured_ground_y = torso.global_position.y + float(torso.get_meta("radius", 0.0))
_probe_top_y = top_y
_probe_bottom_y = bottom_y
_probe_exclude = exclude_rids
func _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:
if _locomotion != null:
return _locomotion.probe_ground_y(world_x, top_y, bottom_y, exclude)
var best_y := bottom_y
var space_state := _rig.get_world_2d().direct_space_state
if space_state == null:
return best_y
var best_dist := INF
for offset: float in GROUND_PROBE_X_OFFSETS:
var query := PhysicsRayQueryParameters2D.create(Vector2(world_x + offset, top_y - GROUND_PROBE_START_OFFSET), Vector2(world_x + offset, bottom_y + GROUND_PROBE_END_OFFSET))
query.collision_mask = 1
query.exclude = exclude
var hit := space_state.intersect_ray(query)
if hit.is_empty():
continue
var y := (hit.get("position", Vector2.ZERO) as Vector2).y
if y < top_y - GROUND_PROBE_TOLERANCE or y > bottom_y + GROUND_PROBE_TOLERANCE:
continue
var distance := absf(y - bottom_y)
if distance < best_dist:
best_dist = distance
best_y = y
return best_y
func _reanchor_root_to_landing() -> void:
if not _captured_pose.has("torso"):
return
var feet_world := Vector2(_captured_landing_center.x, _captured_ground_y)
var old_root := _rig.global_position
var foot_offset := _locomotion.get_foot_offset() if _locomotion != null else Vector2(0.0, -385.0)
_rig.global_position = feet_world + foot_offset
var shift := old_root - _rig.global_position
var local_shift := _rig.to_local(shift) - _rig.to_local(Vector2.ZERO)
for key: String in _captured_pose:
var entry: Dictionary = _captured_pose[key]
entry["pos"] = entry.get("pos", Vector2.ZERO) + local_shift
func _clear_recovery_capture() -> void:
_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()
func _chain_angle_from_captured(captured_rel: float) -> float:
return -captured_rel if _rig.facing_profile == StickmanRig.FacingProfile.LEFT else captured_rel
func _wrap_toward(value: float, target: float) -> float:
return target + wrapf(value - target, -PI, PI)
func _natural_joint_target(current: float, target: float, offset: float) -> float:
var a := wrapf(current + offset, -PI, PI)
var b := wrapf(target + offset, -PI, PI)
var delta := wrapf(b - a, -PI, PI)
if a * b < 0.0 and absf(a) + absf(b) > PI:
delta += TAU if delta < 0.0 else -TAU
return current + delta
func _set_limb_mods_enabled(enabled: bool) -> void:
for joint: String in StickmanRig.BEND_JOINTS:
var modification := _bend_modifications.get(joint) as SkeletonModification2DTwoBoneIK
if modification != null and is_instance_valid(modification):
modification.enabled = enabled
func _restore_head_lookat_state() -> void:
if _head_look_at != null and is_instance_valid(_head_look_at):
_head_look_at.enabled = bool(_pre_ragdoll_state.get("head_lookat_enabled", false))
func _rearm_ik_stack() -> void:
if _skeleton == null or not is_instance_valid(_skeleton):
return
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
if stack != null:
if not stack.get_is_setup():
stack.setup()
stack.enabled = true
_skeleton.set_process_internal(true)
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var driver := torso_marker.get_node_or_null("RemoteTransform2D") as RemoteTransform2D
if driver != null:
driver.update_position = true
driver.update_rotation = true
driver.update_scale = true
func _restore_standing_markers(use_spawn_pose: bool = true) -> void:
var pose := _spawn_pose if use_spawn_pose and not _spawn_pose.is_empty() else STAND_POSE
for marker_name: String in pose:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = pose[marker_name]
marker.position = target.get("pos", marker.position)
if marker_name == "Torso":
marker.rotation = target.get("rot", marker.rotation)
func _get_ik_marker(marker_name: String) -> Marker2D:
if _rig == null:
return null
var path := String(IK_TARGET_PATHS.get(marker_name, ""))
return _rig.get_node_or_null(NodePath(path)) as Marker2D if not path.is_empty() else null
func _set_marker_from_body(marker_name: String, body_key: String) -> void:
var marker := _get_ik_marker(marker_name)
var pose: Dictionary = _captured_pose.get(body_key, {})
if marker == null or pose.is_empty():
return
var direction := Vector2.from_angle(float(pose.get("rot", 0.0)) + _rig.global_rotation)
var center_world := _rig.to_global(pose.get("pos", Vector2.ZERO))
marker.position = _rig.to_local(center_world + direction * float(pose.get("half", 0.0)))
func _snap_skeleton_to_pose() -> void:
_recovery_bones.clear()
var pre_markers: Dictionary = _pre_ragdoll_state.get("markers", {})
var pre_limb_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
var pre_head_rotation := float(_pre_ragdoll_state.get("head_rotation", 0.0))
var torso_chain_offset := float(_pre_ragdoll_state.get("torso_chain_offset", 0.0))
var head_chain_offset := float(_pre_ragdoll_state.get("head_chain_offset", 0.0))
var theta_t := 0.0
var torso_marker := _get_ik_marker("Torso")
if torso_marker != null:
var torso_pose: Dictionary = _captured_pose.get("torso", {})
if not torso_pose.is_empty():
var torso_rel := float(torso_pose.get("rot", 0.0))
var spine_dir := Vector2.from_angle(torso_rel + _rig.global_rotation)
var center_world := _rig.to_global(torso_pose.get("pos", Vector2.ZERO))
torso_marker.position = _rig.to_local(center_world - spine_dir * float(torso_pose.get("half", 0.0)))
var raw_rotation := _chain_angle_from_captured(torso_rel) - torso_chain_offset
var pre_torso_rotation := float((pre_markers.get("Torso", {}) as Dictionary).get("rot", raw_rotation))
theta_t = wrapf(_wrap_toward(raw_rotation, pre_torso_rotation), -PI, PI)
torso_marker.rotation = theta_t
var head_marker := _get_ik_marker("Head")
var head_pose: Dictionary = _captured_pose.get("head", {})
if head_marker != null and not head_pose.is_empty():
head_marker.position = head_pose.get("pos", head_marker.position)
_set_marker_from_body("Left_Hand", "left_lower_arm")
_set_marker_from_body("Right_Hand", "right_lower_arm")
_set_marker_from_body("Left_Leg", "left_lower_leg")
_set_marker_from_body("Right_Leg", "right_lower_leg")
for marker_name: String in ["Left_Leg", "Right_Leg"]:
var marker := _get_ik_marker(marker_name)
var body_key := "left_lower_leg" if marker_name == "Left_Leg" else "right_lower_leg"
if marker == null or (_captured_pose.get(body_key, {}) as Dictionary).is_empty():
continue
var ankle_world := _rig.to_global(marker.position)
var ground_y := _probe_ground_y(ankle_world.x, _probe_top_y, _probe_bottom_y, _probe_exclude)
if ankle_world.y < ground_y:
ankle_world.y = ground_y
marker.position = _rig.to_local(ankle_world)
if not (_captured_pose.get("torso", {}) as Dictionary).is_empty():
for joint: String in LIMB_BONE_PATHS:
var pair: Dictionary = _limb_bones.get(joint, {})
var upper := pair.get("upper") as Bone2D
var lower := pair.get("lower") as Bone2D
if upper == null or lower == null:
continue
var keys: Array = LIMB_BODY_KEYS[joint]
var upper_pose: Dictionary = _captured_pose.get(keys[0], {})
var lower_pose: Dictionary = _captured_pose.get(keys[1], {})
if upper_pose.is_empty() or lower_pose.is_empty():
continue
var upper_chain := _chain_angle_from_captured(float(upper_pose.get("rot", 0.0)))
var lower_chain := _chain_angle_from_captured(float(lower_pose.get("rot", 0.0)))
var pre_pair: Dictionary = pre_limb_rotations.get(joint, {})
upper.rotation = _wrap_toward(upper_chain - theta_t - deg_to_rad(upper.bone_angle), float(pre_pair.get("upper", upper.rotation)))
lower.rotation = _wrap_toward(lower_chain - upper_chain + deg_to_rad(upper.bone_angle) - deg_to_rad(lower.bone_angle), float(pre_pair.get("lower", lower.rotation)))
upper.rotation = wrapf(upper.rotation, -PI, PI)
lower.rotation = wrapf(lower.rotation, -PI, PI)
if joint == "LeftLeg" or joint == "RightLeg":
continue
var target_upper := upper.rotation + wrapf(float(pre_pair.get("upper", upper.rotation)) - upper.rotation, -PI, PI)
var pre_lower := float(pre_pair.get("lower", lower.rotation))
var path_override := String((RECOVERY_JOINT_PATH.get(_rig.facing_profile, {}) as Dictionary).get(joint, ""))
var target_lower := lower.rotation + wrapf(pre_lower - lower.rotation, -PI, PI) if path_override == "shortest" else _natural_joint_target(lower.rotation, pre_lower, deg_to_rad(lower.bone_angle - upper.bone_angle))
_recovery_bones.append({"bone": upper, "target": target_upper})
_recovery_bones.append({"bone": lower, "target": target_lower})
if not head_pose.is_empty() and _head_bone != null:
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)})
_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:
continue
var entry: Dictionary = (_pre_ragdoll_state.get("leg_sides", {}) as Dictionary).get(leg_joint, {})
var forced: Dictionary = RECOVERY_LEG_SIDE_FORCE.get(_rig.facing_profile, {})
if forced.has(leg_joint):
modification.flip_bend_direction = bool(forced[leg_joint])
elif not entry.is_empty():
var upper_key := "left_upper_leg" if leg_joint == "LeftLeg" else "right_upper_leg"
var lower_key := "left_lower_leg" if leg_joint == "LeftLeg" else "right_lower_leg"
var up_pose: Dictionary = _captured_pose.get(upper_key, {})
var low_pose: Dictionary = _captured_pose.get(lower_key, {})
if not up_pose.is_empty() and not low_pose.is_empty():
var dir_u := Vector2.from_angle(float(up_pose.get("rot", 0.0)) + _rig.global_rotation)
var dir_l := Vector2.from_angle(float(low_pose.get("rot", 0.0)) + _rig.global_rotation)
var center_u := _rig.to_global(up_pose.get("pos", Vector2.ZERO))
var hip := center_u - dir_u * float(up_pose.get("half", 0.0))
var knee := center_u + dir_u * float(up_pose.get("half", 0.0))
var ankle := _rig.to_global(low_pose.get("pos", Vector2.ZERO)) + dir_l * float(low_pose.get("half", 0.0))
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
var generation := _stand_up_generation
var target_markers: Dictionary = _pre_ragdoll_state.get("markers", STAND_POSE)
_stand_up_tween = _tween_markers_to(target_markers, STAND_UP_DURATION)
for entry: Dictionary in _recovery_bones:
var bone := entry.get("bone") as Bone2D
if bone != null:
_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.bind(generation))
func _tween_markers_to(target_pose: Dictionary, duration: float) -> Tween:
var tween := create_tween()
tween.set_parallel(true).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
for marker_name: String in target_pose:
var marker := _get_ik_marker(marker_name)
if marker == null:
continue
var target: Dictionary = target_pose[marker_name]
tween.tween_property(marker, "position", target.get("pos", marker.position), duration)
if marker_name == "Torso":
tween.tween_property(marker, "rotation", target.get("rot", marker.rotation), duration)
return tween
func _on_stand_up_finished(generation: int) -> void:
if generation != _stand_up_generation or _rig.state != StickmanRig.RigState.RECOVERING:
return
var recovered_rotations: Dictionary = _pre_ragdoll_state.get("limb_rotations", {})
_rig.set_facing_profile(int(_pre_ragdoll_state.get("facing_profile", int(_rig.facing_profile))))
var bends: Dictionary = _pre_ragdoll_state.get("bends", {})
for joint: String in StickmanRig.BEND_JOINTS:
if bends.has(joint):
_rig.set_joint_bend_flipped(joint, bool(bends[joint]))
_set_limb_mods_enabled(true)
_restore_head_lookat_state()
_recovery_bones.clear()
_rearm_ik_stack()
if _body_container != null:
_body_container.visible = true
_body_container.modulate.a = 1.0
if _locomotion != null:
_locomotion.set_collision_bodies_enabled(true)
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
body.mass = float(entry["mass"]); body.linear_damp = float(entry["linear_damp"]); body.angular_damp = float(entry["angular_damp"]); body.collision_layer = 1; body.collision_mask = 1
body.set_meta("stickman_rig", _rig); body.set_meta("part_name", key); body.set_meta("stickman_ragdoll_body", true)
var shape := CollisionShape2D.new()
if str(entry["kind"]) == "visual":
var visual := _rig.get_node_or_null(NodePath(str(entry["node_path"]))) as Node2D
if visual == null: return
var circle := CircleShape2D.new(); circle.radius = float(entry["radius"]); shape.shape = circle; body.add_child(shape); body.position = visual.global_position; body.set_meta("half_height", 0.0); body.set_meta("radius", float(entry["radius"]))
if not _clone_visuals(body, str(entry["visual_path"])): _add_circle(body, float(entry["radius"]))
else:
var bone := _skeleton.get_node_or_null(NodePath(str(entry["node_path"]))) as Bone2D
if bone == null: return
var origin: Vector2 = bone.global_position; var tip: Vector2
if key == "torso":
var head := _skeleton.get_node_or_null(NodePath("Torso/Head")) as Bone2D
if head == null: return
tip = head.global_position
else: tip = bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))
var length: float = origin.distance_to(tip); var capsule := CapsuleShape2D.new(); capsule.height = length; capsule.radius = float(entry["radius"]); shape.shape = capsule; shape.rotation = -PI * 0.5; body.add_child(shape); body.position = (origin + tip) * 0.5; body.rotation = (tip - origin).angle(); body.set_meta("half_height", length * 0.5); body.set_meta("radius", float(entry["radius"]))
if not _clone_visuals(body, str(entry["visual_path"])): _add_capsule(body, length, float(entry["radius"]))
_root.add_child(body); _bodies[key] = body
func _build_joint(entry: Dictionary) -> void:
var child := _bodies.get(str(entry["child"])) as RigidBody2D; var parent := _bodies.get(str(entry["parent"])) as RigidBody2D; var bone := _skeleton.get_node_or_null(NodePath(str(entry["pin"]))) as Bone2D
if child == null or parent == null or bone == null: return
var pin := PinJoint2D.new(); pin.name = "RagdollPin_" + str(entry["child"]); pin.position = bone.global_position; _root.add_child(pin); pin.node_a = pin.get_path_to(parent); pin.node_b = pin.get_path_to(child); pin.softness = TARGET_SOFTNESS
_apply_joint_limits(pin, str(entry["limit"]), str(entry["child"]))
func _apply_joint_limits(pin: PinJoint2D, limit: String, child_key: String) -> void:
if limit == "shoulder":
pin.angular_limit_enabled = true; pin.angular_limit_lower = -deg_to_rad(160.0); pin.angular_limit_upper = deg_to_rad(160.0)
elif limit != "neck":
var profile: int = int(_rig.facing_profile) if _rig != null else StickmanRig.FacingProfile.FORWARD
var band: Dictionary = JOINT_BAND_LIMITS.get(profile, {}).get(child_key, {})
if band.is_empty(): band = { "lower": -5.0 if limit == "cw" else -150.0, "upper": 150.0 if limit == "cw" else 5.0 }
pin.angular_limit_enabled = true; pin.angular_limit_lower = deg_to_rad(float(band["lower"])); pin.angular_limit_upper = deg_to_rad(float(band["upper"]))
else: pin.angular_limit_enabled = false
func _clone_visuals(body: RigidBody2D, path: String) -> bool:
var source := _rig.get_node_or_null(NodePath(path)) as Node2D
if source == null: return false
var count: int = 0
for child: Node in source.get_children():
if child is Line2D:
var src := child as Line2D; var copy := Line2D.new(); var points := PackedVector2Array()
for point: Vector2 in src.points: points.append(body.to_local(src.to_global(point)))
copy.points = points; copy.width = src.width; copy.default_color = src.default_color; copy.begin_cap_mode = src.begin_cap_mode; copy.end_cap_mode = src.end_cap_mode; copy.joint_mode = src.joint_mode; copy.closed = src.closed; body.add_child(copy); count += 1
elif child is Polygon2D:
var src := child as Polygon2D; var copy := Polygon2D.new(); var points := PackedVector2Array()
for point: Vector2 in src.polygon: points.append(body.to_local(src.to_global(point)))
copy.polygon = points; copy.color = src.color; body.add_child(copy); count += 1
return count > 0
func _add_capsule(body: RigidBody2D, length: float, radius: float) -> void:
var line := Line2D.new(); line.points = PackedVector2Array([Vector2(-length * 0.5, 0.0), Vector2(length * 0.5, 0.0)]); line.width = radius * 2.0; line.default_color = VISUAL_COLOR; line.begin_cap_mode = Line2D.LINE_CAP_ROUND; line.end_cap_mode = Line2D.LINE_CAP_ROUND; body.add_child(line)
func _add_circle(body: RigidBody2D, radius: float) -> void:
var polygon := Polygon2D.new(); var points := PackedVector2Array()
for index: int in CIRCLE_SEGMENTS: points.append(Vector2.from_angle(TAU * float(index) / float(CIRCLE_SEGMENTS)) * radius)
polygon.polygon = points; polygon.color = Color.WHITE; body.add_child(polygon)
+1
View File
@@ -0,0 +1 @@
uid://bihf4kx2qgr1o
+183 -1966
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@ class_name StkRigAdapter
extends RefCounted extends RefCounted
## StkRigAdapter - Standalone runtime adapter (Phase 8). ## StkRigAdapter - Standalone runtime adapter (Phase 8).
## ##
## Fits an instantiated master_rig.tscn to a loaded .stk dictionary: re-fits ## Fits an instantiated scenes/stickman_rig.tscn to a loaded .stk dictionary: re-fits
## the skeleton bone lengths, recalibrates the IK targets, and mounts the ## the skeleton bone lengths, recalibrates the IK targets, and mounts the
## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging" ## .stk vector shapes onto the rig's Body/ visual nodes in the rig's "hanging"
## convention (joint end at the local origin, far end along +Y). The ## convention (joint end at the local origin, far end along +Y). The
+1 -1
View File
@@ -1,7 +1,7 @@
extends Control extends Control
## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9). ## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9).
## ##
## Loads .stk files via StickmanFactory, spawns a master_rig.tscn instance into ## Loads .stk files via StickmanFactory, spawns a scenes/stickman_rig.tscn instance into
## a SubViewport, and provides a debug overlay (skeleton bones + IK handles) ## a SubViewport, and provides a debug overlay (skeleton bones + IK handles)
## plus interactive IK-handle dragging. NOT wired into the editor — run ## plus interactive IK-handle dragging. NOT wired into the editor — run
## standalone via F6 on res://scenes/test_harness.tscn. ## standalone via F6 on res://scenes/test_harness.tscn.
+27 -1
View File
@@ -4,6 +4,27 @@ extends RefCounted
const STICKMEN_DIR := "user://thumbnails/stickmen" const STICKMEN_DIR := "user://thumbnails/stickmen"
const PROP_DIR := "user://thumbnails/props" const PROP_DIR := "user://thumbnails/props"
const PROP_VERSION := 1 const PROP_VERSION := 1
var _memory: Dictionary = {}
func get_texture(cache_path: String) -> Texture2D:
if _memory.has(cache_path):
return _memory[cache_path] as Texture2D
var texture: Texture2D = load_png(cache_path)
if texture != null:
_memory[cache_path] = texture
return texture
func store_texture(cache_path: String, texture: Texture2D) -> Error:
if texture == null:
return ERR_INVALID_PARAMETER
_memory[cache_path] = texture
return save_png(texture, cache_path)
func forget_memory(cache_path: String) -> void:
_memory.erase(cache_path)
func clear_memory() -> void:
_memory.clear()
func stickman_key(path: String) -> String: func stickman_key(path: String) -> String:
@@ -19,12 +40,16 @@ func prop_png(id: String) -> String:
func load_png(png_path: String) -> Texture2D: func load_png(png_path: String) -> Texture2D:
if _memory.has(png_path):
return _memory[png_path] as Texture2D
if not FileAccess.file_exists(png_path): if not FileAccess.file_exists(png_path):
return null return null
var img := Image.load_from_file(png_path) var img := Image.load_from_file(png_path)
if img == null: if img == null:
return null return null
return ImageTexture.create_from_image(img) var texture: Texture2D = ImageTexture.create_from_image(img)
_memory[png_path] = texture
return texture
func save_png(tex: Texture2D, png_path: String) -> Error: func save_png(tex: Texture2D, png_path: String) -> Error:
@@ -34,6 +59,7 @@ func save_png(tex: Texture2D, png_path: String) -> Error:
if img == null: if img == null:
return ERR_CANT_CREATE return ERR_CANT_CREATE
ensure_dir(png_path.get_base_dir()) ensure_dir(png_path.get_base_dir())
_memory[png_path] = tex
return img.save_png(png_path) return img.save_png(png_path)
+58
View File
@@ -0,0 +1,58 @@
class_name AssetCard
extends Button
signal activated(payload: Dictionary)
signal drag_started(payload: Dictionary)
var _payload: Dictionary = {}
var _press_position: Vector2 = Vector2.ZERO
var _dragging: bool = false
var _drag_threshold: float = 8.0
var _icon: TextureRect = null
var _glyph: Label = null
var _label: Label = null
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: Variant = null) -> void:
_payload = payload.duplicate(true)
_drag_threshold = theme.number("palette.drag_threshold", 8.0, 1.0, 128.0) if theme != null else 8.0
custom_minimum_size = Vector2(96, 108)
clip_contents = true
if theme != null:
var size_data: Variant = theme.value("palette.card_size", [96, 94])
if size_data is Array and (size_data as Array).size() == 2: custom_minimum_size = Vector2(float(size_data[0]), maxf(108.0, float(size_data[1])))
add_theme_stylebox_override("normal", theme.stylebox("palette"))
var thumbnail_size: Vector2 = _payload.get("thumbnail_size", Vector2(64, 64))
focus_mode = Control.FOCUS_ALL
tooltip_text = String(_payload.get("label", ""))
for child: Node in get_children(): child.queue_free()
var box := VBoxContainer.new(); box.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT); box.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.clip_contents = true
box.alignment = BoxContainer.ALIGNMENT_CENTER; box.add_theme_constant_override("separation", 2); add_child(box)
_icon = TextureRect.new(); _icon.custom_minimum_size = thumbnail_size; _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE; _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED; _icon.texture = icon_texture; _icon.visible = icon_texture != null; _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_icon)
_glyph = Label.new(); _glyph.text = String(_payload.get("icon", "")); _glyph.visible = icon_texture == null; _glyph.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE; _glyph.add_theme_font_size_override("font_size", 30); box.add_child(_glyph)
_label = Label.new(); _label.text = String(_payload.get("label", "")); _label.custom_minimum_size = Vector2(0, 28); _label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER; _label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART; _label.clip_text = true; _label.mouse_filter = Control.MOUSE_FILTER_IGNORE; box.add_child(_label)
func set_thumbnail(texture: Texture2D) -> void:
if _icon != null: _icon.texture = texture; _icon.visible = texture != null
if _glyph != null and texture != null: _glyph.visible = false
func thumbnail_rect() -> TextureRect:
return _icon
func set_selected(selected: bool) -> void:
button_pressed = selected
func payload() -> Dictionary:
return _payload.duplicate(true)
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT:
if event.pressed:
_press_position = event.position; _dragging = false
elif _dragging:
accept_event()
elif event is InputEventMouseMotion and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and not _dragging:
if event.position.distance_to(_press_position) >= _drag_threshold:
_dragging = true; drag_started.emit(payload()); accept_event()
func _pressed() -> void:
if not _dragging: activated.emit(payload())
+1
View File
@@ -0,0 +1 @@
uid://dayameygn6kdb
+17
View File
@@ -0,0 +1,17 @@
class_name ColorSwatchSelector
extends HBoxContainer
signal color_selected(color: Color)
var _selected: Color = Color.WHITE
var _group: ButtonGroup = ButtonGroup.new()
func configure(colors: Array[Color], selected: Color, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_selected = selected
for color: Color in colors:
var button := Button.new(); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group; button.tooltip_text = color.to_html(true)
var box := StyleBoxFlat.new(); box.bg_color = color; box.set_corner_radius_all(22); button.add_theme_stylebox_override("normal", box)
button.pressed.connect(func() -> void: set_selected(color, true)); add_child(button)
button.set_pressed_no_signal(color.is_equal_approx(selected))
func set_selected(color: Color, emit_signal: bool = false) -> void:
_selected = color
if emit_signal: color_selected.emit(color)
func get_selected() -> Color: return _selected
+1
View File
@@ -0,0 +1 @@
uid://djy6yykmgtxk0
+10
View File
@@ -0,0 +1,10 @@
class_name EmojiQuickGrid
extends GridContainer
signal emoji_selected(text: String)
func configure(items: Array[String], columns: int = 8, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
self.columns = maxi(1, columns)
for text: String in items:
var button := Button.new(); button.text = text; button.custom_minimum_size = Vector2(44,44); button.focus_mode = Control.FOCUS_ALL
if theme != null: button.add_theme_font_override("font", theme.emoji_font())
button.pressed.connect(func() -> void: emoji_selected.emit(text)); add_child(button)
+1
View File
@@ -0,0 +1 @@
uid://dlstr7bm0x258
+17
View File
@@ -0,0 +1,17 @@
class_name FontPresetPicker
extends HBoxContainer
signal preset_selected(id: String)
var _selected_id: String = ""
var _group: ButtonGroup = ButtonGroup.new()
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_selected_id = selected_id
for item: Dictionary in presets:
var id: String = String(item.get("id", "")); if id.is_empty(): continue
var button := Button.new(); button.text = String(item.get("label", id)); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
if item.get("font", null) is Font: button.add_theme_font_override("font", item["font"])
button.pressed.connect(func() -> void: set_selected(id, true)); add_child(button); button.set_pressed_no_signal(id == selected_id)
func set_selected(id: String, emit_signal: bool = false) -> void:
_selected_id = id
if emit_signal: preset_selected.emit(id)
func get_selected_id() -> String: return _selected_id
+1
View File
@@ -0,0 +1 @@
uid://dfwkdo5sx6i4h
+25
View File
@@ -0,0 +1,25 @@
class_name PresetSizeToggle
extends HBoxContainer
signal preset_selected(id: String, value: int)
var _presets: Array[Dictionary] = []
var _selected_id: String = ""
var _group: ButtonGroup = ButtonGroup.new()
func configure(presets: Array[Dictionary], selected_id: String, theme: Variant = null) -> void:
for child: Node in get_children(): child.queue_free()
_presets = presets.duplicate(true); _selected_id = selected_id
for item: Dictionary in _presets:
if String(item.get("id","")).is_empty(): continue
var button := Button.new(); button.text = String(item.get("label", item["id"])); button.custom_minimum_size = Vector2(44,44); button.toggle_mode = true; button.button_group = _group
button.pressed.connect(func() -> void: set_selected(String(item["id"]), true)); add_child(button); button.set_pressed_no_signal(String(item["id"]) == selected_id)
if not _presets.is_empty() and _selected_id.is_empty(): _selected_id = String(_presets[0].get("id", ""))
func set_selected(id: String, emit_signal: bool = false) -> void:
for item: Dictionary in _presets:
if String(item.get("id", "")) == id:
_selected_id = id
if emit_signal: preset_selected.emit(id, int(item.get("value", 0)))
return
func get_selected_id() -> String: return _selected_id
func get_selected_value() -> int:
for item: Dictionary in _presets:
if String(item.get("id", "")) == _selected_id: return int(item.get("value", 0))
return 0
+1
View File
@@ -0,0 +1 @@
uid://fx38yavxtiv4
+375
View File
@@ -0,0 +1,375 @@
# Phase 6a — Airborne Momentum, Procedural Air Pose, and Landing Absorption
Status: implemented and user-verified (2026-09-17).
Source proposal: `plans/PHASE_6a_AIRBORNE_LANDING.md`.
## 1. Scope and verified implementation decisions
Phase 6a extends the existing PLAY-only terrain controller in `StickmanRig`. It does not add a
new public action, change saved queue payloads, add a new `RigState`, or alter the navigation
mesh. The feature has three active internal terrain-motion phases plus the disabled value:
```gdscript
enum TerrainMotion { DISABLED, AIRBORNE, GROUNDED, LANDING_IMPACT }
```
`TerrainMotion` replaces `_terrain_airborne` as the terrain-phase authority;
`_terrain_following_enabled` remains the PLAY lifecycle enable flag. `RigState` remains the
authoritative high-level puppet state
(`ANIMATED`, `RAGDOLL`, `RECOVERING`); terrain motion runs only while `state == ANIMATED`.
The approved implementation resolves the design choices as follows:
- Extreme animated falls do **not** automatically become ragdolls in this phase. Auto-ragdoll
would silently change director intent and, because PLAY currently sets `auto_recover = false`,
could strand a rig. Explicit queued/reactive/direct ragdoll requests remain authoritative.
- Airborne lateral contact is a swept stop: move only through the collision-safe fraction and
set horizontal air velocity to zero. There is no bounce, wall slide, or ledge grab.
- The root remains upright on slopes. At impact, both lower-leg targets lock independently to
their own upward terrain samples, so a ramp or stair may produce unequal foot heights while
the torso compresses vertically.
- Queue work cannot start or advance during `LANDING_IMPACT`. Incoming sequential/takeover work
is retained and runs afterward; speech overlays remain non-blocking and continue on their
existing overlay clock. A direct `set_ragdoll(true)` is the one physical preemption and cancels
landing absorption before the normal ragdoll handoff.
- The air pose is additive to a live pose captured at takeoff. It therefore preserves arbitrary
walk/idle/custom marker poses instead of replacing hands and feet with a factory pose.
## 2. Targets and ownership
| Target | Change |
| --- | --- |
| `scripts/stickman_rig.gd` (`StickmanRig`) | Owns the terrain-motion enum, two-axis air velocity, swept horizontal movement, air-pose capture/solve, height-scaled landing impact timeline, independent landing contacts, and queue barrier. |
| `tests/test_phase6a_airborne_landing.gd` | Focused headless regression suite for state transitions, signed takeoff momentum, forward-before-lift air pose, exact restoration, height-scaled landing, impact hand drop, queue barrier, and ragdoll cleanup. It passes 24/24 assertions. |
| `master_rig.tscn` | No change. Existing `IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg,Torso}` are sufficient. |
| `scripts/sandbox_stage.gd` | No behavioral change expected. Existing PLAY order (`begin_terrain_fall()` then `start_queue()`) and EDIT/DIRECT cleanup (`stop_queue()`, disable terrain following, `snap_to_standing()`) remain the lifecycle contract. |
| Layout/theme | No UI, scene-layout, or theme update. |
## 3. Static contracts
The shipped tuning constants sit beside the existing terrain constants. They are production
defaults, not saved settings:
```gdscript
const AIR_DRAG_X := 0.75 # exponential damping coefficient, s^-1
const AIR_POSE_START_SPEED := 240.0 # downward px/s
const AIR_POSE_FULL_SPEED := 1200.0 # downward px/s
const AIR_POSE_RESPONSE := 10.0 # exponential blend response, s^-1
const AIR_HAND_FORWARD := 220.0 # rig-local +X px at full reach
const AIR_HAND_FORWARD_START_SPEED := 80.0 # downward px/s
const AIR_HAND_FORWARD_FULL_SPEED := 520.0 # downward px/s
const AIR_HAND_LIFT := 680.0 # rig-local px upward at full weight
const AIR_HAND_FLAIL_AMPLITUDE := Vector2(20.0, 16.0)
const AIR_FOOT_FLAIL_AMPLITUDE := Vector2(14.0, 10.0)
const AIR_FLAIL_HZ := 12.0
const HARD_LANDING_MIN_FALL_HEIGHT := 80.0 # vertical fall px
const HARD_LANDING_MAX_FALL_HEIGHT := 900.0 # vertical fall px
const LANDING_COMPRESSION_MIN := 32.0 # torso-marker px downward
const LANDING_COMPRESSION_MAX := 160.0
const LANDING_HAND_DROP_MIN := 270.0 # hand-marker px downward
const LANDING_HAND_DROP_MAX := 520.0
const LANDING_COMPRESS_DURATION := 0.08
const LANDING_RECOVER_DURATION := 0.22
```
`AIR_DRAG_X` exponentially damps signed horizontal takeoff velocity; the shipped `0.75` keeps
the arc visibly strong. The main air-pose weight ramps from zero to one over downward speeds
`240..1200` px/s and follows speed changes with response `10`. Hands first reach in canonical
rig-local `+X` over `80..520` px/s, then lift up to `680` px; the root's LEFT mirror makes the
same local pose face screen-left. Hand/foot flail amplitudes are `(x, y)` rig-local pixels and
run at 12 Hz with opposite phase on the two sides.
Hard-landing reaction is driven by vertical fall distance, not impact speed: below 80 px the
contact is soft; from 80..900 px compression and hand drop interpolate linearly; at 900 px or
more they clamp to 160 px torso compression and 520 px downward hand drop. The impact timeline
compresses for 0.08 s and recovers for 0.22 s (0.30 s total).
Existing `TERRAIN_GRAVITY`, `TERRAIN_MAX_FALL_SPEED`, upward-normal filtering, probe fans,
`TERRAIN_SUPPORT_RANGE`, `PELVIS_MAX_OFFSET`, and feet/root coordinate conventions remain the
single sources of truth.
Replace `_terrain_vertical_velocity` with `_terrain_air_velocity: Vector2`. Add:
```gdscript
var _terrain_motion: TerrainMotion = TerrainMotion.DISABLED
var _last_grounded_walk_velocity_x: float = 0.0
var _air_time: float = 0.0
var _air_pose_weight: float = 0.0
var _air_pose_base: Dictionary = {} # marker name -> captured local Vector2
var _landing_elapsed: float = 0.0
var _landing_impact_speed: float = 0.0
var _landing_compression: float = 0.0
var _landing_torso_base: Vector2 = Vector2.ZERO
var _landing_hand_start: Dictionary = {} # hand marker name -> local Vector2
var _landing_contacts: Dictionary = {} # leg marker name -> world Vector2
```
Required private signatures:
```gdscript
func _enter_airborne(horizontal_velocity: float) -> void
func _update_airborne(delta: float, center_probe: Dictionary, solved: Dictionary) -> void
func _apply_air_pose(delta: float) -> void
func _sweep_airborne_horizontal_motion(motion: Vector2) -> Vector2
func _begin_landing(impact_speed: float, center_probe: Dictionary, solved: Dictionary) -> void
func _update_landing_impact(delta: float) -> void
func _finish_landing_impact() -> void
func _cancel_landing_impact(restore_pose: bool) -> void
func _restore_air_pose() -> void
func _is_action_queue_blocked() -> bool
```
Public method signatures and existing signals remain unchanged. No landing signal is added;
landing is an internal locomotion detail, and `action_started`, `action_finished`, `arrived`, and
`queue_finished` must retain their current meanings and cardinality.
## 4. State and frame flow
`_physics_process()` keeps its current broad order, with explicit terrain ownership:
```text
track root momentum
ragdoll rest update
clear one-frame pelvis correction
walking (grounded only; records actual signed X velocity)
terrain controller
AIRBORNE -> swept X, gravity Y, air pose, landing detection
GROUNDED -> existing foot/stance/pelvis solve
LANDING_IMPACT-> locked feet, torso/hand recovery timeline
settle markers
speech clocks (primary clock frozen only by the landing barrier; overlay clock continues)
queue runner (returns immediately while barrier is active)
head pin / flinch / collision-proxy sync
```
Transitions:
```text
DISABLED --begin_terrain_fall()------------------------------> AIRBORNE
AIRBORNE --valid descending center support-------------------> GROUNDED (soft contact)
AIRBORNE --fall distance >= HARD_LANDING_MIN_FALL_HEIGHT-----> LANDING_IMPACT
GROUNDED --center support lost-------------------------------> AIRBORNE
LANDING_IMPACT --0.08 s compress + 0.22 s recover-----------> GROUNDED
any terrain phase --set_terrain_following_enabled(false)----> DISABLED
AIRBORNE/LANDING_IMPACT --set_ragdoll(true)-----------------> cancel terrain pose, then RAGDOLL
```
`begin_terrain_fall()` always starts in `AIRBORNE` with zero air velocity. A rig placed above
ground therefore drops vertically; a rig that walks off a ledge inherits movement from the last
grounded walking frame.
For compatibility, `snap_to_terrain()` remains an alias of `begin_terrain_fall()`, and
`set_terrain_following_enabled(true)` calls `begin_terrain_fall()` only when currently
`DISABLED` (it is otherwise idempotent). Passing `false` always performs the full transition to
`DISABLED` and cleanup described below.
## 5. Airborne momentum and collision
### 5.1 Takeoff velocity
`_update_walking()` records its actual signed displacement each grounded physics frame:
```gdscript
_last_grounded_walk_velocity_x = (global_position.x - before_x) / delta
```
Record zero when walking performs no horizontal displacement. On the first supported-to-
unsupported transition, `_enter_airborne()` receives that value and captures the live limb
marker pose once. It must not recapture on later airborne frames.
Each airborne frame uses stable exponential damping and the existing gravity cap:
```gdscript
_terrain_air_velocity.x *= exp(-AIR_DRAG_X * delta)
_terrain_air_velocity.y = minf(
_terrain_air_velocity.y + TERRAIN_GRAVITY * delta,
TERRAIN_MAX_FALL_SPEED)
```
Apply the collision-safe horizontal displacement before vertical displacement. The sweep must
reuse the already-built animated collision proxy shapes/transforms and exclude every proxy RID
belonging to this rig. Test the desired `Vector2(vx * delta, 0)` against collision mask 1, take
the minimum safe fraction across the valid proxy shapes, move by that fraction, and set `vx = 0`
when the fraction is less than 1. Missing world/proxy data degrades to the requested movement
with one `StickmanRig:` warning rather than crashing. This deliberately covers TerrainBlock,
props, and other animated rig proxies without introducing a second body-size approximation.
There is no airborne navigation update: `_update_walking()` remains paused in `AIRBORNE` and
`LANDING_IMPACT`. The active `walk_to` action stays active, and navigation resumes from the
actual landing X after recovery. Overshooting a waypoint in the air is allowed; the existing
nav/direct steering corrects it after landing.
### 5.2 Swept landing detection
Keep the existing upward-facing TerrainBlock filter. While descending, a valid center surface is
a landing candidate when its signed Y delta is no farther than the normal support range plus the
current frame's downward displacement. This swept allowance prevents a low-frame-rate fall from
crossing a thin ramp between samples. Upward motion or a surface above the accepted support band
does not count as a landing.
On contact, capture `impact_speed = maxf(_terrain_air_velocity.y, 0.0)` **before** clearing air
velocity. Apply the full center support correction immediately, set air velocity to zero, and
stop lateral momentum; Phase 6a does not preserve a slope-tangent ground velocity.
## 6. Procedural air pose and exact restoration
At takeoff capture local positions for `Left_Hand`, `Right_Hand`, `Left_Leg`, and `Right_Leg`.
The walk animation is already paused by the terrain controller, so these values are stable and
also preserve a non-locomotion/custom pose.
Compute:
```gdscript
target_weight = smoothstep(
AIR_POSE_START_SPEED,
AIR_POSE_FULL_SPEED,
maxf(_terrain_air_velocity.y, 0.0))
_air_pose_weight = lerpf(
_air_pose_weight,
target_weight,
1.0 - exp(-AIR_POSE_RESPONSE * delta))
phase = TAU * AIR_FLAIL_HZ * _air_time
```
Hands receive an upward/away-from-center drag offset plus elliptical sine/cosine flutter. The
right hand uses `phase + PI`, so the hands are anti-phased. Feet receive only the smaller flutter
offsets, also anti-phased. All offsets are multiplied by `_air_pose_weight` and are assigned as
`captured_base + offset` each frame (never accumulated). Because offsets are rig-local, the root
X mirror naturally mirrors the effect for LEFT without side-specific world-space code.
On a soft landing, restore all four captured marker positions exactly and clear the snapshot. On
an impact landing, feet switch immediately to terrain contacts; hands blend from their touchdown
positions back to their captured values across the complete 0.30 s impact timeline. Completion
writes exact captured values before clearing state. No residual procedural offset may survive
landing, terrain disable, `snap_to_standing()`, or ragdoll entry.
## 7. Landing absorption and slopes
For `_air_fall_distance < HARD_LANDING_MIN_FALL_HEIGHT`, transition directly to `GROUNDED`,
restore the air pose, and resume a paused walk animation. No queue barrier or compression is
created.
Otherwise map the clamped vertical fall distance linearly from
`[HARD_LANDING_MIN_FALL_HEIGHT, HARD_LANDING_MAX_FALL_HEIGHT]` to
`[LANDING_COMPRESSION_MIN, LANDING_COMPRESSION_MAX]` and independently to
`[LANDING_HAND_DROP_MIN, LANDING_HAND_DROP_MAX]`, then enter `LANDING_IMPACT`. The measured
downward impact speed is captured for the landing event, but does not select the reaction depth.
At entry:
1. Keep the walk animation paused.
2. Capture the current `IK_Targets/Torso.position` as `_landing_torso_base`.
3. Capture each valid per-foot terrain sample independently in world space. If one foot fan is
invalid, use that foot's X with the accepted center surface Y as its deterministic fallback.
4. Mark both foot contacts locked and write the live leg targets to those world contacts.
During the first 0.08 s, move only the torso marker downward from base to the computed depth with
a quadratic ease-out. During the following 0.22 s, move it back to base with sine-out. Reassert
both foot target global positions every frame; the root stays upright and stationary. Hands begin
at their touchdown positions, move sharply downward by the height-scaled hand-drop amount during
compression, and blend back to their captured takeoff positions over the full 0.30 s timeline.
Thus a slope/stair keeps two unequal ankle heights and lets TwoBoneIK absorb the torso motion as
knee flexion. Do not rotate the root to the surface normal and do not average the two foot
heights.
At completion, write the exact torso base and hand bases, clear landing contacts/timers, change
to `GROUNDED`, then resume the paused walk animation. The normal Phase 6 stance/swing solver owns
the next frame.
This is implemented as a physics-time elapsed-state solver, not a `Tween`. That keeps queue pause,
terrain disable, ragdoll interruption, and headless tests deterministic and avoids stale tween
callbacks writing markers after a mode change.
## 8. Queue and interruption semantics
`_is_action_queue_blocked()` is true only in `LANDING_IMPACT`.
- `_update_runner(delta)` returns before its phase match while blocked. `WAITING` time does not
decrement; `NONE` does not start the next item; `WALKING` cannot finish; ragdoll/recover items
cannot start.
- The primary speech clock is frozen while blocked. Overlay speech remains independent and
continues to count down/display as today.
- `enqueue_reactive()` may still update `_run_queue`. Sequential/takeover items are inserted with
the existing authored-future preservation rules but cannot start until landing completes.
If cancellation touches a walk pose during impact, `_cancel_current_primary()` must skip marker
restoration; the landing solver owns those markers until release.
- `stop_queue()` cancels queue work but does not cancel physical landing while PLAY continues.
- Direct `walk_to()` during `LANDING_IMPACT` is rejected with the normal `StickmanRig:` warning;
queued walking waits behind the barrier.
- Direct `set_ragdoll(true)` cancels landing state with `restore_pose = false`, preserves the
current solved impact pose, and then performs the existing instant ragdoll handoff. Queued or
reactive ragdoll actions wait behind the landing barrier like other primary actions.
- `set_terrain_following_enabled(false)` and `snap_to_standing()` cancel the impact, restore/clear
procedural markers, clear velocity and contacts, and leave no deferred completion work.
Existing action signals do not fire for the physical landing itself. A held action emits exactly
one normal `action_finished` when it later completes, and `queue_finished` still emits once only
after the runtime lane is exhausted.
## 9. Null safety and interaction boundaries
- Missing required IK markers disable only the corresponding procedural channel and issue a
prefixed warning; terrain gravity and queue execution must not crash.
- Collision-proxy sweeps are read-only physics queries. They do not replace proxy synchronization
or Phase 4.5 collision reactions.
- RAGDOLL and RECOVERING never run the air/landing solvers. Existing ragdoll momentum capture sees
the root's actual air movement through `_track_momentum()`.
- The feature is PLAY-only through the existing stage lifecycle. It never changes authored
transforms, `.scene` serialization, action registry payloads, or editor UI.
## 10. Implementation validation
The implemented feature was syntax-checked and exercised with the focused Phase 6a suite:
```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_phase6a_airborne_landing.gd" --verbose
```
Results on 2026-09-17:
- `scripts/stickman_rig.gd` headless syntax check: passed.
- `tests/test_phase6a_airborne_landing.gd`: **24/24 assertions passed**.
- Existing `tests/test_phase4b_terrain.gd`: **8/8 assertions passed**.
- User editor verification: forward-before-lift arm motion, stronger curved momentum, height-scaled
hard landing, impact arm drop, and clean handoff/reset behavior confirmed.
The focused suite covers:
1. `DISABLED -> AIRBORNE`, signed takeoff velocity, full-speed pose offsets, exact air-pose
restoration, and grounded idle velocity reset.
2. Forward hand reach before lift, subsequent hand lift, exponential air drag, and direct ragdoll
cleanup from airborne motion.
3. Minimum and maximum fall-height mapping to compression, queue blocking during impact, deepest
compression at 0.08 s, sharp downward hand reaction, stale-contact rejection, exact torso/hand
restoration at 0.30 s, and barrier release.
Broader terrain, collision-proxy, queue, and slope regressions remain covered by the existing
Phase 4b/4.5 suites and the manual editor scenarios below; they are not duplicated in the focused
24-assertion script.
Manual Godot-editor verification after automated tests:
- walk off both sides of a raised platform and observe a curved, decaying lateral trajectory;
- land at low, medium, and maximum fall speeds on flat ground, both ramp directions, and a stair
edge; confirm visible air flail, knee compression, independent feet, and no marker snap;
- queue `walk_to -> wait -> speak`, fall during the walk, and confirm strict order after recovery;
- inject reactive sequential and overlay actions during compression; and
- switch PLAY to DIRECT/EDIT and trigger ragdoll during compression, confirming clean immediate
ownership handoffs and no later pose write.
## 11. Architectural alternatives and deferred work
- A `CharacterBody2D` root would provide `move_and_slide()` and richer wall/slope response, but it
conflicts with the current Node2D rig root, separately synchronized ten-part proxies, ragdoll
handoff, and authored transform restoration. Reuse proxy-shape sweeps for this bounded phase.
- Adding `AnimationTargets/*_Base` nodes for both hands would generalize layered animation
ownership, but takeoff capture already preserves the live custom pose and avoids a scene/clip
migration. Revisit only if procedural arm layers must run while the base animation keeps playing.
- Automatic ragdoll escalation, bounce, sliding, root slope tilt, air control, and ledge grabs are
intentionally deferred; each changes director-visible behavior beyond landing polish.
- Ten proxy casts per airborne rig per physics frame are acceptable at current sandbox scale but
are recorded as a scaling trade-off in `docs/tech_debt_and_optimizations.md`.
+536
View File
@@ -0,0 +1,536 @@
# Phase 7 UI Polish — Architecture Specification
Status: **proposed; awaiting user approval**
Source plan: `plans/PHASE_7_UI_POLISH.md`
Target: Godot 4.7.1 Sandbox Stage only. The configured Stickman Editor main scene is not changed.
## 1. Scope and current-state audit
Phase 7 is an incremental polish pass, not a rewrite of `SandboxStage`. The stage scene remains deliberately small and the runtime UI remains owned by `SandboxStage._build_ui()`. Existing tests also access several stage fields directly, so moving the full UI into a new scene would add migration risk without improving the requested behavior.
### Already implemented; preserve and restyle
| Plan item | Existing implementation |
|---|---|
| Three mode controls | `_mode_buttons` in `scripts/sandbox_stage.gd` |
| Mode badge, status text, live world coordinates | `_mode_badge`, `_status_label`, `_status_cursor_coords` |
| Mode cursor and target reticle | `_apply_cursor()`, `_build_action_cursor()` |
| Cursor tooltip and direct-action trajectory | `_tooltip`, `StagePlacementOverlay.set_action_trajectory()` |
| Terrain AABB stride, Bresenham painting, Shift lock | `_terrain_stride()`, `_bresenham_cells()`, `_update_terrain_drag()` |
| Three-state terrain ghosts | `_classify_cell()`, `_refresh_terrain_ghosts()` |
| Asset selector with thumbnail cards and dimmer | `asset_selector.tscn`, `asset_selector.gd`, `_selector_dim` |
| Sequential off-screen thumbnail rendering and disk cache | stage `_thumbnail_queue`; `scripts/thumbnails/*` |
| Popup and editor font overrides | `_apply_popup_theme()` and widget `apply_font()` methods |
These behaviors are integration points, not new Phase 7 systems. Their public behavior and existing headless tests must continue to pass.
### In Phase 7
1. Replace the stage-local JSON parsing and scattered style constants with a validated, cached `SandboxTheme` service and schema version 2.
2. Give the existing mode switcher, viewport frame, badge, status bar, tooltips, popups, dialogs, selector, and overlays a coherent theme-driven appearance.
3. Replace the top-row spawn text buttons with a scrollable graphical card palette. Clicking keeps the existing persistent placement behavior; dragging a card performs one placement.
4. Harden the existing thumbnail cache with a memory tier and stale-request protection. Rendering remains serialized on the main thread.
5. Add reusable color, size, font-preset, and emoji controls, with stable typed APIs, ready for later parameter dialogs.
### Explicitly deferred
- Speech bubble geometry/styles, custom speech rendering, and changes to persisted `speak` actions. Section 10 defines the future contract, but Phase 7 does not emit or consume it.
- Embedding the four new reusable widgets into the Speak dialog. That is the first consumer in the future speech-customization phase.
- True background-thread thumbnail rendering. Godot scene/viewport rendering remains main-thread work; Phase 7 limits generation to one job at a time and avoids duplicate/stale work.
- Dragging cards out of the modal `AssetSelector`. Direct drag uses the currently selected Prop or Stickman card in the persistent palette; the modal remains click-to-select.
- New bitmap icon art. Phase 7 uses registry-provided emoji glyphs plus existing generated thumbnails. Art assets can replace them later without changing card APIs.
- A full `.tscn` conversion of the code-built Sandbox UI.
## 2. Files
### Add
- `scripts/sandbox_theme.gd` — `class_name SandboxTheme`, schema validation, legacy aliases, typed accessors, font/style/theme caches.
- `scripts/ui/asset_card.gd` — `class_name AssetCard`, reusable palette/selector card and pointer-drag threshold handling.
- `scripts/ui/color_swatch_selector.gd` — `class_name ColorSwatchSelector`.
- `scripts/ui/preset_size_toggle.gd` — `class_name PresetSizeToggle`.
- `scripts/ui/font_preset_picker.gd` — `class_name FontPresetPicker`.
- `scripts/ui/emoji_quick_grid.gd` — `class_name EmojiQuickGrid`.
- `tests/test_phase7_theme.gd` — schema, fallback, alias, and resource/cache behavior.
- `tests/test_phase7_widgets.gd` — widget data contracts and signals.
- `tests/test_phase7_palette.gd` — card activation/drag state, mode visibility, and placement behavior.
The reusable controls are script-built `Control` subclasses, not new scenes. They have no project-specific node paths and can be instantiated in any popup.
### Change
- `sandbox_theme.json` — migrate the committed theme to schema `2.0`.
- `scripts/sandbox_stage.gd` — own `SandboxTheme`; build/stylize the chrome and palette; route card drag input; use theme accessors; apply theme to popups/dialogs; add thumbnail request generation tokens.
- `scripts/stage_spawner.gd` — expose registry-backed palette descriptors without exposing `_registry`.
- `scripts/asset_selector.gd` — build cells with `AssetCard`; accept a `SandboxTheme`; keep current signals and pagination.
- `scenes/asset_selector.tscn` — name layout containers used for theme spacing/margins and remove hard-coded visual values now owned by the theme. Node unique names and popup behavior remain compatible.
- `scripts/thumbnails/thumbnail_cache.gd` — add an in-memory texture tier and versioned cache API while preserving current path helpers.
- `scripts/stage_placement_overlay.gd` — accept resolved overlay colors/metrics through one style method; drawing/input behavior is unchanged.
- `scripts/stage_director_visuals.gd` — consume a resolved style dictionary rather than reading raw schema internals; preserve `set_style(Dictionary)` as a compatibility wrapper.
- `scripts/action_editor.gd`, `scripts/rule_editor.gd`, `scripts/queue_panel.gd`, `scripts/rule_panel.gd` — add `apply_theme(theme: SandboxTheme)` wrappers and retain current `apply_font(...)` methods for existing tests/callers.
- Existing Phase 3b/3c/4b tests only where assertions must reflect the always-visible mode frame or v2 key names; behavioral coverage is not removed.
### Not changed in Phase 7
- `scenes/sandbox_stage.tscn`
- `scripts/action_registry.gd`
- `scripts/stage_scene_io.gd`
- `scripts/stickman_rig.gd`
- `scripts/stickman_speech_bubble.gd`
## 3. `sandbox_theme.json` schema 2.0
The JSON remains hand-editable. Colors are HTML strings (`#RRGGBB` or `#RRGGBBAA`), resource paths are strings, dimensions are logical screen pixels, and preset IDs are stable lowercase strings.
```json
{
"version": "2.0",
"global": {
"font_preset": "ui",
"font_size": 18,
"text_color": "#F5F7FA",
"muted_text_color": "#AAB2C0",
"bg_color": "#171B24F2",
"surface_color": "#252B38FA",
"border_color": "#FFFFFF33",
"border_width": 1,
"corner_radius": 10,
"padding": 8,
"spacing": 8
},
"fonts": {
"ui_font": "",
"emoji_font": "",
"ui_font_bold": "",
"ui_font_italic": "",
"presets": {
"ui": { "label": "Standard", "path": "" },
"comic": { "label": "Comic", "path": "" },
"pixel": { "label": "Pixel", "path": "" },
"scary": { "label": "Scary", "path": "" }
},
"sizes": {
"action_popup": 24,
"action_popup_emoji": 22,
"assignment_badge": 20,
"rule_label": 16,
"status_pill": 16,
"tooltip": 18,
"queue_panel": 18,
"rule_panel": 18,
"action_editor": 18,
"rule_editor": 18,
"panel_row": 16,
"panel_title": 18,
"asset_label": 14,
"asset_badge": 12
},
"flags": {
"panel_title_bold": true,
"rule_label_bold": false,
"badge_bold": true,
"action_popup_bold": false,
"action_popup_italic": false
}
},
"mode_colors": {
"edit": "#22C6FF",
"direct": "#FFB300",
"play": "#33DD77",
"guide": "#22C6FF"
},
"grid": { "snap_size": 15.0 },
"chrome": {
"style_defaults": { "bg_color": "#171B24F2", "corner_radius": 10 },
"top_bar": { "height": 48 },
"status_bar": { "height": 32 },
"mode_switcher": { "segment_min_width": 104 },
"mode_frame": { "border_width": 3 },
"mode_badge": { "corner_radius": 12, "padding": 10 },
"tooltip": { "bg_color": "#101010E6", "border_width": 1 }
},
"popups": {
"style_defaults": { "bg_color": "#202633FA", "border_color": "#FFFFFF33", "corner_radius": 12, "padding": 12 },
"action_menu": { "font_size": 24 },
"dialog": {},
"asset_selector": { "backdrop_alpha": 0.55 }
},
"palette": {
"style_defaults": { "bg_color": "#252B38FA", "corner_radius": 10, "padding": 6 },
"dock_width": 116,
"card_size": [96, 94],
"icon_size": 30,
"drag_threshold": 8,
"selected_border_width": 3
},
"overlays": {
"style_defaults": { "line_width": 2, "dash_length": 8, "dash_gap": 5 },
"terrain_guide": { "color": "#22C6FF" },
"valid": { "color": "#4DE66680" },
"same_type": { "color": "#FFFFFF26" },
"invalid": { "color": "#FF4D4D80" }
},
"widgets": {
"swatches": ["#FFFFFF", "#111111", "#22C6FF", "#FFB300", "#33DD77", "#FF4D4D"],
"size_presets": [
{ "id": "small", "label": "Small", "value": 20 },
{ "id": "medium", "label": "Medium", "value": 28 },
{ "id": "huge", "label": "HUGE", "value": 40 }
],
"emoji": ["😀", "😂", "😍", "😮", "😢", "😡", "👍", "❤️"]
},
"speech_bubbles": {
"style_defaults": {
"font_preset": "ui",
"size_preset": "medium",
"color": "#FFFFFFFF",
"text_color": "#111111FF",
"border_color": "#00000099",
"border_width": 2,
"corner_radius": 8,
"padding": 14
},
"styles": {
"standard": { "label": "Standard", "geometry": "rounded" },
"yell": { "label": "Yell", "geometry": "burst" },
"thought": { "label": "Thought", "geometry": "cloud" },
"whisper": { "label": "Whisper", "geometry": "dashed" }
}
}
}
```
Array values such as `card_size` must contain exactly two finite numbers. Unsupported or malformed values fall back independently; one bad field does not discard the rest of the file.
### Resolution order
For a component such as `popups.action_menu`, the service deep-merges, from lowest to highest priority:
1. Hard-coded `SandboxTheme.DEFAULT_CONFIG` value.
2. Loaded `global` value.
3. Loaded subsystem `popups.style_defaults` value.
4. Loaded component `popups.action_menu` value.
Font resolution is: component `font_preset`/explicit path → subsystem default → `global.font_preset` → `fonts.ui_font` → `ThemeDB.fallback_font`. An unavailable named preset falls back to `ui`. Emoji font resolution is `fonts.emoji_font` → resolved UI font → `ThemeDB.fallback_font`.
### Validation, warnings, and caching
- Missing, unreadable, malformed, or non-dictionary JSON produces one non-blocking `push_warning` and a complete default config.
- A wrong type, non-finite number, bad color, or missing/non-Font resource warns once per key and falls back only that key.
- Unknown keys are retained in `raw_config()` for forward compatibility but are ignored by typed accessors.
- Fonts are loaded once per normalized non-empty resource path in `_font_cache`.
- Resolved `StyleBoxFlat` templates are cached by the canonical resolved style dictionary. `stylebox()` returns a duplicate so mode-specific color mutation cannot leak into another control.
- The generated Godot `Theme` is cached. Call `invalidate_caches()` only after an explicit reload; Phase 7 does not add live file watching.
- No theme resource failure may prevent Sandbox startup.
## 4. `SandboxTheme` public contract
```gdscript
class_name SandboxTheme
extends RefCounted
const SCHEMA_VERSION: String = "2.0"
const DEFAULT_PATH: String = "res://sandbox_theme.json"
func load_from(path: String = DEFAULT_PATH) -> bool
func raw_config() -> Dictionary
func value(path: String, fallback: Variant = null) -> Variant
func color(path: String, fallback: Color) -> Color
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float
func integer(path: String, fallback: int, minimum: int = -2147483648, maximum: int = 2147483647) -> int
func font(component_path: String = "", preset_id: String = "ui") -> Font
func emoji_font() -> Font
func style(component_path: String) -> Dictionary
func stylebox(component_path: String) -> StyleBoxFlat
func control_theme() -> Theme
func swatches() -> Array[Color]
func size_presets() -> Array[Dictionary]
func font_presets() -> Array[Dictionary]
func emoji_items() -> Array[String]
func invalidate_caches() -> void
```
`raw_config()` returns a deep duplicate. The preset array entries returned by `size_presets()` are `{id: String, label: String, value: int}`; font preset entries are `{id: String, label: String, font: Font}`. Invalid entries are omitted, while a built-in medium size and UI font are always available.
No reload signal is needed in Phase 7 because the theme loads once before UI construction. A later live-theme editor may add `theme_reloaded()` without changing the accessor API.
## 5. Palette descriptors and controls
### `StageSpawner` API
Add:
```gdscript
func get_palette_entries() -> Array[Dictionary]
```
Each returned deep-duplicated entry has this shape:
```gdscript
{
"placement_id": String, # existing id accepted by set_placement_mode()
"kind": String, # "terrain" | "prop" | "stickman" | "area"
"label": String,
"icon": String, # emoji fallback, not a resource path
"category": String, # visible badge/group label
"opens_selector": bool
}
```
Registry defaults are Ground/🧱/Terrain, Ramp/📐/Terrain, Step/🪜/Terrain, Prop/📦/Props, Stickman/🧍/Cast, and Area/🎯/Events. `get_spawnable_ids()` and `get_label()` remain unchanged.
### `AssetCard`
```gdscript
class_name AssetCard
extends Button
signal activated(payload: Dictionary)
signal drag_started(payload: Dictionary)
func configure(payload: Dictionary, icon_texture: Texture2D = null, theme: SandboxTheme = null) -> void
func set_thumbnail(texture: Texture2D) -> void
func set_selected(selected: bool) -> void
func payload() -> Dictionary
```
The payload is deep-copied on input and signal emission. The card owns icon/thumbnail, label, and optional category badge children. It emits `activated` on a release that stayed below `palette.drag_threshold`; after the threshold it emits `drag_started` once and suppresses activation. It does not mutate stage or spawner state directly.
### Reusable widget APIs
```gdscript
# ColorSwatchSelector
signal color_selected(color: Color)
func configure(colors: Array[Color], selected: Color, theme: SandboxTheme = null) -> void
func set_selected(color: Color, emit_signal: bool = false) -> void
func get_selected() -> Color
# PresetSizeToggle
signal preset_selected(id: String, value: int)
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
func set_selected(id: String, emit_signal: bool = false) -> void
func get_selected_id() -> String
func get_selected_value() -> int
# FontPresetPicker
signal preset_selected(id: String)
func configure(presets: Array[Dictionary], selected_id: String, theme: SandboxTheme = null) -> void
func set_selected(id: String, emit_signal: bool = false) -> void
func get_selected_id() -> String
# EmojiQuickGrid
signal emoji_selected(text: String)
func configure(items: Array[String], columns: int = 8, theme: SandboxTheme = null) -> void
```
All configure methods replace prior children, tolerate empty inputs, use a `ButtonGroup` for exclusive preset selection, expose keyboard focus, and meet a minimum 44×44 logical-pixel pointer target.
## 6. Node ownership and layout
`SandboxStage` continues to create one `CanvasLayer` named `UI`. Its runtime ownership becomes:
```text
SandboxStage (Node2D; state and orchestration)
├── GridLayer / World / GhostHolder / PlacementOverlay / DirectorVisuals (world space)
└── UI (CanvasLayer)
├── ModeFrame (Panel; full rect; mouse ignore)
├── TopBar (PanelContainer)
│ └── FileMenu + ModeSwitcher + edit/direct utility controls
├── PaletteDock (PanelContainer; left, below TopBar and above StatusBar)
│ └── ScrollContainer/PaletteCards (VBoxContainer of AssetCard)
├── ModeBadge (PanelContainer; mouse ignore)
├── ActionTooltip (Label; mouse ignore)
├── BottomStatusBar (PanelContainer)
├── SelectorDim (ColorRect; visible only with selector)
└── Popups/dialogs/selectors/editors
```
`PaletteDock` is visible only in EDIT. Existing grid/snap/size controls remain in `TopBar`; spawn buttons leave the top row. `_palette_buttons` is retained and maps placement IDs to `AssetCard` (a `Button` subclass), preserving callers that inspect `button_pressed`/`visible`.
The mode segments share a `ButtonGroup`, have adjacent rounded styles, and retain `_on_mode_segment_toggled`. `_mode_frame` is visible in all three modes and uses the active accent; it remains behind all chrome and ignores mouse input. The badge remains below the top bar and displays the existing text.
The stage assigns `theme.control_theme()` to the UI root and explicitly to Window-derived popups/dialogs that do not reliably inherit across a native/embedded window boundary. Component styleboxes are then applied only where a named component override is needed.
## 7. Input and signal flow
### Click placement
1. `AssetCard.activated(payload)` reaches `SandboxStage._on_palette_card_activated(payload)`.
2. Terrain/Area calls existing `set_placement_mode(placement_id)`.
3. Prop/Stickman opens the existing selector; selecting an entry updates the spawner and arms the existing `prop`/`stickman` placement ID.
4. LMB remains repeated placement and RMB/Escape cancels, matching existing behavior.
### Direct card drag
Godot Control drag-and-drop is not used because the drop surface is a `Node2D` world and a full-screen `Control` would compete with existing `_unhandled_input` selection/gizmo routing.
1. `AssetCard.drag_started(payload)` calls `_begin_palette_drag(payload)`.
2. The stage stores `_palette_drag_payload`, snapshots the prior placement ID, resolves current Prop/Stickman selection, and creates the normal collision-disabled placement ghost.
3. While a palette drag is active, `SandboxStage._input` handles mouse motion and LMB release before pan/zoom routing. Tooltip/modal/rule input is not entered.
4. A release is valid only inside the stage viewport and outside visible interactive chrome (`gui_get_hovered_control() == null` or a mouse-ignore overlay). Valid release places exactly one object at `_viewport_to_world(event.position)`, using existing snap/spawn/finalize/signals/dirty-state code.
5. Release over UI, RMB, Escape, mode change, selector open, or focus loss cancels and frees the drag ghost.
6. A card drag is one-shot and restores the previous persistent placement selection. It never starts terrain line-painting; terrain drag-painting still starts from a stage press after click-arming the tool.
Add private state and helpers in `sandbox_stage.gd`:
```gdscript
var _palette_drag_payload: Dictionary = {}
var _palette_drag_previous_id: String = ""
func _on_palette_card_activated(payload: Dictionary) -> void
func _begin_palette_drag(payload: Dictionary) -> void
func _update_palette_drag(viewport_pos: Vector2) -> void
func _finish_palette_drag(viewport_pos: Vector2) -> void
func _cancel_palette_drag() -> void
func _is_stage_drop_position(viewport_pos: Vector2) -> bool
```
Priority while active is palette drag → modal suppression → pan/zoom → existing world tools. Existing rule/capture/terrain/gizmo priorities remain unchanged when no palette drag is active.
## 8. Thumbnail pipeline
The existing renderer nodes and queue remain stage-owned. Do not introduce threads.
Extend `ThumbnailCache` with:
```gdscript
func get_texture(cache_path: String) -> Texture2D
func store_texture(cache_path: String, texture: Texture2D) -> Error
func forget_memory(cache_path: String) -> void
func clear_memory() -> void
```
`get_texture()` checks an in-memory dictionary, then PNG, and memoizes success. `store_texture()` memoizes and persists. Existing `load_png()`/`save_png()` remain as compatibility wrappers.
The stage increments `_thumbnail_generation: int` whenever selector entries change or the selector closes. Each queued job carries `{generation, kind, entry, cache_path}`. After each `await`, results update the selector only when the generation still matches, the selector is open, and the entry is still present. Cache writes may complete for a stale UI generation because they remain useful. Queue keys prevent duplicate work in the same generation. One job at a time preserves frame stability and the existing renderer assumptions.
## 9. Overlay, popup, and dialog integration
- `StagePlacementOverlay.apply_style(style: Dictionary)` receives resolved guide/valid/invalid metrics. Existing public setters and screen-constant line math remain intact.
- The three terrain ghost modulates come from `overlays.valid`, `overlays.same_type`, and `overlays.invalid`; classification values and commit rules do not change.
- `StageDirectorVisuals.apply_style(style: Dictionary, fonts: Dictionary)` receives already-resolved sizes/colors/fonts. Keep `set_style(Dictionary)` as a legacy wrapper through Phase 7 tests.
- All PopupMenus use `popups.action_menu`; Accept/Confirmation/File dialogs use `popups.dialog`; `AssetSelector` uses `popups.asset_selector`.
- `_apply_popup_theme()` remains temporarily as a wrapper around `SandboxTheme` so existing direct tests do not break.
- Tooltip positioning continues clamping to the visible viewport. Palette dock bounds are also treated as UI, so a tooltip never becomes a valid world drop region.
## 10. Future speech customization contract (blueprint only)
When implemented in a later phase, a flat queue action will optionally carry one nested visual object:
```gdscript
{
"type": "speak",
"text": String,
"duration": float,
"stop_current": bool, # optional, existing
"visual": { # optional; absent means theme defaults
"font_preset": String,
"size_preset": String,
"color": String, # HTML RGBA
"bubble_style": String # standard/yell/thought/whisper
}
}
```
Rule actions will store the same `visual` dictionary under their existing `params`. `ActionRegistry.to_rule_action()`/`from_rule_action()` will copy it unchanged, `StageSceneIO` will validate known scalar fields while preserving absence, and `SpeechBubble.show_text()` will eventually accept a style dictionary. Unknown preset/style IDs must resolve to theme defaults rather than invalidating the action.
Phase 7 only reserves the theme IDs and supplies the reusable controls. It deliberately does not modify save files or runtime speech behavior.
## 11. Compatibility and migration
- Theme schema `1.0`, missing version, and `2.0` are accepted. Unsupported future major versions warn and use recognized keys plus defaults.
- Legacy aliases are applied before validation:
- `mode_colors.edit_accent/direct_accent/play_accent/guide_line` → `mode_colors.edit/direct/play/guide`.
- Existing flat `fonts.*_font_size` keys → matching `fonts.sizes.*` keys.
- `fonts.action_popup.{size,bold,italic}` → `fonts.sizes.action_popup` and `fonts.flags.action_popup_*`.
- Existing bold flags and `assignment_badge_radius` retain their current meaning.
- A v2 key wins when both v2 and its legacy alias exist.
- No automatic write-back of user theme files occurs.
- Existing `user://sandbox_settings.json` values win over theme defaults. If a settings key is absent, the loaded theme value—not the old hard-coded constant—is used.
- `.scene` serialization remains byte-contract compatible because Phase 7 changes no scene payload fields.
- Existing public stage signals (`mode_changed`, `object_placed`, selection/deletion signals), `AssetSelector` signals, placement IDs, and action dictionaries remain unchanged.
- Existing private test-visible fields remain present. `_palette_buttons` values remain `Button`-compatible.
- Missing emoji/custom fonts degrade to `ThemeDB.fallback_font`; labels stay usable even if a glyph renders as a fallback box.
## 12. Delivery phases and acceptance criteria
### 7.1 Theme service and schema
- `SandboxTheme` loads the committed v2 file and produces complete resolved values.
- Missing/malformed files, invalid individual keys, and missing font paths never abort startup.
- v1 aliases reproduce current sizes/colors.
- Font/style/theme caches reuse resources; caller mutation of one returned stylebox does not affect another.
- Existing grid setting precedence is preserved with the corrected theme fallback for missing settings keys.
### 7.2 Chrome and mode polish
- Mode segments are one exclusive group with clear active accent and keyboard focus.
- Frame, badge, and status bar update immediately in EDIT/DIRECT/PLAY; the frame uses each mode color in every mode.
- Existing cursor, tooltip, target trajectory, status coordinate, and Escape behavior pass unchanged.
- At 1280×720, top/bottom chrome and the palette do not cover each other; at smaller windows, the palette scrolls instead of overflowing.
### 7.3 Reusable widgets
- All four controls configure from the theme contract, rebuild safely, emit exactly once per user choice, and expose their selected value.
- Empty/malformed preset arrays fall back safely.
- Every interactive item has keyboard focus and a minimum 44×44 target.
### 7.4 Palette, selector, and thumbnail cache
- Six registry-driven graphical cards appear only in EDIT and preserve `_palette_buttons` state semantics.
- Click-arming, modal Prop/Stickman selection, repeated placement, RMB/Escape cancellation, and mode transitions behave as before.
- Dragging a palette card over the stage places exactly one item; release over UI/cancel places none and leaves no ghost.
- The selector retains pagination, dimming, Browse/Refresh, generated previews, and click selection.
- Cached textures are reused from memory/disk, duplicate jobs are suppressed, and stale async completion never updates a closed or repopulated selector.
### 7.5 Popup/overlay consistency and regression
- Popup menus, dialogs, panels, selector cards, tooltip, terrain guides, ghosts, and action trajectories use the resolved theme.
- Terrain painting still uses template AABB stride, Bresenham cells, three classifications, Shift axis lock, and skip-on-conflict commits.
- Queue/rule/action editor behavior and font APIs remain compatible.
- No changes occur to speech rendering or saved action schemas.
## 13. Validation strategy
### Headless tests
Run the new suites plus the regression suites most directly touched:
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_theme.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_widgets.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase7_palette.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_stage.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_logic.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b_terrain.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase4b1_fixes.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_library.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3b_ui_fixes.gd
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --path "C:\Godot4\stickman" --script res://tests/test_phase3c_bugfix.gd
```
Run `--check-only` on every added/changed `.gd` script before those suites. Tests should exercise public methods/signals where possible; source-text assertions are only a last resort for engine behavior that cannot be driven headlessly.
### User verification in Godot (spec-pipeline hand-off)
After implementation and green headless tests, stop and ask the user to verify:
1. Resize the Sandbox Stage from 1280×720 down to a small window and confirm chrome/palette remain usable.
2. Switch all modes and confirm frame, badge, segment, status, cursor, and visibility changes.
3. Click-arm and drag-drop each card type; cancel by UI release, RMB, Escape, and a mode change.
4. Open Prop and Stickman selectors twice to confirm thumbnail reuse and no visible stale updates.
5. Exercise Direct-mode Walk To and rule targets to verify tooltip/trajectory/reticle layering.
6. Terrain-paint empty, same-type, and conflicting cells with and without Shift.
7. Open Speak/Wait/Rename, queue/rule editors, and context menus to judge font size, contrast, and emoji fallback.
Do not update README/project documentation until the user explicitly confirms the feature works.
## 14. Design decisions and trade-offs requiring approval
1. **Keep the Sandbox UI code-built.** This minimizes churn in a 4,000-line controller and preserves existing test-visible fields. Replacing it with a complete UI scene is deferred.
2. **Use a dedicated theme service, not raw dictionary reads or a JSON-to-Theme singleton.** The stage owns one instance, making tests deterministic and avoiding global state; a cached Godot `Theme` still supplies normal Control inheritance.
3. **Use custom card pointer capture for world drops.** A full-screen Control drop target risks intercepting the existing Node2D world input stack. Drag placement is intentionally one-shot; click placement remains persistent.
4. **Show the mode frame in all modes.** The plan says it reflects the active mode; the current implementation shows it only in DIRECT. Phase 7 changes that behavior.
5. **Reserve but do not implement speech customization.** Theme presets and reusable controls land now, while action persistence/runtime rendering stay unchanged until a dedicated speech phase.
6. **Serialized, cached thumbnail work is preferred over threading.** It is safer with Godot viewports and already matches the working architecture; memory caching and generation tokens address the actual stutter/stale-update risks.
+501
View File
@@ -0,0 +1,501 @@
# Phase 8 — Rig Relocation and Runtime Component Modularization
Status: architecture proposed; awaiting user approval before implementation.
Source proposal: `plans/PHASE_8_RIG_MODULARIZATION.md`.
## 1. Outcome and architectural correction
Move the authored rig scene from `res://master_rig.tscn` to
`res://scenes/stickman_rig.tscn` while preserving its existing scene UID
(`uid://dr5ef3l2var2s`). Split the 3,159-line `StickmanRig` controller into three child
components, but keep `StickmanRig` as the only public facade used by the factory, stage,
harnesses, scene I/O, and existing gameplay code.
One boundary in the source plan should change before implementation: navigation and
`walk_to()` belong in `StickmanLocomotion`, not `StickmanActionRunner`. Walking, terrain
following, ledge momentum, landing, animation pause/resume, and root displacement share one
state machine and one `AnimationPlayer`. Splitting path following from terrain motion would
create two writers for the rig transform and require a per-frame synchronization protocol.
`StickmanActionRunner` should orchestrate actions and call the locomotion API; it should not
implement movement.
The extraction is structural only. It must not change movement tuning, recovery math, action
payloads, signal timing, saved scene data, or editor UI.
## 2. Targets and ownership
| Target | Ownership after Phase 8 |
| --- | --- |
| `scenes/stickman_rig.tscn` | Renamed rig scene. Keeps the existing `Master`, `Body`, `Skeleton2D`, `IK_Targets`, `AnimationTargets`, `AnimationPlayer`, and `AnimationTree` paths. Adds a nonvisual `RigComponents` branch. |
| `scripts/stickman_rig.gd` (`StickmanRig`) | Public compatibility facade; authoritative `RigState`; facing profile, bend flags, IK modification resolution, body z-order, root mirror, head pin, shared node lookup, component wiring, deterministic physics scheduling, and public signal forwarding. |
| `scripts/stickman_locomotion.gd` (`StickmanLocomotion`) | Navigation agent, walking, terrain probes/following, airborne and landing states, root movement, gait/foot contacts, animated collision proxies, airborne sweeps, and impact reactions. |
| `scripts/stickman_ragdoll.gd` (`StickmanRagdoll`) | Momentum tracking, ragdoll definitions and procedural bodies/joints, rest detection, pre-ragdoll/captured-pose data, recovery snap/tween, and ragdoll impulse application. |
| `scripts/stickman_action_runner.gd` (`StickmanActionRunner`) | Authored/runtime action queues, reactive injection, action phase machine, wait timing, speech bubble and overlay arbitration, and action/queue/speech signals. |
| `scripts/stickman_factory.gd` | Loads the relocated scene; return type and `spawn*` API stay unchanged. |
| `scripts/stk_rig_adapter.gd` | No node-path changes. Comments reference the relocated scene. It continues to adapt the root before `_ready()`. |
| `scripts/create_animations.gd`, harnesses, tests, active docs | Update live scene-path literals/comments and component-aware tests. |
`source_stk_path` remains on the root facade because it is instance provenance consumed by
stage serialization, not behavior owned by a component.
No layout, theme, visible node, or user-facing UI change is required.
## 3. Scene structure
The existing visual and skeleton hierarchy must remain byte-for-byte equivalent apart from the
new component branch and external component script resources. Adapter and harness node paths
therefore remain valid.
```text
Master: Node2D [StickmanRig]
├── RigComponents: Node
│ ├── Locomotion: Node [StickmanLocomotion]
│ ├── Ragdoll: Node [StickmanRagdoll]
│ └── ActionRunner: Node [StickmanActionRunner]
├── Body: Node2D
│ └── Body / Head / 8 limb visual containers (unchanged)
├── Skeleton2D (unchanged)
│ └── Torso/Head + 8 limb Bone2D nodes
├── RayCast_Ground (unchanged; legacy)
├── IK_Targets (unchanged)
├── AnimationTargets (unchanged)
├── AnimationPlayer (unchanged)
└── AnimationTree (unchanged)
world/stage parent
└── RagdollBodyContainer (runtime only; unchanged location)
├── 10 RigidBody2D
└── 9 PinJoint2D
```
The components are authored children rather than created in `_ready()`. Missing component nodes
produce one `StickmanRig:` warning per missing node and disable only that subsystem. The root must
never manufacture a fallback component silently because that would hide a broken scene.
## 4. Dependency direction and state authority
```mermaid
flowchart LR
C[StickmanRig facade/coordinator]
L[StickmanLocomotion]
R[StickmanRagdoll]
A[StickmanActionRunner]
U[Stage / Factory / Harness / Scene IO]
U -->|existing public API| C
C -->|configure + deterministic ticks| L
C -->|configure + commands| R
C -->|configure + commands| A
A -->|walk/speech-independent movement query| L
A -->|ragdoll_requested / recovery_requested| C
R -->|state_transition_requested| C
R -->|ground query + recovery reanchor| L
L -->|arrived / facing_requested| C
C -->|forwarded public signals| U
```
Rules:
1. `StickmanRig.state` is the only high-level state authority. Components receive the current
state during commands/ticks and never write it directly.
2. `StickmanActionRunner` has no reference to `StickmanRagdoll`; it requests transitions through
root-connected signals. This allows queue tests without constructing physics bodies.
3. `StickmanRagdoll` may synchronously call only the narrow locomotion recovery/query API; it
must not inspect locomotion private fields.
4. `StickmanLocomotion` requests facing changes by signal so the facade remains the profile and
mirror authority.
5. The components do not implement `_physics_process()`. The root is the sole scheduler. This
preserves the current update order and avoids relying on Godot sibling processing order.
## 5. Static component contracts
The signatures below are the implementation contract. Internal helper names may be retained
during mechanical extraction, but cross-component calls must use only these methods/signals.
### 5.1 `StickmanLocomotion`
```gdscript
class_name StickmanLocomotion
extends Node
signal arrived(target: Vector2)
signal facing_requested(profile: int)
func configure(rig: StickmanRig) -> void
func tick_movement(delta: float, rig_state: int) -> void
func tick_post_skeleton(delta: float, rig_state: int) -> void
func walk_to(target: Vector2, speed: float = -1.0) -> bool
func cancel_walk(restore_idle_pose: bool = true) -> void
func is_walking() -> bool
func consume_walk_completed() -> bool
func begin_terrain_fall() -> void
func set_terrain_following_enabled(enabled: bool) -> void
func is_action_blocked() -> bool
func prepare_for_ragdoll() -> void
func restore_after_recovery() -> void
func probe_ground_y(
world_x: float,
top_y: float,
bottom_y: float,
exclude: Array[RID]
) -> float
func reanchor_for_recovery(feet_world: Vector2, foot_offset: Vector2) -> Vector2
func set_collision_bodies_enabled(enabled: bool) -> void
func apply_collision_reaction(
part_name: String,
normal: Vector2,
impact_speed: float,
contact_position: Vector2 = Vector2.INF
) -> void
```
`tick_movement()` owns the old clear-pelvis → walking → terrain → settle sequence.
`tick_post_skeleton()` owns impact-reaction application and collision-proxy synchronization,
which must remain after the facade's head pin. `reanchor_for_recovery()` moves the root and
returns the mirror-aware rig-local shift used by `StickmanRagdoll` to rebase captured body data.
The locomotion component is the source of truth for `TerrainMotion`, navigation, terrain/air
tuning constants, collision proxy tables, and reaction constants. Compatibility aliases may
remain on `StickmanRig` for one phase, but they must point to component constants rather than
duplicate values.
### 5.2 `StickmanRagdoll`
```gdscript
class_name StickmanRagdoll
extends Node
signal state_transition_requested(next_state: int)
signal rest_state_changed(at_rest: bool)
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void
func tick_pre_movement(delta: float, rig_state: int) -> void
func enter_ragdoll() -> bool
func request_recovery() -> bool
func snap_to_standing() -> bool
func cancel_recovery() -> void
func apply_velocity_boost(velocity: Vector2) -> void
func is_at_rest() -> bool
func initialize_spawn_pose_from_skeleton() -> void
```
`enter_ragdoll()` and recovery methods return whether they actually accepted the transition.
They perform the physical handoff before emitting `state_transition_requested`; the root then
commits `state` and emits the legacy `state_changed` signal exactly once. The component owns the
existing `RAGDOLL_BODIES`, `RAGDOLL_JOINTS`, per-profile joint bands, recovery overrides,
captured dictionaries, tween generation, and procedural container lifetime.
The root provides narrow internal skeleton services used by recovery:
```gdscript
func _get_skeleton() -> Skeleton2D
func _get_body_container() -> Node2D
func _get_animation_player() -> AnimationPlayer
func _get_ik_marker(name: String) -> Marker2D
func _get_limb_bones() -> Dictionary
func _get_bend_modifications() -> Dictionary
func _get_head_bone() -> Bone2D
func _get_head_look_at() -> SkeletonModification2DLookAt
func _set_limb_mods_enabled(enabled: bool) -> void
func _restore_head_lookat_state() -> void
func _rearm_ik_stack() -> void
```
These methods are internal collaboration points, not new consumer APIs.
### 5.3 `StickmanActionRunner`
```gdscript
class_name StickmanActionRunner
extends Node
signal action_started(action: Dictionary, index: int)
signal action_finished(action: Dictionary, index: int)
signal queue_finished
signal queue_changed
signal speech_finished
signal ragdoll_requested(enabled: bool)
signal recovery_requested
var action_queue: Array[Dictionary] = []
func configure(rig: StickmanRig, locomotion: StickmanLocomotion) -> void
func tick(delta: float, rig_state: int, ragdoll_at_rest: bool) -> void
func speak(text: String, duration: float) -> void
func queue_action(action: Dictionary) -> void
func clear_queue() -> void
func get_queue() -> Array[Dictionary]
func remove_action(index: int) -> void
func insert_action(index: int, action: Dictionary) -> void
func queue_size() -> int
func enqueue_reactive(actions: Array[Dictionary]) -> void
func clear_reactive_actions() -> void
func start_queue() -> void
func stop_queue() -> void
func is_queue_running() -> bool
func sync_facing(profile: int) -> void
```
`RunnerState` and `ActionPhase` are component-owned. Root keeps enum aliases/definitions only
where required for source compatibility. The component owns both primary and overlay speech,
creates `SpeechBubble` under `Master` (not under `RigComponents`), and counter-mirrors it through
`sync_facing()`.
## 6. Root compatibility facade
All current consumer-facing members remain available on `StickmanRig` with unchanged names,
parameters, return types, and signal cardinality.
| Existing root surface | Delegates to / remains in |
| --- | --- |
| `set_facing_profile`, `get_facing_profile`, bend getters/setters, bend joint queries | Root |
| `state`, `state_changed`, `set_ragdoll`, `toggle_ragdoll`, `is_in_ragdoll`, `request_recovery`, `snap_to_standing`, `apply_ragdoll_velocity_boost`, `is_ragdoll_at_rest`, `initialize_spawn_pose_from_skeleton` | Root facade → Ragdoll |
| `walk_to`, `is_walking`, `arrived`, `begin_terrain_fall`, `snap_to_terrain`, `set_terrain_following_enabled` | Root facade → Locomotion |
| `set_collision_bodies_enabled`, `apply_collision_reaction` | Root facade → Locomotion |
| `action_queue`, queue mutation/query methods, reactive methods, `start_queue`, `stop_queue`, `is_queue_running`, `speak` | Root facade → ActionRunner |
| `action_started`, `action_finished`, `queue_finished`, `queue_changed`, `speech_finished` | Child signal forwarded by root |
| `facing_profile`, four bend exports, `rest_timeout`, `auto_recover`, `walk_speed` | Remain root exports; values synchronize to the owning component during `_ready()` and in setters afterward. |
| `source_stk_path` | Root |
`action_queue` must be a delegating property because tests, scene loading, and older callers may
read or assign it directly:
```gdscript
var action_queue: Array[Dictionary]:
get:
return _action_runner.action_queue if _action_runner != null else []
set(value):
if _action_runner != null:
_action_runner.action_queue = value
else:
_pending_action_queue = value
```
Direct assignment keeps today's behavior and does not emit `queue_changed`; mutation methods do.
Pre-`_ready()` export/property writes are stored on the facade and pushed to components after
configuration, matching the existing pre-ready setter guard.
Private root fields are not compatibility API. Existing tests that inspect `_ragdoll_bodies`,
`_terrain_motion`, `_run_queue`, or other private internals must move to the owning component.
Do not keep duplicate mutable private state on the root merely to satisfy tests.
## 7. Physics order and state transitions
The root retains a single `_physics_process()` scheduler:
```text
Ragdoll.tick_pre_movement
├── cache root momentum
└── update ragdoll rest detection
Locomotion.tick_movement
├── clear one-frame pelvis correction
├── update walk/navigation
├── update terrain/air/landing
└── settle walk markers
ActionRunner.tick
├── update speech clocks
└── update action state machine
StickmanRig core
└── pin mirrored head rotation
Locomotion.tick_post_skeleton
├── apply impact reactions
└── sync animated collision proxies
```
Ragdoll transition flow:
```mermaid
stateDiagram-v2
[*] --> ANIMATED
ANIMATED --> RAGDOLL: root.set_ragdoll(true)\nlocomotion cleanup → build physics → request state
RECOVERING --> RAGDOLL: root.set_ragdoll(true)\ncancel tween → rebuild physics
RAGDOLL --> RECOVERING: request_recovery()\ncapture → destroy physics → reanchor → snap
RECOVERING --> ANIMATED: recovery tween finished
RAGDOLL --> ANIMATED: snap_to_standing()
RECOVERING --> ANIMATED: snap_to_standing()
```
The facade rejects illegal transition requests before delegating. A component signal can request
a state commit, but `_set_state(next_state)` on the facade is the only function that mutates the
root `state` and emits `state_changed`.
## 8. Practical data flows
### 8.1 Authored queue action
```gdscript
rig.queue_action({"type": "walk_to", "target": Vector2(640, 320), "speed": 300.0})
rig.queue_action({"type": "speak", "text": "Made it!", "duration": 2.0})
rig.start_queue()
```
```text
StickmanRig.queue_action
→ ActionRunner.action_queue
→ root forwards queue_changed
ActionRunner begins walk_to
→ Locomotion.walk_to
→ Locomotion arrived(target)
→ root forwards arrived(target)
→ ActionRunner observes consume_walk_completed() and emits action_finished
```
The queue dictionary shape and deep-copy behavior remain unchanged.
### 8.2 Ragdoll recovery landing handoff
```text
Ragdoll captures torso world center + body pose
→ Locomotion.probe_ground_y(x, top, bottom, ragdoll_rids)
→ Ragdoll computes feet_world
→ Locomotion.reanchor_for_recovery(feet_world, FOOT_OFFSET)
moves Master and returns mirror-aware local_shift
→ Ragdoll rebases captured positions by local_shift
→ Ragdoll snap-solves skeleton and requests RECOVERING
```
The existing capture payload stays internal and unchanged:
```gdscript
{
"torso": {"pos": Vector2(...), "rot": 0.42, "half": 195.0},
"head": {"pos": Vector2(...), "rot": -0.10, "half": 100.0},
# eight limb entries...
}
```
## 9. Relocation policy
Implementation moves the scene once with its UID intact, then updates live references in the
same change:
- `scripts/stickman_factory.gd`
- `scripts/physics_test_harness.gd`
- `tests/test_phase6a_airborne_landing.gd`
- any additional literal discovered by `rg --fixed-strings 'res://master_rig.tscn'`
- active comments in `stickman_rig.gd`, `stk_rig_adapter.gd`, `test_harness.gd`, and
`create_animations.gd`
- implementation-facing comments in the changed scripts
After the user verifies the implementation in Godot, the documentation phase updates active
project docs (`README.md`, `AGENTS.md`, `RIGGING.md`, and the source plan). Historical specs
should not receive a blind search/replace because many statements describe the path that existed
when that phase shipped. Add a short relocation note where a historical file is still used as
live guidance; do not rewrite historical line references or decisions.
Acceptance requires:
```powershell
rg --fixed-strings 'res://master_rig.tscn' scripts scenes tests project.godot
```
to return no live code/scene/test matches. Human-readable historical mentions may remain when
clearly historical.
## 10. Migration sequence
1. **Characterize first.** Run the current focused rig suites and record baselines before moving
code. Add no behavior changes to the extraction patch.
2. **Add component nodes/scripts without relocating the scene.** Wire configuration, root signal
forwarding, and the central scheduler. The scene still loads from the old path during this
step.
3. **Extract action/speech orchestration.** It has the smallest physics surface. Keep all root
queue methods as delegates and migrate private runner tests.
4. **Extract locomotion plus animated collision.** Move navigation, terrain/air/landing, proxy,
and flinch state together so only one component writes the animated root/markers.
5. **Extract ragdoll/recovery.** Connect the narrow ground-query/reanchor API and retain the exact
instant-handoff ordering.
6. **Slim and verify the facade.** Remove duplicate mutable state/constants, leaving aliases only
where compatibility requires them. Verify every root public method and signal.
7. **Relocate the scene last.** Move to `scenes/stickman_rig.tscn`, preserve the UID, update every
live preload/path/comment, force one headless editor rescan for new `class_name` scripts, then
run static checks and all focused suites.
8. **Stop for editor verification.** Per `spec-pipeline`, do not update README, AGENTS, RIGGING,
the source plan, or historical documentation until the user confirms the feature works in
Godot. Documentation cleanup is the manually triggered Phase 4.
## 11. Verification plan
### Static and scene-load checks
```powershell
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --editor --path "C:\Godot4\stickman" --quit
& "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 --check-only --script "res://scripts/stickman_locomotion.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_ragdoll.gd" --path "C:\Godot4\stickman"
& "C:\Godot4\Godot_v4.7.1-stable_win64_console.exe" --headless --check-only --script "res://scripts/stickman_action_runner.gd" --path "C:\Godot4\stickman"
```
Add `tests/test_phase8_rig_modularization.gd` covering:
1. relocated scene loads by new path and retains UID/node paths;
2. all three typed component children resolve;
3. root public methods delegate and root signals emit once;
4. missing optional/component dependency warns instead of crashing;
5. queue/speech execution works while no ragdoll bodies exist;
6. locomotion walking/terrain ticks work while no ragdoll bodies exist;
7. ragdoll enter/recover/snap keeps state order and ten-body/nine-joint cardinality;
8. root exports synchronize before and after `_ready()`;
9. direct `action_queue` assignment retains compatibility semantics; and
10. repeated spawn/free leaves no world-level `RagdollBodyContainer` orphan.
Run the focused regressions most coupled to the extraction:
- `tests/test_phase3c_walk_recovery.gd`
- `tests/test_phase4_final_bugs.gd`
- `tests/test_phase4b_walk.gd`
- `tests/test_phase4b_terrain.gd`
- `tests/test_phase45_collision_bodies.gd`
- `tests/test_phase6a_airborne_landing.gd`
- `tests/test_phase5_file_features.gd`
- `tests/test_cli_scene.gd`
Tests should inspect `$RigComponents/Locomotion`, `$RigComponents/Ragdoll`, or
`$RigComponents/ActionRunner` for subsystem internals instead of requiring stale private mirrors
on `StickmanRig`.
Manual Godot verification after automated checks:
- factory/harness loads an `.stk` and all body geometry follows the same bones;
- LEFT/RIGHT/FORWARD, head mirror pin, bend menus, and z-order remain unchanged;
- queue `walk_to → speak → wait → ragdoll → recover` completes with one signal per event;
- walking over ramps, ledge fall, hard landing, prop collision/flinch, and queue landing barrier;
- ragdoll knock-up, automatic/manual recovery, recovery interruption, and snap-to-standing;
- Sandbox EDIT/DIRECT/PLAY round trip and scene save/load provenance/queue persistence.
## 12. Risks and guardrails
| Risk | Guardrail |
| --- | --- |
| Godot child processing order changes behavior | Components have no physics callbacks; root calls ordered tick methods. |
| Walk and terrain both write the root | They live in one locomotion component and retain the current internal order. |
| State duplicated between root and ragdoll | Root is sole `RigState` authority; component requests transitions. |
| Signals double-fire through forwarding | Child emits subsystem signal; root forwarding is the only consumer-facing emission. Add cardinality tests. |
| Recovery reaches through locomotion internals | Only `probe_ground_y()` and `reanchor_for_recovery()` cross that boundary. |
| Adapter runs before component `_ready()` | Components do not cache geometry until root `_ready()`; adapter node paths remain unchanged. |
| Scene move breaks preload before scripts update | Relocation and all live path updates land atomically; UID is asserted. |
| New `class_name` cache is stale in CLI | Run one `--headless --editor --quit` rescan before `--check-only`. |
| Private-test migration masks regressions | Add behavior assertions through root API/signals plus component-local focused assertions. |
| Broad doc replacement falsifies old specs | Update active docs; annotate historical specs instead of blind replacement. |
## 13. Decisions requiring approval
1. **Approve movement ownership change:** `StickmanLocomotion` owns navigation and `walk_to()`;
`StickmanActionRunner` only orchestrates it. This intentionally differs from the source plan.
2. **Approve animated collision ownership:** collision proxies, airborne sweeps, and impact
reactions move with locomotion, avoiding a fourth component in this phase.
3. **Approve compatibility scope:** preserve all root public APIs/signals/exports, including direct
`action_queue`; migrate tests off private root fields rather than keeping duplicate private
mirrors.
4. **Approve documentation policy:** after editor verification, update active documentation and
preserve historical specs with relocation notes instead of blanket rewriting their historical
references.
Implementation must not begin until these decisions are approved.
+3 -3
View File
@@ -1,6 +1,6 @@
# test_phase3b_library.gd # test_phase3b_library.gd
# Headless tests for Phase 3b Asset Library (no pixel rendering): # Headless tests for Phase 3b Asset Library (no pixel rendering):
# 1. StickmanLibrary.scan() returns 3 entries (test/basic/break) sorted by # 1. StickmanLibrary.scan() returns the 4 checked-in entries sorted by
# display name; empty stickman_name falls back to the filename basename. # display name; empty stickman_name falls back to the filename basename.
# 2. Corrupt / body-parts-less .stk files are skipped (scan dir override). # 2. Corrupt / body-parts-less .stk files are skipped (scan dir override).
# 3. make_entry() valid path -> name+data; missing/corrupt path -> {}. # 3. make_entry() valid path -> name+data; missing/corrupt path -> {}.
@@ -70,11 +70,11 @@ func _test_scan() -> void:
print("--- StickmanLibrary.scan() ---") print("--- StickmanLibrary.scan() ---")
var lib = STICKMAN_LIBRARY.new() var lib = STICKMAN_LIBRARY.new()
var entries: Array[Dictionary] = lib.scan() var entries: Array[Dictionary] = lib.scan()
_check(entries.size() == 3, "scan returns 3 entries (got %d)" % entries.size()) _check(entries.size() == 4, "scan returns 4 entries (got %d)" % entries.size())
var names: Array[String] = [] var names: Array[String] = []
for e: Dictionary in entries: for e: Dictionary in entries:
names.append(String(e["name"])) names.append(String(e["name"]))
_check(names == ["Basic", "break", "test"], _check(names == ["Basic", "Basic Broken", "break", "test"],
"entries sorted by display name (got %s)" % str(names)) "entries sorted by display name (got %s)" % str(names))
_check(String(_by_path(entries, "basic.stk")["name"]) == "Basic", _check(String(_by_path(entries, "basic.stk")["name"]) == "Basic",
"basic.stk display name is 'Basic' (stickman_name)") "basic.stk display name is 'Basic' (stickman_name)")
+2 -2
View File
@@ -279,8 +279,8 @@ func _test_bug4_stage_grid_no_prehighlight() -> void:
% stage._spawner.get_selected_stickman_path()) % stage._spawner.get_selected_stickman_path())
# Real scan has 3 stickmen -> no single-item auto-select skip. # Real scan has 3 stickmen -> no single-item auto-select skip.
var grid: GridContainer = stage._selector._grid var grid: GridContainer = stage._selector._grid
_check(grid.get_child_count() == 3, _check(grid.get_child_count() == 4,
"real scan populates 3 stickman cells (got %d)" % grid.get_child_count()) "real scan populates 4 stickman cells (got %d)" % grid.get_child_count())
_check(not stage._selector.has_method("_is_selected"), _check(not stage._selector.has_method("_is_selected"),
"stage selector exposes no pre-highlight API") "stage selector exposes no pre-highlight API")
_check(not _any_cell_has_stylebox_override(stage._selector), _check(not _any_cell_has_stylebox_override(stage._selector),
+30 -26
View File
@@ -34,7 +34,7 @@
# #
# Fix 2 (recovery re-anchor): _capture_ragdoll_pose() records the ragdoll # Fix 2 (recovery re-anchor): _capture_ragdoll_pose() records the ragdoll
# torso's world landing anchor -- _captured_landing_center (world center) # torso's world landing anchor -- _captured_landing_center (world center)
# plus _captured_ground_y (center.y + RAGDOLL_TORSO_RADIUS, the ground # plus _captured_ground_y (center.y + StickmanRagdoll.TORSO_RADIUS, the ground
# contact line) -- and _start_recovery() calls _reanchor_root_to_landing(), # contact line) -- and _start_recovery() calls _reanchor_root_to_landing(),
# which moves the rig root so the STANDING figure's FEET sit on the ground # which moves the rig root so the STANDING figure's FEET sit on the ground
# at the landing X (feet = (_captured_landing_center.x, _captured_ground_y), # at the landing X (feet = (_captured_landing_center.x, _captured_ground_y),
@@ -544,8 +544,10 @@ func _test_recovery_reanchor() -> void:
rig.state_changed.connect(func(s): state_events.append(int(s))) rig.state_changed.connect(func(s): state_events.append(int(s)))
rig.set_ragdoll(true) rig.set_ragdoll(true)
_check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL") _check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL")
_check(rig._ragdoll_bodies.size() == 10, var ragdoll: StickmanRagdoll = rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
"ragdoll builds 10 bodies (got %d)" % rig._ragdoll_bodies.size()) var ragdoll_bodies: Dictionary = ragdoll.get_bodies()
_check(ragdoll_bodies.size() == 10,
"ragdoll builds 10 bodies (got %d)" % ragdoll_bodies.size())
var body_container := rig.get_node_or_null(NodePath("Body")) as Node2D var body_container := rig.get_node_or_null(NodePath("Body")) as Node2D
var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
_check(body_container != null and not body_container.visible, _check(body_container != null and not body_container.visible,
@@ -553,7 +555,7 @@ func _test_recovery_reanchor() -> void:
_check(skeleton != null and skeleton.modification_stack != null \ _check(skeleton != null and skeleton.modification_stack != null \
and not skeleton.modification_stack.enabled, and not skeleton.modification_stack.enabled,
"IK stack is disabled during RAGDOLL") "IK stack is disabled during RAGDOLL")
var torso: RigidBody2D = rig._ragdoll_bodies.get("torso") as RigidBody2D var torso: RigidBody2D = ragdoll_bodies.get("torso") as RigidBody2D
_check(torso != null, "ragdoll torso body exists") _check(torso != null, "ragdoll torso body exists")
if torso == null: if torso == null:
await _free_stage(stage) await _free_stage(stage)
@@ -563,13 +565,13 @@ func _test_recovery_reanchor() -> void:
% float(torso.get_meta("half_height", 0.0))) % float(torso.get_meta("half_height", 0.0)))
# Simulate "landed": freeze every body, then offset the whole pile. # Simulate "landed": freeze every body, then offset the whole pile.
for key: String in rig._ragdoll_bodies: for key: String in ragdoll_bodies:
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
b.freeze = true b.freeze = true
b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
var torso_before: Vector2 = torso.global_position var torso_before: Vector2 = torso.global_position
for key: String in rig._ragdoll_bodies: for key: String in ragdoll_bodies:
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
b.global_position += LANDING_DELTA b.global_position += LANDING_DELTA
for i in 3: for i in 3:
await physics_frame await physics_frame
@@ -582,10 +584,10 @@ func _test_recovery_reanchor() -> void:
var spine_dir := Vector2.from_angle(torso.global_rotation) var spine_dir := Vector2.from_angle(torso.global_rotation)
var hip_world: Vector2 = torso.global_position - spine_dir * half var hip_world: Vector2 = torso.global_position - spine_dir * half
var landing_center: Vector2 = torso.global_position var landing_center: Vector2 = torso.global_position
var ground_y: float = torso.global_position.y + float(RIG.RAGDOLL_TORSO_RADIUS) var ground_y: float = torso.global_position.y + StickmanRagdoll.TORSO_RADIUS
var feet_world := Vector2(landing_center.x, ground_y) var feet_world := Vector2(landing_center.x, ground_y)
var expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2) var expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET
var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO) var stand_torso: Vector2 = (StickmanRagdoll.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
var final_hip_world: Vector2 = expected_root + stand_torso var final_hip_world: Vector2 = expected_root + stand_torso
var old_hip_anchor_root: Vector2 = hip_world - stand_torso var old_hip_anchor_root: Vector2 = hip_world - stand_torso
_check(hip_world.distance_to(pre_root) > 400.0, _check(hip_world.distance_to(pre_root) > 400.0,
@@ -598,12 +600,12 @@ func _test_recovery_reanchor() -> void:
"request_recovery() enters RECOVERING (state %d)" % int(rig.state)) "request_recovery() enters RECOVERING (state %d)" % int(rig.state))
_check(not state_events.is_empty() and state_events[-1] == int(rig.RigState.RECOVERING), _check(not state_events.is_empty() and state_events[-1] == int(rig.RigState.RECOVERING),
"state_changed emitted RECOVERING (events %s)" % str(state_events)) "state_changed emitted RECOVERING (events %s)" % str(state_events))
_check(rig._captured_landing_center.distance_to(landing_center) <= 0.5, _check(ragdoll._captured_landing_center.distance_to(landing_center) <= 0.5,
"_captured_landing_center matches the simulated landing center (got %s)" "_captured_landing_center matches the simulated landing center (got %s)"
% str(rig._captured_landing_center)) % str(ragdoll._captured_landing_center))
_check(absf(rig._captured_ground_y - ground_y) <= 0.5, _check(absf(ragdoll._captured_ground_y - ground_y) <= 0.5,
"_captured_ground_y matches center.y + torso radius (got %.2f, expected %.2f)" "_captured_ground_y matches center.y + torso radius (got %.2f, expected %.2f)"
% [rig._captured_ground_y, ground_y]) % [ragdoll._captured_ground_y, ground_y])
_check(rig.global_position.distance_to(expected_root) <= 0.5, _check(rig.global_position.distance_to(expected_root) <= 0.5,
"root re-anchored so the feet sit on the landing ground (got %s, expected %s)" "root re-anchored so the feet sit on the landing ground (got %s, expected %s)"
% [str(rig.global_position), str(expected_root)]) % [str(rig.global_position), str(expected_root)])
@@ -779,7 +781,9 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
rig.state_changed.connect(func(s): state_events.append(int(s))) rig.state_changed.connect(func(s): state_events.append(int(s)))
rig.set_ragdoll(true) rig.set_ragdoll(true)
_check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL") _check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL")
var torso: RigidBody2D = rig._ragdoll_bodies.get("torso") as RigidBody2D var ragdoll: StickmanRagdoll = rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
var ragdoll_bodies: Dictionary = ragdoll.get_bodies()
var torso: RigidBody2D = ragdoll_bodies.get("torso") as RigidBody2D
_check(torso != null, "ragdoll torso body exists") _check(torso != null, "ragdoll torso body exists")
if torso == null: if torso == null:
await _free_stage(stage) await _free_stage(stage)
@@ -787,8 +791,8 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
# Freeze + teleport the whole pile, then rotate ONLY the torso body so its # Freeze + teleport the whole pile, then rotate ONLY the torso body so its
# spine lies ~horizontal (a true lying pose ~±90 deg from the standing -90). # spine lies ~horizontal (a true lying pose ~±90 deg from the standing -90).
for key: String in rig._ragdoll_bodies: for key: String in ragdoll_bodies:
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
b.freeze = true b.freeze = true
b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
b.global_position += LANDING_DELTA b.global_position += LANDING_DELTA
@@ -804,13 +808,13 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
var spine_dir := Vector2.from_angle(torso.global_rotation) var spine_dir := Vector2.from_angle(torso.global_rotation)
var hip_world: Vector2 = torso.global_position - spine_dir * half var hip_world: Vector2 = torso.global_position - spine_dir * half
var landing_center: Vector2 = torso.global_position var landing_center: Vector2 = torso.global_position
var ground_y: float = torso.global_position.y + float(RIG.RAGDOLL_TORSO_RADIUS) var ground_y: float = torso.global_position.y + StickmanRagdoll.TORSO_RADIUS
var feet_world := Vector2(landing_center.x, ground_y) var feet_world := Vector2(landing_center.x, ground_y)
var expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2) var expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET
var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO) var stand_torso: Vector2 = (StickmanRagdoll.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
var final_hip_world: Vector2 = expected_root + stand_torso var final_hip_world: Vector2 = expected_root + stand_torso
var old_hip_anchor_root: Vector2 = hip_world - stand_torso var old_hip_anchor_root: Vector2 = hip_world - stand_torso
_check(absf(hip_world.y - ground_y) <= float(RIG.RAGDOLL_TORSO_RADIUS) + 1.0, _check(absf(hip_world.y - ground_y) <= StickmanRagdoll.TORSO_RADIUS + 1.0,
"lying hip sits at the body's mid-height, near (not above) the ground line") "lying hip sits at the body's mid-height, near (not above) the ground line")
_check(old_hip_anchor_root.distance_to(expected_root) > 300.0, _check(old_hip_anchor_root.distance_to(expected_root) > 300.0,
"old hip-anchor root differs wildly from the new feet-anchor root (%.1f px)" "old hip-anchor root differs wildly from the new feet-anchor root (%.1f px)"
@@ -820,12 +824,12 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
rig.request_recovery() rig.request_recovery()
_check(not rig.is_in_ragdoll() and int(rig.state) == int(rig.RigState.RECOVERING), _check(not rig.is_in_ragdoll() and int(rig.state) == int(rig.RigState.RECOVERING),
"request_recovery() enters RECOVERING (state %d)" % int(rig.state)) "request_recovery() enters RECOVERING (state %d)" % int(rig.state))
_check(rig._captured_landing_center.distance_to(landing_center) <= 0.5, _check(ragdoll._captured_landing_center.distance_to(landing_center) <= 0.5,
"_captured_landing_center matches the lying torso center (got %s)" "_captured_landing_center matches the lying torso center (got %s)"
% str(rig._captured_landing_center)) % str(ragdoll._captured_landing_center))
_check(absf(rig._captured_ground_y - ground_y) <= 0.5, _check(absf(ragdoll._captured_ground_y - ground_y) <= 0.5,
"_captured_ground_y matches the lying ground-contact line (got %.2f, expected %.2f)" "_captured_ground_y matches the lying ground-contact line (got %.2f, expected %.2f)"
% [rig._captured_ground_y, ground_y]) % [ragdoll._captured_ground_y, ground_y])
_check(rig.global_position.distance_to(expected_root) <= 0.5, _check(rig.global_position.distance_to(expected_root) <= 0.5,
"root re-anchored so the standing feet sit on the lying ground (got %s, expected %s)" "root re-anchored so the standing feet sit on the lying ground (got %s, expected %s)"
% [str(rig.global_position), str(expected_root)]) % [str(rig.global_position), str(expected_root)])
+66 -56
View File
@@ -42,11 +42,12 @@ func _spawn_rig(stage: Node2D, position: Vector2) -> StickmanRig:
func _test_collision_proxy_geometry(rig: StickmanRig) -> void: func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
_check(rig._collision_bodies.size() == 10, "rig builds ten collision proxies") var locomotion: StickmanLocomotion = rig.get_locomotion()
_check(rig._collision_lengths.size() == 9, "rig records nine fitted capsule lengths") _check(locomotion.collision_bodies.size() == 10, "locomotion builds ten collision proxies")
for part_name: String in rig.COLLISION_PARTS: _check(locomotion.collision_lengths.size() == 9, "locomotion records nine fitted capsule lengths")
var definition: Dictionary = rig.COLLISION_PARTS[part_name] for part_name: String in locomotion.COLLISION_PARTS:
var body := rig._collision_bodies.get(part_name) as AnimatableBody2D var definition: Dictionary = locomotion.COLLISION_PARTS[part_name]
var body := locomotion.collision_bodies.get(part_name) as AnimatableBody2D
_check(body != null, "%s proxy exists" % part_name) _check(body != null, "%s proxy exists" % part_name)
if body == null: if body == null:
continue continue
@@ -65,9 +66,9 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
var circle := shape_node.shape as CircleShape2D var circle := shape_node.shape as CircleShape2D
_check(circle != null, "head uses a circle") _check(circle != null, "head uses a circle")
if circle != null: if circle != null:
_check(circle.radius >= rig.HEAD_COLLISION_RADIUS, "head radius covers at least the fallback radius") _check(circle.radius >= locomotion.HEAD_COLLISION_RADIUS, "head radius covers at least the fallback radius")
var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D var head_visual := rig.get_node_or_null(NodePath("Body/Head")) as Node2D
var bounds: Dictionary = rig._mounted_visual_bounds_local(head_visual) var bounds: Dictionary = locomotion.mounted_visual_bounds_local(head_visual)
_check(bool(bounds.get("valid", false)), "mounted head has measurable geometry") _check(bool(bounds.get("valid", false)), "mounted head has measurable geometry")
if bool(bounds.get("valid", false)): if bool(bounds.get("valid", false)):
_check(shape_node.position.is_equal_approx((bounds["rect"] as Rect2).get_center()), "head collider follows the mounted bbox center") _check(shape_node.position.is_equal_approx((bounds["rect"] as Rect2).get_center()), "head collider follows the mounted bbox center")
@@ -77,14 +78,14 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
_check(capsule != null, "%s uses a capsule" % part_name) _check(capsule != null, "%s uses a capsule" % part_name)
if capsule == null: if capsule == null:
continue continue
var length := float(rig._collision_lengths.get(part_name, -1.0)) var length := float(locomotion.collision_lengths.get(part_name, -1.0))
_check(is_equal_approx(capsule.height, length), "%s capsule uses its fitted length" % part_name) _check(is_equal_approx(capsule.height, length), "%s capsule uses its fitted length" % part_name)
_check(is_equal_approx(capsule.radius, float(definition["radius"])), "%s capsule uses its configured radius" % part_name) _check(is_equal_approx(capsule.radius, float(definition["radius"])), "%s capsule uses its configured radius" % part_name)
_check(shape_node.position.is_equal_approx(Vector2(0.0, length * 0.5)), "%s capsule is centered along +Y" % part_name) _check(shape_node.position.is_equal_approx(Vector2(0.0, length * 0.5)), "%s capsule is centered along +Y" % part_name)
_check(is_zero_approx(shape_node.rotation), "%s capsule needs no hanging-frame rotation" % part_name) _check(is_zero_approx(shape_node.rotation), "%s capsule needs no hanging-frame rotation" % part_name)
var empty_visual := Node2D.new() var empty_visual := Node2D.new()
var empty_bounds: Dictionary = rig._mounted_visual_bounds_local(empty_visual) var empty_bounds: Dictionary = locomotion.mounted_visual_bounds_local(empty_visual)
_check(not bool(empty_bounds.get("valid", true)), "empty visual bounds use the fallback path") _check(not bool(empty_bounds.get("valid", true)), "empty visual bounds use the fallback path")
empty_visual.free() empty_visual.free()
@@ -100,18 +101,19 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void: func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
rig.walk_to(rig.global_position + Vector2(-400.0, 0.0), 300.0) rig.walk_to(rig.global_position + Vector2(-400.0, 0.0), 300.0)
for _frame in 8: for _frame in 8:
await physics_frame await physics_frame
await process_frame await process_frame
rig._sync_collision_bodies() locomotion.sync_collision_bodies()
await physics_frame await physics_frame
await process_frame await process_frame
_check(rig.facing_profile == StickmanRig.FacingProfile.LEFT, "left walk uses the mirrored collision-proxy path") _check(rig.facing_profile == StickmanRig.FacingProfile.LEFT, "left walk uses the mirrored collision-proxy path")
for part_name: String in rig.COLLISION_PARTS: for part_name: String in locomotion.COLLISION_PARTS:
var definition: Dictionary = rig.COLLISION_PARTS[part_name] var definition: Dictionary = locomotion.COLLISION_PARTS[part_name]
var visual := rig.get_node_or_null(NodePath("Body/" + String(definition["visual"]))) as Node2D var visual := rig.get_node_or_null(NodePath("Body/" + String(definition["visual"]))) as Node2D
var body := rig._collision_bodies.get(part_name) as AnimatableBody2D var body := locomotion.collision_bodies.get(part_name) as AnimatableBody2D
if visual == null or body == null: if visual == null or body == null:
_check(false, "%s has nodes for PhysicsServer alignment" % part_name) _check(false, "%s has nodes for PhysicsServer alignment" % part_name)
continue continue
@@ -119,67 +121,70 @@ func _test_collision_proxy_physics_alignment(rig: StickmanRig) -> void:
_check(server_transform.origin.distance_to(visual.global_position) < 0.01, "%s PhysicsServer origin follows its walking visual" % part_name) _check(server_transform.origin.distance_to(visual.global_position) < 0.01, "%s PhysicsServer origin follows its walking visual" % part_name)
_check(server_transform.x.distance_to(visual.global_transform.x) < 0.001, "%s PhysicsServer X basis follows its walking visual" % part_name) _check(server_transform.x.distance_to(visual.global_transform.x) < 0.001, "%s PhysicsServer X basis follows its walking visual" % part_name)
_check(server_transform.y.distance_to(visual.global_transform.y) < 0.001, "%s PhysicsServer Y basis follows its walking visual" % part_name) _check(server_transform.y.distance_to(visual.global_transform.y) < 0.001, "%s PhysicsServer Y basis follows its walking visual" % part_name)
rig._cancel_walking() rig.get_locomotion().cancel_walk()
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD) rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
func _test_reactions(rig: StickmanRig) -> void: func _test_reactions(rig: StickmanRig) -> void:
rig._impact_reactions.clear() var locomotion: StickmanLocomotion = rig.get_locomotion()
locomotion.impact_reactions.clear()
rig.apply_collision_reaction("unknown", Vector2.RIGHT, 400.0) rig.apply_collision_reaction("unknown", Vector2.RIGHT, 400.0)
rig.apply_collision_reaction("head", Vector2.ZERO, 400.0) rig.apply_collision_reaction("head", Vector2.ZERO, 400.0)
rig.apply_collision_reaction("head", Vector2.RIGHT, 0.0) rig.apply_collision_reaction("head", Vector2.RIGHT, 0.0)
_check(rig._impact_reactions.is_empty(), "invalid reactions are ignored") _check(locomotion.impact_reactions.is_empty(), "invalid reactions are ignored")
var head_proxy := rig._collision_bodies["head"] as AnimatableBody2D var head_proxy := locomotion.collision_bodies["head"] as AnimatableBody2D
var head_shape := head_proxy.get_node("CollisionShape2D") as CollisionShape2D var head_shape := head_proxy.get_node("CollisionShape2D") as CollisionShape2D
var head_circle := head_shape.shape as CircleShape2D var head_circle := head_shape.shape as CircleShape2D
var upper_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, -head_circle.radius * 0.7)) var upper_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, -head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2(0.5, 0.5).normalized(), 400.0, upper_left) rig.apply_collision_reaction("head", Vector2(0.5, 0.5).normalized(), 400.0, upper_left)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side") _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side")
rig._impact_reactions.clear() locomotion.impact_reactions.clear()
var upper_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, -head_circle.radius * 0.7)) var upper_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, -head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2(-0.5, 0.5).normalized(), 400.0, upper_right) rig.apply_collision_reaction("head", Vector2(-0.5, 0.5).normalized(), 400.0, upper_right)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side") _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side")
rig._impact_reactions.clear() locomotion.impact_reactions.clear()
var lower_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, head_circle.radius * 0.7)) var lower_left := head_proxy.to_global(head_shape.position + Vector2(-head_circle.radius * 0.7, head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_left) rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_left)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response") _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response")
rig._impact_reactions.clear() locomotion.impact_reactions.clear()
var lower_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, head_circle.radius * 0.7)) var lower_right := head_proxy.to_global(head_shape.position + Vector2(head_circle.radius * 0.7, head_circle.radius * 0.7))
rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_right) rig.apply_collision_reaction("head", Vector2.UP, 400.0, lower_right)
_check(float((rig._impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response") _check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response")
rig._impact_reactions.clear() locomotion.impact_reactions.clear()
rig.apply_collision_reaction("left_lower_arm", Vector2.RIGHT, 10000.0) rig.apply_collision_reaction("left_lower_arm", Vector2.RIGHT, 10000.0)
var lower: Dictionary = rig._impact_reactions.get("LeftLowerArm", {}) var lower: Dictionary = locomotion.impact_reactions.get("LeftLowerArm", {})
var upper: Dictionary = rig._impact_reactions.get("LeftUpperArm", {}) var upper: Dictionary = locomotion.impact_reactions.get("LeftUpperArm", {})
_check(is_equal_approx(absf(float(lower.get("angle", 0.0))), 0.6), "primary reaction caps at 0.6 radians") _check(is_equal_approx(absf(float(lower.get("angle", 0.0))), 0.6), "primary reaction caps at 0.6 radians")
_check(is_equal_approx(absf(float(upper.get("angle", 0.0))), 0.24), "secondary reaction applies 0.4 weight") _check(is_equal_approx(absf(float(upper.get("angle", 0.0))), 0.24), "secondary reaction applies 0.4 weight")
var strong_angle := float(lower.get("angle", 0.0)) var strong_angle := float(lower.get("angle", 0.0))
(rig._impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25 (locomotion.impact_reactions["LeftLowerArm"] as Dictionary)["strength"] = 0.25
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 40.0) rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 40.0)
lower = rig._impact_reactions["LeftLowerArm"] lower = locomotion.impact_reactions["LeftLowerArm"]
_check(is_equal_approx(float(lower["angle"]), strong_angle), "weaker opposite hit retains the stronger signed angle") _check(is_equal_approx(float(lower["angle"]), strong_angle), "weaker opposite hit retains the stronger signed angle")
_check(is_equal_approx(float(lower["strength"]), 1.0), "weaker hit refreshes reaction strength") _check(is_equal_approx(float(lower["strength"]), 1.0), "weaker hit refreshes reaction strength")
rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 10000.0) rig.apply_collision_reaction("left_lower_arm", Vector2.LEFT, 10000.0)
lower = rig._impact_reactions["LeftLowerArm"] lower = locomotion.impact_reactions["LeftLowerArm"]
_check(signf(float(lower["angle"])) != signf(strong_angle), "equal capped hit may replace the reaction direction") _check(signf(float(lower["angle"])) != signf(strong_angle), "equal capped hit may replace the reaction direction")
var lower_bone := rig._reaction_bones["LeftLowerArm"] as Bone2D var lower_bone := locomotion.reaction_bones["LeftLowerArm"] as Bone2D
var base_rotation := lower_bone.rotation var base_rotation := lower_bone.rotation
var max_offset := 0.0 var max_offset := 0.0
for frame in 30: for frame in 30:
rig._apply_impact_reactions(1.0 / 60.0) locomotion.apply_impact_reactions(1.0 / 60.0)
max_offset = maxf(max_offset, absf(angle_difference(base_rotation, lower_bone.rotation))) max_offset = maxf(max_offset, absf(angle_difference(base_rotation, lower_bone.rotation)))
_check(max_offset <= rig.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap") _check(max_offset <= locomotion.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap")
rig._apply_impact_reactions(1.0) locomotion.apply_impact_reactions(1.0)
_check(rig._impact_reactions.is_empty(), "expired reactions are erased") _check(locomotion.impact_reactions.is_empty(), "expired reactions are erased")
_check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation") _check(is_zero_approx(angle_difference(base_rotation, lower_bone.rotation)), "expired reaction restores its captured base rotation")
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void: func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
var authored: Dictionary = stage._authored[rig.get_instance_id()] 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) stage.set_mode(stage.StageMode.PLAY)
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT) rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
rig.global_position += Vector2(220.0, -90.0) rig.global_position += Vector2(220.0, -90.0)
@@ -196,11 +201,11 @@ func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> voi
_check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "PLAY to DIRECT restores the authored stickman position") _check(rig.global_position.distance_to(authored["position"] as Vector2) < 0.01, "PLAY to DIRECT restores the authored stickman position")
_check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "PLAY to DIRECT restores the authored stickman rotation") _check(absf(angle_difference(float(authored["rotation"]), rig.global_rotation)) < 0.001, "PLAY to DIRECT restores the authored stickman rotation")
_check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile") _check(rig.get_facing_profile() == int(authored["facing_profile"]), "PLAY to DIRECT restores the authored facing profile")
for marker_name: String in rig.STAND_POSE: for marker_name: String in StickmanRagdoll.STAND_POSE:
var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D var marker := rig.get_node("IK_Targets/" + marker_name) as Node2D
var expected: Dictionary = rig.STAND_POSE[marker_name] var expected: Dictionary = authored_markers[marker_name]
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % 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(rig.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame") _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: func _test_ghost_collision(stage: Node2D) -> void:
@@ -213,6 +218,7 @@ func _test_ghost_collision(stage: Node2D) -> void:
func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void: func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
stage.set_mode(stage.StageMode.PLAY)
var prop := PropBlock.new() var prop := PropBlock.new()
prop.position = Vector2(50.0, 25.0) prop.position = Vector2(50.0, 25.0)
prop.linear_velocity = Vector2(400.0, 0.0) prop.linear_velocity = Vector2(400.0, 0.0)
@@ -239,8 +245,9 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
_check((emitted_collisions[0]["position"] as Vector2).is_equal_approx(prop.global_position), "PropBlock fallback position is world-space") _check((emitted_collisions[0]["position"] as Vector2).is_equal_approx(prop.global_position), "PropBlock fallback position is world-space")
_check(emitted_separations == [fallback_body], "PropBlock mirrors body_exited with separated") _check(emitted_separations == [fallback_body], "PropBlock mirrors body_exited with separated")
var lower := rig._collision_bodies["left_lower_arm"] as AnimatableBody2D var locomotion: StickmanLocomotion = rig.get_locomotion()
var upper := rig._collision_bodies["left_upper_arm"] as AnimatableBody2D var lower := locomotion.collision_bodies["left_lower_arm"] as AnimatableBody2D
var upper := locomotion.collision_bodies["left_upper_arm"] as AnimatableBody2D
var pair_key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()] var pair_key := "%d:%d" % [rig.get_instance_id(), prop.get_instance_id()]
var rule := { var rule := {
"id": 4500, "id": 4500,
@@ -254,22 +261,23 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
} }
stage._event_rules.clear() stage._event_rules.clear()
stage._event_rules.append(rule) stage._event_rules.append(rule)
var initial_queue_size := rig.queue_size() var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
var initial_runtime_size: int = runner._run_queue.size()
stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(10.0, 20.0), prop) stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(10.0, 20.0), prop)
_check(stage._collision_pairs.has(pair_key), "first part contact creates a rig/prop pair") _check(stage._collision_pairs.has(pair_key), "first part contact creates a rig/prop pair")
_check(rig.queue_size() == initial_queue_size + 1, "first pair contact dispatches a rig-to-prop rule") _check(runner._run_queue.size() == initial_runtime_size + 1, "first pair contact dispatches a rig-to-prop rule")
stage._on_prop_collided(upper, Vector2.RIGHT, Vector2(11.0, 20.0), prop) stage._on_prop_collided(upper, Vector2.RIGHT, Vector2(11.0, 20.0), prop)
var pair: Dictionary = stage._collision_pairs[pair_key] var pair: Dictionary = stage._collision_pairs[pair_key]
_check((pair.get("bodies", {}) as Dictionary).size() == 2, "second part contact joins the active body set") _check((pair.get("bodies", {}) as Dictionary).size() == 2, "second part contact joins the active body set")
_check(rig.queue_size() == initial_queue_size + 1, "second active part does not repeat the pair rule") _check(runner._run_queue.size() == initial_runtime_size + 1, "second active part does not repeat the pair rule")
_check(rig._impact_reactions.has("LeftLowerArm") and rig._impact_reactions.has("LeftUpperArm"), "each newly contacted part can react") _check(locomotion.impact_reactions.has("LeftLowerArm") and locomotion.impact_reactions.has("LeftUpperArm"), "each newly contacted part can react")
stage._on_prop_separated(lower, prop) stage._on_prop_separated(lower, prop)
_check(stage._collision_pairs.has(pair_key), "first part exit retains the pair") _check(stage._collision_pairs.has(pair_key), "first part exit retains the pair")
stage._on_prop_separated(upper, prop) stage._on_prop_separated(upper, prop)
_check(not stage._collision_pairs.has(pair_key), "last part exit clears the pair") _check(not stage._collision_pairs.has(pair_key), "last part exit clears the pair")
stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(12.0, 20.0), prop) stage._on_prop_collided(lower, Vector2.RIGHT, Vector2(12.0, 20.0), prop)
_check(rig.queue_size() == initial_queue_size + 2, "full separation permits a new collision rule") _check(runner._run_queue.size() == initial_runtime_size + 2, "full separation permits a new collision rule")
var prop_ids: Array[int] = [prop.get_instance_id()] var prop_ids: Array[int] = [prop.get_instance_id()]
stage._purge_collision_pairs_for_ids(prop_ids) stage._purge_collision_pairs_for_ids(prop_ids)
_check(not stage._collision_pairs.has(pair_key), "prop deletion purge removes active pairs") _check(not stage._collision_pairs.has(pair_key), "prop deletion purge removes active pairs")
@@ -291,16 +299,16 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
} }
stage._event_rules.clear() stage._event_rules.clear()
stage._event_rules.append(generic_rule) stage._event_rules.append(generic_rule)
var before_generic := rig.queue_size() var before_generic: int = runner._run_queue.size()
stage._on_prop_collided(fallback_body, Vector2.RIGHT, Vector2(13.0, 20.0), prop) stage._on_prop_collided(fallback_body, Vector2.RIGHT, Vector2(13.0, 20.0), prop)
_check(rig.queue_size() == before_generic + 1, "non-stickman contacts preserve prop-to-other event ordering") _check(runner._run_queue.size() == before_generic + 1, "non-stickman contacts preserve prop-to-other event ordering")
var invalid_proxy := Node2D.new() var invalid_proxy := Node2D.new()
invalid_proxy.set_meta("stickman_rig", rig) invalid_proxy.set_meta("stickman_rig", rig)
stage._world.add_child(invalid_proxy) stage._world.add_child(invalid_proxy)
var before_invalid := rig.queue_size() var before_invalid: int = runner._run_queue.size()
stage._on_prop_collided(invalid_proxy, Vector2.RIGHT, Vector2.ZERO, prop) stage._on_prop_collided(invalid_proxy, Vector2.RIGHT, Vector2.ZERO, prop)
_check(rig.queue_size() == before_invalid, "incomplete stickman metadata dispatches no generic rule") _check(runner._run_queue.size() == before_invalid, "incomplete stickman metadata dispatches no generic rule")
var ragdoll_proxy := Node2D.new() var ragdoll_proxy := Node2D.new()
ragdoll_proxy.set_meta("stickman_ragdoll_body", true) ragdoll_proxy.set_meta("stickman_ragdoll_body", true)
@@ -319,18 +327,19 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
func _test_ragdoll_transitions(rig: StickmanRig) -> void: func _test_ragdoll_transitions(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0) rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
_check(not rig._impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll") _check(not locomotion.impact_reactions.is_empty(), "animated rig accepts a reaction before ragdoll")
rig.set_ragdoll(true) rig.set_ragdoll(true)
_check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll") _check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll")
_check(_all_proxy_layers_equal(rig, 0), "ragdoll disables animated collision proxies") _check(_all_proxy_layers_equal(rig, 0), "ragdoll disables animated collision proxies")
_check(rig._impact_reactions.is_empty(), "ragdoll entry clears reactions") _check(locomotion.impact_reactions.is_empty(), "ragdoll entry clears reactions")
rig.request_recovery() rig.request_recovery()
_check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state") _check(rig.state == StickmanRig.RigState.RECOVERING, "recovery request starts the stand-up state")
_check(_all_proxy_layers_equal(rig, 1), "collision proxies stay enabled throughout recovery") _check(_all_proxy_layers_equal(rig, 1), "collision proxies stay enabled throughout recovery")
rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0) rig.apply_collision_reaction("head", Vector2.RIGHT, 400.0)
_check(rig._impact_reactions.is_empty(), "recovering rig ignores visual reactions") _check(locomotion.impact_reactions.is_empty(), "recovering rig ignores visual reactions")
rig.set_ragdoll(true) rig.set_ragdoll(true)
_check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll") _check(rig.state == StickmanRig.RigState.RAGDOLL, "recovery can be interrupted by ragdoll")
@@ -341,7 +350,7 @@ func _test_ragdoll_transitions(rig: StickmanRig) -> void:
func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool: func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
for body: AnimatableBody2D in rig._collision_bodies.values(): for body: AnimatableBody2D in rig.get_locomotion().collision_bodies.values():
if body.collision_layer != expected or body.collision_mask != expected: if body.collision_layer != expected or body.collision_mask != expected:
return false return false
return true return true
@@ -349,8 +358,9 @@ func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
func _proxy_shape_sizes(rig: StickmanRig) -> Dictionary: func _proxy_shape_sizes(rig: StickmanRig) -> Dictionary:
var sizes := {} var sizes := {}
for part_name: String in rig._collision_bodies: var locomotion: StickmanLocomotion = rig.get_locomotion()
var body := rig._collision_bodies[part_name] as AnimatableBody2D for part_name: String in locomotion.collision_bodies:
var body := locomotion.collision_bodies[part_name] as AnimatableBody2D
var shape_node := body.get_node(NodePath("CollisionShape2D")) as CollisionShape2D var shape_node := body.get_node(NodePath("CollisionShape2D")) as CollisionShape2D
if shape_node.shape is CircleShape2D: if shape_node.shape is CircleShape2D:
sizes[part_name] = (shape_node.shape as CircleShape2D).radius sizes[part_name] = (shape_node.shape as CircleShape2D).radius
+34 -28
View File
@@ -161,23 +161,24 @@ func _test_reactive_runtime_lane() -> void:
finished.append({ "action": action, "index": index }) finished.append({ "action": action, "index": index })
) )
rig.start_queue() rig.start_queue()
rig._update_runner(0.0) var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
_check(rig._action_phase == rig.ActionPhase.WAITING, "authored primary wait starts") runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest())
_check(runner._action_phase == runner.ActionPhase.WAITING, "authored primary wait starts")
rig.enqueue_reactive([{ "type": "speak", "text": "One", "duration": 5.0 }]) rig.enqueue_reactive([{ "type": "speak", "text": "One", "duration": 5.0 }])
_check(rig._action_phase == rig.ActionPhase.WAITING, "overlay speak leaves primary wait running") _check(runner._action_phase == runner.ActionPhase.WAITING, "overlay speak leaves primary wait running")
_check(String(rig._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot") _check(String(runner._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot")
rig.enqueue_reactive([{ "type": "speak", "text": "Two", "duration": 5.0 }]) 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(String(runner._overlay_speech.get("text", "")) == "Two", "new overlay speak replaces the previous overlay")
_check(finished.is_empty(), "overlay replacement emits no primary completion") _check(finished.is_empty(), "overlay replacement emits no primary completion")
rig.enqueue_reactive([{ "type": "wait", "duration": 1.0 }]) rig.enqueue_reactive([{ "type": "wait", "duration": 1.0 }])
_check(rig._action_phase == rig.ActionPhase.NONE, "sequential reactive action cancels the active primary") _check(runner._action_phase == runner.ActionPhase.NONE, "sequential reactive action cancels the active primary")
_check(finished.is_empty(), "sequential interruption emits no false action_finished") _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(runner._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future")
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait" _check(String((runner._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") and bool(runner._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane")
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1, _check(int(runner._run_queue[1].get("queue_index", -1)) == 1,
"canceled authored action is abandoned and the next authored item is retained") "canceled authored action is abandoned and the next authored item is retained")
# A takeover must discard the canceled/current item and any pending reactive # A takeover must discard the canceled/current item and any pending reactive
@@ -188,17 +189,17 @@ func _test_reactive_runtime_lane() -> void:
{ "type": "wait", "duration": 20.0 }, { "type": "wait", "duration": 20.0 },
] ]
rig.start_queue() rig.start_queue()
rig._update_runner(0.0) runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest())
rig.enqueue_reactive([ rig.enqueue_reactive([
{ "type": "wait", "duration": 1.0 }, { "type": "wait", "duration": 1.0 },
{ "type": "ragdoll" }, { "type": "ragdoll" },
]) ])
_check(rig._run_queue.size() == 2, "takeover retains itself plus authored future only") _check(runner._run_queue.size() == 2, "takeover retains itself plus authored future only")
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll", _check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll",
"takeover supersedes earlier same-batch primary work") "takeover supersedes earlier same-batch primary work")
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1, _check(int(runner._run_queue[1].get("queue_index", -1)) == 1,
"takeover does not replay the completed or canceled authored item") "takeover does not replay the completed or canceled authored item")
_check(rig._overlay_speech.is_empty(), "takeover cancels the active overlay") _check(runner._overlay_speech.is_empty(), "takeover cancels the active overlay")
_check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue") _check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue")
rig.free() rig.free()
@@ -231,7 +232,8 @@ func _test_spawn_pose_survives_first_solve() -> void:
"spawn solve preserves ghost joint position: %s (delta %.3f)" % [path, position_delta]) "spawn solve preserves ghost joint position: %s (delta %.3f)" % [path, position_delta])
_check(rotation_delta <= 0.02, _check(rotation_delta <= 0.02,
"spawn solve preserves ghost bone rotation: %s (delta %.4f)" % [path, rotation_delta]) "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") var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
_check(ragdoll._spawn_pose.size() == StickmanRagdoll.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers")
rig.free() rig.free()
@@ -240,7 +242,8 @@ func _test_left_walk_finish_pose() -> void:
root.add_child(rig) root.add_child(rig)
await process_frame await process_frame
rig.set_facing_profile(rig.FacingProfile.LEFT) rig.set_facing_profile(rig.FacingProfile.LEFT)
rig._walking = true var locomotion: StickmanLocomotion = rig.get_locomotion()
locomotion._walking = true
rig._set_limb_mods_enabled(true) rig._set_limb_mods_enabled(true)
var stopped_frame: Dictionary = {} var stopped_frame: Dictionary = {}
for joint: String in rig._limb_bones: for joint: String in rig._limb_bones:
@@ -254,7 +257,7 @@ func _test_left_walk_finish_pose() -> void:
var hand_marker := rig.get_node("IK_Targets/Left_Hand") as Marker2D var hand_marker := rig.get_node("IK_Targets/Left_Hand") as Marker2D
var stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0) var stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0)
hand_marker.position = stopped_marker_position hand_marker.position = stopped_marker_position
rig._finish_walk("regression") locomotion._finish_walk()
_check(hand_marker.position.is_equal_approx(stopped_marker_position), _check(hand_marker.position.is_equal_approx(stopped_marker_position),
"walk finish preserves the current animation marker frame") "walk finish preserves the current animation marker frame")
for joint: String in stopped_frame: for joint: String in stopped_frame:
@@ -270,9 +273,9 @@ func _test_left_walk_finish_pose() -> void:
var live_upper := left_arm["upper"] as Bone2D var live_upper := left_arm["upper"] as Bone2D
var live_rotation: float = live_upper.rotation + 0.33 var live_rotation: float = live_upper.rotation + 0.33
live_upper.rotation = live_rotation live_upper.rotation = live_rotation
rig._walking = true locomotion._walking = true
rig._set_limb_mods_enabled(true) rig._set_limb_mods_enabled(true)
rig._cancel_walking(false) locomotion.cancel_walk(false)
_check(absf(angle_difference(live_upper.rotation, live_rotation)) < 0.001, _check(absf(angle_difference(live_upper.rotation, live_rotation)) < 0.001,
"ragdoll-style walk cancellation preserves the current solved limb pose") "ragdoll-style walk cancellation preserves the current solved limb pose")
_check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled, _check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled,
@@ -286,21 +289,22 @@ func _test_stop_clears_head_reaction() -> void:
await process_frame await process_frame
var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig
stage._finalize_placed_node(rig) stage._finalize_placed_node(rig)
var head := rig._reaction_bones.get("Head") as Bone2D var locomotion: StickmanLocomotion = rig.get_locomotion()
var head := locomotion.reaction_bones.get("Head") as Bone2D
var base_rotation: float = head.rotation var base_rotation: float = head.rotation
rig.apply_collision_reaction("head", Vector2.DOWN, 500.0) rig.apply_collision_reaction("head", Vector2.DOWN, 500.0)
rig._apply_impact_reactions(0.01) locomotion.apply_impact_reactions(0.01)
_check(not rig._impact_reactions.is_empty(), "head collision starts a flinch reaction") _check(not locomotion.impact_reactions.is_empty(), "head collision starts a flinch reaction")
stage.set_mode(stage.StageMode.PLAY) stage.set_mode(stage.StageMode.PLAY)
stage.set_mode(stage.StageMode.EDIT) stage.set_mode(stage.StageMode.EDIT)
_check(rig._impact_reactions.is_empty(), "STOP clears the active head reaction") _check(locomotion.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") _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_prop := PropBlock.new()
var stale_proxy := AnimatableBody2D.new() var stale_proxy := AnimatableBody2D.new()
stale_proxy.set_meta("stickman_rig", rig) stale_proxy.set_meta("stickman_rig", rig)
stale_proxy.set_meta("part_name", "head") stale_proxy.set_meta("part_name", "head")
stage._on_prop_collided(stale_proxy, Vector2.DOWN, Vector2.ZERO, stale_prop) 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") _check(locomotion.impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch")
stale_prop.free() stale_prop.free()
stale_proxy.free() stale_proxy.free()
stage.free() stage.free()
@@ -310,16 +314,18 @@ func _test_stop_clears_head_reaction() -> void:
func _test_source_contracts() -> void: func _test_source_contracts() -> void:
var stage_source: String = FileAccess.get_file_as_string("res://scripts/sandbox_stage.gd") 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") var rig_source: String = FileAccess.get_file_as_string("res://scripts/stickman_rig.gd")
var ragdoll_source: String = FileAccess.get_file_as_string("res://scripts/stickman_ragdoll.gd")
_check(stage_source.contains("TERRAIN_DRAG_THRESHOLD_PX"), "terrain uses a drag threshold") _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("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("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("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(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") var runner_source: String = FileAccess.get_file_as_string("res://scripts/stickman_action_runner.gd")
_check(runner_source.contains("_primary_speech") and runner_source.contains("_overlay_speech"), "runner separates primary and overlay speech")
_check(rig_source.contains("_sync_speech_bubble_mirror"), "rig counter-mirrors LEFT 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("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(ragdoll_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") _check(ragdoll_source.contains("_stand_up_generation") and ragdoll_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded")
func _check(condition: bool, label: String) -> void: func _check(condition: bool, label: String) -> void:
+19 -17
View File
@@ -67,6 +67,10 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
# SandboxStage opens a recovery dialog when a prior interactive run left an
# autosave. That native child window survives briefly across this suite's
# short-lived stage fixtures and contaminates camera/navigation assertions.
DirAccess.remove_absolute("user://sandbox_autosave.scene")
print("") print("")
print("========================================================") print("========================================================")
print(" PHASE 4b.1 FIX REGRESSION TEST (headless)") print(" PHASE 4b.1 FIX REGRESSION TEST (headless)")
@@ -362,7 +366,7 @@ func _test_bug3_walk_termination() -> void:
print("--- Bug 3: nav-mode termination + off-mesh direct still works ---") print("--- Bug 3: nav-mode termination + off-mesh direct still works ---")
# On-mesh waypoint near the slab's right edge (the former oscillation zone): # On-mesh waypoint near the slab's right edge (the former oscillation zone):
# the rig must terminate exactly once through the nav branch and stay put. # the rig must terminate exactly once through the nav branch and stay put.
await _walk_case("on-mesh (near slab edge)", Vector2(88.0, -8.0), "nav") await _walk_case("on-mesh (near slab edge)", Vector2(88.0, 0.0), "nav")
# Off-mesh waypoint in open space: direct mode must still reach arrival. # Off-mesh waypoint in open space: direct mode must still reach arrival.
await _walk_case("off-mesh (open space)", Vector2(250.0, 0.0), "direct") await _walk_case("off-mesh (open space)", Vector2(250.0, 0.0), "direct")
@@ -384,24 +388,25 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
_arrived_count = 0 _arrived_count = 0
rig.arrived.connect(_on_arrived) rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0) rig.walk_to(target_feet, 200.0)
_check(not rig._walk_done, "%s: walk not done immediately" % label) var locomotion: StickmanLocomotion = rig.get_locomotion()
_check(locomotion != null and not locomotion._walk_done, "%s: walk not done immediately" % label)
var frames := 0 var frames := 0
var mode_flipped := false var mode_flipped := false
while not rig._walk_done and frames < MAX_WALK_FRAMES: while not locomotion._walk_done and frames < MAX_WALK_FRAMES:
if rig._walk_mode_latched and rig._walk_mode != expected_mode: if locomotion._walk_mode_latched and locomotion._walk_mode != expected_mode:
mode_flipped = true mode_flipped = true
await physics_frame await physics_frame
frames += 1 frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames]) _check(locomotion._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_WALK_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived emission (got %d)" % [label, _arrived_count]) _check(_arrived_count == 1, "%s: exactly one arrived emission (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: steering mode latched" % label) _check(locomotion._walk_mode_latched, "%s: steering mode latched" % label)
_check(rig._walk_mode == expected_mode, _check(locomotion._walk_mode == expected_mode,
"%s: latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode]) "%s: latched to '%s' (got '%s')" % [label, expected_mode, locomotion._walk_mode])
_check(not mode_flipped, "%s: latched mode never flipped mid-walk" % label) _check(not mode_flipped, "%s: latched mode never flipped mid-walk" % label)
# Position snapped to the exact final root target (feet + FOOT_OFFSET). # Position snapped to the exact final root target (feet + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET var final_root: Vector2 = target_feet + StickmanLocomotion.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01, _check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)]) "%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label) _check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
@@ -439,18 +444,15 @@ func _test_bug5_ghost_lifecycle() -> void:
_check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World") _check(ghost.get_parent() == holder, "ghost is parented to the ghost holder, not World")
_check(world.get_children().has(ghost) == false, "ghost is not a selectable World child") _check(world.get_children().has(ghost) == false, "ghost is not a selectable World child")
_check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)") _check(is_equal_approx(ghost.modulate.a, 0.5), "ghost is translucent (alpha 0.5)")
# Block-unit snapped ghost positioning (strides, not the 16-px grid/cursor). # Single-placement preview follows the configured snap grid; footprint stride
# remains exclusively for drag painting.
stage._snap_enabled = true
var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \ var raw_pos: Vector2 = stage._camera.get_global_mouse_position() \
+ stage._spawner.get_spawn_offset("ground") + stage._spawner.get_spawn_offset("ground")
var expected_cell: Vector2i = stage._world_to_terrain_cell(raw_pos) var expected_pos: Vector2 = stage._snap_to_grid(raw_pos)
var expected_pos: Vector2 = stage._terrain_cell_center(expected_cell)
stage._update_ghost_position() stage._update_ghost_position()
_check(ghost.position.distance_to(expected_pos) <= 0.01, _check(ghost.position.distance_to(expected_pos) <= 0.01,
"ghost positioned at the block-unit cell center (got %s, expected %s)" % [ghost.position, expected_pos]) "ghost positioned at the configured snap-grid point (got %s, expected %s)" % [ghost.position, expected_pos])
_check(is_equal_approx(fmod(absf(ghost.position.x), GROUND_STRIDE_X), 0.0),
"ghost x is a multiple of the %dpx stride (got %.1f)" % [GROUND_STRIDE_X, ghost.position.x])
_check(is_equal_approx(fmod(absf(ghost.position.y), GROUND_STRIDE_Y), 0.0),
"ghost y is a multiple of the 32px stride (got %.1f)" % ghost.position.y)
# Drag begin frees the single cursor ghost and builds per-cell ghosts. # Drag begin frees the single cursor ghost and builds per-cell ghosts.
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0))) stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0)))
+32 -23
View File
@@ -21,7 +21,7 @@
# * After Back the rule-builder state is IDLE/empty AND _action_popup is # * After Back the rule-builder state is IDLE/empty AND _action_popup is
# visible again (PopupMenu.visible IS observable headless). # visible again (PopupMenu.visible IS observable headless).
# #
# Bug 3 (head mirror jitter): master_rig.tscn's Head LookAt modification is a # Bug 3 (head mirror jitter): scenes/stickman_rig.tscn's Head LookAt modification is a
# full-range (-180..180), non-inverted, non-local-space constraint. # full-range (-180..180), non-inverted, non-local-space constraint.
# * Constraint flags/band are asserted directly on the instantiated rig. # * Constraint flags/band are asserted directly on the instantiated rig.
# * The Head marker is settled at the walk-left pose (-100,-614), then # * The Head marker is settled at the walk-left pose (-100,-614), then
@@ -261,7 +261,7 @@ func _test_bug3_head_lookat() -> void:
"head bone / Body/Head visual / Head aim marker all resolve") "head bone / Body/Head visual / Head aim marker all resolve")
# 2) Standing-still check at STAND_POSE: no frame-to-frame motion at all. # 2) Standing-still check at STAND_POSE: no frame-to-frame motion at all.
var stand_pos: Vector2 = (RIG.STAND_POSE["Head"] as Dictionary)["pos"] var stand_pos: Vector2 = (StickmanRagdoll.STAND_POSE["Head"] as Dictionary)["pos"]
head_target.position = stand_pos head_target.position = stand_pos
for i: int in WARMUP_FRAMES: for i: int in WARMUP_FRAMES:
await physics_frame await physics_frame
@@ -316,7 +316,7 @@ func _measure_head(head_bone: Bone2D, head_body: Node2D, frames: int) -> Diction
func _test_bug4_reactive_actions() -> void: func _test_bug4_reactive_actions() -> void:
print("") print("")
print("--- Bug 4: enqueue_reactive tags + clear_reactive_actions restores ---") print("--- Bug 4: reactive runtime lane never mutates authored actions ---")
var stage := _new_stage() var stage := _new_stage()
var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0)) var rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
_check(rig != null, "stickman rig spawns for the queue test") _check(rig != null, "stickman rig spawns for the queue test")
@@ -328,6 +328,7 @@ func _test_bug4_reactive_actions() -> void:
rig.queue_action({ "type": "speak", "text": "authored-a", "duration": 2.0 }) rig.queue_action({ "type": "speak", "text": "authored-a", "duration": 2.0 })
rig.queue_action({ "type": "wait", "duration": 1.0 }) rig.queue_action({ "type": "wait", "duration": 1.0 })
_check(rig.queue_size() == 2, "authored queue holds 2 actions") _check(rig.queue_size() == 2, "authored queue holds 2 actions")
var runner := rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
# Cycle 1: a reactive action is injected (rule firing during PLAY), which # Cycle 1: a reactive action is injected (rule firing during PLAY), which
# auto-starts the runner; leaving PLAY stops the queue first, then # auto-starts the runner; leaving PLAY stops the queue first, then
@@ -336,13 +337,13 @@ func _test_bug4_reactive_actions() -> void:
rig.queue_changed.connect(func(): qc1.append(true)) rig.queue_changed.connect(func(): qc1.append(true))
var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }] var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }]
rig.enqueue_reactive(r1) rig.enqueue_reactive(r1)
_check(rig.queue_size() == 3, _check(rig.queue_size() == 2,
"reactive injection grows the queue to 3 (got %d)" % rig.queue_size()) "reactive injection leaves the authored queue at 2 (got %d)" % rig.queue_size())
_check(_count_reactive(rig) == 1, _check(runner._run_queue.size() == 2 and _runtime_reactive_count(runner) == 0 and not runner._overlay_speech.is_empty(),
"injected action is tagged reactive=true") "overlay speak leaves only authored items in the primary runtime lane")
_check(rig.is_queue_running(), _check(not rig.is_queue_running(),
"enqueue_reactive auto-resumes the runner from IDLE") "overlay speak does not start the primary queue")
_check(qc1.size() == 1, "enqueue_reactive emits queue_changed") _check(qc1.is_empty(), "runtime-only injection does not emit authored queue_changed")
rig.stop_queue() rig.stop_queue()
rig.clear_reactive_actions() rig.clear_reactive_actions()
@@ -351,7 +352,7 @@ func _test_bug4_reactive_actions() -> void:
_check(_action_types(rig) == ["speak", "wait"], _check(_action_types(rig) == ["speak", "wait"],
"queue types after clear are exactly the authored actions (got %s)" "queue types after clear are exactly the authored actions (got %s)"
% str(_action_types(rig))) % str(_action_types(rig)))
_check(qc1.size() == 2, "clear_reactive_actions emits queue_changed on change") _check(qc1.is_empty(), "runtime cleanup does not emit authored queue_changed")
# Cycle 2: a bigger reactive burst then stop + clear again - counts must # Cycle 2: a bigger reactive burst then stop + clear again - counts must
# stay stable (no 1,2,3 accumulation across repeated Play sessions). # stay stable (no 1,2,3 accumulation across repeated Play sessions).
@@ -360,10 +361,10 @@ func _test_bug4_reactive_actions() -> void:
{ "type": "ragdoll" }, { "type": "ragdoll" },
] ]
rig.enqueue_reactive(r2) rig.enqueue_reactive(r2)
_check(rig.queue_size() == 4, _check(rig.queue_size() == 2 and runner._run_queue.size() == 3,
"second injection grows to 4 (got %d)" % rig.queue_size()) "takeover keeps 2 authored future items behind one reactive action")
_check(_count_reactive(rig) == 2, _check(bool(runner._run_queue[0].get("reactive", false)),
"second burst tags both actions reactive") "takeover action leads the runtime lane as reactive")
rig.stop_queue() rig.stop_queue()
rig.clear_reactive_actions() rig.clear_reactive_actions()
_check(rig.queue_size() == 2, _check(rig.queue_size() == 2,
@@ -372,25 +373,25 @@ func _test_bug4_reactive_actions() -> void:
"queue types stay stable across two reactive cycles (got %s)" "queue types stay stable across two reactive cycles (got %s)"
% str(_action_types(rig))) % str(_action_types(rig)))
# clear_reactive_actions() must be a no-op while the runner is executing. # Runtime cleanup may run while executing, but it never mutates authored work.
rig.start_queue() rig.start_queue()
_check(rig.is_queue_running(), "runner is EXECUTING after start_queue") _check(rig.is_queue_running(), "runner is EXECUTING after start_queue")
var r3: Array[Dictionary] = [{ "type": "recover" }] var r3: Array[Dictionary] = [{ "type": "recover" }]
rig.enqueue_reactive(r3) rig.enqueue_reactive(r3)
_check(rig.queue_size() == 3, _check(rig.queue_size() == 2 and bool(runner._run_queue[0].get("reactive", false)),
"reactive action can still append while the runner is executing") "reactive takeover enters runtime lane while authored queue stays unchanged")
var qc3: Array = [] var qc3: Array = []
rig.queue_changed.connect(func(): qc3.append(true)) rig.queue_changed.connect(func(): qc3.append(true))
rig.clear_reactive_actions() rig.clear_reactive_actions()
_check(rig.queue_size() == 3 and _count_reactive(rig) == 1, _check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0,
"clear_reactive_actions is a no-op while EXECUTING (reactive action stays)") "clear_reactive_actions removes runtime reactive work while preserving authored actions")
_check(qc3.is_empty(), _check(qc3.is_empty(),
"no queue_changed emitted by the EXECUTING no-op") "runtime cleanup emits no authored queue_changed")
rig.stop_queue() rig.stop_queue()
_check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue") _check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue")
rig.clear_reactive_actions() rig.clear_reactive_actions()
_check(rig.queue_size() == 2 and _count_reactive(rig) == 0, _check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0,
"after stop, clear drops the injected reactive action (queue 2)") "after stop, authored queue remains stable at 2")
await _free_stage(stage) await _free_stage(stage)
@@ -407,6 +408,14 @@ func _count_reactive(rig: StickmanRig) -> int:
return n return n
func _runtime_reactive_count(runner: StickmanActionRunner) -> int:
var count := 0
for item: Dictionary in runner._run_queue:
if bool(item.get("reactive", false)):
count += 1
return count
func _action_types(rig: StickmanRig) -> Array[String]: func _action_types(rig: StickmanRig) -> Array[String]:
var out: Array[String] = [] var out: Array[String] = []
for a: Dictionary in rig.get_queue(): for a: Dictionary in rig.get_queue():
+22 -13
View File
@@ -15,6 +15,7 @@ extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn") const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
const RIG := preload("res://scripts/stickman_rig.gd") const RIG := preload("res://scripts/stickman_rig.gd")
const LOCOMOTION := preload("res://scripts/stickman_locomotion.gd")
const SETTLE_FRAMES := 60 const SETTLE_FRAMES := 60
const MAX_FRAMES := 300 const MAX_FRAMES := 300
@@ -68,27 +69,33 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
_arrived_count = 0 _arrived_count = 0
rig.arrived.connect(_on_arrived) rig.arrived.connect(_on_arrived)
rig.walk_to(target_feet, 200.0) rig.walk_to(target_feet, 200.0)
_check(not rig._walk_mode_latched, "%s: mode not latched before map sync" % label) var locomotion: StickmanLocomotion = rig.get_locomotion()
_check(locomotion != null, "%s: locomotion component is available" % label)
if locomotion == null:
stage.queue_free()
await process_frame
return
_check(not locomotion._walk_mode_latched, "%s: mode not latched before map sync" % label)
var frames := 0 var frames := 0
while not rig._walk_done and frames < MAX_FRAMES: while not locomotion._walk_done and frames < MAX_FRAMES:
await physics_frame await physics_frame
frames += 1 frames += 1
_check(rig._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames]) _check(locomotion._walk_done, "%s: walk finished within %d frames (used %d)" % [label, MAX_FRAMES, frames])
_check(_arrived_count == 1, "%s: exactly one arrived signal (got %d)" % [label, _arrived_count]) _check(_arrived_count == 1, "%s: exactly one arrived signal (got %d)" % [label, _arrived_count])
_check(rig._walk_mode_latched, "%s: mode is latched" % label) _check(locomotion._walk_mode_latched, "%s: mode is latched" % label)
_check(rig._walk_mode == expected_mode, _check(locomotion._walk_mode == expected_mode,
"%s: mode latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode]) "%s: mode latched to '%s' (got '%s')" % [label, expected_mode, locomotion._walk_mode])
# Final position equals the snapped root target (feet target + FOOT_OFFSET). # Final position equals the snapped root target (feet target + FOOT_OFFSET).
var final_root: Vector2 = target_feet + RIG.FOOT_OFFSET var final_root: Vector2 = target_feet + LOCOMOTION.FOOT_OFFSET
_check(rig.global_position.distance_to(final_root) <= 0.01, _check(rig.global_position.distance_to(final_root) <= 0.01,
"%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)]) "%s: position snapped to final root (dist=%.3f)" % [label, rig.global_position.distance_to(final_root)])
# Body-bob settle: Torso marker restored to STAND_POSE (0,10). # Completion freezes the solved final animation frame; it must not be reset
# or drift while the root is at rest.
var torso = rig.get_node_or_null(NodePath("IK_Targets/Torso")) var torso = rig.get_node_or_null(NodePath("IK_Targets/Torso"))
var frozen_torso_position: Vector2 = Vector2.ZERO
if torso != null: if torso != null:
var stand_torso: Dictionary = RIG.STAND_POSE["Torso"] frozen_torso_position = (torso as Node2D).position
var expected_pos: Vector2 = stand_torso.get("pos", Vector2(0, 10)) _check(frozen_torso_position.is_finite(), "%s: Torso final animation marker is finite" % label)
_check((torso as Node2D).position.distance_to(expected_pos) <= 1.0,
"%s: Torso marker restored to standing pose (dist=%.3f)" % [label, (torso as Node2D).position.distance_to(expected_pos)])
else: else:
_check(false, "%s: IK_Targets/Torso missing" % label) _check(false, "%s: IK_Targets/Torso missing" % label)
# Post-arrival stability for 60 physics frames. # Post-arrival stability for 60 physics frames.
@@ -101,6 +108,9 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
break break
_check(settled and rig.global_position == p0, _check(settled and rig.global_position == p0,
"%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES]) "%s: position unchanged for %d frames after arrival" % [label, SETTLE_FRAMES])
if torso != null:
_check((torso as Node2D).position.distance_to(frozen_torso_position) <= 0.01,
"%s: Torso final animation marker remains frozen" % label)
_check(not rig.is_walking(), "%s: is_walking false after arrival" % label) _check(not rig.is_walking(), "%s: is_walking false after arrival" % label)
stage.queue_free() stage.queue_free()
@@ -114,4 +124,3 @@ func _check(condition: bool, message: String) -> void:
else: else:
_failures += 1 _failures += 1
print("FAIL: " + message) print("FAIL: " + message)
+24 -15
View File
@@ -73,8 +73,10 @@ func _add_rule(stage: Node2D, source: Node2D, area: TriggerArea, target: Stickma
func _clear_actions(rig: StickmanRig) -> void: func _clear_actions(rig: StickmanRig) -> void:
rig.action_queue.clear()
rig.stop_queue() rig.stop_queue()
rig.clear_queue()
var runner := rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
runner.hide_speech()
func _test_spawner_and_rule_contract() -> void: func _test_spawner_and_rule_contract() -> void:
@@ -102,31 +104,35 @@ func _test_source_specific_edges() -> void:
var world_area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs() var world_area_rect: Rect2 = (area.global_transform * area.get_area_rect()).abs()
_check(not world_area_rect.has_point(stage._movable_point(source)), "regression setup keeps feet outside the leg-level area") _check(not world_area_rect.has_point(stage._movable_point(source)), "regression setup keeps feet outside the leg-level area")
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.size() == 1, "source crossing queues exactly one consequence") var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner
var action: Dictionary = target.action_queue[0] _check(not runner._overlay_speech.is_empty(), "source crossing starts one overlay consequence")
_check(action.get("type", "") == "speak" and action.get("text", "") == "source only" and bool(action.get("reactive", false)), "speak consequence is normalized as a reactive action") _check(String(runner._overlay_speech.get("text", "")) == "source only", "speak consequence is normalized into overlay speech")
# SceneTree.physics_frame is emitted before node _physics_process callbacks; # SceneTree.physics_frame is emitted before node _physics_process callbacks;
# wait through the following tick so the runner can begin the action. # wait through the following tick so the runner can begin the action.
await physics_frame await physics_frame
await physics_frame await physics_frame
_check(target._speech_active and target._speech_bubble != null and target._speech_bubble.visible, "queued consequence executes as a visible speech bubble") _check(runner._speech_bubble != null and runner._speech_bubble.visible, "queued consequence executes as a visible speech bubble")
runner._overlay_speech["time_left"] = 0.5
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.size() == 1, "polling while inside does not repeat") _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 0.5), "polling while inside does not repeat")
_move_rig_body_center(source, Vector2(-1500.0, 0.0)) _move_rig_body_center(source, Vector2(-1500.0, 0.0))
stage._update_area_entry() stage._update_area_entry()
# The swept-bounds detector deliberately retains the previous pose for one
# poll, so a second far-away sample commits the exit edge.
stage._update_area_entry()
_move_rig_body_center(source, area.global_position) _move_rig_body_center(source, area.global_position)
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.size() == 2, "exit then re-entry queues one additional consequence") _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "exit then re-entry restarts the consequence")
_clear_actions(target) _clear_actions(target)
_move_rig_body_center(other, area.global_position) _move_rig_body_center(other, area.global_position)
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.is_empty(), "different stickman cannot trigger source-specific rule") _check(runner._overlay_speech.is_empty(), "different stickman cannot trigger source-specific rule")
var prop := stage._spawner.spawn("prop", Vector2(-1500.0, 0.0)) as PropBlock var prop := stage._spawner.spawn("prop", Vector2(-1500.0, 0.0)) as PropBlock
stage._finalize_placed_node(prop) stage._finalize_placed_node(prop)
prop.freeze = false prop.freeze = false
prop.global_position = Vector2.ZERO prop.global_position = Vector2.ZERO
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.is_empty(), "unfrozen prop cannot trigger source-specific rule") _check(runner._overlay_speech.is_empty(), "unfrozen prop cannot trigger source-specific rule")
await _free_stage(stage) await _free_stage(stage)
@@ -141,16 +147,18 @@ func _test_wildcard_and_pair_independence() -> void:
stage._update_area_entry() stage._update_area_entry()
_move_rig_body_center(source_a, area_a.global_position) _move_rig_body_center(source_a, area_a.global_position)
stage._update_area_entry() stage._update_area_entry()
var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner
runner._overlay_speech["time_left"] = 0.25
_move_rig_body_center(source_a, Vector2(-1500.0, 0.0)) _move_rig_body_center(source_a, Vector2(-1500.0, 0.0))
stage._update_area_entry() stage._update_area_entry()
_move_rig_body_center(source_b, area_a.global_position) _move_rig_body_center(source_b, area_a.global_position)
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.size() == 2, "wildcard source fires independently for multiple movables") _check(is_equal_approx(float(runner._overlay_speech.get("time_left", 0.0)), 1.0), "wildcard source fires independently for multiple movables")
_clear_actions(target) _clear_actions(target)
_add_rule(stage, source_b, area_b, target, "second pair") _add_rule(stage, source_b, area_b, target, "second pair")
_move_rig_body_center(source_b, area_b.global_position) _move_rig_body_center(source_b, area_b.global_position)
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.size() == 1, "second area/source pair has independent edge state") _check(String(runner._overlay_speech.get("text", "")) == "second pair", "second area/source pair has independent edge state")
await _free_stage(stage) await _free_stage(stage)
@@ -163,15 +171,16 @@ func _test_transforms_points_and_cleanup() -> void:
var source := _spawn_rig(stage, Vector2(-300.0, 0.0)) var source := _spawn_rig(stage, Vector2(-300.0, 0.0))
var target := _spawn_rig(stage, Vector2(2000.0, 0.0)) var target := _spawn_rig(stage, Vector2(2000.0, 0.0))
_add_rule(stage, source, area, target, "transformed") _add_rule(stage, source, area, target, "transformed")
_check(stage._movable_point(source) == source.global_position - STICKMAN_RIG.FOOT_OFFSET, "stickman overlap point is feet, not root") _check(stage._movable_point(source) == source.global_position - StickmanLocomotion.FOOT_OFFSET, "stickman overlap point is feet, not root")
_move_rig_body_center(source, area.global_position) _move_rig_body_center(source, area.global_position)
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.size() == 1, "current transformed area overlaps visible stickman geometry") var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner
_check(String(runner._overlay_speech.get("text", "")) == "transformed", "current transformed area overlaps visible stickman geometry")
_clear_actions(target) _clear_actions(target)
var no_rule_area := _spawn_area(stage, Vector2(-500.0, 0.0)) var no_rule_area := _spawn_area(stage, Vector2(-500.0, 0.0))
source.global_position = no_rule_area.global_position + STICKMAN_RIG.FOOT_OFFSET source.global_position = no_rule_area.global_position + StickmanLocomotion.FOOT_OFFSET
stage._update_area_entry() stage._update_area_entry()
_check(target.action_queue.is_empty(), "area with no matching rule is a no-op") _check(runner._overlay_speech.is_empty(), "area with no matching rule is a no-op")
var stale_rule := _add_rule(stage, source, area, target, "stale") var stale_rule := _add_rule(stage, source, area, target, "stale")
var stale_id := target.get_instance_id() var stale_id := target.get_instance_id()
target.free() target.free()
+131
View File
@@ -0,0 +1,131 @@
extends SceneTree
## Focused deterministic regression checks for the Phase 6a terrain controller.
## Runtime terrain/ramp integration remains covered by the existing stage suites.
const RIG_SCENE := preload("res://scenes/stickman_rig.tscn")
const RIG := preload("res://scripts/stickman_rig.gd")
const LOC := preload("res://scripts/stickman_locomotion.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var rig := RIG_SCENE.instantiate() as Node2D
root.add_child(rig)
await physics_frame
var controller := rig as StickmanRig
_check(controller != null, "master rig exposes StickmanRig")
if controller != null:
_test_air_pose_and_momentum(controller)
_test_grounded_reset_and_ragdoll_cleanup(controller)
_test_landing_timeline_and_barrier(controller)
rig.queue_free()
await process_frame
if _failures == 0:
print("RESULT: ALL PASSED (%d assertions, 0 failures)" % _checks)
quit(0)
else:
print("RESULT: %d FAILURE(S) out of %d assertions" % [_failures, _checks])
quit(1)
func _test_air_pose_and_momentum(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
var left_hand := rig._get_ik_marker("Left_Hand")
var right_hand := rig._get_ik_marker("Right_Hand")
var left_base := left_hand.position
var right_base := right_hand.position
locomotion._enter_airborne(300.0)
_check(locomotion.terrain_motion == LOC.TerrainMotion.AIRBORNE, "enter_airborne selects AIRBORNE")
_check(is_equal_approx(locomotion.terrain_air_velocity.x, 300.0), "takeoff keeps signed horizontal velocity")
locomotion.terrain_air_velocity.y = LOC.AIR_POSE_FULL_SPEED
locomotion._air_time = 0.0
locomotion._apply_air_pose(1.0)
_check(locomotion.air_pose_weight > 0.99 and locomotion.air_pose_weight <= 1.0, "air pose weight clamps at full fall speed")
_check(left_hand.position != left_base and right_hand.position != right_base, "air pose offsets both hands")
locomotion._restore_air_pose()
_check(left_hand.position.is_equal_approx(left_base) and right_hand.position.is_equal_approx(right_base), "air pose restores exact captured markers")
locomotion.terrain_motion = LOC.TerrainMotion.GROUNDED
locomotion._enter_airborne(0.0)
var forward_left_base: Vector2 = locomotion.air_pose_base["Left_Hand"]
var forward_right_base: Vector2 = locomotion.air_pose_base["Right_Hand"]
locomotion.terrain_air_velocity.y = LOC.AIR_HAND_FORWARD_START_SPEED + 20.0
locomotion._air_time = 0.0
locomotion._apply_air_pose(1.0)
_check(left_hand.position.x > forward_left_base.x and right_hand.position.x > forward_right_base.x, "hands reach forward before lift engages")
_check(is_equal_approx(left_hand.position.y, forward_left_base.y) and is_equal_approx(right_hand.position.y, forward_right_base.y), "early forward reach does not lift hands")
locomotion.terrain_air_velocity.y = LOC.AIR_POSE_FULL_SPEED
locomotion._apply_air_pose(1.0)
_check(left_hand.position.y < forward_left_base.y and right_hand.position.y < forward_right_base.y, "hands lift after forward reach")
locomotion._restore_air_pose()
locomotion.terrain_air_velocity = Vector2(300.0, 0.0)
locomotion.terrain_air_velocity.x *= exp(-LOC.AIR_DRAG_X * 0.1)
_check(locomotion.terrain_air_velocity.x > 0.0 and locomotion.terrain_air_velocity.x < 300.0, "air drag preserves forward momentum while decaying it")
func _test_grounded_reset_and_ragdoll_cleanup(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
locomotion.terrain_following_enabled = true
locomotion.terrain_motion = LOC.TerrainMotion.GROUNDED
locomotion._last_grounded_walk_velocity_x = 999.0
rig._physics_process(0.0)
_check(is_zero_approx(locomotion._last_grounded_walk_velocity_x), "grounded idle frame clears stale walk velocity")
locomotion._enter_airborne(-240.0)
locomotion.terrain_air_velocity.y = 640.0
locomotion._apply_air_pose(0.1)
rig.set_ragdoll(true)
_check(rig.state == RIG.RigState.RAGDOLL, "direct ragdoll enters from ordinary airborne motion")
_check(locomotion.terrain_motion == LOC.TerrainMotion.DISABLED and locomotion.air_pose_base.is_empty(), "direct ragdoll clears airborne terrain ownership")
_check(locomotion.terrain_air_velocity.is_zero_approx() and is_zero_approx(locomotion.air_pose_weight), "direct ragdoll clears air velocity and pose weight")
rig.snap_to_standing()
func _test_landing_timeline_and_barrier(rig: StickmanRig) -> void:
var locomotion: StickmanLocomotion = rig.get_locomotion()
var torso := rig._get_ik_marker("Torso")
var left_leg := rig._get_ik_marker("Left_Leg")
var right_leg := rig._get_ik_marker("Right_Leg")
locomotion._enter_airborne(0.0)
var base_torso := torso.position
var left_hand := rig._get_ik_marker("Left_Hand")
var right_hand := rig._get_ik_marker("Right_Hand")
var left_hand_base := left_hand.position
var right_hand_base := right_hand.position
var contacts := {
"Left_Leg": {"surface": left_leg.global_position + Vector2(0.0, 1200.0), "surface_valid": true},
"Right_Leg": {"surface": right_leg.global_position + Vector2(0.0, 1300.0), "surface_valid": true},
}
locomotion.air_fall_distance = LOC.HARD_LANDING_MIN_FALL_HEIGHT
locomotion._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(locomotion.landing_compression, LOC.LANDING_COMPRESSION_MIN), "minimum hard fall height maps to minimum compression")
_check(locomotion.is_action_blocked(), "landing blocks primary queue")
locomotion._update_landing_impact(LOC.LANDING_COMPRESS_DURATION)
_check(is_equal_approx(torso.position.y, base_torso.y + LOC.LANDING_COMPRESSION_MIN), "impact reaches deepest compression at 0.08s")
_check(left_hand.position.y > left_hand_base.y and right_hand.position.y > right_hand_base.y, "hard landing sharply drives hands downward")
_check(left_leg.global_position.y < torso.global_position.y + 500.0 and right_leg.global_position.y < torso.global_position.y + 500.0, "landing ignores stale pre-motion solved contacts")
locomotion._update_landing_impact(LOC.LANDING_RECOVER_DURATION)
_check(locomotion.terrain_motion == LOC.TerrainMotion.GROUNDED, "landing returns to GROUNDED after 0.30s")
_check(torso.position.is_equal_approx(base_torso), "landing restores torso exactly")
_check(left_hand.position.is_equal_approx(left_hand_base) and right_hand.position.is_equal_approx(right_hand_base), "landing restores hand bases exactly")
_check(not locomotion.is_action_blocked(), "queue barrier clears after landing")
locomotion._enter_airborne(0.0)
locomotion.air_fall_distance = LOC.HARD_LANDING_MAX_FALL_HEIGHT
locomotion._begin_landing(0.0, {"world_pos": torso.global_position}, contacts)
_check(is_equal_approx(locomotion.landing_compression, LOC.LANDING_COMPRESSION_MAX), "maximum hard fall height maps to maximum compression")
locomotion._cancel_landing_impact(true)
locomotion._restore_air_pose()
func _check(condition: bool, message: String) -> void:
_checks += 1
if condition:
print("PASS: " + message)
else:
_failures += 1
print("FAIL: " + message)
@@ -0,0 +1 @@
uid://c2w7tlb4cke6t
+17
View File
@@ -0,0 +1,17 @@
extends SceneTree
const SPAWNER := preload("res://scripts/stage_spawner.gd")
const CARD := preload("res://scripts/ui/asset_card.gd")
var _failures: int = 0
func _init() -> void:
var world := Node2D.new()
var spawner = SPAWNER.new(world)
var entries: Array[Dictionary] = spawner.get_palette_entries()
_check(entries.size() == 6, "six registry cards")
_check(String(entries[0].get("placement_id", "")) == "ground", "registry id retained")
var card = CARD.new(); card.configure(entries[0]); _check(card.payload().get("kind", "") == "terrain", "card copies payload")
print("PASS" if _failures == 0 else "FAIL: %d" % _failures); quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition: _failures += 1; push_error("Phase 7 palette: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dt5c3h6p0msb2
+22
View File
@@ -0,0 +1,22 @@
extends SceneTree
const THEME_SCRIPT := preload("res://scripts/sandbox_theme.gd")
var _failures: int = 0
func _init() -> void:
var theme = THEME_SCRIPT.new()
_check(theme.load_from(), "committed v2 theme loads")
_check(theme.integer("fonts.sizes.tooltip", 0) == 18, "v2 font sizes resolve")
_check(theme.color("mode_colors.edit", Color.BLACK) == Color("#22c6ff"), "mode color resolves")
var first: StyleBoxFlat = theme.stylebox("palette")
first.bg_color = Color.RED
var second: StyleBoxFlat = theme.stylebox("palette")
_check(second.bg_color != Color.RED, "stylebox callers receive isolated copies")
_check(not theme.swatches().is_empty() and not theme.size_presets().is_empty(), "widget defaults available")
print("PASS" if _failures == 0 else "FAIL: %d" % _failures)
quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition:
_failures += 1
push_error("Phase 7 theme: " + message)
+1
View File
@@ -0,0 +1 @@
uid://dho8gbkh2u0k4
+60
View File
@@ -0,0 +1,60 @@
extends SceneTree
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
DirAccess.remove_absolute("user://sandbox_autosave.scene")
var stage: Node2D = STAGE_SCENE.instantiate()
root.add_child(stage)
stage.get_window().size = Vector2i(1280, 720)
await process_frame
_check(stage._mode_badge.position.x >= stage._palette_dock.size.x, "badge is outside palette dock")
_check(stage._mode_frame.offset_left >= stage._palette_dock.size.x, "mode frame is inset beyond palette")
_check(stage._mode_buttons.size() == 3 and (stage._mode_buttons[0] as Button).custom_minimum_size.y >= 44.0, "mode buttons are kid-sized")
_check(stage._file_menu.get_theme_font_size("font_size") == 22, "File menu uses themed font size")
_check((stage._mode_buttons[0] as Button).get_theme_font_size("font_size") == 24, "mode switcher uses themed font size")
var mode_switcher: Control = (stage._mode_buttons[0] as Button).get_parent() as Control
_check(absf(mode_switcher.get_global_rect().get_center().x - 640.0) <= 1.0, "mode switcher remains horizontally centered at a known viewport width")
_check(mode_switcher.get_parent().mouse_filter == Control.MOUSE_FILTER_IGNORE, "full-width mode centering wrapper is mouse-transparent")
_check(stage._file_menu.get_popup().get_theme_font_size("font_size") == 26, "File popup uses independently themed font size")
_check(stage._unsaved_changes_dialog.get_theme_font_size("font_size") == 22, "dialog font-size theme metric is applied")
_check(stage._unsaved_changes_dialog.get_ok_button().get_theme_font_size("font_size") == 22, "dialog button inherits themed font size")
_check(stage._unsaved_changes_dialog.get_label().horizontal_alignment == HORIZONTAL_ALIGNMENT_CENTER and stage._unsaved_changes_dialog.get_label().get_theme_font_size("font_size") == 26, "dialog message is centered and larger")
var card: Button = stage._palette_buttons["area"]
_check(card.clip_contents and card.custom_minimum_size.y >= 100.0, "palette cards clip and reserve vertical space")
stage.set_mode(stage.StageMode.DIRECT)
_check(is_zero_approx(stage._mode_frame.offset_left), "Direct frame reclaims hidden palette width")
stage.set_mode(stage.StageMode.EDIT)
_check(stage._mode_frame.offset_left >= stage._palette_dock.size.x, "Edit frame restores palette inset")
_check(not stage.is_point_in_stage_frame(Vector2(1, 80), Rect2(100, 50, 600, 400)), "frame helper rejects a palette-side position")
_check(stage.is_point_in_stage_frame(Vector2(200, 200), Rect2(100, 50, 600, 400)), "stage frame helper accepts an inner viewport position")
stage._grid_size = 15.0
stage._snap_enabled = true
stage.set_placement_mode("step")
stage._place_at(Vector2(22.0, 23.0))
var placed: Node2D = stage._world.get_child(stage._world.get_child_count() - 1) as Node2D
_check(placed.position == Vector2(15.0, 30.0), "single terrain uses configured grid snap")
stage._open_selector("prop")
await process_frame
var selector_card: Variant = stage._selector._grid.get_child(0)
_check(selector_card.custom_minimum_size.x >= 190.0 and selector_card.custom_minimum_size.y >= 220.0, "selector uses enlarged themed card metrics")
_check(String(selector_card._label.text).length() > 0 and selector_card.thumbnail_rect().custom_minimum_size.x >= 156.0, "selector card shows a name beneath an enlarged thumbnail")
stage._on_selector_cancelled()
stage.set_mode(stage.StageMode.DIRECT)
stage._pending_walk_target = true
var right := InputEventMouseButton.new(); right.button_index = MOUSE_BUTTON_RIGHT; right.pressed = true
stage._handle_world_click(right)
_check(not stage._pending_walk_target, "RMB cancels an active direct target")
stage.queue_free()
await process_frame
print("PASS" if _failures == 0 else "FAIL: %d" % _failures)
quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition:
_failures += 1
push_error("Phase 7 UI follow-up: " + message)
+1
View File
@@ -0,0 +1 @@
uid://cnnlc36tnlqil
+17
View File
@@ -0,0 +1,17 @@
extends SceneTree
const COLOR_SWATCH := preload("res://scripts/ui/color_swatch_selector.gd")
const SIZE_TOGGLE := preload("res://scripts/ui/preset_size_toggle.gd")
const FONT_PICKER := preload("res://scripts/ui/font_preset_picker.gd")
const EMOJI_GRID := preload("res://scripts/ui/emoji_quick_grid.gd")
var _failures: int = 0
func _init() -> void:
var swatch = COLOR_SWATCH.new(); swatch.configure([Color.RED], Color.RED); _check(swatch.get_selected() == Color.RED, "swatch selection")
var sizes = SIZE_TOGGLE.new(); sizes.configure([{"id":"small","label":"Small","value":20}], "small"); _check(sizes.get_selected_value() == 20, "size value")
var fonts = FONT_PICKER.new(); fonts.configure([{"id":"ui","label":"UI"}], "ui"); _check(fonts.get_selected_id() == "ui", "font id")
var emoji = EMOJI_GRID.new(); emoji.configure(["😀"], 1); _check(emoji.get_child_count() == 1, "emoji controls")
print("PASS" if _failures == 0 else "FAIL: %d" % _failures); quit(0 if _failures == 0 else 1)
func _check(condition: bool, message: String) -> void:
if not condition: _failures += 1; push_error("Phase 7 widgets: " + message)
+1
View File
@@ -0,0 +1 @@
uid://btjgad0h7xge3
+122
View File
@@ -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)
@@ -0,0 +1 @@
uid://d4q7x1m8n2vkp
+90
View File
@@ -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)
@@ -0,0 +1 @@
uid://dsuireu51jue0
+101
View File
@@ -0,0 +1,101 @@
extends SceneTree
const RIG_SCENE_PATH := "res://scenes/stickman_rig.tscn"
const EXPECTED_UID := "uid://dr5ef3l2var2s"
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var scene_source := FileAccess.get_file_as_string(RIG_SCENE_PATH)
_check(scene_source.contains('uid="%s"' % EXPECTED_UID), "relocated scene retains its UID")
var packed := load(RIG_SCENE_PATH) as PackedScene
_check(packed != null, "relocated rig scene loads")
if packed == null:
_finish()
return
var rig := packed.instantiate() as StickmanRig
_check(rig != null, "scene root remains StickmanRig")
root.add_child(rig)
await process_frame
await physics_frame
var locomotion := rig.get_node_or_null("RigComponents/Locomotion") as StickmanLocomotion
var ragdoll := rig.get_node_or_null("RigComponents/Ragdoll") as StickmanRagdoll
var runner := rig.get_node_or_null("RigComponents/ActionRunner") as StickmanActionRunner
_check(locomotion != null, "typed Locomotion component resolves")
_check(ragdoll != null, "typed Ragdoll component resolves")
_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")
_check(rig.get_queue() == assigned, "root queue query preserves compatibility")
rig.queue_action({"type": "speak", "text": "modular", "duration": 0.1})
_check(runner.action_queue.size() == 2, "root queue mutation delegates")
rig.set_ragdoll(true)
await physics_frame
_check(rig.state == StickmanRig.RigState.RAGDOLL, "root remains ragdoll state authority")
_check(ragdoll.get_bodies().size() == 10, "ragdoll component owns ten physics bodies")
rig.snap_to_standing()
await physics_frame
_check(rig.state == StickmanRig.RigState.ANIMATED, "snap facade returns to ANIMATED")
_check(ragdoll.get_bodies().is_empty(), "snap destroys component-owned ragdoll bodies")
rig.queue_free()
await process_frame
await process_frame
var orphan := root.find_child("RagdollBodyContainer", true, false)
_check(orphan == null, "freeing rig leaves no ragdoll container orphan")
_finish()
func _check(condition: bool, label: String) -> void:
_checks += 1
if condition:
print("PASS: %s" % label)
else:
_failures += 1
push_error("FAIL: %s" % label)
func _finish() -> void:
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)
@@ -0,0 +1 @@
uid://c8ed0hlc7bl6r