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:
@@ -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