- Created ROADMAP.md outlining core features, implementation phases, and detailed breakdown for the Stickman Sandbox Builder project. - Introduced plans for dynamic vector props with `PropBlock` specification, including reusable components and utility functions for prop creation. - Developed a vector terrain system plan detailing the reusable `TerrainBlock` component and associated utility functions for geometry handling. - Implemented `PhysicsTestHarness` scene for testing physics interactions with dynamic props and terrain. - Added scripts for `PropBlock`, `PropUtils`, `TerrainBlock`, and `TerrainUtils` to support dynamic prop creation and terrain management.
499 lines
41 KiB
Markdown
499 lines
41 KiB
Markdown
# AGENTS.md — stickman (Godot 4.4)
|
||
|
||
## Project type
|
||
- **Godot 4.4** 2D/GUI project (Forward Plus renderer)
|
||
- No CLI build/test/lint commands; open in the Godot editor to run
|
||
- **Main scene:** `res://scenes/stickman_editor.tscn` (set as `run/main_scene` in `project.godot`)
|
||
|
||
## 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.
|
||
|
||
## 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
|
||
selection so only one shape is selected at a time (`shape_selected` → deselect others).
|
||
Collects per-part `{shapes[], position, rotation, scale, pivot, length, guide_offset}` for save/load.
|
||
- **Phase 8 save export:** writes top-level `proportions` (hardcoded master-rig rest-pose
|
||
constants 168/200/200/200/391.5 via the `PROPORTIONS` const) and per-part `pivot`/`length`
|
||
computed from the panel's local shape bounding box (`_compute_part_pivot_length()`): `pivot` =
|
||
bbox center, `length` = bbox width for the 4 arm parts (`X_AXIS_PARTS`) and bbox height
|
||
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
|
||
`_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
|
||
rig attachment origin, NOT the circle center — torso→"Hips", upper arms→Shoulders, lower
|
||
arms→Elbows, upper legs→"Hips", lower legs→Knees). Write-only metadata like `pivot`/`length`;
|
||
the load path (`_apply_json_data`) ignores it, so v1.0–v1.4 files load unchanged and gain the
|
||
key on their next save.
|
||
- **Phase 6 recent colors:** stores `_recent_colors: Array[String]` (max 8,
|
||
most-recent-first), loads from `settings.json` (`recent_colors` key) in
|
||
`_load_settings()`, saves on each color selection via `_save_settings()`, and
|
||
broadcasts to all 10 panels via `_broadcast_recent_colors()`.
|
||
- `_on_color_selected(color, part_name)`: deduplicates, inserts the hex string at the
|
||
front, trims to 8 entries, saves settings, then broadcasts to all panels.
|
||
- `_broadcast_recent_colors()`: pushes `_recent_colors` to every panel via
|
||
`BodyPartPanel.set_recent_colors(_recent_colors)`.
|
||
- **Phase 6 status bar (cursor coords):** `_process()` polls
|
||
`get_global_mouse_position()` each frame, checks each panel via
|
||
`is_cursor_over_drawing(global_pos)` and the preview via
|
||
`is_cursor_over_preview(global_pos)`, converts to world space via
|
||
`global_to_world(global_pos)`, and writes `"X: ### Y: ###"` to `_status_cursor_coords`.
|
||
- **Phase 6 snap status:** `_status_snap_status` displays `"SNAP: ON"` / `"SNAP: OFF"`,
|
||
set in `_ready()` and re-synced when snap is toggled (`_on_edit_menu_id_pressed`).
|
||
- **Phase 7 pose guide toggle:** stores `_show_guide: bool` (default `true`), persisted to
|
||
`settings.json` under the `show_pose_guide` key (default `true`) via `_load_settings()` /
|
||
`_save_settings()`. The View menu item (id 1) is a **dynamic, text-only** label (no
|
||
checkmark): "Hide Pose Guide" while the guide is visible, "Show Pose Guide" while hidden,
|
||
set via `_guide_menu_label()`. `_update_guide_menu_item()` refreshes only the item text;
|
||
it is synced on the `about_to_popup` signal (`_on_view_menu_about_to_popup`) and again at
|
||
the end of `_load_settings()` (so a persisted `show_pose_guide: false` shows "Show Pose
|
||
Guide" immediately at startup). `_on_view_menu_id_pressed` (id 1) toggles the state, saves,
|
||
and broadcasts. `_broadcast_settings()` pushes the value to the preview via
|
||
`WholeStickmanPreview.set_show_guide(_show_guide)` (called in `_ready()` after
|
||
`_load_settings()`).
|
||
- `scripts/body_part_panel.gd` — `class_name BodyPartPanel`, `extends PanelContainer`.
|
||
Reusable per-part editor. Public API:
|
||
- `set_shape_data(data: Variant)` — import shape data (Array or single Dictionary; used on Load/Clear)
|
||
- `get_shape_data() -> Array[Dictionary]` — export array of `{shape_type, points, color, closed, vertex_flags}`
|
||
- `clear_shape()` — reset panel, clears all shapes (does **not** emit `shape_changed`)
|
||
- `select()` — mark the panel's topmost shape as selected (white outline highlight)
|
||
- `deselect()` — clear selection and cancel any in-progress vertex drag
|
||
- `signal shape_changed(shapes: Array)` — emitted when any shape is created, modified, deleted, or reordered
|
||
- `signal shape_selected()` — emitted on left-click; the editor deselects all other panels
|
||
- `signal color_selected(color: Color)` — emitted when the color is confirmed (OK button)
|
||
- `set_recent_colors(colors_hex: Array)` — clears existing ColorPicker presets and populates with the given hex colors
|
||
- `is_cursor_over_drawing(global_pos: Vector2) -> bool` — true if `global_pos` is over the drawing surface
|
||
- `global_to_world(global_pos: Vector2) -> Vector2` — maps a global position to drawing ("world") space
|
||
- **Phase 2 vertex editing:** left-click on a shape selects it; drag a vertex handle to
|
||
reshape in real time; with a shape selected, right-click near an outline edge offers
|
||
"Create Point" (inserts a vertex flagged `1` at the edge midpoint). Original vertices
|
||
are filled circles; user-created vertices are hollow rectangles.
|
||
- **Phase 4 multi-shape:** panels store a `shapes[]` array. Right-click context menu
|
||
includes "Send Back" (id 7) and "Bring Forward" (id 8) for Z-ordering. "Delete" (id 5)
|
||
removes the specific shape under the mouse. `_selected_shape_idx` tracks which shape
|
||
is active for vertex editing.
|
||
- **Per-panel zoom:** mouse wheel multiplies `_zoom` by 1.10, clamped to `[0.3, 3.0]`;
|
||
drawing and input hit-testing both run in world space via `draw_set_transform`.
|
||
- **Phase 6 touchpad:** `_gui_input` handles `InputEventMagnifyGesture` (pinch zoom)
|
||
and `InputEventPanGesture` (2-finger drag panning), both checked before
|
||
`InputEventMouseButton`. Pan gesture delta is multiplied by 3.0 for speed
|
||
parity with mouse panning.
|
||
- **Phase 6 cursor-centered zoom:** both mouse wheel and pinch zoom adjust
|
||
`_pan_offset` so the world point under the cursor stays fixed during zoom.
|
||
- **Selection gizmos always on top:** bounding box, rotation circle, and scale
|
||
crosses for the selected part are drawn in a second pass after all parts,
|
||
via `_selected_gizmo_bounds`, so they always render in front.
|
||
- `scripts/whole_stickman_preview.gd` — `class_name WholeStickmanPreview`, `extends Control`;
|
||
the assembly preview. Owns per-part position/rotation/scale, Z-order (`_part_order`),
|
||
selection + gizmos, grid drawing, and pan/zoom. Public API includes `set_body_parts()`,
|
||
`set_show_guide(enabled: bool)`, `reset_view()`, `is_cursor_over_preview(global_pos)`, and
|
||
(Phase 9 Round 5) `get_guide_joint_preview(joint_name) -> Vector2` — returns the preview-space
|
||
position of a `GUIDE_JOINTS` entry via `_guide_to_preview()`, guarded against unknown names
|
||
(`push_warning` + `Vector2.ZERO`).
|
||
- **Phase 7 pose silhouette guide:** `set_show_guide()` stores `_show_guide: bool`
|
||
(default `true`) and redraws; `_draw_silhouette_guide()` is called in `_on_preview_draw()`
|
||
after the part loop and before the drag highlight/selection gizmos, so it renders above
|
||
the grid **and in front of user parts** (ghosting over them), below the selection
|
||
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
|
||
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;
|
||
`GUIDE_SCALE = 1.0`, `GUIDE_FIGURE_CENTER = (0, -93.75)`, `GUIDE_HEAD_RADIUS = 100.0`).
|
||
Color-coded: left limbs cyan-blue `Color(0.35, 0.70, 1.00)`, right limbs orange-red
|
||
`Color(1.00, 0.50, 0.20)`, central spine/head white, with lines at alpha `0.45` and joint
|
||
dots at alpha `0.65`. Joint dots use `GUIDE_JOINT_RADIUS / _zoom` (6 px constant screen
|
||
size). The guide is pure drawing — **no hit-testing** is added, so it never intercepts
|
||
part dragging/selection.
|
||
- `scripts/stk_rig_adapter.gd` — `class_name StkRigAdapter`, `extends RefCounted`; a **standalone
|
||
runtime adapter** (Phase 8, **not referenced by the editor**, extended by Phase 9).
|
||
`static func apply(stk_data, rig)` fits an instantiated `master_rig.tscn` to a loaded `.stk`
|
||
dictionary, calling three private helpers in order: `_fit_bones` (re-fits the 8 limb `Bone2D`
|
||
lengths + lower-bone origins, and zeroes the Head driver's local position so the chin sits on
|
||
the neck joint), `_recalibrate_ik` (repositions the `IK_Targets/Left|Right_Hand` and
|
||
`Left|Right_Leg` targets), and `_mount_shapes` (mounts `.stk` shapes onto the `Body/*` visual
|
||
nodes — one node per shape: closed → single `Polygon2D`, open → single `Line2D` width 2). The
|
||
`RemoteTransform2D` drivers keep their defaults (`update_rotation = true`), so mounted shapes
|
||
follow their bones in every pose.
|
||
- **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
|
||
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.
|
||
- **Phase 9 Round 2 bugfix (hanging-convention mount):** driver rotation neutralization was
|
||
**removed** — the `RemoteTransform2D` drivers keep `update_rotation = true`, so each mounted
|
||
part rotates to follow its bone in every pose (IK flexing included). `_mount_shapes()` no
|
||
longer reads the file's `pivot`/`length` fields — it recomputes a per-part bounding box at
|
||
mount time via `_compute_part_bbox()` (empty bbox → the part is skipped) and derives the
|
||
mount transform via `_compute_mount_transform()`, which emits `{anchor, scale, theta}`:
|
||
geometry is mounted in the rig's **hanging convention** (joint anchor at the local origin,
|
||
far end along local `+Y`). Anchors: head and torso → bottom-center `(cx, max_y)` (chin / hip
|
||
end at the origin); left limbs drawn horizontally → `(max_x, cy)`; right limbs drawn
|
||
horizontally → `(min_x, cy)`; vertically drawn limbs → top-center `(cx, min_y)`. Alignment
|
||
rotation θ maps the far end onto local `+Y`: head `0`, torso `π` (driver π cancels it at
|
||
rest), left horizontal limbs `−π/2`, right horizontal limbs `+π/2`, vertical limbs `0`.
|
||
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`); the
|
||
cross axis stays 1:1 (so horizontally-drawn legs become ~200×28, not 101-px bars).
|
||
`_bone_length_for()` maps each part to its bone length (upper/lower arm/leg, torso).
|
||
`_map_point()` applies `(P − J) ⋅ S` then `q.rotated(θ)`. The **head driver's** local
|
||
position (`Skeleton2D/Torso/Head/RemoteTransform2D`) is zeroed in `_fit_bones()` so the
|
||
mounted head's chin lands on the neck joint. `DEFAULT_LINE_WIDTH := 2.0` (was 16.0) matches
|
||
the editor's 2 px outline. `_reset_node_transform()` still resets each `Body/*` container's
|
||
scale to `(1, 1)` and rotation to `0` before mounting (position untouched, owned by the
|
||
driver).
|
||
- **Phase 9 Round 3 bugfix (part preview transform + one node per shape):** the mount
|
||
pipeline now **composes the part's preview transform** `E(P) = C + R(rot)·S·(P − C)`
|
||
(scale-then-rotate about the raw bbox center — the editor's exact Whole-Stickman-preview
|
||
transform) **before** the hanging-convention mount. `_mount_shapes()` reads the per-part
|
||
`rotation` (degrees, default `0.0`) and `scale` (`{x,y}`, default `(1,1)`) from the part
|
||
dict and applies `E` to the raw joint end `J_raw` and far point `F_pt_raw`
|
||
(`J' = E(J_raw)`, `F' = E(F_pt_raw) − J'`). The anchor, alignment θ, and bone-fit scale `s`
|
||
are then computed on the **transformed geometry**: rotations near ±180°
|
||
(`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end (`A = F_pt'`, `V = −F'`)
|
||
so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint and its
|
||
hip end at the neck); other rotations keep limbs attached along their bones (a 90° forearm
|
||
hangs from the elbow with its content turned, exactly as assembled). The bone-fit scale
|
||
`s = bone_length / |V|` is measured on the transformed extent so user-scaled parts are not
|
||
double-fitted. The **head** mounts upright with `θ = 0`, `s = 1` (a bone-fit scale would
|
||
double-scale the face), but still applies the part scale through `E` (face ≈160 px) with
|
||
the chin at the neck joint and the flip anchor rule still applying. `_mount_shape()` mounts
|
||
**one node per shape**: closed → single `Polygon2D` (fill only, no paired `Line2D` outline);
|
||
open → single `Line2D` (width 2).
|
||
- **Phase 9 Round 4 bugfix (head chin drop):** adds `const HEAD_CHIN_DROP := 28.0`, derived
|
||
from the editor's pose guide — the head circle (radius 100) is centered at the Head joint
|
||
`(0, −463.5)`, so its bottom is `−363.5`; the neck (Head bone origin) is at `−391.5`, so the
|
||
chin drops 28 px below the neck. The mounted head points get a `Vector2(0.0, 28.0)`
|
||
rig-space translation (`offset` in `_compute_mount_transform()` / `_map_point()`, applied
|
||
**after** the part transform and the `(θ = 0, s = 1)` transform; flip-agnostic — only the
|
||
head branch sets a non-zero `offset`). Result: the head's chin lands at world ≈ `(0, −363.5)`,
|
||
overlapping the torso's top (which ends at `−391.5`) by 28 px — matching the silhouette
|
||
guide in the editor.
|
||
- **Phase 9 Round 5 guide-offset application:** `_mount_shapes()` reads each part's
|
||
`guide_offset` (`{x, y}`, default absent) and, **only when the key is present** (old files
|
||
keep the previous offset-0 behavior and the head falls back to the Round 4
|
||
`HEAD_CHIN_DROP`), applies a node-frame translation
|
||
`t = (guide_offset + (A − C)).rotated(−c_node)` where A = the mount anchor already computed
|
||
(the transformed joint end `J'`, or the transformed far end `F_pt'` when flipped — Round 3),
|
||
C = the raw bbox center, and `c_node` = the part's driver `RemoteTransform2D.global_rotation`
|
||
at apply time (read via the reintroduced `DRIVER_PATHS` const; null-guarded, fallback 0.0).
|
||
This converts the editor's master-space guide offset into a bone-relative placement so the
|
||
harness reproduces the guide placement 1:1 (and, for the head — mapped to the guide **Neck**
|
||
joint — subsumes the `HEAD_CHIN_DROP` fallback). `t` is applied in `_map_point()` as the
|
||
final rig-space translation, after `E`/θ/scale/flip and independent of the flip logic.
|
||
Re-saving a `.stk` from the editor populates the offsets.
|
||
- **Phase 9 Round 6 bugfix (guide-driven anchor selection):** when `guide_offset` is present,
|
||
the joint anchor in `_compute_mount_transform()` is now whichever transformed end
|
||
(`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
|
||
`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
|
||
preserves every previously-correct case and naturally reproduces the 180° flip (a flipped
|
||
part's far end lands nearest the joint — e.g. the flipped right upper arm shoulder and the
|
||
flipped torso neck end), plus the head chin (nearest the guide Neck). Old files **without**
|
||
the key keep the previous family rules + flip heuristic exactly as before. `theta`, `s`, the
|
||
Round 5 offset `t`, and the `HEAD_CHIN_DROP` fallback are unchanged — they consume
|
||
`anchor`/`v` generically.
|
||
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_rig.gd` — `class_name StickmanRig`, `extends Node2D`; the **runtime owner of
|
||
facing direction, per-joint bone bend, and `Body/*` z-order** for `master_rig.tscn` (Phase 9
|
||
Task 4). Attached to the `Master` root node of `master_rig.tscn`. **Non-`@tool`** — node
|
||
resolution, flag writes, and z-order reordering run only at runtime (`_ready` + setters on a
|
||
live instance). Enums `FacingProfile { LEFT, RIGHT, FORWARD }` (values are the harness facing-menu
|
||
ids) and `BendDirection { NORMAL, INVERTED }`. Constants (moved from the harness): `SKELETON_PATH`,
|
||
`BODY_CONTAINER_PATH`, `BEND_JOINTS` (`["LeftArm","RightArm","LeftLeg","RightLeg"]`),
|
||
`BEND_JOINT_BONE_PATHS` (each joint → its lower `Bone2D` NodePath relative to `Skeleton2D`),
|
||
`PROFILE_FLAGS` (per-profile `flip_bend_direction` sets), `Z_ORDER_BY_PROFILE` (per-profile
|
||
`Body/*` draw-order tables, back-to-front). Exports: `facing_profile: FacingProfile` (default
|
||
`FORWARD`, a preset whose setter writes the four per-joint vars + reorders `Body/*`) and an
|
||
`@export_group("Bend Direction")` of four `@export_enum("Normal","Inverted")` vars
|
||
`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend` (defaults
|
||
NORMAL/INVERTED/INVERTED/NORMAL = FORWARD). Signals `facing_profile_changed(profile)` /
|
||
`bend_flag_changed(joint, flipped)`. Public API: `set_facing_profile`/`get_facing_profile`,
|
||
`set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, and
|
||
`get_bend_joint_global_position(joint)` (unknown joint → `push_warning` + no-op/`false`/
|
||
`Vector2.ZERO`). `_ready()` resolves `Skeleton2D`/`Body`/4 lower `Bone2D`s/4 TwoBoneIK
|
||
modifications (matched by `joint_two_bone2d_node` NodePath, **never stack index**), enables the
|
||
modification stack (`stack.enabled = true`), applies the current profile once, then sets
|
||
`_nodes_ready`; a `_nodes_ready` guard makes pre-`_ready` setters store-only (robust against
|
||
setter timing during `PackedScene.instantiate()`). Null-guards + `push_warning` prefixed
|
||
`"StickmanRig: "` throughout; never crashes.
|
||
- `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) -> StickmanRig` — instantiates
|
||
`res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, returns the rig root
|
||
(typed as `StickmanRig` since the rig now carries the `StickmanRig` root script).
|
||
- `static func spawn(path: String) -> StickmanRig` — `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()`): true
|
||
bone **segments** (a joint dot at each `Bone2D` origin + a parent→child line to each `Bone2D`
|
||
child, color-coded left cyan / right orange / central white) with leaf bones drawn out to their
|
||
IK targets (`LeftLowerArm→Left_Hand`, `RightLowerArm→Right_Hand`, `LeftLowerLeg→Left_Leg`,
|
||
`RightLowerLeg→Right_Leg`) so wrist/ankle joints are visible (Phase 9 Round 2; previously only
|
||
origin→parent-origin lines were drawn). The **Head** leaf is the exception (Phase 9 Round 3):
|
||
its IK target is a `SkeletonModification2DLookAt` aim point, not a joint, so it is **not** in
|
||
`LEAF_BONE_IK_PATHS` and the no-target fallback draws a ~90 px segment along the bone's own
|
||
direction (`Vector2(length, 0)` rotated by `bone_angle` then `global_rotation`) instead of a
|
||
line to the aim point; colored markers at
|
||
`IK_Targets/{Left_Hand,Right_Hand,Left_Leg,Right_Leg}` when "Show IK Handles" is on. Interactive
|
||
IK: click-drag the `Marker2D` IK targets; the scene's `SkeletonModificationStack2D` TwoBoneIK
|
||
flexes limbs live (the rig self-enables its modification stack in `_ready()`).
|
||
- **Phase 9 Round 7 draggable Torso & Head handles:** `IK_HANDLE_PATHS` now has **6 entries**
|
||
— the 4 limb targets plus `"Head"` (`IK_Targets/Head`, the `SkeletonModification2DLookAt` aim
|
||
point) and `"Torso"` (`IK_Targets/Torso`, whose child `RemoteTransform2D` moves the hip bone).
|
||
Dragging the **Torso** handle translates **bones only** (no target following) — the marker's
|
||
`RemoteTransform2D` moves the hip bone and the whole skeleton + `Body/*` visuals follow
|
||
rigidly, while the limb/head targets stay put (dragging the figure away from them stretches
|
||
the limbs toward the stationary targets, per user decision). Dragging the **Head** handle
|
||
drives the Head bone's LookAt rotation (clamped at the authored ~55° constraint);
|
||
`Body/Head` follows. `_handle_color()` colors the head marker yellow (`HANDLE_COLOR_HEAD`)
|
||
and the torso marker magenta (`HANDLE_COLOR_TORSO`); hands stay green, feet blue. The IK
|
||
overlay also draws a **null-guarded semi-transparent yellow aim line** from the Head bone
|
||
origin to the head marker (`_draw_ik_handles`, width `1.5/zoom`, alpha `0.5`) — a visual aid
|
||
for the LookAt test.
|
||
- **Phase 9 Task 1 skeleton IK bone switches:** adds a "Facing" `MenuButton` (leftmost control
|
||
in the top-bar `HBox`) and per-joint bend-direction toggles for the rig's TwoBoneIK "Flip
|
||
Bend Direction" flags. The facing profile, the per-joint bend flags, and the `Body/*` z-order
|
||
tables now **live in the `StickmanRig` script** (Phase 9 Task 4) — the harness drives the rig
|
||
via `_rig_script: StickmanRig` (typed root from `StickmanFactory.spawn()`; signals connected
|
||
**before** `add_child`) and keeps `_facing_profile` (default `FORWARD`) only as a **UI mirror**
|
||
for the `[√] ` menu prefix + respawn re-application; the rig's exported `facing_profile` is the
|
||
authority. Menu item ids are `StickmanRig.FacingProfile.LEFT/RIGHT/FORWARD` (values used
|
||
directly as menu item ids). State `_context_joint`, `_facing_button`/`_facing_menu`,
|
||
`_context_menu`. The Facing popup (Left/Right/Forward) uses dynamic text-only labels with the
|
||
current profile prefixed `[√] `, refreshed on `about_to_popup` and after selection (mirroring
|
||
the editor's snap-menu pattern); selection calls `_rig_script.set_facing_profile(id)`. Hit-test
|
||
for the right-click toggle iterates `_rig_script.get_bend_joints()` with positions from
|
||
`_rig_script.get_bend_joint_global_position(joint)`. Per-joint toggle: right-click inside the
|
||
viewport on an elbow/knee (the upper↔lower limb connector, within `JOINT_HIT_RADIUS_PX := 14.0`
|
||
screen px converted to world by `_camera.zoom.x`, nearest joint wins) pops a one-item context
|
||
menu labeled **"Normal Bend"** (when the rig's `get_joint_bend_flipped(joint)` is true) or
|
||
**"Invert Bend"** (when false); selecting calls
|
||
`_rig_script.set_joint_bend_flipped(_context_joint, not _rig_script.get_joint_bend_flipped(_context_joint))`.
|
||
Only the 4 elbows/knees are right-click targets — shoulders/hips/wrists/ankles/head/torso are
|
||
not. Lifecycle: `_free_current_rig()` clears `_rig_script` (and `_context_joint`);
|
||
`_load_and_spawn()` captures the remembered profile before `add_child` (so the rig's `_ready()`
|
||
`facing_profile_changed(FORWARD)` doesn't clobber the mirror) then re-applies
|
||
`_rig_script.set_facing_profile(remembered_profile)` after `_resolve_rig_nodes()`. The rig
|
||
enables its own modification stack in `_ready()`. No persistence to disk.
|
||
- **Phase 9 Task 2 body-part z-order:** the rig's `_apply_profile()` reorders the rig's `Body/*`
|
||
visual part nodes (tree order = draw order) from the Facing profile; `Z_ORDER_BY_PROFILE` (now
|
||
owned by `StickmanRig`) maps each `FacingProfile` to the `Body/*` part node names in
|
||
**back-to-front draw order** (Godot 4 `Node2D` draws siblings in tree order; all parts keep
|
||
`z_index = 0`). FORWARD: torso → left/right upper legs → left/right lower legs → left/right
|
||
upper arms → left/right lower arms → head (all limbs in front of the torso); LEFT: left arm
|
||
pair then left leg pair **behind** the torso, right leg pair then right arm pair in front;
|
||
RIGHT: mirrored. In every profile upper limbs stay behind lower limbs; far-side
|
||
(behind-torso) arms draw behind the legs while near-side arms draw in front of the legs; the
|
||
**head is always frontmost**. The rig's `_apply_body_z_order()` walks the profile array
|
||
back-to-front and `move_child(part, count - 1)`s each existing child (missing parts skipped),
|
||
which yields the profile order; unknown extra children stay at the back. Safe with the adapter:
|
||
shapes are children of the part nodes, so moving a part moves its whole shape group. No
|
||
persistence to disk.
|
||
- **Phase 9 Task 3 coordinates display:** adds a "Show Coords" `CheckBox` in the top-bar
|
||
`HBox` (after "Show IK Handles", before the status label), default ON, toggled via
|
||
`_on_show_coords_toggled(pressed)` (sets `_show_coords: bool = true`, flips
|
||
`_coords_panel.visible`). A code-built right-side readout (`_build_coords_panel()`, called
|
||
from `_build_ui()` after the viewport container so it renders in front): `_coords_panel`
|
||
(`PanelContainer`) + monospace selectable `_coords_label` (`RichTextLabel`,
|
||
`selection_enabled` + `context_menu_enabled` + `fit_content`, autowrap off, scroll off,
|
||
`FOCUS_CLICK`; `SystemFont`: Consolas/Menlo/DejaVu Sans Mono/Courier New via the
|
||
`normal_font`/`normal_font_size` (18) theme overrides), anchored `PRESET_TOP_RIGHT`,
|
||
`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);
|
||
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
|
||
paths: Torso, Torso/Head, both upper/lower arms, both upper/lower legs). State
|
||
`_coord_bones: Dictionary` (bone display name → `Bone2D`, keyed by `path.get_file()`),
|
||
`_show_coords`. `_resolve_rig_nodes()` calls `_resolve_coord_bones()` (via
|
||
`_skeleton.get_node_or_null`, `push_warning` on missing); `_free_current_rig()` clears
|
||
`_coord_bones` (toggle persists across respawns). `_process(delta)` early-outs when hidden
|
||
or label null, else `_update_coords_display()` — sections "Skeleton2D" pos + rot deg,
|
||
"Bones" pos + rot deg, "IK Targets" pos only (reusing `IK_HANDLE_PATHS`/`_ik_handles`);
|
||
"No rig loaded" fallback; every read guarded with `is_instance_valid`; the label text is
|
||
only reassigned when the built string changes, so an active text selection survives idle
|
||
frames. Values are
|
||
world-space (`global_position`/`global_rotation`), rotation in degrees via
|
||
`_fmt_deg(rad)` (1 decimal, `°`), `_fmt_vec2(v)` for positions. No persistence to disk.
|
||
- **Phase 9 Task 5 rig animation:** adds top-bar controls immediately after the "Facing" menu
|
||
— an `_anim_dropdown` `OptionButton` populated per spawn from
|
||
`AnimationPlayer.get_animation_list()` (preferring `walk_right` via `DEFAULT_ANIMATION`), a
|
||
`_play_button` whose label swaps "Play"/"Pause"/"Resume" by `_playback_state`, a `_stop_button`
|
||
(Stop), and a `_loop_check` `CheckBox` default ON (harness-level, persists across respawns like
|
||
`_show_coords`). The harness resolves the rig's `AnimationPlayer` directly by node path via the
|
||
`ANIMATION_PLAYER_PATH` const (`_resolve_anim_player()`, called at the end of
|
||
`_resolve_rig_nodes()`) and drives it directly; the `AnimationTree` node remains an untouched
|
||
unconfigured placeholder (out of scope, D1). Loop is implemented by writing
|
||
`Animation.loop_mode` (`LOOP_LINEAR`/`LOOP_NONE`) on the selected animation before each play
|
||
(`_apply_loop_mode()`); playback state is tracked by the enum
|
||
`PlaybackState {STOPPED, PLAYING, PAUSED}` via the button handlers + the `animation_finished`
|
||
signal (guarded by `_loop`) — no polling in `_process`. Changing the dropdown selection stops
|
||
playback; `_free_current_rig()` clears `_anim_player`, the dropdown, `_selected_animation`,
|
||
and state. Playing `walk_right` also sets the rig's `facing_profile` via its animation track →
|
||
export setter → the existing `_on_facing_profile_changed` handling (menu `[√] ` + redraw). No
|
||
persistence to disk. The "Facing" menu and all animation controls are **hidden until an .stk is
|
||
loaded** (`_set_rig_controls_visible(false)` at the end of `_build_ui()` and in
|
||
`_free_current_rig()`; shown on successful spawn in `_load_and_spawn()`).
|
||
- `scripts/terrain_block.gd` — `class_name TerrainBlock`, `extends StaticBody2D`; a **reusable
|
||
vector terrain component** (Vector Terrain System, **not used by the editor**). Builds its three
|
||
children in code: `Polygon2D` (interior fill, `fill_color`), `Line2D` (crisp vector border,
|
||
`outline_color`/`outline_width`, auto-closed loop by appending the first vertex to the end,
|
||
`LINE_JOINT_ROUND` + round caps), and `CollisionPolygon2D` (`BUILD_SOLIDS` solid decomposition —
|
||
supports concave blocks). Exported properties: `polygon_points: PackedVector2Array`,
|
||
`fill_color`, `outline_color`, `outline_width`; a unified setter pushes vertex changes to all
|
||
three children live (no manual rebuilds).
|
||
- `scripts/terrain_utils.gd` — `class_name TerrainUtils`, `extends RefCounted`; static utility
|
||
(**not used by the editor**):
|
||
- `sanitize_points(points: PackedVector2Array, grid_size: float = 16.0) -> PackedVector2Array` —
|
||
sanitization pipeline in order: grid snap → redundancy removal via a **local
|
||
`_simplify_polyline()`** (Godot 4.7 has **no `Geometry2D.simplify_polyline()`**; the local
|
||
version drops consecutive duplicates, a closing duplicate when `last == first`, and collinear
|
||
vertices) → clockwise enforcement via `Geometry2D.is_polygon_clockwise()` (reverses if false,
|
||
guaranteeing clockwise output).
|
||
- `spawn_block(...)` — factory that sanitizes raw input vectors (`sanitize_points`), creates a
|
||
`TerrainBlock`, applies the cleaned points, and adds it to the target container.
|
||
- `scripts/prop_block.gd` — `class_name PropBlock`, `extends RigidBody2D`; a **reusable
|
||
dynamic vector prop component** (Dynamic Vector Props, **not used by the editor**), `@tool`.
|
||
Builds its children in code: `Polygon2D` (interior fill, `fill_color`), `Line2D` (crisp
|
||
vector outline, `outline_color`/`outline_width`, auto-closed loop by appending the first
|
||
vertex, `LINE_JOINT_ROUND` + `LINE_CAP_ROUND`), and a collision node — `CollisionPolygon2D`
|
||
(`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
|
||
`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;
|
||
Cardboard: mass 0.4, friction 0.3, bounce 0.05; Metal: mass 8.0, friction 0.9, bounce 0.0;
|
||
None: mass 1.0, friction 0.5, bounce 0.05) and recolor fill/outline for non-`NONE`. Unified
|
||
live-update setters push geometry/color changes to all children (null-guarded for `@tool`
|
||
editor safety); `_apply_shape()` enables exactly one collision node.
|
||
- `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
|
||
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
|
||
(`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.
|
||
- `scripts/physics_test_harness.gd` — `class_name PhysicsTestHarness`, `extends Node2D`; standalone
|
||
staging scene root (Vector Terrain System / Dynamic Vector Props, **not wired into the editor**;
|
||
run via **F6** on `res://scenes/physics_test_harness.tscn`). Builds flat ground, angled ramps, and stepped
|
||
`TerrainBlock` instances via `TerrainUtils`, instantiates `res://master_rig.tscn` standing on the
|
||
flat ground, and handles camera input. **Keys 1/2/3** spawn dynamic props above the angled ramp
|
||
via `PropUtils.spawn_prop()` (`PROP_SPAWN_POSITION = (300, -300)`): **1** Wood Crate
|
||
(`create_box()`, `WOOD`, velocity `(60,0)`), **2** Bouncy Ball (`create_ball()`, `RUBBER`,
|
||
`(-80,0)`), **3** Heavy Plank (`create_plank()`, `METAL`, `(30,-40)`). Adds a **best-effort
|
||
`StaticBody2D` collision proxy** (`RigCollisionProxy`, `_add_rig_collision_proxy()`) since the
|
||
rig has **no physics bodies of its own** — a 240×1000 px `RectangleShape2D` centered at `(0,-500)`
|
||
(`RIG_PROXY_SIZE`/`RIG_PROXY_CENTER`) matching the standing figure's world bounds, so props
|
||
bounce/rest against it; the proxy is a code-only stand-in, not part of the rig.
|
||
- Scenes:
|
||
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
||
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
||
`%CenterColumn`, `%WholeStickmanPreview`, `%SaveDialog`, `%LoadDialog`,
|
||
`%ClearConfirmDialog`, `%ErrorDialog`, `%StatusBar`, `%CursorCoords`, `%SnapStatus`.
|
||
- **Phase 6 status bar:** `StatusBar` is an `HBoxContainer` bottom-anchored at 28 px
|
||
height containing `CursorCoords` (left) and `SnapStatus` (right); `MainLayout`
|
||
`offset_bottom` is `-28.0` to leave room for it.
|
||
- `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
|
||
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" / "Show Coords" 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.
|
||
- `scenes/physics_test_harness.tscn` — **standalone staging scene** (Vector Terrain System /
|
||
Dynamic Vector Props, not wired into the editor; run via **F6**). Backed by
|
||
`scripts/physics_test_harness.gd`. Root `Node2D` + script, `Camera2D` at position `(0, -400)`
|
||
zoom `0.5`, empty Environment container. Wheel-zoom scales between `0.25x` and `3.0x`
|
||
(resolution independence / vector outline thickness); middle-drag pans. Keys **1/2/3** spawn
|
||
dynamic props (`PropUtils`) above the angled ramp; a best-effort `StaticBody2D` rig collision
|
||
proxy provides a surface for props to bounce/rest against.
|
||
|
||
### 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] }`.
|
||
- `shape_type` values: `"line"`, `"rectangle"`, `"circle"`, `""` (empty).
|
||
Descriptive tag in Phase 2 — rendering uses `closed`.
|
||
- `closed`: `true` = filled + closed outline, `false` = open outline-only.
|
||
- `vertex_flags`: same length as `points`; `0` = original vertex (filled circle),
|
||
`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}`.
|
||
- **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
|
||
are write-only metadata recomputed on every save — never read back on load.
|
||
- **Phase 9 Round 5:** per-part data adds `guide_offset` `{x, y}` (bbox center − guide joint,
|
||
preview space; pure master-space delta) for format v1.5. Write-only metadata like
|
||
`pivot`/`length`; the load path ignores it, so v1.0–v1.4 files load unchanged and gain the
|
||
key on their next save.
|
||
- The JSON `.stk` format is defined in `README.md` (versioned `"1.5"`, extensible;
|
||
`"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`.
|
||
- `recent_colors: Array[String]` — the last up-to-8 selected hex colors, most-recent-first.
|
||
Populated on load and flushed on every color selection.
|
||
- `show_pose_guide: bool` — whether the pose silhouette guide is visible in the Whole
|
||
Stickman preview. Default `true`. Loaded in `_load_settings()` and flushed on every toggle
|
||
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.
|
||
- Use standard Godot `Control` nodes where applicable.
|
||
- Use `class_name` for globally referenced scripts (`BodyPartPanel`, `WholeStickmanPreview`).
|
||
- Prefer `%UniqueName` access over exported node paths in `stickman_editor.gd` / `body_part_panel.gd`.
|
||
- Keep the `.stk` JSON format backward compatible — never change the meaning of an existing key.
|