@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") const PROP_STAGE_ROLES := preload("res://scripts/props/prop_stage_roles.gd") const PROP_USE_SPOT_DATA := preload("res://scripts/props/prop_use_spot_data.gd") const STAGE_PHYSICS := preload("res://scripts/stage/stage_physics_policy.gd") # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- enum ShapeType { POLYGON, CIRCLE } enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL } enum RuntimeRole { EDIT, START_STILL, START_PHYSICS, HELD, RELEASED } ## 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 var use_spots: Array[Dictionary] = [] var next_use_spot_number: int = 1 var stage_roles: Dictionary = {} var runtime_role: RuntimeRole = RuntimeRole.EDIT var wobble_on_impact: bool = false # --------------------------------------------------------------------------- # 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 const DEFAULT_WOBBLE_DURATION: float = 0.48 const DEFAULT_WOBBLE_ANGLE_DEGREES: float = 7.0 const DEFAULT_WOBBLE_OFFSET_LOCAL: float = 4.0 # --------------------------------------------------------------------------- # Exported properties # --------------------------------------------------------------------------- @export_enum("Polygon", "Circle") var shape_type: int = ShapeType.POLYGON: set(value): shape_type = value _apply_shape() @export var polygon_points: PackedVector2Array = PackedVector2Array(): set(value): polygon_points = value if shape_type == ShapeType.POLYGON: _apply_polygon_geometry() @export var radius: float = 32.0: set(value): radius = value if shape_type == ShapeType.CIRCLE: _apply_circle_geometry() @export var fill_color: Color = DEFAULT_FILL_COLOR: set(value): fill_color = value if _polygon != null: _polygon.color = value @export var outline_color: Color = DEFAULT_OUTLINE_COLOR: set(value): outline_color = value if _outline != null: _outline.default_color = value @export var outline_width: float = DEFAULT_OUTLINE_WIDTH: set(value): outline_width = value if _outline != null: _outline.width = value @export_enum("None", "Wood", "Rubber", "Cardboard", "Metal") var material_preset: int = MaterialPreset.NONE: set(value): material_preset = value _apply_material_preset() # --------------------------------------------------------------------------- # Internal node references (built in _ready, @tool-safe) # --------------------------------------------------------------------------- var _polygon: Polygon2D var _outline: Line2D var _collision_polygon: CollisionPolygon2D var _collision_shape: CollisionShape2D var _circle_shape: CircleShape2D 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 var _wobble_duration: float = DEFAULT_WOBBLE_DURATION var _wobble_angle_degrees: float = DEFAULT_WOBBLE_ANGLE_DEGREES var _wobble_offset_local: float = DEFAULT_WOBBLE_OFFSET_LOCAL var _wobble_elapsed: float = 0.0 var _wobble_active: bool = false var _wobble_paused: bool = false var _wobble_center: Vector2 = Vector2.ZERO var _wobble_baselines: Dictionary = {} var _last_physics_velocity: Vector2 = Vector2.ZERO var _has_physics_velocity_sample: 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) signal physics_profile_changed # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _ensure_children() _apply_shape() _apply_style() if stage_roles.is_empty(): stage_roles = PROP_STAGE_ROLES.new_placement(false) apply_runtime_role(RuntimeRole.EDIT) # 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) reset_impact_velocity_baseline() func _process(delta: float) -> void: if Engine.is_editor_hint() or not _wobble_active or _wobble_paused: return _wobble_elapsed = minf(_wobble_elapsed + maxf(delta, 0.0), _wobble_duration) var progress: float = clampf(_wobble_elapsed / _wobble_duration, 0.0, 1.0) if progress >= 1.0: cancel_wobble() return var envelope: float = (1.0 - progress) * (1.0 - progress) var phase: float = TAU * 2.5 * progress var angle: float = deg_to_rad(_wobble_angle_degrees) * sin(phase) * envelope var offset: float = _wobble_offset_local * cos(phase) * envelope _apply_wobble_pose(angle, offset) func _physics_process(_delta: float) -> void: if not freeze and runtime_role in [RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED]: _last_physics_velocity = linear_velocity _has_physics_velocity_sample = _last_physics_velocity.is_finite() func _exit_tree() -> void: cancel_wobble() func set_wobble_on_impact(enabled: bool) -> void: wobble_on_impact = enabled if not enabled: cancel_wobble() func set_wobble_style(duration: float, angle_degrees: float, offset_local: float) -> void: if not is_finite(duration) or not is_finite(angle_degrees) or not is_finite(offset_local): return _wobble_duration = maxf(0.05, duration) _wobble_angle_degrees = maxf(0.0, angle_degrees) _wobble_offset_local = maxf(0.0, offset_local) func preview_wobble() -> void: _start_wobble() func play_impact_wobble() -> void: if wobble_on_impact: _start_wobble() func cancel_wobble() -> void: for raw_node: Variant in _wobble_baselines.keys(): var node: Node2D = raw_node as Node2D if node == null or not is_instance_valid(node): continue var baseline: Dictionary = _wobble_baselines[raw_node] node.position = baseline["position"] as Vector2 node.rotation = float(baseline["rotation"]) node.scale = baseline["scale"] as Vector2 _wobble_baselines.clear() _wobble_active = false _wobble_paused = false _wobble_elapsed = 0.0 func set_wobble_paused(paused: bool) -> void: _wobble_paused = paused and _wobble_active func get_impact_velocity() -> Vector2: if runtime_role in [RuntimeRole.EDIT, RuntimeRole.START_STILL, RuntimeRole.HELD] or freeze: return Vector2.ZERO if _has_physics_velocity_sample and _last_physics_velocity.is_finite(): return _last_physics_velocity return linear_velocity if linear_velocity.is_finite() else Vector2.ZERO func reset_impact_velocity_baseline() -> void: _last_physics_velocity = linear_velocity if linear_velocity.is_finite() else Vector2.ZERO _has_physics_velocity_sample = true func _start_wobble() -> void: cancel_wobble() _capture_wobble_baselines() if _wobble_baselines.is_empty(): return _wobble_active = true _wobble_elapsed = 0.0 _apply_wobble_pose(0.0, _wobble_offset_local) func _capture_wobble_baselines() -> void: var bounds: Dictionary = {"min_x": INF, "min_y": INF, "max_x": -INF, "max_y": -INF} for child_name: String in [POLYGON_NODE_NAME, OUTLINE_NODE_NAME, "Visuals"]: var node: Node2D = get_node_or_null(NodePath(child_name)) as Node2D if node == null or not node.visible: continue _wobble_baselines[node] = {"position": node.position, "rotation": node.rotation, "scale": node.scale} _collect_artwork_bounds(node, bounds) if float(bounds["min_x"]) <= float(bounds["max_x"]): _wobble_center = Vector2( (float(bounds["min_x"]) + float(bounds["max_x"])) * 0.5, (float(bounds["min_y"]) + float(bounds["max_y"])) * 0.5 ) else: _wobble_center = get_use_spot_local_bounds().get_center() func _collect_artwork_bounds(node: Node, bounds: Dictionary) -> void: if node is Polygon2D: for point: Vector2 in (node as Polygon2D).polygon: _accumulate_artwork_point(to_local((node as Polygon2D).to_global(point)), bounds) elif node is Line2D: for point: Vector2 in (node as Line2D).points: _accumulate_artwork_point(to_local((node as Line2D).to_global(point)), bounds) for child: Node in node.get_children(): _collect_artwork_bounds(child, bounds) func _accumulate_artwork_point(point: Vector2, bounds: Dictionary) -> void: bounds["min_x"] = minf(float(bounds["min_x"]), point.x) bounds["min_y"] = minf(float(bounds["min_y"]), point.y) bounds["max_x"] = maxf(float(bounds["max_x"]), point.x) bounds["max_y"] = maxf(float(bounds["max_y"]), point.y) func _apply_wobble_pose(angle: float, offset: float) -> void: for raw_node: Variant in _wobble_baselines: var node: Node2D = raw_node as Node2D if node == null or not is_instance_valid(node): continue var baseline: Dictionary = _wobble_baselines[raw_node] var base_position: Vector2 = baseline["position"] as Vector2 node.position = _wobble_center + (base_position - _wobble_center).rotated(angle) + Vector2(offset, 0.0) node.rotation = float(baseline["rotation"]) + angle node.scale = baseline["scale"] as Vector2 func set_stage_roles(roles: Dictionary) -> void: stage_roles = PROP_STAGE_ROLES.normalize(roles, PROP_STAGE_ROLES.new_placement(has_usable_grip())) apply_stage_depth() if runtime_role != RuntimeRole.EDIT: apply_runtime_role(runtime_role) else: physics_profile_changed.emit() func apply_stage_depth() -> void: var normalized: Dictionary = PROP_STAGE_ROLES.normalize(stage_roles) stage_roles = normalized z_as_relative = false if bool(normalized.get("legacy_draw_order", false)): z_index = 0 else: z_index = -1 if String(normalized.get("depth", PROP_STAGE_ROLES.DEPTH_BEHIND)) == PROP_STAGE_ROLES.DEPTH_BEHIND else 1 func apply_runtime_role(role: RuntimeRole) -> void: runtime_role = role match role: RuntimeRole.EDIT: freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC freeze = true collision_layer = 0 collision_mask = 0 RuntimeRole.START_STILL: freeze_mode = RigidBody2D.FREEZE_MODE_STATIC freeze = true collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking()) collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking()) RuntimeRole.START_PHYSICS, RuntimeRole.RELEASED: freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC freeze = false collision_layer = STAGE_PHYSICS.prop_layer(is_actor_blocking()) collision_mask = STAGE_PHYSICS.prop_mask(is_actor_blocking()) RuntimeRole.HELD: freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC freeze = true collision_layer = 0 collision_mask = 0 if role != RuntimeRole.HELD: apply_stage_depth() # A frozen RigidBody2D can push its cached EDIT transform back onto the # node during the next physics sync. When PLAY scale has been moved into the # child geometry, keep the physics server's body transform at unit scale too # so that a START_STILL prop is not scaled twice on its first physics tick. if _play_scale_active and role != RuntimeRole.EDIT: _sync_transform_to_physics_server() physics_profile_changed.emit() func has_usable_grip() -> bool: var anchors: Dictionary = get_interaction_anchors() return not PropAnchorDataScript.compatible_grip_ids(anchors, 0).is_empty() or not PropAnchorDataScript.compatible_grip_ids(anchors, 1).is_empty() func can_be_picked_up() -> bool: return bool(stage_roles.get("can_pick_up", false)) and has_usable_grip() func is_actor_blocking() -> bool: if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT: return false if runtime_role == RuntimeRole.RELEASED: return bool(stage_roles.get("released_blocks_stickmen", true)) return bool(stage_roles.get("blocks_stickmen", false)) func can_receive_prop_contact() -> bool: if runtime_role == RuntimeRole.HELD or runtime_role == RuntimeRole.EDIT: return false if runtime_role == RuntimeRole.RELEASED: return bool(stage_roles.get("released_bumps_other_props", true)) return runtime_role == RuntimeRole.START_PHYSICS 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_use_spots() -> Array[Dictionary]: var result: Array[Dictionary] = [] for spot: Dictionary in use_spots: result.append(spot.duplicate(true)) return result func get_use_spot(spot_id: String) -> Dictionary: return PROP_USE_SPOT_DATA.find(use_spots, spot_id) func get_use_spot_local_bounds() -> Rect2: if _custom_shapes: return _visual_local_bounds if shape_type == ShapeType.CIRCLE: return Rect2(Vector2(-radius, -radius), Vector2(radius * 2.0, radius * 2.0)) if polygon_points.is_empty(): return Rect2() var minimum: Vector2 = polygon_points[0] var maximum: Vector2 = polygon_points[0] for point: Vector2 in polygon_points: minimum = minimum.min(point) maximum = maximum.max(point) return Rect2(minimum, maximum - minimum) func add_use_spot(local_position: Vector2) -> String: if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position): return "" var number: int = maxi(1, next_use_spot_number) var spot_id: String = "spot_%d" % number if not get_use_spot(spot_id).is_empty(): return "" use_spots.append({"id": spot_id, "number": number, "name": PROP_USE_SPOT_DATA.fallback_name(number), "position": local_position}) next_use_spot_number = number + 1 return spot_id func rename_use_spot(spot_id: String, spot_name: String) -> bool: for index: int in use_spots.size(): if String(use_spots[index].get("id", "")) != spot_id: continue var number: int = int(use_spots[index].get("number", 1)) var normalized_name: String = spot_name.strip_edges() use_spots[index]["name"] = normalized_name if not normalized_name.is_empty() else PROP_USE_SPOT_DATA.fallback_name(number) return true return false func move_use_spot(spot_id: String, local_position: Vector2) -> bool: if not local_position.is_finite() or not get_use_spot_local_bounds().has_point(local_position): return false for index: int in use_spots.size(): if String(use_spots[index].get("id", "")) == spot_id: use_spots[index]["position"] = local_position return true return false func erase_use_spot(spot_id: String) -> bool: for index: int in use_spots.size(): if String(use_spots[index].get("id", "")) == spot_id: use_spots.remove_at(index) return true return false func use_spot_world_position(spot_id: String) -> Vector2: var spot: Dictionary = get_use_spot(spot_id) if spot.is_empty(): return Vector2.INF var local_position: Vector2 = spot.get("position", Vector2.ZERO) var baked_scale: Vector2 = _play_instance_scale if _play_scale_active else Vector2.ONE return global_transform * (local_position * baked_scale) func use_spot_local_from_world(world_position: Vector2) -> Vector2: if not world_position.is_finite() or not _transform_is_invertible(global_transform): return Vector2.INF var local_position: Vector2 = to_local(world_position) return local_position if get_use_spot_local_bounds().has_point(local_position) else Vector2.INF func set_use_spots(spots: Array[Dictionary], next_number: int) -> void: use_spots.clear() for spot: Dictionary in spots: use_spots.append(spot.duplicate(true)) var highest: int = 0 for spot: Dictionary in use_spots: highest = maxi(highest, int(spot.get("number", 0))) next_use_spot_number = maxi(highest + 1, next_number) func _transform_is_invertible(value: Transform2D) -> bool: return value.x.is_finite() and value.y.is_finite() and value.origin.is_finite() and is_finite(value.determinant()) and absf(value.determinant()) > 0.000001 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: cancel_wobble() 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() _sync_transform_to_physics_server() func end_play_instance_scale() -> void: cancel_wobble() 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 _sync_transform_to_physics_server() -> void: var rid := get_rid() if not rid.is_valid(): return PhysicsServer2D.body_set_state(rid, PhysicsServer2D.BODY_STATE_TRANSFORM, global_transform) reset_physics_interpolation() 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