diff --git a/AGENTS.md b/AGENTS.md index 3661ea4..e33ddee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and 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 2 (Sandbox) single-shape support:** `_mount_shapes()` reads a part's `shapes` array + when present (v1.2+), and otherwise — when the part dict itself carries `points` — wraps the + whole dict as a single shape (`shapes = [pd]`), so v1.0/v1.1 single-shape `.stk` files (e.g. + `stickmen/test.stk`) mount as visible geometry instead of being cleared to nothing. - **Phase 9 extension:** also fits the head bone (`Skeleton2D/Torso/Head.position.y = -proportions.torso_length`, x preserved) and mounts the head as **full geometry** like every other part — it clears the `Body/Head` node's inline `@tool` circle script via @@ -245,7 +249,10 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state: int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() -> - bool` / `request_recovery()`. `_physics_process()` → `_track_momentum(delta)` caches the rig + bool` / `request_recovery()` / `snap_to_standing()` (Phase 2 Sandbox: instantly destroys the + ragdoll or cancels the recovery tween, sets the 6 IK targets directly to `STAND_POSE`, re-shows + `Body/*`, re-enables IK, and returns to `ANIMATED` with no stand-up glide). `_physics_process()` + → `_track_momentum(delta)` caches the rig root's linear/angular velocity from per-frame `global_position`/`global_rotation` deltas, then drives `_update_rest_detection()` (auto-recovery trigger). `_enter_ragdoll()` `stop(true)`s the `AnimationPlayer` (`ANIMATION_PLAYER_PATH` const, keep_state — no pose reset), builds the @@ -512,7 +519,76 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and which removes the collision proxy on `RAGDOLL`, re-adds it on `ANIMATED`/`RECOVERING`, and always `_update_ragdoll_toggle()` (proxy helpers are idempotent, so the toggle handler's own add/remove is harmless). The toggle label reads "Stickman" during `RECOVERING` (since - `is_in_ragdoll()` is false). + ` is_in_ragdoll()` is false). +- `scripts/sandbox_stage.gd` — `class_name SandboxStage`, `extends Node2D`; the **Sandbox Stage + Builder** root controller (Phase 2, **not wired into the editor**; run via **F6** on + `res://scenes/sandbox_stage.tscn`). Owns the EDIT/PLAY mode state machine, placement mode, + camera pan/zoom, deletion, status bar, and signal fan-out; instantiates `StageSpawner` / + `StageSelection` / `StageGizmos` (via `preload` consts). `enum StageMode { EDIT, PLAY }` + (default EDIT). EDIT freezes `RigidBody2D` props with `freeze = true` + + `freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC` (script-driven gizmo dragging needs + KINEMATIC, not STATIC) and keeps `StickmanRig`s ANIMATED (`set_ragdoll(false)` runs first); + PLAY unfreezes props and ragdolls stickmen with `auto_recover = false`. Signals + `mode_changed(mode)`, `object_placed(node)`, `object_selected(nodes)`, `object_deselected()`, + `object_deleted(nodes)`. Camera: middle-mouse pan, wheel zoom clamped to `@export min_zoom` / + `max_zoom` (0.1 / 6.0); mouse→world via `_camera.get_global_mouse_position()` (no SubViewport). + `_world_children_selectable()` returns direct `Node2D` children of `World` excluding + `RagdollBodyContainer`. **Authored-state / restart-the-sim:** `_authored` (instance id → + `{node, position, rotation}`) is updated at **edit time** on every place (`_place_at` → + `_save_object_state`) and move/rotate (`transform_committed` → `_on_transform_committed`), + and cleared on delete (`_clear_object_state`). `_enter_edit_mode()` stands stickmen, then + freezes every prop (`freeze_mode = FREEZE_MODE_KINEMATIC` **before** `freeze = true`, so the + body freezes directly as kinematic — never via the static layer, whose transform sync drops a + subsequent position set), then `_restore_authored_state()` teleports via + `global_position`/`global_rotation` + zeroed velocity. Because the freeze+teleport can take a + physics frame or two to settle, `_restore_authored_state()` is also re-asserted over the next + few `_physics_process` frames (`_restore_frames_left`) — so every Play session starts from and + returns to the same authored state. **Placement ghost:** while a palette + item is active, `set_placement_mode(id)` spawns a translucent (`modulate.a = 0.5`), + non-colliding (`collision_layer/mask = 0`, frozen) copy of the object reparented out of + `World` into `_ghost_holder`; `_process()` tracks it to the cursor (+ snap) via + `_update_ghost_position()`, and `_place_at()` re-spawns it after each placement. A ghost + **stickman** has its `Skeleton2D` modification stack disabled and its `AnimationPlayer` + stopped so it renders as a static standing figure (its limbs don't flex/follow the cursor). + **Grid/snap:** an optional `StageGrid` overlay (drawn behind `World`) plus `Grid`/`Snap` + `CheckBox` toggles and a `Size` `SpinBox`, persisted to `user://sandbox_settings.json` + (`grid_size`/`snap_to_grid`/`show_grid`); snapping rounds the placement cursor and translate + drags via `_snap_to_grid()` / `StageGizmos.snap_size`. UI built in code (CanvasLayer + + PanelContainer top bar): mode toggle, six palette buttons (built from the spawner registry), + Grid/Snap/Size controls, status label. +- `scripts/stage_spawner.gd` — `class_name StageSpawner`, `extends RefCounted`; registry-driven + spawner (Phase 2). A `_registry: Array[Dictionary]` maps ids to terrain/prop/stickman templates; + adding a type = appending an entry (no hard-coded id `match`). Reuses `TerrainUtils.spawn_block`, + `PropUtils.spawn_prop`, `StickmanFactory.spawn_from_data` via `preload` consts. Terrain entries + store origin-relative point templates (ground/ramp/step); `_spawn_terrain` centers the template + bbox on its local origin then sets `block.position` to the cursor, so `global_rotation` = + "rotate about center". The Stickman entry caches `res://stickmen/test.stk` once (`load_stk` in + `_init`) and applies `STICKMAN_FOOT_OFFSET (0, -385)` so feet land at the cursor. Also exposes + a `static get_world_aabb(node)` helper (for a stickman it unions the mounted `Body/*` shape + geometry via a recursive `_collect_visual_points()` so the box is centered head-to-feet). +- `scripts/stage_selection.gd` — `class_name StageSelection`, `extends RefCounted`; hover/click/ + box selection via geometric world-space AABB hit-testing (Phase 2). `static get_world_aabb` + unions a `Polygon2D` child's world points (terrain/props) or, for a stickman rig, recursively + unions its mounted `Body/*` `Line2D`/`Polygon2D` geometry (`_collect_visual_points()`), so the + bounding box is centered on the actual figure head-to-feet (not a fixed rect). + `_frontmost_at` = highest `World` child index wins, + smallest area breaks ties. `_is_selectable` excludes the `RagdollBodyContainer` subtree. Signals + `hover_changed(node)` / `selection_changed(nodes)`; public API `get_selected`/`get_primary`/ + `clear_selection`/`select_only`/`add_to_selection`/`toggle_selection`/`is_selected`/`hit_test`/ + `update_hover`/`box_select`. +- `scripts/stage_gizmos.gd` — `class_name StageGizmos`, `extends Node2D`; hover highlight + + selection outline + a rotate ring handle (Phase 2). **No translate handle** — objects are + dragged directly by the root (`begin_translate_drag(node, world_pos)` sets the target + starts + a TRANSLATE drag; `drag_to` drives `global_position` with optional grid snap via `snap_size`). + Pure `_draw()` + distance-based hit-testing (no `Area2D`); `enum Handle { NONE, TRANSLATE, + ROTATE }`. Rotation drives `global_rotation` about the AABB center. `transform_committed(node)` + emitted on drag end. `set_enabled(false)` hides + disables in PLAY. Also draws the box-select + marquee via `set_box_rect(rect)`. +- `scripts/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space + grid overlay (Phase 2). Pure `_draw()`: grid lines pan/zoom with the camera (`camera.get_screen_center_position()` + + viewport extent / zoom), with a heavier **major line every 5 cells**; `grid_size` / `enabled` + are set by `SandboxStage`. Added as the first child of the stage root so it renders behind + `World`; no hit-testing. - Scenes: - `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`, @@ -539,6 +615,12 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and angled ramp; a toggle button flips the rig **Stickman** ↔ **Ragdoll** (removing/restoring the best-effort `StaticBody2D` rig collision proxy); a **Knock Up** button impulses the ragdoll and all props upward. + - `scenes/sandbox_stage.tscn` — **standalone staging scene** (Sandbox Stage Builder, Phase 2, + not wired into the editor; run via **F6**). Backed by `scripts/sandbox_stage.gd`. Minimal + root `Node2D` + script, `Camera2D` at position `(0, -400)` zoom `(0.5, 0.5)`, and an empty + `World` (Node2D) container; the `GridLayer` (`StageGrid`), `GizmoLayer` (`StageGizmos`), + `PlacementGhost` holder, and the CanvasLayer top-bar UI (mode toggle + six palette buttons + + Grid/Snap/Size controls + status label) are built in code at `_ready()`. ### Body-part data model - 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`, diff --git a/README.md b/README.md index 1f7a107..af11476 100644 --- a/README.md +++ b/README.md @@ -391,6 +391,59 @@ Ragdoll bodies spawn fully visible — the entry handoff is instant (the ragdoll **Interruptibility:** `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll` calls are idempotent. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is **not** modified (the `stand_up` / `walk_left` / `walk_right` animations are baked into the scene's `AnimationLibrary` by the `create_animations.gd` editor script, which runs manually in the editor; the baked `stand_up` is an authored reference and the recovery path does not play it). +### 18. Sandbox Stage Builder + +The **Sandbox Stage Builder** (Phase 2) is a standalone, kid-friendly director sandbox: a visual stage where you place terrain, props, and stickmen from a palette, then flip between **Edit Mode** (build) and **Play Mode** (physics simulation). It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`. + +The stage is intentionally **extendable**: the spawn palette is registry-driven (adding an object type = appending one dictionary entry), selection hit-tests arbitrary `Node2D`s geometrically, gizmos drive `global_position`/`global_rotation`, and core events are exposed as signals for future phases (Action Queue, Triggers, Save/Load). + +| File | Purpose | +|---|---| +| `res://scenes/sandbox_stage.tscn` | The stage scene: root `Node2D` + `Camera2D` + empty `World` container. | +| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (mode state machine, placement, camera, deletion, status bar, signals). | +| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`. | +| `res://scripts/stage_selection.gd` | `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric AABB hit-testing. | +| `res://scripts/stage_gizmos.gd` | `class_name StageGizmos`, `extends Node2D` — hover highlight, selection outline, rotate ring handle. | +| `res://scripts/stage_grid.gd` | `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells). | + +**Mode management:** + +- **Edit** (default) — `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active. +- **Play** — props unfreeze and fall, stickmen ragdoll (`set_ragdoll(true)` with `auto_recover = false`), gizmos are hidden, and selection is cleared. +- The mode toggle button (leftmost) flips between the two; a `mode_changed(mode: int)` signal is emitted on every toggle, and the camera view persists across the switch. +- **Restart-the-sim:** each object's position/rotation is saved whenever you place, move, or rotate it; returning to Edit restores that authored state (and zeroes prop velocity), so every Play session starts from the same authored layout. Stickmen snap straight back to standing (no stand-up glide). + +**Spawn palette** (six text buttons, built from the spawner registry): + +- **Ground / Ramp / Step** — `TerrainBlock` terrain, placed by centering the template on its local origin so rotation pivots on the block's center. +- **Crate** (`create_box()` + `WOOD`) / **Ball** (`create_ball()` + `RUBBER`) — `PropBlock` dynamic props. +- **Stickman** — a `StickmanRig` spawned from a cached `res://stickmen/test.stk` via `StickmanFactory.spawn_from_data()`, offset `(0, -385)` so the feet land on the cursor. + +Clicking a palette button enters **placement mode**, which shows a translucent **ghost** of the object under the cursor (snapped to the grid when Snap is on). The stickman ghost is a static standing figure. The next left-click spawns the object there. Placement repeats until you press **Escape** or click a different button. Each placement emits `object_placed(node)`. + +**Selection & gizmos (Edit only):** + +- **Hover** highlights the object under the cursor (subtle outline). +- **Left-click** selects an object (deselects the previous); click empty space to deselect. +- **Direct drag** — click and drag an object to move it (no separate move handle); hold **Shift** while clicking to toggle its selection. +- **Box-select** — drag on empty space draws a marquee; release selects everything inside. Hold **Shift** to add to the selection. +- The **primary** selection shows a white bounding box and a blue **rotate** ring (around the box); drag the ring to rotate about the center. The ring is hit-tested first, so ring clicks rotate rather than move. +- `object_selected(nodes)` / `object_deselected()` are emitted on selection changes. + +**Grid & snap:** + +- A **Grid** checkbox toggles a world-space grid overlay (major line every 5 cells); a **Snap** checkbox rounds placement and dragging to the grid; a **Size** spinbox sets the cell size (1–100 px). +- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`. + +**Deletion & camera:** + +- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`. +- **Middle-mouse drag** pans; **mouse wheel** zooms within the exported `min_zoom` (0.1) / `max_zoom` (6.0) bounds. The view persists across mode toggles. + +**Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: ` and updates live on spawn, selection, deletion, and mode changes. + +> **Extendability contract:** the spawner uses a `Dictionary` registry (no hard-coded `match` on ids), the `World` container accepts any `Node2D`, gizmos work on any object via `global_position`/`global_rotation`, and the root exposes `mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted` signals — all hooks for the future Action Queue, Trigger, and Save/Load phases. Ramps/stairs can be placed and props/ragdolls will slide on them, but stickmen do **not** autonomously walk up/down them yet (planned for a later phase with `NavigationAgent2D` + IK). + ## 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. @@ -527,7 +580,7 @@ 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/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.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. | -| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). **Not used by the editor** — consumed by the runtime pipeline. | +| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. | | `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** | | `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, `Body/*` z-order, and (Phase 10/11) the **kinematic-to-ragdoll** state switch with instant handoff + stand-up recovery, attached to the `master_rig.tscn` root `Master`. Exports a `facing_profile` preset (`FacingProfile` LEFT/RIGHT/FORWARD, default FORWARD) and four `@export_enum("Normal","Inverted")` per-joint bend vars (`left_arm_bend`/`right_arm_bend`/`left_leg_bend`/`right_leg_bend`), plus (Phase 11) `rest_timeout` (2.0 s) and `auto_recover` (true) exports. Non-`@tool`: resolves `Skeleton2D`/`Body`/bend joints at runtime, enables its own modification stack, and applies the profile (flag writes + `Body/*` reorder) in `_ready()` and setters. Signals `facing_profile_changed` / `bend_flag_changed` / `state_changed`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`, plus the ragdoll API `set_ragdoll(enabled)`/`toggle_ragdoll()`/`is_in_ragdoll()`/`request_recovery()` with `state` / `enum RigState { ANIMATED, RAGDOLL, RECOVERING }`. Null-guarded (`push_warning` + skip). **Not used by the editor.** | | `res://scripts/create_animations.gd` | **Phase 11.** `@tool extends EditorScript`; a **standalone editor utility** (run manually with `master_rig.tscn` open; not auto-loaded or referenced at runtime) that supersedes the deleted `scripts/create_walk.gd`. `_run()` bakes `walk_left`/`walk_right` (same keyframes as the old script) and a one-shot `stand_up` (`POSE_DOWN` → `POSE_STANDING`, `STAND_UP_DURATION` 0.8, `loop_mode = LOOP_NONE`) into the open scene's default `AnimationLibrary`. The baked `stand_up` is an **authored reference only** — runtime recovery does not play it (`StickmanRig` tweens the IK targets directly from the captured ragdoll pose, since a fixed first keyframe can never match an arbitrary rest pose). | @@ -539,6 +592,12 @@ Behavior: | `res://scenes/physics_test_harness.tscn` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene backing `scripts/physics_test_harness.gd` (run via **F6**; not wired into the editor). | | `res://scripts/prop_block.gd` | **Dynamic Vector Props.** `class_name PropBlock`, `extends RigidBody2D` — a reusable physical prop building `Polygon2D` (fill) + `Line2D` (outline) + `CollisionPolygon2D`/`CollisionShape2D` (polygon/circle collision) children in code, with material presets (mass + friction/bounce) and live-updating exports. | | `res://scripts/prop_utils.gd` | **Dynamic Vector Props.** `class_name PropUtils`, `extends RefCounted` — static `create_box()` / `create_ball()` / `create_plank()` / `create_triangle()` primitive generators and a `spawn_prop()` factory (sanitizes polygon points via `TerrainUtils`). | +| `res://scripts/sandbox_stage.gd` | **Sandbox Stage Builder.** `class_name SandboxStage`, `extends Node2D` — root controller: EDIT/PLAY mode state machine (freezes props with `FREEZE_MODE_KINEMATIC`, ragdolls stickmen in PLAY), placement mode, camera pan/zoom, deletion, status bar, and signal fan-out (`mode_changed` / `object_placed` / `object_selected` / `object_deselected` / `object_deleted`). Standalone staging scene run via **F6**; not wired into the editor. | +| `res://scripts/stage_spawner.gd` | **Sandbox Stage Builder.** `class_name StageSpawner`, `extends RefCounted` — registry-driven factory (`Array[Dictionary]`, no id `match`); reuses `TerrainUtils` / `PropUtils` / `StickmanFactory`; centers terrain on its origin and caches `stickmen/test.stk` for the Stickman palette entry. | +| `res://scripts/stage_selection.gd` | **Sandbox Stage Builder.** `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric world-space AABB hit-testing (frontmost `World` child wins; `RagdollBodyContainer` subtree excluded); `hover_changed` / `selection_changed` signals. | +| `res://scripts/stage_gizmos.gd` | **Sandbox Stage Builder.** `class_name StageGizmos`, `extends Node2D` — hover highlight + selection outline + rotate ring via `_draw()` and distance-based hit-testing; objects are dragged directly (no move handle); drives `global_position` / `global_rotation`; emits `transform_committed`. | +| `res://scripts/stage_grid.gd` | **Sandbox Stage Builder.** `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells) that pans/zooms with the camera; `grid_size` / `enabled` set by `SandboxStage`. | +| `res://scenes/sandbox_stage.tscn` | **Sandbox Stage Builder.** Standalone staging scene backing `scripts/sandbox_stage.gd` (run via **F6**; not wired into the editor): root `Node2D` + `Camera2D` + empty `World`; the gizmo layer and CanvasLayer top bar are built in code. | | `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, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. | | `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. | diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index 1c12843..5303844 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -20,6 +20,8 @@ This document tracks known technical debt, optimization opportunities, and minor | 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no in‑world UI in the physics harness yet. | Low | ✅ Resolved | Phase 11: added a Rest `SpinBox` (0.1–10 s, step 0.1) to the harness UI that writes `_rig.rest_timeout` (runtime‑only), plus a "Recover Now" button → `_rig.request_recovery()`. (2026‑08‑27) | | 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | ✅ Resolved | Phase 11: `create_walk.gd` deleted; new `scripts/create_animations.gd` (`@tool extends EditorScript`) bakes `walk_left`/`walk_right` (same keyframes) and a one‑shot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (2026‑08‑27) | | 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still re‑adds the proxy as soon as `RECOVERING` begins (`state_changed` handler), while `Body/*` is already visible — the static box can pop in around the standing figure before the stand‑up completes. Consider delaying re‑addition until after recovery finishes (`ANIMATED`). (2026‑08‑27) | +| 11 | **Stage Freeze Abstraction** — Sandbox Stage EDIT‑mode freezing is type‑specific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`‑based sensors) will need another case in `scripts/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a duck‑typed `set_simulating(bool)` interface once more physical object kinds appear. (2026‑08‑27) | +| 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (2026‑08‑27) | --- @@ -50,6 +52,7 @@ This document tracks known technical debt, optimization opportunities, and minor | ---------- | -------------------------------------------------------------- | | 2026-08-26 | Initial creation — migrated observations from Phase 10 review. | | 2026-08-27 | Phase 11 resolved #6 (recovery starting pose), #7 (transition visual pop), #8 (rest timeout UI), #9 (animation generation DRY); #10 (proxy re‑addition) remains open with updated scope. Later revision: stand‑up recovery switched from bridge‑into‑baked‑animation to a direct marker tween (captured pose → `STAND_POSE`), fixing a visible jump; baked `stand_up` kept as authored reference only. Second revision: ragdoll entry builds from the current solved bone positions (IK disabled only after the blend completes) and the recovery snap derives joint ends from capsule half‑heights with Torso `bone_angle` compensation, fixing the entry pose‑pop and the "recovery starts lying" bugs. Third revision: the entire crossfade/blend (`transition_duration`, `BlendDirection`, opacity fade, softness ramp) was **removed on director feedback** — entry is now an instant handoff (build at current pose → hide `Body/*` → disable IK in one call), since the ragdoll spawns at the identical pose and a fade only read as ghosting. | +| 2026-08-27 | Sandbox Stage Builder (Phase 2) added `scripts/sandbox_stage.gd` + `stage_spawner.gd` / `stage_selection.gd` / `stage_gizmos.gd` + `scenes/sandbox_stage.tscn`. Logged #11 (no unified freeze abstraction over the mixed `StaticBody2D` / `RigidBody2D` / `Node2D` population) and #12 (selection hit‑testing uses world‑space AABBs rather than point‑in‑polygon). | --- diff --git a/plans/SANDBOX_STAGE_BUILDER.md b/plans/SANDBOX_STAGE_BUILDER.md new file mode 100644 index 0000000..3790821 --- /dev/null +++ b/plans/SANDBOX_STAGE_BUILDER.md @@ -0,0 +1,302 @@ +# Sandbox Stage Builder +## 1. Overview & Objective + +**This Phase (Phase 2)** builds the foundation for the kid‑friendly director sandbox: a **visual stage** where users can place terrain, props, and stickmen using a simple palette, then switch between **Edit Mode** (building) and **Play Mode** (physics simulation). This phase focuses on the **"Stage Builder"** experience, setting the stage for later phases that add character actions and story logic. + +The stage is designed to be **extendable** so that future phases (Action Queue, Triggers, Save/Load) can plug in without major rewrites. + +--- + +## 2. Scope + +### 2.1. What’s Included +- **Mode Management:** Toggle between `EDIT` (placement/transform) and `PLAY` (physics simulation). +- **Object Spawning:** Palette buttons for placing terrain blocks (Ground, Ramp, Step), dynamic props (Crate, Ball), and Stickman rigs. +- **Selection & Hover:** Click to select, hover to highlight, and **box selection** for mass operations. +- **Transform Gizmos:** Translate and rotate handles for selected objects (visible only in Edit Mode). +- **Deletion:** Delete selected objects via the Delete/Backspace key. +- **Camera:** Pan (middle‑mouse) and zoom (mouse wheel) with configurable zoom limits. +- **Status Bar:** Shows current mode, object count, and selection info. +- **Signals:** Core events (`mode_changed`, `object_placed`, `object_selected`, `object_deleted`) for future extension. + +### 2.2. What’s NOT Included (Future Phases) +- **Stickman navigation / pathfinding** (Phase 3 — Action Queue) +- **Speech bubbles / dialogue** (Phase 3) +- **Area triggers / sensors** (Phase 4) +- **Scene save/load** (Phase 5) +- **Graphical UI polish** (big toggle buttons, icon‑based palette — Phase 5) +- **IK foot placement / slope alignment** (post‑Phase 3) + +**Important:** Ramps and stairs can be **placed** and physics (props/ragdolls) will slide on them, but stickmen will **not** autonomously walk up/down them until Phase 3. + +--- + +## 3. Architecture + +### 3.1. Scene Hierarchy +res://scenes/sandbox_stage.tscn +│ +├── SandboxStage (Node2D) ← Root script (class_name SandboxStage) +│ │ +│ ├── World (Node2D) ← Parent container for ALL spawned objects +│ │ (TerrainBlocks, PropBlocks, StickmanRigs) +│ │ +│ ├── GizmoLayer (Node2D) ← Draws selection outlines + transform handles +│ │ (visible only in EDIT mode) +│ │ +│ ├── Camera2D ← Pan/zoom camera +│ │ +│ ├── UI (CanvasLayer) ← Overlay UI +│ │ ├── TopBar (HBoxContainer) +│ │ │ ├── ModeToggle (Button) ← "Edit" ↔ "Play" (basic text) +│ │ │ ├── SpawnPalette (HBoxContainer) +│ │ │ │ ├── "Ground" (Button) +│ │ │ │ ├── "Ramp" (Button) +│ │ │ │ ├── "Step" (Button) +│ │ │ │ ├── "Crate" (Button) +│ │ │ │ ├── "Ball" (Button) +│ │ │ │ └── "Stickman" (Button) +│ │ │ └── StatusBar (Label) ← "Mode: EDIT | Objects: 12 | Selected: Crate" +│ │ └── (Future: Action Timeline, etc.) +│ │ +│ └── Spawner (Node) ← Container for spawner logic (StageSpawner) + +text + +### 3.2. Scripts (Class Names) + +| Script | Purpose | +|--------|---------| +| `sandbox_stage.gd` (`class_name SandboxStage`) | Root controller. Manages state, selection, mode, signals. | +| `stage_spawner.gd` (`class_name StageSpawner`, `extends RefCounted`) | Registry of spawnable types + factory methods. Reuses `TerrainUtils`, `PropUtils`, `StickmanFactory`. | +| `stage_selection.gd` (`class_name StageSelection`, `extends RefCounted`) | Handles hover, click selection, box selection, and selection signals. | +| `stage_gizmos.gd` (`class_name StageGizmos`, `extends Node2D`) | Draws and handles translate/rotate gizmos. | + +### 3.3. State Management + +- `enum StageMode { EDIT, PLAY }` +- `var current_mode: StageMode` (managed by root script) +- `var selected_objects: Array[Node2D]` (primary selection is index 0 for gizmos) +- **No global singleton** — all state is local to the `SandboxStage` instance, making it self‑contained and testable. + +### 3.4. Signals + +| Signal | Payload | Emitted When | +|--------|---------|--------------| +| `mode_changed(mode: int)` | `StageMode` enum | Mode toggle. | +| `object_placed(node: Node2D)` | Reference to placed object | After successful spawn. | +| `object_selected(nodes: Array[Node2D])` | Array of selected nodes | Selection changes. | +| `object_deselected()` | (none) | Selection cleared. | +| `object_deleted(nodes: Array[Node2D])` | Array of deleted nodes | After deletion. | + +--- + +## 4. Key Features & Design Notes + +### 4.1. Mode Management +- Toggle between **EDIT** (build) and **PLAY** (simulate). +- In `EDIT`: physics is frozen (`RigidBody2D.freeze = true`), gizmos visible, selection active. +- In `PLAY`: physics unfrozen, gizmos hidden, selection disabled. + +### 4.2. Object Spawning +- Uses a **registry dictionary** in `StageSpawner` — adding a new object type is as simple as appending an entry (no hard‑coded `match` statements). +- **Placement mode:** Click a palette button → next click on the `World` spawns the object. +- Repeated placement stays active until user presses **Escape** or clicks a different palette button. +- Spawn position uses the mouse world position (projected from the camera). + +### 4.3. Selection (EDIT Mode Only) +- **Hover:** Subtle highlight/outline on the object under the mouse. +- **Single click:** Selects an object (deselects previous). +- **Box selection:** Click + drag on empty space draws a rectangle. Release selects all objects inside. + - Hold **Shift** to add to current selection instead of replacing. +- **Primary selection:** The first selected object (or the one clicked last) receives the transform gizmos. + +### 4.4. Transform Gizmos (Primary Selection Only) +- **Translate:** Cross/directional handle — drag to move the object in world space. +- **Rotate:** Circular handle — drag to rotate the object around its center. +- Gizmos are implemented as `Area2D` nodes so they intercept mouse events (prevents accidental deselection or spawning). +- Gizmos are completely hidden in `PLAY` mode. + +### 4.5. Deletion +- Press **Delete** or **Backspace** key to remove all currently selected objects. +- Deletion emits `object_deleted` with the list of removed nodes. +- Objects are `queue_free()`d — no orphaned nodes. + +### 4.6. Camera +- **Pan:** Middle‑mouse drag. +- **Zoom:** Mouse wheel. +- Zoom min/max are `@export var` constants (`min_zoom = 0.1`, `max_zoom = 6.0`) in the root script, so they can be easily adjusted later. +- Camera persists across mode toggles. + +### 4.7. Status Bar +- Shows: current mode, total object count, and selected object name/count. +- Updates in real time on selection, spawning, deletion, and mode change. + +### 4.8. UI Polish (Future Phase 5) +- The Phase 2 UI uses **functional text buttons**. +- In Phase 5, these will be replaced with: + - A large, graphical **Mode Toggle** (slider/switch style). + - An icon‑based **Spawn Palette** with draggable cards (drag‑and‑drop onto the stage). + +--- + +## 5. Acceptance Criteria + +### A. Mode Management + +| # | Criterion | How to Test | +|---|-----------|-------------| +| A1 | A **Mode Toggle Button** switches between `EDIT` and `PLAY`. | Click; label changes. | +| A2 | In `EDIT` mode, **all RigidBody2D props are frozen**. | Place a PropBlock → PLAY → falls. Switch to EDIT → freezes. | +| A3 | In `PLAY` mode, **gizmos are hidden** and **selection is disabled**. | Select object in EDIT → PLAY → no selection box; clicks do nothing. | +| A4 | `mode_changed` signal is emitted on every toggle. | Connect a test print. | + +--- + +### B. Object Spawning + +| # | Criterion | How to Test | +|---|-----------|-------------| +| B1 | Spawn Palette contains: **Ground**, **Ramp**, **Step**, **Crate**, **Ball**, **Stickman**. | UI shows all six. | +| B2 | Clicking a palette button **enters placement mode**. | Click "Crate" → click world → crate appears. | +| B3 | **Repeated placement** stays active until Esc or different button. | Click "Crate" → click twice → two crates. | +| B4 | Uses existing factories: `TerrainUtils`, `PropUtils`, `StickmanFactory`. | Inspect spawned object properties. | +| B5 | `object_placed` signal is emitted. | Connect a test print. | + +--- + +### C. Selection (Hover, Click, Box‑Select) — EDIT Only + +| # | Criterion | How to Test | +|---|-----------|-------------| +| C1 | **Hover** highlights the object under the mouse. | Hover over a crate → it glows. | +| C2 | **Single Click** selects an object (deselects previous). | Click a crate → selection box appears. | +| C3 | **Deselect** by clicking empty space. | Click empty space → selection disappears. | +| C4 | Only **one primary selection** for gizmos. | Select crate → click ball → only ball has gizmos. | +| C5 | **Box Selection:** Click + drag draws a rectangle; release selects all objects inside. | Drag box around three crates → all selected. | +| C6 | **Shift+Box** adds to selection (does not replace). | Select A (click) → Shift+Box-select B & C → A, B, C selected. | +| C7 | `object_selected` and `object_deselected` signals are emitted. | Connect and verify. | +| C8 | **StatusBar** updates with selection info. | Box-select three crates → status shows "Selected: 3 objects". | + +--- + +### D. Transform Gizmos (EDIT Only — Primary Selection) + +| # | Criterion | How to Test | +|---|-----------|-------------| +| D1 | **Translate handle** appears around the selected object. | Select a crate → see move handle. | +| D2 | Dragging translate handle moves the object smoothly. | Drag → crate follows mouse. | +| D3 | **Rotate handle** appears around the selected object. | Select a crate → see rotation ring. | +| D4 | Dragging rotate handle rotates the object around its center. | Drag ring → crate spins. | +| D5 | Gizmos **do not appear** in PLAY mode. | Switch to PLAY → gizmos vanish. | +| D6 | Gizmos are **hit‑testable** (clicking them does NOT deselect or spawn). | Click move handle → object stays selected. | + +--- + +### E. Deletion (EDIT Only) + +| # | Criterion | How to Test | +|---|-----------|-------------| +| E1 | Pressing **Delete** or **Backspace** removes selected object(s). | Select a crate → press Delete → gone. | +| E2 | **Multiple deletion** works with box selection. | Box-select three crates → Delete → all gone. | +| E3 | `object_deleted` signal is emitted (array of nodes). | Connect and verify. | +| E4 | No orphaned nodes remain (`queue_free()` called). | Check `World` child count. | + +--- + +### F. Camera & Viewport + +| # | Criterion | How to Test | +|---|-----------|-------------| +| F1 | **Pan:** Middle‑mouse drag pans the view. | Drag → camera moves. | +| F2 | **Zoom:** Mouse wheel zooms in/out within `min_zoom`/`max_zoom` (exported). | Scroll within bounds. Edit constants → bounds update. | +| F3 | Camera does **not** reset on mode toggle. | Zoom/pan → toggle modes → view persists. | + +--- + +### G. Status Bar + +| # | Criterion | How to Test | +|---|-----------|-------------| +| G1 | StatusBar displays **Mode**, **Object Count**, and **Selection Info**. | Place 5 objects → "Objects: 5". Select one → "Selected: Crate". | +| G2 | StatusBar updates in real time. | Click, spawn, delete → label updates instantly. | + +--- + +### H. Performance & Stability + +| # | Criterion | How to Test | +|---|-----------|-------------| +| H1 | Spawning 50+ objects does not drop frames. | Click "Crate" 50 times → observe FPS. | +| H2 | Rapid toggling between EDIT and PLAY does not crash. | Toggle quickly → no errors. | +| H3 | No `push_warning` or errors in the console during normal use. | Monitor Output panel. | + +--- + +### I. Extendability (Design) + +| # | Criterion | Evidence | +|---|-----------|----------| +| I1 | Adding a new spawnable type requires **no modification** to `SandboxStage` — only a registry entry. | Code review: `StageSpawner` uses a Dictionary, not `match`. | +| I2 | `World` can hold **any** `Node2D`‑derived object. | Place a custom node → works. | +| I3 | Gizmos use `global_position`/`global_rotation` → work for any object. | Select a TerrainBlock → gizmos appear and move it. | +| I4 | Signals provide hooks for future systems (Save/Load, Action Queue, Triggers). | Code review: signals exist for all major events. | +| I5 | UI palette is built from data, not hard‑coded buttons. | Adding a button = appending to an array. | + +--- + +## 6. Future Considerations + +### 6.1. Ramp / Stair Dynamics +| Feature | Phase 2 | Phase 3+ | +|---------|---------|----------| +| Place ramp/step terrain | ✅ | ✅ | +| Props roll/fall on ramps | ✅ (physics) | ✅ | +| Ragdoll tumbles on ramps | ✅ (physics) | ✅ | +| Stickman **walks up** ramp | ❌ | ✅ (NavigationAgent2D) | +| Stickman **navigates** stairs | ❌ | ✅ (NavigationAgent2D + IK) | + +### 6.2. UI Polish (Phase 5) +- Replace the text‑based Mode Toggle with a **large, kid‑friendly graphical switch/slider**. +- Replace text palette buttons with **icon‑based draggable cards** (drag‑and‑drop onto the stage). +- Add tooltips and animations for feedback. + +### 6.3. Box Selection Enhancements (Post‑Phase 2) +- Option to **invert** selection (select all except those inside box). +- **Lock** selected objects (prevent accidental moves). + +--- + +## 7. Summary of Deliverables + +| File | Purpose | +|------|---------| +| `res://scenes/sandbox_stage.tscn` | The main stage scene. | +| `res://scripts/sandbox_stage.gd` | Root controller (`class_name SandboxStage`). | +| `res://scripts/stage_spawner.gd` | Spawn registry + factory (`class_name StageSpawner`). | +| `res://scripts/stage_selection.gd` | Selection logic (`class_name StageSelection`). | +| `res://scripts/stage_gizmos.gd` | Gizmo rendering + interaction (`class_name StageGizmos`). | + +--- + +## 8. Acceptance Sign‑Off Checklist + +- [ ] Mode toggle switches between EDIT and PLAY. +- [ ] Physics freezes/unfreezes correctly. +- [ ] All six spawnable types can be placed. +- [ ] Repeated placement works. +- [ ] Hover highlights objects. +- [ ] Single‑click selection works. +- [ ] Box selection works (with Shift add‑to‑selection). +- [ ] Translate gizmo works. +- [ ] Rotate gizmo works. +- [ ] Delete key removes selected objects. +- [ ] Camera pans and zooms within configurable limits. +- [ ] StatusBar updates correctly. +- [ ] All signals are emitted. +- [ ] No errors/warnings in console. +- [ ] Performance is acceptable (50+ objects). + +--- + +*End of Phase 2 Plan* \ No newline at end of file diff --git a/scenes/sandbox_stage.tscn b/scenes/sandbox_stage.tscn new file mode 100644 index 0000000..407df0d --- /dev/null +++ b/scenes/sandbox_stage.tscn @@ -0,0 +1,12 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scripts/sandbox_stage.gd" id="1_stage"] + +[node name="SandboxStage" type="Node2D"] +script = ExtResource("1_stage") + +[node name="Camera2D" type="Camera2D" parent="."] +position = Vector2(0, -400) +zoom = Vector2(0.5, 0.5) + +[node name="World" type="Node2D" parent="."] diff --git a/scripts/sandbox_stage.gd b/scripts/sandbox_stage.gd new file mode 100644 index 0000000..307fe71 --- /dev/null +++ b/scripts/sandbox_stage.gd @@ -0,0 +1,644 @@ +class_name SandboxStage +extends Node2D +## SandboxStage - Kid-friendly director sandbox stage (Phase 2). +## +## A self-contained visual stage for placing terrain, props and stickmen from a +## palette, with EDIT (build) and PLAY (simulate) modes. Selection uses geometric +## hit-testing; objects are dragged directly (no move handle) and rotated via a +## ring handle. A placement ghost previews the object under the cursor, and an +## optional snap-to-grid + grid overlay ease placement. NOT wired into the editor +## - run standalone via F6 on res://scenes/sandbox_stage.tscn. + +# --------------------------------------------------------------------------- +# Preloaded helpers +# --------------------------------------------------------------------------- + +const STAGE_SPAWNER := preload("res://scripts/stage_spawner.gd") +const STAGE_SELECTION := preload("res://scripts/stage_selection.gd") +const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd") +const STAGE_GRID := preload("res://scripts/stage_grid.gd") +const STICKMAN_RIG := preload("res://scripts/stickman_rig.gd") + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + +enum StageMode { EDIT, PLAY } + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +signal mode_changed(mode: int) +signal object_placed(node: Node2D) +signal object_selected(nodes: Array[Node2D]) +signal object_deselected() +signal object_deleted(nodes: Array[Node2D]) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +const ZOOM_STEP: float = 1.10 +const MIN_BOX_AREA: float = 16.0 +const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer" + +const SETTINGS_PATH := "user://sandbox_settings.json" +const DEFAULT_GRID_SIZE := 15.0 +const MIN_GRID_SIZE := 1.0 +const MAX_GRID_SIZE := 100.0 + +# --------------------------------------------------------------------------- +# Exported properties +# --------------------------------------------------------------------------- + +@export var min_zoom: float = 0.1 +@export var max_zoom: float = 6.0 + +# --------------------------------------------------------------------------- +# Node references +# --------------------------------------------------------------------------- + +@onready var _camera: Camera2D = $Camera2D +@onready var _world: Node2D = $World + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var current_mode: StageMode = StageMode.EDIT + +var _spawner: StageSpawner +var _selection: StageSelection +var _gizmos: StageGizmos +var _grid: StageGrid + +var _placement_id: String = "" + +var _mode_button: Button +var _palette_buttons: Dictionary = {} +var _status_label: Label +var _grid_check: CheckBox +var _snap_check: CheckBox +var _grid_size_spin: SpinBox + +var _grid_size: float = DEFAULT_GRID_SIZE +var _snap_enabled: bool = false +var _show_grid: bool = true + +var _panning: bool = false +var _pan_last: Vector2 = Vector2.ZERO + +var _box_selecting: bool = false +var _box_start: Vector2 = Vector2.ZERO +var _box_rect: Rect2 = Rect2() + +var _ghost: Node2D = null +var _ghost_holder: Node2D = null + +## Authored object state (instance id -> {node, position, rotation}), updated on +## every place/move/rotate and restored when returning to EDIT. +var _authored: Dictionary = {} + +## Physics frames remaining before re-asserting the authored restore (a safety +## net for engines where a same-frame freeze + teleport does not stick). +var _restore_frames_left: int = 0 + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _ready() -> void: + _camera.make_current() + _load_settings() + + _spawner = STAGE_SPAWNER.new(_world) + _selection = STAGE_SELECTION.new(_world, _camera) + _selection.selection_changed.connect(_on_selection_changed) + _selection.hover_changed.connect(_on_hover_changed) + + _build_grid_layer() + _build_gizmo_layer() + _build_ghost_holder() + _build_ui() + _apply_grid_settings() + _refresh_status() + + +func _process(_delta: float) -> void: + if _ghost != null and is_instance_valid(_ghost) and current_mode == StageMode.EDIT: + _update_ghost_position() + + +func _physics_process(_delta: float) -> void: + if _restore_frames_left > 0: + _restore_frames_left -= 1 + _restore_authored_state() + +# --------------------------------------------------------------------------- +# Input +# --------------------------------------------------------------------------- + +func _input(event: InputEvent) -> void: + if event is InputEventMouseButton: + _handle_mouse_button(event) + elif event is InputEventMouseMotion: + _handle_mouse_motion(event) + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + _handle_world_click(event) + + +func _unhandled_key_input(event: InputEvent) -> void: + if not (event is InputEventKey) or not event.pressed or event.echo: + return + match event.keycode: + KEY_DELETE, KEY_BACKSPACE: + if current_mode == StageMode.EDIT: + delete_selected() + KEY_ESCAPE: + if _placement_id != "": + set_placement_mode("") + elif not _selection.get_selected().is_empty(): + _selection.clear_selection() + + +func _handle_mouse_button(mb: InputEventMouseButton) -> void: + match mb.button_index: + MOUSE_BUTTON_WHEEL_UP: + if mb.pressed: + _set_zoom(_camera.zoom.x * ZOOM_STEP) + MOUSE_BUTTON_WHEEL_DOWN: + if mb.pressed: + _set_zoom(_camera.zoom.x / ZOOM_STEP) + MOUSE_BUTTON_MIDDLE: + _panning = mb.pressed + _pan_last = mb.position + + +func _handle_world_click(mb: InputEventMouseButton) -> void: + if mb.button_index != MOUSE_BUTTON_LEFT: + return + if current_mode != StageMode.EDIT: + return + var world_pos := _camera.get_global_mouse_position() + if mb.pressed: + if _gizmos.hit_test(world_pos) != STAGE_GIZMOS.Handle.NONE: + _gizmos.begin_drag(world_pos) + elif _placement_id != "": + _place_at(world_pos) + else: + _begin_click_select(world_pos, mb.shift_pressed) + else: + if _gizmos.is_dragging(): + _gizmos.end_drag() + elif _box_selecting: + _finish_box_select(mb.shift_pressed) + + +func _handle_mouse_motion(mm: InputEventMouseMotion) -> void: + if _panning: + _camera.position -= mm.relative / _camera.zoom.x + return + if current_mode != StageMode.EDIT: + return + var world_pos := _camera.get_global_mouse_position() + if _gizmos.is_dragging(): + _gizmos.drag_to(world_pos) + elif _box_selecting: + _box_rect = Rect2(_box_start, world_pos - _box_start) + _gizmos.set_box_rect(_box_rect) + elif not _is_mouse_over_ui(): + _selection.update_hover(world_pos) + +# --------------------------------------------------------------------------- +# Mode management +# --------------------------------------------------------------------------- + +func set_mode(mode: StageMode) -> void: + if mode == current_mode: + return + current_mode = mode + if mode == StageMode.EDIT: + _enter_edit_mode() + else: + _enter_play_mode() + mode_changed.emit(int(mode)) + _refresh_status() + + +func _enter_edit_mode() -> void: + # Snap stickmen straight back to their standing pose/position (no stand-up + # tween glide). + for node: Node2D in _world_children_selectable(): + if node is STICKMAN_RIG: + (node as STICKMAN_RIG).snap_to_standing() + # Freeze every prop (kinematic) so nothing keeps falling in EDIT — including + # any object that may not be in the authored map. freeze_mode is set BEFORE + # freeze so the body freezes directly as kinematic, never via the static + # layer (whose transform sync can drop a subsequent position/rotation set). + for node: Node2D in _world_children_selectable(): + if node is RigidBody2D: + var body := node as RigidBody2D + body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC + body.freeze = true + _restore_authored_state() + # The freeze/teleport can take a physics frame or two to settle in the engine, + # so re-assert the authored transform on the next few physics frames. + _restore_frames_left = 3 + _gizmos.set_enabled(true) + + +func _enter_play_mode() -> void: + _gizmos.set_enabled(false) + _selection.clear_selection() + set_placement_mode("") + for node: Node2D in _world_children_selectable(): + if node is RigidBody2D: + (node as RigidBody2D).freeze = false + for node: Node2D in _world_children_selectable(): + if node is STICKMAN_RIG: + var rig := node as STICKMAN_RIG + rig.auto_recover = false + rig.set_ragdoll(true) + + +func _save_object_state(node: Node2D) -> void: + _authored[node.get_instance_id()] = { + "node": node, + "position": node.global_position, + "rotation": node.global_rotation, + } + + +func _clear_object_state(node: Node2D) -> void: + _authored.erase(node.get_instance_id()) + + +func _restore_authored_state() -> void: + for id: int in _authored.keys(): + var entry: Dictionary = _authored[id] + var node := entry.get("node") as Node2D + if node == null or not is_instance_valid(node): + _authored.erase(id) + continue + node.global_position = entry.get("position", node.global_position) + node.global_rotation = entry.get("rotation", node.global_rotation) + if node is RigidBody2D: + var body := node as RigidBody2D + body.linear_velocity = Vector2.ZERO + body.angular_velocity = 0.0 + +# --------------------------------------------------------------------------- +# Placement +# --------------------------------------------------------------------------- + +func set_placement_mode(id: String) -> void: + _placement_id = id + for pid: String in _palette_buttons: + var btn: Button = _palette_buttons[pid] + btn.set_pressed_no_signal(pid == id) + if id == "": + _free_ghost() + else: + _spawn_ghost() + + +func _place_at(world_pos: Vector2) -> void: + if _snap_enabled: + world_pos = _snap_to_grid(world_pos) + var node := _spawner.spawn(_placement_id, world_pos) + if node == null: + return + if current_mode == StageMode.EDIT and node is RigidBody2D: + var body := node as RigidBody2D + body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC + body.freeze = true + _save_object_state(node) + object_placed.emit(node) + _refresh_status() + _spawn_ghost() + + +func _spawn_ghost() -> void: + _free_ghost() + if _placement_id == "": + return + var ghost := _spawner.spawn(_placement_id, Vector2.ZERO) + if ghost == null: + return + # Reparent out of World so the ghost is not selectable/counted. + if ghost.get_parent() == _world: + _world.remove_child(ghost) + _ghost_holder.add_child(ghost) + ghost.modulate = Color(1.0, 1.0, 1.0, 0.5) + if ghost is RigidBody2D: + var body := ghost as RigidBody2D + body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC + body.freeze = true + body.collision_layer = 0 + body.collision_mask = 0 + elif ghost is StaticBody2D: + var sb := ghost as StaticBody2D + sb.collision_layer = 0 + sb.collision_mask = 0 + elif ghost is STICKMAN_RIG: + # Freeze the ghost rig into a static standing figure: disable IK solving + # and animation so its limbs don't flex/follow anything while the ghost + # tracks the cursor. + var rig := ghost as STICKMAN_RIG + var skeleton := rig.get_node_or_null(NodePath("Skeleton2D")) as Skeleton2D + if skeleton != null and skeleton.modification_stack != null: + skeleton.modification_stack.enabled = false + var anim := rig.get_node_or_null(NodePath("AnimationPlayer")) as AnimationPlayer + if anim != null: + anim.stop() + _ghost = ghost + _update_ghost_position() + + +func _free_ghost() -> void: + if _ghost != null and is_instance_valid(_ghost): + _ghost.queue_free() + _ghost = null + + +func _update_ghost_position() -> void: + if _ghost == null or not is_instance_valid(_ghost): + return + var pos := _camera.get_global_mouse_position() + _spawner.get_spawn_offset(_placement_id) + if _snap_enabled: + pos = _snap_to_grid(pos) + _ghost.position = pos + +# --------------------------------------------------------------------------- +# Selection +# --------------------------------------------------------------------------- + +func _begin_click_select(world_pos: Vector2, shift: bool) -> void: + var hit := _selection.hit_test(world_pos) + if hit != null: + if shift: + _selection.toggle_selection(hit) + if _selection.is_selected(hit): + _gizmos.begin_translate_drag(hit, world_pos) + else: + _selection.select_only(hit) + _gizmos.begin_translate_drag(hit, world_pos) + else: + _box_selecting = true + _box_start = world_pos + _box_rect = Rect2(world_pos, Vector2.ZERO) + _gizmos.set_box_rect(_box_rect) + + +func _finish_box_select(shift: bool) -> void: + _box_selecting = false + _gizmos.set_box_rect(Rect2()) + var rect := _box_rect.abs() + if rect.get_area() < MIN_BOX_AREA: + if not shift: + _selection.clear_selection() + else: + _selection.box_select(rect, shift) + +# --------------------------------------------------------------------------- +# Deletion +# --------------------------------------------------------------------------- + +func delete_selected() -> void: + var selected := _selection.get_selected().duplicate() + if selected.is_empty(): + return + for node: Node2D in selected: + if is_instance_valid(node): + _clear_object_state(node) + node.queue_free() + _selection.clear_selection() + object_deleted.emit(selected) + _refresh_status() + +# --------------------------------------------------------------------------- +# Status / UI +# --------------------------------------------------------------------------- + +func _refresh_status() -> void: + if _status_label == null: + return + var mode_text := "EDIT" if current_mode == StageMode.EDIT else "PLAY" + var count := _world_children_selectable().size() + var sel := _selection.get_selected() + var sel_text: String + if sel.is_empty(): + sel_text = "none" + elif sel.size() == 1: + sel_text = String(sel[0].name) + else: + sel_text = "%d objects" % sel.size() + _status_label.text = "Mode: %s | Objects: %d | Selected: %s" % [mode_text, count, sel_text] + if _mode_button != null: + _mode_button.set_pressed_no_signal(current_mode == StageMode.PLAY) + _mode_button.text = "Play" if current_mode == StageMode.EDIT else "Edit" + + +func _build_ui() -> void: + var ui := CanvasLayer.new() + ui.name = "UI" + add_child(ui) + + var top_bar := PanelContainer.new() + top_bar.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + top_bar.offset_bottom = 40.0 + ui.add_child(top_bar) + + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 8) + top_bar.add_child(hbox) + + _mode_button = Button.new() + _mode_button.toggle_mode = true + _mode_button.toggled.connect(_on_mode_toggled) + hbox.add_child(_mode_button) + + for id: String in _spawner.get_spawnable_ids(): + var btn := Button.new() + btn.text = _spawner.get_label(id) + btn.toggle_mode = true + btn.toggled.connect(_on_palette_toggled.bind(id)) + hbox.add_child(btn) + _palette_buttons[id] = btn + + _grid_check = CheckBox.new() + _grid_check.text = "Grid" + _grid_check.button_pressed = _show_grid + _grid_check.toggled.connect(_on_grid_toggled) + hbox.add_child(_grid_check) + + _snap_check = CheckBox.new() + _snap_check.text = "Snap" + _snap_check.button_pressed = _snap_enabled + _snap_check.toggled.connect(_on_snap_toggled) + hbox.add_child(_snap_check) + + var grid_label := Label.new() + grid_label.text = "Size" + hbox.add_child(grid_label) + + _grid_size_spin = SpinBox.new() + _grid_size_spin.min_value = MIN_GRID_SIZE + _grid_size_spin.max_value = MAX_GRID_SIZE + _grid_size_spin.step = 1.0 + _grid_size_spin.value = _grid_size + _grid_size_spin.rounded = true + _grid_size_spin.value_changed.connect(_on_grid_size_changed) + hbox.add_child(_grid_size_spin) + + _status_label = Label.new() + _status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + hbox.add_child(_status_label) + + +func _build_gizmo_layer() -> void: + _gizmos = STAGE_GIZMOS.new() + _gizmos.name = "GizmoLayer" + _gizmos.camera = _camera + _gizmos.transform_committed.connect(_on_transform_committed) + add_child(_gizmos) + + +func _build_grid_layer() -> void: + _grid = STAGE_GRID.new() + _grid.name = "GridLayer" + _grid.camera = _camera + add_child(_grid) + move_child(_grid, 0) + + +func _build_ghost_holder() -> void: + _ghost_holder = Node2D.new() + _ghost_holder.name = "PlacementGhost" + add_child(_ghost_holder) + +# --------------------------------------------------------------------------- +# Signal handlers +# --------------------------------------------------------------------------- + +func _on_mode_toggled(pressed: bool) -> void: + set_mode(StageMode.PLAY if pressed else StageMode.EDIT) + + +func _on_palette_toggled(pressed: bool, id: String) -> void: + if pressed: + set_placement_mode(id) + elif _placement_id == id: + set_placement_mode("") + + +func _on_grid_toggled(pressed: bool) -> void: + _show_grid = pressed + _apply_grid_settings() + _save_settings() + + +func _on_snap_toggled(pressed: bool) -> void: + _snap_enabled = pressed + _apply_grid_settings() + _save_settings() + + +func _on_grid_size_changed(value: float) -> void: + _grid_size = clampf(value, MIN_GRID_SIZE, MAX_GRID_SIZE) + _apply_grid_settings() + _save_settings() + + +func _on_selection_changed(nodes: Array) -> void: + _gizmos.set_target(_selection.get_primary()) + if nodes.is_empty(): + object_deselected.emit() + else: + object_selected.emit(nodes) + _refresh_status() + + +func _on_hover_changed(node: Node2D) -> void: + _gizmos.set_hover(node) + + +func _on_transform_committed(node: Node2D) -> void: + _save_object_state(node) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +func _set_zoom(value: float) -> void: + var z := clampf(value, min_zoom, max_zoom) + _camera.zoom = Vector2(z, z) + + +func _is_mouse_over_ui() -> bool: + return get_viewport().gui_get_hovered_control() != null + + +func _snap_to_grid(v: Vector2) -> Vector2: + if _grid_size <= 0.0: + return v + return Vector2(roundf(v.x / _grid_size) * _grid_size, roundf(v.y / _grid_size) * _grid_size) + + +func _apply_grid_settings() -> void: + if _grid != null: + _grid.grid_size = _grid_size + _grid.enabled = _show_grid + if _gizmos != null: + _gizmos.snap_size = _grid_size if _snap_enabled else 0.0 + + +## Direct Node2D children of World, excluding the ragdoll body container. +func _world_children_selectable() -> Array[Node2D]: + var result: Array[Node2D] = [] + for child: Node in _world.get_children(): + var node := child as Node2D + if node == null: + continue + if node.name == RAGDOLL_CONTAINER_NAME: + continue + result.append(node) + return result + +# --------------------------------------------------------------------------- +# Settings persistence +# --------------------------------------------------------------------------- + +func _load_settings() -> void: + if not FileAccess.file_exists(SETTINGS_PATH): + return + var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ) + if file == null: + return + var json: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if not json is Dictionary: + return + var d := json as Dictionary + _grid_size = float(d.get("grid_size", DEFAULT_GRID_SIZE)) + _grid_size = clampf(_grid_size, MIN_GRID_SIZE, MAX_GRID_SIZE) + _snap_enabled = bool(d.get("snap_to_grid", false)) + _show_grid = bool(d.get("show_grid", true)) + + +func _save_settings() -> void: + var data := { + "version": "1.0", + "grid_size": _grid_size, + "snap_to_grid": _snap_enabled, + "show_grid": _show_grid, + } + var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE) + if file: + file.store_string(JSON.stringify(data, "\t", false)) + file.close() diff --git a/scripts/sandbox_stage.gd.uid b/scripts/sandbox_stage.gd.uid new file mode 100644 index 0000000..6f2fe6c --- /dev/null +++ b/scripts/sandbox_stage.gd.uid @@ -0,0 +1 @@ +uid://dkhhg81ft70mm diff --git a/scripts/stage_gizmos.gd b/scripts/stage_gizmos.gd new file mode 100644 index 0000000..c42bf3e --- /dev/null +++ b/scripts/stage_gizmos.gd @@ -0,0 +1,172 @@ +class_name StageGizmos +extends Node2D +## StageGizmos - Hover highlight, selection outline and a rotate gizmo for the +## Sandbox Stage Builder (Phase 2). +## +## Translation is done by dragging the object directly (no translate handle); +## only the rotate ring is a dedicated handle. Pure drawing + distance-based +## handle hit-testing (no Area2D). Dragging drives global_position / +## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode. + +const STAGE_SELECTION := preload("res://scripts/stage_selection.gd") + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + +enum Handle { NONE, TRANSLATE, ROTATE } + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +signal transform_committed(node: Node2D) + +# --------------------------------------------------------------------------- +# Style constants (screen-space px; divided by zoom for world-space drawing) +# --------------------------------------------------------------------------- + +const SELECTION_COLOR := Color(1.0, 1.0, 1.0, 0.9) +const HOVER_COLOR := Color(1.0, 0.9, 0.2, 0.5) +const ROTATE_COLOR := Color(0.3, 0.6, 1.0, 0.9) + +const SELECTION_WIDTH := 2.0 +const HOVER_WIDTH := 1.5 +const ROTATE_WIDTH := 2.0 + +const ROTATE_HANDLE_MARGIN_PX := 48.0 +const ROTATE_RING_TOLERANCE_PX := 12.0 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var camera: Camera2D = null + +## When > 0, translation drags snap to this grid size (0 = off). +var snap_size: float = 0.0 + +var _enabled: bool = true +var _target: Node2D = null +var _hovered: Node2D = null +var _dragging: Handle = Handle.NONE +var _drag_last: Vector2 = Vector2.ZERO +var _box_rect: Rect2 = Rect2() + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +func set_target(node: Node2D) -> void: + _target = node + queue_redraw() + + +func set_hover(node: Node2D) -> void: + _hovered = node + queue_redraw() + + +func set_enabled(enabled: bool) -> void: + _enabled = enabled + visible = enabled + queue_redraw() + + +func get_target() -> Node2D: + return _target + + +func set_box_rect(rect: Rect2) -> void: + _box_rect = rect + queue_redraw() + + +## Returns ROTATE if the cursor is on the rotate ring of the primary selection. +func hit_test(world_pos: Vector2) -> Handle: + if _target == null or not is_instance_valid(_target) or not _enabled: + return Handle.NONE + var rect := STAGE_SELECTION.get_world_aabb(_target) + var ring := _rotate_ring_radius(rect) + if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom(): + return Handle.ROTATE + return Handle.NONE + + +## Begins a rotate drag (used when the ring is clicked). +func begin_drag(world_pos: Vector2) -> void: + _dragging = hit_test(world_pos) + _drag_last = world_pos + + +## Begins a translate drag on `node` (used when an object body is clicked). +func begin_translate_drag(node: Node2D, world_pos: Vector2) -> void: + _target = node + _dragging = Handle.TRANSLATE + _drag_last = world_pos + + +func drag_to(world_pos: Vector2) -> void: + if _target == null or not is_instance_valid(_target) or _dragging == Handle.NONE: + return + var delta := world_pos - _drag_last + if _dragging == Handle.TRANSLATE: + _target.global_position += delta + if snap_size > 0.0: + _target.global_position = _snap(_target.global_position) + elif _dragging == Handle.ROTATE: + var center := STAGE_SELECTION.get_world_aabb(_target).get_center() + var before := (_drag_last - center).angle() + var after := (world_pos - center).angle() + _target.global_rotation += after - before + _drag_last = world_pos + queue_redraw() + + +func end_drag() -> void: + if _dragging != Handle.NONE and _target != null and is_instance_valid(_target): + transform_committed.emit(_target) + _dragging = Handle.NONE + + +func is_dragging() -> bool: + return _dragging != Handle.NONE + +# --------------------------------------------------------------------------- +# Frame / drawing +# --------------------------------------------------------------------------- + +func _process(_delta: float) -> void: + if _enabled and (_target != null or _hovered != null or _box_rect.has_area()): + queue_redraw() + + +func _draw() -> void: + if not _enabled: + return + var zoom := _zoom() + if _hovered != null and is_instance_valid(_hovered) and _hovered != _target: + draw_rect(STAGE_SELECTION.get_world_aabb(_hovered), HOVER_COLOR, false, HOVER_WIDTH / zoom) + if _target != null and is_instance_valid(_target): + var rect := STAGE_SELECTION.get_world_aabb(_target) + draw_rect(rect, SELECTION_COLOR, false, SELECTION_WIDTH / zoom) + draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true) + if _box_rect.has_area(): + draw_rect(_box_rect, HOVER_COLOR, false, HOVER_WIDTH / zoom) + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +func _zoom() -> float: + if camera != null and is_instance_valid(camera): + return maxf(camera.zoom.x, 0.0001) + return 1.0 + + +func _rotate_ring_radius(rect: Rect2) -> float: + return rect.size.length() * 0.5 + ROTATE_HANDLE_MARGIN_PX / _zoom() + + +func _snap(v: Vector2) -> Vector2: + return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size) diff --git a/scripts/stage_gizmos.gd.uid b/scripts/stage_gizmos.gd.uid new file mode 100644 index 0000000..544e5a2 --- /dev/null +++ b/scripts/stage_gizmos.gd.uid @@ -0,0 +1 @@ +uid://dcwylgjtosys7 diff --git a/scripts/stage_grid.gd b/scripts/stage_grid.gd new file mode 100644 index 0000000..3d347ab --- /dev/null +++ b/scripts/stage_grid.gd @@ -0,0 +1,46 @@ +class_name StageGrid +extends Node2D +## StageGrid - Optional world-space grid overlay for the Sandbox Stage Builder. +## +## Draws grid lines that pan/zoom with the camera, with a heavier major line +## every few cells. Pure drawing; no hit-testing. Rendered behind the World by +## keeping this node as the first child of the stage root. + +var camera: Camera2D = null +var grid_size: float = 15.0 +var enabled: bool = true + +const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.08) +const MAJOR_COLOR := Color(1.0, 1.0, 1.0, 0.16) +const MAJOR_EVERY := 5 + +func _process(_delta: float) -> void: + if enabled: + queue_redraw() + + +func _draw() -> void: + if not enabled or grid_size <= 0.0: + return + if camera == null or not is_instance_valid(camera): + return + var viewport_size := get_viewport_rect().size + var zoom := maxf(camera.zoom.x, 0.0001) + var half := viewport_size * 0.5 / zoom + var center := camera.get_screen_center_position() + var rect := Rect2(center - half, half * 2.0) + var line_width := 1.0 / zoom + + var x := floorf(rect.position.x / grid_size) * grid_size + while x <= rect.end.x: + var grid_index := int(round(x / grid_size)) + var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR + draw_line(Vector2(x, rect.position.y), Vector2(x, rect.end.y), color, line_width) + x += grid_size + + var y := floorf(rect.position.y / grid_size) * grid_size + while y <= rect.end.y: + var grid_index := int(round(y / grid_size)) + var color := MAJOR_COLOR if grid_index % MAJOR_EVERY == 0 else GRID_COLOR + draw_line(Vector2(rect.position.x, y), Vector2(rect.end.x, y), color, line_width) + y += grid_size diff --git a/scripts/stage_grid.gd.uid b/scripts/stage_grid.gd.uid new file mode 100644 index 0000000..e5aabc9 --- /dev/null +++ b/scripts/stage_grid.gd.uid @@ -0,0 +1 @@ +uid://dpyu1lyn6evw7 diff --git a/scripts/stage_selection.gd b/scripts/stage_selection.gd new file mode 100644 index 0000000..863f38a --- /dev/null +++ b/scripts/stage_selection.gd @@ -0,0 +1,200 @@ +class_name StageSelection +extends RefCounted +## StageSelection - Hover, click and box selection for the Sandbox Stage Builder. +## +## Hit-testing is geometric (world-space AABBs), so it works uniformly for +## TerrainBlock, PropBlock and StickmanRig (which has no physics collision). +## Frontmost World child wins; smallest area breaks ties. The ragdoll body +## container and its subtree are never selectable. + +# --------------------------------------------------------------------------- +# Signals +# --------------------------------------------------------------------------- + +signal hover_changed(node: Node2D) +signal selection_changed(nodes: Array[Node2D]) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +const RAGDOLL_CONTAINER_NAME := "RagdollBodyContainer" + +## Rig-local bounds for stickman hit-testing (mirrors the rig's standing bounds). +const RIG_LOCAL_RECT := Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0)) + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var _world: Node2D +var _camera: Camera2D +var _selected: Array[Node2D] = [] +var _hovered: Node2D = null +var _primary: Node2D = null + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _init(world: Node2D, camera: Camera2D) -> void: + _world = world + _camera = camera + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +func get_selected() -> Array[Node2D]: + return _selected + + +func get_primary() -> Node2D: + return _primary + + +func clear_selection() -> void: + _selected.clear() + _primary = null + selection_changed.emit([]) + + +func select_only(node: Node2D) -> void: + _selected = [node] + _primary = node + selection_changed.emit(_selected.duplicate()) + + +func add_to_selection(node: Node2D) -> void: + if not _selected.has(node): + _selected.append(node) + _primary = node + selection_changed.emit(_selected.duplicate()) + + +func toggle_selection(node: Node2D) -> void: + if _selected.has(node): + _selected.erase(node) + _primary = _selected[_selected.size() - 1] if not _selected.is_empty() else null + else: + _selected.append(node) + _primary = node + selection_changed.emit(_selected.duplicate()) + + +func is_selected(node: Node2D) -> bool: + return _selected.has(node) + + +## Returns the frontmost selectable node under `world_pos`, or null. +func hit_test(world_pos: Vector2) -> Node2D: + return _frontmost_at(world_pos) + + +## Refreshes the hovered node under `world_pos`, emitting hover_changed when it +## changes. Returns the new hover target (or null). +func update_hover(world_pos: Vector2) -> Node2D: + var hit := _frontmost_at(world_pos) + if hit != _hovered: + _hovered = hit + hover_changed.emit(hit) + return hit + + +## Selects all selectable nodes whose AABB intersects `rect`. When `additive`, +## appends to the current selection instead of replacing it. +func box_select(rect: Rect2, additive: bool) -> void: + var hits: Array[Node2D] = [] + for child: Node in _world.get_children(): + var node := child as Node2D + if node == null or not _is_selectable(node): + continue + if get_world_aabb(node).intersects(rect): + hits.append(node) + if additive: + for node: Node2D in hits: + if not _selected.has(node): + _selected.append(node) + if not hits.is_empty(): + _primary = hits[hits.size() - 1] + else: + _selected = hits + _primary = hits[hits.size() - 1] if not hits.is_empty() else null + selection_changed.emit(_selected.duplicate()) + + +## World-space AABB for a node. Terrain/props use their Polygon2D child; a +## stickman rig unions its mounted `Body/*` shape geometry (so the box is +## centered on the actual figure, head to feet). +static func get_world_aabb(node: Node2D) -> Rect2: + if node == null or not is_instance_valid(node): + return Rect2() + var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D + if poly != null and not poly.polygon.is_empty(): + var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO) + for p: Vector2 in poly.polygon: + rect = rect.expand(node.to_global(p)) + return rect + var body := node.get_node_or_null(NodePath("Body")) as Node2D + if body != null: + var acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF } + _collect_visual_points(body, acc) + if acc["min_x"] <= acc["max_x"]: + return Rect2(Vector2(acc["min_x"], acc["min_y"]), Vector2(acc["max_x"] - acc["min_x"], acc["max_y"] - acc["min_y"])) + if node.get_node_or_null(NodePath("Skeleton2D")) != null: + return node.global_transform * RIG_LOCAL_RECT + return Rect2(node.global_position, Vector2.ZERO) + + +## Recursively unions the world-space points of every Line2D / Polygon2D under +## `node` into `acc` (keys min_x/min_y/max_x/max_y). +static func _collect_visual_points(node: Node, acc: Dictionary) -> void: + if node is Line2D: + for p: Vector2 in (node as Line2D).points: + _accumulate((node as Line2D).to_global(p), acc) + elif node is Polygon2D: + for p: Vector2 in (node as Polygon2D).polygon: + _accumulate((node as Polygon2D).to_global(p), acc) + for child: Node in node.get_children(): + _collect_visual_points(child, acc) + + +static func _accumulate(p: Vector2, acc: Dictionary) -> void: + acc["min_x"] = minf(acc["min_x"], p.x) + acc["min_y"] = minf(acc["min_y"], p.y) + acc["max_x"] = maxf(acc["max_x"], p.x) + acc["max_y"] = maxf(acc["max_y"], p.y) + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +## A selectable node is a direct Node2D child of World that is not the ragdoll +## body container. +func _is_selectable(node: Node) -> bool: + if node == null or not (node is Node2D): + return false + if node.get_parent() != _world: + return false + if node.name == RAGDOLL_CONTAINER_NAME: + return false + return true + + +## Highest World child index wins; on equal area the frontmost (first hit in +## reverse iteration) stays. +func _frontmost_at(world_pos: Vector2) -> Node2D: + var children := _world.get_children() + var best: Node2D = null + var best_area := INF + for i: int in range(children.size() - 1, -1, -1): + var node := children[i] as Node2D + if node == null or not _is_selectable(node): + continue + var aabb := get_world_aabb(node) + if aabb.has_point(world_pos): + var area := aabb.get_area() + if best == null or area < best_area: + best = node + best_area = area + return best diff --git a/scripts/stage_selection.gd.uid b/scripts/stage_selection.gd.uid new file mode 100644 index 0000000..371e73a --- /dev/null +++ b/scripts/stage_selection.gd.uid @@ -0,0 +1 @@ +uid://c52c38mrywjpi diff --git a/scripts/stage_spawner.gd b/scripts/stage_spawner.gd new file mode 100644 index 0000000..994f70a --- /dev/null +++ b/scripts/stage_spawner.gd @@ -0,0 +1,235 @@ +class_name StageSpawner +extends RefCounted +## StageSpawner - Registry-driven factory for the Sandbox Stage Builder (Phase 2). +## +## A spawn registry maps an id to a terrain/prop/stickman template. Adding a new +## spawnable type only requires appending a registry entry - no hard-coded match +## statements on ids. Reuses TerrainUtils, PropUtils and StickmanFactory. + +# --------------------------------------------------------------------------- +# Preloaded dependencies (resolved directly, independent of the global class +# registry, so this script compiles even when the editor's class cache is stale) +# --------------------------------------------------------------------------- + +const TERRAIN_UTILS := preload("res://scripts/terrain_utils.gd") +const PROP_UTILS := preload("res://scripts/prop_utils.gd") +const PROP_BLOCK := preload("res://scripts/prop_block.gd") +const STICKMAN_FACTORY := preload("res://scripts/stickman_factory.gd") + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +## Default stickman asset (the only complete, upright figure). +const DEFAULT_STICKMAN_PATH := "res://stickmen/test.stk" + +## The rig's feet rest ~385 px below its root (hips), so placing the root 385 px +## above the cursor puts the feet on it. +const STICKMAN_FOOT_OFFSET := Vector2(0.0, -385.0) + +const TERRAIN_GRID_SIZE: float = 16.0 + +# --------------------------------------------------------------------------- +# State +# --------------------------------------------------------------------------- + +var _world: Node2D +var _registry: Array[Dictionary] = [] +var _stickman_data: Dictionary = {} + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +func _init(world: Node2D) -> void: + _world = world + _stickman_data = STICKMAN_FACTORY.load_stk(DEFAULT_STICKMAN_PATH) + if _stickman_data.is_empty(): + push_warning("StageSpawner: failed to load default stickman '%s'." % DEFAULT_STICKMAN_PATH) + _build_registry() + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +func get_spawnable_ids() -> Array[String]: + var ids: Array[String] = [] + for entry: Dictionary in _registry: + ids.append(String(entry["id"])) + return ids + + +func get_label(id: String) -> String: + var entry := _find_entry(id) + return String(entry.get("label", id)) if not entry.is_empty() else id + + +func get_spawn_offset(id: String) -> Vector2: + var entry := _find_entry(id) + if entry.is_empty(): + return Vector2.ZERO + return entry.get("spawn_offset", Vector2.ZERO) + + +## Spawn the registry type at `world_position`; returns null + push_warning on +## an unknown id. +func spawn(id: String, world_position: Vector2) -> Node2D: + var entry := _find_entry(id) + if entry.is_empty(): + push_warning("StageSpawner: unknown spawn id '%s'." % id) + return null + var offset: Vector2 = entry.get("spawn_offset", Vector2.ZERO) + var pos := world_position + offset + match String(entry.get("kind", "")): + "terrain": + return _spawn_terrain(entry, pos) + "prop": + return _spawn_prop(entry, pos) + "stickman": + return _spawn_stickman(pos) + _: + push_warning("StageSpawner: unknown spawn kind '%s'." % entry.get("kind", "")) + return null + + +## World-space AABB for a spawnable node: union of the Polygon2D child's world +## points; for a stickman rig, union of its mounted `Body/*` shape geometry; a +## point rect otherwise. +static func get_world_aabb(node: Node2D) -> Rect2: + if node == null or not is_instance_valid(node): + return Rect2() + var poly := node.get_node_or_null(NodePath("Polygon2D")) as Polygon2D + if poly != null and not poly.polygon.is_empty(): + var rect := Rect2(node.to_global(poly.polygon[0]), Vector2.ZERO) + for p: Vector2 in poly.polygon: + rect = rect.expand(node.to_global(p)) + return rect + var body := node.get_node_or_null(NodePath("Body")) as Node2D + if body != null: + var acc := { "min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF } + _collect_visual_points(body, acc) + if acc["min_x"] <= acc["max_x"]: + return Rect2(Vector2(acc["min_x"], acc["min_y"]), Vector2(acc["max_x"] - acc["min_x"], acc["max_y"] - acc["min_y"])) + if node.get_node_or_null(NodePath("Skeleton2D")) != null: + return node.global_transform * Rect2(Vector2(-120.0, -1000.0), Vector2(240.0, 1000.0)) + return Rect2(node.global_position, Vector2.ZERO) + + +static func _collect_visual_points(node: Node, acc: Dictionary) -> void: + if node is Line2D: + for p: Vector2 in (node as Line2D).points: + _accumulate((node as Line2D).to_global(p), acc) + elif node is Polygon2D: + for p: Vector2 in (node as Polygon2D).polygon: + _accumulate((node as Polygon2D).to_global(p), acc) + for child: Node in node.get_children(): + _collect_visual_points(child, acc) + + +static func _accumulate(p: Vector2, acc: Dictionary) -> void: + acc["min_x"] = minf(acc["min_x"], p.x) + acc["min_y"] = minf(acc["min_y"], p.y) + acc["max_x"] = maxf(acc["max_x"], p.x) + acc["max_y"] = maxf(acc["max_y"], p.y) + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +func _build_registry() -> void: + _registry = [ + { + "id": "ground", "label": "Ground", "kind": "terrain", + "points": PackedVector2Array([Vector2(-100, -16), Vector2(100, -16), Vector2(100, 16), Vector2(-100, 16)]), + "fill": Color(0.25, 0.55, 0.25), "outline": Color(0.05, 0.10, 0.05), "width": 2.0, + "spawn_offset": Vector2.ZERO, + }, + { + "id": "ramp", "label": "Ramp", "kind": "terrain", + "points": PackedVector2Array([Vector2(-96, 32), Vector2(96, -96), Vector2(96, -32), Vector2(-96, 96)]), + "fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0, + "spawn_offset": Vector2.ZERO, + }, + { + "id": "step", "label": "Step", "kind": "terrain", + "points": PackedVector2Array([ + Vector2(-128, 128), Vector2(128, 128), Vector2(128, -128), Vector2(64, -128), + Vector2(64, -64), Vector2(0, -64), Vector2(0, 0), Vector2(-64, 0), + Vector2(-64, 64), Vector2(-128, 64), + ]), + "fill": Color(0.30, 0.50, 0.30), "outline": Color(0.05, 0.10, 0.05), "width": 2.0, + "spawn_offset": Vector2.ZERO, + }, + { + "id": "crate", "label": "Crate", "kind": "prop", + "payload": PROP_UTILS.create_box(), "preset": PROP_BLOCK.MaterialPreset.WOOD, + "spawn_offset": Vector2.ZERO, + }, + { + "id": "ball", "label": "Ball", "kind": "prop", + "payload": PROP_UTILS.create_ball(), "preset": PROP_BLOCK.MaterialPreset.RUBBER, + "spawn_offset": Vector2.ZERO, + }, + { + "id": "stickman", "label": "Stickman", "kind": "stickman", + "spawn_offset": STICKMAN_FOOT_OFFSET, + }, + ] + + +func _find_entry(id: String) -> Dictionary: + for entry: Dictionary in _registry: + if String(entry.get("id", "")) == id: + return entry + return {} + +# --------------------------------------------------------------------------- +# Spawn helpers +# --------------------------------------------------------------------------- + +## Center the terrain template on its local origin so the block rotates about +## its own center, then place the block at the cursor. +func _spawn_terrain(entry: Dictionary, world_position: Vector2) -> TerrainBlock: + var template: PackedVector2Array = entry["points"] + var center := _points_center(template) + var centered := PackedVector2Array() + for p: Vector2 in template: + centered.append(p - center) + var block: TerrainBlock = TERRAIN_UTILS.spawn_block( + _world, + centered, + TERRAIN_GRID_SIZE, + entry.get("fill", TERRAIN_UTILS.DEFAULT_FILL_COLOR), + entry.get("outline", TERRAIN_UTILS.DEFAULT_OUTLINE_COLOR), + float(entry.get("width", 2.0)) + ) + block.position = world_position + return block + + +func _spawn_prop(entry: Dictionary, world_position: Vector2) -> PropBlock: + var payload: Dictionary = entry["payload"] + var preset: int = int(entry.get("preset", PROP_BLOCK.MaterialPreset.WOOD)) + return PROP_UTILS.spawn_prop(_world, world_position, payload, preset, Vector2.ZERO) + + +func _spawn_stickman(world_position: Vector2) -> StickmanRig: + if _stickman_data.is_empty(): + push_warning("StageSpawner: no stickman data loaded; check '%s'." % DEFAULT_STICKMAN_PATH) + return null + var rig: StickmanRig = STICKMAN_FACTORY.spawn_from_data(_stickman_data) + if rig == null: + push_warning("StageSpawner: failed to spawn stickman.") + return null + rig.position = world_position + _world.add_child(rig) + return rig + + +static func _points_center(pts: PackedVector2Array) -> Vector2: + if pts.is_empty(): + return Vector2.ZERO + var sum := Vector2.ZERO + for p: Vector2 in pts: + sum += p + return sum / float(pts.size()) diff --git a/scripts/stage_spawner.gd.uid b/scripts/stage_spawner.gd.uid new file mode 100644 index 0000000..ed87ef4 --- /dev/null +++ b/scripts/stage_spawner.gd.uid @@ -0,0 +1 @@ +uid://c4adnnx5m3qss diff --git a/scripts/stickman_rig.gd b/scripts/stickman_rig.gd index 0ba889b..6c35ed9 100644 --- a/scripts/stickman_rig.gd +++ b/scripts/stickman_rig.gd @@ -436,6 +436,35 @@ func request_recovery() -> void: _start_recovery() +## Instantly snaps the rig back to its authored standing pose — no stand-up +## tween. Used by the sandbox stage so a stickman "reappears" at its starting +## position/state on return to EDIT (instead of animating the recovery glide). +func snap_to_standing() -> void: + if state == RigState.ANIMATED: + return + if state == RigState.RAGDOLL: + _destroy_ragdoll() + else: + _cancel_recovery() + # Set the IK targets directly to the standing pose (no tween). + for marker_name: String in STAND_POSE: + var marker := _get_ik_marker(marker_name) + if marker == null: + continue + var target: Dictionary = STAND_POSE[marker_name] + marker.position = target.get("pos", marker.position) + if marker_name == "Torso": + marker.rotation = target.get("rot", marker.rotation) + # Re-show the kinematic puppet and re-enable IK. + if _skeleton != null and is_instance_valid(_skeleton) and _skeleton.modification_stack != null: + _skeleton.modification_stack.enabled = true + if _body_container != null and is_instance_valid(_body_container): + _body_container.visible = true + _body_container.modulate.a = 1.0 + state = RigState.ANIMATED + state_changed.emit(int(state)) + + ## Applies the same velocity delta to every ragdoll body via a mass-scaled ## central impulse, preserving the ragdoll's internal structure. No-op outside ## RAGDOLL mode. Used by the physics harness "Knock Up" button. diff --git a/scripts/stk_rig_adapter.gd b/scripts/stk_rig_adapter.gd index 6276e67..1ad4aff 100644 --- a/scripts/stk_rig_adapter.gd +++ b/scripts/stk_rig_adapter.gd @@ -233,9 +233,14 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void: var part_data: Variant = body_parts.get(part_name, {}) if part_data is Dictionary: var pd := part_data as Dictionary - var shapes_var: Variant = pd.get("shapes", []) - if shapes_var is Array: - shapes = shapes_var as Array + if pd.has("shapes"): + var shapes_var: Variant = pd.get("shapes") + if shapes_var is Array: + shapes = shapes_var as Array + elif pd.has("points"): + # v1.0/v1.1 single-shape format: the part dict is itself one + # shape (wrapped like the editor's load path). + shapes = [pd] # Phase 9 Round 3: the preview's per-part rotation (degrees) and # scale about the bbox center are applied to the mounted geometry. rotation_deg = float(pd.get("rotation", 0.0))