656 lines
24 KiB
Python
656 lines
24 KiB
Python
import math
|
|
from collections import namedtuple
|
|
|
|
from renderable_line import (
|
|
BoxCollisionRenderable,
|
|
CircleCollisionRenderable,
|
|
TileBoxCollisionRenderable,
|
|
)
|
|
|
|
# collision shape types
|
|
CST_NONE = 0
|
|
"Don't use a CollisionShape"
|
|
CST_CIRCLE = 1
|
|
"Use a CircleCollisionShap"
|
|
CST_AABB = 2
|
|
"Use an AABBCollisionShape"
|
|
CST_TILE = 3
|
|
"""
|
|
Tile-based collision: generate multiple AABBCollisionShapes to approximate all
|
|
non-blank (character index 0) tiles of our GameObject's default Art's
|
|
"collision layer", whose string name is defined in GO.col_layer_name.
|
|
"""
|
|
|
|
# collision types
|
|
CT_NONE = 0
|
|
CT_PLAYER = 1
|
|
CT_GENERIC_STATIC = 2
|
|
CT_GENERIC_DYNAMIC = 3
|
|
|
|
# collision type groups, eg static and dynamic
|
|
CTG_STATIC = [CT_GENERIC_STATIC]
|
|
'"Collision type group", collections of CT_* values for more convenient checks.'
|
|
CTG_DYNAMIC = [CT_GENERIC_DYNAMIC, CT_PLAYER]
|
|
'"Collision type group", collections of CT_* values for more convenient checks.'
|
|
|
|
__pdoc__ = {}
|
|
# named tuples for collision structs that don't merit a class
|
|
Contact = namedtuple("Contact", ["overlap", "timestamp"])
|
|
__pdoc__["Contact"] = "Represents a contact between two objects."
|
|
|
|
ShapeOverlap = namedtuple("ShapeOverlap", ["x", "y", "dist", "area", "other"])
|
|
__pdoc__["ShapeOverlap"] = "Represents a CollisionShape's overlap with another."
|
|
|
|
|
|
class CollisionShape:
|
|
"""
|
|
Abstract class for a shape that can overlap and collide with other shapes.
|
|
Shapes are part of a Collideable which in turn is part of a GameObject.
|
|
"""
|
|
|
|
def resolve_overlaps_with_shapes(self, shapes):
|
|
"Resolve this shape's overlap(s) with given list of shapes."
|
|
overlaps = []
|
|
for other in shapes:
|
|
if other is self:
|
|
continue
|
|
overlap = self.get_overlap(other)
|
|
if overlap.dist < 0:
|
|
overlaps.append(overlap)
|
|
if len(overlaps) == 0:
|
|
return
|
|
# resolve collisions in order of largest -> smallest overlap
|
|
overlaps.sort(key=lambda item: item.area, reverse=True)
|
|
for i, old_overlap in enumerate(overlaps):
|
|
# resolve first overlap without recalculating
|
|
overlap = self.get_overlap(old_overlap.other) if i > 0 else overlaps[0]
|
|
self.resolve_overlap(overlap)
|
|
|
|
def resolve_overlap(self, overlap):
|
|
"Resolve this shape's given overlap."
|
|
other = overlap.other
|
|
# tell objects they're overlapping, pass penetration vector
|
|
a_coll_b, a_started_b = self.go.overlapped(other.go, overlap)
|
|
b_coll_a, b_started_a = other.go.overlapped(self.go, overlap)
|
|
# if either object says it shouldn't collide with other, don't
|
|
if not a_coll_b or not b_coll_a:
|
|
return
|
|
# push shapes apart according to mass
|
|
total_mass = max(0, self.go.mass) + max(0, other.go.mass)
|
|
if self.go.is_dynamic():
|
|
if not other.go.is_dynamic() or other.go.mass < 0:
|
|
a_push = overlap.dist
|
|
else:
|
|
a_push = (self.go.mass / total_mass) * overlap.dist
|
|
# move parent object, not shape
|
|
self.go.x += a_push * overlap.x
|
|
self.go.y += a_push * overlap.y
|
|
# update all shapes based on object's new position
|
|
self.go.collision.update_transform_from_object()
|
|
if other.go.is_dynamic():
|
|
if not self.go.is_dynamic() or self.go.mass < 0:
|
|
b_push = overlap.dist
|
|
else:
|
|
b_push = (other.go.mass / total_mass) * overlap.dist
|
|
other.go.x -= b_push * overlap.x
|
|
other.go.y -= b_push * overlap.y
|
|
other.go.collision.update_transform_from_object()
|
|
# call objs' started_colliding once collisions have been resolved
|
|
world = self.go.world
|
|
if a_started_b:
|
|
world.try_object_method(self.go, self.go.started_colliding, [other.go])
|
|
if b_started_a:
|
|
world.try_object_method(other.go, other.go.started_colliding, [self.go])
|
|
|
|
def get_overlapping_static_shapes(self):
|
|
"Return a list of static shapes that overlap with this shape."
|
|
overlapping_shapes = []
|
|
shape_left, shape_top, shape_right, shape_bottom = self.get_box()
|
|
# add padding to overlapping tiles check
|
|
if False:
|
|
padding = 0.01
|
|
shape_left -= padding
|
|
shape_top -= padding
|
|
shape_right += padding
|
|
shape_bottom += padding
|
|
for obj in self.go.world.objects.values():
|
|
if obj is self.go or not obj.should_collide() or obj.is_dynamic():
|
|
continue
|
|
# always check non-tile-based static shapes
|
|
if obj.collision_shape_type != CST_TILE:
|
|
overlapping_shapes += obj.collision.shapes
|
|
else:
|
|
# skip if even bounds don't overlap
|
|
obj_left, obj_top, obj_right, obj_bottom = obj.get_edges()
|
|
if not boxes_overlap(
|
|
shape_left,
|
|
shape_top,
|
|
shape_right,
|
|
shape_bottom,
|
|
obj_left,
|
|
obj_top,
|
|
obj_right,
|
|
obj_bottom,
|
|
):
|
|
continue
|
|
overlapping_shapes += obj.collision.get_shapes_overlapping_box(
|
|
shape_left, shape_top, shape_right, shape_bottom
|
|
)
|
|
return overlapping_shapes
|
|
|
|
|
|
class CircleCollisionShape(CollisionShape):
|
|
"CollisionShape using a circle area."
|
|
|
|
def __init__(self, loc_x, loc_y, radius, game_object):
|
|
self.x, self.y = loc_x, loc_y
|
|
self.radius = radius
|
|
self.go = game_object
|
|
|
|
def get_box(self):
|
|
"Return world coordinates of our bounds (left, top, right, bottom)"
|
|
return (
|
|
self.x - self.radius,
|
|
self.y - self.radius,
|
|
self.x + self.radius,
|
|
self.y + self.radius,
|
|
)
|
|
|
|
def is_point_inside(self, x, y):
|
|
"Return True if given point is inside this shape."
|
|
return (self.x - x) ** 2 + (self.y - y) ** 2 <= self.radius**2
|
|
|
|
def overlaps_line(self, x1, y1, x2, y2):
|
|
"Return True if this circle overlaps given line segment."
|
|
return circle_overlaps_line(self.x, self.y, self.radius, x1, y1, x2, y2)
|
|
|
|
def get_overlap(self, other):
|
|
"Return ShapeOverlap data for this shape's overlap with given other."
|
|
if type(other) is CircleCollisionShape:
|
|
px, py, pdist1, pdist2 = point_circle_penetration(
|
|
self.x, self.y, other.x, other.y, self.radius + other.radius
|
|
)
|
|
elif type(other) is AABBCollisionShape:
|
|
px, py, pdist1, pdist2 = circle_box_penetration(
|
|
self.x,
|
|
self.y,
|
|
other.x,
|
|
other.y,
|
|
self.radius,
|
|
other.halfwidth,
|
|
other.halfheight,
|
|
)
|
|
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
|
|
return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other)
|
|
|
|
|
|
class AABBCollisionShape(CollisionShape):
|
|
"CollisionShape using an axis-aligned bounding box area."
|
|
|
|
def __init__(self, loc_x, loc_y, halfwidth, halfheight, game_object):
|
|
self.x, self.y = loc_x, loc_y
|
|
self.halfwidth, self.halfheight = halfwidth, halfheight
|
|
self.go = game_object
|
|
# for CST_TILE objects, lists of tile(s) we cover
|
|
self.tiles = []
|
|
|
|
def get_box(self):
|
|
return (
|
|
self.x - self.halfwidth,
|
|
self.y - self.halfheight,
|
|
self.x + self.halfwidth,
|
|
self.y + self.halfheight,
|
|
)
|
|
|
|
def is_point_inside(self, x, y):
|
|
"Return True if given point is inside this shape."
|
|
return point_in_box(x, y, *self.get_box())
|
|
|
|
def overlaps_line(self, x1, y1, x2, y2):
|
|
"Return True if this box overlaps given line segment."
|
|
left, top, right, bottom = self.get_box()
|
|
return box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2)
|
|
|
|
def get_overlap(self, other):
|
|
"Return ShapeOverlap data for this shape's overlap with given other."
|
|
if type(other) is AABBCollisionShape:
|
|
px, py, pdist1, pdist2 = box_penetration(
|
|
self.x,
|
|
self.y,
|
|
other.x,
|
|
other.y,
|
|
self.halfwidth,
|
|
self.halfheight,
|
|
other.halfwidth,
|
|
other.halfheight,
|
|
)
|
|
elif type(other) is CircleCollisionShape:
|
|
px, py, pdist1, pdist2 = circle_box_penetration(
|
|
other.x,
|
|
other.y,
|
|
self.x,
|
|
self.y,
|
|
other.radius,
|
|
self.halfwidth,
|
|
self.halfheight,
|
|
)
|
|
# reverse result if we're shape B
|
|
px, py = -px, -py
|
|
area = abs(pdist1 * pdist2) if pdist1 < 0 else 0
|
|
return ShapeOverlap(x=px, y=py, dist=pdist1, area=area, other=other)
|
|
|
|
|
|
class Collideable:
|
|
"Collision component for GameObjects. Contains a list of shapes."
|
|
|
|
use_art_offset = False
|
|
"use game object's art_off_pct values"
|
|
|
|
def __init__(self, obj):
|
|
"Create new Collideable for given GameObject."
|
|
self.go = obj
|
|
self.cl = self.go.world.cl
|
|
self.renderables, self.shapes = [], []
|
|
self.tile_shapes = {}
|
|
"Dict of shapes accessible by (x,y) tile coordinates"
|
|
self.contacts = {}
|
|
"Dict of contacts with other objects, by object name"
|
|
self.create_shapes()
|
|
|
|
def create_shapes(self):
|
|
"""
|
|
Create collision shape(s) appropriate to our game object's
|
|
collision_shape_type value.
|
|
"""
|
|
self._clear_shapes()
|
|
if self.go.collision_shape_type == CST_NONE:
|
|
return
|
|
elif self.go.collision_shape_type == CST_CIRCLE:
|
|
self._create_circle()
|
|
elif self.go.collision_shape_type == CST_AABB:
|
|
self._create_box()
|
|
elif self.go.collision_shape_type == CST_TILE:
|
|
self.tile_shapes.clear()
|
|
self._create_merged_tile_boxes()
|
|
# update renderables once if static
|
|
if not self.go.is_dynamic():
|
|
self.update_renderables()
|
|
|
|
def _clear_shapes(self):
|
|
for r in self.renderables:
|
|
r.destroy()
|
|
self.renderables = []
|
|
for shape in self.shapes:
|
|
self.cl._remove_shape(shape)
|
|
self.shapes = []
|
|
"List of CollisionShapes"
|
|
|
|
def _create_circle(self):
|
|
x = self.go.x + self.go.col_offset_x
|
|
y = self.go.y + self.go.col_offset_y
|
|
shape = self.cl._add_circle_shape(x, y, self.go.col_radius, self.go)
|
|
self.shapes = [shape]
|
|
self.renderables = [CircleCollisionRenderable(shape)]
|
|
|
|
def _create_box(self):
|
|
x = self.go.x # + self.go.col_offset_x
|
|
y = self.go.y # + self.go.col_offset_y
|
|
shape = self.cl._add_box_shape(
|
|
x, y, self.go.col_width / 2, self.go.col_height / 2, self.go
|
|
)
|
|
self.shapes = [shape]
|
|
self.renderables = [BoxCollisionRenderable(shape)]
|
|
|
|
def _create_merged_tile_boxes(self):
|
|
"Create AABB shapes for a CST_TILE object"
|
|
# generate fewer, larger boxes!
|
|
frame = self.go.renderable.frame
|
|
if self.go.col_layer_name not in self.go.art.layer_names:
|
|
self.go.app.dev_log(
|
|
f"{self.go.name}: Couldn't find collision layer with name '{self.go.col_layer_name}'"
|
|
)
|
|
return
|
|
layer = self.go.art.layer_names.index(self.go.col_layer_name)
|
|
|
|
# tile is available if it's not empty and not already covered by a shape
|
|
def tile_available(tile_x, tile_y):
|
|
return (
|
|
self.go.art.get_char_index_at(frame, layer, tile_x, tile_y) != 0
|
|
and (tile_x, tile_y) not in self.tile_shapes
|
|
)
|
|
|
|
def tile_range_available(start_x, end_x, start_y, end_y):
|
|
for y in range(start_y, end_y + 1):
|
|
for x in range(start_x, end_x + 1):
|
|
if not tile_available(x, y):
|
|
return False
|
|
return True
|
|
|
|
for y in range(self.go.art.height):
|
|
for x in range(self.go.art.width):
|
|
if not tile_available(x, y):
|
|
continue
|
|
# determine how big we can make this box
|
|
# first fill left to right
|
|
end_x = x
|
|
while end_x < self.go.art.width - 1 and tile_available(end_x + 1, y):
|
|
end_x += 1
|
|
# then fill top to bottom
|
|
end_y = y
|
|
while end_y < self.go.art.height - 1 and tile_range_available(
|
|
x, end_x, y, end_y + 1
|
|
):
|
|
end_y += 1
|
|
# compute origin and halfsizes of box covering tile range
|
|
wx1, wy1 = self.go.get_tile_loc(x, y, tile_center=True)
|
|
wx2, wy2 = self.go.get_tile_loc(end_x, end_y, tile_center=True)
|
|
wx = (wx1 + wx2) / 2
|
|
halfwidth = (end_x - x) * self.go.art.quad_width
|
|
halfwidth /= 2
|
|
halfwidth += self.go.art.quad_width / 2
|
|
wy = (wy1 + wy2) / 2
|
|
halfheight = (end_y - y) * self.go.art.quad_height
|
|
halfheight /= 2
|
|
halfheight += self.go.art.quad_height / 2
|
|
shape = self.cl._add_box_shape(wx, wy, halfwidth, halfheight, self.go)
|
|
# fill in cell(s) in our tile collision dict,
|
|
# write list of tiles shape covers to shape.tiles
|
|
for tile_y in range(y, end_y + 1):
|
|
for tile_x in range(x, end_x + 1):
|
|
self.tile_shapes[(tile_x, tile_y)] = shape
|
|
shape.tiles.append((tile_x, tile_y))
|
|
r = TileBoxCollisionRenderable(shape)
|
|
# update renderable once to set location correctly
|
|
r.update()
|
|
self.shapes.append(shape)
|
|
self.renderables.append(r)
|
|
|
|
def get_shape_overlapping_point(self, x, y):
|
|
"Return shape if it's overlapping given point, None if no overlap."
|
|
tile_x, tile_y = self.go.get_tile_at_point(x, y)
|
|
return self.tile_shapes.get((tile_x, tile_y), None)
|
|
|
|
def get_shapes_overlapping_box(self, left, top, right, bottom):
|
|
"Return a list of our shapes that overlap given box."
|
|
shapes = []
|
|
tiles = self.go.get_tiles_overlapping_box(left, top, right, bottom)
|
|
for x, y in tiles:
|
|
shape = self.tile_shapes.get((x, y), None)
|
|
if shape and shape not in shapes:
|
|
shapes.append(shape)
|
|
return shapes
|
|
|
|
def update(self):
|
|
if self.go and self.go.is_dynamic():
|
|
self.update_transform_from_object()
|
|
|
|
def update_transform_from_object(self, obj=None):
|
|
"Snap our shapes to location of given object (if unspecified, our GO)."
|
|
obj = obj or self.go
|
|
# CST_TILE shouldn't run here, it's static-only
|
|
if obj.collision_shape_type == CST_TILE:
|
|
return
|
|
for shape in self.shapes:
|
|
shape.x = obj.x + obj.col_offset_x
|
|
shape.y = obj.y + obj.col_offset_y
|
|
|
|
def set_shape_color(self, shape, new_color):
|
|
"Set the color of a given shape's debug LineRenderable."
|
|
try:
|
|
shape_index = self.shapes.index(shape)
|
|
except ValueError:
|
|
return
|
|
self.renderables[shape_index].color = new_color
|
|
self.renderables[shape_index].build_geo()
|
|
self.renderables[shape_index].rebind_buffers()
|
|
|
|
def update_renderables(self):
|
|
for r in self.renderables:
|
|
r.update()
|
|
|
|
def render(self):
|
|
for r in self.renderables:
|
|
r.render()
|
|
|
|
def destroy(self):
|
|
for r in self.renderables:
|
|
r.destroy()
|
|
# remove our shapes from CollisionLord's shape list
|
|
for shape in self.shapes:
|
|
self.cl._remove_shape(shape)
|
|
|
|
|
|
class CollisionLord:
|
|
"""
|
|
Collision manager object, tracks Collideables, detects overlaps and
|
|
resolves collisions.
|
|
"""
|
|
|
|
iterations = 7
|
|
"""
|
|
Number of times to resolve collisions per update. Lower at own risk;
|
|
multi-object collisions require multiple iterations to settle correctly.
|
|
"""
|
|
|
|
def __init__(self, world):
|
|
self.world = world
|
|
self.ticks = 0
|
|
# list of objects processed for collision this frame
|
|
self.collisions_this_frame = []
|
|
self.reset()
|
|
|
|
def report(self):
|
|
print(
|
|
f"{self}: {len(self.dynamic_shapes)} dynamic shapes, {len(self.static_shapes)} static shapes"
|
|
)
|
|
|
|
def reset(self):
|
|
self.dynamic_shapes, self.static_shapes = [], []
|
|
|
|
def _add_circle_shape(self, x, y, radius, game_object):
|
|
shape = CircleCollisionShape(x, y, radius, game_object)
|
|
if game_object.is_dynamic():
|
|
self.dynamic_shapes.append(shape)
|
|
else:
|
|
self.static_shapes.append(shape)
|
|
return shape
|
|
|
|
def _add_box_shape(self, x, y, halfwidth, halfheight, game_object):
|
|
shape = AABBCollisionShape(x, y, halfwidth, halfheight, game_object)
|
|
if game_object.is_dynamic():
|
|
self.dynamic_shapes.append(shape)
|
|
else:
|
|
self.static_shapes.append(shape)
|
|
return shape
|
|
|
|
def _remove_shape(self, shape):
|
|
if shape in self.dynamic_shapes:
|
|
self.dynamic_shapes.remove(shape)
|
|
elif shape in self.static_shapes:
|
|
self.static_shapes.remove(shape)
|
|
|
|
def update(self):
|
|
"Resolve overlaps between all relevant world objects."
|
|
for _ in range(self.iterations):
|
|
# filter shape lists for anything out of room etc
|
|
valid_dynamic_shapes = []
|
|
for shape in self.dynamic_shapes:
|
|
if shape.go.should_collide():
|
|
valid_dynamic_shapes.append(shape)
|
|
for shape in valid_dynamic_shapes:
|
|
shape.resolve_overlaps_with_shapes(valid_dynamic_shapes)
|
|
for shape in valid_dynamic_shapes:
|
|
static_shapes = shape.get_overlapping_static_shapes()
|
|
shape.resolve_overlaps_with_shapes(static_shapes)
|
|
# check which objects stopped colliding
|
|
for obj in self.world.objects.values():
|
|
obj.check_finished_contacts()
|
|
self.ticks += 1
|
|
self.collisions_this_frame = []
|
|
|
|
|
|
# collision handling
|
|
|
|
|
|
def point_in_box(x, y, box_left, box_top, box_right, box_bottom):
|
|
"Return True if given point lies within box with given corners."
|
|
return box_left <= x <= box_right and box_bottom <= y <= box_top
|
|
|
|
|
|
def boxes_overlap(left_a, top_a, right_a, bottom_a, left_b, top_b, right_b, bottom_b):
|
|
"Return True if given boxes A and B overlap."
|
|
for x, y in (
|
|
(left_a, top_a),
|
|
(right_a, top_a),
|
|
(right_a, bottom_a),
|
|
(left_a, bottom_a),
|
|
):
|
|
if left_b <= x <= right_b and bottom_b <= y <= top_b:
|
|
return True
|
|
return False
|
|
|
|
|
|
def lines_intersect(x1, y1, x2, y2, x3, y3, x4, y4):
|
|
"Return True if given lines intersect."
|
|
denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
|
|
numer = (x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)
|
|
numer2 = (x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)
|
|
if denom == 0:
|
|
if numer == 0 and numer2 == 0:
|
|
# coincident
|
|
return False
|
|
# parallel
|
|
return False
|
|
ua = numer / denom
|
|
ub = numer2 / denom
|
|
return ua >= 0 and ua <= 1 and ub >= 0 and ub <= 1
|
|
|
|
|
|
def line_point_closest_to_point(point_x, point_y, x1, y1, x2, y2):
|
|
"Return point on given line that's closest to given point."
|
|
wx, wy = point_x - x1, point_y - y1
|
|
dir_x, dir_y = x2 - x1, y2 - y1
|
|
proj = wx * dir_x + wy * dir_y
|
|
if proj <= 0:
|
|
# line point 1 is closest
|
|
return x1, y1
|
|
vsq = dir_x**2 + dir_y**2
|
|
if proj >= vsq:
|
|
# line point 2 is closest
|
|
return x2, y2
|
|
else:
|
|
# closest point is between 1 and 2
|
|
return x1 + (proj / vsq) * dir_x, y1 + (proj / vsq) * dir_y
|
|
|
|
|
|
def circle_overlaps_line(circle_x, circle_y, radius, x1, y1, x2, y2):
|
|
"Return True if given circle overlaps given line."
|
|
# get closest point on line to circle center
|
|
closest_x, closest_y = line_point_closest_to_point(
|
|
circle_x, circle_y, x1, y1, x2, y2
|
|
)
|
|
dist_x, dist_y = closest_x - circle_x, closest_y - circle_y
|
|
return dist_x**2 + dist_y**2 <= radius**2
|
|
|
|
|
|
def box_overlaps_line(left, top, right, bottom, x1, y1, x2, y2):
|
|
"Return True if given box overlaps given line."
|
|
# TODO: determine if this is less efficient than slab method below
|
|
if point_in_box(x1, y1, left, top, right, bottom) and point_in_box(
|
|
x2, y2, left, top, right, bottom
|
|
):
|
|
return True
|
|
# check left/top/right/bottoms edges
|
|
return (
|
|
lines_intersect(left, top, left, bottom, x1, y1, x2, y2)
|
|
or lines_intersect(left, top, right, top, x1, y1, x2, y2)
|
|
or lines_intersect(right, top, right, bottom, x1, y1, x2, y2)
|
|
or lines_intersect(left, bottom, right, bottom, x1, y1, x2, y2)
|
|
)
|
|
|
|
|
|
def box_overlaps_ray(left, top, right, bottom, x1, y1, x2, y2):
|
|
"Return True if given box overlaps given ray."
|
|
# TODO: determine if this can be adapted for line segments
|
|
# (just a matter of setting tmin/tmax properly?)
|
|
tmin, tmax = -math.inf, math.inf
|
|
dir_x, dir_y = x2 - x1, y2 - y1
|
|
if abs(dir_x) > 0:
|
|
tx1 = (left - x1) / dir_x
|
|
tx2 = (right - x1) / dir_x
|
|
tmin = max(tmin, min(tx1, tx2))
|
|
tmax = min(tmax, max(tx1, tx2))
|
|
if abs(dir_y) > 0:
|
|
ty1 = (top - y1) / dir_y
|
|
ty2 = (bottom - y1) / dir_y
|
|
tmin = max(tmin, min(ty1, ty2))
|
|
tmax = min(tmax, max(ty1, ty2))
|
|
return tmax >= tmin
|
|
|
|
|
|
def point_circle_penetration(point_x, point_y, circle_x, circle_y, radius):
|
|
"Return normalized penetration x, y, and distance for given circles."
|
|
dx, dy = circle_x - point_x, circle_y - point_y
|
|
pdist = math.sqrt(dx**2 + dy**2)
|
|
# point is center of circle, arbitrarily project out in +X
|
|
if pdist == 0:
|
|
return 1, 0, -radius, -radius
|
|
# TODO: calculate other axis of intersection for area?
|
|
return dx / pdist, dy / pdist, pdist - radius, pdist - radius
|
|
|
|
|
|
def box_penetration(ax, ay, bx, by, ahw, ahh, bhw, bhh):
|
|
"Return penetration vector and magnitude for given boxes."
|
|
left_a, right_a = ax - ahw, ax + ahw
|
|
top_a, bottom_a = ay + ahh, ay - ahh
|
|
left_b, right_b = bx - bhw, bx + bhw
|
|
top_b, bottom_b = by + bhh, by - bhh
|
|
# A to left or right of B?
|
|
px = right_a - left_b if ax <= bx else right_b - left_a
|
|
# A above or below B?
|
|
py = top_b - bottom_a if ay >= by else top_a - bottom_b
|
|
dx, dy = bx - ax, by - ay
|
|
widths, heights = ahw + bhw, ahh + bhh
|
|
# return separating axis + penetration depth (+ other axis for area calc)
|
|
if widths + px - abs(dx) < heights + py - abs(dy):
|
|
if dx >= 0:
|
|
return 1, 0, -px, -py
|
|
elif dx < 0:
|
|
return -1, 0, -px, -py
|
|
else:
|
|
if dy >= 0:
|
|
return 0, 1, -py, -px
|
|
elif dy < 0:
|
|
return 0, -1, -py, -px
|
|
|
|
|
|
def circle_box_penetration(
|
|
circle_x, circle_y, box_x, box_y, circle_radius, box_hw, box_hh
|
|
):
|
|
"Return penetration vector and magnitude for given circle and box."
|
|
box_left, box_right = box_x - box_hw, box_x + box_hw
|
|
box_top, box_bottom = box_y + box_hh, box_y - box_hh
|
|
# if circle center inside box, use box-on-box penetration vector + distance
|
|
if point_in_box(circle_x, circle_y, box_left, box_top, box_right, box_bottom):
|
|
return box_penetration(
|
|
circle_x,
|
|
circle_y,
|
|
box_x,
|
|
box_y,
|
|
circle_radius,
|
|
circle_radius,
|
|
box_hw,
|
|
box_hh,
|
|
)
|
|
# find point on AABB edges closest to center of circle
|
|
# clamp = min(highest, max(lowest, val))
|
|
px = min(box_right, max(box_left, circle_x))
|
|
py = min(box_top, max(box_bottom, circle_y))
|
|
closest_x = circle_x - px
|
|
closest_y = circle_y - py
|
|
d = math.sqrt(closest_x**2 + closest_y**2)
|
|
pdist = circle_radius - d
|
|
if d == 0:
|
|
return
|
|
# TODO: calculate other axis of intersection for area?
|
|
return -closest_x / d, -closest_y / d, -pdist, -pdist
|