feat: Add project roadmap and initial specifications for dynamic vector props and terrain system

- Created ROADMAP.md outlining core features, implementation phases, and detailed breakdown for the Stickman Sandbox Builder project.
- Introduced plans for dynamic vector props with `PropBlock` specification, including reusable components and utility functions for prop creation.
- Developed a vector terrain system plan detailing the reusable `TerrainBlock` component and associated utility functions for geometry handling.
- Implemented `PhysicsTestHarness` scene for testing physics interactions with dynamic props and terrain.
- Added scripts for `PropBlock`, `PropUtils`, `TerrainBlock`, and `TerrainUtils` to support dynamic prop creation and terrain management.
This commit is contained in:
2026-08-26 09:35:09 -04:00
parent 4d490eef6c
commit 6edf3e53e3
16 changed files with 1139 additions and 0 deletions
+64
View File
@@ -365,6 +365,63 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
persistence to disk. The "Facing" menu and all animation controls are **hidden until an .stk is
loaded** (`_set_rig_controls_visible(false)` at the end of `_build_ui()` and in
`_free_current_rig()`; shown on successful spawn in `_load_and_spawn()`).
- `scripts/terrain_block.gd` — `class_name TerrainBlock`, `extends StaticBody2D`; a **reusable
vector terrain component** (Vector Terrain System, **not used by the editor**). Builds its three
children in code: `Polygon2D` (interior fill, `fill_color`), `Line2D` (crisp vector border,
`outline_color`/`outline_width`, auto-closed loop by appending the first vertex to the end,
`LINE_JOINT_ROUND` + round caps), and `CollisionPolygon2D` (`BUILD_SOLIDS` solid decomposition —
supports concave blocks). Exported properties: `polygon_points: PackedVector2Array`,
`fill_color`, `outline_color`, `outline_width`; a unified setter pushes vertex changes to all
three children live (no manual rebuilds).
- `scripts/terrain_utils.gd` — `class_name TerrainUtils`, `extends RefCounted`; static utility
(**not used by the editor**):
- `sanitize_points(points: PackedVector2Array, grid_size: float = 16.0) -> PackedVector2Array` —
sanitization pipeline in order: grid snap → redundancy removal via a **local
`_simplify_polyline()`** (Godot 4.7 has **no `Geometry2D.simplify_polyline()`**; the local
version drops consecutive duplicates, a closing duplicate when `last == first`, and collinear
vertices) → clockwise enforcement via `Geometry2D.is_polygon_clockwise()` (reverses if false,
guaranteeing clockwise output).
- `spawn_block(...)` — factory that sanitizes raw input vectors (`sanitize_points`), creates a
`TerrainBlock`, applies the cleaned points, and adds it to the target container.
- `scripts/prop_block.gd` — `class_name PropBlock`, `extends RigidBody2D`; a **reusable
dynamic vector prop component** (Dynamic Vector Props, **not used by the editor**), `@tool`.
Builds its children in code: `Polygon2D` (interior fill, `fill_color`), `Line2D` (crisp
vector outline, `outline_color`/`outline_width`, auto-closed loop by appending the first
vertex, `LINE_JOINT_ROUND` + `LINE_CAP_ROUND`), and a collision node — `CollisionPolygon2D`
(`BUILD_SOLIDS`) in `POLYGON` mode, or `CollisionShape2D` + `CircleShape2D` (48-segment
radial loop, `CIRCLE_SEGMENTS = 48`) in `CIRCLE` mode, toggled via `shape_type`. Exported
properties: `shape_type` (`@export_enum("Polygon","Circle")`), `polygon_points:
PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_width`, and
`material_preset` (`@export_enum("None","Wood","Rubber","Cardboard","Metal")`). Presets set
`mass` + `physics_material_override` via static `mass_for()`/`physics_material()`/`tint_for()`
(Wood: mass 3.0, friction 0.6, bounce 0.1; Rubber: mass 0.5, friction 0.9, bounce 0.85;
Cardboard: mass 0.4, friction 0.3, bounce 0.05; Metal: mass 8.0, friction 0.9, bounce 0.0;
None: mass 1.0, friction 0.5, bounce 0.05) and recolor fill/outline for non-`NONE`. Unified
live-update setters push geometry/color changes to all children (null-guarded for `@tool`
editor safety); `_apply_shape()` enables exactly one collision node.
- `scripts/prop_utils.gd` — `class_name PropUtils`, `extends RefCounted`; static factory
(**not used by the editor**):
- `create_box(size := Vector2(48,48))` / `create_ball(radius := 24.0)` /
`create_plank(length := 160.0, thickness := 16.0)` / `create_triangle(base := 56.0, height
:= 48.0)` — primitive generators returning shape-payload dictionaries with default
dimensions + color themes (wood/rubber/metal/cardboard).
- `spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity
:= Vector2.ZERO) -> PropBlock` — instantiates a `PropBlock`, applies the payload
(`shape_type` + geometry + colors via `_apply_shape_payload()`, sanitizing polygon points
through `TerrainUtils.sanitize_points()`), sets `linear_velocity` after `add_child` (only
when non-zero), and returns the spawned prop.
- `scripts/physics_test_harness.gd` — `class_name PhysicsTestHarness`, `extends Node2D`; standalone
staging scene root (Vector Terrain System / Dynamic Vector Props, **not wired into the editor**;
run via **F6** on `res://scenes/physics_test_harness.tscn`). Builds flat ground, angled ramps, and stepped
`TerrainBlock` instances via `TerrainUtils`, instantiates `res://master_rig.tscn` standing on the
flat ground, and handles camera input. **Keys 1/2/3** spawn dynamic props above the angled ramp
via `PropUtils.spawn_prop()` (`PROP_SPAWN_POSITION = (300, -300)`): **1** Wood Crate
(`create_box()`, `WOOD`, velocity `(60,0)`), **2** Bouncy Ball (`create_ball()`, `RUBBER`,
`(-80,0)`), **3** Heavy Plank (`create_plank()`, `METAL`, `(30,-40)`). Adds a **best-effort
`StaticBody2D` collision proxy** (`RigCollisionProxy`, `_add_rig_collision_proxy()`) since the
rig has **no physics bodies of its own** — a 240×1000 px `RectangleShape2D` centered at `(0,-500)`
(`RIG_PROXY_SIZE`/`RIG_PROXY_CENTER`) matching the standing figure's world bounds, so props
bounce/rest against it; the proxy is a code-only stand-in, not part of the rig.
- Scenes:
- `scenes/stickman_editor.tscn` — main editor layout; unique-name nodes (`%Prefix`) used
for typed `@onready` access: `%MenuBar`, `%StickmanNameEdit`, `%LeftColumn`,
@@ -382,6 +439,13 @@ assembled in a "Whole Stickman" preview that supports translation, rotation, and
loaded-filename status label;
`SubViewport` world with an enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on
spawn); interactive limb IK via `SkeletonModificationStack2D` TwoBoneIK.
- `scenes/physics_test_harness.tscn` — **standalone staging scene** (Vector Terrain System /
Dynamic Vector Props, not wired into the editor; run via **F6**). Backed by
`scripts/physics_test_harness.gd`. Root `Node2D` + script, `Camera2D` at position `(0, -400)`
zoom `0.5`, empty Environment container. Wheel-zoom scales between `0.25x` and `3.0x`
(resolution independence / vector outline thickness); middle-drag pans. Keys **1/2/3** spawn
dynamic props (`PropUtils`) above the angled ramp; a best-effort `StaticBody2D` rig collision
proxy provides a surface for props to bounce/rest against.
### Body-part data model
- 10 internal part keys (ordered): `head`, `torso`, `left_upper_arm`, `left_lower_arm`,
+86
View File
@@ -267,6 +267,86 @@ The factory is the intended runtime API: `StickmanFactory.spawn("res://stickmen/
Debug overlay (a world-space `Node2D` `_draw()`): true bone **segments** drawn between each `Bone2D` origin and its Bone2D children (color-coded left cyan / right orange / central white, with a joint dot per bone), with limb leaf bones drawn out to their IK targets so the forearm/shin segments and wrist/ankle joints are visible (Phase 9 Round 2) and the **Head** leaf drawn along the bone's own direction (~90 px, since its IK target is a LookAt aim point, not a joint) (Phase 9 Round 3), when **Show Bones** is on; colored markers at the six IK targets — hands green, feet blue, head **yellow**, torso **magenta** (Phase 9 Round 7) — plus a semi-transparent yellow aim line from the Head bone to the head marker, when **Show IK Handles** is on. Each load frees the previous rig and spawns a fresh one.
### 15. Vector Terrain System
The **Vector Terrain System** is a standalone, reusable component for building **crisp, resolution-independent vector terrain** (flat ground, angled ramps, stepped platforms) — with matching 2D collision. It is **not wired into the editor**; it is exercised through a dedicated staging scene run via **F6**.
**`TerrainBlock` (`res://scripts/terrain_block.gd`)** — `@tool` `class_name TerrainBlock`, `extends StaticBody2D`. A single terrain segment that builds its three children in code:
| Child | Node type | Purpose |
|---|---|---|
| Interior fill | `Polygon2D` | Fills the block's interior with `fill_color`. |
| Vector border | `Line2D` | Crisp outline using `outline_color` / `outline_width`; auto-closes the loop by appending the first vertex to the end, with `LINE_JOINT_ROUND` and round caps. |
| Collision | `CollisionPolygon2D` | Physical body; `BUILD_SOLIDS` solid decomposition (supports **concave** blocks). |
**Exported properties** (`polygon_points: PackedVector2Array`, `fill_color`, `outline_color`, `outline_width`) are driven by a single unified setter that pushes vertex changes to all three children live — no manual rebuilds.
**`TerrainUtils` (`res://scripts/terrain_utils.gd`)** — `class_name TerrainUtils`, `extends RefCounted`, static utility.
- `sanitize_points(points: PackedVector2Array, grid_size: float = 16.0) -> PackedVector2Array` — sanitizes a raw vertex list through a fixed pipeline, in order:
1. **Grid snap** — rounds each vertex to the `grid_size` grid (default **16.0**).
2. **Redundancy removal** — a local `_simplify_polyline()` (Godot 4.7 has **no** `Geometry2D.simplify_polyline()`), which drops consecutive duplicates, a closing duplicate when `last == first`, and collinear vertices.
3. **Clockwise enforcement**`Geometry2D.is_polygon_clockwise()` reverses the winding if it is not already clockwise, **guaranteeing clockwise output**.
- `spawn_block(...)` — factory that sanitizes the raw input vectors (`sanitize_points`), creates a `TerrainBlock`, applies the cleaned points, and adds it to the target container.
**`physics_test_harness.tscn` / `scripts/physics_test_harness.gd`** — `class_name PhysicsTestHarness`, `extends Node2D`; a **standalone staging scene** (run via **F6**; not wired into the editor). It builds flat ground, angled ramps, and stepped `TerrainBlock` instances via `TerrainUtils`, then instantiates `res://master_rig.tscn` standing on the flat ground. The scene root is a `Node2D` + script with a `Camera2D` at position `(0, -400)` zoom `0.5` and an empty `Environment` container. The camera wheel-zoom scales between **0.25x and 3.0x** (to test resolution independence / vector outline thickness); middle-drag pans. It also hosts the **Dynamic Vector Props** spawner (see below): press **1/2/3** to drop physics props above the angled ramp, plus a best-effort `StaticBody2D` collision proxy for the rig (which has no physics bodies of its own).
### 16. Dynamic Vector Props
The **Dynamic Vector Props** system is a standalone, reusable component for building **physical, dynamic props** (crates, balls, planks) as `RigidBody2D` bodies with crisp vector rendering and matching collision. It is **not wired into the editor**; it is exercised through the physics test harness staging scene via **F6**.
**`PropBlock` (`res://scripts/prop_block.gd`)** — `@tool` `class_name PropBlock`, `extends RigidBody2D`. A single physical prop whose children are built in code, `@tool`-safe (null-guarded for editor safety):
| Child | Node type | Purpose |
|---|---|---|
| Interior fill | `Polygon2D` | Fills the prop's interior with `fill_color`. |
| Vector outline | `Line2D` | Crisp outline using `outline_color` / `outline_width`; auto-closes the loop by appending the first vertex to the end, with `LINE_JOINT_ROUND` / `LINE_CAP_ROUND`. |
| Collision (polygon mode) | `CollisionPolygon2D` | `BUILD_SOLIDS` solid decomposition; used for `POLYGON` shape type. |
| Collision (circle mode) | `CollisionShape2D` + `CircleShape2D` | Radial 48-segment loop (`CIRCLE_SEGMENTS = 48`); used for `CIRCLE` shape type. |
**Exported properties:**
| Property | Type | Default | Behavior |
|---|---|---|---|
| `shape_type` | `@export_enum("Polygon","Circle")` | `POLYGON` | Selects which geometry + which collision node is enabled (polygon ↔ circle). |
| `polygon_points` | `PackedVector2Array` | empty | Polygon geometry; applies only when `shape_type == POLYGON`. |
| `radius` | `float` | `32.0` | Circle radius; applies only when `shape_type == CIRCLE`. |
| `fill_color` | `Color` | `(0.55, 0.35, 0.15)` | Interior fill. |
| `outline_color` | `Color` | `(0.15, 0.08, 0.02)` | Vector outline. |
| `outline_width` | `float` | `2.0` | Vector outline thickness. |
| `material_preset` | `@export_enum("None","Wood","Rubber","Cardboard","Metal")` | `None` | Sets `mass` + `physics_material_override` (`PhysicsMaterial.friction` / `.bounce`) and themed colors. |
**Material presets** (`mass_for` / `physics_material` / `tint_for` static factories):
| Preset | mass | friction | bounce | Fill tint |
|---|---|---|---|---|
| Wood | `3.0` | `0.6` | `0.1` | `(0.55, 0.38, 0.2)` |
| Rubber | `0.5` | `0.9` | `0.85` | `(0.9, 0.2, 0.2)` |
| Cardboard | `0.4` | `0.3` | `0.05` | `(0.85, 0.72, 0.45)` |
| Metal | `8.0` | `0.9` | `0.0` | `(0.5, 0.55, 0.6)` |
| None | `1.0` | `0.5` | `0.05` | default fill |
A **unified live-update setter** drives geometry to all children live — a change to `polygon_points` / `radius` / `shape_type` pushes to the `Polygon2D`, `Line2D`, and the active collision node with no manual rebuilds. Non-`NONE` presets also recolor `fill_color` and `outline_color` (outline = `tint_for(preset).darkened(0.55)`).
**`PropUtils` (`res://scripts/prop_utils.gd`)** — `class_name PropUtils`, `extends RefCounted`, static factory.
- **Primitive generators** return shape-payload dictionaries with default dimensions + color themes:
- `create_box(size := Vector2(48,48), ...)` — a 4-point `POLYGON` (wood theme).
- `create_ball(radius := 24.0, ...)` — a `CIRCLE` payload (rubber theme).
- `create_plank(length := 160.0, thickness := 16.0, ...)` — a 4-point `POLYGON` (metal theme).
- `create_triangle(base := 56.0, height := 48.0, ...)` — a 3-point `POLYGON` (cardboard theme).
- `spawn_prop(container, position, shape_payload, material_preset := WOOD, initial_velocity := Vector2.ZERO) -> PropBlock` — factory that instantiates a `PropBlock`, applies the payload (`shape_type` + geometry + colors), sets the material preset, sets `linear_velocity` after `add_child` (when non-zero), and returns the spawned prop. Polygon `points` are sanitized through `TerrainUtils.sanitize_points()`.
**Physics test harness controls (extended):** the `physics_test_harness.gd` scene now accepts **1 / 2 / 3** key presses to spawn props above the angled ramp (spawn point `(300, -300)`), tumbling them down the terrain:
| Key | Prop | Primitive | Preset | Initial velocity |
|---|---|---|---|---|
| **1** | Wood Crate | `create_box()` | `WOOD` | `(60, 0)` |
| **2** | Bouncy Ball | `create_ball()` | `RUBBER` | `(-80, 0)` |
| **3** | Heavy Plank | `create_plank()` | `METAL` | `(30, -40)` |
**Rig collision proxy caveat:** the standing `master_rig.tscn` figure has **no physics bodies of its own**, so a best-effort code-only `StaticBody2D` proxy (`RigCollisionProxy`) provides a static collision surface matching the figure's world bounds — a 240×1000 px `RectangleShape2D` box centered at `(0, -500)`. Props bounce/rest against it. The proxy is a stand-in for the rig's eventual physics bodies and is not part of the rig itself.
## 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.
@@ -408,6 +488,12 @@ Behavior:
| `res://scripts/stickman_rig.gd` | **Phase 9 Task 4.** `class_name StickmanRig`, `extends Node2D`; the runtime owner of facing direction, per-joint bone bend, and `Body/*` z-order, 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`). 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`; public API `set_facing_profile`/`get_facing_profile`, `set_joint_bend_flipped`/`get_joint_bend_flipped`, `get_bend_joints()`, `get_bend_joint_global_position()`. Null-guarded (`push_warning` + skip). **Not used by the editor.** |
| `res://scripts/test_harness.gd` | **Phase 9.** Standalone staging scene (run via **F6** on `res://scenes/test_harness.tscn`, not wired into the editor) for debugging bone scales, vector-drawing offsets, and IK limits in isolation. Top UI bar: "Open .stk…" / quick-select buttons (`stickmen/break.stk`, `stickmen/basic.stk`, `stickmen/test.stk`), "Show Bones" / "Show IK Handles" toggles, loaded-filename label. `SubViewport` world + enabled `Camera2D` (middle-mouse pan, wheel zoom, recenter on spawn); each load frees the previous rig and spawns a fresh one via `StickmanFactory.spawn()`. A world-space debug overlay draws true bone segments (joint dots + parent→child lines, with limb leaf bones drawn out to their IK targets so wrist/ankle joints are visible; the **Head** leaf is the exception — its target is a LookAt aim point, not a joint, so it draws a ~90 px segment along the bone's own direction instead) and colored IK-target markers (hands green, feet blue, head yellow, torso magenta) plus a semi-transparent yellow head-aim line; the **6** `Marker2D` IK targets are click-draggable — the 4 limb targets flex limbs live via `SkeletonModificationStack2D` TwoBoneIK (the rig self-enables its stack), the Torso target translates the whole rig via its `RemoteTransform2D`, and the Head target drives the head's LookAt aim rotation (Phase 9 Round 7). |
| `res://scenes/test_harness.tscn` | **Phase 9.** Standalone staging scene backing `scripts/test_harness.gd` (run via **F6**; not wired into the editor). |
| `res://scripts/terrain_block.gd` | **Vector Terrain System.** `class_name TerrainBlock`, `extends StaticBody2D` — a reusable vector terrain component building `Polygon2D` (fill) + `Line2D` (border) + `CollisionPolygon2D` (`BUILD_SOLIDS`, supports concave) children in code. |
| `res://scripts/terrain_utils.gd` | **Vector Terrain System.** `class_name TerrainUtils`, `extends RefCounted` — static `sanitize_points()` (grid snap → local `_simplify_polyline()` → clockwise enforcement) and a `spawn_block()` factory. |
| `res://scripts/physics_test_harness.gd` | **Vector Terrain System / Dynamic Vector Props.** Standalone staging scene root building flat/ramp/step terrain via `TerrainUtils`, instantiating `master_rig.tscn`, spawning props via **1/2/3** (`PropUtils`), and adding a rig collision proxy (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_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/body_part_panel.gd` | Multi-shape creation, vertex editing, shape dragging, per-panel zoom & pan, grid drawing & snap-to-grid, ColorPicker, shape/vertex delete, Z-ordering (Send Back / Bring Forward), shape Copy/Paste, shape Mirror X/Y, drawing (fill + outline for closed shapes). |
| `res://scripts/whole_stickman_preview.gd` | Assembly preview, drag-to-reposition, part selection with white bounding box, rotation gizmo (circle below box) with Ctrl 15° snap, scale gizmo (corner crosses) with Ctrl aspect lock, part Z-ordering (Send Back / Bring Forward) via `part_order`, part Mirror X/Y (scale negation), zoom & pan, grid drawing & snap-to-grid, pose silhouette guide (Phase 7), part hit-bounds, labels, and (Phase 9 Round 5) `get_guide_joint_preview()` — the preview-space position of a guide joint, used by the editor to export per-part `guide_offset`. |
| `res://addons/curved_lines_2d/` | Scalable Vector Shapes 2D addon (v2.27.7) — required dependency. |
+71
View File
@@ -0,0 +1,71 @@
# Stickman Sandbox Builder - Project Roadmap
A kid-friendly (ages 1015) interactive physics sandbox where users build environments, script stickman behaviors, and run live simulations.
---
## 1. Core Feature Breakdown
### Environment & Physics Foundation
* **Terrain Construction:** Static environment pieces (floors, ramps, steps, walls) using `StaticBody2D` with grid snapping and auto-tiling collision boundaries.
* **Dynamic Props:** Rigid-body objects (boxes, balls, platforms) with customizable physical properties (gravity, mass, bounce, friction).
* **Dynamic Ragdoll Engine:** Seamless transition for stickmen between kinematic/animation-driven movement and dynamic `RigidBody2D` ragdoll physics upon impact, falling, or manual triggers.
### Character Action & Directives
* **Point-and-Click Navigation:** Pathfinding and waypoint movement system for walking, running, and climbing to target positions.
* **Action & Speech Queues:** Sequenced action engine allowing stickmen to speak (speech/thought bubbles) and perform contextual animations (jump, wave, dance).
* **IK Environment Interaction:** Contextual IK targeting for gripping props, sitting on ledges, or stepping on uneven terrain.
### Kid-Friendly UI & Canvas
* **Palette Workbench:** Icon-driven sidebar categorization (Terrain, Props, Actors, Logic) for drag-and-drop creation.
* **In-World Context Menus:** Popover radial menus on selected objects for quick actions (e.g., *Set Speech*, *Assign Action*, *Edit Color*, *Toggle Ragdoll*).
* **Simplified Gizmos:** Intuitive, clutter-free handles for scaling, rotating, and moving objects in 2D space.
### Visual Logic & Event Triggers
* **Event-Action Rules:** Simplified cause-and-effect block system (e.g., *When [Stickman] touches [Red Button] → [Drop Box]*).
* **Area Triggers:** Placeable sensor zones (`Area2D`) that fire scene events when characters or props enter/exit.
### Scene Management & State
* **Play / Edit Mode Toggle:** Instant mode switching—**Edit Mode** freezes physics for building; **Play Mode** unfreezes physics and runs interactive behaviors.
* **Save/Load System:** JSON serialization engine to preserve scene layouts, prop positions, customized stickmen, and event scripts.
---
## 2. Recommended Implementation Roadmap
| Phase | Focus | Core Deliverables | Target Architecture |
| :--- | :--- | :--- | :--- |
| **Phase 1** | Physics & Ragdolls | Terrain shapes, prop physics, and Kinematic-to-Ragdoll swap | `RigidBody2D` + `PinJoint2D` |
| **Phase 2** | Play / Edit Engine | World building harness, object spawner, selection & transform gizmos | State Machine & Canvas Layer |
| **Phase 3** | Character Actions | Waypoint pathfinding, speech bubbles, animation/action queuing | `NavigationAgent2D` + Action Runner |
| **Phase 4** | Visual Logic | Sensor zones, trigger-action mapping, interactive props | Event Bus & Logic Nodes |
| **Phase 5** | UX Polish & Saving | Radial context menus, sidebar palette, JSON scene serialization | UI Theme & JSON Parser |
---
## 3. Detailed Phase Breakdown
### Phase 1: Physics & Ragdoll Foundation
1. Build static terrain blocks (`StaticBody2D`) with slope and step collision.
2. Create dynamic prop types (`RigidBody2D`) with mass and surface material presets.
3. Implement ragdoll transition: detach visual bones/rig from animation drivers and bind to physics joint network on trigger.
### Phase 2: Edit vs. Play Sandbox Harness
1. Implement central `GameManager` handling `EDIT` and `PLAY` states.
2. Create object spawner interface allowing drag-and-drop placement from UI into the world.
3. Add hover, selection outline, and 2D transform gizmos (translate, rotate, delete) active during `EDIT` mode.
### Phase 3: Character Command System
1. Integrate `NavigationAgent2D` on stickmen for walking across user-built terrain.
2. Build a directive queue manager (`WalkTo`, `PlayAnim`, `SayText`, `Wait`).
3. Add floating UI speech bubbles tied to the `Head` bone transform.
### Phase 4: Logic & Triggers
1. Create `TriggerArea2D` nodes with visual boundaries visible in Edit Mode.
2. Build lightweight node-based or dropdown event connector (*Trigger* -> *Target* -> *Action*).
3. Implement interactive props like buttons, levers, and trapdoors.
### Phase 5: UI Refinement & Persistence
1. Build radial popover context menu for selected objects.
2. Polish UI layout, visual feedback, and sound effects for kid accessibility.
3. Implement full scene save/load pipeline targeting `.json` format.
+40
View File
@@ -0,0 +1,40 @@
# Specification: Dynamic Vector Props (`PropBlock`)
**Objective:** Create a reusable, dynamic physics prop framework (`PropBlock`) using `RigidBody2D` with resolution-independent vector visuals (`Polygon2D`, `Line2D`) and configurable physics material profiles (mass, friction, bounce) that interact with `TerrainBlock` instances.
---
## 1. Reusable `PropBlock` Component (`RigidBody2D`)
- **Node Hierarchy:** A root `RigidBody2D` with three direct children: `Polygon2D` for interior visual fill, `Line2D` for crisp vector borders, and a physics collision node (`CollisionPolygon2D` for custom polygons or `CollisionShape2D` with a `CircleShape2D` for spheres).
- **Script Architecture:** `class_name PropBlock`, `extends RigidBody2D` equipped with `@tool` mode for live editor previewing.
- **Exported Properties:**
- **Shape Parameters:** Shape type selector (Polygon vs. Circle), `polygon_points` (`PackedVector2Array`), and `radius` (`float`).
- **Visual Styling:** `fill_color`, `outline_color`, and `outline_width` matching the stroke aesthetic of `TerrainBlock`.
- **Physics Presets:** A preset selector or exported `PhysicsMaterial` handle for quick material assignment (e.g., _Wood Crate_, _Bouncy Rubber_, _Light Cardboard_, _Heavy Metal_).
- **Live Update Setter:** A unified property setter that updates visual fill and border geometry dynamically:
- **Polygons:** Assigns `polygon_points` to `Polygon2D` and `CollisionPolygon2D` (`BUILD_SOLIDS`), closing the `Line2D` outline by appending the first vertex to the end with rounded line joints and caps.
- **Circles:** Generates a smooth radial point loop for `Polygon2D` and `Line2D` while updating the radius of a `CircleShape2D` to maintain fast, accurate spherical collision.
---
## 2. `PropUtils` Factory & Preset Helper
- **Class Setup:** `class_name PropUtils`, `extends RefCounted` providing static utility methods for spawning dynamic props.
- **Primitive Generators:** Helper functions to programmatically build common prop primitives (e.g., `create_box()`, `create_ball()`, `create_plank()`, `create_triangle()`) with default vector dimensions and color themes.
- **Physics Material Presets:** Internal factory definitions for pre-configured `PhysicsMaterial` resources:
- _Wood:_ Moderate mass, medium friction, low bounce.
- _Rubber:_ Low mass, high friction, high restitution/bounce ($0.8+$).
- _Metal:_ High mass, high friction, zero bounce.
- **Point Sanitization:** Any custom polygonal points passed to `PropUtils` should run through `TerrainUtils.sanitize_points()` to guarantee clockwise vertex order and strip collinear points before creating the prop.
- **Spawner Factory (`spawn_prop`):** Static function taking a target parent container, position, shape payload, and material preset to instantiate a `PropBlock`, set its initial velocity, and add it to the scene.
---
## 3. `PhysicsTestHarness` Staging Integration
- **Controls & Spawning:** Update `scripts/physics_test_harness.gd` to bind key inputs (or mouse clicks) for spawning dynamic props directly into the test world (e.g., Press `1` for Wood Crate, `2` for Bouncy Ball, `3` for Heavy Plank).
- **Physics Verification Checklist:**
- **Vector Scaling & Zoom:** Ensure stroke weights and line join quality on dynamic props remain crisp and visually coherent with static terrain across camera zoom levels (0.25x to 3.0x).
- **Collision Accuracy:** Verify dynamic props roll smoothly down angled ramps, bounce predictably off step ledges, and stack stably on flat terrain without clipping or falling through `TerrainBlock` geometry.
- **Rig Interaction:** Confirm dynamic props collide accurately with the standing `MasterRig` instance on the ground.
+23
View File
@@ -0,0 +1,23 @@
## Vector Terrain System
Now that stickmen can be spawned, we need to implement a world for the character.
We want the world to be based on vector graphics so that camera zooming and panning as smooth and crisp.
According to ROADMAP.md, we want to start with vector terrains.
The following phases need work:
### Phase 1. Reusable TerrainBlock Component (StaticBody2D)
- Node Hierarchy: A root StaticBody2D with three direct children: Polygon2D for interior fill, Line2D for crisp vector borders, and CollisionPolygon2D for physics boundaries.
- Property Exposure: An @tool script exposing exported properties for polygon_points (PackedVector2Array), fill_color, outline_color, and outline_width.
- Auto-Update Logic: A unified property setter that pushes vertex changes directly to Polygon2D and CollisionPolygon2D (configured with BUILD_SOLIDS), while automatically appending the first vertex to the end of Line2D to close the border loop with rounded line joints.
### Phase 2. PhysicsTestHarness Test Scene
- Scene Layout: A standalone test root containing a Camera2D, an environment container with flat ground, angled ramps, and stepped TerrainBlock instances, plus an instantiated MasterRig.
- Camera Verification: A simple input script on Camera2D that scales zoom levels between 0.25x and 3.0x on mouse wheel scroll to test resolution independence and vector outline thickness.
### Phase 3. TerrainUtils Geometry Engine
- Point Sanitization: A static utility class that processes raw input points by applying grid snapping, running Geometry2D.simplify_polyline() to remove redundant nodes, and enforcing clockwise vertex ordering via Geometry2D.is_polygon_clockwise() for solid collision detection.
- Factory Spawner: A helper function that takes user input vectors, processes them through the sanitization pipeline, creates a new TerrainBlock instance, applies the cleaned points, and adds it to the target container.
+12
View File
@@ -0,0 +1,12 @@
[gd_scene format=3]
[ext_resource type="Script" path="res://scripts/physics_test_harness.gd" id="1_harness"]
[node name="PhysicsTestHarness" type="Node2D"]
script = ExtResource("1_harness")
[node name="Camera2D" type="Camera2D" parent="."]
position = Vector2(0, -400)
zoom = Vector2(0.5, 0.5)
[node name="Environment" type="Node2D" parent="."]
+203
View File
@@ -0,0 +1,203 @@
class_name PhysicsTestHarness
extends Node2D
## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2).
##
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
## factory, spawns a master_rig.tscn instance standing on the flat ground, and
## provides camera zoom/pan input. NOT wired into the editor — run standalone
## via F6 on res://scenes/physics_test_harness.tscn.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const MIN_ZOOM: float = 0.25
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
const RIG_SCENE := preload("res://master_rig.tscn")
## Preloaded prop scripts: resolved via preload (not the global class registry)
## so the harness compiles even when the editor's class cache is stale.
const PROP_UTILS_SCRIPT := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK_SCRIPT := preload("res://scripts/prop_block.gd")
## World-space Y of the flat ground's top surface.
const GROUND_TOP_Y: float = 0.0
## Rig root placement: the rig's feet rest ~385 px below its root, so placing
## the root 385 px above the ground puts the feet on the top surface.
const RIG_SPAWN_POSITION := Vector2(0.0, -385.0)
## Spawn point for dynamic props: above the angled ramp so they tumble down.
const PROP_SPAWN_POSITION := Vector2(300.0, -300.0)
## Best-effort collision proxy for the rig (which has no physics bodies): a
## static box matching the standing figure's world bounds (x ±120, y 0..-1000).
const RIG_PROXY_SIZE := Vector2(240.0, 1000.0)
const RIG_PROXY_CENTER := Vector2(0.0, -500.0)
# ---------------------------------------------------------------------------
# Node references
# ---------------------------------------------------------------------------
@onready var _camera: Camera2D = $Camera2D
@onready var _environment: Node2D = $Environment
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _is_panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_camera.make_current()
_build_environment()
_spawn_rig()
# ---------------------------------------------------------------------------
# Input (camera zoom / pan)
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
_handle_mouse_button(event as InputEventMouseButton)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event as InputEventMouseMotion)
elif event is InputEventKey:
_handle_key(event as InputEventKey)
func _handle_key(key: InputEventKey) -> void:
if not key.pressed or key.echo:
return
match key.keycode:
KEY_1:
_spawn_prop_crate()
KEY_2:
_spawn_prop_ball()
KEY_3:
_spawn_prop_plank()
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:
_is_panning = mb.pressed
_pan_last = mb.position
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
if _is_panning:
_camera.position -= mm.relative / _camera.zoom.x
func _set_zoom(value: float) -> void:
var z := clampf(value, MIN_ZOOM, MAX_ZOOM)
_camera.zoom = Vector2(z, z)
# ---------------------------------------------------------------------------
# Environment / rig construction
# ---------------------------------------------------------------------------
func _build_environment() -> void:
# Flat ground: top surface at GROUND_TOP_Y, extending downward (solid).
TerrainUtils.spawn_block(_environment, PackedVector2Array([
Vector2(-800.0, GROUND_TOP_Y),
Vector2(800.0, GROUND_TOP_Y),
Vector2(800.0, GROUND_TOP_Y + 64.0),
Vector2(-800.0, GROUND_TOP_Y + 64.0),
]))
# Angled ramp (a sloped quad rising 128 px over its 192 px run).
TerrainUtils.spawn_block(_environment, PackedVector2Array([
Vector2(208.0, GROUND_TOP_Y),
Vector2(400.0, GROUND_TOP_Y - 128.0),
Vector2(400.0, GROUND_TOP_Y - 64.0),
Vector2(208.0, GROUND_TOP_Y + 64.0),
]))
# Stepped terrain (a single concave staircase — exercises BUILD_SOLIDS).
TerrainUtils.spawn_block(_environment, PackedVector2Array([
Vector2(496.0, GROUND_TOP_Y + 64.0),
Vector2(752.0, GROUND_TOP_Y + 64.0),
Vector2(752.0, GROUND_TOP_Y - 192.0),
Vector2(688.0, GROUND_TOP_Y - 192.0),
Vector2(688.0, GROUND_TOP_Y - 128.0),
Vector2(624.0, GROUND_TOP_Y - 128.0),
Vector2(624.0, GROUND_TOP_Y - 64.0),
Vector2(560.0, GROUND_TOP_Y - 64.0),
Vector2(560.0, GROUND_TOP_Y),
Vector2(496.0, GROUND_TOP_Y),
]))
func _spawn_rig() -> void:
var rig := RIG_SCENE.instantiate() as Node2D
if rig == null:
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
return
rig.position = RIG_SPAWN_POSITION
add_child(rig)
_add_rig_collision_proxy()
# ---------------------------------------------------------------------------
# Dynamic prop spawning (keys 1/2/3)
# ---------------------------------------------------------------------------
func _spawn_prop_crate() -> void:
PROP_UTILS_SCRIPT.spawn_prop(
_environment,
PROP_SPAWN_POSITION + Vector2(-24.0, 0.0),
PROP_UTILS_SCRIPT.create_box(),
PROP_BLOCK_SCRIPT.MaterialPreset.WOOD,
Vector2(60.0, 0.0)
)
func _spawn_prop_ball() -> void:
PROP_UTILS_SCRIPT.spawn_prop(
_environment,
PROP_SPAWN_POSITION,
PROP_UTILS_SCRIPT.create_ball(),
PROP_BLOCK_SCRIPT.MaterialPreset.RUBBER,
Vector2(-80.0, 0.0)
)
func _spawn_prop_plank() -> void:
PROP_UTILS_SCRIPT.spawn_prop(
_environment,
PROP_SPAWN_POSITION + Vector2(24.0, 0.0),
PROP_UTILS_SCRIPT.create_plank(),
PROP_BLOCK_SCRIPT.MaterialPreset.METAL,
Vector2(30.0, -40.0)
)
## The rig has no physics bodies, so a code-only StaticBody2D proxy provides a
## collision surface matching its standing bounds. Props bounce/rest against it.
func _add_rig_collision_proxy() -> void:
var proxy := StaticBody2D.new()
proxy.name = "RigCollisionProxy"
proxy.position = RIG_PROXY_CENTER
var shape := CollisionShape2D.new()
shape.name = "CollisionShape2D"
var rect := RectangleShape2D.new()
rect.size = RIG_PROXY_SIZE
shape.shape = rect
proxy.add_child(shape)
add_child(proxy)
+1
View File
@@ -0,0 +1 @@
uid://cvejolojthtcs
+255
View File
@@ -0,0 +1,255 @@
@tool
class_name PropBlock
extends RigidBody2D
## PropBlock - Reusable dynamic vector prop (RigidBody2D).
##
## A root RigidBody2D with three children built in code — a Polygon2D (interior
## fill), a Line2D (crisp vector outline, round joints/caps), and a collision
## node: a CollisionPolygon2D with BUILD_SOLIDS for polygon props, or a
## CollisionShape2D with a CircleShape2D for circle props. Fully @tool: exported
## properties update the children live. A material preset selector sets mass,
## friction/bounce (PhysicsMaterial), and themed visuals.
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum ShapeType { POLYGON, CIRCLE }
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
# ---------------------------------------------------------------------------
# Child node names
# ---------------------------------------------------------------------------
const POLYGON_NODE_NAME := "Polygon2D"
const OUTLINE_NODE_NAME := "Outline"
const COLLISION_POLYGON_NODE_NAME := "CollisionPolygon2D"
const COLLISION_SHAPE_NODE_NAME := "CollisionShape2D"
## Segment count for the generated circle polygon/outline.
const CIRCLE_SEGMENTS: int = 48
const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
@export_enum("Polygon", "Circle") var shape_type: int = ShapeType.POLYGON:
set(value):
shape_type = value
_apply_shape()
@export var polygon_points: PackedVector2Array = PackedVector2Array():
set(value):
polygon_points = value
if shape_type == ShapeType.POLYGON:
_apply_polygon_geometry()
@export var radius: float = 32.0:
set(value):
radius = value
if shape_type == ShapeType.CIRCLE:
_apply_circle_geometry()
@export var fill_color: Color = DEFAULT_FILL_COLOR:
set(value):
fill_color = value
if _polygon != null:
_polygon.color = value
@export var outline_color: Color = DEFAULT_OUTLINE_COLOR:
set(value):
outline_color = value
if _outline != null:
_outline.default_color = value
@export var outline_width: float = DEFAULT_OUTLINE_WIDTH:
set(value):
outline_width = value
if _outline != null:
_outline.width = value
@export_enum("None", "Wood", "Rubber", "Cardboard", "Metal") var material_preset: int = MaterialPreset.NONE:
set(value):
material_preset = value
_apply_material_preset()
# ---------------------------------------------------------------------------
# Internal node references (built in _ready, @tool-safe)
# ---------------------------------------------------------------------------
var _polygon: Polygon2D
var _outline: Line2D
var _collision_polygon: CollisionPolygon2D
var _collision_shape: CollisionShape2D
var _circle_shape: CircleShape2D
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_ensure_children()
_apply_shape()
_apply_style()
# ---------------------------------------------------------------------------
# Internal build / apply
# ---------------------------------------------------------------------------
func _ensure_children() -> void:
_polygon = get_node_or_null(NodePath(POLYGON_NODE_NAME)) as Polygon2D
if _polygon == null:
_polygon = Polygon2D.new()
_polygon.name = POLYGON_NODE_NAME
add_child(_polygon)
_outline = get_node_or_null(NodePath(OUTLINE_NODE_NAME)) as Line2D
if _outline == null:
_outline = Line2D.new()
_outline.name = OUTLINE_NODE_NAME
_outline.joint_mode = Line2D.LINE_JOINT_ROUND
_outline.begin_cap_mode = Line2D.LINE_CAP_ROUND
_outline.end_cap_mode = Line2D.LINE_CAP_ROUND
add_child(_outline)
_collision_polygon = get_node_or_null(NodePath(COLLISION_POLYGON_NODE_NAME)) as CollisionPolygon2D
if _collision_polygon == null:
_collision_polygon = CollisionPolygon2D.new()
_collision_polygon.name = COLLISION_POLYGON_NODE_NAME
_collision_polygon.build_mode = CollisionPolygon2D.BUILD_SOLIDS
add_child(_collision_polygon)
_collision_shape = get_node_or_null(NodePath(COLLISION_SHAPE_NODE_NAME)) as CollisionShape2D
if _collision_shape == null:
_collision_shape = CollisionShape2D.new()
_collision_shape.name = COLLISION_SHAPE_NODE_NAME
_circle_shape = CircleShape2D.new()
_circle_shape.radius = radius
_collision_shape.shape = _circle_shape
add_child(_collision_shape)
else:
# A pre-existing collision shape (e.g. persisted in a scene) may already
# hold a circle shape; reuse it so radius updates keep working.
_circle_shape = _collision_shape.shape as CircleShape2D
func _apply_shape() -> void:
if shape_type == ShapeType.CIRCLE:
_apply_circle_geometry()
_set_collision_polygon_enabled(false)
_set_collision_shape_enabled(true)
else:
_apply_polygon_geometry()
_set_collision_polygon_enabled(true)
_set_collision_shape_enabled(false)
func _apply_polygon_geometry() -> void:
if _polygon != null:
_polygon.polygon = polygon_points
if _collision_polygon != null:
_collision_polygon.polygon = polygon_points if polygon_points.size() >= 3 else PackedVector2Array()
if _outline != null:
var outline_points := polygon_points.duplicate()
if not outline_points.is_empty():
outline_points.append(polygon_points[0])
_outline.points = outline_points
func _apply_circle_geometry() -> void:
var loop := PackedVector2Array()
for i: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(i) / float(CIRCLE_SEGMENTS)
loop.append(Vector2(cos(angle), sin(angle)) * radius)
if _polygon != null:
_polygon.polygon = loop
if _outline != null:
var outline_points := loop.duplicate()
if not outline_points.is_empty():
outline_points.append(loop[0])
_outline.points = outline_points
if _circle_shape != null:
_circle_shape.radius = radius
func _set_collision_polygon_enabled(enabled: bool) -> void:
if _collision_polygon != null:
_collision_polygon.disabled = not enabled
func _set_collision_shape_enabled(enabled: bool) -> void:
if _collision_shape != null:
_collision_shape.disabled = not enabled
func _apply_style() -> void:
if _polygon != null:
_polygon.color = fill_color
if _outline != null:
_outline.default_color = outline_color
_outline.width = outline_width
func _apply_material_preset() -> void:
mass = mass_for(material_preset)
physics_material_override = physics_material(material_preset)
if material_preset != MaterialPreset.NONE:
fill_color = tint_for(material_preset)
outline_color = tint_for(material_preset).darkened(0.55)
# ---------------------------------------------------------------------------
# Static preset factories
# ---------------------------------------------------------------------------
static func physics_material(preset: int) -> PhysicsMaterial:
var mat := PhysicsMaterial.new()
match preset:
MaterialPreset.WOOD:
mat.friction = 0.6
mat.bounce = 0.1
MaterialPreset.RUBBER:
mat.friction = 0.9
mat.bounce = 0.85
MaterialPreset.CARDBOARD:
mat.friction = 0.3
mat.bounce = 0.05
MaterialPreset.METAL:
mat.friction = 0.9
mat.bounce = 0.0
_:
mat.friction = 0.5
mat.bounce = 0.05
return mat
static func mass_for(preset: int) -> float:
match preset:
MaterialPreset.WOOD:
return 3.0
MaterialPreset.RUBBER:
return 0.5
MaterialPreset.CARDBOARD:
return 0.4
MaterialPreset.METAL:
return 8.0
_:
return 1.0
static func tint_for(preset: int) -> Color:
match preset:
MaterialPreset.WOOD:
return Color(0.55, 0.38, 0.2, 1.0)
MaterialPreset.RUBBER:
return Color(0.9, 0.2, 0.2, 1.0)
MaterialPreset.CARDBOARD:
return Color(0.85, 0.72, 0.45, 1.0)
MaterialPreset.METAL:
return Color(0.5, 0.55, 0.6, 1.0)
_:
return DEFAULT_FILL_COLOR
+1
View File
@@ -0,0 +1 @@
uid://sbrgty3kyjfv
+159
View File
@@ -0,0 +1,159 @@
class_name PropUtils
extends RefCounted
## PropUtils - Static factory for dynamic vector props.
##
## Primitive generators (box, ball, plank, triangle) return shape-payload
## dictionaries with default dimensions and color themes. spawn_prop instantiates
## a PropBlock, applies the payload + a physics material preset, sets an initial
## velocity, and adds it to a container. Custom polygon points are sanitized
## through TerrainUtils.sanitize_points().
# ---------------------------------------------------------------------------
# Preloaded dependencies (resolved directly, independent of the global class
# registry, so this script compiles even when the editor's class cache is stale)
# ---------------------------------------------------------------------------
const PropBlockScript := preload("res://scripts/prop_block.gd")
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
# ---------------------------------------------------------------------------
# Color themes
# ---------------------------------------------------------------------------
const WOOD_FILL := Color(0.55, 0.38, 0.2, 1.0)
const WOOD_OUTLINE := Color(0.25, 0.16, 0.06, 1.0)
const RUBBER_FILL := Color(0.9, 0.2, 0.2, 1.0)
const RUBBER_OUTLINE := Color(0.35, 0.05, 0.05, 1.0)
const METAL_FILL := Color(0.5, 0.55, 0.6, 1.0)
const METAL_OUTLINE := Color(0.15, 0.18, 0.22, 1.0)
const CARDBOARD_FILL := Color(0.85, 0.72, 0.45, 1.0)
const CARDBOARD_OUTLINE := Color(0.4, 0.32, 0.18, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
# ---------------------------------------------------------------------------
# Primitive generators
# ---------------------------------------------------------------------------
static func create_box(
size: Vector2 = Vector2(48.0, 48.0),
fill_color: Color = WOOD_FILL,
outline_color: Color = WOOD_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
var half := size * 0.5
return {
"type": PropBlockScript.ShapeType.POLYGON,
"points": PackedVector2Array([
Vector2(-half.x, -half.y),
Vector2(half.x, -half.y),
Vector2(half.x, half.y),
Vector2(-half.x, half.y),
]),
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
static func create_ball(
radius: float = 24.0,
fill_color: Color = RUBBER_FILL,
outline_color: Color = RUBBER_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
return {
"type": PropBlockScript.ShapeType.CIRCLE,
"radius": radius,
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
static func create_plank(
length: float = 160.0,
thickness: float = 16.0,
fill_color: Color = METAL_FILL,
outline_color: Color = METAL_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
var half_length := length * 0.5
var half_thickness := thickness * 0.5
return {
"type": PropBlockScript.ShapeType.POLYGON,
"points": PackedVector2Array([
Vector2(-half_length, -half_thickness),
Vector2(half_length, -half_thickness),
Vector2(half_length, half_thickness),
Vector2(-half_length, half_thickness),
]),
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
static func create_triangle(
base: float = 56.0,
height: float = 48.0,
fill_color: Color = CARDBOARD_FILL,
outline_color: Color = CARDBOARD_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
var half_base := base * 0.5
var half_height := height * 0.5
return {
"type": PropBlockScript.ShapeType.POLYGON,
"points": PackedVector2Array([
Vector2(-half_base, half_height),
Vector2(half_base, half_height),
Vector2(0.0, -half_height),
]),
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
# ---------------------------------------------------------------------------
# Factory spawner
# ---------------------------------------------------------------------------
## Instantiate a PropBlock from a shape payload and material preset, set an
## initial velocity, and add it to `container`. Returns the spawned prop.
static func spawn_prop(
container: Node,
position: Vector2,
shape_payload: Dictionary,
material_preset: int = PropBlockScript.MaterialPreset.WOOD,
initial_velocity: Vector2 = Vector2.ZERO
) -> PropBlockScript:
var prop: PropBlockScript = PropBlockScript.new()
prop.name = "PropBlock"
prop.position = position
prop.material_preset = material_preset
_apply_shape_payload(prop, shape_payload)
container.add_child(prop)
if initial_velocity != Vector2.ZERO:
prop.linear_velocity = initial_velocity
return prop
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
static func _apply_shape_payload(prop: PropBlockScript, payload: Dictionary) -> void:
var shape_type: int = int(payload.get("type", PropBlockScript.ShapeType.POLYGON))
prop.shape_type = shape_type
if shape_type == PropBlockScript.ShapeType.CIRCLE:
prop.radius = float(payload.get("radius", 24.0))
else:
var raw_points: PackedVector2Array = payload.get("points", PackedVector2Array())
prop.polygon_points = TerrainUtilsScript.sanitize_points(raw_points)
if payload.has("fill_color"):
prop.fill_color = payload["fill_color"]
if payload.has("outline_color"):
prop.outline_color = payload["outline_color"]
if payload.has("outline_width"):
prop.outline_width = float(payload["outline_width"])
+1
View File
@@ -0,0 +1 @@
uid://bb5newxhg2ifi
+117
View File
@@ -0,0 +1,117 @@
@tool
class_name TerrainBlock
extends StaticBody2D
## TerrainBlock - Reusable StaticBody2D vector-terrain component (Phase 1).
##
## A self-contained terrain block: a StaticBody2D root with three children built
## in code — a Polygon2D (interior fill), a Line2D (crisp vector outline, closed
## by appending the first vertex, rounded joints/caps), and a CollisionPolygon2D
## configured with BUILD_SOLIDS so concave terrain blocks collide correctly.
## Fully @tool: exported properties update the children live in the editor.
# ---------------------------------------------------------------------------
# Child node names
# ---------------------------------------------------------------------------
const POLYGON_NODE_NAME := "Polygon2D"
const OUTLINE_NODE_NAME := "Outline"
const COLLISION_NODE_NAME := "CollisionPolygon2D"
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
## The terrain polygon's vertices (local space). The unified setter pushes the
## array to the Polygon2D and CollisionPolygon2D, and closes the Line2D loop by
## appending the first vertex to the end.
@export var polygon_points: PackedVector2Array = PackedVector2Array():
set(value):
polygon_points = value
_apply_points()
## Interior fill color (Polygon2D).
@export var fill_color: Color = Color(0.25, 0.55, 0.25, 1.0):
set(value):
fill_color = value
if _polygon != null:
_polygon.color = value
## Outline color (Line2D).
@export var outline_color: Color = Color(0.05, 0.10, 0.05, 1.0):
set(value):
outline_color = value
if _outline != null:
_outline.default_color = value
## Outline width in pixels (Line2D).
@export var outline_width: float = 2.0:
set(value):
outline_width = value
if _outline != null:
_outline.width = value
# ---------------------------------------------------------------------------
# Internal node references (built in _ready, @tool-safe)
# ---------------------------------------------------------------------------
var _polygon: Polygon2D
var _outline: Line2D
var _collision: CollisionPolygon2D
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_ensure_children()
_apply_points()
_apply_style()
# ---------------------------------------------------------------------------
# Internal build / apply
# ---------------------------------------------------------------------------
func _ensure_children() -> void:
_polygon = get_node_or_null(NodePath(POLYGON_NODE_NAME)) as Polygon2D
if _polygon == null:
_polygon = Polygon2D.new()
_polygon.name = POLYGON_NODE_NAME
add_child(_polygon)
_outline = get_node_or_null(NodePath(OUTLINE_NODE_NAME)) as Line2D
if _outline == null:
_outline = Line2D.new()
_outline.name = OUTLINE_NODE_NAME
_outline.joint_mode = Line2D.LINE_JOINT_ROUND
_outline.begin_cap_mode = Line2D.LINE_CAP_ROUND
_outline.end_cap_mode = Line2D.LINE_CAP_ROUND
add_child(_outline)
_collision = get_node_or_null(NodePath(COLLISION_NODE_NAME)) as CollisionPolygon2D
if _collision == null:
_collision = CollisionPolygon2D.new()
_collision.name = COLLISION_NODE_NAME
_collision.build_mode = CollisionPolygon2D.BUILD_SOLIDS
add_child(_collision)
## Pushes the current polygon_points to the Polygon2D and CollisionPolygon2D,
## and appends the first vertex to the Line2D to close the border loop.
func _apply_points() -> void:
if _polygon != null:
_polygon.polygon = polygon_points
if _collision != null:
_collision.polygon = polygon_points if polygon_points.size() >= 3 else PackedVector2Array()
if _outline != null:
var outline_points := polygon_points.duplicate()
if not outline_points.is_empty():
outline_points.append(polygon_points[0])
_outline.points = outline_points
func _apply_style() -> void:
if _polygon != null:
_polygon.color = fill_color
if _outline != null:
_outline.default_color = outline_color
_outline.width = outline_width
+1
View File
@@ -0,0 +1 @@
uid://bw31hvuc1uako
+104
View File
@@ -0,0 +1,104 @@
class_name TerrainUtils
extends RefCounted
## TerrainUtils - Static geometry engine for vector terrain (Phase 3).
##
## Two responsibilities: sanitize raw input points into a clean, grid-snapped,
## clockwise-ordered polygon; and spawn a configured TerrainBlock into a target
## container. Consumed by the standalone physics test harness; not referenced by
## the editor.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_GRID_SIZE: float = 16.0
const DEFAULT_FILL_COLOR := Color(0.25, 0.55, 0.25, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.05, 0.10, 0.05, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
# ---------------------------------------------------------------------------
# Point sanitization
# ---------------------------------------------------------------------------
## Snap raw points to a grid, drop redundant nodes, and enforce clockwise
## winding (required for solid collision decomposition). Returns a new array;
## the input is not modified.
static func sanitize_points(points: PackedVector2Array, grid_size: float = DEFAULT_GRID_SIZE) -> PackedVector2Array:
var cleaned := PackedVector2Array()
for p: Vector2 in points:
cleaned.append(_snap_to_grid(p, grid_size))
cleaned = _simplify_polyline(cleaned)
if cleaned.size() >= 3 and not Geometry2D.is_polygon_clockwise(cleaned):
cleaned.reverse()
return cleaned
# ---------------------------------------------------------------------------
# Factory spawner
# ---------------------------------------------------------------------------
## Sanitize `points`, create a new TerrainBlock, apply the cleaned points and
## styling, and add it to `container`. Returns the spawned block.
static func spawn_block(
container: Node,
points: PackedVector2Array,
grid_size: float = DEFAULT_GRID_SIZE,
fill_color: Color = DEFAULT_FILL_COLOR,
outline_color: Color = DEFAULT_OUTLINE_COLOR,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> TerrainBlock:
var block := TerrainBlock.new()
block.name = "TerrainBlock"
block.polygon_points = sanitize_points(points, grid_size)
block.fill_color = fill_color
block.outline_color = outline_color
block.outline_width = outline_width
container.add_child(block)
return block
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
static func _snap_to_grid(p: Vector2, grid_size: float) -> Vector2:
if grid_size <= 0.0:
return p
return Vector2(roundf(p.x / grid_size) * grid_size, roundf(p.y / grid_size) * grid_size)
## Remove redundant nodes: consecutive duplicates (e.g. collapsed by grid
## snapping) and collinear middle vertices (redundant for both render and
## collision). NOTE: Geometry2D.simplify_polyline() does not exist in Godot
## 4.7.1, so this local pass stands in for it.
static func _simplify_polyline(points: PackedVector2Array) -> PackedVector2Array:
var deduped := PackedVector2Array()
for p: Vector2 in points:
if not deduped.is_empty() and deduped[deduped.size() - 1].is_equal_approx(p):
continue
deduped.append(p)
# Drop a closing duplicate (last vertex == first vertex). Polygons are
# treated as open here; otherwise the collinear pass below would treat the
# first/last vertices as "collinear" with each other and erase the first
# corner, producing a degenerate polygon.
if deduped.size() >= 2 and deduped[0].is_equal_approx(deduped[deduped.size() - 1]):
deduped.remove_at(deduped.size() - 1)
if deduped.size() < 3:
return deduped
var simplified := PackedVector2Array()
var n: int = deduped.size()
for i: int in n:
var prev := deduped[(i - 1 + n) % n]
var curr := deduped[i]
var next := deduped[(i + 1) % n]
if _is_collinear(prev, curr, next):
continue
simplified.append(curr)
return simplified if simplified.size() >= 3 else deduped
static func _is_collinear(a: Vector2, b: Vector2, c: Vector2) -> bool:
return absf((b - a).cross(c - b)) < 0.001
+1
View File
@@ -0,0 +1 @@
uid://cduiqhk7xpm4m