Accedi
IT
games/webrtc-demo / src / Scene / LinkScene.php · commit 87ad512
<?phpnamespace App\Scene;use App\Net\Rtc;use App\Ui\Look;use JumPHP\Renderer\Renderer;use JumPHP\Scene\AbstractScene;use JumPHP\Util\Lang;use JumPHP\WebRtc\DataChannel;use JumPHP\WebRtc\WebRtc;/** * Two WebRTC peers on this screen, A and B, connected to each other (their signals go straight from one to the other: in a real game they * go through a server, see DuelScene). Two data channels: 'chat' (reliable: PING / PONG, the round trip measured) and 'cursor' (unreliable, * unordered, like UDP: A's cursor, drawn by B from what arrives). Space / a click: a PING; the arrows / the mouse: the cursor. */final class LinkScene extends AbstractScene{    public const A = [8, 22, 146, 104];    public const B = [166, 22, 146, 104];    private ?DataChannel $chatA = null;    private ?DataChannel $cursorA = null;    private string $stateA = 'new';    private string $stateB = 'new';    private string $backend = '';    /** @var array{0: float, 1: float} A's cursor, 0..1 of its panel */    private array $cursor = [0.5, 0.5];    /** @var array{0: float, 1: float}|null A's cursor as B received it */    private ?array $ghost = null;    /** @var array{0: list<string>, 1: list<string>} the last messages of A and B */    private array $log = [[], []];    /** @var list<array{0: float, 1: int}> packets in flight on the screen: [age, direction 1 = A to B, -1 = B to A] */    private array $packets = [];    private int $pings = 0;    private int $received = 0;    private int $cursorPackets = 0;    private ?float $rtt = null;    private float $time = 0;    private float $nextCursor = 0;    private float $nextAuto = 0;    private bool $demo = false;    private string $error = '';    /** @var array{0: float, 1: float}|null where the mouse was last frame (it moves the cursor only when it moves) */    private ?array $mouseAt = null;    public function __construct(private WebRtc $webrtc)    {    }    public function enter(array $params = []): void    {        Look::apply($this->engine, $params);        $this->demo = (bool)($params['demo'] ?? false);        $this->backend = Rtc::prepare($this->webrtc);        $this->stateA = $this->stateB = 'new';        $this->cursor = [0.5, 0.5];        $this->ghost = null;        $this->log = [[], []];        $this->packets = [];        $this->pings = $this->received = $this->cursorPackets = 0;        $this->rtt = null;        $this->time = $this->nextCursor = 0;        $this->nextAuto = 1.0;        $this->error = '';        $this->mouseAt = null;        $a = $this->webrtc->peer(['stun' => '']);        // one computer: no public address needed        $b = $this->webrtc->peer(['stun' => '']);        $a->on('signal', fn (array $s) => $b->signal($s));             // a real game sends them through a server (Signaling::overNet)        $b->on('signal', fn (array $s) => $a->signal($s));        $a->on('state', fn (string $s) => $this->stateA = $s);        $b->on('state', fn (string $s) => $this->stateB = $s);        $a->on('error', fn (string $e) => $this->error = $e);        $b->on('error', fn (string $e) => $this->error = $e);        $this->chatA = $a->channel('chat');                                          // reliable, ordered        $this->cursorA = $a->channel('cursor', ordered: false, retransmits: 0);     // like UDP        $this->chatA->on('message', fn (string $m) => $this->chatToA($m));        $b->on('channel', function (DataChannel $ch): void {            $ch->on('message', fn (string $m) => $ch->label === 'chat' ? $this->chatToB($ch, $m) : $this->cursorToB($m));        });        $a->offer();                                                                 // A offers, B answers by itself    }    public function exit(): void    {        $this->webrtc->closeAll();        $this->chatA = $this->cursorA = null;    }    /** B got a message on 'chat': it answers a PING with a PONG (same number, same time stamp). */    private function chatToB(DataChannel $ch, string $m): void    {        $this->packets[] = [0.0, 1];        $this->note(1, $m);        if (str_starts_with($m, 'PING')) {            $ch->send('PONG' . substr($m, 4));            $this->packets[] = [0.0, -1];        }    }    private function chatToA(string $m): void    {        $this->note(0, $m);        $parts = explode(' ', $m);        if ($parts[0] === 'PONG' && isset($parts[2])) {            $this->received++;            $this->rtt = max(0.0, $this->engine->time() - (float)$parts[2]) * 1000;        }    }    private function cursorToB(string $m): void    {        $xy = explode(',', $m);        if (count($xy) === 2) {            $this->ghost = [(float)$xy[0], (float)$xy[1]];            $this->cursorPackets++;        }    }    private function note(int $side, string $m): void    {        $words = explode(' ', $m);        $this->log[$side][] = $words[0] . ' #' . ($words[1] ?? '');        $this->log[$side] = array_slice($this->log[$side], -4);    }    private function ping(): void    {        if ($this->chatA?->isOpen()) {            $this->chatA->send('PING ' . ++$this->pings . ' ' . round($this->engine->time(), 4));            $this->note(0, 'PING ' . $this->pings);        }    }    public function update(float $dt): void    {        $this->webrtc->backend()->poll();     // the extension polls on a loop timer too; here it also runs in game tests (frames without the loop)        if (Look::menuPressed($this->engine)) {            $this->scenes()->switchTo(TitleScene::class);            return;        }        $this->time += $dt;        foreach ($this->packets as $i => $p) {            $this->packets[$i][0] += $dt;        }        $this->packets = array_values(array_filter($this->packets, fn (array $p) => $p[0] < 0.4));        // A's cursor: the arrows, the mouse over A's panel, or the demo's circle        [$ax, $ay, $aw, $ah] = self::A;        $mouse = $this->engine->input->getMouse();        if ($this->demo) {            $this->cursor = [0.5 + 0.35 * cos($this->time * 1.7), 0.5 + 0.3 * sin($this->time * 2.3)];        } elseif ($this->mouseAt !== null && $this->mouseAt !== [$mouse->x, $mouse->y] && Look::hover($this->engine, $ax, $ay, $aw, $ah)) {            $this->cursor = [($mouse->x - $ax) / $aw, ($mouse->y - $ay) / $ah];        }        $this->mouseAt = [$mouse->x, $mouse->y];        $a = $this->actions();        $this->cursor[0] = max(0.0, min(1.0, $this->cursor[0] + $a->axis('left', 'right') * 0.8 * $dt));        $this->cursor[1] = max(0.0, min(1.0, $this->cursor[1] + $a->axis('up', 'down') * 0.8 * $dt));        if ($this->cursorA?->isOpen() && $this->time >= $this->nextCursor) {            $this->cursorA->send(round($this->cursor[0], 3) . ',' . round($this->cursor[1], 3));            $this->nextCursor = $this->time + 1 / 30;        }        if ($a->isPressed('send') || $a->isPressed('confirm') || $mouse->isPressed(1)) {            $this->ping();        }        if ($this->demo && $this->time >= $this->nextAuto) {            $this->ping();            $this->nextAuto = $this->time + 0.6;        }    }    public function render(Renderer $r): void    {        $r->clear(Look::BG);        $font = $this->font();        $w = $this->width();        Look::menuButton($r, $this->engine);        $font->draw($r, Lang::t('Two peers on this screen'), $w / 2 + 20, 5, 1, [255, 255, 255], 'center');        foreach ([0 => self::A, 1 => self::B] as $side => [$x, $y, $pw, $ph]) {            $state = $side === 0 ? $this->stateA : $this->stateB;            $r->fillRect($x, $y, $pw, $ph, Look::PANEL);            $r->drawRect($x, $y, $pw, $ph, $side === 0 ? Look::ME : Look::OTHER);            $font->draw($r, Lang::t($side === 0 ? 'Peer A' : 'Peer B'), $x + 5, $y + 4, 1, $side === 0 ? Look::ME : Look::OTHER);            $r->fillCircle($x + $pw - 8, $y + 8, 3, Look::stateColor($state));            $font->draw($r, Look::state($state), $x + $pw - 14, $y + 4, 1, Look::MUTED, 'right');            foreach ($this->log[$side] as $i => $line) {                $font->draw($r, $line, $x + 5, $y + 18 + $i * 10, 1, Look::TEXT);            }        }        // A's cursor and the copy B draws from the 'cursor' channel        [$ax, $ay, $aw, $ah] = self::A;        $cx = $ax + $this->cursor[0] * $aw;        $cy = $ay + $this->cursor[1] * $ah;        $r->fillCircle($cx, $cy, 4, Look::ME);        if ($this->ghost !== null) {            [$bx, $by, $bw, $bh] = self::B;            $r->fillCircle($bx + $this->ghost[0] * $bw, $by + $this->ghost[1] * $bh, 4, [64, 214, 128, 170]);        }        // the link between them, and the packets in flight (drawn slower than they go: a few milliseconds here)        $midY = self::A[1] + self::A[3] / 2;        $r->drawLine(self::A[0] + self::A[2], $midY, self::B[0], $midY, Look::LINE);        foreach ($this->packets as [$age, $dir]) {            $t = $age / 0.4;            $px = $dir === 1 ? self::A[0] + self::A[2] + $t * 20 : self::B[0] - $t * 20;            $r->fillCircle($px, $midY + ($dir === 1 ? -3 : 3), 2, $dir === 1 ? Look::ME : Look::OTHER);        }        $base = self::A[1] + self::A[3] + 4;        $rtt = $this->rtt === null ? '–' : (string)round($this->rtt, 1);        $font->draw($r, Lang::t('Round trip: {ms} ms', ['ms' => $rtt]) . '   ' . Lang::t('PONG received: {n}', ['n' => $this->received])            . '   ' . Lang::t('Cursor packets: {n}', ['n' => $this->cursorPackets]), $w / 2, $base, 1, Look::TEXT, 'center');        if ($this->error !== '') {            $font->draw($r, $this->error, $w / 2, $base + 10, 1, [255, 120, 120], 'center');        } else {            $font->draw($r, Lang::t('Space or click: send a PING.'), $w / 2, $base + 10, 1, Look::MUTED, 'center');            $font->draw($r, Lang::t('Arrows or mouse: move the cursor, B sees it.'), $w / 2, $base + 20, 1, Look::MUTED, 'center');        }        $font->draw($r, Lang::t('WebRTC here: {what}', ['what' => Lang::t(Rtc::describe($this->backend))]), $w / 2, $base + 32, 1, Look::ACCENT, 'center');    }}