- 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.
105 lines
4.0 KiB
GDScript
105 lines
4.0 KiB
GDScript
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
|