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`,