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:
@@ -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`,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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="."]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cvejolojthtcs
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://sbrgty3kyjfv
|
||||
@@ -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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb5newxhg2ifi
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bw31hvuc1uako
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://cduiqhk7xpm4m
|
||||
Reference in New Issue
Block a user