From 6edf3e53e3d3c11c583d33f72e8546b618631514 Mon Sep 17 00:00:00 2001 From: Ryan Kegel Date: Wed, 26 Aug 2026 09:35:09 -0400 Subject: [PATCH] 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. --- AGENTS.md | 64 +++++++ README.md | 86 ++++++++++ ROADMAP.md | 71 ++++++++ plans/DYNAMIC_VECTOR_PROPS.md | 40 +++++ plans/VECTOR_TERRAIN_SYSTEM.md | 23 +++ scenes/physics_test_harness.tscn | 12 ++ scripts/physics_test_harness.gd | 203 ++++++++++++++++++++++ scripts/physics_test_harness.gd.uid | 1 + scripts/prop_block.gd | 255 ++++++++++++++++++++++++++++ scripts/prop_block.gd.uid | 1 + scripts/prop_utils.gd | 159 +++++++++++++++++ scripts/prop_utils.gd.uid | 1 + scripts/terrain_block.gd | 117 +++++++++++++ scripts/terrain_block.gd.uid | 1 + scripts/terrain_utils.gd | 104 ++++++++++++ scripts/terrain_utils.gd.uid | 1 + 16 files changed, 1139 insertions(+) create mode 100644 ROADMAP.md create mode 100644 plans/DYNAMIC_VECTOR_PROPS.md create mode 100644 plans/VECTOR_TERRAIN_SYSTEM.md create mode 100644 scenes/physics_test_harness.tscn create mode 100644 scripts/physics_test_harness.gd create mode 100644 scripts/physics_test_harness.gd.uid create mode 100644 scripts/prop_block.gd create mode 100644 scripts/prop_block.gd.uid create mode 100644 scripts/prop_utils.gd create mode 100644 scripts/prop_utils.gd.uid create mode 100644 scripts/terrain_block.gd create mode 100644 scripts/terrain_block.gd.uid create mode 100644 scripts/terrain_utils.gd create mode 100644 scripts/terrain_utils.gd.uid diff --git a/AGENTS.md b/AGENTS.md index 693de53..f105f2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, diff --git a/README.md b/README.md index 710efa6..b47abda 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..b5cef2e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,71 @@ +# Stickman Sandbox Builder - Project Roadmap + +A kid-friendly (ages 10–15) 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. \ No newline at end of file diff --git a/plans/DYNAMIC_VECTOR_PROPS.md b/plans/DYNAMIC_VECTOR_PROPS.md new file mode 100644 index 0000000..7c0f90b --- /dev/null +++ b/plans/DYNAMIC_VECTOR_PROPS.md @@ -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. diff --git a/plans/VECTOR_TERRAIN_SYSTEM.md b/plans/VECTOR_TERRAIN_SYSTEM.md new file mode 100644 index 0000000..613e80a --- /dev/null +++ b/plans/VECTOR_TERRAIN_SYSTEM.md @@ -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. diff --git a/scenes/physics_test_harness.tscn b/scenes/physics_test_harness.tscn new file mode 100644 index 0000000..7675153 --- /dev/null +++ b/scenes/physics_test_harness.tscn @@ -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="."] diff --git a/scripts/physics_test_harness.gd b/scripts/physics_test_harness.gd new file mode 100644 index 0000000..9b4e631 --- /dev/null +++ b/scripts/physics_test_harness.gd @@ -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) diff --git a/scripts/physics_test_harness.gd.uid b/scripts/physics_test_harness.gd.uid new file mode 100644 index 0000000..efc9b43 --- /dev/null +++ b/scripts/physics_test_harness.gd.uid @@ -0,0 +1 @@ +uid://cvejolojthtcs diff --git a/scripts/prop_block.gd b/scripts/prop_block.gd new file mode 100644 index 0000000..c08fd28 --- /dev/null +++ b/scripts/prop_block.gd @@ -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 diff --git a/scripts/prop_block.gd.uid b/scripts/prop_block.gd.uid new file mode 100644 index 0000000..d5acb07 --- /dev/null +++ b/scripts/prop_block.gd.uid @@ -0,0 +1 @@ +uid://sbrgty3kyjfv diff --git a/scripts/prop_utils.gd b/scripts/prop_utils.gd new file mode 100644 index 0000000..1454034 --- /dev/null +++ b/scripts/prop_utils.gd @@ -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"]) diff --git a/scripts/prop_utils.gd.uid b/scripts/prop_utils.gd.uid new file mode 100644 index 0000000..b9f60c4 --- /dev/null +++ b/scripts/prop_utils.gd.uid @@ -0,0 +1 @@ +uid://bb5newxhg2ifi diff --git a/scripts/terrain_block.gd b/scripts/terrain_block.gd new file mode 100644 index 0000000..7f82801 --- /dev/null +++ b/scripts/terrain_block.gd @@ -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 diff --git a/scripts/terrain_block.gd.uid b/scripts/terrain_block.gd.uid new file mode 100644 index 0000000..2e21254 --- /dev/null +++ b/scripts/terrain_block.gd.uid @@ -0,0 +1 @@ +uid://bw31hvuc1uako diff --git a/scripts/terrain_utils.gd b/scripts/terrain_utils.gd new file mode 100644 index 0000000..0d5cf97 --- /dev/null +++ b/scripts/terrain_utils.gd @@ -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 diff --git a/scripts/terrain_utils.gd.uid b/scripts/terrain_utils.gd.uid new file mode 100644 index 0000000..cb3a824 --- /dev/null +++ b/scripts/terrain_utils.gd.uid @@ -0,0 +1 @@ +uid://cduiqhk7xpm4m