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

- Created ROADMAP.md outlining core features, implementation phases, and detailed breakdown for the Stickman Sandbox Builder project.
- Introduced plans for dynamic vector props with `PropBlock` specification, including reusable components and utility functions for prop creation.
- Developed a vector terrain system plan detailing the reusable `TerrainBlock` component and associated utility functions for geometry handling.
- Implemented `PhysicsTestHarness` scene for testing physics interactions with dynamic props and terrain.
- Added scripts for `PropBlock`, `PropUtils`, `TerrainBlock`, and `TerrainUtils` to support dynamic prop creation and terrain management.
This commit is contained in:
2026-08-26 09:35:09 -04:00
parent 4d490eef6c
commit 6edf3e53e3
16 changed files with 1139 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
class_name TerrainUtils
extends RefCounted
## TerrainUtils - Static geometry engine for vector terrain (Phase 3).
##
## Two responsibilities: sanitize raw input points into a clean, grid-snapped,
## clockwise-ordered polygon; and spawn a configured TerrainBlock into a target
## container. Consumed by the standalone physics test harness; not referenced by
## the editor.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_GRID_SIZE: float = 16.0
const DEFAULT_FILL_COLOR := Color(0.25, 0.55, 0.25, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.05, 0.10, 0.05, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
# ---------------------------------------------------------------------------
# Point sanitization
# ---------------------------------------------------------------------------
## Snap raw points to a grid, drop redundant nodes, and enforce clockwise
## winding (required for solid collision decomposition). Returns a new array;
## the input is not modified.
static func sanitize_points(points: PackedVector2Array, grid_size: float = DEFAULT_GRID_SIZE) -> PackedVector2Array:
var cleaned := PackedVector2Array()
for p: Vector2 in points:
cleaned.append(_snap_to_grid(p, grid_size))
cleaned = _simplify_polyline(cleaned)
if cleaned.size() >= 3 and not Geometry2D.is_polygon_clockwise(cleaned):
cleaned.reverse()
return cleaned
# ---------------------------------------------------------------------------
# Factory spawner
# ---------------------------------------------------------------------------
## Sanitize `points`, create a new TerrainBlock, apply the cleaned points and
## styling, and add it to `container`. Returns the spawned block.
static func spawn_block(
container: Node,
points: PackedVector2Array,
grid_size: float = DEFAULT_GRID_SIZE,
fill_color: Color = DEFAULT_FILL_COLOR,
outline_color: Color = DEFAULT_OUTLINE_COLOR,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> TerrainBlock:
var block := TerrainBlock.new()
block.name = "TerrainBlock"
block.polygon_points = sanitize_points(points, grid_size)
block.fill_color = fill_color
block.outline_color = outline_color
block.outline_width = outline_width
container.add_child(block)
return block
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
static func _snap_to_grid(p: Vector2, grid_size: float) -> Vector2:
if grid_size <= 0.0:
return p
return Vector2(roundf(p.x / grid_size) * grid_size, roundf(p.y / grid_size) * grid_size)
## Remove redundant nodes: consecutive duplicates (e.g. collapsed by grid
## snapping) and collinear middle vertices (redundant for both render and
## collision). NOTE: Geometry2D.simplify_polyline() does not exist in Godot
## 4.7.1, so this local pass stands in for it.
static func _simplify_polyline(points: PackedVector2Array) -> PackedVector2Array:
var deduped := PackedVector2Array()
for p: Vector2 in points:
if not deduped.is_empty() and deduped[deduped.size() - 1].is_equal_approx(p):
continue
deduped.append(p)
# Drop a closing duplicate (last vertex == first vertex). Polygons are
# treated as open here; otherwise the collinear pass below would treat the
# first/last vertices as "collinear" with each other and erase the first
# corner, producing a degenerate polygon.
if deduped.size() >= 2 and deduped[0].is_equal_approx(deduped[deduped.size() - 1]):
deduped.remove_at(deduped.size() - 1)
if deduped.size() < 3:
return deduped
var simplified := PackedVector2Array()
var n: int = deduped.size()
for i: int in n:
var prev := deduped[(i - 1 + n) % n]
var curr := deduped[i]
var next := deduped[(i + 1) % n]
if _is_collinear(prev, curr, next):
continue
simplified.append(curr)
return simplified if simplified.size() >= 3 else deduped
static func _is_collinear(a: Vector2, b: Vector2, c: Vector2) -> bool:
return absf((b - a).cross(c - b)) < 0.001