"""Physics/collision nodes: CharacterBody, CollisionShape, Area, ShapeCast (2D and 3D)."""
from __future__ import annotations
import logging
import math
import numpy as np
from .descriptors import Collision, Property, Signal
from .math.types import Vec2, Vec3
from .nodes_2d.node2d import Node2D
from .nodes_3d.node3d import Node3D
from .shapecast import ShapeCast2D, ShapeCast3D # noqa: F401
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Gizmo helper: generate circle line segments
# ---------------------------------------------------------------------------
_CIRCLE_SEGMENTS = 24
def _circle_lines_2d(cx: float, cy: float, radius: float, segments: int = _CIRCLE_SEGMENTS) -> list[tuple[Vec2, Vec2]]:
"""Return line segments approximating a circle in 2D."""
lines: list[tuple[Vec2, Vec2]] = []
step = math.tau / segments
prev = Vec2(cx + radius, cy)
for i in range(1, segments + 1):
angle = i * step
cur = Vec2(cx + radius * math.cos(angle), cy + radius * math.sin(angle))
lines.append((prev, cur))
prev = cur
return lines
def _circle_lines_3d(
center: Vec3, u: Vec3, v: Vec3, radius: float, segments: int = _CIRCLE_SEGMENTS,
) -> list[tuple[Vec3, Vec3]]:
"""Return line segments approximating a circle in 3D on the plane defined by u, v axes."""
lines: list[tuple[Vec3, Vec3]] = []
step = math.tau / segments
prev = center + u * radius
for i in range(1, segments + 1):
angle = i * step
cur = center + u * (radius * math.cos(angle)) + v * (radius * math.sin(angle))
lines.append((prev, cur))
prev = cur
return lines
# ============================================================================
# Shared mixins to deduplicate 2D/3D logic
# ============================================================================
class _CharacterBodyBase:
"""Shared logic for CharacterBody2D and CharacterBody3D."""
def _character_body_init(self):
"""Initialize contact tracking signals. Called from __init__."""
self.body_entered = Signal()
self.body_exited = Signal()
self._prev_contacts: set = set()
def _emit_contact_signals(self, current_contacts: set):
"""Compare current contacts with previous frame and emit enter/exit signals."""
for body in current_contacts - self._prev_contacts:
self.body_entered(body)
for body in self._prev_contacts - current_contacts:
self.body_exited(body)
self._prev_contacts = current_contacts
def is_on_floor(self) -> bool:
"""True if last collision normal aligns with floor_normal (dot > 0.7)."""
if not self.last_collision:
return False
return self.last_collision.normal.dot(self.floor_normal) > 0.7
def is_on_wall(self) -> bool:
"""True if last collision normal is mostly perpendicular to floor_normal."""
if not self.last_collision:
return False
return abs(self.last_collision.normal.dot(self.floor_normal)) < 0.3
def is_on_ceiling(self) -> bool:
"""True if last collision normal points opposite to floor_normal (dot < -0.7)."""
if not self.last_collision:
return False
return self.last_collision.normal.dot(self.floor_normal) < -0.7
class _CollisionShapeBase:
"""Shared logic for CollisionShape2D and CollisionShape3D."""
radius = Property(1.0, range=(0, 10000), hint="Collision radius")
def overlaps_point(self, point) -> bool:
"""Test if a point is inside this shape."""
diff = self.world_position - point
if hasattr(diff, 'z'):
return (diff.x**2 + diff.y**2 + diff.z**2) <= self.radius ** 2
return (diff.x*diff.x + diff.y*diff.y) <= self.radius * self.radius
class _AreaBase:
"""Shared logic for Area2D and Area3D."""
collision_layer = Property(1, hint="Collision layer bitmask")
collision_mask = Property(1, hint="Collision mask bitmask")
monitoring = Property(True, hint="Whether to detect overlaps")
def _layers_match(self, other) -> bool:
return bool(self.collision_mask & other.collision_layer)
def _any_shape_overlaps(self, my_shapes, other_shapes) -> bool:
return any(s.overlaps(o) for s in my_shapes for o in other_shapes)
def get_overlapping_bodies(self) -> list:
return list(self._overlapping_bodies)
def get_overlapping_areas(self) -> list:
return list(self._overlapping_areas)
def has_overlapping_bodies(self) -> bool:
return bool(self._overlapping_bodies)
def has_overlapping_areas(self) -> bool:
return bool(self._overlapping_areas)
# ============================================================================
# CharacterBody2D
# ============================================================================
[docs]
class CharacterBody2D(_CharacterBodyBase, Node2D):
"""2D node with velocity, movement, and collision response.
Uses CollisionShape2D children for circle or rectangle collision.
Screen coordinates: Y-down, so floor normal defaults to (0, -1).
"""
collision_layer = Property(1, hint="Collision layer bitmask", group="Collision")
collision_mask = Property(1, hint="Collision mask bitmask", group="Collision")
max_slides = Property(4, range=(1, 16), hint="Max collision iterations per frame", group="Collision")
def __init__(self, collision=None, **kwargs):
super().__init__(**kwargs)
self._character_body_init()
self.velocity = Vec2()
self.floor_normal = Vec2(0, -1) # Y-down screen coords: floor points up
self.last_collision: Collision | None = None
if collision is not None:
if isinstance(collision, int | float):
collision = CollisionShape2D(name="Collision", radius=collision)
elif isinstance(collision, tuple | list | np.ndarray):
collision = CollisionShape2D(name="Collision", extents=Vec2(collision))
self.collision = self.add_child(collision)
else:
self.collision = None
[docs]
def move_and_slide(self, dt: float = 1.0):
"""Apply velocity with collision response. Resolves overlaps and slides along surfaces."""
# Substep at high velocities to prevent tunneling through thin walls.
# If the frame displacement exceeds the collision radius, split into
# multiple smaller steps so no single step overshoots a wall.
speed_sq = self.velocity.x ** 2 + self.velocity.y ** 2
col_radius = self.collision.radius if self.collision else 8.0
disp = (speed_sq ** 0.5) * dt
steps = max(1, int(disp / col_radius) + 1) if disp > col_radius else 1
sub_dt = dt / steps
self.last_collision = None
all_contacted: set[CharacterBody2D] = set()
for _ in range(steps):
self.position += self.velocity * sub_dt
if not self.collision or not self._tree:
continue
for _ in range(self.max_slides):
overlaps = self.get_overlapping()
if not overlaps:
break
other = overlaps[0]
all_contacted.add(other)
pen = self.collision.get_penetration(other.collision)
if pen is None:
break
normal, penetration = pen
self.position += normal * penetration
vdot = self.velocity.dot(normal)
if vdot < 0:
self.velocity -= normal * vdot
self.last_collision = Collision(
normal=normal, collider=other,
position=other.collision.world_position + normal * other.collision.radius,
depth=penetration,
)
self._emit_contact_signals(all_contacted)
[docs]
def get_overlapping(self, group: str = None, body_type: type = None) -> list[CharacterBody2D]:
"""Find all CharacterBody2D nodes whose collision overlaps ours."""
if not self.collision or not self._tree:
return []
candidates = self._tree.get_group(group) if group else self._tree.root.find_all(CharacterBody2D)
return [
b for b in candidates
if b is not self
and (body_type is None or isinstance(b, body_type))
and isinstance(b, CharacterBody2D)
and b.collision
and self.collision.overlaps(b.collision)
]
# ============================================================================
# 2D collision helpers (circle / AABB)
# ============================================================================
def _circle_aabb_overlap(cp: Vec2, cr: float, rp: Vec2, re: Vec2) -> bool:
"""Circle (center *cp*, radius *cr*) vs AABB (center *rp*, half-extents *re*)."""
cx, cy = float(cp[0]), float(cp[1])
dx = cx - max(float(rp[0] - re[0]), min(cx, float(rp[0] + re[0])))
dy = cy - max(float(rp[1] - re[1]), min(cy, float(rp[1] + re[1])))
return dx * dx + dy * dy <= cr * cr
def _pen_cc(ap: Vec2, ar: float, bp: Vec2, br: float) -> tuple[Vec2, float] | None:
"""Circle-circle penetration: push *a* out of *b*."""
diff = ap - bp
dist_sq = diff.x * diff.x + diff.y * diff.y
radii = ar + br
if dist_sq > radii * radii:
return None
if dist_sq < 1e-10:
return (Vec2(0, -1), radii)
dist = math.sqrt(dist_sq)
return (diff * (1.0 / dist), radii - dist)
def _pen_rr(ap: Vec2, ae: Vec2, bp: Vec2, be: Vec2) -> tuple[Vec2, float] | None:
"""Rect-rect (AABB) penetration: push *a* out of *b*."""
dx, dy = ap.x - bp.x, ap.y - bp.y
ox = ae.x + be.x - abs(dx)
oy = ae.y + be.y - abs(dy)
if ox <= 0 or oy <= 0:
return None
if ox < oy:
return (Vec2(1.0 if dx > 0 else -1.0, 0.0), ox)
return (Vec2(0.0, 1.0 if dy > 0 else -1.0), oy)
def _pen_circle_rect(cp: Vec2, cr: float, rp: Vec2, re: Vec2) -> tuple[Vec2, float] | None:
"""Penetration of circle into rect. Normal pushes circle out of rect."""
cx, cy = float(cp[0]), float(cp[1])
rx, ry, ex, ey = float(rp[0]), float(rp[1]), float(re[0]), float(re[1])
# Closest point on rect to circle centre
clx = max(rx - ex, min(cx, rx + ex))
cly = max(ry - ey, min(cy, ry + ey))
dx, dy = cx - clx, cy - cly
dist_sq = dx * dx + dy * dy
if dist_sq > cr * cr:
return None
if dist_sq < 1e-10:
# Circle centre inside rect — push to nearest edge
to_l, to_r = cx - (rx - ex), (rx + ex) - cx
to_t, to_b = cy - (ry - ey), (ry + ey) - cy
m = min(to_l, to_r, to_t, to_b)
if m == to_l:
return (Vec2(-1, 0), to_l + cr)
if m == to_r:
return (Vec2(1, 0), to_r + cr)
if m == to_t:
return (Vec2(0, -1), to_t + cr)
return (Vec2(0, 1), to_b + cr)
dist = math.sqrt(dist_sq)
return (Vec2(dx / dist, dy / dist), cr - dist)
# ============================================================================
# CollisionShape2D
# ============================================================================
[docs]
class CollisionShape2D(_CollisionShapeBase, Node2D):
"""2D collision shape: circle (default) or axis-aligned rectangle.
Circle mode (default): set ``radius``. Rectangle mode: set ``extents``
to a ``Vec2(half_width, half_height)``. When ``extents`` is non-zero the
shape is treated as an AABB and ``radius`` is ignored for overlap tests.
"""
radius = Property(1.0, range=(0, 10000), hint="Collision radius (circle mode)", group="Collision")
extents = Property(Vec2(), hint="Half-size (rect mode; zero = circle)", group="Collision")
gizmo_colour = Property((0.2, 0.8, 0.2, 0.6), hint="Editor gizmo colour")
@property
def is_rect(self) -> bool:
"""True when extents are non-zero (rectangle mode)."""
e = self.extents
return float(e[0]) > 0 and float(e[1]) > 0
# -- overlap tests -------------------------------------------------------
[docs]
def overlaps(self, other: CollisionShape2D) -> bool:
"""Test overlap with another CollisionShape2D (circle or rect)."""
sr, orr = self.is_rect, other.is_rect
sp, op = self.world_position, other.world_position
if not sr and not orr:
diff = sp - op
radii = self.radius + other.radius
return diff.x * diff.x + diff.y * diff.y <= radii * radii
if sr and orr:
se, oe = self.extents, other.extents
return abs(sp.x - op.x) <= se.x + oe.x and abs(sp.y - op.y) <= se.y + oe.y
# One circle, one rect
if sr:
return _circle_aabb_overlap(op, other.radius, sp, self.extents)
return _circle_aabb_overlap(sp, self.radius, op, other.extents)
[docs]
def overlaps_point(self, point: tuple[float, float] | np.ndarray) -> bool:
"""Test if a point is inside this shape."""
p = self.world_position
if self.is_rect:
e = self.extents
return abs(float(point[0]) - p.x) <= e.x and abs(float(point[1]) - p.y) <= e.y
diff = p - point
return diff.x * diff.x + diff.y * diff.y <= self.radius * self.radius
# -- penetration for collision response ----------------------------------
[docs]
def get_penetration(self, other: CollisionShape2D) -> tuple[Vec2, float] | None:
"""Return ``(normal, depth)`` to push **self** out of **other**, or ``None``."""
sr, orr = self.is_rect, other.is_rect
sp, op = self.world_position, other.world_position
if not sr and not orr:
return _pen_cc(sp, self.radius, op, other.radius)
if sr and orr:
return _pen_rr(sp, self.extents, op, other.extents)
if not sr and orr:
return _pen_circle_rect(sp, self.radius, op, other.extents)
# self=rect, other=circle → flip circle-rect result
result = _pen_circle_rect(op, other.radius, sp, self.extents)
if result is None:
return None
n, d = result
return (Vec2(-n.x, -n.y), d)
# -- gizmo ---------------------------------------------------------------
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return wireframe line segments for the collision shape in world space."""
p = self.world_position
if self.is_rect:
e = self.extents
tl, tr = Vec2(p.x - e.x, p.y - e.y), Vec2(p.x + e.x, p.y - e.y)
br, bl = Vec2(p.x + e.x, p.y + e.y), Vec2(p.x - e.x, p.y + e.y)
return [(tl, tr), (tr, br), (br, bl), (bl, tl)]
return _circle_lines_2d(p.x, p.y, float(self.radius))
# ============================================================================
# Area2D — Trigger zone with enter/exit signals
# ============================================================================
[docs]
class Area2D(_AreaBase, Node2D):
"""2D trigger zone that detects overlapping bodies and areas.
Add CollisionShape2D children to define the detection region.
Emits body_entered/body_exited when CharacterBody2D nodes overlap,
and area_entered/area_exited for other Area2D nodes.
"""
collision_layer = Property(1, hint="Collision layer bitmask", group="Collision")
collision_mask = Property(1, hint="Collision mask bitmask", group="Collision")
monitoring = Property(True, hint="Whether to detect overlaps", group="Collision")
gizmo_colour = Property((0.2, 0.6, 1.0, 0.5), hint="Editor gizmo colour")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.body_entered = Signal()
self.body_exited = Signal()
self.area_entered = Signal()
self.area_exited = Signal()
self._overlapping_bodies: set[CharacterBody2D] = set()
self._overlapping_areas: set[Area2D] = set()
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return gizmo lines from child collision shapes, or a default diamond if none."""
lines: list[tuple[Vec2, Vec2]] = []
for child in self.children:
if isinstance(child, CollisionShape2D) and hasattr(child, 'get_gizmo_lines'):
lines.extend(child.get_gizmo_lines())
if not lines:
p = self.world_position
s = 15.0
corners = [Vec2(p.x, p.y - s), Vec2(p.x + s, p.y), Vec2(p.x, p.y + s), Vec2(p.x - s, p.y)]
for i in range(4):
lines.append((corners[i], corners[(i + 1) % 4]))
return lines
[docs]
def physics_process(self, dt: float):
if not self.monitoring or not self._tree:
return
my_shapes = self.find_all(CollisionShape2D)
if not my_shapes:
return
# Check bodies
current_bodies: set[CharacterBody2D] = set()
for body in self._tree.root.find_all(CharacterBody2D):
if not self._layers_match(body) or not body.collision:
continue
if isinstance(body.collision, CollisionShape2D):
body_shapes = [body.collision]
else:
body_shapes = body.find_all(CollisionShape2D)
if self._any_shape_overlaps(my_shapes, body_shapes):
current_bodies.add(body)
for body in current_bodies - self._overlapping_bodies:
self.body_entered(body)
for body in self._overlapping_bodies - current_bodies:
self.body_exited(body)
self._overlapping_bodies = current_bodies
# Check areas
current_areas: set[Area2D] = set()
for area in self._tree.root.find_all(Area2D):
if area is self or not self._layers_match(area):
continue
other_shapes = area.find_all(CollisionShape2D)
if self._any_shape_overlaps(my_shapes, other_shapes):
current_areas.add(area)
for area in current_areas - self._overlapping_areas:
self.area_entered(area)
for area in self._overlapping_areas - current_areas:
self.area_exited(area)
self._overlapping_areas = current_areas
# ============================================================================
# CharacterBody3D
# ============================================================================
[docs]
class CharacterBody3D(_CharacterBodyBase, Node3D):
"""3D node with velocity, movement, and collision response.
Uses sphere-sphere collision via CollisionShape3D children.
Y-up convention: floor normal defaults to (0, 1, 0).
"""
collision_layer = Property(1, hint="Collision layer bitmask", group="Collision")
collision_mask = Property(1, hint="Collision mask bitmask", group="Collision")
max_slides = Property(4, range=(1, 16), hint="Max collision iterations per frame", group="Collision")
def __init__(self, collision=None, **kwargs):
super().__init__(**kwargs)
self._character_body_init()
self.velocity: tuple[float, float, float] | np.ndarray = Vec3()
self.floor_normal = Vec3(0, 1, 0)
self.last_collision: Collision | None = None
if collision is not None:
if isinstance(collision, int | float):
collision = CollisionShape3D(name="Collision", radius=collision)
self.collision = self.add_child(collision)
else:
self.collision = None
[docs]
def move_and_slide(self, dt: float = 1.0):
"""Apply velocity with collision response. Resolves overlaps and slides along surfaces."""
self.position += self.velocity * dt
self.last_collision = None
if not self.collision or not self._tree:
self._emit_contact_signals(set())
return
contacted: set[CharacterBody3D] = set()
for _ in range(self.max_slides):
overlaps = self.get_overlapping()
if not overlaps:
break
other = overlaps[0]
contacted.add(other)
our_pos = self.collision.world_position
their_pos = other.collision.world_position
diff = our_pos - their_pos
dist = diff.length()
if dist < 1e-10:
normal = Vec3(0, 1, 0)
dist = 0.0
else:
normal = diff * (1.0 / dist)
penetration = (self.collision.radius + other.collision.radius) - dist
if penetration <= 0:
break
self.position += normal * penetration
vel = Vec3(self.velocity) if not isinstance(self.velocity, Vec3) else self.velocity
vdot = vel.dot(normal)
if vdot < 0:
self.velocity = vel - normal * vdot
contact = their_pos + normal * other.collision.radius
self.last_collision = Collision(
normal=normal, collider=other, position=contact, depth=penetration,
)
self._emit_contact_signals(contacted)
[docs]
def get_overlapping(self, group: str = None, body_type: type = None) -> list[CharacterBody3D]:
if not self.collision or not self._tree:
return []
candidates = self._tree.get_group(group) if group else self._tree.root.find_all(CharacterBody3D)
return [
b for b in candidates
if b is not self
and (body_type is None or isinstance(b, body_type))
and isinstance(b, CharacterBody3D)
and b.collision
and self.collision.overlaps(b.collision)
]
# ============================================================================
# CollisionShape3D
# ============================================================================
[docs]
class CollisionShape3D(_CollisionShapeBase, Node3D):
"""Sphere collision shape. Attach as child of a CharacterBody3D."""
radius = Property(1.0, range=(0, 10000), hint="Collision radius", group="Collision")
pickable = Property(False, hint="Receive input_event on mouse click")
gizmo_colour = Property((0.2, 0.8, 0.2, 0.6), hint="Editor gizmo colour")
[docs]
def overlaps(self, other: CollisionShape3D) -> bool:
diff = self.world_position - other.world_position
dist_sq = diff.x**2 + diff.y**2 + diff.z**2
radii = self.radius + other.radius
return dist_sq <= radii * radii
[docs]
def overlaps_point(self, point: tuple[float, float, float] | np.ndarray) -> bool:
diff = self.world_position - point
return (diff.x**2 + diff.y**2 + diff.z**2) <= self.radius ** 2
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return wireframe line segments for the collision sphere (3 circles: XY, XZ, YZ)."""
p = self.world_position
r = float(self.radius)
lines: list[tuple[Vec3, Vec3]] = []
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 1, 0), r)) # XY
lines.extend(_circle_lines_3d(p, Vec3(1, 0, 0), Vec3(0, 0, 1), r)) # XZ
lines.extend(_circle_lines_3d(p, Vec3(0, 1, 0), Vec3(0, 0, 1), r)) # YZ
return lines
# ============================================================================
# Area3D — Trigger zone with enter/exit signals
# ============================================================================
[docs]
class Area3D(_AreaBase, Node3D):
"""3D trigger zone that detects overlapping bodies and areas.
Add CollisionShape3D children to define the detection region.
Emits body_entered/body_exited when CharacterBody3D nodes overlap,
and area_entered/area_exited for other Area3D nodes.
"""
collision_layer = Property(1, hint="Collision layer bitmask", group="Collision")
collision_mask = Property(1, hint="Collision mask bitmask", group="Collision")
monitoring = Property(True, hint="Whether to detect overlaps", group="Collision")
gizmo_colour = Property((0.2, 0.6, 1.0, 0.5), hint="Editor gizmo colour")
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.body_entered = Signal()
self.body_exited = Signal()
self.area_entered = Signal()
self.area_exited = Signal()
self._overlapping_bodies: set[CharacterBody3D] = set()
self._overlapping_areas: set[Area3D] = set()
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec3, Vec3]]:
"""Return gizmo lines from child collision shapes, or a small axis cross if none."""
lines: list[tuple[Vec3, Vec3]] = []
for child in self.children:
if isinstance(child, CollisionShape3D) and hasattr(child, 'get_gizmo_lines'):
lines.extend(child.get_gizmo_lines())
if not lines:
p = self.world_position
s = 0.5
lines = [
(Vec3(p.x - s, p.y, p.z), Vec3(p.x + s, p.y, p.z)),
(Vec3(p.x, p.y - s, p.z), Vec3(p.x, p.y + s, p.z)),
(Vec3(p.x, p.y, p.z - s), Vec3(p.x, p.y, p.z + s)),
]
return lines
[docs]
def physics_process(self, dt: float):
if not self.monitoring or not self._tree:
return
my_shapes = self.find_all(CollisionShape3D)
if not my_shapes:
return
# Check bodies
current_bodies: set[CharacterBody3D] = set()
for body in self._tree.root.find_all(CharacterBody3D):
if not self._layers_match(body) or not body.collision:
continue
body_shapes = (
[body.collision] if isinstance(body.collision, CollisionShape3D) else body.find_all(CollisionShape3D)
)
if self._any_shape_overlaps(my_shapes, body_shapes):
current_bodies.add(body)
for body in current_bodies - self._overlapping_bodies:
self.body_entered(body)
for body in self._overlapping_bodies - current_bodies:
self.body_exited(body)
self._overlapping_bodies = current_bodies
# Check areas
current_areas: set[Area3D] = set()
for area in self._tree.root.find_all(Area3D):
if area is self or not self._layers_match(area):
continue
other_shapes = area.find_all(CollisionShape3D)
if self._any_shape_overlaps(my_shapes, other_shapes):
current_areas.add(area)
for area in current_areas - self._overlapping_areas:
self.area_entered(area)
for area in self._overlapping_areas - current_areas:
self.area_exited(area)
self._overlapping_areas = current_areas