Статьи

Как они это сделали? PHPSnake: обнаружение нажатий клавиш

Векторное изображение джойстика старой школы

На недавней конференции в Болгарии состоялся хакатон, для которого Эндрю Картер создал консольную версию PHP популярной игры «змея».

Я подумал, что это действительно интересная концепция, и, поскольку у Эндрю есть история использования PHP для странных вещей , я решил демистифицировать и объяснить, как это было сделано.

Оригинальный репозиторий находится здесь , но мы создадим его версию с нуля в этой серии, так что нет необходимости его клонировать.

Скриншот игры

Предпосылки и правила

Как обычно, я буду использовать свою коробку Homestead Improved для мгновенного запуска и запуска. Вы можете использовать свою собственную среду, просто убедитесь, что у нее есть достойная версия PHP, запускаемая из командной строки.

Игра-змея, которую мы копируем, имеет следующие особенности:

  • змея начинается как одиночный персонаж на экране и становится длиннее на одного персонажа каждый раз, когда съедает кусок пищи.
  • еда появляется случайно в любом месте на карте.
  • в режиме одиночной игры змея управляется клавишами со стрелками.
  • в режиме двух игроков одна змея управляется клавишами WSAD, а другая — клавишами со стрелками.
  • в режиме одиночной игры стены являются препятствиями и вызывают столкновение. Бег в стену или в себя заканчивает игру.
  • в многопользовательском режиме только ваша змея или змея противника является препятствием — стены обвивают весь мир. Столкновение сбрасывает длину вашей змеи до 0. Игрок с самой длинной змеей по истечении 100 секунд считается победителем.
  • это CLI, поэтому не запускается в браузере — запускается в окне терминала

Обратите внимание, что игра не работает в родной Windows — чтобы запустить ее на платформе Windows, используйте хорошую виртуальную машину, такую ​​как Homestead Improved .

Бутстрапирование

Чтобы запустить CLI (консольную) игру, нам нужно нечто похожее на файл index.php на традиционных веб-сайтах — «фронт-контроллер», который читает входные данные нашей командной строки, анализирует их и затем запускает необходимые классы, как в традиционном веб-приложение. Мы назовем этот файл play.php .

 <?php $param = ($argc > 1) ? $argv[1] : ''; echo "Hello, you said: " . $param; 

Итак, если мы php play.php something этот файл с php play.php something , мы получим:

 Hello, you said: something 

Давайте теперь сделаем фиктивный SnakeGame.php в классе classes подпапок, который вызывается этим фронт-контроллером.

 // classes/Snake.php <?php namespace PHPSnake; class SnakeGame { public function __construct() { echo "Hello, I am snake!"; } } 

Давайте также обновим фронт-контроллер, чтобы загрузить этот класс и вызвать его:

 <?php use PHPSnake\SnakeGame; require_once 'classes/SnakeGame.php'; $param = ($argc > 1) ? $argv[1] : ''; $snake = new SnakeGame(); 

Хорошо, теперь мы должны увидеть змеиное приветствие, если перезапустим php play.php .

Рамки

Принцип работы традиционных игр заключается в повторной проверке состояния системы каждый кадр, а более высокая частота кадров означает более частую проверку. Например, когда отображается экран (один кадр), система может сообщить нам, что нажата буква A, и что один из игроков сталкивается с едой, поэтому должен произойти рост змеи. Все это происходит в одном кадре, и «FPS» или число кадров в секунду означает именно то, сколько таких системных наблюдений происходит в секунду.

Языки программирования, предназначенные для этого, имеют встроенные циклы для проверки состояния. PHP … не так много. Мы можем обойти это, но давайте пройдемся по шагам. Давайте изменим конструктор Снейка так:

  public function __construct() { echo "Hello, I am snake!"; $stdin = fopen('php://stdin', 'r'); while (1) { $key = fgetc($stdin); echo $key; } } 

Сначала мы открываем поток «stdin», что означает, что мы создаем способ для PHP получать «стандартный ввод» из командной строки, обрабатывая его так, как если бы он был файлом (следовательно, fopen ). Функция fgetc используется для получения одного символа из указателя файла (в отличие от fgets который получает целую строку), а затем ключ выводится на экран. Там есть цикл while, поэтому PHP продолжает ожидать ввода большего количества данных и не завершает сценарий после нажатия одной клавиши.

Однако, если мы попробуем запустить наше приложение, мы заметим, что клавиша отображается только после того, как мы нажмем клавишу ввода — то есть после новой строки. Более того — все, что мы написали, находит отклик. Вместо этого мы хотим, чтобы PHP отображал каждую нажатую клавишу, когда мы ее нажимаем.

Вот два способа сделать это.

Запущенная

