<?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'); }}