proejct commit

This commit is contained in:
2026-09-24 15:45:31 +02:00
commit 82d52be8a5
63 changed files with 8748 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests der pygame-freien Physik-Schicht."""
+171
View File
@@ -0,0 +1,171 @@
"""Tests für Formen, Materialien und Körper (Masse, Trägheit, Impulse, Zustände)."""
import math
from collections.abc import Callable
import pytest
from sandbox.physics import Body, Box, Circle, HalfPlane, Material, Vector2
from sandbox.physics.vector import ZERO
from tests.helpers import material
def test_circle_area_and_moment_of_inertia() -> None:
circle = Circle(10)
assert circle.area() == pytest.approx(math.pi * 100)
assert circle.moment_of_inertia(mass=2) == pytest.approx(0.5 * 2 * 100)
assert circle.half_extents() == (10, 10)
def test_box_area_and_infinite_inertia() -> None:
box = Box(3, 2)
assert box.area() == 24
assert math.isinf(box.moment_of_inertia(mass=5))
assert box.half_extents() == (3, 2)
@pytest.mark.parametrize(
"factory",
[lambda: Circle(0), lambda: Circle(-1), lambda: Box(0, 1), lambda: Box(1, -2)],
)
def test_invalid_shapes_are_rejected(factory: Callable[[], object]) -> None:
with pytest.raises(ValueError, match="positiv"):
factory()
def test_half_plane_requires_unit_normal() -> None:
with pytest.raises(ValueError, match="Länge 1"):
HalfPlane(Vector2(0, 2), 0)
def test_half_plane_distance_and_contains() -> None:
floor = HalfPlane(Vector2(0, -1), -100) # frei ist y ≤ 100
assert floor.distance(Vector2(0, 90)) == pytest.approx(10)
assert floor.distance(Vector2(0, 110)) == pytest.approx(-10)
assert floor.contains(Vector2(0, 110))
assert not floor.contains(Vector2(0, 90))
assert floor.contains(Vector2(0, 90), tolerance=10)
assert math.isinf(floor.half_extents()[0])
def test_point_containment_with_tolerance() -> None:
circle = Body.circle(Vector2(100, 100), 10, material())
assert circle.contains_point(Vector2(107, 107))
assert not circle.contains_point(Vector2(110, 110))
assert circle.contains_point(Vector2(110, 110), tolerance=5)
box = Body.box(Vector2(0, 0), 10, 5, material())
assert box.contains_point(Vector2(10, -5))
assert not box.contains_point(Vector2(10.5, 0))
assert box.contains_point(Vector2(10.5, 0), tolerance=1)
def test_mass_is_area_times_density() -> None:
circle = Body.circle(Vector2(), 10, material(density=2))
assert circle.mass == pytest.approx(math.pi * 100 * 2)
assert circle.inv_mass == pytest.approx(1 / circle.mass)
box = Body.box(Vector2(), 10, 5, material(density=0.5))
assert box.mass == pytest.approx(20 * 10 * 0.5)
def test_bigger_bodies_are_heavier() -> None:
small = Body.circle(Vector2(), 10, material())
large = Body.circle(Vector2(), 20, material())
assert large.mass == pytest.approx(4 * small.mass) # Fläche wächst mit r²
def test_static_bodies_have_infinite_mass_and_never_move() -> None:
wall = Body.wall(Vector2(0, 1), 0, material())
assert wall.is_static
assert wall.inv_mass == 0
assert wall.inv_inertia == 0
assert math.isinf(wall.mass)
wall.apply_impulse(Vector2(100, 100))
assert wall.velocity == ZERO
assert wall.kinetic_energy() == 0
assert wall.momentum() == ZERO
def test_half_planes_cannot_be_dynamic() -> None:
with pytest.raises(ValueError, match="statisch"):
Body(HalfPlane(Vector2(0, 1), 0), Vector2(), material())
def test_dynamic_bodies_need_positive_density() -> None:
with pytest.raises(ValueError, match="Dichte"):
Body.circle(Vector2(), 10, material(density=0))
@pytest.mark.parametrize(
("density", "restitution", "friction"),
[(-1, 0.5, 0.5), (1, -0.1, 0.5), (1, 1.5, 0.5), (1, 0.5, -1)],
)
def test_invalid_materials_are_rejected(
density: float, restitution: float, friction: float
) -> None:
with pytest.raises(ValueError, match=r"Dichte|Restitution|Reibung"):
Material(density, restitution, friction)
def test_circles_can_rotate_but_boxes_cannot() -> None:
circle = Body.circle(Vector2(), 10, material())
box = Body(Box(10, 10), Vector2(), material(), angular_velocity=5)
assert circle.can_rotate
assert not box.can_rotate
assert box.angular_velocity == 0 # Drehung wird bei Rechtecken ignoriert
def test_off_center_impulse_spins_a_circle() -> None:
circle = Body.circle(Vector2(), 10, material())
# Stoß nach rechts an der Unterseite: erzeugt Vorwärtsbewegung und Drehung.
circle.apply_impulse(Vector2(circle.mass * 10, 0), offset=Vector2(0, 10))
assert circle.velocity.is_close(Vector2(10, 0))
expected_spin = circle.inv_inertia * Vector2(0, 10).cross(Vector2(circle.mass * 10, 0))
assert circle.angular_velocity == pytest.approx(expected_spin)
assert circle.angular_velocity < 0
def test_off_center_impulse_does_not_spin_a_box() -> None:
box = Body.box(Vector2(), 10, 10, material())
box.apply_impulse(Vector2(box.mass, 0), offset=Vector2(0, 10))
assert box.angular_velocity == 0
assert box.velocity.is_close(Vector2(1, 0))
def test_kinetic_energy_and_momentum() -> None:
circle = Body.circle(Vector2(), 10, material(), velocity=Vector2(3, 4))
circle.angular_velocity = 2
expected = 0.5 * circle.mass * 25 + 0.5 * circle.inertia * 4
assert circle.kinetic_energy() == pytest.approx(expected)
assert circle.momentum().is_close(Vector2(3, 4) * circle.mass)
def test_bounds() -> None:
assert Body.circle(Vector2(10, 20), 5, material()).bounds() == (5, 15, 15, 25)
assert Body.box(Vector2(10, 20), 4, 2, material()).bounds() == (6, 18, 14, 22)
def test_sleep_wake_and_teleport() -> None:
body = Body.circle(Vector2(0, 0), 10, material(), velocity=Vector2(5, 0))
body.angular_velocity = 1
body.sleep_time = 1
body.put_to_sleep()
assert body.sleeping
assert body.velocity == ZERO
assert body.angular_velocity == 0
body.teleport(Vector2(50, 60))
assert not body.sleeping
assert body.sleep_time == 0
assert body.position == body.previous_position == Vector2(50, 60)
def test_ids_are_unique() -> None:
bodies = [Body.circle(Vector2(), 10, material()) for _ in range(5)]
assert len({body.id for body in bodies}) == 5
def test_repr_mentions_id_and_sleep_state() -> None:
body = Body.circle(Vector2(1, 2), 10, material())
assert f"id={body.id}" in repr(body)
body.put_to_sleep()
assert "schläft" in repr(body)
+56
View File
@@ -0,0 +1,56 @@
"""Tests der Broadphase (Sweep-and-Prune)."""
import random
from sandbox.physics import Body, Vector2
from sandbox.physics.broadphase import find_candidate_pairs
from tests.helpers import material
def _random_scene(seed: int, count: int) -> list[Body]:
rng = random.Random(seed)
bodies = []
for _ in range(count):
position = Vector2(rng.uniform(0, 400), rng.uniform(0, 300))
if rng.random() < 0.5:
bodies.append(Body.circle(position, rng.uniform(5, 30), material()))
else:
bodies.append(Body.box(position, rng.uniform(5, 30), rng.uniform(5, 30), material()))
return bodies
def _overlap(a: Body, b: Body) -> bool:
a_min_x, a_min_y, a_max_x, a_max_y = a.bounds()
b_min_x, b_min_y, b_max_x, b_max_y = b.bounds()
return a_min_x <= b_max_x and b_min_x <= a_max_x and a_min_y <= b_max_y and b_min_y <= a_max_y
def test_matches_brute_force_on_random_scenes() -> None:
for seed in range(5):
bodies = _random_scene(seed, 60)
expected = {
(min(a.id, b.id), max(a.id, b.id))
for i, a in enumerate(bodies)
for b in bodies[i + 1 :]
if _overlap(a, b)
}
found = [(a.id, b.id) for a, b in find_candidate_pairs(bodies)]
assert len(found) == len(set(found)), "kein Paar doppelt"
assert set(found) == expected
def test_pairs_are_ordered_by_id() -> None:
bodies = _random_scene(7, 40)
for a, b in find_candidate_pairs(bodies):
assert a.id < b.id
def test_pairs_of_sleeping_bodies_are_skipped() -> None:
a = Body.circle(Vector2(0, 0), 10, material())
b = Body.circle(Vector2(15, 0), 10, material())
c = Body.circle(Vector2(30, 0), 10, material())
a.put_to_sleep()
b.put_to_sleep()
pairs = {(x.id, y.id) for x, y in find_candidate_pairs([a, b, c])}
assert (a.id, b.id) not in pairs # beide schlafen
assert (b.id, c.id) in pairs # schlafend + wach wird geprüft
+244
View File
@@ -0,0 +1,244 @@
"""Tests der Kollisionserkennung für alle Formpaarungen einschließlich Randfällen."""
import random
import pytest
from sandbox.physics import Body, Vector2, collide
from sandbox.physics.collision import (
COINCIDENT_NORMAL,
collide_box_half_plane,
collide_boxes,
collide_circle_box,
collide_circle_half_plane,
collide_circles,
)
from tests.helpers import material
def circle(x: float, y: float, radius: float) -> Body:
return Body.circle(Vector2(x, y), radius, material())
def box(x: float, y: float, half_width: float, half_height: float) -> Body:
return Body.box(Vector2(x, y), half_width, half_height, material())
def floor_at(y: float) -> Body:
return Body.wall(Vector2(0, -1), -y, material()) # frei ist alles oberhalb von y
# --------------------------------------------------------------------- Kreis–Kreis
def test_separated_circles_do_not_collide() -> None:
assert collide_circles(circle(0, 0, 10), circle(21, 0, 10)) is None
def test_touching_circles_form_a_contact_without_penetration() -> None:
contact = collide_circles(circle(0, 0, 10), circle(20, 0, 10))
assert contact is not None
assert contact.penetration == pytest.approx(0)
def test_overlapping_circles() -> None:
a = circle(0, 0, 10)
b = circle(15, 0, 10)
contact = collide_circles(a, b)
assert contact is not None
assert contact.body_a is a
assert contact.body_b is b
assert contact.normal.is_close(Vector2(1, 0))
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(7.5, 0)) # Mitte des Überlappungsbereichs
def test_coincident_circles_use_a_fixed_fallback_normal() -> None:
contact = collide_circles(circle(5, 5, 10), circle(5, 5, 8))
assert contact is not None
assert contact.normal == COINCIDENT_NORMAL
assert contact.penetration == pytest.approx(18)
# --------------------------------------------------------------------- Kreis–Rechteck
def test_circle_touching_the_top_face_of_a_box() -> None:
contact = collide_circle_box(circle(0, -14, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis (oben) zur Box (unten)
assert contact.penetration == pytest.approx(1)
assert contact.point.is_close(Vector2(0, -10))
def test_circle_touching_the_right_face_of_a_box() -> None:
contact = collide_circle_box(circle(24, 0, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0))
assert contact.penetration == pytest.approx(1)
def test_circle_touching_a_corner_gets_a_diagonal_normal() -> None:
contact = collide_circle_box(circle(23, -13, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 1).normalized())
assert contact.penetration == pytest.approx(5 - 18**0.5)
assert contact.point.is_close(Vector2(20, -10))
def test_circle_near_a_corner_but_outside_does_not_collide() -> None:
# Die umschließenden Boxen überlappen, der Kreis verfehlt die Ecke aber knapp.
assert collide_circle_box(circle(24, -14, 5), box(0, 0, 20, 10)) is None
def test_circle_center_inside_box_is_pushed_out_through_the_nearest_side_face() -> None:
contact = collide_circle_box(circle(15, 2, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0)) # Kreis bewegt sich entlang −n nach rechts
assert contact.penetration == pytest.approx(5 + 5) # Radius + Abstand zur Kante
assert contact.point.is_close(Vector2(20, 2))
def test_circle_center_inside_box_is_pushed_out_through_the_nearest_top_face() -> None:
contact = collide_circle_box(circle(3, -8, 5), box(0, 0, 20, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # Kreis bewegt sich nach oben
assert contact.penetration == pytest.approx(5 + 2)
assert contact.point.is_close(Vector2(3, -10))
# --------------------------------------------------------------------- Rechteck–Rechteck
def test_boxes_are_separated_along_the_axis_of_least_overlap() -> None:
a = box(0, 0, 10, 10)
contact = collide_boxes(a, box(15, 5, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(1, 0))
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(7.5, 2.5)) # Mitte des Überlappungsrechtecks
contact = collide_boxes(a, box(5, -18, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, -1))
assert contact.penetration == pytest.approx(2)
def test_equal_overlap_prefers_the_vertical_axis() -> None:
contact = collide_boxes(box(0, 0, 10, 10), box(15, 15, 10, 10))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1))
@pytest.mark.parametrize(("x", "y"), [(21, 0), (0, 25), (-21, 5), (3, -20.5)])
def test_separated_boxes_do_not_collide(x: float, y: float) -> None:
assert collide_boxes(box(0, 0, 10, 10), box(x, y, 10, 10)) is None
# --------------------------------------------------------------------- Wände
def test_circle_resting_on_the_floor() -> None:
contact = collide_circle_half_plane(circle(50, 95, 10), floor_at(100))
assert contact is not None
assert contact.normal.is_close(Vector2(0, 1)) # vom Kreis in die Wand
assert contact.penetration == pytest.approx(5)
assert contact.point.is_close(Vector2(50, 100))
def test_circle_above_the_floor_does_not_collide() -> None:
assert collide_circle_half_plane(circle(50, 80, 10), floor_at(100)) is None
def test_circle_completely_behind_the_wall_is_still_detected() -> None:
# Der "Tunnel"-Fall: Selbst ein Körper hinter der Wandlinie wird erkannt
# und in den freien Raum zurückgeschoben.
contact = collide_circle_half_plane(circle(50, 130, 10), floor_at(100))
assert contact is not None
assert contact.penetration == pytest.approx(40)
assert contact.normal.is_close(Vector2(0, 1))
def test_box_against_floor_and_side_wall() -> None:
contact = collide_box_half_plane(box(50, 95, 20, 10), floor_at(100))
assert contact is not None
assert contact.penetration == pytest.approx(5)
left_wall = Body.wall(Vector2(1, 0), 0, material())
contact = collide_box_half_plane(box(15, 50, 20, 10), left_wall)
assert contact is not None
assert contact.normal.is_close(Vector2(-1, 0))
assert contact.penetration == pytest.approx(5)
assert collide_box_half_plane(box(25, 50, 20, 10), left_wall) is None
# --------------------------------------------------------------------- Zuordnung
def test_collide_orders_bodies_canonically() -> None:
c = circle(0, -14, 5)
b = box(0, 0, 20, 10)
for first, second in ((c, b), (b, c)):
contact = collide(first, second)
assert contact is not None
assert contact.body_a is c
assert contact.body_b is b
assert contact.normal.is_close(Vector2(0, 1))
wall = floor_at(100)
contact = collide(wall, circle(50, 95, 10))
assert contact is not None
assert contact.body_b is wall
def test_two_walls_never_collide() -> None:
assert collide(floor_at(100), floor_at(50)) is None
def test_contact_key_and_repr() -> None:
a = circle(0, 0, 10)
b = circle(15, 0, 10)
contact = collide(a, b)
assert contact is not None
assert contact.key == (a.id, b.id)
assert "penetration=5.000" in repr(contact)
# --------------------------------------------------------------------- Eigenschaften
def _random_body(rng: random.Random) -> Body:
x = rng.uniform(-40, 40)
y = rng.uniform(-40, 40)
if rng.random() < 0.5:
return circle(x, y, rng.uniform(5, 30))
return box(x, y, rng.uniform(5, 30), rng.uniform(5, 30))
def _random_wall(rng: random.Random) -> Body:
normal = rng.choice([Vector2(1, 0), Vector2(-1, 0), Vector2(0, 1), Vector2(0, -1)])
return Body.wall(normal, rng.uniform(-40, 40), material())
def test_random_contacts_are_consistent_and_resolvable() -> None:
"""Zufällige Kontakte sind in sich stimmig und lassen sich exakt auflösen.
Für viele Zufallspaare gilt: Die Normale ist ein Einheitsvektor, die Tiefe ist ≥ 0, und
verschiebt man Körper A um (Tiefe + ε) entgegen der Normalen, berühren sich die Körper
nicht mehr. Genau diese Information braucht die Kollisionsauflösung.
"""
rng = random.Random(1234)
checked = 0
for _ in range(3000):
first = _random_body(rng)
second = _random_wall(rng) if rng.random() < 0.25 else _random_body(rng)
contact = collide(first, second)
if contact is None:
continue
checked += 1
assert contact.normal.length() == pytest.approx(1)
assert contact.penetration >= 0
mover = contact.body_a
mover.position = mover.position - contact.normal * (contact.penetration + 1e-6)
assert collide(contact.body_a, contact.body_b) is None
assert checked > 500 # genug echte Kontakte geprüft
+105
View File
@@ -0,0 +1,105 @@
"""Tests des Schlafmodus (Inseln, Einschlafen, Aufwecken)."""
from dataclasses import replace
from sandbox.config import PHYSICS
from sandbox.physics import Body, Contact, Vector2, collide
from sandbox.physics.sleep import UnionFind, find_islands, update_sleep, wake_touched_bodies
from tests.helpers import material
def _circle(x: float, y: float = 0.0, radius: float = 10.0) -> Body:
return Body.circle(Vector2(x, y), radius, material())
def _contacts(*pairs: tuple[Body, Body]) -> list[Contact]:
contacts = [collide(a, b) for a, b in pairs]
assert all(contact is not None for contact in contacts)
return [contact for contact in contacts if contact is not None]
def test_union_find_merges_sets() -> None:
union_find = UnionFind([1, 2, 3, 4, 5])
union_find.union(1, 2)
union_find.union(3, 4)
union_find.union(2, 4)
assert union_find.find(1) == union_find.find(3) == union_find.find(4)
assert union_find.find(5) != union_find.find(1)
def test_touching_bodies_form_islands_but_walls_do_not_connect_them() -> None:
a, b, c, d = _circle(0), _circle(15), _circle(30), _circle(200)
floor = Body.wall(Vector2(0, -1), -5, material()) # berührt alle Kreise
contacts = _contacts((a, b), (b, c), (a, floor), (d, floor))
islands = find_islands([a, b, c, d], contacts)
assert [set(island) for island in islands] == [{a, b, c}, {d}]
def test_an_island_sleeps_only_when_all_its_bodies_rest() -> None:
config = replace(PHYSICS, time_to_sleep=0.5)
resting, moving = _circle(0), _circle(15)
moving.velocity = Vector2(50, 0)
contacts = _contacts((resting, moving))
for _ in range(100):
update_sleep([resting, moving], contacts, 0.01, config)
assert resting.sleep_time >= 0.5
assert not resting.sleeping # der bewegte Nachbar hält die Insel wach
moving.velocity = Vector2(0, 0)
for _ in range(60):
update_sleep([resting, moving], contacts, 0.01, config)
assert resting.sleeping
assert moving.sleeping
def test_waking_one_body_wakes_its_whole_island() -> None:
a, b, c, outsider = _circle(0), _circle(15), _circle(30), _circle(300)
island = [a, b, c]
for body in island:
body.put_to_sleep(island)
outsider.put_to_sleep()
c.wake()
assert not any(body.sleeping for body in island)
assert all(body.sleep_island is None for body in island)
assert outsider.sleeping
def test_touching_a_sleeper_wakes_and_reports_its_whole_island() -> None:
a, b, c = _circle(0), _circle(15), _circle(30)
island = [b, c]
for body in island:
body.put_to_sleep(island)
woken = wake_touched_bodies(_contacts((a, b)))
assert set(woken) == {b, c}
assert not c.sleeping
def test_held_bodies_keep_their_island_awake() -> None:
held, other = _circle(0), _circle(15)
held.held = True
contacts = _contacts((held, other))
for _ in range(200):
update_sleep([held, other], contacts, 0.01, PHYSICS)
assert not held.sleeping
assert not other.sleeping
assert held.sleep_time == 0
def test_sleepers_touched_by_awake_bodies_are_woken_but_not_by_walls() -> None:
sleeper, awake = _circle(0), _circle(15)
sleeper.put_to_sleep()
floor = Body.wall(Vector2(0, -1), -5, material())
lonely = _circle(300)
lonely.put_to_sleep()
both_asleep = _circle(500)
both_asleep.put_to_sleep()
neighbour = _circle(515)
neighbour.put_to_sleep()
woken = wake_touched_bodies(
_contacts((sleeper, awake), (lonely, floor), (both_asleep, neighbour))
)
assert woken == [sleeper]
assert not sleeper.sleeping
assert lonely.sleeping
assert both_asleep.sleeping
+186
View File
@@ -0,0 +1,186 @@
"""Tests der Kollisionsauflösung: Stöße, Restitution, Reibung, Rollen, Positionskorrektur.
Die Erwartungswerte stammen aus der klassischen Mechanik (Impuls- und Energieerhaltung,
Coulomb-Reibung, Rollbedingung v = ω·r) und nicht aus der Implementierung selbst.
"""
import math
import pytest
from sandbox.config import PHYSICS
from tests.helpers import (
add_box,
add_circle,
make_world,
simulate,
total_kinetic_energy,
total_momentum,
)
GRAVITY = PHYSICS.gravity[1]
FLOOR = 700.0
def _head_on_pair(restitution: float) -> tuple:
"""Zwei Kreise ohne Schwerkraft, Massenverhältnis 1:3, frontal aufeinander zu."""
world = make_world(gravity=(0.0, 0.0))
light = add_circle(world, 300, 350, 20, velocity=(200, 0), restitution=restitution)
heavy = add_circle(
world, 500, 350, 20, velocity=(-100, 0), density=3.0, restitution=restitution
)
return world, light, heavy
def test_elastic_collision_conserves_momentum_and_energy() -> None:
world, light, heavy = _head_on_pair(restitution=1.0)
momentum_before = total_momentum(world.bodies)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0) # Stoß bei t ≈ 0,53 s, danach noch weit weg von den Wänden
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
assert total_kinetic_energy(world.bodies) == pytest.approx(energy_before, rel=1e-9)
# Lehrbuchformel für den 1D-Stoß: v1' = ((m1 − m2)·v1 + 2·m2·v2) / (m1 + m2)
m1, m2, v1, v2 = light.mass, heavy.mass, 200.0, -100.0
assert light.velocity.x == pytest.approx(((m1 - m2) * v1 + 2 * m2 * v2) / (m1 + m2))
assert heavy.velocity.x == pytest.approx(((m2 - m1) * v2 + 2 * m1 * v1) / (m1 + m2))
assert light.velocity.x == pytest.approx(-250)
assert heavy.velocity.x == pytest.approx(50)
def test_equal_masses_exchange_velocities_in_an_elastic_collision() -> None:
world = make_world(gravity=(0.0, 0.0))
a = add_circle(world, 300, 350, 20, velocity=(150, 0), restitution=1.0)
b = add_circle(world, 400, 350, 20, restitution=1.0)
simulate(world, 0.8)
assert a.velocity.x == pytest.approx(0, abs=1e-9)
assert b.velocity.x == pytest.approx(150)
def test_perfectly_inelastic_collision_leaves_a_common_velocity() -> None:
world, light, heavy = _head_on_pair(restitution=0.0)
momentum_before = total_momentum(world.bodies)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0)
common = momentum_before.x / (light.mass + heavy.mass) # = −25 px/s
assert light.velocity.x == pytest.approx(common)
assert heavy.velocity.x == pytest.approx(common)
assert total_momentum(world.bodies).is_close(momentum_before, tolerance=1e-6)
assert total_kinetic_energy(world.bodies) < energy_before
def test_restitution_below_one_loses_energy_as_predicted() -> None:
world, light, heavy = _head_on_pair(restitution=0.5)
energy_before = total_kinetic_energy(world.bodies)
simulate(world, 1.0)
# Newtons Stoßgesetz: Trenngeschwindigkeit = e · Aufprallgeschwindigkeit (300 px/s).
assert heavy.velocity.x - light.velocity.x == pytest.approx(0.5 * 300)
# Energieverlust beim zentralen Stoß: ΔE = ½·μ·(1 − e²)·v_rel² mit μ = m1·m2/(m1 + m2).
reduced_mass = light.mass * heavy.mass / (light.mass + heavy.mass)
expected_loss = 0.5 * reduced_mass * (1 - 0.5**2) * 300**2
energy_loss = energy_before - total_kinetic_energy(world.bodies)
assert energy_loss == pytest.approx(expected_loss, rel=1e-6)
def test_slow_impacts_below_the_threshold_do_not_bounce() -> None:
world = make_world(gravity=(0.0, 0.0))
approach = 0.8 * PHYSICS.restitution_threshold
a = add_circle(world, 300, 350, 20, velocity=(approach, 0), restitution=1.0)
b = add_circle(world, 345, 350, 20, restitution=1.0)
simulate(world, 0.5)
assert b.velocity.x - a.velocity.x == pytest.approx(0, abs=1e-9)
def test_frictionless_oblique_impact_keeps_the_tangential_velocity() -> None:
world = make_world(gravity=(0.0, 0.0), wall_friction=0.0)
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), restitution=1.0, friction=0.0)
simulate(world, 0.4) # trifft die linke Wand bei t ≈ 0,27 s
assert ball.velocity.x == pytest.approx(300)
assert ball.velocity.y == pytest.approx(120)
assert ball.angular_velocity == 0 # Normalimpulse erzeugen kein Drehmoment
def test_friction_on_an_oblique_impact_makes_a_ball_spin() -> None:
world = make_world(gravity=(0.0, 0.0))
ball = add_circle(world, 100, 350, 20, velocity=(-300, 120), friction=0.8)
simulate(world, 0.4)
assert ball.angular_velocity != 0
assert abs(ball.velocity.y) < 120 # Reibung hat die Tangentialgeschwindigkeit gebremst
def test_sliding_box_decelerates_with_coulomb_friction() -> None:
world = make_world(wall_friction=0.6)
box = add_box(world, 200, FLOOR - 20, 20, 20, velocity=(300, 0), friction=0.4)
simulate(world, 1.5)
mu = math.sqrt(0.4 * 0.6) # gemischter Reibwert Box/Boden
expected_distance = 300**2 / (2 * mu * GRAVITY) # s = v² / (2·μ·g)
assert box.position.x - 200 == pytest.approx(expected_distance, rel=0.03)
assert box.velocity.x == pytest.approx(0, abs=1e-6)
def test_sliding_disk_starts_rolling_at_two_thirds_of_its_speed() -> None:
"""Eine rutschende Scheibe geht ins Rollen über und behält dabei ⅔ ihres Tempos.
Reibung bremst die Scheibe (I = ½·m·r²) und versetzt sie in Drehung, bis v = ω·r gilt.
Drehimpulserhaltung um den Kontaktpunkt liefert dann v = v0 / (1 + I/(m·r²)) = ⅔·v0 –
unabhängig vom Reibwert.
"""
world = make_world(rolling_resistance=0.0, sleep_enabled=False)
radius = 20
disk = add_circle(world, 200, FLOOR - radius, radius, velocity=(300, 0), friction=0.5)
simulate(world, 1.0)
assert disk.velocity.x == pytest.approx(200, rel=0.01)
assert disk.angular_velocity * radius == pytest.approx(disk.velocity.x, rel=0.01)
def test_rolling_resistance_decelerates_a_rolling_ball() -> None:
"""Rollwiderstand: Moment μ_r·N·r ⇒ Verzögerung a = μ_r·g / (1 + I/(m·r²)) = μ_r·g/1,5."""
world = make_world(sleep_enabled=False)
radius = 25
ball = add_circle(
world, 200, FLOOR - radius, radius, velocity=(200, 0), angular_velocity=200 / radius
)
simulate(world, 1.0)
expected_deceleration = PHYSICS.rolling_resistance * GRAVITY / 1.5
assert 200 - ball.velocity.x == pytest.approx(expected_deceleration, rel=0.02)
def test_resting_contact_carries_exactly_the_weight() -> None:
world = make_world(sleep_enabled=False)
box = add_box(world, 300, FLOOR - 20, 30, 20)
simulate(world, 0.5)
(contact,) = world.contacts
# Pro Schritt muss der Boden den Impuls m·g·dt aufbringen, damit die Box liegen bleibt.
assert contact.normal_impulse == pytest.approx(box.mass * GRAVITY * PHYSICS.time_step)
assert contact.tangent_impulse == pytest.approx(0, abs=1e-9)
def test_impulses_respect_non_penetration_and_coulomb_bounds() -> None:
world = make_world()
for i in range(12):
add_circle(world, 300 + 25 * (i % 4), 200 + 30 * (i // 4), 14, velocity=(40 * i, 0))
add_box(world, 600 + 30 * (i % 3), 150 + 35 * (i // 3), 14, 16)
for _ in range(240):
world.step(PHYSICS.time_step)
for contact in world.contacts:
assert contact.normal_impulse >= 0
mu = math.sqrt(contact.body_a.material.friction * contact.body_b.material.friction)
assert abs(contact.tangent_impulse) <= mu * contact.normal_impulse + 1e-9
def test_position_correction_separates_without_adding_velocity() -> None:
world = make_world(gravity=(0.0, 0.0), sleep_enabled=False)
a = add_circle(world, 500, 350, 20)
b = add_circle(world, 510, 350, 20) # 30 px Überlappung
simulate(world, 1.0)
assert a.position.distance_to(b.position) >= 40 - PHYSICS.penetration_slop - 1e-9
assert a.velocity.length() == 0
assert b.velocity.length() == 0
def test_warm_starting_can_be_disabled() -> None:
world = make_world(warm_starting=False)
box = add_box(world, 300, FLOOR - 20, 30, 20)
simulate(world, 2.0)
assert box.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
+57
View File
@@ -0,0 +1,57 @@
"""Tests des festen Zeitschritts (Akkumulator)."""
import pytest
from sandbox.physics import FixedTimestep
STEP = 1 / 120
def test_a_60_fps_frame_runs_two_physics_steps() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
assert timestep.advance(1 / 60) == 2
assert timestep.alpha == pytest.approx(0, abs=1e-6)
def test_short_frames_accumulate() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
assert timestep.advance(1 / 240) == 0
assert timestep.alpha == pytest.approx(0.5)
assert timestep.advance(1 / 240) == 1
def test_no_steps_are_lost_through_rounding() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
total = sum(timestep.advance(1 / 60) for _ in range(1000))
assert total == 2000
def test_alpha_stays_in_the_unit_interval() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
for frame in (0.003, 0.011, 0.017, 0.0001, 0.02):
timestep.advance(frame)
assert 0 <= timestep.alpha < 1
def test_huge_frames_are_capped_to_avoid_the_spiral_of_death() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=5)
assert timestep.advance(2.0) == 5
assert timestep.alpha == 0 # der Rückstand wurde verworfen
assert timestep.advance(1 / 60) == 2
def test_reset_discards_accumulated_time() -> None:
timestep = FixedTimestep(STEP, max_steps_per_frame=8)
timestep.advance(STEP * 0.9)
timestep.reset()
assert timestep.alpha == 0
assert timestep.advance(STEP * 0.5) == 0
def test_invalid_arguments_are_rejected() -> None:
with pytest.raises(ValueError, match="positiv"):
FixedTimestep(0, 8)
with pytest.raises(ValueError, match="mindestens"):
FixedTimestep(STEP, 0)
with pytest.raises(ValueError, match="negativ"):
FixedTimestep(STEP, 8).advance(-0.1)
+127
View File
@@ -0,0 +1,127 @@
"""Tests der eigenen Vektorklasse."""
import copy
import math
import pickle
import pytest
from sandbox.physics.vector import ZERO, Vector2
def test_addition_and_subtraction() -> None:
assert Vector2(1, 2) + Vector2(3, -5) == Vector2(4, -3)
assert Vector2(1, 2) - Vector2(3, -5) == Vector2(-2, 7)
def test_scaling_from_both_sides_and_division() -> None:
assert Vector2(1.5, -2) * 2 == Vector2(3, -4)
assert 2 * Vector2(1.5, -2) == Vector2(3, -4)
assert Vector2(3, -4) / 2 == Vector2(1.5, -2)
def test_negation() -> None:
assert -Vector2(1, -2) == Vector2(-1, 2)
def test_dot_product() -> None:
assert Vector2(1, 2).dot(Vector2(3, 4)) == 11
assert Vector2(1, 0).dot(Vector2(0, 5)) == 0 # orthogonal
def test_cross_product_sign_and_value() -> None:
assert Vector2(1, 0).cross(Vector2(0, 1)) == 1
assert Vector2(0, 1).cross(Vector2(1, 0)) == -1
assert Vector2(2, 3).cross(Vector2(4, 6)) == 0 # parallel
def test_length_and_length_squared() -> None:
v = Vector2(3, 4)
assert v.length() == 5
assert v.length_squared() == 25
def test_normalized_has_unit_length_and_same_direction() -> None:
n = Vector2(3, 4).normalized()
assert n.length() == pytest.approx(1.0)
assert n.is_close(Vector2(0.6, 0.8))
def test_normalizing_the_zero_vector_returns_zero_instead_of_nan() -> None:
assert Vector2(0, 0).normalized() == ZERO
def test_perpendicular_is_rotated_by_plus_90_degrees() -> None:
v = Vector2(2, 1)
p = v.perpendicular()
assert p == Vector2(-1, 2)
assert v.dot(p) == 0
assert v.cross(p) > 0
def test_distance() -> None:
assert Vector2(1, 1).distance_to(Vector2(4, 5)) == 5
def test_clamped_limits_only_long_vectors() -> None:
short = Vector2(1, 1)
assert short.clamped(10) is short
clamped = Vector2(30, 40).clamped(10)
assert clamped.length() == pytest.approx(10)
assert clamped.is_close(Vector2(6, 8))
def test_lerp() -> None:
a = Vector2(0, 10)
b = Vector2(10, 20)
assert a.lerp(b, 0) == a
assert a.lerp(b, 1) == b
assert a.lerp(b, 0.5) == Vector2(5, 15)
def test_is_finite() -> None:
assert Vector2(1, 2).is_finite()
assert not Vector2(math.nan, 0).is_finite()
assert not Vector2(0, math.inf).is_finite()
def test_is_close_uses_absolute_tolerance() -> None:
assert Vector2(1, 1).is_close(Vector2(1 + 1e-12, 1))
assert not Vector2(1, 1).is_close(Vector2(1.1, 1))
assert Vector2(1, 1).is_close(Vector2(1.1, 1), tolerance=0.2)
def test_equality_hash_and_comparison_with_other_types() -> None:
assert Vector2(1, 2) == Vector2(1.0, 2.0)
assert Vector2(1, 2) != Vector2(2, 1)
assert Vector2(1, 2) != (1, 2)
assert len({Vector2(1, 2), Vector2(1, 2), Vector2(2, 1)}) == 2
def test_vectors_are_immutable() -> None:
v = Vector2(1, 2)
with pytest.raises(AttributeError):
v.x = 5 # type: ignore[misc]
with pytest.raises(AttributeError):
del v.y
assert v == Vector2(1, 2)
def test_operations_do_not_modify_operands() -> None:
a = Vector2(1, 2)
b = Vector2(3, 4)
_ = (a + b, a - b, a * 3, a / 2, -a, a.normalized(), a.clamped(0.1), a.lerp(b, 0.5))
assert a == Vector2(1, 2)
assert b == Vector2(3, 4)
def test_copy_and_pickle_round_trip() -> None:
v = Vector2(1.5, -2.5)
assert copy.copy(v) == v
assert copy.deepcopy(v) == v
assert pickle.loads(pickle.dumps(v)) == v
def test_repr_and_tuple() -> None:
assert repr(Vector2(1.5, 2)) == "Vector2(1.5, 2)"
assert Vector2(1.5, 2).to_tuple() == (1.5, 2)
+446
View File
@@ -0,0 +1,446 @@
"""Szenario-Tests der Physikwelt: Fallen, Aufprallen, Stapeln, Ruhen, Grenzen, Stabilität."""
import itertools
import random
import pytest
from sandbox.config import MIN_BODY_SIZE, PHYSICS, TIME_STEP
from sandbox.physics import Body, Vector2, World
from tests.helpers import (
WORLD_HEIGHT,
WORLD_WIDTH,
add_box,
add_circle,
make_world,
material,
simulate,
total_kinetic_energy,
)
GRAVITY = PHYSICS.gravity[1]
FLOOR = WORLD_HEIGHT
#: Geduldete Rest-Überlappung ruhender Körper: die Toleranz ``penetration_slop`` plus
#: ein tausendstel Pixel für die nie ganz auskonvergierte Iteration.
MAX_RESTING_OVERLAP = PHYSICS.penetration_slop + 1e-3
# --------------------------------------------------------------------- Fallen und Aufprallen
def test_free_fall_matches_half_g_t_squared() -> None:
world = make_world(height=2000)
ball = add_circle(world, 500, 100, 10)
steps = 60
simulate(world, steps * TIME_STEP)
t = steps * TIME_STEP
fallen = ball.position.y - 100
assert fallen == pytest.approx(0.5 * GRAVITY * t * t, rel=0.02)
# Semi-impliziter Euler exakt: v_k = k·g·dt und s_n = g·dt²·n·(n+1)/2.
assert ball.velocity.y == pytest.approx(GRAVITY * t)
assert fallen == pytest.approx(GRAVITY * TIME_STEP**2 * steps * (steps + 1) / 2)
def test_bodies_without_gravity_stay_put() -> None:
world = make_world()
world.gravity_enabled = False
ball = add_circle(world, 500, 100, 10)
simulate(world, 1.0)
assert ball.position == Vector2(500, 100)
assert world.gravity == Vector2(0, 0)
def test_fallen_ball_comes_to_rest_on_the_floor_and_never_leaves_the_world() -> None:
world = make_world()
radius = 20
ball = add_circle(world, 500, 100, radius, restitution=0.6)
lowest = 0.0
for _ in range(round(8 / TIME_STEP)):
world.step(TIME_STEP)
lowest = max(lowest, ball.position.y + radius)
assert ball.position.x - radius >= 0
assert ball.position.x + radius <= WORLD_WIDTH
# Tiefer als die Notbremse erlaubt, sinkt der Ball nie in den Boden ein.
assert lowest <= FLOOR + PHYSICS.max_wall_penetration + 1e-9
assert ball.sleeping
assert ball.velocity == Vector2(0, 0)
assert ball.position.y == pytest.approx(FLOOR - radius, abs=PHYSICS.penetration_slop)
def _first_bounce_apex(restitution: float) -> tuple[float, float]:
"""Lässt einen Ball fallen und liefert (Fallhöhe, Steighöhe nach dem 1. Aufprall)."""
world = make_world()
radius = 20
start_y = 200.0
ball = add_circle(world, 500, start_y, radius, restitution=restitution)
resting_y = FLOOR - radius
bounced = False
apex = resting_y
for _ in range(round(4 / TIME_STEP)):
world.step(TIME_STEP)
if ball.velocity.y < 0:
bounced = True
apex = min(apex, ball.position.y)
elif bounced:
break # Scheitelpunkt überschritten
return resting_y - start_y, resting_y - apex
def test_bounce_height_follows_the_restitution_squared() -> None:
# Energieerhaltung beim Flug: h' / h = (v'/v)² = e².
drop, rise = _first_bounce_apex(restitution=0.8)
assert rise < drop # Energie geht verloren
assert rise == pytest.approx(0.8**2 * drop, rel=0.01)
def test_perfectly_elastic_ball_keeps_its_bounce_height() -> None:
"""Ein vollkommen elastischer Ball springt immer wieder genau auf seine Fallhöhe.
Er darf nie höher springen – sonst entstünde Energie aus dem Nichts, etwa durch die im
Aufprallschritt schon addierte Schwerkraft.
"""
world = make_world()
radius = 20
start_y = 250.0
ball = add_circle(world, 500, start_y, radius, restitution=1.0)
apexes = []
rising = False
for _ in range(round(8 / TIME_STEP)):
world.step(TIME_STEP)
if ball.velocity.y < 0:
rising = True
elif rising:
apexes.append(ball.position.y)
rising = False
assert len(apexes) >= 4
for apex in apexes:
assert apex == pytest.approx(start_y, abs=2.0) # 2 px bei 430 px Fallhöhe
def test_ball_without_restitution_does_not_bounce() -> None:
drop, rise = _first_bounce_apex(restitution=0.0)
assert drop > 0
assert rise == pytest.approx(0, abs=1.0)
def test_total_energy_never_increases_without_external_forces() -> None:
world = make_world(gravity=(0.0, 0.0))
rng = random.Random(42)
for _ in range(30):
velocity = (rng.uniform(-300, 300), rng.uniform(-300, 300))
x, y = rng.uniform(60, 940), rng.uniform(60, 640)
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 35), velocity=velocity)
else:
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35), velocity=velocity)
energy = total_kinetic_energy(world.bodies)
for _ in range(round(5 / TIME_STEP)):
world.step(TIME_STEP)
new_energy = total_kinetic_energy(world.bodies)
assert new_energy <= energy * (1 + 1e-9) + 1e-6
energy = new_energy
# --------------------------------------------------------------------- Überlappen und Stapeln
def test_overlapping_bodies_are_pushed_apart() -> None:
world = make_world()
bottom = add_box(world, 500, FLOOR - 30, 40, 30)
top = add_box(world, 510, FLOOR - 70, 30, 30) # steckt 20 px in der unteren Box
simulate(world, 2.0)
gap = (bottom.position.y - 30) - (top.position.y + 30)
assert gap >= -MAX_RESTING_OVERLAP
def test_circle_spawned_inside_a_box_pops_out() -> None:
world = make_world()
base = add_box(world, 500, FLOOR - 40, 60, 40)
ball = add_circle(world, 520, FLOOR - 50, 15) # Mittelpunkt liegt in der Box
simulate(world, 2.0)
assert ball.position.y + 15 <= base.position.y - 40 + MAX_RESTING_OVERLAP
def test_box_stack_is_stable_and_falls_asleep() -> None:
world = make_world()
half_height = 20
boxes = [
add_box(world, 500, FLOOR - half_height - i * 2 * half_height, 30, half_height)
for i in range(6)
]
simulate(world, 5.0)
for lower, upper in itertools.pairwise(boxes):
assert upper.position.x == pytest.approx(500, abs=1e-6) # kein seitliches Wandern
gap = (lower.position.y - half_height) - (upper.position.y + half_height)
assert -MAX_RESTING_OVERLAP <= gap <= 1e-9
assert all(box.sleeping for box in boxes)
def test_pile_of_mixed_bodies_settles_completely() -> None:
world = make_world()
rng = random.Random(3)
for _ in range(60):
x, y = rng.uniform(60, 940), rng.uniform(40, 500)
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 35))
else:
add_box(world, x, y, rng.uniform(12, 35), rng.uniform(12, 35))
simulate(world, 8.0)
assert all(body.sleeping for body in world.bodies)
# --------------------------------------------------------------------- Geschwindigkeit und Tunneln
def test_speed_is_clamped_to_the_maximum() -> None:
world = make_world(gravity=(0.0, 0.0))
ball = add_circle(world, 500, 350, 20, velocity=(50_000, 0))
world.step(TIME_STEP)
assert ball.velocity.length() == pytest.approx(PHYSICS.max_speed)
def test_spin_is_clamped_to_the_maximum_in_both_directions() -> None:
world = make_world(gravity=(0.0, 0.0))
clockwise = add_circle(world, 300, 350, 20, angular_velocity=10_000)
counter = add_circle(world, 600, 350, 20, angular_velocity=-10_000)
world.step(TIME_STEP)
assert clockwise.angular_velocity == pytest.approx(PHYSICS.max_angular_speed)
assert counter.angular_velocity == pytest.approx(-PHYSICS.max_angular_speed)
def test_emergency_brake_never_interferes_with_free_flight() -> None:
# Die Notbremse muss über der größten Eindringtiefe pro Schritt liegen, sonst würde sie
# normale Aufpralle verfälschen (Positionen verschieben und Energie hinzufügen).
assert PHYSICS.max_wall_penetration > PHYSICS.max_speed * PHYSICS.time_step
def test_emergency_brake_limits_deep_wall_penetration() -> None:
world = make_world(gravity=(0.0, 0.0))
box = add_box(world, -10, 350, 20, 20) # steckt 30 px in der linken Wand
world.step(TIME_STEP)
assert box.bounds()[0] >= -PHYSICS.max_wall_penetration - 1e-9
simulate(world, 0.5)
assert box.bounds()[0] >= -MAX_RESTING_OVERLAP
def test_fast_small_ball_does_not_tunnel_through_a_wall() -> None:
world = make_world(gravity=(0.0, 0.0))
radius = MIN_BODY_SIZE
ball = add_circle(world, 500, 350, radius, velocity=(-50_000, -30_000), restitution=1.0)
for _ in range(round(3 / TIME_STEP)):
world.step(TIME_STEP)
allowed = PHYSICS.max_wall_penetration + 1e-9
assert -allowed <= ball.position.x - radius
assert ball.position.x + radius <= WORLD_WIDTH + allowed
assert -allowed <= ball.position.y - radius
assert ball.position.y + radius <= WORLD_HEIGHT + allowed
def test_two_smallest_bodies_at_max_speed_do_not_pass_through_each_other() -> None:
world = make_world(gravity=(0.0, 0.0))
radius = MIN_BODY_SIZE
speed = PHYSICS.max_speed
left = add_circle(world, 300, 350, radius, velocity=(speed, 0), restitution=1.0)
right = add_circle(world, 700, 350, radius, velocity=(-speed, 0), restitution=1.0)
simulate(world, 0.25) # Zusammenstoß nach ≈ 0,15 s
assert left.position.x < right.position.x
assert left.velocity.x < 0 < right.velocity.x
# --------------------------------------------------------------------- Schlafmodus
def test_sleeping_body_is_woken_by_an_impact() -> None:
world = make_world()
resting = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 1.5)
assert resting.sleeping
falling = add_circle(world, 500, 600, 15) # trifft die Box nach ≈ 0,3 s
simulate(world, 0.4)
assert not falling.sleeping
assert not resting.sleeping
def test_removing_a_support_wakes_the_body_on_top() -> None:
world = make_world()
support = add_box(world, 500, FLOOR - 20, 40, 20)
load = add_box(world, 500, FLOOR - 60, 30, 20)
simulate(world, 2.0)
assert support.sleeping
assert load.sleeping
world.remove_body(support)
assert not load.sleeping
simulate(world, 1.5)
assert load.position.y == pytest.approx(FLOOR - 20, abs=PHYSICS.penetration_slop)
def test_removing_a_body_only_wakes_sleeping_neighbours() -> None:
world = make_world()
removed = add_box(world, 300, FLOOR - 20, 40, 20)
neighbour = add_box(world, 360, FLOOR - 20, 20, 20)
far_away = add_box(world, 800, FLOOR - 20, 20, 20)
simulate(world, 2.0)
awake = add_circle(world, 300, 300, 15) # fällt noch, ist also wach
world.remove_body(removed)
assert not neighbour.sleeping
assert far_away.sleeping # weit weg: bleibt ungestört liegen
assert not awake.sleeping
assert removed not in world.bodies
def _is_supported(body: Body, world: World) -> bool:
"""Liegt der Körper auf dem Boden oder auf einem tiefer liegenden Körper auf?"""
min_x, _, max_x, max_y = body.bounds()
if max_y >= world.height - 1.0:
return True
for other in world.bodies:
if other is body or other.position.y <= body.position.y:
continue # nur Körper darunter können tragen
other_min_x, other_min_y, other_max_x, _ = other.bounds()
overlaps_horizontally = other_min_x <= max_x and min_x <= other_max_x
if overlaps_horizontally and other_min_y <= max_y + 1.0:
return True
return False
def test_moving_the_base_of_a_sleeping_stack_wakes_the_whole_stack() -> None:
"""Ein schlafender Stapel verliert seine Basis – die Kisten darüber fallen.
Die unterste Kiste wird (wie beim Ziehen im Pausenmodus) ohne Physikschritt weggesetzt.
Die Kisten darüber dürfen danach nicht in der Luft weiterschlafen.
"""
world = make_world()
stack = [add_box(world, 500, FLOOR - 20 - 40 * i, 30, 20) for i in range(3)]
simulate(world, 2.0)
assert all(box.sleeping for box in stack)
stack[0].teleport(Vector2(150, FLOOR - 20))
assert not any(box.sleeping for box in stack)
simulate(world, 2.0)
assert stack[1].position.y == pytest.approx(FLOOR - 20, abs=MAX_RESTING_OVERLAP)
assert stack[2].position.y == pytest.approx(FLOOR - 60, abs=2 * MAX_RESTING_OVERLAP)
def test_knocking_away_a_support_does_not_leave_bodies_floating() -> None:
"""Wird die Unterlage eines schlafenden Körpers weggestoßen, fällt er herunter.
Die flache Box verliert den Kontakt zur außermittig liegenden Kugel schon im ersten
Schritt nach dem Treffer. Ohne gemeinsames Wecken der Insel bliebe die Kugel schlafend
in der Luft hängen.
"""
world = make_world()
base = add_box(world, 500, FLOOR - 12, 40, 12) # flache Box am Boden
ball = add_circle(world, 530, FLOOR - 24 - 15, 15) # liegt 30 px außermittig darauf
simulate(world, 2.0)
assert base.sleeping
assert ball.sleeping
# Schwerer, flacher Rammbock trifft nur die Box, nicht die Kugel.
add_box(world, 800, FLOOR - 10, 20, 10, velocity=(-PHYSICS.max_speed, 0), density=5.0)
simulate(world, 2.0)
assert abs(ball.position.x - base.position.x) > 40 + 15 # Box ist unter der Kugel weg
assert ball.position.y == pytest.approx(FLOOR - 15, abs=MAX_RESTING_OVERLAP)
for body in world.bodies:
if body.sleeping:
assert _is_supported(body, world), f"{body} schläft in der Luft"
def test_toggling_gravity_wakes_everything() -> None:
world = make_world()
box = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 1.5)
assert box.sleeping
world.gravity_enabled = False
assert not box.sleeping
world.gravity_enabled = False # keine Änderung, kein erneutes Wecken nötig
assert not world.gravity_enabled
def test_held_bodies_ignore_gravity_and_never_sleep() -> None:
world = make_world()
ball = add_circle(world, 500, 300, 20)
ball.held = True
simulate(world, 2.0)
assert ball.position == Vector2(500, 300)
assert not ball.sleeping
def test_sleeping_can_be_disabled() -> None:
world = make_world(sleep_enabled=False)
box = add_box(world, 500, FLOOR - 20, 40, 20)
simulate(world, 3.0)
assert not box.sleeping
# --------------------------------------------------------------------- Verwaltung
def test_world_rejects_invalid_sizes_and_static_bodies() -> None:
with pytest.raises(ValueError, match="positive Größe"):
World(0, 100, PHYSICS)
world = make_world()
with pytest.raises(ValueError, match="Wände"):
world.add_body(Body.wall(Vector2(0, 1), 0, material()))
def test_body_at_returns_the_topmost_body() -> None:
world = make_world()
lower = add_box(world, 100, 100, 30, 30)
upper = add_circle(world, 110, 100, 20)
assert world.body_at(Vector2(110, 100)) is upper
assert world.body_at(Vector2(80, 80)) is lower
assert world.body_at(Vector2(300, 300)) is None
assert world.body_at(Vector2(133, 100), tolerance=5) is upper
def test_clamp_inside_keeps_the_whole_body_in_the_world() -> None:
world = make_world()
assert world.clamp_inside(Vector2(-50, 900), (20, 10)) == Vector2(20, WORLD_HEIGHT - 10)
inside = Vector2(300, 300)
assert world.clamp_inside(inside, (20, 10)) is inside
def test_clear_removes_all_bodies() -> None:
world = make_world()
for i in range(5):
add_circle(world, 100 + 50 * i, 600, 20)
simulate(world, 0.2)
world.clear()
assert world.bodies == []
assert world.contacts == []
simulate(world, 0.1) # auch eine leere Welt lässt sich weiter simulieren
def test_simulation_is_deterministic() -> None:
def run() -> list[tuple[float, float]]:
world = make_world()
rng = random.Random(5)
for _ in range(25):
add_circle(world, rng.uniform(50, 950), rng.uniform(50, 400), rng.uniform(12, 30))
simulate(world, 2.0)
return [body.position.to_tuple() for body in world.bodies]
assert run() == run()
@pytest.mark.slow
def test_hundred_bodies_stay_stable_for_a_thousand_steps() -> None:
world = make_world()
rng = random.Random(99)
for _ in range(100):
x, y = rng.uniform(40, 960), rng.uniform(40, 660)
velocity = (rng.uniform(-600, 600), rng.uniform(-600, 600))
if rng.random() < 0.5:
add_circle(world, x, y, rng.uniform(12, 30), velocity=velocity)
else:
add_box(world, x, y, rng.uniform(12, 30), rng.uniform(12, 30), velocity=velocity)
allowed = PHYSICS.max_wall_penetration + 1e-6
for _ in range(1000):
world.step(TIME_STEP)
for body in world.bodies:
assert body.position.is_finite()
assert body.velocity.is_finite()
assert body.velocity.length() <= PHYSICS.max_speed + 1e-6
min_x, min_y, max_x, max_y = body.bounds()
assert min_x >= -allowed and max_x <= WORLD_WIDTH + allowed
assert min_y >= -allowed and max_y <= WORLD_HEIGHT + allowed