Первый способ — через инструмент под названием stty который поставляется с терминальными приложениями в системах * nix (поэтому нет Windows, если вы не используете виртуальную машину типа Homestead Improved ). Он используется для изменения и настройки ввода и вывода терминала с помощью флагов — при наличии префикса - они часто означают «деактивацию» и наоборот.

То, что мы хотим, cbreak флаг cbreak stty. Согласно документам:

Обычно драйвер tty буферизует набранные символы до тех пор, пока не будет напечатан перевод строки или возврат каретки. Подпрограмма cbreak отключает буферизацию строки и обработку символов стирания / уничтожения (символы прерывания и управления потоком не затрагиваются), что делает символы, набранные пользователем, немедленно доступными для программы.

С точки зрения непрофессионала, мы больше не ждем нажатия клавиши ввода для отправки ввода.

  public function __construct() { echo "Hello, I am snake!"; system('stty cbreak'); $stdin = fopen('php://stdin', 'r'); while (1) { $c = ord(fgetc($stdin)); echo "Char read: $c\n"; } 

Мы вызываем system функцию, которая в основном действует как прокси-сервер оболочки, и перенаправляет команду, предоставленную в качестве аргумента, на терминал, с которого мы запускаем приложение PHP. После этого, запуск приложения с помощью php play.php позволит вам писать символы и php play.php их сразу после каждого нажатия клавиши.

Обратите внимание, что мы получаем код ключа, потому что мы завернули символ в ord — эта функция возвращает код ASCII для данного символа.

STYT решение работает

обратный вызов readline

Второй способ заключается в использовании удивительно мистической и недокументированной функции readline_callback_handler_install в сочетании с stream_select (также * nix только потому, что stream_select вызывает команду select системы, которая недоступна в Windows).

readline_callback_handler_install принимает сообщение-подсказку в качестве первого аргумента (то есть, что «спросить» у пользователя) и обратный вызов в качестве второго. В нашем случае мы оставляем ее как пустую функцию, потому что она нам на самом деле не нужна — мы читаем символы, анализируя STDIN, константу, которая на самом деле является просто ярлыком для fopen('php://stdin', 'r'); , Наш код для этой части выглядит так:

  public function __construct() { echo "Hello, I am snake!"; readline_callback_handler_install('', function() { }); while (true) { $r = array(STDIN); $w = NULL; $e = NULL; $n = stream_select($r, $w, $e, null); if ($n) { $c = ord(stream_get_contents(STDIN, 1)); echo "Char read: $c\n"; } } } 

Выбор потока принимает несколько потоков и действует как прослушиватель событий, когда что-то меняется в любом из них. Учитывая, что мы ищем только «чтение» (т.е. ввод), мы определяем это как формат массива STDIN . Остальные установлены в NULL, они нам не нужны. Поскольку stream_select принимает только значения по ссылке, мы не можем передавать NULL напрямую, они должны быть заранее определены как переменные.

Блок if проверяет, является ли $n положительным ( $n — количество обновленных потоков), и, если это так, извлекает первый символ из STDIN, который является нашей клавишей ввода:

Решение readline работает

Что лучше?

Я предпочитаю метод stty потому что оба являются * nix только, оба вызывают системные команды, но последний, возможно, более сложный, и его эффект может варьироваться в зависимости от текущих настроек терминала в данной ОС.

Обратите внимание на существенную разницу между двумя GIF-изображениями выше — метод stty также отображает нажатие символа перед выводом его кода клавиши. Чтобы полностью удалить весь автоматический вывод и заставить PHP обрабатывать все это, нам нужен еще один флаг stty : -echo .

  public function __construct() { echo "Hello, I am snake!"; system('stty cbreak -echo'); $stdin = fopen('php://stdin', 'r'); while (1) { $c = ord(fgetc($stdin)); echo "Char read: $c\n"; } } 

Согласно документации , -echo отключает вывод вводимых символов.

Отображение змей на направления

Само собой разумеется, нам нужен способ сообщить каждой змее, в каком направлении двигаться. Во-первых, давайте Snake.php новый класс Snake в Snake.php чтобы представить экземпляр игрока и удерживать его состояние.

 <?php namespace PHPSnake; class Snake { /** @var string */ private $name; /** @var string */ private $direction; /** @var int */ private $size = 0; const DIRECTIONS = ['UP', 'DOWN', 'LEFT', 'RIGHT']; public function __construct(string $name = null) { if ($name === null) { $this->name = $this->generateRandomName(); } else { $this->name = $name; } } public function getName() : string { return $this->name; } public function setDirection(string $direction) : Snake { $direction = strtoupper($direction); if (!in_array($direction, Snake::DIRECTIONS)) { throw new \InvalidArgumentException( 'Invalid direction. Up, down, left, and right supported!' ); } $this->direction = $direction; echo $this->name.' is going '.$direction."\n"; return $this; } private function generateRandomName(int $length = 6) : string { $length = ($length > 3) ? $length : 6; $name = ''; $consonants = 'bcdfghklmnpqrstvwxyz'; $vowels = 'aeiou'; for ($i = 0; $i < $length; $i++) { if ($i % 2 == 0) { $name .= $consonants[rand(0, strlen($consonants)-1)]; } else { $name .= $vowels[rand(0, strlen($vowels)-1)]; } } return ucfirst($name); } } 

После создания змея получает имя. Если ничего не указано, простая функция генерирует случайную единицу. Направление является нулевым во время создания экземпляра, и направления, которые могут быть переданы, ограничены константой DIRECTIONS класса.

Далее мы обновим наш класс SnakeGame .

 <?php namespace PHPSnake; class SnakeGame { /** @var array */ private $snakes = []; public function __construct() { } /** * Adds a snake to the game * @param Snake $s * @return SnakeGame */ public function addSnake(Snake $s) : SnakeGame { $this->snakes[] = $s; return $this; } /** * Runs the game */ public function run() : void { if (count($this->snakes) < 1) { throw new \Exception('Too few players!'); } system('stty cbreak -echo'); $stdin = fopen('php://stdin', 'r'); while (1) { $c = ord(fgetc($stdin)); echo "Char read: $c\n"; } } } 

Это перемещает логику addSnake нажатия клавиш в функцию запуска, которую мы вызываем после добавления необходимых змей в игру через addSnake .

Наконец, мы можем обновить frontcontroller, чтобы использовать эти обновленные классы.

 <?php use PHPSnake\Snake; use PHPSnake\SnakeGame; require_once 'classes/Snake.php'; require_once 'classes/SnakeGame.php'; $param = ($argc > 1) ? $argv[1] : ''; $game = new SnakeGame(); $game->addSnake(new Snake()); $game->run(); 

Теперь все становится немного более структурированным! Наконец, давайте сопоставим направления со змеями.

У нас будут настраиваемые сопоставления клавиш для каждого игрока, и в зависимости от того, сколько игроков мы включим, именно столько сопоставлений мы будем загружать. Этот способ намного проще, чем длинный блок переключателей. Давайте добавим свойство $mappings в наш класс SnakeGame :

  /** * Key mappings * @var array */ private $mappings = [ [ 65 => 'up', 66 => 'down', 68 => 'left', 67 => 'right', 56 => 'up', 50 => 'down', 52 => 'left', 54 => 'right', ], [ 119 => 'up', 115 => 'down', 97 => 'left', 100 => 'right', ], ]; 

Каждый массив соответствует одному игроку / змеи. Главным игроком можно управлять либо с помощью клавиш управления курсором, либо с помощью соответствующих чисел. Второй игрок может управляться только через ключи WSAD. Вы можете видеть, как легко это делает добавление новых сопоставлений для дополнительных игроков.

Затем давайте обновим метод run :

  /** * Runs the game */ public function run() : void { if (count($this->snakes) < 1) { throw new \Exception('Too few players!'); } $mappings = []; foreach ($this->snakes as $i => $snake) { foreach ($this->mappings[$i] as $key => $dir) { $mappings[$key] = [$dir, $i]; } } system('stty cbreak -echo'); $stdin = fopen('php://stdin', 'r'); while (1) { $c = ord(fgetc($stdin)); echo "Char read: $c\n"; if (isset($mappings[$c])) { $mapping = $mappings[$c]; $this->snakes[$mapping[1]]->setDirection($mapping[0]); } } } 

Метод run теперь загружает столько сопоставлений, сколько имеется змей, и переупорядочивает их так, чтобы код ключа был ключом массива сопоставлений, а индекс направления и змеи был дочерним массивом — это позволяет очень легко позже использовать только одно линия изменения направления. Если мы сейчас запустим нашу игру (я обновил play.php чтобы добавить двух змей), мы заметим, что нажатие случайных клавиш просто генерирует коды клавиш, а нажатие WSAD или клавиш курсора выводит имя змеи и направление ее движения. двигаться после нажатия клавиши:

Змеи выкрикивая направления

Теперь у нас есть довольно хорошо структурированная игровая база для мониторинга нажатий клавиш и реагирования на них.

Вывод

В этом уроке мы рассмотрели игровые циклы ввода при нажатии клавиш в приложениях PHP CLI. Это нестандартное использование PHP собирается стать еще более странным во второй части, где мы будем иметь дело с рендерингом игроков, перемещением, границами и столкновениями.

В то же время, есть ли у вас какие-либо идеи о том, как еще больше улучшить получение нажатия клавиш? Дайте нам знать об этом в комментариях!