Add sandbox stage builder scripts and functionality

- Introduced StageGizmos for hover highlighting, selection outlines, and rotation handles in the sandbox stage builder.
- Added StageGrid for an optional world-space grid overlay that adjusts with camera panning and zooming.
- Implemented StageSelection for geometric hit-testing and selection management of nodes in the sandbox.
- Created StageSpawner as a registry-driven factory for spawning terrain, props, and stickmen, allowing for dynamic template management.
- Each script includes necessary constants, state management, and public API methods for interaction.
This commit is contained in:
2026-08-28 21:53:55 -04:00
parent 0e971d99b1
commit ef30931b20
17 changed files with 1800 additions and 6 deletions
+84 -2
View File
@@ -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`,
+60 -1
View File
@@ -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 (1100 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: <name or count>` 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 46 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 46 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. |
+3
View File
@@ -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 inworld UI in the physics harness yet. | Low | ✅ Resolved | Phase 11: added a Rest `SpinBox` (0.110 s, step 0.1) to the harness UI that writes `_rig.rest_timeout` (runtimeonly), plus a "Recover Now" button → `_rig.request_recovery()`. (20260827) |
| 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 oneshot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (20260827) |
| 10 | **Rig Collision Proxy Readdition** — The proxy is readded on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still readds 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 standup completes. Consider delaying readdition until after recovery finishes (`ANIMATED`). (20260827) |
| 11 | **Stage Freeze Abstraction** — Sandbox Stage EDITmode freezing is typespecific: `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 ducktyped `set_simulating(bool)` interface once more physical object kinds appear. (20260827) |
| 12 | **Stage AABB Selection Precision**`StageSelection.get_world_aabb` uses conservative worldspace AABBs (polygon point union / fixed rig rect), not pointinpolygon. | Low | Open | Clicks in the boundingbox corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can misselect. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (20260827) |
---
@@ -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 readdition) remains open with updated scope. Later revision: standup recovery switched from bridgeintobakedanimation 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 halfheights with Torso `bone_angle` compensation, fixing the entry posepop 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 hittesting uses worldspace AABBs rather than pointinpolygon). |
---
+302
View File
@@ -0,0 +1,302 @@
# Sandbox Stage Builder
## 1. Overview & Objective
**This Phase (Phase 2)** builds the foundation for the kidfriendly 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. Whats 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 (middlemouse) 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. Whats 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, iconbased palette — Phase 5)
- **IK foot placement / slope alignment** (postPhase 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 selfcontained 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 hardcoded `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:** Middlemouse 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 iconbased **Spawn Palette** with draggable cards (draganddrop 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, BoxSelect) — 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 **hittestable** (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:** Middlemouse 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 hardcoded 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 textbased Mode Toggle with a **large, kidfriendly graphical switch/slider**.
- Replace text palette buttons with **iconbased draggable cards** (draganddrop onto the stage).
- Add tooltips and animations for feedback.
### 6.3. Box Selection Enhancements (PostPhase 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 SignOff 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.
- [ ] Singleclick selection works.
- [ ] Box selection works (with Shift addtoselection).
- [ ] 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*
+12
View File
@@ -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="."]
+644
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://dkhhg81ft70mm
+172
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://dcwylgjtosys7
+46
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dpyu1lyn6evw7
+200
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://c52c38mrywjpi
+235
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
uid://c4adnnx5m3qss
+29
View File
@@ -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.
+6 -1
View File
@@ -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 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))