Anmelden
DE
games/webrtc-demo / src / Scene / DuelScene.php · commit 87ad512
<?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');    }}