Phase 14 - Organized directories.
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bhlmhegchc1cu
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj2xh11d1wk7j
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://sbrgty3kyjfv
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0s2ncqoqflus
|
||||
@@ -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"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb5newxhg2ifi
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://7cehuu3v8tig
|
||||
Reference in New Issue
Block a user