Sign in
EN
games/satisfying-bounce / src / Lib / Physics2d / World.php · commit 8bdd6e4
<?phpnamespace JumPHP\Physics2d;use JumPHP\Kernel\GameConfig;/** * The simulation: balls bouncing inside rings, on pegs and walls, under gravity, at a fixed step ($hz a second, the * same run for the same seed and the same calls). update($dt) advances by whole steps; hits() hands over what was hit * since the last call, oldest first, each one: *   ['type' => 'ring'|'peg'|'wall'|'ball'|'escape', 'time' => s, 'x', 'y' (where), 'speed' (the ball's, before), *    'ball' => Ball, 'body' => Ring|Peg|Wall|Ball] * ('escape': a ball left a ring through its gap). on(fn (array $hit) => ...) hears them as they happen. */final class World{    public float $gravity = 600;    public float $restitution = 1.0;    public int $hz = 240;    public float $maxSpeed = 1800;    /** balls bounce on each other */    public bool $ballsCollide = false;    public float $time = 0;    public Random $random;    /** @var list<Ball> */    private array $balls = [];    /** @var list<Ring> */    private array $rings = [];    /** @var list<Peg> */    private array $pegs = [];    /** @var list<Wall> */    private array $walls = [];    /** @var list<array<string, mixed>> */    private array $hits = [];    /** @var list<callable> */    private array $listeners = [];    /** @var array<string, true> "ring id:ball id" of the balls that left a ring */    private array $out = [];    private float $carry = 0;    private int $seed = 1;    public function __construct(?GameConfig $config = null)    {        $c = (array)($config?->get('physics', []) ?? []);        $this->gravity = (float)($c['gravity'] ?? 600);        $this->restitution = (float)($c['restitution'] ?? 1.0);        $this->hz = max(30, (int)($c['hz'] ?? 240));        $this->seed = (int)($c['seed'] ?? 1);        $this->random = new Random($this->seed);    }    /** Empty, time 0, the configured seed again (or $seed). */    public function reset(?int $seed = null): void    {        $this->balls = $this->rings = $this->pegs = $this->walls = $this->hits = [];        $this->listeners = [];        $this->out = [];        $this->time = 0;        $this->carry = 0;        $this->random->seed($seed ?? $this->seed);    }    public function on(callable $listener): void    {        $this->listeners[] = $listener;    }    public function addBall(Ball $ball): Ball    {        $this->balls[] = $ball;        return $ball;    }    public function addRing(Ring $ring): Ring    {        $this->rings[] = $ring;        return $ring;    }    public function addPeg(Peg $peg): Peg    {        $this->pegs[] = $peg;        return $peg;    }    public function addWall(Wall $wall): Wall    {        $this->walls[] = $wall;        return $wall;    }    public function removeBall(Ball $ball): void    {        $this->forget(':' . spl_object_id($ball), false);        $this->balls = array_values(array_filter($this->balls, fn (Ball $b) => $b !== $ball));    }    public function removeRing(Ring $ring): void    {        $this->forget(spl_object_id($ring) . ':', true);        $this->rings = array_values(array_filter($this->rings, fn (Ring $r) => $r !== $ring));    }    /** The "left this ring" marks of a removed body: object ids are reused by PHP, a new ring must not inherit them. */    private function forget(string $part, bool $prefix): void    {        foreach (array_keys($this->out) as $key) {            if ($prefix ? str_starts_with((string)$key, $part) : str_ends_with((string)$key, $part)) {                unset($this->out[$key]);            }        }    }    /** @return list<Ball> */    public function balls(): array    {        return $this->balls;    }    /** @return list<Ring> */    public function rings(): array    {        return $this->rings;    }    /** @return list<Peg> */    public function pegs(): array    {        return $this->pegs;    }    /** @return list<Wall> */    public function walls(): array    {        return $this->walls;    }    /** What was hit since the last call (and forgets it). @return list<array<string, mixed>> */    public function hits(): array    {        $hits = $this->hits;        $this->hits = [];        return $hits;    }    public function update(float $dt): void    {        $h = 1 / $this->hz;        $this->carry += $dt;        $steps = 0;        while ($this->carry >= $h - 1e-9 && $steps < $this->hz) {            $this->carry -= $h;            $this->step($h);            $steps++;        }    }    /** One fixed step of $h seconds. */    public function step(float $h): void    {        $this->time += $h;        foreach ($this->rings as $ring) {            $ring->angle = fmod($ring->angle + $ring->spin * $h, 2 * M_PI);            if ($ring->grow != 0) {                $ring->radius = max($ring->minRadius, min($ring->maxRadius, $ring->radius + $ring->grow * $h));            }        }        foreach ($this->balls as $ball) {            $ball->vy += $this->gravity * $h;            $speed = $ball->speed();            if ($speed > $this->maxSpeed) {                $ball->vx *= $this->maxSpeed / $speed;                $ball->vy *= $this->maxSpeed / $speed;            }            $ball->x += $ball->vx * $h;            $ball->y += $ball->vy * $h;            $this->collideRings($ball);            $this->collidePegs($ball);            $this->collideWalls($ball);            if ($ball->trailLength > 0) {                $ball->trail[] = [$ball->x, $ball->y];                if (count($ball->trail) > $ball->trailLength) {                    array_shift($ball->trail);                }            }        }        if ($this->ballsCollide) {            $this->collideBalls();        }    }    private function collideRings(Ball $ball): void    {        foreach ($this->rings as $ring) {            $key = spl_object_id($ring) . ':' . spl_object_id($ball);            if (!$ring->solid || isset($this->out[$key])) {                continue;            }            $dx = $ball->x - $ring->x;            $dy = $ball->y - $ring->y;            $d = sqrt($dx * $dx + $dy * $dy);            if ($d - $ball->radius > $ring->radius) {                $this->out[$key] = true;           // through the gap: out for good                $this->hit('escape', $ball, $ring, $ball->x, $ball->y, $ball->speed());                continue;            }            if ($d + $ball->radius < $ring->radius || $d < 1e-6) {                continue;            }            if ($ring->inGap(atan2($dy, $dx))) {                continue;            }            $nx = $dx / $d;            $ny = $dy / $d;            $vn = $ball->vx * $nx + $ball->vy * $ny;            $speed = $ball->speed();            $ball->x = $ring->x + $nx * ($ring->radius - $ball->radius);            $ball->y = $ring->y + $ny * ($ring->radius - $ball->radius);            if ($vn > 0) {                $ball->vx -= (1 + $this->restitution) * $vn * $nx;                $ball->vy -= (1 + $this->restitution) * $vn * $ny;                $ball->hits++;                $ring->hits++;                if ($ring->growOnHit != 0) {                    $ring->radius = max($ring->minRadius, min($ring->maxRadius, $ring->radius + $ring->growOnHit));                }                if ($ring->openOnHit != 0) {                    $ring->gap = min(2 * M_PI, $ring->gap + $ring->openOnHit);                }                $this->hit('ring', $ball, $ring, $ring->x + $nx * $ring->radius, $ring->y + $ny * $ring->radius, $speed);            }        }    }    private function collidePegs(Ball $ball): void    {        foreach ($this->pegs as $peg) {            $dx = $ball->x - $peg->x;            $dy = $ball->y - $peg->y;            $min = $ball->radius + $peg->radius;            $d2 = $dx * $dx + $dy * $dy;            if ($d2 >= $min * $min || $d2 < 1e-9) {                continue;            }            $d = sqrt($d2);            $nx = $dx / $d;            $ny = $dy / $d;            $speed = $ball->speed();            $ball->x = $peg->x + $nx * $min;            $ball->y = $peg->y + $ny * $min;            $vn = $ball->vx * $nx + $ball->vy * $ny;            if ($vn < 0) {                $ball->vx -= (1 + $this->restitution) * $vn * $nx;                $ball->vy -= (1 + $this->restitution) * $vn * $ny;                $ball->hits++;                $peg->hits++;                $this->hit('peg', $ball, $peg, $peg->x + $nx * $peg->radius, $peg->y + $ny * $peg->radius, $speed);            }        }    }    private function collideWalls(Ball $ball): void    {        foreach ($this->walls as $wall) {            $wx = $wall->x2 - $wall->x1;            $wy = $wall->y2 - $wall->y1;            $len2 = $wx * $wx + $wy * $wy;            $t = $len2 > 0 ? max(0.0, min(1.0, (($ball->x - $wall->x1) * $wx + ($ball->y - $wall->y1) * $wy) / $len2)) : 0.0;            $px = $wall->x1 + $wx * $t;            $py = $wall->y1 + $wy * $t;            $dx = $ball->x - $px;            $dy = $ball->y - $py;            $d2 = $dx * $dx + $dy * $dy;            if ($d2 >= $ball->radius * $ball->radius || $d2 < 1e-9) {                continue;            }            $d = sqrt($d2);            $nx = $dx / $d;            $ny = $dy / $d;            $speed = $ball->speed();            $ball->x = $px + $nx * $ball->radius;            $ball->y = $py + $ny * $ball->radius;            $vn = $ball->vx * $nx + $ball->vy * $ny;            if ($vn < 0) {                $ball->vx -= (1 + $this->restitution) * $vn * $nx;                $ball->vy -= (1 + $this->restitution) * $vn * $ny;                $ball->hits++;                $this->hit('wall', $ball, $wall, $px, $py, $speed);            }        }    }    private function collideBalls(): void    {        $n = count($this->balls);        for ($i = 0; $i < $n; $i++) {            for ($j = $i + 1; $j < $n; $j++) {                $a = $this->balls[$i];                $b = $this->balls[$j];                $dx = $b->x - $a->x;                $dy = $b->y - $a->y;                $min = $a->radius + $b->radius;                $d2 = $dx * $dx + $dy * $dy;                if ($d2 >= $min * $min || $d2 < 1e-9) {                    continue;                }                $d = sqrt($d2);                $nx = $dx / $d;                $ny = $dy / $d;                $push = ($min - $d) / 2;                $a->x -= $nx * $push;                $a->y -= $ny * $push;                $b->x += $nx * $push;                $b->y += $ny * $push;                $rel = ($a->vx - $b->vx) * $nx + ($a->vy - $b->vy) * $ny;                if ($rel > 0) {                    $k = (1 + $this->restitution) * $rel / 2;                    $a->vx -= $k * $nx;                    $a->vy -= $k * $ny;                    $b->vx += $k * $nx;                    $b->vy += $k * $ny;                    $this->hit('ball', $a, $b, $a->x + $nx * $a->radius, $a->y + $ny * $a->radius, $rel);                }            }        }    }    private function hit(string $type, Ball $ball, object $body, float $x, float $y, float $speed): void    {        $hit = ['type' => $type, 'time' => $this->time, 'x' => $x, 'y' => $y, 'speed' => $speed, 'ball' => $ball, 'body' => $body];        $this->hits[] = $hit;        foreach ($this->listeners as $listener) {            $listener($hit);        }    }}