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
+203
View File
@@ -0,0 +1,203 @@
class_name PhysicsTestHarness
extends Node2D
## PhysicsTestHarness - Standalone vector-terrain physics test scene (Phase 2).
##
## Builds flat ground, an angled ramp and stepped terrain via the TerrainUtils
## factory, spawns a master_rig.tscn instance standing on the flat ground, and
## provides camera zoom/pan input. NOT wired into the editor — run standalone
## via F6 on res://scenes/physics_test_harness.tscn.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const MIN_ZOOM: float = 0.25
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
const RIG_SCENE := preload("res://master_rig.tscn")
## Preloaded prop scripts: resolved via preload (not the global class registry)
## so the harness compiles even when the editor's class cache is stale.
const PROP_UTILS_SCRIPT := preload("res://scripts/prop_utils.gd")
const PROP_BLOCK_SCRIPT := preload("res://scripts/prop_block.gd")
## World-space Y of the flat ground's top surface.
const GROUND_TOP_Y: float = 0.0
## Rig root placement: the rig's feet rest ~385 px below its root, so placing
## the root 385 px above the ground puts the feet on the top surface.
const RIG_SPAWN_POSITION := Vector2(0.0, -385.0)
## Spawn point for dynamic props: above the angled ramp so they tumble down.
const PROP_SPAWN_POSITION := Vector2(300.0, -300.0)
## Best-effort collision proxy for the rig (which has no physics bodies): a
## static box matching the standing figure's world bounds (x ±120, y 0..-1000).
const RIG_PROXY_SIZE := Vector2(240.0, 1000.0)
const RIG_PROXY_CENTER := Vector2(0.0, -500.0)
# ---------------------------------------------------------------------------
# Node references
# ---------------------------------------------------------------------------
@onready var _camera: Camera2D = $Camera2D
@onready var _environment: Node2D = $Environment
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _is_panning: bool = false
var _pan_last: Vector2 = Vector2.ZERO
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_camera.make_current()
_build_environment()
_spawn_rig()
# ---------------------------------------------------------------------------
# Input (camera zoom / pan)
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
_handle_mouse_button(event as InputEventMouseButton)
elif event is InputEventMouseMotion:
_handle_mouse_motion(event as InputEventMouseMotion)
elif event is InputEventKey:
_handle_key(event as InputEventKey)
func _handle_key(key: InputEventKey) -> void:
if not key.pressed or key.echo:
return
match key.keycode:
KEY_1:
_spawn_prop_crate()
KEY_2:
_spawn_prop_ball()
KEY_3:
_spawn_prop_plank()
func _handle_mouse_button(mb: InputEventMouseButton) -> void:
match mb.button_index:
MOUSE_BUTTON_WHEEL_UP:
if mb.pressed:
_set_zoom(_camera.zoom.x * ZOOM_STEP)
MOUSE_BUTTON_WHEEL_DOWN:
if mb.pressed:
_set_zoom(_camera.zoom.x / ZOOM_STEP)
MOUSE_BUTTON_MIDDLE:
_is_panning = mb.pressed
_pan_last = mb.position
func _handle_mouse_motion(mm: InputEventMouseMotion) -> void:
if _is_panning:
_camera.position -= mm.relative / _camera.zoom.x
func _set_zoom(value: float) -> void:
var z := clampf(value, MIN_ZOOM, MAX_ZOOM)
_camera.zoom = Vector2(z, z)
# ---------------------------------------------------------------------------
# Environment / rig construction
# ---------------------------------------------------------------------------
func _build_environment() -> void:
# Flat ground: top surface at GROUND_TOP_Y, extending downward (solid).
TerrainUtils.spawn_block(_environment, PackedVector2Array([
Vector2(-800.0, GROUND_TOP_Y),
Vector2(800.0, GROUND_TOP_Y),
Vector2(800.0, GROUND_TOP_Y + 64.0),
Vector2(-800.0, GROUND_TOP_Y + 64.0),
]))
# Angled ramp (a sloped quad rising 128 px over its 192 px run).
TerrainUtils.spawn_block(_environment, PackedVector2Array([
Vector2(208.0, GROUND_TOP_Y),
Vector2(400.0, GROUND_TOP_Y - 128.0),
Vector2(400.0, GROUND_TOP_Y - 64.0),
Vector2(208.0, GROUND_TOP_Y + 64.0),
]))
# Stepped terrain (a single concave staircase — exercises BUILD_SOLIDS).
TerrainUtils.spawn_block(_environment, PackedVector2Array([
Vector2(496.0, GROUND_TOP_Y + 64.0),
Vector2(752.0, GROUND_TOP_Y + 64.0),
Vector2(752.0, GROUND_TOP_Y - 192.0),
Vector2(688.0, GROUND_TOP_Y - 192.0),
Vector2(688.0, GROUND_TOP_Y - 128.0),
Vector2(624.0, GROUND_TOP_Y - 128.0),
Vector2(624.0, GROUND_TOP_Y - 64.0),
Vector2(560.0, GROUND_TOP_Y - 64.0),
Vector2(560.0, GROUND_TOP_Y),
Vector2(496.0, GROUND_TOP_Y),
]))
func _spawn_rig() -> void:
var rig := RIG_SCENE.instantiate() as Node2D
if rig == null:
push_warning("PhysicsTestHarness: failed to instantiate master_rig.tscn.")
return
rig.position = RIG_SPAWN_POSITION
add_child(rig)
_add_rig_collision_proxy()
# ---------------------------------------------------------------------------
# Dynamic prop spawning (keys 1/2/3)
# ---------------------------------------------------------------------------
func _spawn_prop_crate() -> void:
PROP_UTILS_SCRIPT.spawn_prop(
_environment,
PROP_SPAWN_POSITION + Vector2(-24.0, 0.0),
PROP_UTILS_SCRIPT.create_box(),
PROP_BLOCK_SCRIPT.MaterialPreset.WOOD,
Vector2(60.0, 0.0)
)
func _spawn_prop_ball() -> void:
PROP_UTILS_SCRIPT.spawn_prop(
_environment,
PROP_SPAWN_POSITION,
PROP_UTILS_SCRIPT.create_ball(),
PROP_BLOCK_SCRIPT.MaterialPreset.RUBBER,
Vector2(-80.0, 0.0)
)
func _spawn_prop_plank() -> void:
PROP_UTILS_SCRIPT.spawn_prop(
_environment,
PROP_SPAWN_POSITION + Vector2(24.0, 0.0),
PROP_UTILS_SCRIPT.create_plank(),
PROP_BLOCK_SCRIPT.MaterialPreset.METAL,
Vector2(30.0, -40.0)
)
## The rig has no physics bodies, so a code-only StaticBody2D proxy provides a
## collision surface matching its standing bounds. Props bounce/rest against it.
func _add_rig_collision_proxy() -> void:
var proxy := StaticBody2D.new()
proxy.name = "RigCollisionProxy"
proxy.position = RIG_PROXY_CENTER
var shape := CollisionShape2D.new()
shape.name = "CollisionShape2D"
var rect := RectangleShape2D.new()
rect.size = RIG_PROXY_SIZE
shape.shape = rect
proxy.add_child(shape)
add_child(proxy)
+1
View File
@@ -0,0 +1 @@
uid://cvejolojthtcs
+255
View File
@@ -0,0 +1,255 @@
@tool
class_name PropBlock
extends RigidBody2D
## PropBlock - Reusable dynamic vector prop (RigidBody2D).
##
## A root RigidBody2D with three children built in code — a Polygon2D (interior
## fill), a Line2D (crisp vector outline, round joints/caps), and a collision
## node: a CollisionPolygon2D with BUILD_SOLIDS for polygon props, or a
## CollisionShape2D with a CircleShape2D for circle props. Fully @tool: exported
## properties update the children live. A material preset selector sets mass,
## friction/bounce (PhysicsMaterial), and themed visuals.
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum ShapeType { POLYGON, CIRCLE }
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
# ---------------------------------------------------------------------------
# Child node names
# ---------------------------------------------------------------------------
const POLYGON_NODE_NAME := "Polygon2D"
const OUTLINE_NODE_NAME := "Outline"
const COLLISION_POLYGON_NODE_NAME := "CollisionPolygon2D"
const COLLISION_SHAPE_NODE_NAME := "CollisionShape2D"
## Segment count for the generated circle polygon/outline.
const CIRCLE_SEGMENTS: int = 48
const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
@export_enum("Polygon", "Circle") var shape_type: int = ShapeType.POLYGON:
set(value):
shape_type = value
_apply_shape()
@export var polygon_points: PackedVector2Array = PackedVector2Array():
set(value):
polygon_points = value
if shape_type == ShapeType.POLYGON:
_apply_polygon_geometry()
@export var radius: float = 32.0:
set(value):
radius = value
if shape_type == ShapeType.CIRCLE:
_apply_circle_geometry()
@export var fill_color: Color = DEFAULT_FILL_COLOR:
set(value):
fill_color = value
if _polygon != null:
_polygon.color = value
@export var outline_color: Color = DEFAULT_OUTLINE_COLOR:
set(value):
outline_color = value
if _outline != null:
_outline.default_color = value
@export var outline_width: float = DEFAULT_OUTLINE_WIDTH:
set(value):
outline_width = value
if _outline != null:
_outline.width = value
@export_enum("None", "Wood", "Rubber", "Cardboard", "Metal") var material_preset: int = MaterialPreset.NONE:
set(value):
material_preset = value
_apply_material_preset()
# ---------------------------------------------------------------------------
# Internal node references (built in _ready, @tool-safe)
# ---------------------------------------------------------------------------
var _polygon: Polygon2D
var _outline: Line2D
var _collision_polygon: CollisionPolygon2D
var _collision_shape: CollisionShape2D
var _circle_shape: CircleShape2D
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_ensure_children()
_apply_shape()
_apply_style()
# ---------------------------------------------------------------------------
# Internal build / apply
# ---------------------------------------------------------------------------
func _ensure_children() -> void:
_polygon = get_node_or_null(NodePath(POLYGON_NODE_NAME)) as Polygon2D
if _polygon == null:
_polygon = Polygon2D.new()
_polygon.name = POLYGON_NODE_NAME
add_child(_polygon)
_outline = get_node_or_null(NodePath(OUTLINE_NODE_NAME)) as Line2D
if _outline == null:
_outline = Line2D.new()
_outline.name = OUTLINE_NODE_NAME
_outline.joint_mode = Line2D.LINE_JOINT_ROUND
_outline.begin_cap_mode = Line2D.LINE_CAP_ROUND
_outline.end_cap_mode = Line2D.LINE_CAP_ROUND
add_child(_outline)
_collision_polygon = get_node_or_null(NodePath(COLLISION_POLYGON_NODE_NAME)) as CollisionPolygon2D
if _collision_polygon == null:
_collision_polygon = CollisionPolygon2D.new()
_collision_polygon.name = COLLISION_POLYGON_NODE_NAME
_collision_polygon.build_mode = CollisionPolygon2D.BUILD_SOLIDS
add_child(_collision_polygon)
_collision_shape = get_node_or_null(NodePath(COLLISION_SHAPE_NODE_NAME)) as CollisionShape2D
if _collision_shape == null:
_collision_shape = CollisionShape2D.new()
_collision_shape.name = COLLISION_SHAPE_NODE_NAME
_circle_shape = CircleShape2D.new()
_circle_shape.radius = radius
_collision_shape.shape = _circle_shape
add_child(_collision_shape)
else:
# A pre-existing collision shape (e.g. persisted in a scene) may already
# hold a circle shape; reuse it so radius updates keep working.
_circle_shape = _collision_shape.shape as CircleShape2D
func _apply_shape() -> void:
if shape_type == ShapeType.CIRCLE:
_apply_circle_geometry()
_set_collision_polygon_enabled(false)
_set_collision_shape_enabled(true)
else:
_apply_polygon_geometry()
_set_collision_polygon_enabled(true)
_set_collision_shape_enabled(false)
func _apply_polygon_geometry() -> void:
if _polygon != null:
_polygon.polygon = polygon_points
if _collision_polygon != null:
_collision_polygon.polygon = polygon_points if polygon_points.size() >= 3 else PackedVector2Array()
if _outline != null:
var outline_points := polygon_points.duplicate()
if not outline_points.is_empty():
outline_points.append(polygon_points[0])
_outline.points = outline_points
func _apply_circle_geometry() -> void:
var loop := PackedVector2Array()
for i: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(i) / float(CIRCLE_SEGMENTS)
loop.append(Vector2(cos(angle), sin(angle)) * radius)
if _polygon != null:
_polygon.polygon = loop
if _outline != null:
var outline_points := loop.duplicate()
if not outline_points.is_empty():
outline_points.append(loop[0])
_outline.points = outline_points
if _circle_shape != null:
_circle_shape.radius = radius
func _set_collision_polygon_enabled(enabled: bool) -> void:
if _collision_polygon != null:
_collision_polygon.disabled = not enabled
func _set_collision_shape_enabled(enabled: bool) -> void:
if _collision_shape != null:
_collision_shape.disabled = not enabled
func _apply_style() -> void:
if _polygon != null:
_polygon.color = fill_color
if _outline != null:
_outline.default_color = outline_color
_outline.width = outline_width
func _apply_material_preset() -> void:
mass = mass_for(material_preset)
physics_material_override = physics_material(material_preset)
if material_preset != MaterialPreset.NONE:
fill_color = tint_for(material_preset)
outline_color = tint_for(material_preset).darkened(0.55)
# ---------------------------------------------------------------------------
# Static preset factories
# ---------------------------------------------------------------------------
static func physics_material(preset: int) -> PhysicsMaterial:
var mat := PhysicsMaterial.new()
match preset:
MaterialPreset.WOOD:
mat.friction = 0.6
mat.bounce = 0.1
MaterialPreset.RUBBER:
mat.friction = 0.9
mat.bounce = 0.85
MaterialPreset.CARDBOARD:
mat.friction = 0.3
mat.bounce = 0.05
MaterialPreset.METAL:
mat.friction = 0.9
mat.bounce = 0.0
_:
mat.friction = 0.5
mat.bounce = 0.05
return mat
static func mass_for(preset: int) -> float:
match preset:
MaterialPreset.WOOD:
return 3.0
MaterialPreset.RUBBER:
return 0.5
MaterialPreset.CARDBOARD:
return 0.4
MaterialPreset.METAL:
return 8.0
_:
return 1.0
static func tint_for(preset: int) -> Color:
match preset:
MaterialPreset.WOOD:
return Color(0.55, 0.38, 0.2, 1.0)
MaterialPreset.RUBBER:
return Color(0.9, 0.2, 0.2, 1.0)
MaterialPreset.CARDBOARD:
return Color(0.85, 0.72, 0.45, 1.0)
MaterialPreset.METAL:
return Color(0.5, 0.55, 0.6, 1.0)
_:
return DEFAULT_FILL_COLOR
+1
View File
@@ -0,0 +1 @@
uid://sbrgty3kyjfv
+159
View File
@@ -0,0 +1,159 @@
class_name PropUtils
extends RefCounted
## PropUtils - Static factory for dynamic vector props.
##
## Primitive generators (box, ball, plank, triangle) return shape-payload
## dictionaries with default dimensions and color themes. spawn_prop instantiates
## a PropBlock, applies the payload + a physics material preset, sets an initial
## velocity, and adds it to a container. Custom polygon points are sanitized
## through TerrainUtils.sanitize_points().
# ---------------------------------------------------------------------------
# Preloaded dependencies (resolved directly, independent of the global class
# registry, so this script compiles even when the editor's class cache is stale)
# ---------------------------------------------------------------------------
const PropBlockScript := preload("res://scripts/prop_block.gd")
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
# ---------------------------------------------------------------------------
# Color themes
# ---------------------------------------------------------------------------
const WOOD_FILL := Color(0.55, 0.38, 0.2, 1.0)
const WOOD_OUTLINE := Color(0.25, 0.16, 0.06, 1.0)
const RUBBER_FILL := Color(0.9, 0.2, 0.2, 1.0)
const RUBBER_OUTLINE := Color(0.35, 0.05, 0.05, 1.0)
const METAL_FILL := Color(0.5, 0.55, 0.6, 1.0)
const METAL_OUTLINE := Color(0.15, 0.18, 0.22, 1.0)
const CARDBOARD_FILL := Color(0.85, 0.72, 0.45, 1.0)
const CARDBOARD_OUTLINE := Color(0.4, 0.32, 0.18, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
# ---------------------------------------------------------------------------
# Primitive generators
# ---------------------------------------------------------------------------
static func create_box(
size: Vector2 = Vector2(48.0, 48.0),
fill_color: Color = WOOD_FILL,
outline_color: Color = WOOD_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
var half := size * 0.5
return {
"type": PropBlockScript.ShapeType.POLYGON,
"points": PackedVector2Array([
Vector2(-half.x, -half.y),
Vector2(half.x, -half.y),
Vector2(half.x, half.y),
Vector2(-half.x, half.y),
]),
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
static func create_ball(
radius: float = 24.0,
fill_color: Color = RUBBER_FILL,
outline_color: Color = RUBBER_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
return {
"type": PropBlockScript.ShapeType.CIRCLE,
"radius": radius,
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
static func create_plank(
length: float = 160.0,
thickness: float = 16.0,
fill_color: Color = METAL_FILL,
outline_color: Color = METAL_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
var half_length := length * 0.5
var half_thickness := thickness * 0.5
return {
"type": PropBlockScript.ShapeType.POLYGON,
"points": PackedVector2Array([
Vector2(-half_length, -half_thickness),
Vector2(half_length, -half_thickness),
Vector2(half_length, half_thickness),
Vector2(-half_length, half_thickness),
]),
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
static func create_triangle(
base: float = 56.0,
height: float = 48.0,
fill_color: Color = CARDBOARD_FILL,
outline_color: Color = CARDBOARD_OUTLINE,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> Dictionary:
var half_base := base * 0.5
var half_height := height * 0.5
return {
"type": PropBlockScript.ShapeType.POLYGON,
"points": PackedVector2Array([
Vector2(-half_base, half_height),
Vector2(half_base, half_height),
Vector2(0.0, -half_height),
]),
"fill_color": fill_color,
"outline_color": outline_color,
"outline_width": outline_width,
}
# ---------------------------------------------------------------------------
# Factory spawner
# ---------------------------------------------------------------------------
## Instantiate a PropBlock from a shape payload and material preset, set an
## initial velocity, and add it to `container`. Returns the spawned prop.
static func spawn_prop(
container: Node,
position: Vector2,
shape_payload: Dictionary,
material_preset: int = PropBlockScript.MaterialPreset.WOOD,
initial_velocity: Vector2 = Vector2.ZERO
) -> PropBlockScript:
var prop: PropBlockScript = PropBlockScript.new()
prop.name = "PropBlock"
prop.position = position
prop.material_preset = material_preset
_apply_shape_payload(prop, shape_payload)
container.add_child(prop)
if initial_velocity != Vector2.ZERO:
prop.linear_velocity = initial_velocity
return prop
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
static func _apply_shape_payload(prop: PropBlockScript, payload: Dictionary) -> void:
var shape_type: int = int(payload.get("type", PropBlockScript.ShapeType.POLYGON))
prop.shape_type = shape_type
if shape_type == PropBlockScript.ShapeType.CIRCLE:
prop.radius = float(payload.get("radius", 24.0))
else:
var raw_points: PackedVector2Array = payload.get("points", PackedVector2Array())
prop.polygon_points = TerrainUtilsScript.sanitize_points(raw_points)
if payload.has("fill_color"):
prop.fill_color = payload["fill_color"]
if payload.has("outline_color"):
prop.outline_color = payload["outline_color"]
if payload.has("outline_width"):
prop.outline_width = float(payload["outline_width"])
+1
View File
@@ -0,0 +1 @@
uid://bb5newxhg2ifi
+117
View File
@@ -0,0 +1,117 @@
@tool
class_name TerrainBlock
extends StaticBody2D
## TerrainBlock - Reusable StaticBody2D vector-terrain component (Phase 1).
##
## A self-contained terrain block: a StaticBody2D root with three children built
## in code — a Polygon2D (interior fill), a Line2D (crisp vector outline, closed
## by appending the first vertex, rounded joints/caps), and a CollisionPolygon2D
## configured with BUILD_SOLIDS so concave terrain blocks collide correctly.
## Fully @tool: exported properties update the children live in the editor.
# ---------------------------------------------------------------------------
# Child node names
# ---------------------------------------------------------------------------
const POLYGON_NODE_NAME := "Polygon2D"
const OUTLINE_NODE_NAME := "Outline"
const COLLISION_NODE_NAME := "CollisionPolygon2D"
# ---------------------------------------------------------------------------
# Exported properties
# ---------------------------------------------------------------------------
## The terrain polygon's vertices (local space). The unified setter pushes the
## array to the Polygon2D and CollisionPolygon2D, and closes the Line2D loop by
## appending the first vertex to the end.
@export var polygon_points: PackedVector2Array = PackedVector2Array():
set(value):
polygon_points = value
_apply_points()
## Interior fill color (Polygon2D).
@export var fill_color: Color = Color(0.25, 0.55, 0.25, 1.0):
set(value):
fill_color = value
if _polygon != null:
_polygon.color = value
## Outline color (Line2D).
@export var outline_color: Color = Color(0.05, 0.10, 0.05, 1.0):
set(value):
outline_color = value
if _outline != null:
_outline.default_color = value
## Outline width in pixels (Line2D).
@export var outline_width: float = 2.0:
set(value):
outline_width = value
if _outline != null:
_outline.width = value
# ---------------------------------------------------------------------------
# Internal node references (built in _ready, @tool-safe)
# ---------------------------------------------------------------------------
var _polygon: Polygon2D
var _outline: Line2D
var _collision: CollisionPolygon2D
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_ensure_children()
_apply_points()
_apply_style()
# ---------------------------------------------------------------------------
# Internal build / apply
# ---------------------------------------------------------------------------
func _ensure_children() -> void:
_polygon = get_node_or_null(NodePath(POLYGON_NODE_NAME)) as Polygon2D
if _polygon == null:
_polygon = Polygon2D.new()
_polygon.name = POLYGON_NODE_NAME
add_child(_polygon)
_outline = get_node_or_null(NodePath(OUTLINE_NODE_NAME)) as Line2D
if _outline == null:
_outline = Line2D.new()
_outline.name = OUTLINE_NODE_NAME
_outline.joint_mode = Line2D.LINE_JOINT_ROUND
_outline.begin_cap_mode = Line2D.LINE_CAP_ROUND
_outline.end_cap_mode = Line2D.LINE_CAP_ROUND
add_child(_outline)
_collision = get_node_or_null(NodePath(COLLISION_NODE_NAME)) as CollisionPolygon2D
if _collision == null:
_collision = CollisionPolygon2D.new()
_collision.name = COLLISION_NODE_NAME
_collision.build_mode = CollisionPolygon2D.BUILD_SOLIDS
add_child(_collision)
## Pushes the current polygon_points to the Polygon2D and CollisionPolygon2D,
## and appends the first vertex to the Line2D to close the border loop.
func _apply_points() -> void:
if _polygon != null:
_polygon.polygon = polygon_points
if _collision != null:
_collision.polygon = polygon_points if polygon_points.size() >= 3 else PackedVector2Array()
if _outline != null:
var outline_points := polygon_points.duplicate()
if not outline_points.is_empty():
outline_points.append(polygon_points[0])
_outline.points = outline_points
func _apply_style() -> void:
if _polygon != null:
_polygon.color = fill_color
if _outline != null:
_outline.default_color = outline_color
_outline.width = outline_width
+1
View File
@@ -0,0 +1 @@
uid://bw31hvuc1uako
+104
View File
@@ -0,0 +1,104 @@
class_name TerrainUtils
extends RefCounted
## TerrainUtils - Static geometry engine for vector terrain (Phase 3).
##
## Two responsibilities: sanitize raw input points into a clean, grid-snapped,
## clockwise-ordered polygon; and spawn a configured TerrainBlock into a target
## container. Consumed by the standalone physics test harness; not referenced by
## the editor.
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_GRID_SIZE: float = 16.0
const DEFAULT_FILL_COLOR := Color(0.25, 0.55, 0.25, 1.0)
const DEFAULT_OUTLINE_COLOR := Color(0.05, 0.10, 0.05, 1.0)
const DEFAULT_OUTLINE_WIDTH: float = 2.0
# ---------------------------------------------------------------------------
# Point sanitization
# ---------------------------------------------------------------------------
## Snap raw points to a grid, drop redundant nodes, and enforce clockwise
## winding (required for solid collision decomposition). Returns a new array;
## the input is not modified.
static func sanitize_points(points: PackedVector2Array, grid_size: float = DEFAULT_GRID_SIZE) -> PackedVector2Array:
var cleaned := PackedVector2Array()
for p: Vector2 in points:
cleaned.append(_snap_to_grid(p, grid_size))
cleaned = _simplify_polyline(cleaned)
if cleaned.size() >= 3 and not Geometry2D.is_polygon_clockwise(cleaned):
cleaned.reverse()
return cleaned
# ---------------------------------------------------------------------------
# Factory spawner
# ---------------------------------------------------------------------------
## Sanitize `points`, create a new TerrainBlock, apply the cleaned points and
## styling, and add it to `container`. Returns the spawned block.
static func spawn_block(
container: Node,
points: PackedVector2Array,
grid_size: float = DEFAULT_GRID_SIZE,
fill_color: Color = DEFAULT_FILL_COLOR,
outline_color: Color = DEFAULT_OUTLINE_COLOR,
outline_width: float = DEFAULT_OUTLINE_WIDTH
) -> TerrainBlock:
var block := TerrainBlock.new()
block.name = "TerrainBlock"
block.polygon_points = sanitize_points(points, grid_size)
block.fill_color = fill_color
block.outline_color = outline_color
block.outline_width = outline_width
container.add_child(block)
return block
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
static func _snap_to_grid(p: Vector2, grid_size: float) -> Vector2:
if grid_size <= 0.0:
return p
return Vector2(roundf(p.x / grid_size) * grid_size, roundf(p.y / grid_size) * grid_size)
## Remove redundant nodes: consecutive duplicates (e.g. collapsed by grid
## snapping) and collinear middle vertices (redundant for both render and
## collision). NOTE: Geometry2D.simplify_polyline() does not exist in Godot
## 4.7.1, so this local pass stands in for it.
static func _simplify_polyline(points: PackedVector2Array) -> PackedVector2Array:
var deduped := PackedVector2Array()
for p: Vector2 in points:
if not deduped.is_empty() and deduped[deduped.size() - 1].is_equal_approx(p):
continue
deduped.append(p)
# Drop a closing duplicate (last vertex == first vertex). Polygons are
# treated as open here; otherwise the collinear pass below would treat the
# first/last vertices as "collinear" with each other and erase the first
# corner, producing a degenerate polygon.
if deduped.size() >= 2 and deduped[0].is_equal_approx(deduped[deduped.size() - 1]):
deduped.remove_at(deduped.size() - 1)
if deduped.size() < 3:
return deduped
var simplified := PackedVector2Array()
var n: int = deduped.size()
for i: int in n:
var prev := deduped[(i - 1 + n) % n]
var curr := deduped[i]
var next := deduped[(i + 1) % n]
if _is_collinear(prev, curr, next):
continue
simplified.append(curr)
return simplified if simplified.size() >= 3 else deduped
static func _is_collinear(a: Vector2, b: Vector2, c: Vector2) -> bool:
return absf((b - a).cross(c - b)) < 0.001
+1
View File
@@ -0,0 +1 @@
uid://cduiqhk7xpm4m