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:
@@ -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
|
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
|
`RemoteTransform2D` drivers keep their defaults (`update_rotation = true`), so mounted shapes
|
||||||
follow their bones in every pose.
|
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 =
|
- **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
|
-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
|
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,
|
physics mode switch plus a `RECOVERING` stand-up state. `enum RigState { ANIMATED, RAGDOLL,
|
||||||
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
|
RECOVERING }`, `var state: RigState` (default `ANIMATED`), `signal state_changed(new_state:
|
||||||
int)`, and public API `set_ragdoll(enabled: bool)` / `toggle_ragdoll()` / `is_in_ragdoll() ->
|
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
|
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
|
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
|
`AnimationPlayer` (`ANIMATION_PLAYER_PATH` const, keep_state — no pose reset), builds the
|
||||||
@@ -513,6 +520,75 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
|
|||||||
always `_update_ragdoll_toggle()` (proxy helpers are idempotent, so the toggle handler's own
|
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
|
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:
|
||||||
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
|
||||||
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
|
||||||
@@ -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
|
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
|
the best-effort `StaticBody2D` rig collision proxy); a **Knock Up** button impulses the
|
||||||
ragdoll and all props upward.
|
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
|
### Body-part data model
|
||||||
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
|
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
|
||||||
|
|||||||
@@ -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).
|
**Interruptibility:** `set_ragdoll(true)` during `RECOVERING` kills the stand-up tween and rebuilds the ragdoll; `set_ragdoll(false)` during `RAGDOLL` routes through `_start_recovery()`; repeated `set_ragdoll` calls are idempotent. All ragdoll nodes are spawned procedurally — `master_rig.tscn` is **not** modified (the `stand_up` / `walk_left` / `walk_right` animations are baked into the scene's `AnimationLibrary` by the `create_animations.gd` editor script, which runs manually in the editor; the baked `stand_up` is an authored reference and the recovery path does not play it).
|
||||||
|
|
||||||
|
### 18. Sandbox Stage Builder
|
||||||
|
|
||||||
|
The **Sandbox Stage Builder** (Phase 2) is a standalone, kid-friendly director sandbox: a visual stage where you place terrain, props, and stickmen from a palette, then flip between **Edit Mode** (build) and **Play Mode** (physics simulation). It is **not wired into the editor** — run via **F6** on `res://scenes/sandbox_stage.tscn`.
|
||||||
|
|
||||||
|
The stage is intentionally **extendable**: the spawn palette is registry-driven (adding an object type = appending one dictionary entry), selection hit-tests arbitrary `Node2D`s geometrically, gizmos drive `global_position`/`global_rotation`, and core events are exposed as signals for future phases (Action Queue, Triggers, Save/Load).
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `res://scenes/sandbox_stage.tscn` | The stage scene: root `Node2D` + `Camera2D` + empty `World` container. |
|
||||||
|
| `res://scripts/sandbox_stage.gd` | `class_name SandboxStage`, `extends Node2D` — root controller (mode state machine, placement, camera, deletion, status bar, signals). |
|
||||||
|
| `res://scripts/stage_spawner.gd` | `class_name StageSpawner`, `extends RefCounted` — registry-driven factory reusing `TerrainUtils` / `PropUtils` / `StickmanFactory`. |
|
||||||
|
| `res://scripts/stage_selection.gd` | `class_name StageSelection`, `extends RefCounted` — hover/click/box selection via geometric AABB hit-testing. |
|
||||||
|
| `res://scripts/stage_gizmos.gd` | `class_name StageGizmos`, `extends Node2D` — hover highlight, selection outline, rotate ring handle. |
|
||||||
|
| `res://scripts/stage_grid.gd` | `class_name StageGrid`, `extends Node2D` — optional world-space grid overlay (major line every 5 cells). |
|
||||||
|
|
||||||
|
**Mode management:**
|
||||||
|
|
||||||
|
- **Edit** (default) — `RigidBody2D` props are frozen (`freeze = true` + `freeze_mode = FREEZE_MODE_KINEMATIC`), stickmen stand as `ANIMATED` puppets, gizmos are visible, and selection is active.
|
||||||
|
- **Play** — props unfreeze and fall, stickmen ragdoll (`set_ragdoll(true)` with `auto_recover = false`), gizmos are hidden, and selection is cleared.
|
||||||
|
- The mode toggle button (leftmost) flips between the two; a `mode_changed(mode: int)` signal is emitted on every toggle, and the camera view persists across the switch.
|
||||||
|
- **Restart-the-sim:** each object's position/rotation is saved whenever you place, move, or rotate it; returning to Edit restores that authored state (and zeroes prop velocity), so every Play session starts from the same authored layout. Stickmen snap straight back to standing (no stand-up glide).
|
||||||
|
|
||||||
|
**Spawn palette** (six text buttons, built from the spawner registry):
|
||||||
|
|
||||||
|
- **Ground / Ramp / Step** — `TerrainBlock` terrain, placed by centering the template on its local origin so rotation pivots on the block's center.
|
||||||
|
- **Crate** (`create_box()` + `WOOD`) / **Ball** (`create_ball()` + `RUBBER`) — `PropBlock` dynamic props.
|
||||||
|
- **Stickman** — a `StickmanRig` spawned from a cached `res://stickmen/test.stk` via `StickmanFactory.spawn_from_data()`, offset `(0, -385)` so the feet land on the cursor.
|
||||||
|
|
||||||
|
Clicking a palette button enters **placement mode**, which shows a translucent **ghost** of the object under the cursor (snapped to the grid when Snap is on). The stickman ghost is a static standing figure. The next left-click spawns the object there. Placement repeats until you press **Escape** or click a different button. Each placement emits `object_placed(node)`.
|
||||||
|
|
||||||
|
**Selection & gizmos (Edit only):**
|
||||||
|
|
||||||
|
- **Hover** highlights the object under the cursor (subtle outline).
|
||||||
|
- **Left-click** selects an object (deselects the previous); click empty space to deselect.
|
||||||
|
- **Direct drag** — click and drag an object to move it (no separate move handle); hold **Shift** while clicking to toggle its selection.
|
||||||
|
- **Box-select** — drag on empty space draws a marquee; release selects everything inside. Hold **Shift** to add to the selection.
|
||||||
|
- The **primary** selection shows a white bounding box and a blue **rotate** ring (around the box); drag the ring to rotate about the center. The ring is hit-tested first, so ring clicks rotate rather than move.
|
||||||
|
- `object_selected(nodes)` / `object_deselected()` are emitted on selection changes.
|
||||||
|
|
||||||
|
**Grid & snap:**
|
||||||
|
|
||||||
|
- A **Grid** checkbox toggles a world-space grid overlay (major line every 5 cells); a **Snap** checkbox rounds placement and dragging to the grid; a **Size** spinbox sets the cell size (1–100 px).
|
||||||
|
- Grid size, snap, and grid visibility persist to `user://sandbox_settings.json`.
|
||||||
|
|
||||||
|
**Deletion & camera:**
|
||||||
|
|
||||||
|
- **Delete** / **Backspace** removes all selected objects (`queue_free()`) and emits `object_deleted(nodes)`.
|
||||||
|
- **Middle-mouse drag** pans; **mouse wheel** zooms within the exported `min_zoom` (0.1) / `max_zoom` (6.0) bounds. The view persists across mode toggles.
|
||||||
|
|
||||||
|
**Status bar:** shows `Mode: EDIT/PLAY | Objects: N | Selected: <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`)
|
## File format (`.stk`)
|
||||||
|
|
||||||
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
|
Files are UTF-8 JSON, pretty-printed with tab indentation. The format is versioned and designed to remain **backward/forward compatible** — new fields can be added without breaking older files.
|
||||||
@@ -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/stickman_editor.tscn` | **Main scene** — editor layout, File/Edit/View menu bar, dialogs (`GridConfigDialog` + SpinBox), column containers (unique-name nodes). |
|
||||||
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
| `res://scenes/body_part_panel.tscn` | Reusable single body-part editor panel (title, drawing area, context menu, `ColorPickerPopup`); expands vertically in its column. |
|
||||||
| `res://scripts/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.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/stickman_editor.gd` | Editor controller — File/Edit/View menu actions, save/load/clear, JSON v1.5 serialization with multi-shape/rotation/scale, `part_order`, Phase 8 `proportions`/`pivot`/`length`, and Phase 9 Round 5 per-part `guide_offset` export, `settings.json` load/save, editor-wide shape clipboard (Copy/Paste across panels), broadcast of grid/snap settings to panels, Reset Views, populates panels, coordinates selection across panels. |
|
||||||
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). **Not used by the editor** — consumed by the runtime pipeline. |
|
| `res://scripts/stk_rig_adapter.gd` | **Phase 8, extended by Phase 9 (Rounds 4–6 bugfix).** Standalone runtime adapter (`class_name StkRigAdapter`, `static func apply(stk_data, rig)`): fits an instantiated `master_rig.tscn` to a loaded `.stk` by re-fitting the 8 limb bones (`Skeleton2D/Torso/...` `Bone2D` lengths + lower-bone origins), recalibrating the IK targets (`IK_Targets/Left|Right_Hand`, `Left|Right_Leg`), and mounting the `.stk` shapes onto the `Body/*` visual nodes (**one node per shape**: closed → single `Polygon2D` fill, open → single `Line2D` width 2). Shape mounting recomputes each part's bounding box at mount time (file `pivot`/`length` are no longer trusted) and derives a mount transform in the rig's **hanging convention** (joint anchor at the local origin, far end along local `+Y`) via `_compute_mount_transform()`: the part's preview transform `E(P) = C + R(rot)·S·(P − C)` (rotation + scale about the bbox center — the editor's exact Whole-Stickman-preview transform) is composed **first**, then the anchor/alignment θ/bone-fit scale are computed on the **transformed geometry**; rotations near ±180° (`|wrapf(rot)| > 0.75π`) swap the attachment to the drawn far end so flips are visible (e.g. the 180° torso shows its drawn neck end at the hip joint). Anchors (raw family rules): head/torso bottom-center `(cx, max_y)`, left horizontal limbs `(max_x, cy)`, right horizontal limbs `(min_x, cy)`, vertically drawn limbs top-center `(cx, min_y)`; alignment rotation θ maps the far end onto `+Y`; scaling is **anisotropic** — only the **auto-detected drawn long axis** (`width >= height`) scales to the bone length (`bone_length/extent`, guard `extent <= 0.0001` → `1.0`), cross-axis thickness stays 1:1. The `RemoteTransform2D` drivers keep `update_rotation = true`, so mounted shapes follow their bones under IK flexing. (Phase 9 Round 5) when a part dict carries `guide_offset`, the mounted geometry is translated by `t = (guide_offset + (A − C)).rotated(−c_node)`; (Phase 9 Round 6) when `guide_offset` is present, the joint anchor is whichever transformed end (`E(J_raw)` or `E(F_pt_raw)`) is nearest the part's guide joint (`center − guide_offset`), replacing the per-side family choice + 180° flip heuristic for that case (fixing the lower-left-leg and lower-right-arm, which were mounted 180° off their bones) — old files without the key keep the family rules + flip heuristic as the fallback in the driver's bone frame (A = mount anchor incl. the 180° flip rule, C = raw bbox center, `c_node` = driver `RemoteTransform2D.global_rotation`), so the harness reproduces the editor's guide-relative placement 1:1; old files without the key keep the offset-0 behavior (head falls back to `HEAD_CHIN_DROP`). Each `Body/*` container's scale is reset to `(1,1)` / rotation `0` (position untouched). (Phase 9) also fits the head bone (`Head.position.y = -proportions.torso_length`) while mounting the head as **full geometry** — it clears the head's inline `@tool` circle script and mounts `.stk` head shapes as `Line2D`/`Polygon2D`, and zeroes the Head driver's local position so the chin sits on the neck joint; the head mounts upright (`θ = 0`, `s = 1`) but still applies the part scale via `E` (face ≈160 px). `_mount_shapes()` also handles **v1.0/v1.1 single-shape** part dicts (wraps the part dict as one shape when it carries `points` but no `shapes` array), so older `.stk` files mount as visible geometry instead of being cleared. **Not used by the editor** — consumed by the runtime pipeline. |
|
||||||
| `res://scripts/stickman_factory.gd` | **Phase 9.** Runtime entry point (`class_name StickmanFactory`, `extends RefCounted`); a static factory that turns a `.stk` file into a live, rigged `master_rig.tscn` instance. `load_stk(path)` reads + parses the file (`{}` + `push_warning` on failure); `spawn_from_data(stk_data)` instantiates `res://master_rig.tscn`, calls `StkRigAdapter.apply(stk_data, rig)`, and returns the rig root **typed as `StickmanRig`** (the rig now carries the `StickmanRig` root script); `spawn(path)` chains them (`null` on empty data). **Not used by the editor.** |
|
| `res://scripts/stickman_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/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). |
|
| `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://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_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/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/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://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. |
|
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no in‑world UI in the physics harness yet. | Low | ✅ Resolved | Phase 11: added a Rest `SpinBox` (0.1–10 s, step 0.1) to the harness UI that writes `_rig.rest_timeout` (runtime‑only), plus a "Recover Now" button → `_rig.request_recovery()`. (2026‑08‑27) |
|
| 8 | **Rest Timeout UI** — The director can adjust `rest_timeout` via inspector, but there is no in‑world UI in the physics harness yet. | Low | ✅ Resolved | Phase 11: added a Rest `SpinBox` (0.1–10 s, step 0.1) to the harness UI that writes `_rig.rest_timeout` (runtime‑only), plus a "Recover Now" button → `_rig.request_recovery()`. (2026‑08‑27) |
|
||||||
| 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | ✅ Resolved | Phase 11: `create_walk.gd` deleted; new `scripts/create_animations.gd` (`@tool extends EditorScript`) bakes `walk_left`/`walk_right` (same keyframes) and a one‑shot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (2026‑08‑27) |
|
| 9 | **Animation Generation DRY** — `create_walk.gd` is a standalone script. It should be merged into a unified `create_animations.gd` that also generates `stand_up` and idle animations. | Medium | ✅ Resolved | Phase 11: `create_walk.gd` deleted; new `scripts/create_animations.gd` (`@tool extends EditorScript`) bakes `walk_left`/`walk_right` (same keyframes) and a one‑shot `stand_up` into the scene's `AnimationLibrary` (the baked `stand_up` is an authored reference only — runtime recovery tweens the IK targets directly). (2026‑08‑27) |
|
||||||
| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still re‑adds the proxy as soon as `RECOVERING` begins (`state_changed` handler), while `Body/*` is already visible — the static box can pop in around the standing figure before the stand‑up completes. Consider delaying re‑addition until after recovery finishes (`ANIMATED`). (2026‑08‑27) |
|
| 10 | **Rig Collision Proxy Re‑addition** — The proxy is re‑added on ragdoll exit, but may cause a brief visual pop if it appears while the kinematic rig is visible. | Low | Open | Phase 11 still re‑adds the proxy as soon as `RECOVERING` begins (`state_changed` handler), while `Body/*` is already visible — the static box can pop in around the standing figure before the stand‑up completes. Consider delaying re‑addition until after recovery finishes (`ANIMATED`). (2026‑08‑27) |
|
||||||
|
| 11 | **Stage Freeze Abstraction** — Sandbox Stage EDIT‑mode freezing is type‑specific: `RigidBody2D.freeze_mode = FREEZE_MODE_KINEMATIC` for props, `StickmanRig.set_ragdoll(false)` for stickmen, nothing for `StaticBody2D` terrain. There is no unified "freeze" abstraction over the mixed physics population. | Low | Open | A future physics type (e.g. `Area2D`‑based sensors) will need another case in `scripts/sandbox_stage.gd` `_enter_edit_mode()` / `_enter_play_mode()`. Consider a duck‑typed `set_simulating(bool)` interface once more physical object kinds appear. (2026‑08‑27) |
|
||||||
|
| 12 | **Stage AABB Selection Precision** — `StageSelection.get_world_aabb` uses conservative world‑space AABBs (polygon point union / fixed rig rect), not point‑in‑polygon. | Low | Open | Clicks in the bounding‑box corners of large or rotated terrain may select a block even outside its polygon, and overlapping blocks can mis‑select. Refine with `Geometry2D.is_point_in_polygon()` for `TerrainBlock`/`PropBlock` polygons (and circle distance for ball props) once selection precision matters. (2026‑08‑27) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ This document tracks known technical debt, optimization opportunities, and minor
|
|||||||
| ---------- | -------------------------------------------------------------- |
|
| ---------- | -------------------------------------------------------------- |
|
||||||
| 2026-08-26 | Initial creation — migrated observations from Phase 10 review. |
|
| 2026-08-26 | Initial creation — migrated observations from Phase 10 review. |
|
||||||
| 2026-08-27 | Phase 11 resolved #6 (recovery starting pose), #7 (transition visual pop), #8 (rest timeout UI), #9 (animation generation DRY); #10 (proxy re‑addition) remains open with updated scope. Later revision: stand‑up recovery switched from bridge‑into‑baked‑animation to a direct marker tween (captured pose → `STAND_POSE`), fixing a visible jump; baked `stand_up` kept as authored reference only. Second revision: ragdoll entry builds from the current solved bone positions (IK disabled only after the blend completes) and the recovery snap derives joint ends from capsule half‑heights with Torso `bone_angle` compensation, fixing the entry pose‑pop and the "recovery starts lying" bugs. Third revision: the entire crossfade/blend (`transition_duration`, `BlendDirection`, opacity fade, softness ramp) was **removed on director feedback** — entry is now an instant handoff (build at current pose → hide `Body/*` → disable IK in one call), since the ragdoll spawns at the identical pose and a fade only read as ghosting. |
|
| 2026-08-27 | Phase 11 resolved #6 (recovery starting pose), #7 (transition visual pop), #8 (rest timeout UI), #9 (animation generation DRY); #10 (proxy re‑addition) remains open with updated scope. Later revision: stand‑up recovery switched from bridge‑into‑baked‑animation to a direct marker tween (captured pose → `STAND_POSE`), fixing a visible jump; baked `stand_up` kept as authored reference only. Second revision: ragdoll entry builds from the current solved bone positions (IK disabled only after the blend completes) and the recovery snap derives joint ends from capsule half‑heights with Torso `bone_angle` compensation, fixing the entry pose‑pop and the "recovery starts lying" bugs. Third revision: the entire crossfade/blend (`transition_duration`, `BlendDirection`, opacity fade, softness ramp) was **removed on director feedback** — entry is now an instant handoff (build at current pose → hide `Body/*` → disable IK in one call), since the ragdoll spawns at the identical pose and a fade only read as ghosting. |
|
||||||
|
| 2026-08-27 | Sandbox Stage Builder (Phase 2) added `scripts/sandbox_stage.gd` + `stage_spawner.gd` / `stage_selection.gd` / `stage_gizmos.gd` + `scenes/sandbox_stage.tscn`. Logged #11 (no unified freeze abstraction over the mixed `StaticBody2D` / `RigidBody2D` / `Node2D` population) and #12 (selection hit‑testing uses world‑space AABBs rather than point‑in‑polygon). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
# Sandbox Stage Builder
|
||||||
|
## 1. Overview & Objective
|
||||||
|
|
||||||
|
**This Phase (Phase 2)** builds the foundation for the kid‑friendly director sandbox: a **visual stage** where users can place terrain, props, and stickmen using a simple palette, then switch between **Edit Mode** (building) and **Play Mode** (physics simulation). This phase focuses on the **"Stage Builder"** experience, setting the stage for later phases that add character actions and story logic.
|
||||||
|
|
||||||
|
The stage is designed to be **extendable** so that future phases (Action Queue, Triggers, Save/Load) can plug in without major rewrites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope
|
||||||
|
|
||||||
|
### 2.1. What’s Included
|
||||||
|
- **Mode Management:** Toggle between `EDIT` (placement/transform) and `PLAY` (physics simulation).
|
||||||
|
- **Object Spawning:** Palette buttons for placing terrain blocks (Ground, Ramp, Step), dynamic props (Crate, Ball), and Stickman rigs.
|
||||||
|
- **Selection & Hover:** Click to select, hover to highlight, and **box selection** for mass operations.
|
||||||
|
- **Transform Gizmos:** Translate and rotate handles for selected objects (visible only in Edit Mode).
|
||||||
|
- **Deletion:** Delete selected objects via the Delete/Backspace key.
|
||||||
|
- **Camera:** Pan (middle‑mouse) and zoom (mouse wheel) with configurable zoom limits.
|
||||||
|
- **Status Bar:** Shows current mode, object count, and selection info.
|
||||||
|
- **Signals:** Core events (`mode_changed`, `object_placed`, `object_selected`, `object_deleted`) for future extension.
|
||||||
|
|
||||||
|
### 2.2. What’s NOT Included (Future Phases)
|
||||||
|
- **Stickman navigation / pathfinding** (Phase 3 — Action Queue)
|
||||||
|
- **Speech bubbles / dialogue** (Phase 3)
|
||||||
|
- **Area triggers / sensors** (Phase 4)
|
||||||
|
- **Scene save/load** (Phase 5)
|
||||||
|
- **Graphical UI polish** (big toggle buttons, icon‑based palette — Phase 5)
|
||||||
|
- **IK foot placement / slope alignment** (post‑Phase 3)
|
||||||
|
|
||||||
|
**Important:** Ramps and stairs can be **placed** and physics (props/ragdolls) will slide on them, but stickmen will **not** autonomously walk up/down them until Phase 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architecture
|
||||||
|
|
||||||
|
### 3.1. Scene Hierarchy
|
||||||
|
res://scenes/sandbox_stage.tscn
|
||||||
|
│
|
||||||
|
├── SandboxStage (Node2D) ← Root script (class_name SandboxStage)
|
||||||
|
│ │
|
||||||
|
│ ├── World (Node2D) ← Parent container for ALL spawned objects
|
||||||
|
│ │ (TerrainBlocks, PropBlocks, StickmanRigs)
|
||||||
|
│ │
|
||||||
|
│ ├── GizmoLayer (Node2D) ← Draws selection outlines + transform handles
|
||||||
|
│ │ (visible only in EDIT mode)
|
||||||
|
│ │
|
||||||
|
│ ├── Camera2D ← Pan/zoom camera
|
||||||
|
│ │
|
||||||
|
│ ├── UI (CanvasLayer) ← Overlay UI
|
||||||
|
│ │ ├── TopBar (HBoxContainer)
|
||||||
|
│ │ │ ├── ModeToggle (Button) ← "Edit" ↔ "Play" (basic text)
|
||||||
|
│ │ │ ├── SpawnPalette (HBoxContainer)
|
||||||
|
│ │ │ │ ├── "Ground" (Button)
|
||||||
|
│ │ │ │ ├── "Ramp" (Button)
|
||||||
|
│ │ │ │ ├── "Step" (Button)
|
||||||
|
│ │ │ │ ├── "Crate" (Button)
|
||||||
|
│ │ │ │ ├── "Ball" (Button)
|
||||||
|
│ │ │ │ └── "Stickman" (Button)
|
||||||
|
│ │ │ └── StatusBar (Label) ← "Mode: EDIT | Objects: 12 | Selected: Crate"
|
||||||
|
│ │ └── (Future: Action Timeline, etc.)
|
||||||
|
│ │
|
||||||
|
│ └── Spawner (Node) ← Container for spawner logic (StageSpawner)
|
||||||
|
|
||||||
|
text
|
||||||
|
|
||||||
|
### 3.2. Scripts (Class Names)
|
||||||
|
|
||||||
|
| Script | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `sandbox_stage.gd` (`class_name SandboxStage`) | Root controller. Manages state, selection, mode, signals. |
|
||||||
|
| `stage_spawner.gd` (`class_name StageSpawner`, `extends RefCounted`) | Registry of spawnable types + factory methods. Reuses `TerrainUtils`, `PropUtils`, `StickmanFactory`. |
|
||||||
|
| `stage_selection.gd` (`class_name StageSelection`, `extends RefCounted`) | Handles hover, click selection, box selection, and selection signals. |
|
||||||
|
| `stage_gizmos.gd` (`class_name StageGizmos`, `extends Node2D`) | Draws and handles translate/rotate gizmos. |
|
||||||
|
|
||||||
|
### 3.3. State Management
|
||||||
|
|
||||||
|
- `enum StageMode { EDIT, PLAY }`
|
||||||
|
- `var current_mode: StageMode` (managed by root script)
|
||||||
|
- `var selected_objects: Array[Node2D]` (primary selection is index 0 for gizmos)
|
||||||
|
- **No global singleton** — all state is local to the `SandboxStage` instance, making it self‑contained and testable.
|
||||||
|
|
||||||
|
### 3.4. Signals
|
||||||
|
|
||||||
|
| Signal | Payload | Emitted When |
|
||||||
|
|--------|---------|--------------|
|
||||||
|
| `mode_changed(mode: int)` | `StageMode` enum | Mode toggle. |
|
||||||
|
| `object_placed(node: Node2D)` | Reference to placed object | After successful spawn. |
|
||||||
|
| `object_selected(nodes: Array[Node2D])` | Array of selected nodes | Selection changes. |
|
||||||
|
| `object_deselected()` | (none) | Selection cleared. |
|
||||||
|
| `object_deleted(nodes: Array[Node2D])` | Array of deleted nodes | After deletion. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Features & Design Notes
|
||||||
|
|
||||||
|
### 4.1. Mode Management
|
||||||
|
- Toggle between **EDIT** (build) and **PLAY** (simulate).
|
||||||
|
- In `EDIT`: physics is frozen (`RigidBody2D.freeze = true`), gizmos visible, selection active.
|
||||||
|
- In `PLAY`: physics unfrozen, gizmos hidden, selection disabled.
|
||||||
|
|
||||||
|
### 4.2. Object Spawning
|
||||||
|
- Uses a **registry dictionary** in `StageSpawner` — adding a new object type is as simple as appending an entry (no hard‑coded `match` statements).
|
||||||
|
- **Placement mode:** Click a palette button → next click on the `World` spawns the object.
|
||||||
|
- Repeated placement stays active until user presses **Escape** or clicks a different palette button.
|
||||||
|
- Spawn position uses the mouse world position (projected from the camera).
|
||||||
|
|
||||||
|
### 4.3. Selection (EDIT Mode Only)
|
||||||
|
- **Hover:** Subtle highlight/outline on the object under the mouse.
|
||||||
|
- **Single click:** Selects an object (deselects previous).
|
||||||
|
- **Box selection:** Click + drag on empty space draws a rectangle. Release selects all objects inside.
|
||||||
|
- Hold **Shift** to add to current selection instead of replacing.
|
||||||
|
- **Primary selection:** The first selected object (or the one clicked last) receives the transform gizmos.
|
||||||
|
|
||||||
|
### 4.4. Transform Gizmos (Primary Selection Only)
|
||||||
|
- **Translate:** Cross/directional handle — drag to move the object in world space.
|
||||||
|
- **Rotate:** Circular handle — drag to rotate the object around its center.
|
||||||
|
- Gizmos are implemented as `Area2D` nodes so they intercept mouse events (prevents accidental deselection or spawning).
|
||||||
|
- Gizmos are completely hidden in `PLAY` mode.
|
||||||
|
|
||||||
|
### 4.5. Deletion
|
||||||
|
- Press **Delete** or **Backspace** key to remove all currently selected objects.
|
||||||
|
- Deletion emits `object_deleted` with the list of removed nodes.
|
||||||
|
- Objects are `queue_free()`d — no orphaned nodes.
|
||||||
|
|
||||||
|
### 4.6. Camera
|
||||||
|
- **Pan:** Middle‑mouse drag.
|
||||||
|
- **Zoom:** Mouse wheel.
|
||||||
|
- Zoom min/max are `@export var` constants (`min_zoom = 0.1`, `max_zoom = 6.0`) in the root script, so they can be easily adjusted later.
|
||||||
|
- Camera persists across mode toggles.
|
||||||
|
|
||||||
|
### 4.7. Status Bar
|
||||||
|
- Shows: current mode, total object count, and selected object name/count.
|
||||||
|
- Updates in real time on selection, spawning, deletion, and mode change.
|
||||||
|
|
||||||
|
### 4.8. UI Polish (Future Phase 5)
|
||||||
|
- The Phase 2 UI uses **functional text buttons**.
|
||||||
|
- In Phase 5, these will be replaced with:
|
||||||
|
- A large, graphical **Mode Toggle** (slider/switch style).
|
||||||
|
- An icon‑based **Spawn Palette** with draggable cards (drag‑and‑drop onto the stage).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Acceptance Criteria
|
||||||
|
|
||||||
|
### A. Mode Management
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| A1 | A **Mode Toggle Button** switches between `EDIT` and `PLAY`. | Click; label changes. |
|
||||||
|
| A2 | In `EDIT` mode, **all RigidBody2D props are frozen**. | Place a PropBlock → PLAY → falls. Switch to EDIT → freezes. |
|
||||||
|
| A3 | In `PLAY` mode, **gizmos are hidden** and **selection is disabled**. | Select object in EDIT → PLAY → no selection box; clicks do nothing. |
|
||||||
|
| A4 | `mode_changed` signal is emitted on every toggle. | Connect a test print. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Object Spawning
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| B1 | Spawn Palette contains: **Ground**, **Ramp**, **Step**, **Crate**, **Ball**, **Stickman**. | UI shows all six. |
|
||||||
|
| B2 | Clicking a palette button **enters placement mode**. | Click "Crate" → click world → crate appears. |
|
||||||
|
| B3 | **Repeated placement** stays active until Esc or different button. | Click "Crate" → click twice → two crates. |
|
||||||
|
| B4 | Uses existing factories: `TerrainUtils`, `PropUtils`, `StickmanFactory`. | Inspect spawned object properties. |
|
||||||
|
| B5 | `object_placed` signal is emitted. | Connect a test print. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### C. Selection (Hover, Click, Box‑Select) — EDIT Only
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| C1 | **Hover** highlights the object under the mouse. | Hover over a crate → it glows. |
|
||||||
|
| C2 | **Single Click** selects an object (deselects previous). | Click a crate → selection box appears. |
|
||||||
|
| C3 | **Deselect** by clicking empty space. | Click empty space → selection disappears. |
|
||||||
|
| C4 | Only **one primary selection** for gizmos. | Select crate → click ball → only ball has gizmos. |
|
||||||
|
| C5 | **Box Selection:** Click + drag draws a rectangle; release selects all objects inside. | Drag box around three crates → all selected. |
|
||||||
|
| C6 | **Shift+Box** adds to selection (does not replace). | Select A (click) → Shift+Box-select B & C → A, B, C selected. |
|
||||||
|
| C7 | `object_selected` and `object_deselected` signals are emitted. | Connect and verify. |
|
||||||
|
| C8 | **StatusBar** updates with selection info. | Box-select three crates → status shows "Selected: 3 objects". |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### D. Transform Gizmos (EDIT Only — Primary Selection)
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| D1 | **Translate handle** appears around the selected object. | Select a crate → see move handle. |
|
||||||
|
| D2 | Dragging translate handle moves the object smoothly. | Drag → crate follows mouse. |
|
||||||
|
| D3 | **Rotate handle** appears around the selected object. | Select a crate → see rotation ring. |
|
||||||
|
| D4 | Dragging rotate handle rotates the object around its center. | Drag ring → crate spins. |
|
||||||
|
| D5 | Gizmos **do not appear** in PLAY mode. | Switch to PLAY → gizmos vanish. |
|
||||||
|
| D6 | Gizmos are **hit‑testable** (clicking them does NOT deselect or spawn). | Click move handle → object stays selected. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### E. Deletion (EDIT Only)
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| E1 | Pressing **Delete** or **Backspace** removes selected object(s). | Select a crate → press Delete → gone. |
|
||||||
|
| E2 | **Multiple deletion** works with box selection. | Box-select three crates → Delete → all gone. |
|
||||||
|
| E3 | `object_deleted` signal is emitted (array of nodes). | Connect and verify. |
|
||||||
|
| E4 | No orphaned nodes remain (`queue_free()` called). | Check `World` child count. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F. Camera & Viewport
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| F1 | **Pan:** Middle‑mouse drag pans the view. | Drag → camera moves. |
|
||||||
|
| F2 | **Zoom:** Mouse wheel zooms in/out within `min_zoom`/`max_zoom` (exported). | Scroll within bounds. Edit constants → bounds update. |
|
||||||
|
| F3 | Camera does **not** reset on mode toggle. | Zoom/pan → toggle modes → view persists. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### G. Status Bar
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| G1 | StatusBar displays **Mode**, **Object Count**, and **Selection Info**. | Place 5 objects → "Objects: 5". Select one → "Selected: Crate". |
|
||||||
|
| G2 | StatusBar updates in real time. | Click, spawn, delete → label updates instantly. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### H. Performance & Stability
|
||||||
|
|
||||||
|
| # | Criterion | How to Test |
|
||||||
|
|---|-----------|-------------|
|
||||||
|
| H1 | Spawning 50+ objects does not drop frames. | Click "Crate" 50 times → observe FPS. |
|
||||||
|
| H2 | Rapid toggling between EDIT and PLAY does not crash. | Toggle quickly → no errors. |
|
||||||
|
| H3 | No `push_warning` or errors in the console during normal use. | Monitor Output panel. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### I. Extendability (Design)
|
||||||
|
|
||||||
|
| # | Criterion | Evidence |
|
||||||
|
|---|-----------|----------|
|
||||||
|
| I1 | Adding a new spawnable type requires **no modification** to `SandboxStage` — only a registry entry. | Code review: `StageSpawner` uses a Dictionary, not `match`. |
|
||||||
|
| I2 | `World` can hold **any** `Node2D`‑derived object. | Place a custom node → works. |
|
||||||
|
| I3 | Gizmos use `global_position`/`global_rotation` → work for any object. | Select a TerrainBlock → gizmos appear and move it. |
|
||||||
|
| I4 | Signals provide hooks for future systems (Save/Load, Action Queue, Triggers). | Code review: signals exist for all major events. |
|
||||||
|
| I5 | UI palette is built from data, not hard‑coded buttons. | Adding a button = appending to an array. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Future Considerations
|
||||||
|
|
||||||
|
### 6.1. Ramp / Stair Dynamics
|
||||||
|
| Feature | Phase 2 | Phase 3+ |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| Place ramp/step terrain | ✅ | ✅ |
|
||||||
|
| Props roll/fall on ramps | ✅ (physics) | ✅ |
|
||||||
|
| Ragdoll tumbles on ramps | ✅ (physics) | ✅ |
|
||||||
|
| Stickman **walks up** ramp | ❌ | ✅ (NavigationAgent2D) |
|
||||||
|
| Stickman **navigates** stairs | ❌ | ✅ (NavigationAgent2D + IK) |
|
||||||
|
|
||||||
|
### 6.2. UI Polish (Phase 5)
|
||||||
|
- Replace the text‑based Mode Toggle with a **large, kid‑friendly graphical switch/slider**.
|
||||||
|
- Replace text palette buttons with **icon‑based draggable cards** (drag‑and‑drop onto the stage).
|
||||||
|
- Add tooltips and animations for feedback.
|
||||||
|
|
||||||
|
### 6.3. Box Selection Enhancements (Post‑Phase 2)
|
||||||
|
- Option to **invert** selection (select all except those inside box).
|
||||||
|
- **Lock** selected objects (prevent accidental moves).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Summary of Deliverables
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `res://scenes/sandbox_stage.tscn` | The main stage scene. |
|
||||||
|
| `res://scripts/sandbox_stage.gd` | Root controller (`class_name SandboxStage`). |
|
||||||
|
| `res://scripts/stage_spawner.gd` | Spawn registry + factory (`class_name StageSpawner`). |
|
||||||
|
| `res://scripts/stage_selection.gd` | Selection logic (`class_name StageSelection`). |
|
||||||
|
| `res://scripts/stage_gizmos.gd` | Gizmo rendering + interaction (`class_name StageGizmos`). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Acceptance Sign‑Off Checklist
|
||||||
|
|
||||||
|
- [ ] Mode toggle switches between EDIT and PLAY.
|
||||||
|
- [ ] Physics freezes/unfreezes correctly.
|
||||||
|
- [ ] All six spawnable types can be placed.
|
||||||
|
- [ ] Repeated placement works.
|
||||||
|
- [ ] Hover highlights objects.
|
||||||
|
- [ ] Single‑click selection works.
|
||||||
|
- [ ] Box selection works (with Shift add‑to‑selection).
|
||||||
|
- [ ] Translate gizmo works.
|
||||||
|
- [ ] Rotate gizmo works.
|
||||||
|
- [ ] Delete key removes selected objects.
|
||||||
|
- [ ] Camera pans and zooms within configurable limits.
|
||||||
|
- [ ] StatusBar updates correctly.
|
||||||
|
- [ ] All signals are emitted.
|
||||||
|
- [ ] No errors/warnings in console.
|
||||||
|
- [ ] Performance is acceptable (50+ objects).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Phase 2 Plan*
|
||||||
@@ -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="."]
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dkhhg81ft70mm
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dcwylgjtosys7
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dpyu1lyn6evw7
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c52c38mrywjpi
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c4adnnx5m3qss
|
||||||
@@ -436,6 +436,35 @@ func request_recovery() -> void:
|
|||||||
_start_recovery()
|
_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
|
## Applies the same velocity delta to every ragdoll body via a mass-scaled
|
||||||
## central impulse, preserving the ragdoll's internal structure. No-op outside
|
## central impulse, preserving the ragdoll's internal structure. No-op outside
|
||||||
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
|
## RAGDOLL mode. Used by the physics harness "Knock Up" button.
|
||||||
|
|||||||
@@ -233,9 +233,14 @@ static func _mount_shapes(stk_data: Dictionary, rig: Node2D) -> void:
|
|||||||
var part_data: Variant = body_parts.get(part_name, {})
|
var part_data: Variant = body_parts.get(part_name, {})
|
||||||
if part_data is Dictionary:
|
if part_data is Dictionary:
|
||||||
var pd := part_data as 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:
|
if shapes_var is Array:
|
||||||
shapes = shapes_var as 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
|
# Phase 9 Round 3: the preview's per-part rotation (degrees) and
|
||||||
# scale about the bbox center are applied to the mounted geometry.
|
# scale about the bbox center are applied to the mounted geometry.
|
||||||
rotation_deg = float(pd.get("rotation", 0.0))
|
rotation_deg = float(pd.get("rotation", 0.0))
|
||||||
|
|||||||
Reference in New Issue
Block a user