Phase 14 - Organized directories.

This commit is contained in:
2026-10-03 19:13:39 -04:00
parent 6666173c4f
commit d950fd9d9a
307 changed files with 1452 additions and 1110 deletions
+23
View File
@@ -0,0 +1,23 @@
class_name MissingPropPlaceholder
extends Node2D
var original_entry: Dictionary = {}
var missing_prop_path: String = ""
var stage_object_id: String = ""
const MARKER_SIZE := Vector2(180.0, 54.0)
func configure(entry: Dictionary) -> void:
original_entry = entry.duplicate(true)
missing_prop_path = String(entry.get("asset_id", ""))
stage_object_id = String(entry.get("stage_object_id", ""))
queue_redraw()
func get_area_rect() -> Rect2:
return Rect2(-MARKER_SIZE * 0.5, MARKER_SIZE)
func _draw() -> void:
var rect := get_area_rect()
draw_rect(rect, Color(0.76, 0.10, 0.10, 0.95), true)
draw_rect(rect, Color(1.0, 0.78, 0.78, 1.0), false, 2.0)
var asset_name := missing_prop_path.get_file()
draw_string(ThemeDB.fallback_font, rect.position + Vector2(10.0, 33.0), "Missing prop - %s" % asset_name, HORIZONTAL_ALIGNMENT_CENTER, rect.size.x - 20.0, 16, Color.WHITE)
@@ -0,0 +1 @@
uid://iwogmwpek8cc
+174
View File
@@ -0,0 +1,174 @@
class_name PropAnchorData
extends RefCounted
## Normalized, JSON-safe prop-anchor model shared by authoring tools.
const ORIGIN_ID: String = "origin"
const ROLE_STRINGS: PackedStringArray = ["pivot", "grip", "emitter", "impact", "mount", "custom"]
const ALLOWED_HAND_STRINGS: PackedStringArray = ["both", "left_only", "right_only"]
enum Hand { LEFT, RIGHT }
static func default_anchors() -> Dictionary:
return {ORIGIN_ID: _origin()}
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary:
var result: Dictionary = {}
if raw is Dictionary:
for raw_id: Variant in (raw as Dictionary):
if not (raw as Dictionary)[raw_id] is Dictionary:
if warn: push_warning("PropAnchorData: skipped malformed anchor.")
continue
var id: String = normalize_id(str(raw_id), result)
if id == ORIGIN_ID:
continue
var source: Dictionary = (raw as Dictionary)[raw_id] as Dictionary
var role: String = str(source.get("role", "custom")).to_lower()
if not ROLE_STRINGS.has(role) or role == "pivot":
if role == "pivot" and warn: push_warning("PropAnchorData: non-origin pivot changed to custom.")
role = "custom"
var hand: String = str(source.get("allowed_hands", "both")).to_lower()
if not ALLOWED_HAND_STRINGS.has(hand): hand = "both"
result[id] = {"role": role, "position": Vector2(_finite(source.get("x", source.get("position", Vector2.ZERO)), true), _finite(source.get("y", source.get("position", Vector2.ZERO)), false)), "rotation_deg": _wrap_deg(_finite(source.get("rotation_deg", 0.0))), "z_index_offset": clampi(int(source.get("z_index_offset", 0)), -5, 5), "allowed_hands": hand}
result[ORIGIN_ID] = _origin()
return result.duplicate(true)
static func serialize_anchors(anchors: Dictionary) -> Dictionary:
var normalized: Dictionary = normalize_anchors(anchors, false)
var result: Dictionary = {}
for id: String in normalized:
var anchor: Dictionary = normalized[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
result[id] = {"role": str(anchor.get("role", "custom")), "x": pos.x, "y": pos.y, "rotation_deg": float(anchor.get("rotation_deg", 0.0)), "z_index_offset": int(anchor.get("z_index_offset", 0)), "allowed_hands": str(anchor.get("allowed_hands", "both"))}
return result
static func editable_ids(anchors: Dictionary) -> Array[String]:
var result: Array[String] = []
for id: String in normalize_anchors(anchors, false):
if id != ORIGIN_ID:
result.append(id)
return result
static func preferred_editable_id(anchors: Dictionary, preferred_id: String = "") -> String:
var normalized: Dictionary = normalize_anchors(anchors, false)
if preferred_id != ORIGIN_ID and normalized.has(preferred_id):
return preferred_id
if normalized.has("primary_grip") and str(normalized["primary_grip"].get("role", "")) == "grip":
return "primary_grip"
for id: String in editable_ids(normalized):
if str(normalized[id].get("role", "")) == "grip":
return id
var ids: Array[String] = editable_ids(normalized)
return ids[0] if not ids.is_empty() else ""
static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
var normalized: Dictionary = normalize_anchors(anchors, false)
if not is_finite(delta.x) or not is_finite(delta.y):
push_warning("PropAnchorData: ignored non-finite anchor translation.")
return normalized
for id: String in editable_ids(normalized):
var anchor: Dictionary = normalized[id].duplicate(true)
anchor["position"] = anchor.get("position", Vector2.ZERO) + delta
normalized[id] = anchor
normalized[ORIGIN_ID] = _origin()
return normalized
static func try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
if not is_finite(delta.x) or not is_finite(delta.y):
return {"ok": false, "anchors": {}}
var result: Dictionary = normalize_anchors(anchors, false)
for id: String in editable_ids(result):
var anchor: Dictionary = result[id].duplicate(true)
var pos: Vector2 = anchor.get("position", Vector2.ZERO) + delta
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
anchor["position"] = pos; result[id] = anchor
result[ORIGIN_ID] = _origin()
return {"ok": true, "anchors": result}
static func try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary:
if not is_finite(angle_rad): return {"ok": false, "anchors": {}}
var result: Dictionary = normalize_anchors(anchors, false)
for id: String in editable_ids(result):
var anchor: Dictionary = result[id].duplicate(true)
var pos: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle_rad)
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
anchor["position"] = pos
anchor["rotation_deg"] = _wrap_deg(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle_rad))
result[id] = anchor
result[ORIGIN_ID] = _origin()
return {"ok": true, "anchors": result}
## Returns a fully normalized scaled copy, or {"ok": false} without changing
## the caller's data when the operation cannot be represented safely.
static func try_scale_non_origin(anchors: Dictionary, factor: float) -> Dictionary:
if not is_finite(factor) or factor <= 0.0:
return {"ok": false, "anchors": {}}
var normalized: Dictionary = normalize_anchors(anchors, false)
for id: String in editable_ids(normalized):
var anchor: Dictionary = normalized[id].duplicate(true)
var position: Vector2 = anchor.get("position", Vector2.ZERO) * factor
if not is_finite(position.x) or not is_finite(position.y):
return {"ok": false, "anchors": {}}
anchor["position"] = position
normalized[id] = anchor
normalized[ORIGIN_ID] = _origin()
return {"ok": true, "anchors": normalized}
static func normalize_id(candidate: String, anchors: Dictionary) -> String:
var id: String = candidate.strip_edges().to_lower()
var regex := RegEx.new(); regex.compile("[^a-z0-9]+")
id = regex.sub(id, "_", true).strip_edges().trim_prefix("_").trim_suffix("_")
if id.is_empty() or not id[0].is_valid_identifier(): id = "anchor"
id = id.substr(0, 32)
if id == ORIGIN_ID and not anchors.has(id): return id
var base: String = id; var suffix: int = 2
while anchors.has(id) or id == ORIGIN_ID:
id = base.substr(0, 32 - str(suffix).length()) + "_" + str(suffix); suffix += 1
return id
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]:
var ids: Array[String] = []
for id: String in normalize_anchors(anchors, false):
if str(normalize_anchors(anchors, false)[id].get("role", "")) == role: ids.append(id)
return ids
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]:
var result: Array[String] = []
for id: String in ids_for_role(anchors, "grip"):
var allowed: String = str(normalize_anchors(anchors, false)[id].get("allowed_hands", "both"))
if allowed == "both" or (hand == Hand.LEFT and allowed == "left_only") or (hand == Hand.RIGHT and allowed == "right_only"): result.append(id)
return result
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D:
var position: Vector2 = anchor.get("position", Vector2.ZERO)
var angle: float = deg_to_rad(float(anchor.get("rotation_deg", 0.0)))
if mirror_x: position.x = -position.x; angle = PI - angle
return Transform2D(angle, position)
static func solve_attachment_transform(target_transform: Transform2D, anchor: Dictionary, positive_scale: Vector2, mirror_x: bool) -> Transform2D:
var effective: Transform2D = local_transform(anchor, mirror_x)
var scale: Vector2 = Vector2(maxf(absf(positive_scale.x) if is_finite(positive_scale.x) else 1.0, 0.0001), maxf(absf(positive_scale.y) if is_finite(positive_scale.y) else 1.0, 0.0001))
var basis := Transform2D(target_transform.get_rotation() - effective.get_rotation(), Vector2.ZERO).scaled(scale)
basis.origin = target_transform.origin - basis.basis_xform(effective.origin)
return basis
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary:
var center: Vector2 = bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO
var horizontal: bool = bounds.size.x >= bounds.size.y
var length: float = bounds.size.x if horizontal else bounds.size.y
var angle: float = 0.0 if horizontal else 90.0
var endpoint: Vector2 = Vector2(bounds.end.x, center.y) if horizontal else Vector2(center.x, bounds.end.y)
var offset: Vector2 = (Vector2(minf(length * 0.2, 32.0), 0.0) if horizontal else Vector2(0.0, minf(length * 0.2, 32.0)))
var make := func(role: String, pos: Vector2) -> Dictionary: return {"role": role, "position": pos, "rotation_deg": angle, "z_index_offset": 0, "allowed_hands": "both"}
var out: Dictionary = default_anchors()
match preset:
"one_handed": out["primary_grip"] = make.call("grip", center)
"two_handed": out["primary_grip"] = make.call("grip", center - offset); out["secondary_grip"] = make.call("grip", center + offset)
"tool_nozzle": out["primary_grip"] = make.call("grip", center); out["emitter"] = make.call("emitter", endpoint)
"melee_edge": out["primary_grip"] = make.call("grip", center); out["impact"] = make.call("impact", endpoint)
return out
static func _origin() -> Dictionary: return {"role": "pivot", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
static func _finite(value: Variant, x: bool = true) -> float:
var n: float = value.x if value is Vector2 and x else (value.y if value is Vector2 else float(value))
return n if is_finite(n) else 0.0
static func _wrap_deg(value: float) -> float: return fposmod(value + 180.0, 360.0) - 180.0
+1
View File
@@ -0,0 +1 @@
uid://bhlmhegchc1cu
+101
View File
@@ -0,0 +1,101 @@
class_name PropAnchorOverlay
extends RefCounted
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
enum DragMode { NONE, TRANSLATE, ROTATE }
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
const MARKER_RADIUS: float = 9.0
const HIT_RADIUS: float = 15.0
const ROTATE_OFFSET: float = 28.0
const ROTATION_DOT_RADIUS: float = 5.0
const ROTATION_DOT_HIT_RADIUS: float = 12.0
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _selected_id: String = ""
var _show_inactive: bool = true
var _drag_mode: DragMode = DragMode.NONE
var _drag_offset: Vector2 = Vector2.ZERO
func set_anchor_data(data: Variant) -> void:
_anchors = PropAnchorDataScript.normalize_anchors(data)
if _selected_id == PropAnchorDataScript.ORIGIN_ID or not _anchors.has(_selected_id): _selected_id = ""
func get_anchor_data() -> Dictionary: return _anchors.duplicate(true)
func select_anchor(anchor_id: String) -> void:
var next_id: String = anchor_id if anchor_id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(anchor_id) else ""
if next_id != _selected_id:
_selected_id = next_id
anchor_selected.emit(_selected_id)
func get_selected_anchor_id() -> String: return _selected_id
func set_show_inactive(enabled: bool) -> void: _show_inactive = enabled
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String:
for id: String in _anchors:
if id == PropAnchorDataScript.ORIGIN_ID: continue
if not _visible(id): continue
var anchor: Dictionary = _anchors[id]
if world_pos.distance_to(anchor.get("position", Vector2.ZERO)) <= HIT_RADIUS / maxf(zoom, 0.001): return id
return ""
func begin_pointer(world_pos: Vector2, zoom: float) -> bool:
# The dot is intentionally outside the marker hit target, so test it before
# ordinary anchor selection. This matches the shape transform gizmo model.
if not _selected_id.is_empty() and _anchors.has(_selected_id):
var selected: Dictionary = _anchors[_selected_id]
var role: String = str(selected.get("role", "custom"))
var selected_pos: Vector2 = selected.get("position", Vector2.ZERO)
var dot: Vector2 = selected_pos + Vector2(0.0, ROTATE_OFFSET / maxf(zoom, 0.001))
if (role == "grip" or role == "emitter") and world_pos.distance_to(dot) <= ROTATION_DOT_HIT_RADIUS / maxf(zoom, 0.001):
_drag_mode = DragMode.ROTATE
return true
var id: String = hit_test_anchor(world_pos, zoom)
if id.is_empty(): return false
select_anchor(id)
var anchor: Dictionary = _anchors[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
var role: String = str(anchor.get("role", "custom"))
_drag_mode = DragMode.TRANSLATE; _drag_offset = pos - world_pos
return true
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void:
if _drag_mode == DragMode.NONE or _selected_id.is_empty(): return
var anchor: Dictionary = _anchors[_selected_id].duplicate(true)
if _drag_mode == DragMode.TRANSLATE:
var pos: Vector2 = world_pos + _drag_offset
if snap: pos = Vector2(snappedf(pos.x, grid_size), snappedf(pos.y, grid_size))
anchor["position"] = pos
else:
var angle: float = rad_to_deg((world_pos - anchor.get("position", Vector2.ZERO)).angle())
if ctrl: angle = snappedf(angle, 15.0)
anchor["rotation_deg"] = fposmod(angle + 180.0, 360.0) - 180.0
_anchors[_selected_id] = anchor
anchors_changed.emit(get_anchor_data())
func end_pointer() -> void:
if _drag_mode != DragMode.NONE: anchors_changed.emit(get_anchor_data())
_drag_mode = DragMode.NONE
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void:
for id: String in _anchors:
if id == PropAnchorDataScript.ORIGIN_ID: continue
if _visible(id): _draw_marker(canvas, id, _anchors[id], zoom, id == _selected_id)
func _visible(id: String) -> bool: return id != PropAnchorDataScript.ORIGIN_ID and (_show_inactive or id == _selected_id)
func _draw_marker(canvas: CanvasItem, id: String, anchor: Dictionary, zoom: float, selected: bool) -> void:
var pos: Vector2 = anchor.get("position", Vector2.ZERO); var radius: float = MARKER_RADIUS / maxf(zoom, 0.001)
var role: String = str(anchor.get("role", "custom")); var color: Color = {"pivot": Color.YELLOW, "grip": Color.CYAN, "emitter": Color.RED, "impact": Color.HOT_PINK, "mount": Color.MEDIUM_PURPLE}.get(role, Color.LIGHT_GRAY)
canvas.draw_circle(pos, radius + 2.0 / zoom, Color(0, 0, 0, 0.85))
if role == "pivot": canvas.draw_line(pos - Vector2(radius, 0), pos + Vector2(radius, 0), color, 2.0 / zoom); canvas.draw_line(pos - Vector2(0, radius), pos + Vector2(0, radius), color, 2.0 / zoom)
elif role == "impact": canvas.draw_colored_polygon(PackedVector2Array([pos + Vector2(0,-radius), pos + Vector2(radius,0), pos + Vector2(0,radius), pos + Vector2(-radius,0)]), color)
else: canvas.draw_circle(pos, radius, color, false, 2.0 / zoom)
if role == "grip" or role == "emitter":
var direction: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(float(anchor.get("rotation_deg", 0.0)))) * radius * 2.0
canvas.draw_line(pos, pos + direction, color, 2.0 / zoom)
if selected:
var dot: Vector2 = pos + Vector2(0.0, ROTATE_OFFSET / zoom)
canvas.draw_line(pos, dot, Color(0, 0, 0, 0.85), 4.0 / zoom)
canvas.draw_line(pos, dot, color, 2.0 / zoom)
canvas.draw_circle(dot, (ROTATION_DOT_RADIUS + 2.0) / zoom, Color(0, 0, 0, 0.85))
canvas.draw_circle(dot, ROTATION_DOT_RADIUS / zoom, color)
if selected: canvas.draw_arc(pos, radius + 5.0 / zoom, 0.0, TAU, 20, Color.YELLOW, 2.0 / zoom)
+1
View File
@@ -0,0 +1 @@
uid://cj2xh11d1wk7j
+430
View File
@@ -0,0 +1,430 @@
@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.
const PropVisuals := preload("res://scripts/props/prop_visuals.gd")
const PropAnchorDataScript := preload("res://scripts/props/prop_anchor_data.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum ShapeType { POLYGON, CIRCLE }
enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
## Stable library provenance used by Sandbox Stage scene files.
var spawn_id: String = ""
## Stable identity of this placed prop in one Sandbox Stage scene. Unlike
## spawn_id, this value is unique per instance and survives scene save/load.
var stage_object_id: String = ""
var interaction_anchors: Dictionary = {}
var interaction_reserved_by: int = 0
var interaction_owner_id: int = 0
# ---------------------------------------------------------------------------
# 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
const MAX_CONTACTS_REPORTED := 32
# ---------------------------------------------------------------------------
# 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
var _custom_shapes: bool = false
var _visual_local_bounds := Rect2()
var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
var _play_instance_scale: Vector2 = Vector2.ONE
var _play_scale_active: bool = false
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when this prop's body makes contact with another physics body.
## `other` is the body that entered contact (Phase 4 trigger source).
signal collided(other: Node, contact_normal: Vector2, contact_position: Vector2)
signal separated(other: Node)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_ensure_children()
_apply_shape()
_apply_style()
# Enable contact reporting so the body_entered signal fires (Phase 4).
contact_monitor = true
max_contacts_reported = MAX_CONTACTS_REPORTED
continuous_cd = RigidBody2D.CCD_MODE_CAST_SHAPE
if not body_entered.is_connected(_on_body_entered):
body_entered.connect(_on_body_entered)
if not body_exited.is_connected(_on_body_exited):
body_exited.connect(_on_body_exited)
func set_interaction_anchors(value: Dictionary) -> void:
interaction_anchors = PropAnchorDataScript.normalize_anchors(value)
func get_interaction_anchors() -> Dictionary:
return PropAnchorDataScript.normalize_anchors(interaction_anchors, false)
func ensure_default_grip() -> void:
var normalized := get_interaction_anchors()
if not PropAnchorDataScript.ids_for_role(normalized, "grip").is_empty():
interaction_anchors = normalized
return
var bounds := _visual_local_bounds
if bounds.size == Vector2.ZERO:
if shape_type == ShapeType.CIRCLE:
bounds = Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0))
elif polygon_points.size() >= 3:
var min_point := polygon_points[0]
var max_point := polygon_points[0]
for point: Vector2 in polygon_points:
min_point = min_point.min(point)
max_point = max_point.max(point)
bounds = Rect2(min_point, max_point - min_point)
interaction_anchors = PropAnchorDataScript.preset_layout("one_handed", bounds)
func _on_body_entered(body: Node) -> void:
var center: Vector2 = (body as Node2D).global_position if body is Node2D else global_position
var normal: Vector2 = (center - global_position).normalized()
var position: Vector2 = global_position
var direct_state := PhysicsServer2D.body_get_direct_state(get_rid())
if direct_state != null:
for index: int in direct_state.get_contact_count():
if direct_state.get_contact_collider_id(index) == body.get_instance_id():
var reported: Vector2 = direct_state.get_contact_local_normal(index)
if not reported.is_zero_approx():
normal = reported.normalized()
if normal.dot(center - global_position) < 0.0:
normal = -normal
position = direct_state.get_contact_local_position(index)
break
collided.emit(body, normal, position)
func _on_body_exited(body: Node) -> void:
separated.emit(body)
# ---------------------------------------------------------------------------
# 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 not _custom_shapes and material_preset != MaterialPreset.NONE:
fill_color = tint_for(material_preset)
outline_color = tint_for(material_preset).darkened(0.55)
func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2Array) -> void:
_ensure_children()
_custom_shapes = true
_polygon.visible = false
_outline.visible = false
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
_custom_collision_hull = collision_hull.duplicate()
_collision_polygon.polygon = _custom_collision_hull
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
if visuals == null:
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
for child: Node in visuals.get_children(): child.queue_free()
_visual_local_bounds = PropVisuals.build(visuals, shapes)
_apply_material_preset()
func has_custom_shapes() -> bool:
return _custom_shapes
func get_visual_local_bounds() -> Rect2:
return _visual_local_bounds
func get_interaction_scale() -> Vector2:
return _play_instance_scale if _play_scale_active else scale.abs()
## RigidBody2D transforms do not reliably retain scale during simulation. Move
## the authored uniform scale into child visuals and collision geometry for
## PLAY, then restore the root scale on return to EDIT for scene persistence.
func begin_play_instance_scale() -> void:
if _play_scale_active:
return
_play_instance_scale = scale
if _play_instance_scale.is_equal_approx(Vector2.ONE):
return
_play_scale_active = true
scale = Vector2.ONE
_apply_play_scale()
func end_play_instance_scale() -> void:
if not _play_scale_active:
return
_play_scale_active = false
_set_visual_scale(Vector2.ONE)
_apply_shape()
if _custom_shapes and _collision_polygon != null:
_collision_polygon.polygon = _custom_collision_hull
scale = _play_instance_scale
func _apply_play_scale() -> void:
_set_visual_scale(_play_instance_scale)
if _custom_shapes:
if _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(_custom_collision_hull, _play_instance_scale)
return
if shape_type == ShapeType.CIRCLE:
# A non-uniform legacy instance scale makes an ellipse. Use the generated
# polygon hull while simulating so collision follows both axes exactly.
if _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(_circle_loop(), _play_instance_scale)
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
elif _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(polygon_points, _play_instance_scale)
func _set_visual_scale(value: Vector2) -> void:
if _polygon != null:
_polygon.scale = value
if _outline != null:
_outline.scale = value
var visuals: Node2D = get_node_or_null(NodePath("Visuals")) as Node2D
if visuals != null:
visuals.scale = value
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
var result: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points:
result.append(point * value)
return result
func _circle_loop() -> PackedVector2Array:
var result: PackedVector2Array = PackedVector2Array()
for index: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
result.append(Vector2(cos(angle), sin(angle)) * radius)
return result
# ---------------------------------------------------------------------------
# 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
+114
View File
@@ -0,0 +1,114 @@
class_name PropLibrary
extends RefCounted
const PROP_UTILS := preload("res://scripts/props/prop_utils.gd")
const PROP_BLOCK := preload("res://scripts/props/prop_block.gd")
const PROP_FILE := preload("res://scripts/editors/prop/prop_file.gd")
const PROP_VISUALS := preload("res://scripts/props/prop_visuals.gd")
const CONTENT_PATHS := preload("res://scripts/shared/content_paths.gd")
static var _templates: Array[Dictionary] = []
static func get_entries() -> Array[Dictionary]:
if _templates.is_empty():
_build_templates()
return _templates
static func get_ids() -> Array[String]:
var ids: Array[String] = []
for t: Dictionary in get_entries():
ids.append(String(t["id"]))
return ids
static func get_entry(id: String) -> Dictionary:
for t: Dictionary in get_entries():
if String(t["id"]) == id:
return t
return make_custom_entry(id) if is_custom_path(id) else {}
static func scan(dir_path: String = PROP_FILE.DEFAULT_DIR) -> Array[Dictionary]:
var entries: Array[Dictionary] = get_entries().duplicate(true)
if not DirAccess.dir_exists_absolute(dir_path):
push_warning("PropLibrary: prop directory '%s' does not exist." % dir_path)
return entries
var directory: DirAccess = DirAccess.open(dir_path)
if directory == null:
return entries
var paths: Array[String] = []
directory.list_dir_begin()
var file_name: String = directory.get_next()
while not file_name.is_empty():
if not directory.current_is_dir() and file_name.to_lower().ends_with(PROP_FILE.FILE_EXT):
paths.append(dir_path.path_join(file_name))
file_name = directory.get_next()
directory.list_dir_end()
paths.sort_custom(func(a: String, b: String) -> bool: return a.nocasecmp_to(b) < 0)
for path: String in paths:
var entry := make_custom_entry(path)
if not entry.is_empty(): entries.append(entry)
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if bool(a.get("is_custom", false)) != bool(b.get("is_custom", false)): return not bool(a.get("is_custom", false))
var an := String(a.get("name", "")).to_lower(); var bn := String(b.get("name", "")).to_lower()
return an < bn if an != bn else String(a.get("path", "")).nocasecmp_to(String(b.get("path", ""))) < 0)
return entries
static func make_custom_entry(path: String) -> Dictionary:
var normalized_path: String = canonical_path(path)
if not is_custom_path(normalized_path): return {}
var raw: Dictionary = PROP_FILE.load(normalized_path)
if raw.is_empty():
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
return {}
var parsed: Dictionary = PROP_FILE.from_file_dict(raw)
if parsed.is_empty():
push_warning("PropLibrary: could not load custom prop '%s'." % normalized_path)
return {}
var shapes: Array[Dictionary] = parsed.get("shapes", [])
var hull: PackedVector2Array = PROP_VISUALS.collision_hull(shapes)
if shapes.is_empty() or hull.size() < 3:
push_warning("PropLibrary: custom prop '%s' has no valid closed collision geometry." % normalized_path)
return {}
var name := String(parsed.get("prop_name", "")).strip_edges()
if name.is_empty(): name = normalized_path.get_file().get_basename()
var preset: int = int(parsed.get("material_preset", PROP_BLOCK.MaterialPreset.NONE))
return {"id": normalized_path, "path": normalized_path, "name": name, "material_preset": preset, "material_label": PROP_FILE.preset_to_string(preset).capitalize(), "is_custom": true, "payload": PROP_FILE.flatten_to_payload(shapes), "shapes": shapes, "collision_hull": hull, "anchors": parsed.get("anchors", {})}
static func is_custom_path(path: String) -> bool:
return path.to_lower().ends_with(PROP_FILE.FILE_EXT) and (path.begins_with("res://") or path.begins_with("user://") or path.is_absolute_path())
static func canonical_path(path: String) -> String:
return CONTENT_PATHS.canonicalize(path.replace("\\", "/").simplify_path())
static func get_default_id() -> String:
return "crate"
static func _build_templates() -> void:
_templates = [
_make("crate", "Crate", PROP_BLOCK.MaterialPreset.WOOD, "Wood", PROP_UTILS.create_box()),
_make("ball", "Ball", PROP_BLOCK.MaterialPreset.RUBBER, "Rubber", PROP_UTILS.create_ball()),
_make("plank", "Plank", PROP_BLOCK.MaterialPreset.METAL, "Metal", PROP_UTILS.create_plank()),
_make("triangle", "Triangle", PROP_BLOCK.MaterialPreset.CARDBOARD, "Cardboard", PROP_UTILS.create_triangle()),
]
static func _make(id: String, name: String, preset: int, label: String, payload: Dictionary) -> Dictionary:
return {
"id": id,
"name": name,
"material_preset": preset,
"material_label": label,
"payload": payload,
"is_custom": false,
"path": "",
"shapes": [],
"collision_hull": PackedVector2Array(),
}
+1
View File
@@ -0,0 +1 @@
uid://b0s2ncqoqflus
+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/props/prop_block.gd")
const TerrainUtilsScript := preload("res://scripts/terrain/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
+89
View File
@@ -0,0 +1,89 @@
class_name PropVisuals
extends RefCounted
## Shared custom-prop drawing and collision approximation.
const CIRCLE_SEGMENTS: int = 24
static func build(parent: Node2D, shapes: Array[Dictionary]) -> Rect2:
var bounds := Rect2()
var has_bounds := false
for index: int in shapes.size():
var shape: Dictionary = shapes[index]
var points := _shape_points(shape)
if points.size() < 2:
push_warning("PropVisuals: skipped shape with insufficient points.")
continue
var group := Node2D.new()
group.name = "Shape_%d" % index
parent.add_child(group)
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
var fill := Color.from_string(String(shape.get("fill_color", "")), Color.WHITE)
var outline := Color.from_string(String(shape.get("outline_color", "")), Color.BLACK)
var width: float = maxf(0.0, float(shape.get("outline_width", 2.0)))
if closed and points.size() >= 3:
var polygon := Polygon2D.new()
polygon.name = "Polygon2D"
polygon.polygon = points
polygon.color = fill
group.add_child(polygon)
var line := Line2D.new()
line.name = "Line2D"
var loop: PackedVector2Array = points.duplicate()
if closed:
loop.append(points[0])
line.points = loop
line.default_color = outline
line.width = width
line.joint_mode = Line2D.LINE_JOINT_ROUND
line.begin_cap_mode = Line2D.LINE_CAP_ROUND
line.end_cap_mode = Line2D.LINE_CAP_ROUND
group.add_child(line)
for point: Vector2 in points:
if not has_bounds:
bounds = Rect2(point, Vector2.ZERO)
has_bounds = true
else:
bounds = bounds.expand(point)
return bounds
static func collision_hull(shapes: Array[Dictionary]) -> PackedVector2Array:
var points := PackedVector2Array()
for shape: Dictionary in shapes:
var closed := bool(shape.get("closed", String(shape.get("shape_type", "")) != "line"))
if closed:
points.append_array(_shape_points(shape))
if points.size() < 3:
return PackedVector2Array()
var hull: PackedVector2Array = Geometry2D.convex_hull(points)
if hull.size() < 3 or absf(_polygon_area(hull)) <= 0.001:
return PackedVector2Array()
return hull
static func _shape_points(shape: Dictionary) -> PackedVector2Array:
var raw: Variant = shape.get("points", PackedVector2Array())
var points := PackedVector2Array()
if raw is PackedVector2Array:
points = raw as PackedVector2Array
elif raw is Array:
for item: Variant in raw as Array:
if item is Vector2:
points.append(item as Vector2)
elif item is Dictionary:
var data: Dictionary = item as Dictionary
points.append(Vector2(float(data.get("x", 0.0)), float(data.get("y", 0.0))))
if points.is_empty() and String(shape.get("shape_type", "")) == "circle":
var radius: float = maxf(0.0, float(shape.get("radius", 24.0)))
for index: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
points.append(Vector2(cos(angle), sin(angle)) * radius)
return points
static func _polygon_area(points: PackedVector2Array) -> float:
var area: float = 0.0
for index: int in points.size():
var next: Vector2 = points[(index + 1) % points.size()]
area += points[index].cross(next)
return area * 0.5
+1
View File
@@ -0,0 +1 @@
uid://7cehuu3v8tig