Note
Go to the end to download the full example code
Goo
This example shows how to implement a the basics of a WorldOfGoo clone
/home/runner/.local/lib/python3.10/site-packages/b2d/testbed/backend/matplotlib_gif_gui/matplotlib_gif_gui.py:52: UserWarning: You passed in an explicit save_count=240 which is being ignored in favor of frames=240.
self.ani = animation.FuncAnimation(
from b2d.testbed import TestbedBase
from dataclasses import dataclass
import math
import random
import numpy
import b2d
from functools import partial
import networkx
def best_pairwise_distance(data, f, distance):
n = len(data)
best = (None, None, float("inf"))
for i in range(n - 1):
da = f(data[i])
for j in range(i + 1, n):
db = f(data[j])
d = distance(da, db)
if d < best[2]:
best = (i, j, d)
return best
def rand_color():
return tuple([random.random() for i in range(3)])
class Level(object):
def __init__(self, testbed):
self.testbed = testbed
self.world = testbed.world
self.gap_size = 15
self.kill_sensors_height = 0.5
self.usable_size = 20
self.h = 10
self.end_zone_height = 3
self.outline_verts = [
(0, self.h),
(0, 2 * self.h),
(0, self.h),
(self.usable_size, self.h),
(self.usable_size, 0),
(self.usable_size + self.gap_size, 0),
(self.usable_size + self.gap_size, self.h),
(2 * self.usable_size + self.gap_size, self.h),
(2 * self.usable_size + self.gap_size, 2 * self.h),
]
# outline of the level
shape = b2d.chain_shape(vertices=numpy.flip(self.outline_verts, axis=0))
self.outline = self.world.create_static_body(position=(0, 0), shape=shape)
# kill sensors
self.kill_sensor_pos = (
self.usable_size + self.gap_size / 2,
self.kill_sensors_height / 2,
)
shape = b2d.polygon_shape(box=(self.gap_size / 2, self.kill_sensors_height / 2))
self._kill_sensor = self.world.create_static_body(
position=self.kill_sensor_pos,
fixtures=b2d.fixture_def(shape=shape, is_sensor=True),
)
self._kill_sensor.user_data = "destroyer"
# end sensor
shape = b2d.polygon_shape(box=(self.usable_size / 2, self.end_zone_height / 2))
self._end_sensor = self.world.create_static_body(
position=(
1.5 * self.usable_size + self.gap_size,
self.h + self.end_zone_height / 2,
),
fixtures=b2d.fixture_def(shape=shape, is_sensor=True),
)
self._end_sensor.user_data = "goal"
goo_radius = 1
a = self.testbed.insert_goo(
pos=(self.usable_size / 3, self.h + goo_radius), static=True
)
b = self.testbed.insert_goo(
pos=(self.usable_size * 2 / 3, self.h + goo_radius), static=True
)
c = self.testbed.insert_goo(
pos=(self.usable_size * 1 / 2, self.h + goo_radius + 4), static=False
)
self.testbed.connect_goos(a, b)
self.testbed.connect_goos(a, c)
self.testbed.connect_goos(b, c)
def draw(self, debug_draw):
# draw outline
for i in range(len(self.outline_verts) - 1):
debug_draw.draw_segment(
self.outline_verts[i],
self.outline_verts[i + 1],
color=(1, 1, 0),
line_width=0.3,
)
left = list(self.kill_sensor_pos)
left[0] -= self.gap_size / 2
left[1] += self.kill_sensors_height / 2
right = list(self.kill_sensor_pos)
right[0] += self.gap_size / 2
right[1] += self.kill_sensors_height / 2
debug_draw.draw_segment(left, right, (1, 0, 0), line_width=0.4)
class FindGoos(b2d.QueryCallback):
def __init__(self):
super(FindGoos, self).__init__()
self.goos = []
def report_fixture(self, fixture):
body = fixture.body
if body.user_data == "goo":
self.goos.append(body)
return True
class Goo(TestbedBase):
name = "Goo"
def __init__(self, settings=None):
super(Goo, self).__init__(settings=settings)
self.goo_graph = networkx.Graph()
self.level = Level(testbed=self)
# mouse related
self.last_mouse_pos = None
self.is_mouse_down = False
# callback to draw tentative placement
self.draw_callback = None
# goos marked for destruction
self.goo_to_destroy = []
# joints marked for destruction
self.joints_to_destroy = []
self.gamma = 0.003
self.break_threshold = 0.5
# time point when goo can be inserted
self.insert_time_point = 0
self.insert_delay = 1.0
# handle finishing of level
self.with_goal_contact = dict()
# amount of seconds one has to be in the finishing zone
self.win_delay = 3.0
# particle system will be defined an used on win!
# this is then used for some kind of fireworks
self.psystem = None
self.emitter = None
self.emitter_stop_time = None
self.emitter_start_time = None
# trigger some fireworks on win
def on_win(self, win_body):
if self.psystem is None:
# particle system
pdef = b2d.particle_system_def(
viscous_strength=0.9,
spring_strength=0.0,
damping_strength=100.5,
pressure_strength=1.0,
color_mixing_strength=0.05,
density=0.1,
)
self.psystem = self.world.create_particle_system(pdef)
self.psystem.radius = 0.1
self.psystem.damping = 0.5
emitter_def = b2d.RandomizedRadialEmitterDef()
emitter_def.emite_rate = 2000
emitter_def.lifetime = 0.9
emitter_def.enabled = True
emitter_def.inner_radius = 0.0
emitter_def.outer_radius = 0.1
emitter_def.velocity_magnitude = 1000.0
emitter_def.start_angle = 0
emitter_def.stop_angle = 2 * math.pi
emitter_def.transform = b2d.Transform(
win_body.position + b2d.vec2(0, 20), b2d.Rot(0)
)
self.emitter = b2d.RandomizedRadialEmitter(self.psystem, emitter_def)
self.emitter_stop_time = self.elapsed_time + 0.2
def draw_goo(self, pos, angle, body=None):
self.debug_draw.draw_solid_circle(pos, 1, axis=None, color=(1, 0, 1))
self.debug_draw.draw_circle(pos, 1.1, (1, 1, 1), line_width=0.1)
if body is not None:
centers = [
body.get_world_point((-0.3, 0.2)),
body.get_world_point((0.3, 0.2)),
]
for center in centers:
self.debug_draw.draw_solid_circle(
center, 0.4, axis=None, color=(1, 1, 1)
)
self.debug_draw.draw_solid_circle(
center, 0.2, axis=None, color=(0, 0, 0)
)
def draw_edge(self, pos_a, pos_b, stress):
no_stress = numpy.array([1, 1, 1])
has_stress = numpy.array([1, 0, 0])
color = (1.0 - stress) * no_stress + stress * has_stress
color = tuple([float(c) for c in color])
self.debug_draw.draw_segment(pos_a, pos_b, color=color, line_width=0.4)
def insert_goo(self, pos, static=False):
if static:
f = self.world.create_static_body
else:
f = self.world.create_dynamic_body
goo = f(
position=pos,
fixtures=b2d.fixture_def(shape=b2d.circle_shape(radius=1), density=1),
user_data="goo",
)
self.goo_graph.add_node(goo)
return goo
def connect_goos(self, goo_a, goo_b):
length = (goo_a.position - goo_b.position).length
joint = self.world.create_distance_joint(
goo_a,
goo_b,
stiffness=500,
damping=0.1,
length=length,
user_data=dict(length=length, stress=0),
)
self.goo_graph.add_edge(goo_a, goo_b, joint=joint)
def query_placement(self, pos):
radius = 8
# find all goos in around pos
pos = b2d.vec2(pos)
box = b2d.aabb(
lower_bound=pos - b2d.vec2(radius, radius),
upper_bound=pos + b2d.vec2(radius, radius),
)
query = FindGoos()
self.world.query_aabb(query, box)
goos = query.goos
n_goos = len(goos)
if n_goos >= 2:
# try to insert to goo as edge between
# 2 existing goos
def distance(a, b, p):
if self.goo_graph.has_edge(a[0], b[0]):
return float("inf")
return numpy.linalg.norm((a[1] + b[1]) / 2 - p)
i, j, best_dist = best_pairwise_distance(
goos,
f=lambda goo: (goo, numpy.array(goo.position)),
distance=partial(distance, p=pos),
)
if best_dist < 0.8:
def draw_callback():
self.draw_edge(goos[i].position, goos[j].position, stress=0)
def insert_callack():
self.connect_goos(goos[i], goos[j])
return True, draw_callback, insert_callack
# try to insert the goo as brand new
# goo and connect it with 2 existing goos
f = lambda goo: (goo, (goo.position - b2d.vec2(pos)).length)
def distance(a, b):
if not self.goo_graph.has_edge(a[0], b[0]):
return float("inf")
return a[1] + b[1]
i, j, best_dist = best_pairwise_distance(goos, f=f, distance=distance)
if best_dist < float("inf"):
def draw_callback():
self.draw_edge(pos, goos[i].position, stress=0)
self.draw_edge(pos, goos[j].position, stress=0)
self.draw_goo(pos, angle=None)
def insert_callack():
goo = self.insert_goo(pos=pos)
self.connect_goos(goo, goos[i])
self.connect_goos(goo, goos[j])
return True, draw_callback, insert_callack
return False, None, None
def on_mouse_down(self, pos):
self.last_mouse_pos = pos
self.is_mouse_down = True
can_be_placed, draw_callback, insert_callback = self.query_placement(pos)
if can_be_placed:
if self.elapsed_time < self.insert_time_point:
return True
self.draw_callback = draw_callback
return True
return False
def on_mouse_move(self, pos):
self.last_mouse_pos = pos
if self.is_mouse_down:
can_be_placed, draw_callback, insert_callback = self.query_placement(pos)
if can_be_placed:
if self.elapsed_time < self.insert_time_point:
return True
self.draw_callback = draw_callback
return True
else:
self.draw_callback = None
return False
def on_mouse_up(self, pos):
self.last_mouse_pos = pos
self.is_mouse_down = False
self.draw_callback = None
can_be_placed, draw_callback, insert_callback = self.query_placement(pos)
if can_be_placed:
if self.elapsed_time < self.insert_time_point:
return True
# self.draw_callback = draw_callback
insert_callback()
self.insert_time_point = self.elapsed_time + self.insert_delay
return True
return False
def begin_contact(self, contact):
body_a = contact.body_a
body_b = contact.body_b
if body_b.user_data == "goo":
body_a, body_b = body_b, body_a
user_data_a = body_a.user_data
user_data_b = body_b.user_data
if body_a.user_data == "goo":
if user_data_b == "destroyer":
self.goo_to_destroy.append(body_a)
elif user_data_b == "goal":
self.with_goal_contact[body_a] = self.elapsed_time + self.win_delay
def end_contact(self, contact):
body_a = contact.body_a
body_b = contact.body_b
if body_b.user_data == "goo":
body_a, body_b = body_b, body_a
user_data_a = body_a.user_data
user_data_b = body_b.user_data
if body_a.user_data == "goo":
if user_data_b == "goal":
if body_a in self.with_goal_contact:
del self.with_goal_contact[body_a]
def pre_step(self, dt):
# query if goo can be inserted
if (
self.is_mouse_down
and self.last_mouse_pos is not None
and self.draw_callback is None
):
can_be_placed, draw_callback, insert_callback = self.query_placement(
self.last_mouse_pos
)
if can_be_placed and self.elapsed_time >= self.insert_time_point:
self.draw_callback = draw_callback
# compute joint stress
for goo_a, goo_b, joint in self.goo_graph.edges(data="joint"):
jd = joint.user_data
# distance based stress
insert_length = jd["length"]
length = (goo_a.position - goo_b.position).length
d = length - insert_length
if d > 0:
# reaction force based stress
rf = joint.get_reaction_force(30).length
normalized_rf = 1.0 - math.exp(-rf * self.gamma)
jd["stress"] = normalized_rf / self.break_threshold
if normalized_rf > self.break_threshold:
self.joints_to_destroy.append((goo_a, goo_b, joint))
else:
jd["stress"] = 0
for goo_a, goo_b, joint in self.joints_to_destroy:
self.goo_graph.remove_edge(u=goo_a, v=goo_b)
self.world.destroy_joint(joint)
self.joints_to_destroy = []
# destroy goos
for goo in self.goo_to_destroy:
self.goo_graph.remove_node(goo)
self.world.destroy_body(goo)
# destroy all with wrong degree
while True:
destroyed_any = False
to_remove = []
for goo in self.goo_graph.nodes:
if self.goo_graph.degree(goo) < 2:
destroyed_any = True
to_remove.append(goo)
if not destroyed_any:
break
for goo in to_remove:
self.goo_graph.remove_node(goo)
self.world.destroy_body(goo)
self.goo_to_destroy = []
# check if we are done
for goo, finish_time in self.with_goal_contact.items():
if finish_time <= self.elapsed_time:
self.on_win(goo)
if self.emitter is not None:
if self.emitter_stop_time is not None:
if self.elapsed_time > self.emitter_stop_time:
self.emitter.enabled = False
self.emitter_start_time = self.elapsed_time + 0.4
self.emitter_stop_time = None
p = list(self.emitter.position)
p[0] += (random.random() - 0.5) * 10.0
p[1] += (random.random() - 0.5) * 2.0
self.emitter.position = p
if self.emitter_start_time is not None:
if self.elapsed_time > self.emitter_start_time:
self.emitter.enabled = True
self.emitter_start_time = None
self.emitter_stop_time = self.elapsed_time + 0.2
self.emitter.step(dt)
def post_debug_draw(self):
self.level.draw(self.debug_draw)
# draw mouse when mouse is down
if (
self.is_mouse_down
and self.last_mouse_pos is not None
and self.draw_callback is None
):
d = (self.insert_time_point - self.elapsed_time) / self.insert_delay
if d > 0:
d = d * math.pi * 2
x = math.sin(d)
y = math.cos(d)
p = self.last_mouse_pos[0] + x, self.last_mouse_pos[1] + y
self.debug_draw.draw_segment(
p, self.last_mouse_pos, color=(1, 0, 0), line_width=0.2
)
self.debug_draw.draw_circle(
self.last_mouse_pos, 1, (1, 0, 0), line_width=0.2
)
# draw the tentative placement
if self.draw_callback is not None:
self.draw_callback()
for goo_a, goo_b, joint in self.goo_graph.edges(data="joint"):
self.draw_edge(
goo_a.position, goo_b.position, stress=joint.user_data["stress"]
)
for goo in self.goo_graph:
self.draw_goo(goo.position, goo.angle, body=goo)
if __name__ == "__main__":
ani = b2d.testbed.run(Goo)
ani
Total running time of the script: ( 0 minutes 37.898 seconds)