Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbce638bc5 | ||
|
|
db4ff8d939 | ||
|
|
9a22322dba | ||
|
|
1a2a43c805 | ||
|
|
baa19a9341 | ||
|
|
2071ee03b7 | ||
|
|
3e39fd9287 | ||
|
|
08247eadef |
@@ -1,5 +1,5 @@
|
||||
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_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
@@ -10,15 +10,19 @@ You are the Lead Systems Architect. Refer to `AGENTS.md` for project directory s
|
||||
## Responsibilities
|
||||
|
||||
1. **Analyze:** Read existing code patterns before designing.
|
||||
2. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
|
||||
3. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
|
||||
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. **Design:** Define API payload shapes, GDScript type hints, and signal flows.
|
||||
4. **Log:** Append trade-offs to `docs/tech_debt_and_optimizations.md`.
|
||||
|
||||
## 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:
|
||||
- Target Nodes / Classes modified.
|
||||
- Signal interfaces & static type contracts.
|
||||
- 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.
|
||||
"""
|
||||
@@ -1,11 +1,13 @@
|
||||
# AGENTS.md — stickman (Godot 4.4)
|
||||
# AGENTS.md — stickman (Godot 4.7.1)
|
||||
|
||||
## 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`.
|
||||
- **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`)
|
||||
|
||||
## Command-line testing & debug playback
|
||||
|
||||
- **Godot binary (4.7.1) — resolve per platform.** Commands below use `<godot>` as shorthand:
|
||||
- macOS: `/Applications/Godot.app/Contents/MacOS/Godot`
|
||||
- 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.
|
||||
|
||||
## 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
|
||||
are authored per-panel (each panel supports **multiple shapes** with Z-ordering) and
|
||||
assembled in a "Whole Stickman" preview that supports translation, rotation, and scale.
|
||||
|
||||
Stickman Studio is a kid-friendly 2D scene-building and directing project—not only a
|
||||
stick-figure editor. Its broader product direction is an approachable moviemaker where
|
||||
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
|
||||
|
||||
- **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,
|
||||
but keep it present — it is required for the legacy `stick.tscn` rig.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `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.
|
||||
Writes `FILE_VERSION "1.5"`; auto-migrates `"1.0"`–`"1.4"` files on load. Coordinates cross-panel
|
||||
@@ -44,7 +57,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
otherwise. `pivot`/`length`/`proportions` are write-only metadata — never read back on load,
|
||||
recomputed on every save.
|
||||
- **Phase 9 Round 5 guide-offset export:** each part's save dict also gains `"guide_offset":
|
||||
{x, y}` = (part bbox center in preview space) − (guide joint in preview space), computed in
|
||||
{x, y}` = (part bbox center in preview space) − (guide joint in preview space), computed in
|
||||
`_collect_all_shape_data()` as `(pos + pivot) - _whole_preview.get_guide_joint_preview(...)` —
|
||||
a **pure master-space delta** (both points are preview-world coordinates, so panel-size terms
|
||||
cancel). The part→joint map is `const GUIDE_JOINT_FOR_PART` (head→"Neck" — the head bone's
|
||||
@@ -124,7 +137,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
gizmos and drag highlight. The guide is **centered** at the default view and after
|
||||
`Reset Views`, computed at draw time from the live preview size via
|
||||
`_guide_to_preview()` = `(master_pos - GUIDE_FIGURE_CENTER) * GUIDE_SCALE +
|
||||
preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
|
||||
preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
|
||||
world-space fixture that moves with pan/zoom. Joint positions are **hardcoded
|
||||
constants** derived from the `master_rig.tscn` rest pose (`GUIDE_JOINTS`: 13 anchors
|
||||
Head/Neck/Shoulders/Elbows/Wrists/Hips/Knees/Ankles;
|
||||
@@ -149,7 +162,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
whole dict as a single shape (`shapes = [pd]`), so v1.0/v1.1 single-shape `.stk` files (e.g.
|
||||
`stickmen/test.stk`) mount as visible geometry instead of being cleared to nothing.
|
||||
- **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y =
|
||||
-proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every
|
||||
-proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every
|
||||
other part — it clears the `Body/Head` node's inline `@tool` circle script via
|
||||
`set_script(null)` and mounts `.stk` head shapes as `Line2D`/`Polygon2D`. Dead helpers
|
||||
`_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` were removed.
|
||||
@@ -220,7 +233,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
(`j_prime = E(J_raw)` or `f_pt_prime = E(F_pt_raw)`) is **nearest the part's stored guide
|
||||
joint** (`center − guide_offset`): if `d_far < d_joint` (strict) the far end attaches
|
||||
(`anchor = f_pt_prime`, `v = −f_prime`), else the family end (`anchor = j_prime`, `v =
|
||||
f_prime`). This replaces the per-side family choice **and** the 180° flip heuristic for the
|
||||
f_prime`). This replaces the per-side family choice **and** the 180° flip heuristic for the
|
||||
`guide_offset` case, fixing the **lower left leg** (knee now at the joint, was the ankle)
|
||||
and **lower right arm** (elbow now at the joint, was the wrist), both 180° off their bones
|
||||
because the user's drawn-side conventions are inconsistent per part. The nearest-end rule
|
||||
@@ -291,9 +304,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
intentionally static.
|
||||
- **Phase 10 ragdoll state system:** `StickmanRig` owns a reversible `ANIMATED ⇄ RAGDOLL`
|
||||
physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
|
||||
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
|
||||
int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() ->
|
||||
bool` / `request_recovery()` / `snap_to_standing()` (Phase 2 Sandbox: instantly destroys the
|
||||
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
|
||||
int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() ->
|
||||
bool` / `request_recovery()` / `snap_to_standing()` (Phase 2 Sandbox: instantly destroys the
|
||||
ragdoll or cancels the recovery tween, sets the 6 IK targets directly to `STAND_POSE`, re-shows
|
||||
`Body/*`, re-enables IK, and returns to `ANIMATED` with no stand-up glide). `_physics_process()`
|
||||
→ `_track_momentum(delta)` caches the rig
|
||||
@@ -304,7 +317,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
it first would revert the bones to the authored rest pose, popping the figure), then hides
|
||||
`Body/*` and disables the IK stack **immediately** — an instant handoff with no crossfade (the
|
||||
ragdoll spawns at exactly the same pose, so a fade would only read as ghosting), sets `state =
|
||||
RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D`
|
||||
RAGDOLL` + emits. `_build_ragdoll()` creates a `Node2D`
|
||||
container `"RagdollBodyContainer"` under the rig's **parent** (world root; fallback
|
||||
`get_tree().current_scene`) and populates it from the `RAGDOLL_BODIES` table (**10**
|
||||
`RigidBody2D`: torso `CapsuleShape2D` radius 12 mass 8.0, head `CircleShape2D` radius 100 mass
|
||||
@@ -325,20 +338,18 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
harness "Knock Up" button. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is
|
||||
**not** modified.
|
||||
- **Phase 11 instant handoff + recovery:** `_update_rest_detection()` (only when `state ==
|
||||
RAGDOLL`)
|
||||
RAGDOLL`)
|
||||
reads `_ragdoll_bodies["torso"]`: when its linear/angular velocity drops below
|
||||
`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`
|
||||
on it calls `_start_recovery()`.
|
||||
- **Pre-ragdoll capture:** `_enter_ragdoll()` first calls `_capture_pre_ragdoll_state()`,
|
||||
on it calls `_start_recovery()`. - **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
|
||||
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
|
||||
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`
|
||||
decomposition (±π) cancels out. This snapshot is the recovery **END target** (possibly a
|
||||
mid-walk pose; the animation is not resumed).
|
||||
- **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
|
||||
mid-walk pose; the animation is not resumed). - **Recovery capture + landing:** `_start_recovery()` (also `request_recovery()`, no-op
|
||||
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
|
||||
probes the terrain for the ground surface under the fallen figure via `_probe_ground_y()`
|
||||
@@ -348,10 +359,9 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
clamp at snap time. It then `_destroy_ragdoll()`s, calls `_reanchor_root_to_landing()`
|
||||
(translates 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)`, `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
|
||||
the LEFT X-mirror), sets `state = RECOVERING` + emits.
|
||||
- **Snap:** `_snap_skeleton_to_pose()` writes the captured pose: the torso marker via
|
||||
the LEFT X-mirror), sets `state = RECOVERING` + emits. - **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`;
|
||||
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
|
||||
@@ -365,22 +375,19 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`Skeleton2D.set_process_internal(true)`, and re-asserts the Torso marker's
|
||||
`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
|
||||
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`)
|
||||
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`)
|
||||
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
|
||||
`_natural_joint_target(current, target, offset)` (offset = `bone_angle_lower −
|
||||
bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space
|
||||
bone_angle_upper`; the through-straight rule is evaluated in the joint's rendered space
|
||||
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
|
||||
disabled during the tween. The baked `"stand_up"` animation is **not** played (a fixed
|
||||
first keyframe can never match an arbitrary ragdoll rest pose).
|
||||
- **Finish:** `_on_stand_up_finished()` restores the pre-ragdoll facing profile + per-joint
|
||||
first keyframe can never match an arbitrary ragdoll rest pose). - **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,
|
||||
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
|
||||
poses).
|
||||
- `_pin_mirrored_head_rotation()` pins the head only while `ANIMATED`. Interruptible:
|
||||
poses). - `_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(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated
|
||||
`set_ragdoll` calls are idempotent. `is_in_ragdoll()` stays
|
||||
@@ -403,17 +410,43 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
recreate a flinch after STOP. PLAY→DIRECT restoration also resets ANIMATED rigs' authored
|
||||
transform, facing, IK markers, and torso rotation. Null-guards + `push_warning` prefixed
|
||||
`StickmanRig:` throughout.
|
||||
- **Phase 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
|
||||
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)`
|
||||
(feet → root; matches `StageSpawner.STICKMAN_FOOT_OFFSET`), `NAV_AGENT_LOCAL_POS := (0, 385)`
|
||||
(`== -FOOT_OFFSET`), `ARRIVE_DISTANCE` / `NAV_PATH_DESIRED_DISTANCE` / `NAV_TARGET_DESIRED_DISTANCE`
|
||||
/ `SPEECH_BUBBLE_OFFSET`. New signals `arrived`, `action_started(action, index)`,
|
||||
`action_finished(action, index)`, `queue_finished` (on completion, not stop), `queue_changed`
|
||||
(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
|
||||
+ 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
|
||||
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;
|
||||
@@ -558,7 +591,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`offset_top = 40.0`, `offset_right = -8.0`, `offset_left = -COORDS_PANEL_WIDTH` (320.0),
|
||||
`grow_vertical = GROW_DIRECTION_END` + `grow_horizontal = GROW_DIRECTION_BEGIN`
|
||||
(auto-height/width, grows left so text never runs off-screen), `mouse_filter =
|
||||
MOUSE_FILTER_IGNORE` on the panel (the label itself stays interactive for selection);
|
||||
MOUSE_FILTER_IGNORE` on the panel (the label itself stays interactive for selection);
|
||||
StyleBoxFlat
|
||||
bg `Color(0,0,0,0.55)`, border `Color(1,1,1,0.12)` w1, corner radius 4, content margin 8.
|
||||
Consts `COORDS_PANEL_WIDTH := 320.0` and `COORD_BONE_PATHS` (10 Skeleton2D-relative bone
|
||||
@@ -621,7 +654,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
(`BUILD_SOLIDS`) in `POLYGON` mode, or `CollisionShape2D` + `CircleShape2D` (48-segment
|
||||
radial loop, `CIRCLE_SEGMENTS = 48`) in `CIRCLE` mode, toggled via `shape_type`. Exported
|
||||
properties: `shape_type` (`@export_enum("Polygon","Circle")`), `polygon_points:
|
||||
PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_width`, and
|
||||
PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_width`, and
|
||||
`material_preset` (`@export_enum("None","Wood","Rubber","Cardboard","Metal")`). Presets set
|
||||
`mass` + `physics_material_override` via static `mass_for()`/`physics_material()`/`tint_for()`
|
||||
(Wood: mass 3.0, friction 0.6, bounce 0.1; Rubber: mass 0.5, friction 0.9, bounce 0.85;
|
||||
@@ -637,17 +670,17 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
stickman-prop contacts; the old geometric feet-point stickman test is removed.
|
||||
- `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor**
|
||||
(Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect()
|
||||
-> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill +
|
||||
-> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill +
|
||||
dashed border). **No physics and no signals** — it is a pure geometric region evaluated by
|
||||
`sandbox_stage.gd`'s event engine (`_update_area_entry`) for `entered_area` rules.
|
||||
- `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory
|
||||
(**not used by the editor**):
|
||||
- `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` /
|
||||
`create_plank(length := 160.0, thickness := 16.0)` / `create_triangle(base := 56.0, height
|
||||
:= 48.0)` — primitive generators returning shape-payload dictionaries with default
|
||||
:= 48.0)` — primitive generators returning shape-payload dictionaries with default
|
||||
dimensions + color themes (wood/rubber/metal/cardboard).
|
||||
- `spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity
|
||||
:= Vector2.ZERO) -> PropBlock` — instantiates a `PropBlock`, applies the payload
|
||||
:= Vector2.ZERO) -> PropBlock` — instantiates a `PropBlock`, applies the payload
|
||||
(`shape_type` + geometry + colors via `_apply_shape_payload()`, sanitizing polygon points
|
||||
through `TerrainUtils.sanitize_points()`), sets `linear_velocity` after `add_child` (only
|
||||
when non-zero), and returns the spawned prop.
|
||||
@@ -675,7 +708,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
returns early if one exists, so rapid toggling leaves no duplicate proxies.
|
||||
- **Phase 10 external forces:** a **"Knock Up"** button (`_knock_up()`) tests impulses beyond
|
||||
gravity: when the rig is in RAGDOLL mode it calls `StickmanRig.apply_ragdoll_velocity_boost(
|
||||
KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ragdoll body,
|
||||
KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ragdoll body,
|
||||
preserving internal structure), and applies the same upward velocity delta to every dynamic
|
||||
prop (`RigidBody2D` child of `_environment`) so the whole pile flies up together.
|
||||
- **Phase 11 recovery UI:** a `Label("Rest")` + `SpinBox` (`_rest_timeout_spinbox`, min 0.1 /
|
||||
@@ -689,13 +722,14 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
` is_in_ragdoll()` is false).
|
||||
- `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
|
||||
`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` /
|
||||
`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` +
|
||||
`freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs
|
||||
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()`,
|
||||
`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).
|
||||
@@ -742,18 +776,19 @@ 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
|
||||
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
|
||||
hit-tested) carrying a procedural `NavigationPolygon` from per-`TerrainBlock` convex decomposition
|
||||
(`_rebake_navigation()`, `Geometry2D.decompose_polygon_in_convex` + fan triangulation, world-space
|
||||
via `block.transform * p`); a `_nav_dirty` flag re-bakes once per frame (`_process`) on terrain
|
||||
hit-tested) carrying a procedural `NavigationPolygon` from exposed upward-surface ribbons and
|
||||
short step bridges (`_rebake_navigation()`, world-space via `block.transform * p`); a `_nav_dirty`
|
||||
flag re-bakes once per frame (`_process`) on terrain
|
||||
place / move / rotate (`transform_committed`) / delete. Instantiates a `StageDirectorVisuals`
|
||||
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()`
|
||||
`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` →
|
||||
`mark_dirty()`. `_set_build_controls_visible()` also hides the Direct button in PLAY.
|
||||
- **Phase 4 triggers & event system:** adds a When→Then **rule system** — `_event_rules:
|
||||
Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`,
|
||||
Array[Dictionary]` of `{id, trigger, actions}` rules (trigger types: `arrived_at_waypoint`,
|
||||
`action_finished`, `speech_finished`, `entered_area`, `collided`; action types reuse the Phase
|
||||
3a set: `walk_to`/`speak`/`wait`/`ragdoll`/`recover`). Rules persist across Play/Edit mode
|
||||
toggles but are **not** saved to disk (Phase 5). A geometric **area-entry engine** runs in PLAY
|
||||
@@ -789,12 +824,22 @@ 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
|
||||
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.
|
||||
- **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
|
||||
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
|
||||
`_build_ui()` with theme/font overrides applied via `AssetSelector.apply_font`), backed by a
|
||||
dim backdrop `_selector_dim` (a black `ColorRect` at `SELECTOR_DIM_ALPHA` = 0.5, `mouse_filter =
|
||||
MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer` behind the selector, shown only while the selector
|
||||
MOUSE_FILTER_IGNORE`, on the UI `CanvasLayer` behind the selector, shown only while the selector
|
||||
is open). State
|
||||
`_selector_open`/`_selector_kind`, `_thumbnail_queue`, `_thumbnail_busy`. `_ready()` builds
|
||||
`_stickman_library`/`_thumbnail_cache` and the two thumbnail renderer `Node`s
|
||||
@@ -889,7 +934,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
are `static func` and cannot run in a `const`). Public API: `static get_entries()` (builds once,
|
||||
returns the 4 templates), `static get_ids()`, `static get_entry(id) -> Dictionary` (`{}` if
|
||||
unknown), `static get_default_id() -> String` (`"crate"`). Template dicts carry `{id, name,
|
||||
material_preset, material_label, payload}` — Crate/Wood (`create_box()` + `WOOD`), Ball/Rubber
|
||||
material_preset, material_label, payload}` — Crate/Wood (`create_box()` + `WOOD`), Ball/Rubber
|
||||
(`create_ball()` + `RUBBER`), Plank/Metal (`create_plank()` + `METAL`), Triangle/Cardboard
|
||||
(`create_triangle()` + `CARDBOARD`) — generator color themes already match the presets.
|
||||
- `scripts/thumbnails/thumbnail_cache.gd` — `class_name ThumbnailCache`, `extends RefCounted`
|
||||
@@ -915,7 +960,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
identical to the rig actually placed on stage, not a re-implementation of the editor preview.
|
||||
- `scripts/thumbnails/prop_thumbnail.gd` — `class_name PropThumbnail`, `extends Node` (Phase 3b,
|
||||
**not used by the editor**). Renders a prop template into a `Texture2D`. `const SIZE :=
|
||||
Vector2i(200, 200)`. `_ready()` builds the same offscreen `SubViewport` + `Camera2D` pattern as
|
||||
Vector2i(200, 200)`. `_ready()` builds the same offscreen `SubViewport` + `Camera2D` pattern as
|
||||
`StickmanThumbnail`. `render(payload, material_preset) -> Texture2D` mirrors `PropBlock`'s
|
||||
geometry into a plain `Node2D` with a `Polygon2D` (fill) + `Line2D` (closed loop, round
|
||||
joints/caps) — **not** a `RigidBody2D`, so nothing falls under gravity inside the viewport;
|
||||
@@ -931,13 +976,13 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
stickmen, then `popup_centered()`), `set_entries(entries)` (used by Refresh — resets page +
|
||||
thumbnails), `set_thumbnail(entry, tex)` (lazy handoff from the stage's drain), `close()`, and
|
||||
`apply_font(ui_font, emoji_font)` (walks the authored controls). `_ready()` sets `exclusive =
|
||||
true` and wires the footer buttons; `_unhandled_input` maps `KEY_ESCAPE` → `cancelled.emit()`
|
||||
true` and wires the footer buttons; `_unhandled_input` maps `KEY_ESCAPE` → `cancelled.emit()`
|
||||
(the Window's built-in Esc close is not relied on). Re-centers on window resize: the root's
|
||||
`size_changed` signal re-runs `popup_centered()` while the popup is visible. Cell build:
|
||||
`_rebuild()` frees the `%GridContainer` children, toggles the empty-state `%EmptyLabel`,
|
||||
shows/hides Prev/Next/page label by page count, and slices the current page via the
|
||||
**static pure** `page_bounds(total, page, page_size) -> Dictionary {start, end, total,
|
||||
page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect`
|
||||
page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect`
|
||||
(cached/placeholder texture) + a name `Label` + (prop only) a material-badge `Label`; cells emit
|
||||
`item_selected`. No cell is pre-highlighted on open (the previous selection highlight styling
|
||||
was removed). The `.tscn` is a **minimal shell**
|
||||
@@ -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)`.
|
||||
- `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()`
|
||||
+ 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
|
||||
`World`; no hit-testing.
|
||||
- Scenes:
|
||||
@@ -1023,11 +1068,12 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
palette-button selector grids.
|
||||
|
||||
### Body-part data model
|
||||
|
||||
- 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_leg`, `right_lower_leg`.
|
||||
- Shape dictionary: `{ "shape_type": String, "points": Array[{x,y}], "color": "#hex",
|
||||
"closed": bool, "vertex_flags": Array[int] }`.
|
||||
"closed": bool, "vertex_flags": Array[int] }`.
|
||||
- `shape_type` values: `"line"`, `"rectangle"`, `"circle"`, `""` (empty).
|
||||
Descriptive tag in Phase 2 — rendering uses `closed`.
|
||||
- `closed`: `true` = filled + closed outline, `false` = open outline-only.
|
||||
@@ -1035,7 +1081,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`1` = user-created via "Create Point" (hollow rectangle).
|
||||
- **Phase 4:** A panel stores a `shapes[]` array of shape dictionaries. Z-order = array
|
||||
position (first = back, last = front). Per-part data includes `{shapes[], position,
|
||||
rotation, scale}`.
|
||||
rotation, scale}`.
|
||||
- **Phase 8:** per-part data adds `pivot` `{x, y}` (local bounding-box center = rotation
|
||||
origin) and `length` (float, bbox extent along the segment axis) for format v1.4; the
|
||||
`.stk` root also gains a top-level `proportions` object (5 rig bone lengths). All three
|
||||
@@ -1048,6 +1094,7 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
||||
`"1.0"`–`"1.4"` files auto-migrate on load).
|
||||
|
||||
### settings.json (Phase 6)
|
||||
|
||||
- Persisted editor preferences written to `settings.json` via `_save_settings()` and
|
||||
loaded in `_load_settings()`.
|
||||
- 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()`.
|
||||
|
||||
### Legacy scene (do not delete)
|
||||
|
||||
- `stick.tscn` — the original rigged/animated figure using `Skeleton2D` + IK targets +
|
||||
`RemoteTransform2D` + `Line2D` limbs, plus an embedded `@tool` script drawing the head
|
||||
circle. Animations: "walk", "walk_to", "RESET". **Not** the current main scene; kept for
|
||||
future animation/rigging phases.
|
||||
|
||||
## Editor conventions
|
||||
|
||||
- `.godot/` is gitignored; never edit it manually.
|
||||
- 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.
|
||||
|
||||
@@ -510,12 +510,19 @@ The **Director Tool** (Phase 3a) turns the Sandbox Stage into a mini director's
|
||||
**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).
|
||||
- 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).
|
||||
|
||||
**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:**
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
**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)
|
||||
|
||||
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
|
||||
{
|
||||
"version": "1.0",
|
||||
"fonts": {
|
||||
"ui_font": "",
|
||||
"emoji_font": "",
|
||||
"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.
|
||||
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
|
||||
in `user://sandbox_settings.json`. Missing or malformed theme data falls back
|
||||
to built-in defaults; missing fonts fall back to `ThemeDB.fallback_font` with a
|
||||
warning. Speech font/size/emoji lists are reusable-widget data reserved for a
|
||||
future speech-customization UI and do not yet alter saved speech actions.
|
||||
|
||||
**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/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_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/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). |
|
||||
@@ -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 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 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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
| 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) |
|
||||
| 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-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-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). |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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": ""
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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?
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,35 +1,11 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"fonts": {
|
||||
"ui_font": "",
|
||||
"emoji_font": "",
|
||||
"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,
|
||||
"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"
|
||||
}
|
||||
"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":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": {"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} },
|
||||
"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":{"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":["😀","😂","😍","😮","😢","😡","👍","❤️"] }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
[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" 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"]
|
||||
script/source = "@tool
|
||||
@@ -54,11 +57,7 @@ joint_two_bone2d_node = NodePath("Torso/LeftUpperLeg/LeftLowerLeg")
|
||||
bone_index = 1
|
||||
bone2d_node = NodePath("Torso/Head")
|
||||
target_nodepath = NodePath("../IK_Targets/Head")
|
||||
enable_constraint = true
|
||||
constraint_angle_min = -180.0
|
||||
constraint_angle_max = 180.0
|
||||
constraint_angle_invert = false
|
||||
constraint_in_localspace = false
|
||||
enable_constraint = false
|
||||
|
||||
[sub_resource type="SkeletonModificationStack2D" id="SkeletonModificationStack2D_j4hao"]
|
||||
resource_local_to_scene = true
|
||||
@@ -190,7 +189,7 @@ tracks/1/keys = {
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
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/loop_wrap = true
|
||||
tracks/2/keys = {
|
||||
@@ -202,7 +201,7 @@ tracks/2/keys = {
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
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/loop_wrap = true
|
||||
tracks/3/keys = {
|
||||
@@ -266,7 +265,7 @@ tracks/1/keys = {
|
||||
tracks/2/type = "value"
|
||||
tracks/2/imported = false
|
||||
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/loop_wrap = true
|
||||
tracks/2/keys = {
|
||||
@@ -278,7 +277,7 @@ tracks/2/keys = {
|
||||
tracks/3/type = "value"
|
||||
tracks/3/imported = false
|
||||
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/loop_wrap = true
|
||||
tracks/3/keys = {
|
||||
@@ -325,6 +324,17 @@ _data = {
|
||||
[node name="Master" type="Node2D" unique_id=319103476]
|
||||
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="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]
|
||||
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]
|
||||
position = Vector2(-96, 200)
|
||||
target_position = Vector2(0, 200)
|
||||
@@ -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))
|
||||
_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:
|
||||
for child: Node in node.get_children():
|
||||
|
||||
+28
-41
@@ -10,8 +10,9 @@ const COLUMNS := 4
|
||||
const ROWS := 3
|
||||
const PAGE_SIZE := COLUMNS * ROWS
|
||||
|
||||
const CELL_MIN_SIZE := Vector2(140.0, 168.0)
|
||||
const THUMB_SIZE := Vector2(120.0, 120.0)
|
||||
const CELL_MIN_SIZE := Vector2(190.0, 220.0)
|
||||
const THUMB_SIZE := Vector2(156.0, 156.0)
|
||||
const ASSET_CARD := preload("res://scripts/ui/asset_card.gd")
|
||||
|
||||
var kind: String = ""
|
||||
|
||||
@@ -22,6 +23,7 @@ var _cell_texrects: Dictionary = {}
|
||||
var _placeholder_tex: ImageTexture = null
|
||||
var _ui_font: Font = null
|
||||
var _emoji_font: Font = null
|
||||
var _sandbox_theme: Variant = null
|
||||
|
||||
@onready var _title_label: Label = %TitleLabel
|
||||
@onready var _grid: GridContainer = %GridContainer
|
||||
@@ -87,6 +89,21 @@ func apply_font(ui_font: Font, emoji_font: Font) -> void:
|
||||
for c: Control in controls:
|
||||
_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:
|
||||
if visible and event is InputEventKey:
|
||||
@@ -152,46 +169,16 @@ func _rebuild() -> void:
|
||||
|
||||
func _build_cell(entry: Dictionary) -> Control:
|
||||
var key := _entry_key(entry)
|
||||
var cell := Button.new()
|
||||
cell.custom_minimum_size = CELL_MIN_SIZE
|
||||
cell.toggle_mode = false
|
||||
var cell: Variant = ASSET_CARD.new()
|
||||
# Selector cells inherit the popup theme. Avoid a per-card state override so
|
||||
# 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.pressed.connect(_on_cell_pressed.bind(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
|
||||
cell.activated.connect(func(_payload: Dictionary) -> void: _on_cell_pressed(entry))
|
||||
var tex: Texture2D = _thumbnails.get(key, null)
|
||||
tex_rect.texture = tex if tex != null else _get_placeholder()
|
||||
vbox.add_child(tex_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)
|
||||
cell.set_thumbnail(tex if tex != null else _get_placeholder())
|
||||
_cell_texrects[key] = cell.thumbnail_rect()
|
||||
|
||||
return cell
|
||||
|
||||
@@ -211,7 +198,7 @@ func _apply_font_to(c: Control) -> void:
|
||||
|
||||
func _get_placeholder() -> ImageTexture:
|
||||
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))
|
||||
_placeholder_tex = ImageTexture.create_from_image(img)
|
||||
return _placeholder_tex
|
||||
|
||||
@@ -25,7 +25,7 @@ const POSE_PATHS: Dictionary = {
|
||||
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_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
|
||||
## IK targets directly from the captured ragdoll pose instead.
|
||||
const POSE_DOWN: Dictionary = {
|
||||
|
||||
@@ -3,7 +3,7 @@ extends Node2D
|
||||
## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2).
|
||||
##
|
||||
## 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
|
||||
## 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.
|
||||
@@ -16,7 +16,7 @@ const MIN_ZOOM: float = 0.25
|
||||
const MAX_ZOOM: float = 3.0
|
||||
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)
|
||||
## 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:
|
||||
var rig := RIG_SCENE.instantiate() as StickmanRig
|
||||
if rig == null:
|
||||
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
|
||||
push_warning("PhysicsTestHarness: failed to instantiate scenes/stickman_rig.tscn.")
|
||||
return
|
||||
rig.position = RIG_SPAWN_POSITION
|
||||
# Manual recovery only ("Recover Now"): auto-recover is left off so the
|
||||
|
||||
@@ -191,6 +191,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
if _empty_label != null:
|
||||
_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:
|
||||
for child: Node in node.get_children():
|
||||
|
||||
@@ -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))
|
||||
_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:
|
||||
for child: Node in node.get_children():
|
||||
|
||||
@@ -196,6 +196,9 @@ func apply_font(ui_font: Font, emoji_font: Font, sizes: Dictionary) -> void:
|
||||
if _empty_label != null:
|
||||
_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:
|
||||
for child: Node in node.get_children():
|
||||
|
||||
+412
-87
@@ -24,6 +24,8 @@ const ASSET_SELECTOR := preload("res://scenes/asset_selector.tscn")
|
||||
const STICKMAN_LIBRARY := preload("res://scripts/stickman_library.gd")
|
||||
const PROP_LIBRARY := preload("res://scripts/prop_library.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 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 RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer"
|
||||
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 THEME_PATH := "res://sandbox_theme.json"
|
||||
@@ -165,9 +169,13 @@ var _selector_dim: ColorRect = null
|
||||
var _browse_dialog: FileDialog = null
|
||||
var _thumbnail_queue: Array[Dictionary] = []
|
||||
var _thumbnail_busy: bool = false
|
||||
var _thumbnail_generation: int = 0
|
||||
|
||||
var _mode_buttons: Dictionary = {} # int (StageMode) -> Button
|
||||
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_cursor_coords: Label
|
||||
var _direct_hint_label: Label
|
||||
@@ -264,6 +272,7 @@ var _nav_dirty: bool = true
|
||||
|
||||
## Parsed sandbox_theme.json (or {} if missing/malformed).
|
||||
var _theme: Dictionary = {}
|
||||
var _sandbox_theme = null
|
||||
var _theme_grid_default: float = DEFAULT_GRID_SIZE
|
||||
var _accent_edit: Color = Color("#22c6ff")
|
||||
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>"
|
||||
## and "<rig_id>:<prop_id>".
|
||||
var _area_overlap: Dictionary = {}
|
||||
var _area_previous_bounds: Dictionary = {}
|
||||
var _collision_pairs: Dictionary = {}
|
||||
|
||||
## Lightweight toast text + countdown (cleared in _process on expiry).
|
||||
@@ -418,6 +428,7 @@ func _ready() -> void:
|
||||
_apply_theme_to_visuals()
|
||||
_rebuild_grid_cells()
|
||||
_apply_grid_settings()
|
||||
_update_stage_chrome_layout()
|
||||
_apply_mode_frame()
|
||||
_sync_mode_buttons()
|
||||
_refresh_mode_badge()
|
||||
@@ -460,6 +471,18 @@ func _physics_process(_delta: float) -> 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:
|
||||
# Touchpad pinch zoom.
|
||||
_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:
|
||||
delete_selected()
|
||||
KEY_ESCAPE:
|
||||
if _capture_kind != CaptureKind.NONE:
|
||||
if not _palette_drag_payload.is_empty():
|
||||
_cancel_palette_drag()
|
||||
elif _capture_kind != CaptureKind.NONE:
|
||||
_cancel_capture()
|
||||
elif _rule_step != RuleStep.IDLE:
|
||||
_cancel_rule_build()
|
||||
@@ -526,6 +551,16 @@ func _handle_world_click(mb: InputEventMouseButton) -> void:
|
||||
return
|
||||
if mb.button_index == MOUSE_BUTTON_RIGHT:
|
||||
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()
|
||||
return
|
||||
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:
|
||||
if mode == current_mode:
|
||||
return
|
||||
var previous_mode: StageMode = current_mode
|
||||
if mode == StageMode.PLAY and not _cli_startup:
|
||||
if not _save_scene_to_path(AUTOSAVE_PATH, true):
|
||||
push_warning("SandboxStage: autosave before Play failed.")
|
||||
current_mode = mode
|
||||
if not _palette_drag_payload.is_empty():
|
||||
_cancel_palette_drag()
|
||||
match mode:
|
||||
StageMode.EDIT:
|
||||
_enter_edit_mode()
|
||||
_enter_edit_mode(previous_mode)
|
||||
StageMode.DIRECT:
|
||||
_enter_direct_mode()
|
||||
_enter_direct_mode(previous_mode)
|
||||
StageMode.PLAY:
|
||||
_enter_play_mode()
|
||||
_sync_mode_buttons()
|
||||
@@ -630,12 +668,13 @@ func set_mode(mode: StageMode) -> void:
|
||||
|
||||
|
||||
func _enter_edit_shared() -> void:
|
||||
# Snap stickmen straight back to their standing pose/position (no stand-up
|
||||
# tween glide), stopping any director queue first.
|
||||
# Restore stickmen to their authored spawn/ghost pose (no stand-up tween
|
||||
# glide), stopping any director queue first.
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
rig.stop_queue()
|
||||
rig.set_terrain_following_enabled(false)
|
||||
rig.snap_to_standing()
|
||||
rig.clear_reactive_actions()
|
||||
# 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)
|
||||
# Clear edge-trigger state so PLAY overlaps do not leak stale results.
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.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()
|
||||
_clear_director_pending()
|
||||
_set_edit_controls_visible(true)
|
||||
@@ -691,10 +734,14 @@ func _clear_director_pending() -> void:
|
||||
_director_visuals.clear_edit_waypoint()
|
||||
|
||||
|
||||
## Direct is internally an "edit-with-direct" state: same freeze/stand/gizmo
|
||||
## side effects, but with the spawner/grid controls hidden, the director hint
|
||||
## shown, and any placement/selection/pending-target cleared.
|
||||
func _enter_direct_mode() -> void:
|
||||
## Direct is internally an "edit-with-direct" state: the same frozen world,
|
||||
## but with the spawner/grid controls hidden, the director hint shown, and any
|
||||
## placement/selection/pending-target cleared.
|
||||
func _enter_direct_mode(previous_mode: StageMode) -> void:
|
||||
# EDIT and DIRECT share the same non-simulating world. Preserve the exact
|
||||
# placed stickman pose between them; a transition from PLAY still needs the
|
||||
# full cleanup and authored-pose restore.
|
||||
if previous_mode != StageMode.EDIT:
|
||||
_enter_edit_shared()
|
||||
set_placement_mode("")
|
||||
_selection.clear_selection()
|
||||
@@ -715,6 +762,7 @@ func _enter_play_mode() -> void:
|
||||
_set_direct_controls_visible(false)
|
||||
# Clear edge-trigger state so PLAY overlaps start from a clean slate.
|
||||
_area_overlap.clear()
|
||||
_area_previous_bounds.clear()
|
||||
_collision_pairs.clear()
|
||||
for node: Node2D in _world_children_selectable():
|
||||
if node is RigidBody2D:
|
||||
@@ -724,6 +772,7 @@ func _enter_play_mode() -> void:
|
||||
if node is STICKMAN_RIG:
|
||||
var rig := node as STICKMAN_RIG
|
||||
rig.auto_recover = false
|
||||
rig.begin_terrain_fall()
|
||||
rig.start_queue()
|
||||
rig_count += 1
|
||||
_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:
|
||||
if _snap_enabled:
|
||||
world_pos = _snap_to_grid(world_pos)
|
||||
_free_ghost()
|
||||
_drag_painted.clear()
|
||||
_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.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())
|
||||
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:
|
||||
@@ -1512,7 +1563,7 @@ func _refresh_status() -> void:
|
||||
sel_text = "%d objects" % sel.size()
|
||||
var text := "%s | Objects: %d | Selected: %s" % [_scene_name, count, sel_text]
|
||||
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():
|
||||
text += " | " + _rule_hint
|
||||
if not _toast_text.is_empty():
|
||||
@@ -1549,9 +1600,10 @@ func _sync_mode_buttons() -> void:
|
||||
func _apply_mode_frame() -> void:
|
||||
if _mode_frame == null:
|
||||
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")
|
||||
sb.border_color = _accent_for_mode(current_mode)
|
||||
if sb != null: sb.border_color = _accent_for_mode(current_mode)
|
||||
|
||||
|
||||
func _refresh_mode_badge() -> void:
|
||||
@@ -1638,15 +1690,96 @@ func _apply_ui_font(control: Control) -> void:
|
||||
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:
|
||||
if _status_cursor_coords == null:
|
||||
return
|
||||
if _camera == null or not is_instance_valid(_camera):
|
||||
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()
|
||||
_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:
|
||||
if _is_awaiting_click() != _last_awaiting_click:
|
||||
_apply_cursor()
|
||||
@@ -1662,6 +1795,9 @@ func _build_ui() -> void:
|
||||
_mode_frame.name = "ModeFrame"
|
||||
_mode_frame.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_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()
|
||||
frame_style.bg_color = Color(0.0, 0.0, 0.0, 0.0)
|
||||
frame_style.border_color = _accent_direct
|
||||
@@ -1671,22 +1807,43 @@ func _build_ui() -> void:
|
||||
|
||||
var top_bar := PanelContainer.new()
|
||||
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)
|
||||
|
||||
var hbox := HBoxContainer.new()
|
||||
hbox.name = "Content"
|
||||
hbox.add_theme_constant_override("separation", 8)
|
||||
top_bar.add_child(hbox)
|
||||
var chrome := Control.new()
|
||||
chrome.name = "Content"
|
||||
chrome.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
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.name = "FileMenu"
|
||||
_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_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("Open…", 1)
|
||||
var recent_popup := PopupMenu.new()
|
||||
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)
|
||||
file_popup.add_child(recent_popup)
|
||||
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.id_pressed.connect(_on_file_menu_id_pressed)
|
||||
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 = [
|
||||
[StageMode.EDIT, "✏️ Edit"],
|
||||
[StageMode.DIRECT, "🎬 Direct"],
|
||||
@@ -1708,39 +1878,32 @@ func _build_ui() -> void:
|
||||
var btn := Button.new()
|
||||
btn.text = String(def[1])
|
||||
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))
|
||||
hbox.add_child(btn)
|
||||
mode_switcher.add_child(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.text = "Click a stickman to direct, or ⚡ When… for rules"
|
||||
_direct_hint_label.visible = false
|
||||
_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.text = "Grid"
|
||||
_grid_check.button_pressed = _show_grid
|
||||
_grid_check.toggled.connect(_on_grid_toggled)
|
||||
hbox.add_child(_grid_check)
|
||||
right_group.add_child(_grid_check)
|
||||
|
||||
_snap_check = CheckBox.new()
|
||||
_snap_check.text = "Snap"
|
||||
_snap_check.button_pressed = _snap_enabled
|
||||
_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.text = "Size"
|
||||
hbox.add_child(_grid_size_label)
|
||||
right_group.add_child(_grid_size_label)
|
||||
|
||||
_grid_size_spin = SpinBox.new()
|
||||
_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.rounded = true
|
||||
_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).
|
||||
var bottom_bar := PanelContainer.new()
|
||||
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)
|
||||
|
||||
var status_hbox := HBoxContainer.new()
|
||||
@@ -1775,8 +1958,8 @@ func _build_ui() -> void:
|
||||
_mode_badge.name = "ModeBadge"
|
||||
_mode_badge.mouse_filter = Control.MOUSE_FILTER_IGNORE
|
||||
_mode_badge.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT)
|
||||
_mode_badge.offset_top = 48.0
|
||||
_mode_badge.offset_left = 8.0
|
||||
_mode_badge.offset_top = TOP_BAR_HEIGHT + 12.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()
|
||||
badge_style.bg_color = _accent_edit
|
||||
badge_style.set_corner_radius_all(12)
|
||||
@@ -1867,6 +2050,7 @@ func _build_ui() -> void:
|
||||
speak_box.add_child(_speak_stop_current_check)
|
||||
_speak_dialog.register_text_enter(_speak_edit)
|
||||
ui.add_child(_speak_dialog)
|
||||
_apply_dialog_theme(_speak_dialog)
|
||||
|
||||
_wait_dialog = AcceptDialog.new()
|
||||
_wait_dialog.title = "Wait"
|
||||
@@ -1878,6 +2062,7 @@ func _build_ui() -> void:
|
||||
_wait_spin.value = 1.0
|
||||
_wait_dialog.add_child(_wait_spin)
|
||||
ui.add_child(_wait_dialog)
|
||||
_apply_dialog_theme(_wait_dialog)
|
||||
|
||||
_apply_popup_theme(_action_popup)
|
||||
_apply_popup_theme(_trigger_popup)
|
||||
@@ -1901,6 +2086,7 @@ func _build_ui() -> void:
|
||||
ui.add_child(_selector)
|
||||
# apply_font AFTER add_child so the selector's @onready nodes resolve first.
|
||||
_selector.apply_font(_ui_font, _emoji_font)
|
||||
_selector.apply_theme(_sandbox_theme)
|
||||
|
||||
_browse_dialog = FileDialog.new()
|
||||
_browse_dialog.title = "Open .stk"
|
||||
@@ -1930,7 +2116,7 @@ func _build_ui() -> void:
|
||||
_rename_scene_dialog.name = "RenameSceneDialog"
|
||||
_rename_scene_dialog.title = "Rename Scene"
|
||||
_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.name = "SceneNameEdit"
|
||||
_scene_name_edit.placeholder_text = "Scene name"
|
||||
@@ -1940,24 +2126,29 @@ func _build_ui() -> void:
|
||||
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())
|
||||
ui.add_child(_rename_scene_dialog)
|
||||
_apply_dialog_theme(_rename_scene_dialog)
|
||||
|
||||
_missing_assets_dialog = AcceptDialog.new()
|
||||
_missing_assets_dialog.name = "MissingAssetsDialog"
|
||||
_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)
|
||||
_apply_dialog_theme(_missing_assets_dialog)
|
||||
_cli_error_dialog = AcceptDialog.new()
|
||||
_cli_error_dialog.name = "CliErrorDialog"
|
||||
_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)
|
||||
_apply_dialog_theme(_cli_error_dialog)
|
||||
_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.add_button("Discard", false, "discard")
|
||||
_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.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.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)
|
||||
ui.add_child(_unsaved_changes_dialog)
|
||||
_apply_dialog_theme(_unsaved_changes_dialog)
|
||||
|
||||
# ---- Phase 3c editor tools ----
|
||||
|
||||
@@ -1975,8 +2166,10 @@ func _build_ui() -> void:
|
||||
_apply_popup_theme(_waypoint_context)
|
||||
|
||||
_confirm_dialog = ConfirmationDialog.new()
|
||||
_confirm_dialog.min_size = _theme_dialog_size("confirm_size", Vector2i(620, 300))
|
||||
_confirm_dialog.confirmed.connect(_on_confirm_confirmed)
|
||||
ui.add_child(_confirm_dialog)
|
||||
_apply_dialog_theme(_confirm_dialog)
|
||||
|
||||
_queue_panel = QUEUE_PANEL.instantiate() as QueuePanel
|
||||
_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)
|
||||
ui.add_child(_queue_panel)
|
||||
_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.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)
|
||||
ui.add_child(_rule_panel)
|
||||
_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.committed.connect(_on_action_editor_committed)
|
||||
@@ -2001,6 +2196,7 @@ func _build_ui() -> void:
|
||||
_action_editor.target_requested.connect(_on_action_editor_target_requested)
|
||||
ui.add_child(_action_editor)
|
||||
_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.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)
|
||||
ui.add_child(_rule_editor)
|
||||
_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:
|
||||
@@ -2061,14 +2258,15 @@ func _build_navigation() -> void:
|
||||
_rebake_navigation()
|
||||
|
||||
|
||||
## Rebuild the navigation mesh from every TerrainBlock child of World: each
|
||||
## block's world-space polygon is convex-decomposed and fan-triangulated into
|
||||
## one manual NavigationPolygon.
|
||||
## Rebuild navigation from exposed top edges only. Each qualifying terrain edge
|
||||
## becomes a shallow ribbon just above its collision surface, so agents route
|
||||
## over floors/ramps rather than through solid terrain polygons.
|
||||
func _rebake_navigation() -> void:
|
||||
if _nav_region == null or not is_instance_valid(_nav_region):
|
||||
return
|
||||
var verts := PackedVector2Array()
|
||||
var triangles: Array[PackedInt32Array] = []
|
||||
var surfaces: Array[Dictionary] = []
|
||||
for child: Node in _world.get_children():
|
||||
var block := child as TerrainBlock
|
||||
if block == null:
|
||||
@@ -2076,14 +2274,28 @@ func _rebake_navigation() -> void:
|
||||
var pts := PackedVector2Array()
|
||||
for p: Vector2 in block.polygon_points:
|
||||
pts.append(block.transform * p)
|
||||
var pieces := Geometry2D.decompose_polygon_in_convex(pts)
|
||||
if pieces.is_empty():
|
||||
pieces = [pts]
|
||||
for piece: PackedVector2Array in pieces:
|
||||
var base: int = verts.size()
|
||||
verts.append_array(piece)
|
||||
for i: int in range(1, piece.size() - 1):
|
||||
triangles.append(PackedInt32Array([base, base + i, base + i + 1]))
|
||||
for i: int in pts.size():
|
||||
var a := pts[i]
|
||||
var b := pts[(i + 1) % pts.size()]
|
||||
var edge := b - a
|
||||
if edge.length_squared() <= 0.001:
|
||||
continue
|
||||
# TerrainUtils stores clockwise polygons; this right normal is
|
||||
# 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()
|
||||
poly.set_vertices(verts)
|
||||
for tri: PackedInt32Array in triangles:
|
||||
@@ -2092,6 +2304,24 @@ func _rebake_navigation() -> void:
|
||||
if DEBUG_STAGE:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2117,6 +2347,57 @@ func _on_palette_toggled(pressed: bool, id: String) -> void:
|
||||
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:
|
||||
for pid: String in _palette_buttons:
|
||||
(_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:
|
||||
_selector_open = false
|
||||
_selector_kind = ""
|
||||
_thumbnail_generation += 1
|
||||
_thumbnail_queue.clear()
|
||||
if _selector_dim != null:
|
||||
_selector_dim.visible = false
|
||||
@@ -2188,26 +2470,28 @@ func _on_refresh_requested() -> void:
|
||||
|
||||
func _load_or_enqueue_thumbnails(kind: String, entries: Array[Dictionary]) -> void:
|
||||
_thumbnail_queue.clear()
|
||||
_thumbnail_generation += 1
|
||||
var generation: int = _thumbnail_generation
|
||||
for entry: Dictionary in entries:
|
||||
if kind == "stickman":
|
||||
var path := String(entry.get("path", ""))
|
||||
var key := _thumbnail_cache.stickman_key(path)
|
||||
var png := _thumbnail_cache.stickman_png(key)
|
||||
if FileAccess.file_exists(png):
|
||||
var tex := _thumbnail_cache.load_png(png)
|
||||
var tex := _thumbnail_cache.get_texture(png)
|
||||
if tex != null:
|
||||
if tex != null and _selector != null:
|
||||
_selector.set_thumbnail(entry, tex)
|
||||
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:
|
||||
var id := String(entry.get("id", ""))
|
||||
var png := _thumbnail_cache.prop_png(id)
|
||||
if FileAccess.file_exists(png):
|
||||
var tex := _thumbnail_cache.load_png(png)
|
||||
var tex := _thumbnail_cache.get_texture(png)
|
||||
if tex != null:
|
||||
if tex != null and _selector != null:
|
||||
_selector.set_thumbnail(entry, tex)
|
||||
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:
|
||||
@@ -2223,15 +2507,15 @@ func _drain_thumbnail_queue() -> void:
|
||||
var data: Dictionary = entry["data"]
|
||||
tex = await _stickman_thumb.render(data)
|
||||
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:
|
||||
var payload: Dictionary = entry["payload"]
|
||||
var preset: int = int(entry["material_preset"])
|
||||
tex = await _prop_thumb.render(payload, preset)
|
||||
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
|
||||
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)
|
||||
|
||||
|
||||
@@ -2527,17 +2811,17 @@ func _action_hint_text() -> String:
|
||||
func _action_origin() -> Vector2:
|
||||
if _pending_walk_target:
|
||||
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
|
||||
if _capture_kind != CaptureKind.NONE:
|
||||
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):
|
||||
return _panel_rig.global_position - STICKMAN_RIG.FOOT_OFFSET
|
||||
return _panel_rig.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
return Vector2.ZERO
|
||||
if _rule_step != RuleStep.IDLE:
|
||||
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 src := instance_from_id(int(trigger.get("source", -1)))
|
||||
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
|
||||
match id:
|
||||
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:
|
||||
_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:
|
||||
_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()
|
||||
else:
|
||||
_rule_step = RuleStep.SELECT_ACTION
|
||||
@@ -2709,7 +2993,7 @@ func _on_rule_action_popup_id_pressed(id: int) -> void:
|
||||
actions.append(action)
|
||||
_rule_builder["actions"] = actions
|
||||
_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()
|
||||
|
||||
|
||||
@@ -2792,7 +3076,7 @@ func _handle_trigger_target_click(world_pos: Vector2) -> void:
|
||||
_rule_step = RuleStep.SELECT_ACTION
|
||||
_open_rule_action_popup()
|
||||
else:
|
||||
_rule_hint = "Click a waypoint dot (Esc to cancel)"
|
||||
_rule_hint = "Click a waypoint dot (Esc or RMB to cancel)"
|
||||
_refresh_status()
|
||||
"entered_area":
|
||||
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
|
||||
_open_rule_action_popup()
|
||||
else:
|
||||
_rule_hint = "Click a trigger area (Esc to cancel)"
|
||||
_rule_hint = "Click a trigger area (Esc or RMB to cancel)"
|
||||
_refresh_status()
|
||||
"collided":
|
||||
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
|
||||
_open_rule_action_popup()
|
||||
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()
|
||||
|
||||
|
||||
@@ -2831,7 +3115,7 @@ func _handle_action_target_click(world_pos: Vector2) -> void:
|
||||
match String(action.get("type", "")):
|
||||
"walk_to":
|
||||
_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()
|
||||
"speak":
|
||||
_rule_step = RuleStep.PARAMS
|
||||
@@ -3155,12 +3439,31 @@ func _update_area_entry() -> void:
|
||||
movables.append(rig)
|
||||
elif node is PropBlock and not (node as PropBlock).freeze:
|
||||
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:
|
||||
var area_rect := (area.global_transform * area.get_area_rect()).abs()
|
||||
for node: Node2D in movables:
|
||||
var point := _movable_point(node)
|
||||
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))
|
||||
if inside and not was:
|
||||
_area_overlap[key] = true
|
||||
@@ -3172,7 +3475,7 @@ func _update_area_entry() -> void:
|
||||
## Stickman point = feet (root - FOOT_OFFSET); prop point = global position.
|
||||
func _movable_point(node: Node2D) -> Vector2:
|
||||
if node is STICKMAN_RIG:
|
||||
return node.global_position - StickmanRig.FOOT_OFFSET
|
||||
return node.global_position - StickmanLocomotion.FOOT_OFFSET
|
||||
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_from_panel = from_panel
|
||||
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)
|
||||
_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:
|
||||
@@ -3610,11 +3913,11 @@ func _on_rule_editor_trigger_target_requested(type: String) -> void:
|
||||
_rule_editor.hide()
|
||||
match TRIGGER_REGISTRY.target_type(type):
|
||||
"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":
|
||||
_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":
|
||||
_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:
|
||||
@@ -3706,7 +4009,7 @@ func _on_action_editor_committed(flat: Dictionary) -> void:
|
||||
_show_toast("Action inserted")
|
||||
"rule_add":
|
||||
_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
|
||||
"rule_edit":
|
||||
if _rule_editor != null:
|
||||
@@ -3728,7 +4031,7 @@ func _on_action_editor_cancelled() -> void:
|
||||
|
||||
func _on_action_editor_target_requested() -> void:
|
||||
_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:
|
||||
@@ -3808,6 +4111,8 @@ func _apply_grid_settings() -> void:
|
||||
func _set_edit_controls_visible(visible: bool) -> void:
|
||||
for btn: Button in _palette_buttons.values():
|
||||
btn.visible = visible
|
||||
if _palette_dock != null:
|
||||
_palette_dock.visible = visible
|
||||
if _grid_check != null:
|
||||
_grid_check.visible = visible
|
||||
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 /
|
||||
## malformed fallback); defaults to the committed res:// theme.
|
||||
func _load_theme(source_path: String = THEME_PATH) -> void:
|
||||
_sandbox_theme = SANDBOX_THEME.new()
|
||||
_sandbox_theme.load_from(source_path)
|
||||
_theme = {}
|
||||
_build_font_sizes()
|
||||
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:
|
||||
push_warning("SandboxStage: theme file '%s' is malformed; using defaults." % source_path)
|
||||
return
|
||||
_theme = json as Dictionary
|
||||
_theme = _sandbox_theme.raw_config()
|
||||
var fonts: Dictionary = _theme.get("fonts", {})
|
||||
_action_popup_font_size = int(fonts.get("action_popup_font_size", 24))
|
||||
_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_play = _parse_color(String(colors.get("play_accent", "")), _accent_play)
|
||||
_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()
|
||||
|
||||
|
||||
@@ -3961,7 +4286,7 @@ func _apply_theme_to_visuals() -> void:
|
||||
_director_visuals.bold_font = _ui_font_bold
|
||||
_director_visuals.italic_font = _ui_font_italic
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://02tfgtwtgfcw
|
||||
@@ -95,6 +95,21 @@ func set_style(cfg: Dictionary) -> void:
|
||||
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:
|
||||
rules = r
|
||||
mark_dirty()
|
||||
@@ -129,7 +144,7 @@ func hit_test_waypoint_action(world_pos: Vector2) -> Dictionary:
|
||||
var queue := rig.get_queue()
|
||||
if queue.is_empty():
|
||||
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():
|
||||
var action: Dictionary = queue[i]
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
@@ -177,7 +192,7 @@ func _draw_rig_queue(rig: StickmanRig, zoom: float) -> void:
|
||||
if queue.is_empty():
|
||||
return
|
||||
# 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
|
||||
for i: int in queue.size():
|
||||
var action: Dictionary = queue[i]
|
||||
@@ -256,7 +271,7 @@ func _collect_waypoints() -> Array[Vector2]:
|
||||
var queue := rig.get_queue()
|
||||
if queue.is_empty():
|
||||
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():
|
||||
var action: Dictionary = queue[i]
|
||||
if String(action.get("type", "")) == "walk_to":
|
||||
|
||||
@@ -22,12 +22,25 @@ var action_target: Vector2 = Vector2.ZERO
|
||||
var action_valid: bool = true
|
||||
var valid_color: Color = Color(0.3, 0.9, 0.4)
|
||||
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_GAP_PX := 5.0
|
||||
const LINE_WIDTH_PX := 2.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:
|
||||
if terrain_guide_visible or action_visible:
|
||||
@@ -70,15 +83,15 @@ func _draw() -> void:
|
||||
|
||||
func _draw_dashed(from: Vector2, to: Vector2, color: Color) -> void:
|
||||
var zoom := _zoom()
|
||||
var dash := DASH_LENGTH_PX / zoom
|
||||
var gap := DASH_GAP_PX / zoom
|
||||
var dash := dash_length_px / zoom
|
||||
var gap := dash_gap_px / zoom
|
||||
var dir := from.direction_to(to)
|
||||
var total := from.distance_to(to)
|
||||
var dist := 0.0
|
||||
while dist < total:
|
||||
var start := from + dir * 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
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,18 @@ func get_spawnable_ids() -> Array[String]:
|
||||
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:
|
||||
var entry := _find_entry(id)
|
||||
return String(entry.get("label", id)) if not entry.is_empty() else id
|
||||
|
||||
@@ -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", "")))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd0gqgr5oyu3g
|
||||
@@ -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"]
|
||||
|
||||
# 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 = {
|
||||
"upper_arm_length": 168.0,
|
||||
"lower_arm_length": 200.0,
|
||||
|
||||
@@ -3,7 +3,7 @@ extends RefCounted
|
||||
## StickmanFactory - Runtime factory for spawning rig instances from .stk files
|
||||
## (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
|
||||
## referenced by the editor.
|
||||
|
||||
@@ -11,7 +11,7 @@ extends RefCounted
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const RIG_SCENE := preload("res://master_rig.tscn")
|
||||
const RIG_SCENE := preload("res://scenes/stickman_rig.tscn")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxyogpn8rv8xy
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bihf4kx2qgr1o
|
||||
+183
-1966
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ class_name StkRigAdapter
|
||||
extends RefCounted
|
||||
## 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
|
||||
## .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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
extends Control
|
||||
## 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)
|
||||
## plus interactive IK-handle dragging. NOT wired into the editor — run
|
||||
## standalone via F6 on res://scenes/test_harness.tscn.
|
||||
|
||||
@@ -4,6 +4,27 @@ extends RefCounted
|
||||
const STICKMEN_DIR := "user://thumbnails/stickmen"
|
||||
const PROP_DIR := "user://thumbnails/props"
|
||||
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:
|
||||
@@ -19,12 +40,16 @@ func prop_png(id: String) -> String:
|
||||
|
||||
|
||||
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):
|
||||
return null
|
||||
var img := Image.load_from_file(png_path)
|
||||
if img == 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:
|
||||
@@ -34,6 +59,7 @@ func save_png(tex: Texture2D, png_path: String) -> Error:
|
||||
if img == null:
|
||||
return ERR_CANT_CREATE
|
||||
ensure_dir(png_path.get_base_dir())
|
||||
_memory[png_path] = tex
|
||||
return img.save_png(png_path)
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
uid://dayameygn6kdb
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://djy6yykmgtxk0
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlstr7bm0x258
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfwkdo5sx6i4h
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://fx38yavxtiv4
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,6 +1,6 @@
|
||||
# test_phase3b_library.gd
|
||||
# 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.
|
||||
# 2. Corrupt / body-parts-less .stk files are skipped (scan dir override).
|
||||
# 3. make_entry() valid path -> name+data; missing/corrupt path -> {}.
|
||||
@@ -70,11 +70,11 @@ func _test_scan() -> void:
|
||||
print("--- StickmanLibrary.scan() ---")
|
||||
var lib = STICKMAN_LIBRARY.new()
|
||||
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] = []
|
||||
for e: Dictionary in entries:
|
||||
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))
|
||||
_check(String(_by_path(entries, "basic.stk")["name"]) == "Basic",
|
||||
"basic.stk display name is 'Basic' (stickman_name)")
|
||||
|
||||
@@ -279,8 +279,8 @@ func _test_bug4_stage_grid_no_prehighlight() -> void:
|
||||
% stage._spawner.get_selected_stickman_path())
|
||||
# Real scan has 3 stickmen -> no single-item auto-select skip.
|
||||
var grid: GridContainer = stage._selector._grid
|
||||
_check(grid.get_child_count() == 3,
|
||||
"real scan populates 3 stickman cells (got %d)" % grid.get_child_count())
|
||||
_check(grid.get_child_count() == 4,
|
||||
"real scan populates 4 stickman cells (got %d)" % grid.get_child_count())
|
||||
_check(not stage._selector.has_method("_is_selected"),
|
||||
"stage selector exposes no pre-highlight API")
|
||||
_check(not _any_cell_has_stylebox_override(stage._selector),
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
#
|
||||
# Fix 2 (recovery re-anchor): _capture_ragdoll_pose() records the ragdoll
|
||||
# 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(),
|
||||
# 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),
|
||||
@@ -544,8 +544,10 @@ func _test_recovery_reanchor() -> void:
|
||||
rig.state_changed.connect(func(s): state_events.append(int(s)))
|
||||
rig.set_ragdoll(true)
|
||||
_check(rig.is_in_ragdoll(), "set_ragdoll(true) puts the rig in RAGDOLL")
|
||||
_check(rig._ragdoll_bodies.size() == 10,
|
||||
"ragdoll builds 10 bodies (got %d)" % rig._ragdoll_bodies.size())
|
||||
var ragdoll: StickmanRagdoll = rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
|
||||
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 skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D
|
||||
_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 \
|
||||
and not skeleton.modification_stack.enabled,
|
||||
"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")
|
||||
if torso == null:
|
||||
await _free_stage(stage)
|
||||
@@ -563,13 +565,13 @@ func _test_recovery_reanchor() -> void:
|
||||
% float(torso.get_meta("half_height", 0.0)))
|
||||
|
||||
# Simulate "landed": freeze every body, then offset the whole pile.
|
||||
for key: String in rig._ragdoll_bodies:
|
||||
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D
|
||||
for key: String in ragdoll_bodies:
|
||||
var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
|
||||
b.freeze = true
|
||||
b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
var torso_before: Vector2 = torso.global_position
|
||||
for key: String in rig._ragdoll_bodies:
|
||||
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D
|
||||
for key: String in ragdoll_bodies:
|
||||
var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
|
||||
b.global_position += LANDING_DELTA
|
||||
for i in 3:
|
||||
await physics_frame
|
||||
@@ -582,10 +584,10 @@ func _test_recovery_reanchor() -> void:
|
||||
var spine_dir := Vector2.from_angle(torso.global_rotation)
|
||||
var hip_world: Vector2 = torso.global_position - spine_dir * half
|
||||
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 expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2)
|
||||
var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
|
||||
var expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET
|
||||
var stand_torso: Vector2 = (StickmanRagdoll.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
|
||||
var final_hip_world: Vector2 = expected_root + stand_torso
|
||||
var old_hip_anchor_root: Vector2 = hip_world - stand_torso
|
||||
_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))
|
||||
_check(not state_events.is_empty() and state_events[-1] == int(rig.RigState.RECOVERING),
|
||||
"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)"
|
||||
% str(rig._captured_landing_center))
|
||||
_check(absf(rig._captured_ground_y - ground_y) <= 0.5,
|
||||
% str(ragdoll._captured_landing_center))
|
||||
_check(absf(ragdoll._captured_ground_y - ground_y) <= 0.5,
|
||||
"_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,
|
||||
"root re-anchored so the feet sit on the landing ground (got %s, expected %s)"
|
||||
% [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.set_ragdoll(true)
|
||||
_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")
|
||||
if torso == null:
|
||||
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
|
||||
# spine lies ~horizontal (a true lying pose ~±90 deg from the standing -90).
|
||||
for key: String in rig._ragdoll_bodies:
|
||||
var b: RigidBody2D = rig._ragdoll_bodies[key] as RigidBody2D
|
||||
for key: String in ragdoll_bodies:
|
||||
var b: RigidBody2D = ragdoll_bodies[key] as RigidBody2D
|
||||
b.freeze = true
|
||||
b.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
|
||||
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 hip_world: Vector2 = torso.global_position - spine_dir * half
|
||||
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 expected_root: Vector2 = feet_world + (RIG.FOOT_OFFSET as Vector2)
|
||||
var stand_torso: Vector2 = (RIG.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
|
||||
var expected_root: Vector2 = feet_world + StickmanLocomotion.FOOT_OFFSET
|
||||
var stand_torso: Vector2 = (StickmanRagdoll.STAND_POSE["Torso"] as Dictionary).get("pos", Vector2.ZERO)
|
||||
var final_hip_world: Vector2 = expected_root + 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")
|
||||
_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)"
|
||||
@@ -820,12 +824,12 @@ func _run_lying_recovery_case(label: String, torso_rotation: float) -> void:
|
||||
rig.request_recovery()
|
||||
_check(not rig.is_in_ragdoll() and int(rig.state) == int(rig.RigState.RECOVERING),
|
||||
"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)"
|
||||
% str(rig._captured_landing_center))
|
||||
_check(absf(rig._captured_ground_y - ground_y) <= 0.5,
|
||||
% str(ragdoll._captured_landing_center))
|
||||
_check(absf(ragdoll._captured_ground_y - ground_y) <= 0.5,
|
||||
"_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,
|
||||
"root re-anchored so the standing feet sit on the lying ground (got %s, expected %s)"
|
||||
% [str(rig.global_position), str(expected_root)])
|
||||
|
||||
@@ -42,11 +42,12 @@ func _spawn_rig(stage: Node2D, position: Vector2) -> StickmanRig:
|
||||
|
||||
|
||||
func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
||||
_check(rig._collision_bodies.size() == 10, "rig builds ten collision proxies")
|
||||
_check(rig._collision_lengths.size() == 9, "rig records nine fitted capsule lengths")
|
||||
for part_name: String in rig.COLLISION_PARTS:
|
||||
var definition: Dictionary = rig.COLLISION_PARTS[part_name]
|
||||
var body := rig._collision_bodies.get(part_name) as AnimatableBody2D
|
||||
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
||||
_check(locomotion.collision_bodies.size() == 10, "locomotion builds ten collision proxies")
|
||||
_check(locomotion.collision_lengths.size() == 9, "locomotion records nine fitted capsule lengths")
|
||||
for part_name: String in locomotion.COLLISION_PARTS:
|
||||
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)
|
||||
if body == null:
|
||||
continue
|
||||
@@ -65,9 +66,9 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
||||
var circle := shape_node.shape as CircleShape2D
|
||||
_check(circle != null, "head uses a circle")
|
||||
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 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")
|
||||
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")
|
||||
@@ -77,14 +78,14 @@ func _test_collision_proxy_geometry(rig: StickmanRig) -> void:
|
||||
_check(capsule != null, "%s uses a capsule" % part_name)
|
||||
if capsule == null:
|
||||
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.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(is_zero_approx(shape_node.rotation), "%s capsule needs no hanging-frame rotation" % part_name)
|
||||
|
||||
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")
|
||||
empty_visual.free()
|
||||
|
||||
@@ -100,18 +101,19 @@ func _test_collision_proxy_geometry(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)
|
||||
for _frame in 8:
|
||||
await physics_frame
|
||||
await process_frame
|
||||
rig._sync_collision_bodies()
|
||||
locomotion.sync_collision_bodies()
|
||||
await physics_frame
|
||||
await process_frame
|
||||
_check(rig.facing_profile == StickmanRig.FacingProfile.LEFT, "left walk uses the mirrored collision-proxy path")
|
||||
for part_name: String in rig.COLLISION_PARTS:
|
||||
var definition: Dictionary = rig.COLLISION_PARTS[part_name]
|
||||
for part_name: String in locomotion.COLLISION_PARTS:
|
||||
var definition: Dictionary = locomotion.COLLISION_PARTS[part_name]
|
||||
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:
|
||||
_check(false, "%s has nodes for PhysicsServer alignment" % part_name)
|
||||
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.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)
|
||||
rig._cancel_walking()
|
||||
rig.get_locomotion().cancel_walk()
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
|
||||
|
||||
|
||||
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("head", Vector2.ZERO, 400.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_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))
|
||||
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")
|
||||
rig._impact_reactions.clear()
|
||||
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "upper-left head contact lowers the struck side")
|
||||
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))
|
||||
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")
|
||||
rig._impact_reactions.clear()
|
||||
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "upper-right head contact lowers the struck side")
|
||||
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))
|
||||
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")
|
||||
rig._impact_reactions.clear()
|
||||
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) > 0.0, "lower-left upward contact inverts the upper-left response")
|
||||
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))
|
||||
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")
|
||||
rig._impact_reactions.clear()
|
||||
_check(float((locomotion.impact_reactions["Head"] as Dictionary)["angle"]) < 0.0, "lower-right upward contact inverts the upper-right response")
|
||||
locomotion.impact_reactions.clear()
|
||||
|
||||
rig.apply_collision_reaction("left_lower_arm", Vector2.RIGHT, 10000.0)
|
||||
var lower: Dictionary = rig._impact_reactions.get("LeftLowerArm", {})
|
||||
var upper: Dictionary = rig._impact_reactions.get("LeftUpperArm", {})
|
||||
var lower: Dictionary = locomotion.impact_reactions.get("LeftLowerArm", {})
|
||||
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(upper.get("angle", 0.0))), 0.24), "secondary reaction applies 0.4 weight")
|
||||
|
||||
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)
|
||||
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["strength"]), 1.0), "weaker hit refreshes reaction strength")
|
||||
|
||||
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")
|
||||
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 max_offset := 0.0
|
||||
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)))
|
||||
_check(max_offset <= rig.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap")
|
||||
rig._apply_impact_reactions(1.0)
|
||||
_check(rig._impact_reactions.is_empty(), "expired reactions are erased")
|
||||
_check(max_offset <= locomotion.REACTION_MAX_ANGLE + 0.001, "repeated reaction frames never accumulate beyond the angular cap")
|
||||
locomotion.apply_impact_reactions(1.0)
|
||||
_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")
|
||||
|
||||
|
||||
func _test_direct_restores_animated_pose(stage: Node2D, rig: StickmanRig) -> void:
|
||||
var authored: Dictionary = stage._authored[rig.get_instance_id()]
|
||||
var ragdoll := rig.get_node("RigComponents/Ragdoll") as StickmanRagdoll
|
||||
var authored_markers := ragdoll.get_spawn_pose()
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
rig.set_facing_profile(StickmanRig.FacingProfile.LEFT)
|
||||
rig.global_position += Vector2(220.0, -90.0)
|
||||
@@ -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(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")
|
||||
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 expected: Dictionary = rig.STAND_POSE[marker_name]
|
||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to standing after PLAY" % marker_name)
|
||||
_check(absf(angle_difference(torso_marker.rotation, float(rig.STAND_POSE["Torso"]["rot"]))) < 0.001, "torso marker cannot retain an upside-down PLAY frame")
|
||||
var expected: Dictionary = authored_markers[marker_name]
|
||||
_check(marker.position.distance_to(expected["pos"] as Vector2) < 0.01, "%s marker returns to its authored spawn pose after PLAY" % marker_name)
|
||||
_check(absf(angle_difference(torso_marker.rotation, float((authored_markers["Torso"] as Dictionary)["rot"]))) < 0.001, "torso marker restores its authored rotation after PLAY")
|
||||
|
||||
|
||||
func _test_ghost_collision(stage: Node2D) -> void:
|
||||
@@ -213,6 +218,7 @@ func _test_ghost_collision(stage: Node2D) -> void:
|
||||
|
||||
|
||||
func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
var prop := PropBlock.new()
|
||||
prop.position = Vector2(50.0, 25.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_separations == [fallback_body], "PropBlock mirrors body_exited with separated")
|
||||
|
||||
var lower := rig._collision_bodies["left_lower_arm"] as AnimatableBody2D
|
||||
var upper := rig._collision_bodies["left_upper_arm"] as AnimatableBody2D
|
||||
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
||||
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 rule := {
|
||||
"id": 4500,
|
||||
@@ -254,22 +261,23 @@ func _test_stage_contact_routing(stage: Node2D, rig: StickmanRig) -> void:
|
||||
}
|
||||
stage._event_rules.clear()
|
||||
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)
|
||||
_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)
|
||||
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(rig.queue_size() == initial_queue_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(runner._run_queue.size() == initial_runtime_size + 1, "second active part does not repeat the pair rule")
|
||||
_check(locomotion.impact_reactions.has("LeftLowerArm") and locomotion.impact_reactions.has("LeftUpperArm"), "each newly contacted part can react")
|
||||
|
||||
stage._on_prop_separated(lower, prop)
|
||||
_check(stage._collision_pairs.has(pair_key), "first part exit retains the pair")
|
||||
stage._on_prop_separated(upper, prop)
|
||||
_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)
|
||||
_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()]
|
||||
stage._purge_collision_pairs_for_ids(prop_ids)
|
||||
_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.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)
|
||||
_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()
|
||||
invalid_proxy.set_meta("stickman_rig", rig)
|
||||
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)
|
||||
_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()
|
||||
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:
|
||||
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
||||
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)
|
||||
_check(rig.state == StickmanRig.RigState.RAGDOLL, "rig enters ragdoll")
|
||||
_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()
|
||||
_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")
|
||||
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)
|
||||
_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:
|
||||
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:
|
||||
return false
|
||||
return true
|
||||
@@ -349,8 +358,9 @@ func _all_proxy_layers_equal(rig: StickmanRig, expected: int) -> bool:
|
||||
|
||||
func _proxy_shape_sizes(rig: StickmanRig) -> Dictionary:
|
||||
var sizes := {}
|
||||
for part_name: String in rig._collision_bodies:
|
||||
var body := rig._collision_bodies[part_name] as AnimatableBody2D
|
||||
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
||||
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
|
||||
if shape_node.shape is CircleShape2D:
|
||||
sizes[part_name] = (shape_node.shape as CircleShape2D).radius
|
||||
|
||||
@@ -161,23 +161,24 @@ func _test_reactive_runtime_lane() -> void:
|
||||
finished.append({ "action": action, "index": index })
|
||||
)
|
||||
rig.start_queue()
|
||||
rig._update_runner(0.0)
|
||||
_check(rig._action_phase == rig.ActionPhase.WAITING, "authored primary wait starts")
|
||||
var runner: StickmanActionRunner = rig.get_node("RigComponents/ActionRunner") as StickmanActionRunner
|
||||
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 }])
|
||||
_check(rig._action_phase == rig.ActionPhase.WAITING, "overlay speak leaves primary wait running")
|
||||
_check(String(rig._overlay_speech.get("text", "")) == "One", "overlay speak occupies the single overlay slot")
|
||||
_check(runner._action_phase == runner.ActionPhase.WAITING, "overlay speak leaves primary wait running")
|
||||
_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 }])
|
||||
_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")
|
||||
|
||||
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(rig._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future")
|
||||
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait"
|
||||
and bool(rig._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane")
|
||||
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
|
||||
_check(runner._run_queue.size() == 2, "sequential interrupt retains only injected plus authored future")
|
||||
_check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "wait"
|
||||
and bool(runner._run_queue[0].get("reactive", false)), "injected sequential action leads the runtime lane")
|
||||
_check(int(runner._run_queue[1].get("queue_index", -1)) == 1,
|
||||
"canceled authored action is abandoned and the next authored item is retained")
|
||||
|
||||
# A takeover must discard the canceled/current item and any pending reactive
|
||||
@@ -188,17 +189,17 @@ func _test_reactive_runtime_lane() -> void:
|
||||
{ "type": "wait", "duration": 20.0 },
|
||||
]
|
||||
rig.start_queue()
|
||||
rig._update_runner(0.0)
|
||||
runner.tick(0.0, int(rig.state), rig.is_ragdoll_at_rest())
|
||||
rig.enqueue_reactive([
|
||||
{ "type": "wait", "duration": 1.0 },
|
||||
{ "type": "ragdoll" },
|
||||
])
|
||||
_check(rig._run_queue.size() == 2, "takeover retains itself plus authored future only")
|
||||
_check(String((rig._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll",
|
||||
_check(runner._run_queue.size() == 2, "takeover retains itself plus authored future only")
|
||||
_check(String((runner._run_queue[0]["action"] as Dictionary).get("type", "")) == "ragdoll",
|
||||
"takeover supersedes earlier same-batch primary work")
|
||||
_check(int(rig._run_queue[1].get("queue_index", -1)) == 1,
|
||||
_check(int(runner._run_queue[1].get("queue_index", -1)) == 1,
|
||||
"takeover does not replay the completed or canceled authored item")
|
||||
_check(rig._overlay_speech.is_empty(), "takeover cancels the active overlay")
|
||||
_check(runner._overlay_speech.is_empty(), "takeover cancels the active overlay")
|
||||
_check(rig.action_queue.size() == 2, "reactive work never mutates the authored queue")
|
||||
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])
|
||||
_check(rotation_delta <= 0.02,
|
||||
"spawn solve preserves ghost bone rotation: %s (delta %.4f)" % [path, rotation_delta])
|
||||
_check(rig._spawn_pose.size() == rig.IK_TARGET_PATHS.size(), "factory captures all six spawn-pose markers")
|
||||
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()
|
||||
|
||||
|
||||
@@ -240,7 +242,8 @@ func _test_left_walk_finish_pose() -> void:
|
||||
root.add_child(rig)
|
||||
await process_frame
|
||||
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)
|
||||
var stopped_frame: Dictionary = {}
|
||||
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 stopped_marker_position := hand_marker.position + Vector2(17.0, -23.0)
|
||||
hand_marker.position = stopped_marker_position
|
||||
rig._finish_walk("regression")
|
||||
locomotion._finish_walk()
|
||||
_check(hand_marker.position.is_equal_approx(stopped_marker_position),
|
||||
"walk finish preserves the current animation marker 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_rotation: float = live_upper.rotation + 0.33
|
||||
live_upper.rotation = live_rotation
|
||||
rig._walking = true
|
||||
locomotion._walking = 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,
|
||||
"ragdoll-style walk cancellation preserves the current solved limb pose")
|
||||
_check((rig._bend_modifications["LeftArm"] as SkeletonModification2DTwoBoneIK).enabled,
|
||||
@@ -286,21 +289,22 @@ func _test_stop_clears_head_reaction() -> void:
|
||||
await process_frame
|
||||
var rig := stage._spawner.spawn("stickman", Vector2.ZERO) as StickmanRig
|
||||
stage._finalize_placed_node(rig)
|
||||
var head := rig._reaction_bones.get("Head") as Bone2D
|
||||
var locomotion: StickmanLocomotion = rig.get_locomotion()
|
||||
var head := locomotion.reaction_bones.get("Head") as Bone2D
|
||||
var base_rotation: float = head.rotation
|
||||
rig.apply_collision_reaction("head", Vector2.DOWN, 500.0)
|
||||
rig._apply_impact_reactions(0.01)
|
||||
_check(not rig._impact_reactions.is_empty(), "head collision starts a flinch reaction")
|
||||
locomotion.apply_impact_reactions(0.01)
|
||||
_check(not locomotion.impact_reactions.is_empty(), "head collision starts a flinch reaction")
|
||||
stage.set_mode(stage.StageMode.PLAY)
|
||||
stage.set_mode(stage.StageMode.EDIT)
|
||||
_check(rig._impact_reactions.is_empty(), "STOP clears the active head reaction")
|
||||
_check(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")
|
||||
var stale_prop := PropBlock.new()
|
||||
var stale_proxy := AnimatableBody2D.new()
|
||||
stale_proxy.set_meta("stickman_rig", rig)
|
||||
stale_proxy.set_meta("part_name", "head")
|
||||
stage._on_prop_collided(stale_proxy, Vector2.DOWN, Vector2.ZERO, stale_prop)
|
||||
_check(rig._impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch")
|
||||
_check(locomotion.impact_reactions.is_empty(), "post-STOP collision callback cannot recreate the flinch")
|
||||
stale_prop.free()
|
||||
stale_proxy.free()
|
||||
stage.free()
|
||||
@@ -310,16 +314,18 @@ func _test_stop_clears_head_reaction() -> void:
|
||||
func _test_source_contracts() -> void:
|
||||
var stage_source: String = FileAccess.get_file_as_string("res://scripts/sandbox_stage.gd")
|
||||
var rig_source: String = FileAccess.get_file_as_string("res://scripts/stickman_rig.gd")
|
||||
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("PhysicsServer2D.body_set_state"), "frozen prop commits synchronize physics")
|
||||
_check(stage_source.contains("transform_started.connect"), "gizmo start cancels deferred restoration")
|
||||
_check(stage_source.contains("if current_mode != StageMode.PLAY:"), "collision callbacks are gated to PLAY mode")
|
||||
_check(stage_source.contains("_speak_stop_current_check") and stage_source.contains("\"stop_current\": _speak_stop_current_check.button_pressed"), "legacy rule speak persists stop-current")
|
||||
_check(rig_source.contains("_primary_speech") and rig_source.contains("_overlay_speech"), "rig separates primary and overlay speech")
|
||||
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("initialize_spawn_pose_from_skeleton"), "rig captures factory spawn pose")
|
||||
_check(rig_source.contains("bone.to_global(Vector2(bone.length, 0.0).rotated(deg_to_rad(bone.bone_angle)))"), "spawn endpoints include Bone2D bone_angle")
|
||||
_check(rig_source.contains("_stand_up_generation") and rig_source.contains("_on_stand_up_finished.bind(generation)"), "stale recovery tween callbacks are generation-guarded")
|
||||
_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(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:
|
||||
|
||||
@@ -67,6 +67,10 @@ func _initialize() -> 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(" 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 ---")
|
||||
# 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.
|
||||
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.
|
||||
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
|
||||
rig.arrived.connect(_on_arrived)
|
||||
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 mode_flipped := false
|
||||
while not rig._walk_done and frames < MAX_WALK_FRAMES:
|
||||
if rig._walk_mode_latched and rig._walk_mode != expected_mode:
|
||||
while not locomotion._walk_done and frames < MAX_WALK_FRAMES:
|
||||
if locomotion._walk_mode_latched and locomotion._walk_mode != expected_mode:
|
||||
mode_flipped = true
|
||||
await physics_frame
|
||||
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(rig._walk_mode_latched, "%s: steering mode latched" % label)
|
||||
_check(rig._walk_mode == expected_mode,
|
||||
"%s: latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
|
||||
_check(locomotion._walk_mode_latched, "%s: steering mode latched" % label)
|
||||
_check(locomotion._walk_mode == expected_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)
|
||||
|
||||
# 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,
|
||||
"%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)
|
||||
@@ -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(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)")
|
||||
# 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() \
|
||||
+ stage._spawner.get_spawn_offset("ground")
|
||||
var expected_cell: Vector2i = stage._world_to_terrain_cell(raw_pos)
|
||||
var expected_pos: Vector2 = stage._terrain_cell_center(expected_cell)
|
||||
var expected_pos: Vector2 = stage._snap_to_grid(raw_pos)
|
||||
stage._update_ghost_position()
|
||||
_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])
|
||||
_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)
|
||||
"ghost positioned at the configured snap-grid point (got %s, expected %s)" % [ghost.position, expected_pos])
|
||||
|
||||
# Drag begin frees the single cursor ghost and builds per-cell ghosts.
|
||||
stage._begin_terrain_drag(stage._terrain_cell_center(Vector2i(30, 0)))
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
# * After Back the rule-builder state is IDLE/empty AND _action_popup is
|
||||
# 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.
|
||||
# * Constraint flags/band are asserted directly on the instantiated rig.
|
||||
# * 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")
|
||||
|
||||
# 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
|
||||
for i: int in WARMUP_FRAMES:
|
||||
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:
|
||||
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 rig: StickmanRig = stage._spawner.spawn("stickman", Vector2(0, 0))
|
||||
_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": "wait", "duration": 1.0 })
|
||||
_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
|
||||
# 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))
|
||||
var r1: Array[Dictionary] = [{ "type": "speak", "text": "reactive-1", "duration": 1.0 }]
|
||||
rig.enqueue_reactive(r1)
|
||||
_check(rig.queue_size() == 3,
|
||||
"reactive injection grows the queue to 3 (got %d)" % rig.queue_size())
|
||||
_check(_count_reactive(rig) == 1,
|
||||
"injected action is tagged reactive=true")
|
||||
_check(rig.is_queue_running(),
|
||||
"enqueue_reactive auto-resumes the runner from IDLE")
|
||||
_check(qc1.size() == 1, "enqueue_reactive emits queue_changed")
|
||||
_check(rig.queue_size() == 2,
|
||||
"reactive injection leaves the authored queue at 2 (got %d)" % rig.queue_size())
|
||||
_check(runner._run_queue.size() == 2 and _runtime_reactive_count(runner) == 0 and not runner._overlay_speech.is_empty(),
|
||||
"overlay speak leaves only authored items in the primary runtime lane")
|
||||
_check(not rig.is_queue_running(),
|
||||
"overlay speak does not start the primary queue")
|
||||
_check(qc1.is_empty(), "runtime-only injection does not emit authored queue_changed")
|
||||
|
||||
rig.stop_queue()
|
||||
rig.clear_reactive_actions()
|
||||
@@ -351,7 +352,7 @@ func _test_bug4_reactive_actions() -> void:
|
||||
_check(_action_types(rig) == ["speak", "wait"],
|
||||
"queue types after clear are exactly the authored actions (got %s)"
|
||||
% 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
|
||||
# stay stable (no 1,2,3 accumulation across repeated Play sessions).
|
||||
@@ -360,10 +361,10 @@ func _test_bug4_reactive_actions() -> void:
|
||||
{ "type": "ragdoll" },
|
||||
]
|
||||
rig.enqueue_reactive(r2)
|
||||
_check(rig.queue_size() == 4,
|
||||
"second injection grows to 4 (got %d)" % rig.queue_size())
|
||||
_check(_count_reactive(rig) == 2,
|
||||
"second burst tags both actions reactive")
|
||||
_check(rig.queue_size() == 2 and runner._run_queue.size() == 3,
|
||||
"takeover keeps 2 authored future items behind one reactive action")
|
||||
_check(bool(runner._run_queue[0].get("reactive", false)),
|
||||
"takeover action leads the runtime lane as reactive")
|
||||
rig.stop_queue()
|
||||
rig.clear_reactive_actions()
|
||||
_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)"
|
||||
% 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()
|
||||
_check(rig.is_queue_running(), "runner is EXECUTING after start_queue")
|
||||
var r3: Array[Dictionary] = [{ "type": "recover" }]
|
||||
rig.enqueue_reactive(r3)
|
||||
_check(rig.queue_size() == 3,
|
||||
"reactive action can still append while the runner is executing")
|
||||
_check(rig.queue_size() == 2 and bool(runner._run_queue[0].get("reactive", false)),
|
||||
"reactive takeover enters runtime lane while authored queue stays unchanged")
|
||||
var qc3: Array = []
|
||||
rig.queue_changed.connect(func(): qc3.append(true))
|
||||
rig.clear_reactive_actions()
|
||||
_check(rig.queue_size() == 3 and _count_reactive(rig) == 1,
|
||||
"clear_reactive_actions is a no-op while EXECUTING (reactive action stays)")
|
||||
_check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0,
|
||||
"clear_reactive_actions removes runtime reactive work while preserving authored actions")
|
||||
_check(qc3.is_empty(),
|
||||
"no queue_changed emitted by the EXECUTING no-op")
|
||||
"runtime cleanup emits no authored queue_changed")
|
||||
rig.stop_queue()
|
||||
_check(not rig.is_queue_running(), "runner returns to IDLE after stop_queue")
|
||||
rig.clear_reactive_actions()
|
||||
_check(rig.queue_size() == 2 and _count_reactive(rig) == 0,
|
||||
"after stop, clear drops the injected reactive action (queue 2)")
|
||||
_check(rig.queue_size() == 2 and _runtime_reactive_count(runner) == 0,
|
||||
"after stop, authored queue remains stable at 2")
|
||||
|
||||
await _free_stage(stage)
|
||||
|
||||
@@ -407,6 +408,14 @@ func _count_reactive(rig: StickmanRig) -> int:
|
||||
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]:
|
||||
var out: Array[String] = []
|
||||
for a: Dictionary in rig.get_queue():
|
||||
|
||||
+22
-13
@@ -15,6 +15,7 @@ extends SceneTree
|
||||
|
||||
const STAGE_SCENE := preload("res://scenes/sandbox_stage.tscn")
|
||||
const RIG := preload("res://scripts/stickman_rig.gd")
|
||||
const LOCOMOTION := preload("res://scripts/stickman_locomotion.gd")
|
||||
|
||||
const SETTLE_FRAMES := 60
|
||||
const MAX_FRAMES := 300
|
||||
@@ -68,27 +69,33 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
|
||||
_arrived_count = 0
|
||||
rig.arrived.connect(_on_arrived)
|
||||
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
|
||||
while not rig._walk_done and frames < MAX_FRAMES:
|
||||
while not locomotion._walk_done and frames < MAX_FRAMES:
|
||||
await physics_frame
|
||||
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(rig._walk_mode_latched, "%s: mode is latched" % label)
|
||||
_check(rig._walk_mode == expected_mode,
|
||||
"%s: mode latched to '%s' (got '%s')" % [label, expected_mode, rig._walk_mode])
|
||||
_check(locomotion._walk_mode_latched, "%s: mode is latched" % label)
|
||||
_check(locomotion._walk_mode == expected_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).
|
||||
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,
|
||||
"%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 frozen_torso_position: Vector2 = Vector2.ZERO
|
||||
if torso != null:
|
||||
var stand_torso: Dictionary = RIG.STAND_POSE["Torso"]
|
||||
var expected_pos: Vector2 = stand_torso.get("pos", Vector2(0, 10))
|
||||
_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)])
|
||||
frozen_torso_position = (torso as Node2D).position
|
||||
_check(frozen_torso_position.is_finite(), "%s: Torso final animation marker is finite" % label)
|
||||
else:
|
||||
_check(false, "%s: IK_Targets/Torso missing" % label)
|
||||
# Post-arrival stability for 60 physics frames.
|
||||
@@ -101,6 +108,9 @@ func _walk_case(label: String, target_feet: Vector2, expected_mode: String) -> v
|
||||
break
|
||||
_check(settled and rig.global_position == p0,
|
||||
"%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)
|
||||
|
||||
stage.queue_free()
|
||||
@@ -114,4 +124,3 @@ func _check(condition: bool, message: String) -> void:
|
||||
else:
|
||||
_failures += 1
|
||||
print("FAIL: " + message)
|
||||
|
||||
|
||||
+24
-15
@@ -73,8 +73,10 @@ func _add_rule(stage: Node2D, source: Node2D, area: TriggerArea, target: Stickma
|
||||
|
||||
|
||||
func _clear_actions(rig: StickmanRig) -> void:
|
||||
rig.action_queue.clear()
|
||||
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:
|
||||
@@ -102,31 +104,35 @@ func _test_source_specific_edges() -> void:
|
||||
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")
|
||||
stage._update_area_entry()
|
||||
_check(target.action_queue.size() == 1, "source crossing queues exactly one consequence")
|
||||
var action: Dictionary = target.action_queue[0]
|
||||
_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")
|
||||
var runner := target.get_node("RigComponents/ActionRunner") as StickmanActionRunner
|
||||
_check(not runner._overlay_speech.is_empty(), "source crossing starts one overlay consequence")
|
||||
_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;
|
||||
# wait through the following tick so the runner can begin the action.
|
||||
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()
|
||||
_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))
|
||||
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)
|
||||
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)
|
||||
_move_rig_body_center(other, area.global_position)
|
||||
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
|
||||
stage._finalize_placed_node(prop)
|
||||
prop.freeze = false
|
||||
prop.global_position = Vector2.ZERO
|
||||
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)
|
||||
|
||||
|
||||
@@ -141,16 +147,18 @@ func _test_wildcard_and_pair_independence() -> void:
|
||||
stage._update_area_entry()
|
||||
_move_rig_body_center(source_a, area_a.global_position)
|
||||
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))
|
||||
stage._update_area_entry()
|
||||
_move_rig_body_center(source_b, area_a.global_position)
|
||||
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)
|
||||
_add_rule(stage, source_b, area_b, target, "second pair")
|
||||
_move_rig_body_center(source_b, area_b.global_position)
|
||||
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)
|
||||
|
||||
|
||||
@@ -163,15 +171,16 @@ func _test_transforms_points_and_cleanup() -> void:
|
||||
var source := _spawn_rig(stage, Vector2(-300.0, 0.0))
|
||||
var target := _spawn_rig(stage, Vector2(2000.0, 0.0))
|
||||
_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)
|
||||
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)
|
||||
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()
|
||||
_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_id := target.get_instance_id()
|
||||
target.free()
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dt5c3h6p0msb2
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dho8gbkh2u0k4
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cnnlc36tnlqil
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://btjgad0h7xge3
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user