feat: Implement Stickman Factory and Test Harness for runtime rigging
- Added StickmanFactory to load and instantiate .stk files into master_rig.tscn. - Created a Test Harness scene for debugging bone scales, vector drawing offsets, and IK limits. - Updated stk_rig_adapter to fit head bone and mount head shapes as full geometry. - Enhanced README and PROJECT documentation to reflect new features and usage. - Introduced UI elements for file selection and visual debugging in the Test Harness. - Refactored code to improve clarity and maintainability, including removal of unused functions.
This commit is contained in:
@@ -108,14 +108,42 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
|
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
|
||||||
part dragging/selection.
|
part dragging/selection.
|
||||||
- `scripts/stk_rig_adapter.gd` — `class_name StkRigAdapter`, `extends RefCounted`; a **standalone
|
- `scripts/stk_rig_adapter.gd` — `class_name StkRigAdapter`, `extends RefCounted`; a **standalone
|
||||||
runtime adapter** (Phase 8, **not referenced by the editor**). `static func apply(stk_data, rig)`
|
runtime adapter** (Phase 8, **not referenced by the editor**, extended by Phase 9).
|
||||||
fits an instantiated `master_rig.tscn` to a loaded `.stk` dictionary, calling three private
|
`static func apply(stk_data, rig)` fits an instantiated `master_rig.tscn` to a loaded `.stk`
|
||||||
helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D` lengths + lower-bone origins),
|
dictionary, calling three private helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D`
|
||||||
`_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand` and `Left|Right_Leg` targets),
|
lengths + lower-bone origins), `_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand`
|
||||||
and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual nodes — open → `Line2D`,
|
and `Left|Right_Leg` targets), and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*`
|
||||||
closed → `Polygon2D` fill + `Line2D` outline, width 16). Targets `master_rig.tscn` node paths;
|
visual nodes — open → `Line2D`, closed → `Polygon2D` fill + `Line2D` outline, width 16).
|
||||||
every node lookup is null-guarded (missing node → `push_warning` + skip, never crash). Consumed
|
- **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y =
|
||||||
by a future runtime pipeline.
|
-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.
|
||||||
|
Targets `master_rig.tscn` node paths; every node lookup is null-guarded (missing node →
|
||||||
|
`push_warning` + skip, never crash). Consumed by a future runtime pipeline.
|
||||||
|
- `scripts/stickman_factory.gd` — `class_name StickmanFactory`, `extends RefCounted`; a **static
|
||||||
|
factory** and the **runtime entry point** (Phase 9, **not used by the editor**) that turns a
|
||||||
|
`.stk` file into a live, rigged `master_rig.tscn` instance:
|
||||||
|
- `static func load_stk(path: String) -> Dictionary` — reads a `.stk` file (`FileAccess` +
|
||||||
|
`JSON.parse_string`); returns `{}` + `push_warning` on failure.
|
||||||
|
- `static func spawn_from_data(stk_data: Dictionary) -> Node2D` — instantiates
|
||||||
|
`res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root.
|
||||||
|
- `static func spawn(path: String) -> Node2D` — `load_stk()` then `spawn_from_data()`;
|
||||||
|
returns `null` on empty data.
|
||||||
|
- `scripts/test_harness.gd` — **standalone staging scene** (Phase 9, **not wired into the editor**;
|
||||||
|
run via **F6** on `res://scenes/test_harness.tscn`) for debugging bone scales, vector drawing
|
||||||
|
offsets, and IK limits in isolation. Top UI bar: "Open .stk…" button → `FileDialog` (`*.stk`);
|
||||||
|
quick-select buttons for `stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`; "Show
|
||||||
|
Bones" / "Show IK Handles" checkboxes; a status label showing the loaded filename. Viewport:
|
||||||
|
`SubViewportContainer` → `SubViewport` → world `Node2D` + enabled `Camera2D`; middle-mouse pan,
|
||||||
|
mouse-wheel zoom, camera recenters on each spawn. Each load frees the previous rig and spawns a
|
||||||
|
fresh one via `StickmanFactory.spawn()`. Debug overlay (a world-space `Node2D` `_draw()`): bone
|
||||||
|
lines between each `Bone2D` global origin and its parent's (color-coded left cyan / right orange /
|
||||||
|
central white + joint dots) when "Show Bones" is on; colored markers at
|
||||||
|
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
|
||||||
|
IK: click-drag the 4 limb `Marker2D` IK targets; the scene's `SkeletonModificationStack2D`
|
||||||
|
TwoBoneIK flexes limbs live. The harness enables the modification stack (`enabled = true`) after
|
||||||
|
each spawn.
|
||||||
- Scenes:
|
- Scenes:
|
||||||
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
||||||
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
||||||
@@ -127,6 +155,11 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
- `scenes/body_part_panel.tscn` — instantiated 10× at runtime (5 per column). Each panel
|
- `scenes/body_part_panel.tscn` — instantiated 10× at runtime (5 per column). Each panel
|
||||||
sets `size_flags_vertical = SIZE_EXPAND_FILL` so the panels expand to fill the column
|
sets `size_flags_vertical = SIZE_EXPAND_FILL` so the panels expand to fill the column
|
||||||
height in their parent VBoxContainer.
|
height in their parent VBoxContainer.
|
||||||
|
- `scenes/test_harness.tscn` — **standalone staging scene** (Phase 9, not wired into the
|
||||||
|
editor; run via **F6**). Backed by `scripts/test_harness.gd`. Top UI bar with file open /
|
||||||
|
quick-select, "Show Bones" / "Show IK Handles" toggles, and a loaded-filename status label;
|
||||||
|
`SubViewport` world with an enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on
|
||||||
|
spawn); interactive limb IK via `SkeletonModificationStack2D` TwoBoneIK.
|
||||||
|
|
||||||
### Body-part data model
|
### Body-part data model
|
||||||
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
|
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
|
||||||
|
|||||||
+33
@@ -341,6 +341,39 @@ Update Hand IK target default rests to match total arm length.
|
|||||||
|
|
||||||
Replace default Line2D nodes under $Body/ or instantiate .stk vector shape nodes attached as children to their corresponding Bone2D / RemoteTransform2D paths.
|
Replace default Line2D nodes under $Body/ or instantiate .stk vector shape nodes attached as children to their corresponding Bone2D / RemoteTransform2D paths.
|
||||||
|
|
||||||
|
## Stickman editor (Phase 9)
|
||||||
|
|
||||||
|
### Stickman factory
|
||||||
|
We need to implement a static stickman factory that loads .stk files. (1.4)
|
||||||
|
It should instantiate master_rig.tscn, resize all Bone2D bones dynamically according to the file's 'proportions', reposition IK targets, and mount custom vector objects(shapes) under $Body/
|
||||||
|
|
||||||
|
Proposed specs:
|
||||||
|
Bone Adaptations:
|
||||||
|
- Set $Skeleton2D/Torso/Head.position.y = -proportions.torso_length.
|
||||||
|
- Set $Skeleton2D/Torso/LeftUpperArm.length = proportions.upper_arm_length.
|
||||||
|
- Set $Skeleton2D/Torso/LeftUpperArm/LeftLowerArm.position.x = -proportions.upper_arm_length.
|
||||||
|
- Set $Skeleton2D/Torso/RightUpperArm.length = proportions.upper_arm_length.
|
||||||
|
- Set $Skeleton2D/Torso/RightUpperArm/RightLowerArm.position.x = proportions.upper_arm_length.
|
||||||
|
- Set $Skeleton2D/Torso/LeftUpperLeg/LeftLowerLeg.position.y = proportions.upper_leg_length.
|
||||||
|
- Set $Skeleton2D/Torso/RightUpperLeg/RightLowerLeg.position.y = proportions.upper_leg_length. IK Target Positions:Reposition $IK_Targets/Left_Hand and $IK_Targets/Right_Hand based on combined arm lengths.Reposition $IK_Targets/Left_Leg and $IK_Targets/Right_Leg to (upper_leg_length + lower_leg_length).Graphics Mount:
|
||||||
|
- Clear default Line2D and script components on $Body/ subnodes.
|
||||||
|
- For each body part, instantiate a drawing node that renders the .stk shape points offset by the part's "pivot" coordinate.
|
||||||
|
|
||||||
|
### Test harness scene
|
||||||
|
We need a test harness that serves as a staging ground for debugging bone scales, vector drawing offsets, and IK limits in isolation.
|
||||||
|
|
||||||
|
#### What the Test Harness Should Include:
|
||||||
|
- File Picker UI:
|
||||||
|
A button opening a FileDialog (or quick-select buttons for test files like break_2.stk) to instantly spawn or swap characters at runtime.
|
||||||
|
|
||||||
|
- Visual Debug Overlay Toggles:
|
||||||
|
Toggle Bone Visibility: Shows/hides Godot's Skeleton2D lines over the rendered .stk graphics so you can verify joint pivot alignment.
|
||||||
|
|
||||||
|
- Toggle IK Handles: Shows colored markers for hand and foot IK targets so you can click and drag them around.
|
||||||
|
|
||||||
|
- Interactive IK / Pose Dragging:
|
||||||
|
Simple click-and-drag logic on the screen to pull hand/foot targets and watch the dynamic limb flexing in real time.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
1. Try to use standard Godot controls when applicable
|
1. Try to use standard Godot controls when applicable
|
||||||
|
|||||||
@@ -239,6 +239,30 @@ The Whole Stickman preview treats all shapes in a body-part panel as **one combi
|
|||||||
2. A confirmation dialog warns that the current stickman will be cleared.
|
2. A confirmation dialog warns that the current stickman will be cleared.
|
||||||
3. Confirm to reset all panels and the preview (zoom resets to 100%, pan offset to origin).
|
3. Confirm to reset all panels and the preview (zoom resets to 100%, pan offset to origin).
|
||||||
|
|
||||||
|
### 14. Test Harness & Stickman Factory (Phase 9)
|
||||||
|
|
||||||
|
Phase 9 adds the **runtime pipeline** that turns a saved `.stk` file into a live, rigged `master_rig.tscn` instance — plus a standalone staging scene for debugging it in isolation. **Neither is wired into the editor.**
|
||||||
|
|
||||||
|
**Stickman Factory (`res://scripts/stickman_factory.gd`)** — the runtime entry point:
|
||||||
|
|
||||||
|
| Method | Signature | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| `load_stk` | `static func load_stk(path: String) -> Dictionary` | Reads a `.stk` file via `FileAccess` + `JSON.parse_string`. Returns `{}` and `push_warning` on failure. |
|
||||||
|
| `spawn_from_data` | `static func spawn_from_data(stk_data: Dictionary) -> Node2D` | Instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root. |
|
||||||
|
| `spawn` | `static func spawn(path: String) -> Node2D` | Chains `load_stk()` → `spawn_from_data()`. Returns `null` on empty data. |
|
||||||
|
|
||||||
|
The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/basic.stk")` yields a rigged `Node2D` ready to add to the scene tree. `StkRigAdapter` (Phase 8, extended by Phase 9) now also fits the head bone (`Head.position.y = -proportions.torso_length`) and mounts the head as **full geometry** like every other part (clearing its inline `@tool` circle script).
|
||||||
|
|
||||||
|
**Test Harness (`res://scenes/test_harness.tscn`)** — run via **F6** on the scene (NOT via the main editor scene). Controls:
|
||||||
|
|
||||||
|
- **Open .stk…** — `FileDialog` filtered to `*.stk`; quick-select buttons for `res://stickmen/break.stk`, `res://stickmen/basic.stk`, `res://stickmen/test.stk`.
|
||||||
|
- **Show Bones / Show IK Handles** — checkboxes toggling the debug overlay.
|
||||||
|
- **Loaded filename** — status label showing the currently loaded file.
|
||||||
|
- **Pan / zoom** — middle-mouse drag to pan, mouse-wheel to zoom the `Camera2D`; the camera recenters on each spawn.
|
||||||
|
- **IK drag** — click and drag any of the 4 limb `Marker2D` IK targets (`IK_Targets/Left_Hand`, `Right_Hand`, `Left_Leg`, `Right_Leg`); the scene's `SkeletonModificationStack2D` TwoBoneIK flexes the limb live. The harness enables the modification stack after each spawn.
|
||||||
|
|
||||||
|
Debug overlay (a world-space `Node2D` `_draw()`): bone lines drawn between each `Bone2D` global origin and its parent's (color-coded left cyan / right orange / central white, with joint dots) when **Show Bones** is on; colored markers at the four IK targets when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
|
||||||
|
|
||||||
## File format (`.stk`)
|
## File format (`.stk`)
|
||||||
|
|
||||||
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
|
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
|
||||||
@@ -373,7 +397,10 @@ Behavior:
|
|||||||
| `res://scenes/stickman_editor.tscn` | **Main scene** — editor layout, File/Edit/View menu bar, dialogs (`GridConfigDialog` + SpinBox), column containers (unique-name nodes). |
|
| `res://scenes/stickman_editor.tscn` | **Main scene** — editor layout, File/Edit/View menu bar, dialogs (`GridConfigDialog` + SpinBox), column containers (unique-name nodes). |
|
||||||
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
||||||
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.4 serialization with multi-shape/rotation/scale, `part_order`, and Phase 8 `proportions`/`pivot`/`length`, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.4 serialization with multi-shape/rotation/scale, `part_order`, and Phase 8 `proportions`/`pivot`/`length`, `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.** 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 (open shapes → `Line2D`, closed → `Polygon2D` fill + `Line2D` outline, width 16). **Not used by the editor** — consumed by a future runtime pipeline. |
|
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9.** 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`), mounting the `.stk` shapes onto the `Body/*` visual nodes (open shapes → `Line2D`, closed → `Polygon2D` fill + `Line2D` outline, width 16), and (Phase 9) fitting 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`. **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` and calls `StkRigAdapter.apply(stk_data, rig)`; `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
||||||
|
| `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 bone lines and IK-target markers; the 4 limb `Marker2D` IK targets are click-draggable, flexing limbs live via `SkeletonModificationStack2D` TwoBoneIK (enabled after each spawn). |
|
||||||
|
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
|
||||||
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
|
| `res://scripts/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
|
||||||
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels. |
|
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels. |
|
||||||
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
|
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
|
||||||
@@ -456,3 +483,5 @@ BodyPartPanel.shape_selected() ---(bound to part_name)---> stickman_editor
|
|||||||
> **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`.
|
> **Phase 7:** Adds the pose silhouette guide to the Whole Stickman preview — a semi-transparent, color-coded stick figure (left cyan-blue, right orange-red, central white) whose 13 joint anchors match the rest-pose pivots of the future `master_rig.tscn` rig, drawn above the grid and in front of user parts (ghosting over them, below the selection gizmos and drag highlight), centered in the preview at the default view. Toggleable via the dynamic **View → Hide/Show Pose Guide** label (on by default, persisted to `settings.json`). The guide is a view aid only: **no `.stk` format change** — `FILE_VERSION` stays `"1.3"`.
|
||||||
|
|
||||||
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0–v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
|
> **Phase 8:** The `.stk` export evolved to **v1.4** with write-only metadata the editor never reads back. A top-level `proportions` object stores the 5 master-rig rest-pose bone lengths (`upper_arm_length` 168.0, `lower_arm_length` 200.0, `upper_leg_length` 200.0, `lower_leg_length` 200.0, `torso_length` 391.5) — hardcoded constants from `master_rig.tscn`, not measured from the user's shapes. Each `body_parts` entry gains `pivot` (local bounding-box center = rotation origin) and `length` (bbox extent along the segment axis: width for arms, height for torso/legs/head). v1.0–v1.3 files load unchanged and gain these keys on their next save. A new standalone `res://scripts/stk_rig_adapter.gd` (`class_name StkRigAdapter`) fits an instantiated `master_rig.tscn` to a loaded `.stk` (bone fitting + IK recalibration + visual shape mount); it is **not** used by the editor and is reserved for a future runtime pipeline.
|
||||||
|
|
||||||
|
> **Phase 9:** Adds the **runtime pipeline** for turning a `.stk` file into a live, rigged `master_rig.tscn` instance, plus a standalone staging scene to debug it. A new `res://scripts/stickman_factory.gd` (`class_name StickmanFactory`) provides the runtime entry point: `load_stk(path)` reads/parses a `.stk` (`FileAccess` + `JSON.parse_string`, `{}` + `push_warning` on failure), `spawn_from_data(stk_data)` instantiates `master_rig.tscn` and applies `StkRigAdapter.apply(stk_data, rig)`, and `spawn(path)` chains them (`null` on empty data). `StkRigAdapter` is extended to also fit the head bone (`Head.position.y = -proportions.torso_length`) and to mount the head as **full geometry** like every other part — clearing its inline `@tool` circle script and mounting `.stk` head shapes as `Line2D`/`Polygon2D` (removed the dead `_mount_head_circle`, `_compute_shapes_bbox`, and `_first_shape_color` helpers). A new standalone scene/resource pair, `res://scenes/test_harness.tscn` + `res://scripts/test_harness.gd` (run via **F6**), loads `.stk` files (open dialog + quick-select for `stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), toggles a world-space debug overlay (bone lines + IK-target markers), pans/zooms a `Camera2D`, and lets you click-drag the 4 limb IK targets with live TwoBoneIK flexing (`SkeletonModificationStack2D` enabled on spawn). Neither the factory nor the harness is wired into the editor. **No `.stk` format change** — `FILE_VERSION` stays `"1.4"`.
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/test_harness.gd" id="1_harness"]
|
||||||
|
|
||||||
|
[node name="TestHarness" type="Control"]
|
||||||
|
layout_mode = 3
|
||||||
|
anchors_preset = 15
|
||||||
|
anchor_right = 1.0
|
||||||
|
anchor_bottom = 1.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
grow_vertical = 2
|
||||||
|
script = ExtResource("1_harness")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
class_name StickmanFactory
|
||||||
|
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,
|
||||||
|
## adapting it via StkRigAdapter. Consumed by the standalone test harness, not
|
||||||
|
## referenced by the editor.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const RIG_SCENE := preload("res://master_rig.tscn")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
static func load_stk(path: String) -> Dictionary:
|
||||||
|
var file := FileAccess.open(path, FileAccess.READ)
|
||||||
|
if file == null:
|
||||||
|
push_warning("StickmanFactory: failed to open '%s' (error %d)." % [path, FileAccess.get_open_error()])
|
||||||
|
return {}
|
||||||
|
|
||||||
|
var text := file.get_as_text()
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
var parsed: Variant = JSON.parse_string(text)
|
||||||
|
if parsed == null or not parsed is Dictionary:
|
||||||
|
push_warning("StickmanFactory: failed to parse '%s' as JSON." % path)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return parsed as Dictionary
|
||||||
|
|
||||||
|
|
||||||
|
static func spawn_from_data(stk_data: Dictionary) -> Node2D:
|
||||||
|
var rig := RIG_SCENE.instantiate() as Node2D
|
||||||
|
StkRigAdapter.apply(stk_data, rig)
|
||||||
|
return rig
|
||||||
|
|
||||||
|
|
||||||
|
static func spawn(path: String) -> Node2D:
|
||||||
|
var data := load_stk(path)
|
||||||
|
if data.is_empty():
|
||||||
|
return null
|
||||||
|
return spawn_from_data(data)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cdrs7705oidyc
|
||||||
+15
-49
@@ -61,6 +61,8 @@ const IK_RIGHT_HAND := "IK_Targets/Right_Hand"
|
|||||||
const IK_LEFT_LEG := "IK_Targets/Left_Leg"
|
const IK_LEFT_LEG := "IK_Targets/Left_Leg"
|
||||||
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
|
const IK_RIGHT_LEG := "IK_Targets/Right_Leg"
|
||||||
|
|
||||||
|
const HEAD_BONE_PATH := "Skeleton2D/Torso/Head"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -90,6 +92,7 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
|
|||||||
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
var la := float(proportions.get("lower_arm_length", DEFAULT_PROPORTIONS["lower_arm_length"]))
|
||||||
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
var ul := float(proportions.get("upper_leg_length", DEFAULT_PROPORTIONS["upper_leg_length"]))
|
||||||
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
var ll := float(proportions.get("lower_leg_length", DEFAULT_PROPORTIONS["lower_leg_length"]))
|
||||||
|
var torso := float(proportions.get("torso_length", DEFAULT_PROPORTIONS["torso_length"]))
|
||||||
|
|
||||||
# Arms — upper length + lower-bone origin on X.
|
# Arms — upper length + lower-bone origin on X.
|
||||||
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
|
_set_prop(rig, BONE_PATHS["left_upper_arm"], "length", ua)
|
||||||
@@ -107,6 +110,12 @@ static func _fit_bones(stk_data: Dictionary, rig: Node2D) -> void:
|
|||||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
|
_set_prop(rig, BONE_PATHS["right_lower_leg"], "position", Vector2(0.0, ul))
|
||||||
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
|
_set_prop(rig, BONE_PATHS["right_lower_leg"], "length", ll)
|
||||||
|
|
||||||
|
# Head — position the head bone at the top of the torso (preserve the
|
||||||
|
# existing x so the rig's ~ -0.1288 x-offset is retained).
|
||||||
|
var head_bone := rig.get_node_or_null(NodePath(HEAD_BONE_PATH))
|
||||||
|
if head_bone is Node2D:
|
||||||
|
_set_prop(rig, HEAD_BONE_PATH, "position", Vector2((head_bone as Node2D).position.x, -torso))
|
||||||
|
|
||||||
|
|
||||||
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
|
static func _set_prop(rig: Node2D, path: String, prop: String, value: Variant) -> void:
|
||||||
var node := rig.get_node_or_null(NodePath(path))
|
var node := rig.get_node_or_null(NodePath(path))
|
||||||
@@ -183,16 +192,17 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
|
|||||||
pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0)))
|
pivot = Vector2(float(pv.get("x", 0.0)), float(pv.get("y", 0.0)))
|
||||||
part_length = float(pd.get("length", 0.0))
|
part_length = float(pd.get("length", 0.0))
|
||||||
|
|
||||||
# Head: prefer setting the circle script's exports.
|
|
||||||
if part_name == "head" and ("radius" in visual):
|
|
||||||
_mount_head_circle(visual, shapes)
|
|
||||||
continue
|
|
||||||
|
|
||||||
var bone_length := _bone_length_for(part_name, proportions)
|
var bone_length := _bone_length_for(part_name, proportions)
|
||||||
var scale_factor := 1.0
|
var scale_factor := 1.0
|
||||||
if part_name != "head" and part_length > 0.0001:
|
if part_name != "head" and part_length > 0.0001:
|
||||||
scale_factor = bone_length / part_length
|
scale_factor = bone_length / part_length
|
||||||
|
|
||||||
|
# Phase 9: the Body/Head node is a plain Node2D carrying an inline
|
||||||
|
# @tool circle-drawing script; clear it so the head is mounted with the
|
||||||
|
# same full-geometry path as every other part.
|
||||||
|
if part_name == "head":
|
||||||
|
visual.set_script(null)
|
||||||
|
|
||||||
_reset_own_geometry(visual)
|
_reset_own_geometry(visual)
|
||||||
_clear_visual_children(visual)
|
_clear_visual_children(visual)
|
||||||
|
|
||||||
@@ -217,21 +227,6 @@ static func _bone_length_for(part_name: String, proportions: Dictionary) -> floa
|
|||||||
return 1.0
|
return 1.0
|
||||||
|
|
||||||
|
|
||||||
static func _mount_head_circle(visual: Node, shapes: Array) -> void:
|
|
||||||
_clear_visual_children(visual)
|
|
||||||
if shapes.is_empty():
|
|
||||||
visual.set("radius", 0.0)
|
|
||||||
return
|
|
||||||
|
|
||||||
var radius := 0.0
|
|
||||||
var bbox := _compute_shapes_bbox(shapes)
|
|
||||||
if bbox.size.x > 0.0001 or bbox.size.y > 0.0001:
|
|
||||||
var diameter: float = bbox.size.y if bbox.size.y > 0.0001 else bbox.size.x
|
|
||||||
radius = diameter * 0.5
|
|
||||||
visual.set("radius", radius)
|
|
||||||
visual.set("color", _first_shape_color(shapes))
|
|
||||||
|
|
||||||
|
|
||||||
static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void:
|
static func _mount_shape(visual: Node, shape: Dictionary, pivot: Vector2, scale_factor: float) -> void:
|
||||||
var pts := _transform_points(shape.get("points", []), pivot, scale_factor)
|
var pts := _transform_points(shape.get("points", []), pivot, scale_factor)
|
||||||
if pts.size() < 2:
|
if pts.size() < 2:
|
||||||
@@ -288,32 +283,3 @@ static func _clear_visual_children(visual: Node) -> void:
|
|||||||
if child is Line2D or child is Polygon2D:
|
if child is Line2D or child is Polygon2D:
|
||||||
visual.remove_child(child)
|
visual.remove_child(child)
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
|
|
||||||
|
|
||||||
static func _compute_shapes_bbox(shapes: Array) -> Rect2:
|
|
||||||
var min_x := INF
|
|
||||||
var min_y := INF
|
|
||||||
var max_x := -INF
|
|
||||||
var max_y := -INF
|
|
||||||
for shape in shapes:
|
|
||||||
if not shape is Dictionary:
|
|
||||||
continue
|
|
||||||
var pts_var: Variant = (shape as Dictionary).get("points", [])
|
|
||||||
if pts_var is Array:
|
|
||||||
for p in pts_var as Array:
|
|
||||||
if p is Dictionary:
|
|
||||||
var d := p as Dictionary
|
|
||||||
min_x = minf(min_x, float(d.get("x", 0.0)))
|
|
||||||
min_y = minf(min_y, float(d.get("y", 0.0)))
|
|
||||||
max_x = maxf(max_x, float(d.get("x", 0.0)))
|
|
||||||
max_y = maxf(max_y, float(d.get("y", 0.0)))
|
|
||||||
if min_x > max_x or min_y > max_y:
|
|
||||||
return Rect2()
|
|
||||||
return Rect2(Vector2(min_x, min_y), Vector2(max_x - min_x, max_y - min_y))
|
|
||||||
|
|
||||||
|
|
||||||
static func _first_shape_color(shapes: Array) -> Color:
|
|
||||||
for shape in shapes:
|
|
||||||
if shape is Dictionary:
|
|
||||||
return Color.from_string(str((shape as Dictionary).get("color", "#ffffff")), Color.WHITE)
|
|
||||||
return Color.WHITE
|
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
extends Control
|
||||||
|
## TestHarness - Standalone runtime rig viewer / IK playground (Phase 9).
|
||||||
|
##
|
||||||
|
## Loads .stk files via StickmanFactory, spawns a master_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.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MIN_ZOOM: float = 0.1
|
||||||
|
const MAX_ZOOM: float = 5.0
|
||||||
|
const ZOOM_STEP: float = 1.10
|
||||||
|
|
||||||
|
const HANDLE_HIT_RADIUS_PX: float = 12.0 # screen-space grab radius
|
||||||
|
const HANDLE_DRAW_RADIUS_PX: float = 6.0 # screen-space marker radius
|
||||||
|
const JOINT_DOT_RADIUS_PX: float = 4.0 # screen-space bone-joint dot radius
|
||||||
|
const BONE_LINE_WIDTH_PX: float = 2.0 # screen-space bone line width
|
||||||
|
|
||||||
|
const BONE_COLOR_LEFT := Color(0.35, 0.70, 1.00)
|
||||||
|
const BONE_COLOR_RIGHT := Color(1.00, 0.50, 0.20)
|
||||||
|
const BONE_COLOR_CENTRAL := Color(1.00, 1.00, 1.00)
|
||||||
|
const HANDLE_COLOR_HAND := Color(0.00, 1.00, 0.00) # green
|
||||||
|
const HANDLE_COLOR_FOOT := Color(0.00, 0.50, 1.00) # blue
|
||||||
|
|
||||||
|
const SKELETON_PATH := "Skeleton2D"
|
||||||
|
|
||||||
|
## Quick-load buttons: [label, res:// path].
|
||||||
|
const QUICK_LOADS: Array = [
|
||||||
|
["Break", "res://stickmen/break.stk"],
|
||||||
|
["Basic", "res://stickmen/basic.stk"],
|
||||||
|
["Test", "res://stickmen/test.stk"],
|
||||||
|
]
|
||||||
|
|
||||||
|
## IK handle node paths (relative to rig root), keyed by handle name.
|
||||||
|
const IK_HANDLE_PATHS: Dictionary = {
|
||||||
|
"Left_Hand": "IK_Targets/Left_Hand",
|
||||||
|
"Right_Hand": "IK_Targets/Right_Hand",
|
||||||
|
"Left_Leg": "IK_Targets/Left_Leg",
|
||||||
|
"Right_Leg": "IK_Targets/Right_Leg",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Runtime-built node references
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _viewport_container: SubViewportContainer
|
||||||
|
var _viewport: SubViewport
|
||||||
|
var _world: Node2D
|
||||||
|
var _camera: Camera2D
|
||||||
|
var _debug_overlay: Node2D
|
||||||
|
var _status_label: Label
|
||||||
|
var _file_dialog: FileDialog
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# State
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
var _rig: Node2D = null
|
||||||
|
var _skeleton: Skeleton2D = null
|
||||||
|
var _ik_handles: Dictionary = {} # { String : Marker2D }
|
||||||
|
|
||||||
|
var _show_bones: bool = true
|
||||||
|
var _show_ik: bool = true
|
||||||
|
|
||||||
|
var _is_panning: bool = false
|
||||||
|
var _pan_last: Vector2 = Vector2.ZERO
|
||||||
|
var _dragging_handle: Marker2D = null
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
_build_ui()
|
||||||
|
_viewport_container.resized.connect(_on_viewport_container_resized)
|
||||||
|
call_deferred("_on_viewport_container_resized")
|
||||||
|
|
||||||
|
|
||||||
|
func _input(event: InputEvent) -> void:
|
||||||
|
if _file_dialog != null and _file_dialog.visible:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event is InputEventMouseButton:
|
||||||
|
_handle_mouse_button(event as InputEventMouseButton)
|
||||||
|
elif event is InputEventMouseMotion:
|
||||||
|
_handle_mouse_motion(event as InputEventMouseMotion)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UI construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _build_ui() -> void:
|
||||||
|
# Dark background behind the transparent SubViewport.
|
||||||
|
var bg := ColorRect.new()
|
||||||
|
bg.color = Color(0.10, 0.10, 0.10, 1.0)
|
||||||
|
bg.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
add_child(bg)
|
||||||
|
|
||||||
|
# Top UI bar.
|
||||||
|
var top_bar := PanelContainer.new()
|
||||||
|
top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE)
|
||||||
|
top_bar.offset_bottom = 40.0
|
||||||
|
add_child(top_bar)
|
||||||
|
|
||||||
|
var hbox := HBoxContainer.new()
|
||||||
|
hbox.add_theme_constant_override("separation", 8)
|
||||||
|
top_bar.add_child(hbox)
|
||||||
|
|
||||||
|
var open_btn := Button.new()
|
||||||
|
open_btn.text = "Open .stk…"
|
||||||
|
open_btn.pressed.connect(_on_open_pressed)
|
||||||
|
hbox.add_child(open_btn)
|
||||||
|
|
||||||
|
for entry: Array in QUICK_LOADS:
|
||||||
|
var btn := Button.new()
|
||||||
|
btn.text = str(entry[0])
|
||||||
|
btn.pressed.connect(_on_quick_load.bind(str(entry[1])))
|
||||||
|
hbox.add_child(btn)
|
||||||
|
|
||||||
|
var bones_cb := CheckBox.new()
|
||||||
|
bones_cb.text = "Show Bones"
|
||||||
|
bones_cb.button_pressed = true
|
||||||
|
bones_cb.toggled.connect(_on_show_bones_toggled)
|
||||||
|
hbox.add_child(bones_cb)
|
||||||
|
|
||||||
|
var ik_cb := CheckBox.new()
|
||||||
|
ik_cb.text = "Show IK Handles"
|
||||||
|
ik_cb.button_pressed = true
|
||||||
|
ik_cb.toggled.connect(_on_show_ik_toggled)
|
||||||
|
hbox.add_child(ik_cb)
|
||||||
|
|
||||||
|
_status_label = Label.new()
|
||||||
|
_status_label.text = "No file loaded"
|
||||||
|
_status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT
|
||||||
|
_status_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
|
||||||
|
_status_label.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
|
hbox.add_child(_status_label)
|
||||||
|
|
||||||
|
# Viewport area (fills everything below the top bar).
|
||||||
|
_viewport_container = SubViewportContainer.new()
|
||||||
|
_viewport_container.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
_viewport_container.offset_top = 40.0
|
||||||
|
_viewport_container.mouse_filter = Control.MOUSE_FILTER_STOP
|
||||||
|
add_child(_viewport_container)
|
||||||
|
|
||||||
|
_viewport = SubViewport.new()
|
||||||
|
_viewport.transparent_bg = true
|
||||||
|
_viewport_container.add_child(_viewport)
|
||||||
|
|
||||||
|
_world = Node2D.new()
|
||||||
|
_world.name = "World"
|
||||||
|
_viewport.add_child(_world)
|
||||||
|
|
||||||
|
_camera = Camera2D.new()
|
||||||
|
_camera.enabled = true
|
||||||
|
_world.add_child(_camera)
|
||||||
|
_camera.make_current()
|
||||||
|
|
||||||
|
_debug_overlay = Node2D.new()
|
||||||
|
_debug_overlay.name = "DebugOverlay"
|
||||||
|
_debug_overlay.draw.connect(_on_debug_overlay_draw)
|
||||||
|
_world.add_child(_debug_overlay)
|
||||||
|
|
||||||
|
# File dialog for "Open .stk…".
|
||||||
|
_file_dialog = FileDialog.new()
|
||||||
|
_file_dialog.title = "Open .stk"
|
||||||
|
_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
|
||||||
|
_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
|
||||||
|
_file_dialog.filters = PackedStringArray(["*.stk ; Stickman Files"])
|
||||||
|
_file_dialog.file_selected.connect(_on_file_selected)
|
||||||
|
add_child(_file_dialog)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_viewport_container_resized() -> void:
|
||||||
|
var size := _viewport_container.size
|
||||||
|
if size.x < 1.0 or size.y < 1.0:
|
||||||
|
return
|
||||||
|
_viewport.size = Vector2i(size)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input handling
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
|
||||||
|
if not _is_over_viewport(mb.position):
|
||||||
|
return
|
||||||
|
|
||||||
|
if mb.button_index == MOUSE_BUTTON_MIDDLE:
|
||||||
|
if mb.pressed:
|
||||||
|
_is_panning = true
|
||||||
|
_pan_last = mb.position
|
||||||
|
else:
|
||||||
|
_is_panning = false
|
||||||
|
return
|
||||||
|
|
||||||
|
if mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed:
|
||||||
|
_apply_zoom(mb.position, ZOOM_STEP)
|
||||||
|
return
|
||||||
|
if mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed:
|
||||||
|
_apply_zoom(mb.position, 1.0 / ZOOM_STEP)
|
||||||
|
return
|
||||||
|
|
||||||
|
if mb.button_index == MOUSE_BUTTON_LEFT:
|
||||||
|
if mb.pressed:
|
||||||
|
_dragging_handle = _hit_test_handle(_viewport_to_world(mb.position))
|
||||||
|
else:
|
||||||
|
_dragging_handle = null
|
||||||
|
|
||||||
|
|
||||||
|
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
|
||||||
|
if _is_panning:
|
||||||
|
var delta := mm.position - _pan_last
|
||||||
|
_pan_last = mm.position
|
||||||
|
_camera.position -= delta / _camera.zoom
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
return
|
||||||
|
|
||||||
|
if _dragging_handle != null and is_instance_valid(_dragging_handle):
|
||||||
|
_dragging_handle.global_position = _viewport_to_world(mm.position)
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _is_over_viewport(screen_pos: Vector2) -> bool:
|
||||||
|
return _viewport_container.get_global_rect().has_point(screen_pos)
|
||||||
|
|
||||||
|
|
||||||
|
func _viewport_to_world(screen_pos: Vector2) -> Vector2:
|
||||||
|
var local := screen_pos - _viewport_container.global_position
|
||||||
|
return (local - _viewport.size * 0.5) / _camera.zoom + _camera.position
|
||||||
|
|
||||||
|
|
||||||
|
func _apply_zoom(screen_pos: Vector2, factor: float) -> void:
|
||||||
|
var world_before := _viewport_to_world(screen_pos)
|
||||||
|
var new_zoom := clampf(_camera.zoom.x * factor, MIN_ZOOM, MAX_ZOOM)
|
||||||
|
_camera.zoom = Vector2(new_zoom, new_zoom)
|
||||||
|
var world_after := _viewport_to_world(screen_pos)
|
||||||
|
_camera.position += world_before - world_after
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _hit_test_handle(world_pos: Vector2) -> Marker2D:
|
||||||
|
var hit_radius := HANDLE_HIT_RADIUS_PX / _camera.zoom.x
|
||||||
|
var best: Marker2D = null
|
||||||
|
var best_dist := hit_radius
|
||||||
|
for handle_name: String in _ik_handles:
|
||||||
|
var handle := _ik_handles[handle_name] as Marker2D
|
||||||
|
if handle == null or not is_instance_valid(handle):
|
||||||
|
continue
|
||||||
|
var dist := world_pos.distance_to(handle.global_position)
|
||||||
|
if dist <= best_dist:
|
||||||
|
best = handle
|
||||||
|
best_dist = dist
|
||||||
|
return best
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Debug overlay drawing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_debug_overlay_draw() -> void:
|
||||||
|
if _show_bones:
|
||||||
|
_draw_bones()
|
||||||
|
if _show_ik:
|
||||||
|
_draw_ik_handles()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_bones() -> void:
|
||||||
|
if _skeleton == null or not is_instance_valid(_skeleton):
|
||||||
|
return
|
||||||
|
var zoom := _camera.zoom.x
|
||||||
|
var line_w := BONE_LINE_WIDTH_PX / zoom
|
||||||
|
var dot_r := JOINT_DOT_RADIUS_PX / zoom
|
||||||
|
|
||||||
|
for i: int in _skeleton.get_bone_count():
|
||||||
|
var bone := _skeleton.get_bone(i)
|
||||||
|
if bone == null:
|
||||||
|
continue
|
||||||
|
var color := _bone_color(bone.name)
|
||||||
|
var origin := bone.global_position
|
||||||
|
_debug_overlay.draw_circle(origin, dot_r, color)
|
||||||
|
|
||||||
|
var parent := bone.get_parent()
|
||||||
|
if parent is Bone2D:
|
||||||
|
_debug_overlay.draw_line((parent as Bone2D).global_position, origin, color, line_w)
|
||||||
|
|
||||||
|
|
||||||
|
func _draw_ik_handles() -> void:
|
||||||
|
var zoom := _camera.zoom.x
|
||||||
|
var r := HANDLE_DRAW_RADIUS_PX / zoom
|
||||||
|
var outline_w := 1.5 / zoom
|
||||||
|
|
||||||
|
for handle_name: String in _ik_handles:
|
||||||
|
var handle := _ik_handles[handle_name] as Marker2D
|
||||||
|
if handle == null or not is_instance_valid(handle):
|
||||||
|
continue
|
||||||
|
var color := HANDLE_COLOR_HAND if handle_name.ends_with("Hand") else HANDLE_COLOR_FOOT
|
||||||
|
var pos := handle.global_position
|
||||||
|
_debug_overlay.draw_circle(pos, r, color)
|
||||||
|
_debug_overlay.draw_arc(pos, r, 0.0, TAU, 24, Color(1.0, 1.0, 1.0, 0.8), outline_w)
|
||||||
|
|
||||||
|
|
||||||
|
func _bone_color(bone_name: String) -> Color:
|
||||||
|
if bone_name.begins_with("Left"):
|
||||||
|
return BONE_COLOR_LEFT
|
||||||
|
if bone_name.begins_with("Right"):
|
||||||
|
return BONE_COLOR_RIGHT
|
||||||
|
return BONE_COLOR_CENTRAL
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Spawn / swap
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_open_pressed() -> void:
|
||||||
|
_file_dialog.popup_centered_ratio(0.6)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_quick_load(path: String) -> void:
|
||||||
|
_load_and_spawn(path)
|
||||||
|
|
||||||
|
|
||||||
|
func _on_file_selected(path: String) -> void:
|
||||||
|
_load_and_spawn(path)
|
||||||
|
|
||||||
|
|
||||||
|
func _load_and_spawn(path: String) -> void:
|
||||||
|
_free_current_rig()
|
||||||
|
|
||||||
|
var rig := StickmanFactory.spawn(path)
|
||||||
|
if rig == null:
|
||||||
|
_status_label.text = "Error: failed to load " + path.get_file()
|
||||||
|
return
|
||||||
|
|
||||||
|
_rig = rig
|
||||||
|
_world.add_child(_rig)
|
||||||
|
_world.move_child(_rig, 0) # keep the rig behind the debug overlay
|
||||||
|
|
||||||
|
_resolve_rig_nodes()
|
||||||
|
_ensure_modification_stack_enabled()
|
||||||
|
|
||||||
|
_status_label.text = path.get_file()
|
||||||
|
_recenter_camera()
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _free_current_rig() -> void:
|
||||||
|
if _rig != null and is_instance_valid(_rig):
|
||||||
|
_rig.queue_free()
|
||||||
|
_rig = null
|
||||||
|
_skeleton = null
|
||||||
|
_ik_handles.clear()
|
||||||
|
_dragging_handle = null
|
||||||
|
|
||||||
|
|
||||||
|
func _resolve_rig_nodes() -> void:
|
||||||
|
_skeleton = _rig.get_node_or_null(NodePath(SKELETON_PATH)) as Skeleton2D
|
||||||
|
if _skeleton == null:
|
||||||
|
push_warning("TestHarness: missing '%s' node in rig." % SKELETON_PATH)
|
||||||
|
|
||||||
|
_ik_handles.clear()
|
||||||
|
for handle_name: String in IK_HANDLE_PATHS:
|
||||||
|
var handle := _rig.get_node_or_null(NodePath(IK_HANDLE_PATHS[handle_name])) as Marker2D
|
||||||
|
if handle != null:
|
||||||
|
_ik_handles[handle_name] = handle
|
||||||
|
else:
|
||||||
|
push_warning("TestHarness: missing IK handle '%s'." % IK_HANDLE_PATHS[handle_name])
|
||||||
|
|
||||||
|
|
||||||
|
func _ensure_modification_stack_enabled() -> void:
|
||||||
|
if _skeleton == null:
|
||||||
|
return
|
||||||
|
var stack: SkeletonModificationStack2D = _skeleton.modification_stack
|
||||||
|
if stack != null:
|
||||||
|
stack.enabled = true
|
||||||
|
else:
|
||||||
|
push_warning("TestHarness: Skeleton2D has no modification_stack assigned.")
|
||||||
|
|
||||||
|
|
||||||
|
func _recenter_camera() -> void:
|
||||||
|
if _rig == null:
|
||||||
|
return
|
||||||
|
# The rig root sits at the hips; nudge the camera up so the whole figure is
|
||||||
|
# roughly centered in the viewport.
|
||||||
|
_camera.position = _rig.global_position + Vector2(0.0, -50.0)
|
||||||
|
_camera.zoom = Vector2.ONE
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Checkbox handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func _on_show_bones_toggled(pressed: bool) -> void:
|
||||||
|
_show_bones = pressed
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_show_ik_toggled(pressed: bool) -> void:
|
||||||
|
_show_ik = pressed
|
||||||
|
_debug_overlay.queue_redraw()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b7wkt57rkpfo4
|
||||||
+37
-37
@@ -59,7 +59,7 @@
|
|||||||
"y": 227.806777954102
|
"y": 227.806777954102
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 455.448852539063,
|
"x": 455.448852539062,
|
||||||
"y": 213.165756225586
|
"y": 213.165756225586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"y": 193.165756225586
|
"y": 193.165756225586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 455.448852539063,
|
"x": 455.448852539062,
|
||||||
"y": 173.165756225586
|
"y": 173.165756225586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 514.874938964844,
|
"x": 514.874938964844,
|
||||||
"y": 174.483520507813
|
"y": 174.483520507812
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 497.417388916016,
|
"x": 497.417388916016,
|
||||||
@@ -246,31 +246,31 @@
|
|||||||
"shape_type": "circle",
|
"shape_type": "circle",
|
||||||
"points": [
|
"points": [
|
||||||
{
|
{
|
||||||
"x": 490.218383789063,
|
"x": 490.218383789062,
|
||||||
"y": 122.896606445313
|
"y": 122.896606445312
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 510.218383789063,
|
"x": 510.218383789062,
|
||||||
"y": 128.255599975586
|
"y": 128.255599975586
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 524.859375,
|
"x": 524.859375,
|
||||||
"y": 142.896606445313
|
"y": 142.896606445312
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 530.218383789063,
|
"x": 530.218383789062,
|
||||||
"y": 162.896606445313
|
"y": 162.896606445312
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 450.218383789063,
|
"x": 450.218383789062,
|
||||||
"y": 162.896606445313
|
"y": 162.896606445312
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 455.577392578125,
|
"x": 455.577392578125,
|
||||||
"y": 142.896606445313
|
"y": 142.896606445312
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 470.218383789063,
|
"x": 470.218383789062,
|
||||||
"y": 128.255599975586
|
"y": 128.255599975586
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -288,8 +288,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 473.278625488281,
|
"x": 248.278564453125,
|
||||||
"y": 381.460693359375
|
"y": 126.460693359375
|
||||||
},
|
},
|
||||||
"rotation": -360.0,
|
"rotation": -360.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -335,8 +335,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 649.5,
|
"x": 419.5,
|
||||||
"y": 599.0
|
"y": 344.0
|
||||||
},
|
},
|
||||||
"rotation": 0.0,
|
"rotation": 0.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -382,8 +382,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 371.5,
|
"x": 136.5,
|
||||||
"y": 609.5
|
"y": 364.5
|
||||||
},
|
},
|
||||||
"rotation": 0.0,
|
"rotation": 0.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -406,11 +406,11 @@
|
|||||||
"y": 127.010650634766
|
"y": 127.010650634766
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 533.026489257813,
|
"x": 533.026489257812,
|
||||||
"y": 127.221878051758
|
"y": 127.221878051758
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"x": 532.166625976563,
|
"x": 532.166625976562,
|
||||||
"y": 142.74137878418
|
"y": 142.74137878418
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -429,8 +429,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 305.000091552734,
|
"x": 70.0000305175781,
|
||||||
"y": 534.999938964844
|
"y": 295.0
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -476,8 +476,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 711.0,
|
"x": 481.0,
|
||||||
"y": 622.0
|
"y": 377.0
|
||||||
},
|
},
|
||||||
"rotation": -180.0,
|
"rotation": -180.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -523,8 +523,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 707.087768554688,
|
"x": 482.087707519531,
|
||||||
"y": 524.3349609375
|
"y": 289.335083007812
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -570,8 +570,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 366.208190917969,
|
"x": 141.208190917969,
|
||||||
"y": 959.632446289063
|
"y": 714.632446289062
|
||||||
},
|
},
|
||||||
"rotation": -60.0,
|
"rotation": -60.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -617,8 +617,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 345.5,
|
"x": 115.5,
|
||||||
"y": 1129.0
|
"y": 879.0
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -664,8 +664,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 538.218627929688,
|
"x": 318.218627929688,
|
||||||
"y": 957.340209960938
|
"y": 727.340270996094
|
||||||
},
|
},
|
||||||
"rotation": 60.0,
|
"rotation": 60.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -711,8 +711,8 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"position": {
|
"position": {
|
||||||
"x": 628.5,
|
"x": 403.5,
|
||||||
"y": 1139.5
|
"y": 899.5
|
||||||
},
|
},
|
||||||
"rotation": 90.0,
|
"rotation": 90.0,
|
||||||
"scale": {
|
"scale": {
|
||||||
@@ -727,7 +727,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"created_at": "2026-08-18T00:05:43",
|
"created_at": "2026-08-18T21:39:41",
|
||||||
"modified_at": "2026-08-18T00:05:43"
|
"modified_at": "2026-08-18T21:39:41"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user