<?phpnamespace App\Scene;use App\Game\Duel;use App\Net\Rtc;use App\Ui\Look;use JumPHP\Net\MessageCodec;use JumPHP\Net\NetSession;use JumPHP\Renderer\Renderer;use JumPHP\Scene\AbstractScene;use JumPHP\Util\Lang;use JumPHP\WebRtc\DataChannel;use JumPHP\WebRtc\Peer;use JumPHP\WebRtc\Signaling;use JumPHP\WebRtc\WebRtc;/** * Catch the stars: two players, peer to peer. The host runs the rules (Duel) and sends what it sees ~30 times a second on the 'game' channel * (unreliable, unordered: a lost snapshot is replaced by the next one); the other player sends its position on the same channel; 'event' * (reliable) carries the pings and "play again". * * mode 'online': the two players met on the relay (LobbyScene), which only carries their WebRTC signals (Signaling::overNet), then nothing. * mode 'bot': the other player is a bot of this game with a peer of its own, connected through WebRTC like a remote player. * demo: true (smoke test): the bot plays both sides. */final class DuelScene extends AbstractScene{ private const SEND_EVERY = 1 / 30; private string $mode = 'bot'; private bool $host = true; private bool $demo = false; private string $backend = ''; private ?NetSession $net = null; private ?Peer $peer = null; private ?DataChannel $game = null; private ?DataChannel $event = null; private ?DataChannel $botGame = null; private Duel $duel; private string $state = 'new'; private string $notice = ''; /** @var array{0: float, 1: float} */ private array $me = [40, 90]; /** @var array{0: float, 1: float} the other player, as last received */ private array $other = [270, 90]; /** @var array<string, mixed>|null the host's last snapshot (the guest draws it; the bot plays from its own copy) */ private ?array $view = null; /** @var array<string, mixed>|null */ private ?array $botView = null; /** @var array{0: float, 1: float} */ private array $bot = [270, 90]; private ?float $rtt = null; private float $time = 0; private float $nextSend = 0; private float $nextPing = 0; public function __construct(private WebRtc $webrtc) { $this->duel = new Duel(1); } public function enter(array $params = []): void { Look::apply($this->engine, $params); $this->mode = (string)($params['mode'] ?? 'bot') === 'online' ? 'online' : 'bot'; $this->demo = (bool)($params['demo'] ?? false); $this->backend = Rtc::prepare($this->webrtc); $this->duel->reset((int)($params['seed'] ?? 0)); $this->me = [40, 90]; $this->other = $this->bot = [270, 90]; $this->view = $this->botView = null; $this->rtt = null; $this->time = $this->nextSend = $this->nextPing = 0; $this->state = 'new'; $this->notice = ''; $this->game = $this->event = $this->botGame = null; $this->net = null; if ($this->mode === 'online') { $this->startOnline($params); } else { $this->startBot(); } } /** The bot: a second peer of this game, connected to ours with WebRTC (its signals handed over directly). */ private function startBot(): void { $this->host = true; $me = $this->peer = $this->webrtc->peer(['stun' => '']); $bot = $this->webrtc->peer(['stun' => '']); $me->on('signal', fn (array $s) => $bot->signal($s)); $bot->on('signal', fn (array $s) => $me->signal($s)); $bot->on('channel', function (DataChannel $ch): void { if ($ch->label === 'game') { $this->botGame = $ch; $ch->on('message', function (string $m): void { $s = json_decode($m, true); if (is_array($s) && ($s['t'] ?? '') === 's') { $this->botView = $s; } }); } else { $ch->on('message', fn (string $m) => $this->answerPing($ch, $m)); } }); $this->openChannels(); $this->listen($this->peer); $this->peer->offer(); } /** Online: the room of the lobby carries our signals, then the game goes peer to peer. */ private function startOnline(array $params): void { $net = $params['net'] ?? null; if (!$net instanceof NetSession) { $this->scenes()->switchTo(TitleScene::class); return; } $this->net = $net; $this->host = (bool)($params['host'] ?? false); $net->removeAllListeners(); $net->on('peerLeave', fn () => $this->notice = 'The other player left.'); $otherId = (int)($params['peer'] ?? ($net->peers()[0] ?? 0)); $this->peer = $this->webrtc->peer(); Signaling::overNet($net, $this->peer, $otherId); $this->listen($this->peer); if ($this->host) { $this->openChannels(); $this->peer->offer(); } else { $this->peer->on('channel', function (DataChannel $ch): void { $ch->label === 'game' ? $this->game = $ch : $this->event = $ch; $this->attach($ch); }); } } /** The offerer makes the channels (they are part of its offer). */ private function openChannels(): void { if ($this->peer === null) { return; } $this->game = $this->peer->channel('game', ordered: false, retransmits: 0); $this->event = $this->peer->channel('event'); $this->attach($this->game); $this->attach($this->event); } private function listen(Peer $peer): void { $peer->on('state', function (string $s): void { $this->state = $s; if (in_array($s, ['disconnected', 'failed', 'closed'], true) && $this->notice === '') { $this->notice = 'The connection was lost.'; } }); $peer->on('error', fn (string $e) => $this->notice = $e); } private function attach(DataChannel $ch): void { $ch->on('message', function (string $m) use ($ch): void { $msg = json_decode($m, true); if (!is_array($msg)) { return; } match ($msg['t'] ?? '') { 's' => $this->view = $msg, 'p' => $this->other = [(float)($msg['p'][0] ?? 0), (float)($msg['p'][1] ?? 0)], 'pong' => $this->rtt = max(0.0, $this->engine->time() - (float)($msg['at'] ?? 0)) * 1000, 'again' => $this->host ? $this->restart() : null, default => $this->answerPing($ch, $m), }; }); } private function answerPing(DataChannel $ch, string $m): void { $msg = json_decode($m, true); if (is_array($msg) && ($msg['t'] ?? '') === 'ping') { $ch->sendJson(['t' => 'pong', 'at' => $msg['at'] ?? 0]); } } private function restart(): void { $this->duel->reset(); $this->me = [40, 90]; $this->bot = [270, 90]; } public function exit(): void { $this->webrtc->closeAll(); $this->peer = null; $this->game = $this->event = $this->botGame = null; $this->net?->close(); $this->net = null; } private function connected(): bool { return (bool)$this->game?->isOpen(); } 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; if (!$this->connected()) { return; } $over = $this->host ? $this->duel->over : (bool)($this->view['o'] ?? false); $a = $this->actions(); if ($over) { if ($a->isPressed('confirm') || $a->isPressed('send') || $this->engine->input->getMouse()->isPressed(1) || ($this->demo && $this->time > 1)) { $this->host ? $this->restart() : $this->event?->sendJson(['t' => 'again']); } } else { $this->me = Duel::move($this->me, ...$this->direction($dt)); } if ($this->host) { $this->duel->step($dt, $this->me, $this->other); // the other player where its last message put it (the bot too) } if ($this->mode === 'bot' && $this->botGame?->isOpen() && !(bool)($this->botView['o'] ?? false)) { $this->bot = Duel::move($this->bot, ...[...Duel::towardNearest($this->bot, $this->botView['s'] ?? []), $dt * 0.85]); } if ($this->time >= $this->nextSend) { $this->nextSend = $this->time + self::SEND_EVERY; if ($this->host) { $this->game?->sendJson($this->duel->snapshot($this->me, $this->other)); } else { $this->game?->sendJson(['t' => 'p', 'p' => [round($this->me[0], 1), round($this->me[1], 1)]]); } $this->botGame?->sendJson(['t' => 'p', 'p' => [round($this->bot[0], 1), round($this->bot[1], 1)]]); } if ($this->time >= $this->nextPing) { $this->nextPing = $this->time + 1; $this->event?->sendJson(['t' => 'ping', 'at' => round($this->engine->time(), 4)]); } } /** Where I go: the keys, the finger / the mouse held (toward it), or the bot's choice in the demo. @return array{0: float, 1: float, 2: float} */ private function direction(float $dt): array { if ($this->demo) { $stars = $this->host ? $this->duel->stars : ($this->view['s'] ?? []); return [...Duel::towardNearest($this->me, $stars), $dt]; } $a = $this->actions(); $dx = $a->axis('left', 'right'); $dy = $a->axis('up', 'down'); $mouse = $this->engine->input->getMouse(); if ($dx === 0 && $dy === 0 && $mouse->isHeld(1) && $mouse->y > Duel::TOP) { $tx = $mouse->x - $this->me[0] - Duel::SIZE / 2; $ty = $mouse->y - $this->me[1] - Duel::SIZE / 2; $len = sqrt($tx * $tx + $ty * $ty); if ($len > 2) { return [$tx / $len, $ty / $len, $dt]; } } return [(float)$dx, (float)$dy, $dt]; } public function render(Renderer $r): void { $r->clear(Look::BG); $font = $this->font(); $w = $this->width(); $r->fillRect(0, Duel::TOP, $w, $this->height() - Duel::TOP, [14, 17, 34]); $r->drawRect(0, Duel::TOP, $w, $this->height() - Duel::TOP, Look::LINE); Look::menuButton($r, $this->engine); // what to draw: the host has the rules, the guest the last snapshot if ($this->host) { $stars = $this->duel->stars; [$mine, $theirs] = [$this->duel->scores[0], $this->duel->scores[1]]; $left = $this->duel->left; $over = $this->duel->over; $other = $this->other; } else { $stars = $this->view['s'] ?? []; [$theirs, $mine] = $this->view['sc'] ?? [0, 0]; $left = (float)($this->view['l'] ?? Duel::SECONDS); $over = (bool)($this->view['o'] ?? false); $other = $this->view['h'] ?? $this->other; } $otherName = Lang::t($this->mode === 'bot' ? 'Bot' : 'Other player'); $font->draw($r, Lang::t('You') . ' ' . $mine . ' – ' . $theirs . ' ' . $otherName, $w / 2 + 14, 5, 1, Look::TEXT, 'center'); $font->draw($r, (string)ceil($left) . ' s', $w - 6, 5, 1, Look::STAR, 'right'); if (!$this->connected()) { $msg = $this->notice !== '' ? Lang::t($this->notice) : Lang::t('Connecting peer to peer...'); $font->draw($r, $msg, $w / 2, 80, 1, Look::TEXT, 'center'); $font->draw($r, Lang::t('State: {s}', ['s' => Look::state($this->state)]), $w / 2, 94, 1, Look::stateColor($this->state), 'center'); return; } foreach ($stars as $s) { $r->fillCircle((float)$s[0], (float)$s[1], Duel::STAR_RADIUS, Look::STAR); $r->fillCircle((float)$s[0], (float)$s[1], 1.5, [255, 255, 255]); } $this->player($r, $other, Look::OTHER, $otherName); $this->player($r, $this->me, Look::ME, Lang::t('You')); $ping = $this->rtt === null ? '–' : (string)round($this->rtt, 1); $footer = Lang::t('Ping: {ms} ms', ['ms' => $ping]) . ' ' . Lang::t('WebRTC here: {what}', ['what' => Lang::t(Rtc::describe($this->backend))]); $font->draw($r, $footer, $w / 2, $this->height() - $font->height() - 3, 1, Look::MUTED, 'center'); if ($this->time < 4 && !$over) { $font->draw($r, Lang::t('Catch the stars! Arrows, or hold the mouse / your finger.'), $w / 2, Duel::TOP + 6, 1, Look::TEXT, 'center'); } if ($this->notice !== '') { $font->draw($r, Lang::t($this->notice), $w / 2, Duel::TOP + 18, 1, [255, 120, 120], 'center'); } if ($over) { $r->fillRect(60, 64, 200, 46, [0, 0, 0, 200]); $r->drawRect(60, 64, 200, 46, Look::ACCENT); $result = $mine === $theirs ? 'Draw!' : ($mine > $theirs ? 'You win!' : 'You lose!'); $font->draw($r, Lang::t($result), $w / 2, 70, 2, [255, 255, 255], 'center'); $font->draw($r, Lang::t('Space, Enter or a tap: play again'), $w / 2, 94, 1, Look::MUTED, 'center'); } } /** @param array<mixed> $pos */ private function player(Renderer $r, array $pos, array $color, string $name): void { $x = (float)($pos[0] ?? 0); $y = (float)($pos[1] ?? 0); $r->fillRect($x, $y, Duel::SIZE, Duel::SIZE, $color); $this->font()->draw($r, $name, $x + Duel::SIZE / 2, $y - 10, 1, $color, 'center'); }}