Initial commit, SVG loading for physics sandbox doesnt work.

This commit is contained in:
csd4ni3l
2025-09-12 08:19:42 +02:00
commit 9c632d1bec
24 changed files with 2721 additions and 0 deletions

33
game/body_inventory.py Normal file
View File

@@ -0,0 +1,33 @@
import arcade, arcade.gui, math
class BodyInventory(arcade.gui.UIGridLayout):
def __init__(self, window_width, window_height, selected, items):
super().__init__(column_count=2, row_count=math.ceil(len(items) / 2), size_hint=(0.2, 0.1))
self.items = items
self.selected_item = selected
self.buttons = {}
self.window_width, self.window_height = window_width, window_height
n = 0
for name, image in items.items():
self.buttons[name] = self.add(arcade.gui.UITextureButton(width=(window_width * 0.2) / 4, height=(window_width * 0.2) / 4).with_background(texture=arcade.load_texture(image), color=arcade.color.WHITE if name == self.selected_item else arcade.color.TRANSPARENT_BLACK), column=n % 2, row=n // 2)
self.buttons[name].on_click = lambda event, name=name: self.change_to(name)
n += 1
def add_item(self, name, image):
if not (len(self.items) + 1 <= (self.column_count * self.row_count)):
self.row_count += 1
self._update_size_hints()
self.items[name] = image
self.buttons[name] = self.add(arcade.gui.UITextureButton(width=(self.window_width * 0.2) / 2, height=(self.window_height * 0.1) / math.ceil(len(self.items) / 2), color=arcade.color.TRANSPARENT_BLACK).with_background(texture=image), column=len(self.items) % 2, row=len(self.items) // 2)
self.buttons[name].on_click = lambda event, name=name: self.change_to(name)
def change_to(self, name):
self.buttons[self.selected_item] = self.buttons[self.selected_item].with_background(color=arcade.color.TRANSPARENT_BLACK)
self.selected_item = name
self.buttons[self.selected_item] = self.buttons[self.selected_item].with_background(color=arcade.color.WHITE)

77
game/boid.py Normal file
View File

@@ -0,0 +1,77 @@
import arcade, arcade.math, random, math
class Boid(arcade.Sprite):
def __init__(self, boid_num, x, y):
super().__init__(arcade.load_texture("assets/graphics/boid.png"), center_x=x, center_y=y)
random_angle = random.randint(0, 361)
self.boid_num = boid_num
self.direction = arcade.math.Vec2(math.cos(random_angle), math.sin(random_angle))
self.velocity = 5
self.radius = 10
self.w_separation = 1.0
self.w_cohesion = 1.0
self.w_alignment = 1.0
self.small_radius = 100
self.large_radius = 250
def calculate_separation(self, neighbours: list[int, arcade.math.Vec2, arcade.math.Vec2]):
steeraway_vectors = [arcade.math.Vec2(*self.position) - neighbour[2] for neighbour in neighbours]
if not steeraway_vectors:
return self.direction
return (sum(steeraway_vectors) / len(steeraway_vectors)).normalize()
def calculate_alignment(self, neighbours: list[int, arcade.math.Vec2, arcade.math.Vec2]):
directions = [neighbour[1] for neighbour in neighbours]
if not directions:
return self.direction
return (sum(directions) / len(directions)).normalize()
def calculate_cohesion(self, neighbours: list[int, arcade.math.Vec2, arcade.math.Vec2]):
positions = [neighbour[2] for neighbour in neighbours]
if not positions:
return self.direction
return ((sum(positions) / len(positions)) - self.position).normalize()
def update(self, window_width, window_height, boids):
small_radius_neighbours, large_radius_neighbours = [], []
for boid_data in boids:
distance = boid_data[2].distance(arcade.math.Vec2(*self.position))
if boid_data[0] == self.boid_num or distance > self.large_radius:
continue
if distance <= self.small_radius:
small_radius_neighbours.append(boid_data)
large_radius_neighbours.append(boid_data)
self.direction = self.w_separation * self.calculate_separation(small_radius_neighbours) + self.w_alignment * self.calculate_alignment(large_radius_neighbours) + self.w_cohesion * self.calculate_cohesion(large_radius_neighbours)
if self.direction.length() > 1:
self.direction = self.direction.normalize()
self.position += self.direction * self.velocity
self.angle = 90 - math.degrees(self.direction.heading())
if self.center_x <= self.radius:
self.center_x = self.radius
self.direction = self.direction.reflect(arcade.math.Vec2(1, 0))
elif self.center_x >= (window_width * 0.8) - self.radius:
self.center_x = (window_width * 0.8) - self.radius
self.direction = self.direction.reflect(arcade.math.Vec2(-1, 0))
if self.center_y <= self.radius:
self.center_y = self.radius
self.direction = self.direction.reflect(arcade.math.Vec2(0, 1))
elif self.center_y >= window_height - self.radius:
self.center_y = window_height - self.radius
self.direction = self.direction.reflect(arcade.math.Vec2(0, -1))

65
game/boid_simulator.py Normal file
View File

@@ -0,0 +1,65 @@
import arcade, arcade.gui, random
from game.boid import Boid
class Game(arcade.gui.UIView):
def __init__(self, pypresence_client):
super().__init__()
self.pypresence_client = pypresence_client
self.pypresence_client.update(state='Playing a simulator', details='Boids simulator', start=self.pypresence_client.start_time)
self.boid_sprites = arcade.SpriteList()
self.current_boid_num = 1
self.anchor = self.add_widget(arcade.gui.UIAnchorLayout(size_hint=(1, 1)))
self.settings_box = self.anchor.add(arcade.gui.UIBoxLayout(space_between=5, align="center", size_hint=(0.2, 1)).with_background(color=arcade.color.GRAY), anchor_x="right", anchor_y="bottom")
self.settings_label = self.settings_box.add(arcade.gui.UILabel(text="Settings", font_size=24))
self.add_setting("Separation Weight: {value}", 0.1, 5, 0.1, "w_separation")
self.add_setting("Alignment Weight: {value}", 0.1, 5, 0.1, "w_alignment")
self.add_setting("Cohesion Weight: {value}", 0.1, 5, 0.1, "w_cohesion")
self.add_setting("Small Radius: {value}", 25, 250, 25, "small_radius", 100)
self.add_setting("Large Radius: {value}", 50, 500, 50, "large_radius", 250)
def add_setting(self, text, min_value, max_value, step, boid_variable, default=None):
label = self.settings_box.add(arcade.gui.UILabel(text.format(value=default or 1.0)))
slider = self.settings_box.add(arcade.gui.UISlider(value=1.0, min_value=min_value, max_value=max_value, step=step, size_hint=(1, 0.05)))
slider._render_steps = lambda surface: None
slider.on_change = lambda event, label=label: self.change_value(label, text, boid_variable, event.new_value)
def change_value(self, label, text, boid_variable, value):
label.text = text.format(value=value)
for boid in self.boid_sprites:
setattr(boid, boid_variable, value)
def create_boid(self, x, y):
boid = Boid(self.current_boid_num, x, y)
self.boid_sprites.append(boid)
self.current_boid_num += 1
def setup_boids(self):
for i in range(25):
self.create_boid(random.randint(self.window.width / 2 - 150, self.window.width / 2), random.randint(self.window.height / 2 - 150, self.window.height / 2))
def on_show_view(self):
super().on_show_view()
self.setup_boids()
def on_update(self, delta_time):
boid_directions = [(boid.boid_num, boid.direction, arcade.math.Vec2(*boid.position)) for boid in self.boid_sprites]
for boid in self.boid_sprites:
boid.update(self.window.width, self.window.height, boid_directions)
if self.window.mouse[arcade.MOUSE_BUTTON_LEFT]:
self.create_boid(self.window.mouse.data["x"], self.window.mouse.data["y"])
def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.ESCAPE:
from menus.main import Main
self.window.show_view(Main(self.pypresence_client))
def on_draw(self):
super().on_draw()
self.boid_sprites.draw()

372
game/physics_playground.py Normal file
View File

@@ -0,0 +1,372 @@
import arcade, arcade.gui, pymunk, pymunk.util, math, time, os, io, cairosvg
from PIL import Image
from arcade.gui.experimental.scroll_area import UIScrollArea, UIScrollBar
from svgpathtools import svg2paths
from game.body_inventory import BodyInventory
from utils.constants import menu_background_color, button_style
from utils.preload import button_texture, button_hovered_texture
class SpritePhysics(arcade.Sprite):
def __init__(self, pymunk_obj, filename):
super().__init__(filename, center_x=pymunk_obj.body.position.x, center_y=pymunk_obj.body.position.y)
self.pymunk_obj = pymunk_obj
self.origin_x = 0
self.origin_y = 0
class PhysicsCoin(SpritePhysics):
def __init__(self, pymunk_obj, filename):
super().__init__(pymunk_obj, filename)
self.width = pymunk_obj.radius * 2
self.height = pymunk_obj.radius * 2
class PhysicsCrate(SpritePhysics):
def __init__(self, pymunk_obj, filename, width, height):
super().__init__(pymunk_obj, filename)
self.width = width
self.height = height
class Game(arcade.gui.UIView):
def __init__(self, pypresence_client):
super().__init__()
self.pypresence_client = pypresence_client
self.pypresence_client.update(state='Playing a simulator', details='Physics Playground', start=self.pypresence_client.start_time)
arcade.set_background_color(arcade.color.WHITE)
self.space = pymunk.Space()
self.spritelist: arcade.SpriteList[SpritePhysics] = arcade.SpriteList()
self.walls = []
self.custom_pymunk_objs = {}
self.dragged_shape = None
self.last_mouse_position = 0, 0
self.last_processing_time_update = time.perf_counter()
self.iterations = 35
self.space.iterations = self.iterations
self.gravity_x = 0
self.gravity_y = -900
self.space.gravity = (self.gravity_x, self.gravity_y)
self.crate_elasticity = 0.5
self.crate_friction = 0.9
self.crate_mass = 1
self.coin_elasticity = 0.5
self.coin_friction = 0.9
self.coin_mass = 1
self.anchor = self.add_widget(arcade.gui.UIAnchorLayout(size_hint=(1, 1)))
self.info_box = self.anchor.add(arcade.gui.UIBoxLayout(space_between=3, align="left"), anchor_x="left", anchor_y="top")
self.fps_label = self.info_box.add(arcade.gui.UILabel(text="FPS: 60", text_color=arcade.color.BLACK))
self.object_count_label = self.info_box.add(arcade.gui.UILabel(text="Object count: 0", text_color=arcade.color.BLACK))
self.processing_time_label = self.info_box.add(arcade.gui.UILabel(text="Processing time: 0 ms", text_color=arcade.color.BLACK))
self.settings_box = self.anchor.add(arcade.gui.UIBoxLayout(space_between=5, align="center", size_hint=(0.2, 1)).with_background(color=arcade.color.GRAY), anchor_x="right", anchor_y="bottom")
self.settings_title_label = self.settings_box.add(arcade.gui.UILabel(text="Settings", font_size=24))
self.settings_box.add(arcade.gui.UISpace(size_hint=(0, 0.025)))
self.add_setting("Crate Elasticity: {value}", 0, 3, 0.1, "crate_elasticity", "elasticity", PhysicsCrate)
self.add_setting("Coin Elasticity: {value}", 0, 3, 0.1, "coin_elasticity", "elasticity", PhysicsCoin)
self.add_setting("Crate Friction: {value}", 0, 10, 0.1, "crate_friction", "friction", PhysicsCrate)
self.add_setting("Coin Friction: {value}", 0, 10, 0.1, "coin_friction", "friction", PhysicsCoin)
self.add_setting("Crate Mass: {value}kg", 1, 100, 1, "crate_mass", "mass", PhysicsCrate)
self.add_setting("Coin Mass: {value}kg", 1, 100, 1, "coin_mass", "mass", PhysicsCoin)
self.add_setting("Gravity X: {value}", -900, 900, 100, "gravity_x", on_change=lambda label, value: self.change_gravity(label, value, "x"))
self.add_setting("Gravity Y: {value}", -1800, 1800, 100, "gravity_y", on_change=lambda label, value: self.change_gravity(label, value, "y"))
self.inventory_grid = self.settings_box.add(BodyInventory(self.window.width, self.window.height, "crate", {"crate": ":resources:images/tiles/boxCrate_double.png", "coin": ":resources:images/items/coinGold.png"}))
self.add_custom_body_button = self.settings_box.add(arcade.gui.UITextureButton(text="Add custom body from SVG", size_hint=(1, 0.1), width=self.window.width * 0.2, height=self.window.height * 0.1))
self.add_custom_body_button.on_click = lambda event: self.custom_body_ui()
def change_gravity(self, label, value, gravity_type):
if gravity_type == "x":
self.gravity_x = value
else:
self.gravity_y = value
self.space.gravity = pymunk.Vec2d(self.gravity_x, self.gravity_y)
label.text = f"Gravity {gravity_type.capitalize()}: {value}"
def add_setting(self, text, min_value, max_value, step, local_variable, pymunk_variable=None, instance=None, on_change=None):
label = self.settings_box.add(arcade.gui.UILabel(text.format(value=getattr(self, local_variable))))
slider = self.settings_box.add(arcade.gui.UISlider(value=getattr(self, local_variable), min_value=min_value, max_value=max_value, step=step, size_hint=(1, 0.05)))
slider._render_steps = lambda surface: None
if pymunk_variable:
slider.on_change = lambda event, label=label: self.change_value(label, text, local_variable, event.new_value, pymunk_variable, instance)
elif on_change:
slider.on_change = lambda event, label=label: on_change(label, event.new_value)
else:
slider.on_change = lambda event, label=label: self.change_value(label, text, local_variable, event.new_value)
def change_value(self, label, text, local_variable, value, pymunk_variable=None, instance=None):
label.text = text.format(value=value)
setattr(self, local_variable, value)
if pymunk_variable:
for sprite in self.spritelist:
if isinstance(sprite, instance):
setattr(sprite.pymunk_obj, pymunk_variable, value)
def create_wall(self, width, height, x, y):
body = pymunk.Body(body_type=pymunk.Body.STATIC)
body.position = pymunk.Vec2d(x, y)
pymunk_obj = pymunk.Segment(body, [0, height], [width, height], 0.0)
pymunk_obj.friction = 10
self.space.add(pymunk_obj, body)
self.walls.append(pymunk_obj)
def create_crate(self, x, y, size, mass):
pymunk_moment = pymunk.moment_for_box(mass, (size, size))
pymunk_body = pymunk.Body(mass, pymunk_moment)
pymunk_body.position = pymunk.Vec2d(x, y)
pymunk_shape = pymunk.Poly.create_box(pymunk_body, (size, size))
pymunk_shape.elasticity = self.crate_elasticity
pymunk_shape.friction = self.crate_friction
self.space.add(pymunk_body, pymunk_shape)
sprite = PhysicsCrate(pymunk_shape, ":resources:images/tiles/boxCrate_double.png", width=size, height=size)
self.spritelist.append(sprite)
def create_coin(self, x, y, radius, mass):
inertia = pymunk.moment_for_circle(mass, 0, radius, (0, 0))
body = pymunk.Body(mass, inertia)
body.position = x, y
body.velocity = 0, 0
shape = pymunk.Circle(body, radius, pymunk.Vec2d(0, 0))
shape.friction = self.coin_friction
shape.elasticity = self.coin_elasticity
self.space.add(body, shape)
sprite = PhysicsCoin(shape, ":resources:images/items/coinGold.png")
self.spritelist.append(sprite)
def on_draw(self):
super().on_draw()
self.spritelist.draw()
for wall in self.walls:
body = wall.body
pv1 = body.position + wall.a.rotated(body.angle)
pv2 = body.position + wall.b.rotated(body.angle)
arcade.draw_line(pv1.x, pv1.y, pv2.x, pv2.y, arcade.color.BLACK, 2)
def on_mouse_press(self, x, y, button, modifiers):
if button == arcade.MOUSE_BUTTON_LEFT:
self.last_mouse_position = x, y
shape_list = self.space.point_query((x, y), 1, pymunk.ShapeFilter())
if len(shape_list) > 0:
self.dragged_shape = shape_list[0]
def on_mouse_release(self, x, y, button, modifiers):
self.dragged_shape = None
def on_mouse_motion(self, x, y, dx, dy):
if self.dragged_shape is not None:
self.last_mouse_position = x, y
self.dragged_shape.shape.body.position = self.last_mouse_position
self.dragged_shape.shape.body.velocity = dx * 50, dy * 50
def clear_custom_body_ui(self):
if hasattr(self, "custom_body_ui_box"):
self.anchor.remove(self.custom_body_ui_box)
self.custom_body_ui_box.clear()
del self.custom_body_ui_box
if hasattr(self, "file_manager_ui_box"):
self.anchor.remove(self.file_manager_ui_box)
self.file_manager_ui_box.clear()
del self.file_manager_ui_box
if hasattr(self, "scrollbar"):
self.anchor.remove(self.scrollbar)
del self.scrollbar
def sample_path(self, path, segments=50):
pts = []
for i in range(segments + 1):
point = path.point(i / segments)
pts.append((point.real, point.imag))
return pts
def add_custom_body(self, file_path):
paths, _ = svg2paths(file_path)
pts = self.sample_path(paths[0], 15)
png_bytes = cairosvg.svg2png(url=file_path, scale=1.0)
original_image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
original_width, _ = original_image.size
desired_width = 32
scale_factor = desired_width / original_width
pts = [(x * scale_factor, y * scale_factor) for x, y in pts]
hull = pymunk.util.convex_hull(pts)
moment = pymunk.moment_for_poly(1.0, hull)
png_bytes = cairosvg.svg2png(url=file_path, scale=scale_factor)
image = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
texture = arcade.Texture(image)
self.custom_pymunk_objs[file_path] = (hull, moment, texture)
self.inventory_grid.add_item(file_path, texture)
self.clear_custom_body_ui()
def create_custom_body(self, file_path, x, y, mass):
hull, moment, image = self.custom_pymunk_objs[file_path]
body = pymunk.Body(mass, moment)
body.position = pymunk.Vec2d(x, y)
shape = pymunk.Poly(body, hull)
self.space.add(body, shape)
sprite = SpritePhysics(shape, image)
sprite.origin_x = image.width / 2
sprite.origin_y = image.height / 2
self.spritelist.append(sprite)
def get_directory_content(self, directory):
try:
entries = os.listdir(directory)
except PermissionError:
return None
filtered = [
entry for entry in entries
if (os.path.isdir(os.path.join(directory, entry)) and not "." in entry) or
os.path.splitext(entry)[1].lower() == ".svg"
]
sorted_entries = sorted(
filtered,
key=lambda x: (0 if os.path.isdir(os.path.join(directory, x)) else 1, x.lower())
)
return sorted_entries
def file_manager(self, current_directory=None):
self.clear_custom_body_ui()
if not current_directory:
current_directory = os.getcwd()
self.scroll_area = UIScrollArea(size_hint=(0.5, 0.5)) # center on screen
self.scroll_area.scroll_speed = -50
self.anchor.add(self.scroll_area, anchor_x="center", anchor_y="center")
self.scrollbar = UIScrollBar(self.scroll_area)
self.scrollbar.size_hint = (0.02, 0.5)
self.anchor.add(self.scrollbar, anchor_x="center", anchor_y="center", align_x=self.window.width / 4)
self.file_manager_ui_box = self.scroll_area.add(arcade.gui.UIBoxLayout(space_between=10).with_background(color=arcade.color.GRAY))
self.file_manager_ui_box.add(arcade.gui.UILabel(f"File Manager ({current_directory})", font_size=16))
go_up_button = self.file_manager_ui_box.add(arcade.gui.UITextureButton(text="Go up", texture=button_texture, texture_hovered=button_hovered_texture, style=button_style, width=self.window.width / 2, height=self.window.height / 10))
go_up_button.on_click = lambda event, current_directory=current_directory: self.file_manager(os.path.dirname(current_directory))
for file in self.get_directory_content(current_directory):
if os.path.isfile(f"{current_directory}/{file}"):
file_button = self.file_manager_ui_box.add(arcade.gui.UITextureButton(text=file, texture=button_texture, texture_hovered=button_hovered_texture, style=button_style, width=self.window.width / 2, height=self.window.height / 10))
file_button.on_click = lambda event, file=file: self.custom_body_ui(f"{current_directory}/{file}")
else:
file_button = self.file_manager_ui_box.add(arcade.gui.UITextureButton(text=file, texture=button_texture, texture_hovered=button_hovered_texture, style=button_style, width=self.window.width / 2, height=self.window.height / 10))
file_button.on_click = lambda event, file=file: self.file_manager(f"{current_directory}/{file}")
def custom_body_ui(self, file_selected=None):
self.clear_custom_body_ui()
self.custom_body_ui_box = self.anchor.add(arcade.gui.UIBoxLayout(space_between=10, size_hint=(0.5, 0.5)).with_background(color=arcade.color.GRAY), anchor_x="center")
self.custom_body_ui_box.add(arcade.gui.UILabel("Add Custom Body from SVG File", font_size=24))
select_file_button = self.custom_body_ui_box.add(arcade.gui.UITextureButton(text=f"Select File ({file_selected})", texture=button_texture, texture_hovered=button_hovered_texture, style=button_style, width=self.window.width / 2, height=self.window.height / 10))
select_file_button.on_click = lambda event: self.file_manager()
add_button = self.custom_body_ui_box.add(arcade.gui.UITextureButton(text="Add Body", texture=button_texture, texture_hovered=button_hovered_texture, style=button_style, width=self.window.width / 2, height=self.window.height / 10))
if file_selected:
add_button.on_click = lambda event, file_selected=file_selected: self.add_custom_body(file_selected)
def on_update(self, delta_time):
if self.window.keyboard[arcade.key.W]:
if self.inventory_grid.selected_item == "crate":
self.create_crate(self.window.mouse.data['x'], self.window.mouse.data['y'], 32, self.crate_mass)
elif self.inventory_grid.selected_item == "coin":
self.create_coin(self.window.mouse.data['x'], self.window.mouse.data['y'], 10, self.coin_mass)
else:
self.create_custom_body(self.inventory_grid.selected_item, self.window.mouse.data['x'], self.window.mouse.data['y'], 1.0)
for sprite in self.spritelist:
if sprite.pymunk_obj.body.position.x < 0 or sprite.pymunk_obj.body.position.x > self.window.width * 0.8 or sprite.pymunk_obj.body.position.y < 0:
self.space.remove(sprite.pymunk_obj, sprite.pymunk_obj.body)
sprite.remove_from_sprite_lists()
start = time.perf_counter()
self.space.step(self.window._draw_rate)
if self.dragged_shape is not None:
self.dragged_shape.shape.body.position = self.last_mouse_position
self.dragged_shape.shape.body.velocity = 0, 0
for sprite in self.spritelist:
sprite.center_x = sprite.pymunk_obj.body.position.x + sprite.origin_x
sprite.center_y = sprite.pymunk_obj.body.position.y + sprite.origin_y
sprite.angle = -math.degrees(sprite.pymunk_obj.body.angle)
self.object_count_label.text = f"Object count: {len(self.spritelist)}"
current_time = time.perf_counter()
if current_time - self.last_processing_time_update > 0.2:
self.last_processing_time_update = current_time
self.processing_time_label.text = f"Processing time: {round((current_time - start) * 1000, 2)} ms"
def on_key_press(self, symbol, modifiers):
if symbol == arcade.key.ESCAPE:
arcade.set_background_color(menu_background_color)
from menus.main import Main
self.window.show_view(Main(self.pypresence_client))
elif symbol == arcade.key.D:
self.create_wall((self.window.width * 0.8) / 10, 80, self.window.mouse.data["x"] - (self.window.width * 0.8) / 20, self.window.mouse.data["y"] - 80)
elif symbol == arcade.key.C:
for sprite in self.spritelist:
self.space.remove(sprite.pymunk_obj, sprite.pymunk_obj.body)
self.spritelist.clear()
def on_show_view(self):
super().on_show_view()
self.create_wall((self.window.width * 0.8), 80, 0, 0)