Пилим игровой мультиплеерный сервер на базе esp32: завершение. Портируем игру на esp32

от автора

Картинка Freepik

Итак, в прошлой части статьи мы начали довольно интересное дело — написание своей собственной мультиплеерной игры, которая бы запускалась на esp32, используя её как сервер.

Сегодня мы продолжим это дело и закончим наш проект!

Сразу небольшой спойлер: мы сделаем минимально необходимое, набросав основу игры и добившись её устойчивой работы, в то время как дополнительные «плюшки» — игровой счёт, компьютерные противники (кстати, было бы любопытно прикрутить в этом качестве к esp32 нейросеть!) и прочие улучшающие элементы — вы можете сделать самостоятельно, взяв за основу тот код, который будет в конце статьи. Для тех, кто не в курсе, что такое esp32, можно почитать, например, тут, только надо иметь в виду, что там описана одна из версий — а их существует целая линейка и она постоянно пополняется.
Итак…

В целом об игре

Изначально, как мы могли видеть в первой части статьи, написанная нами игра представляла собой однопользовательскую простую html-страничку с максимально простой механикой: одна машинка, управляемая клавишами полноразмерной клавиатуры компьютера (стрелками и пробелом), которая могла стрелять, а её летящие снаряды могли взаимодействовать со стенами лабиринта, в котором она находилась, разрушая их, и разрушение сопровождалось определёнными визуальными эффектами (разлетающиеся частицы).

Но это была локальная игра, где вся логика работала в браузере клиента. Теперь же нам предстоит переосмыслить всё это, чтобы создать многопользовательскую игру на основе сервера, которым будет являться esp32.

Так как весь рабочий код игры уже готов к моменту написания статьи, то и мы будем говорить о нём как о сложившемся факте, и рассмотрим ряд интересных моментов всей архитектуры в целом.

Итак, esp32 является сервером, обрабатывающим игровые события, к которому подключается игроки, используя браузер в качестве среды для запуска игры, из которого отправляют команды (например, «сдвинуться влево» и т. д.), а сервер, в свою очередь, отдаёт им (всем подключённым к игре участникам) — обновлённое состояние игры.

Почему была выбрана такая логика: в целях синхронизации игроков, так как если бы логика игры работала у каждого игрока локально, то их версии игры очень быстро бы рассинхронизировались.

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

В качестве альтернативных путей реализации архитектуры можно было бы выбрать P2P (Peer-to-Peer) соединение, при котором игроки соединяются напрямую, однако это гораздо сложнее в реализации, и менее надёжно.

Ещё одним альтернативным путём могло бы быть использование готового сервера, например, на Node.js, который представляет собой среду выполнения JavaScript на стороне сервера, и, в отличие от нашего случая, где код выполняется на микроконтроллере, Node.js требует для запуска отдельного компьютера (сервера).

Чем он потенциально мог бы быть хорош: так как он использует однопоточную событийную модель с асинхронными операциями, это означает, что он может обрабатывать множество одновременных подключений без создания отдельных потоков для каждого клиента, что для нашего случая было бы идеально: сервер принимает сообщения от игроков через websocket, обрабатывает их и рассылает обновления состояния, всё в одном потоке, без блокировок.

В то же время клиентская часть осталась бы практически без изменений, — это был бы тот же самый html/css/javascript, но подключался бы он к Node.js-серверу, вместо esp32.

Какие бы плюсы нам дало использование Node.js:

  • высокая производительность, которая позволила бы подключаться более чем 10 игрокам,
  • наличие множества готовых библиотек, лёгкая отладка.

Несмотря на явные плюсы, всё же для нашей небольшой игры, которая является своего рода экспериментом, использование таких мощностей довольно избыточно, хотя бы по причине того, что потребуется отдельный сервер (ПК или VPS).

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

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

Несмотря на слабость аппаратной платформы esp32, её производительности всё же должно хватить на обработку команд порядка 10 одновременно подключённых игроков: насколько мне известно, esp32 поддерживает порядка 10-15 wi-fi клиентов, однако, реальное количество зависит от нагрузки, версии esp32, сложности эфирной ситуации (насколько много помех и насколько забит эфир в целом).

Поэтому принято считать, что оптимальное количество клиентов esp32 находится в интервале 5-8 устройств.

Тут сразу следует сделать небольшую ремарку и отметить такой момент, что у меня не было возможности протестить игру на максимальном количестве клиентов (ну просто нет у меня такого количества устройств в наличии), я тестировал на двух: одним клиентом являлся компьютер, с wi-fi «свистком», а вторым — смартфон.

Всё работает довольно шустро, явных тормозов не наблюдается (что, впрочем, не исключает того, что они появятся, при подключении максимального количества клиентов).

В данный момент система соединений для игры представляет собой локальную сеть, топологии «звезда» — когда в центре находится сервер — esp32 (на котором сконфигурирована wi-fi точка доступа), к которому подключаются клиенты — игроки.

То есть в данный момент это сугубо игра для локальной сети. Если же вы захотите её «вывести в интернет» — необходимо будет внести соответствующие изменения в код.

С другой стороны, такое построение игры максимально уменьшает временной лаг при прохождении данных.

Передача данных в игре реализована с применением json, например, такого вида:

{"type":"state", "vehicles":[{"x":100, "y":200}]}

а постоянное соединение игроков с сервером обеспечивается за счёт протокола websocket, что даёт оперативное обновление игровой ситуации, с доведением этой информации до каждого подключённого игрока.

Ниже рассмотрим некоторые моменты более подробно.

Переход от «кадров» — к «состоянию»

В начальном коде, с которого мы начинали работу, анимация работала через использование setInterval — вёлся постоянно перерасчёт позиций снарядов и машинки, где setInterval — это способ выполнять код регулярно, через определённые промежутки времени, например, через каждые 50 мс.

JavaScript предоставляет, как минимум, две возможности для этого:

setInterval: бесконечный запуск функции, через фиксированные промежутки времени.
Например, вот так это выглядело в прошлом коде:

// Движение снаряда через интервалы (устаревший подход)   const projectileInterval = setInterval(() => {     projectileX += 5; // Смещение на 5px     projectile.style.left = `${projectileX}px`;      if (projectileX > 400) {       clearInterval(projectileInterval); // Остановка     }   }, 50); // Повтор каждые 50 мс  

Однако такой подход имеет известные проблемы:

  • дрожание анимации — если сервер перегружен, то кадры могут пропускаться,
  • нагрузка на процессор — постоянный перерасчёт,
  • сложности синхронизации для мультиплеерной игры — у разных игроков разные интервалы, что приводит в результате к рассинхронизации.

Альтернативным подходом является использование setTimeout, который выполняет функцию один раз после задержки, однако может имитировать интервал через использование рекурсии:

function moveProjectile() {     projectileX += 5;     projectile.style.left = `${projectileX}px`;      if (projectileX < 400) {       setTimeout(moveProjectile, 50); // Рекурсивный вызов     }   }   moveProjectile(); // Старт   

Почему мы решили отказаться в актуальной версии игры от интервалов:

  • CSS-анимация эффективнее, так как браузер сам оптимизирует отрисовку, используя GPU (не нагружается CPU),
  • Управлением временем занимается сервер, отправляя обновление позиций каждые 50 мс, в то время как CSS занимается плавной интерполяцией изменений между этими точками,
  • Более стабильная работа, так как нет риска, что интервал накопит лаг при нагрузке (как это могло бы произойти с setInterval).

Однако, если всё так плохо с интервалами, для чего же их вообще используют?

Тут всё очень просто: они могут быть использованы для невизуальных задач, например, таких, как опрос сервера, а также в простых одностраничных приложениях без анимаций.

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

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

Объектная модель

В изначальном коде переменные machineX , machineY были разбросаны по скрипту, а теперь же всё структурировано, что даёт лучшую читаемость кода и можно легко добавлять новые свойства.

struct GameState {   struct Vehicle { int id; float x; float y; };   struct Projectile { int id; int ownerId; float x; float y; };   Vehicle vehicles[MAX_PLAYERS];   Projectile projectiles[MAX_PROJECTILES]; }; 

Кроме того, такой подход облегчает сериализацию, то есть объекты можно легко преобразовывать в json, для передачи клиентам.

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

Передача данных

Как выше уже было отмечено, для передачи данных используется формат json, где данные передаются в модифицированной (упрощённой) форме.

Например:

{"id":1,"x":100}

вместо:

{"vehicleId": 1, "positionX": 100}

Таким образом размер сообщения минимизируется, например, ключи type — вместо commandType.

Также, для оптимизации передачи используется отправка только изменений — например, если машинка не двигалась, то её данные не дублируются в каждом сообщении.

Сделано это для того, чтобы избежать лагов, так как esp32 имеет мало оперативной памяти (порядка 200 КБ) и слабый процессор, а избыточные данные могут привести к лагам.

Например, размер типичного сообщения составляет порядка 200 байт, при условии, что играют четыре игрока. При 20 FPS (50 мс), это будет приблизительно равно 4 кб/с, что вполне подходит для wi-fi передачи через esp32.

Esp32 использует библиотеку ArduinoJson для сериализации (т.е. упаковки) данных в более компактный формат:

DynamicJsonDocument doc(256); // Выделяем память doc["type"] = "move";        // Тип команды doc["x"] = 100;              // Координата X String output; serializeJson(doc, output);  // -> {"type":"move","x":100} 

Сервер рассылает данные всем подключённым клиентам через вызов функции рассылки сообщений, у объекта websocket: ws.textAll(output).

Клиенты, в свою очередь, получают сырую строку, и парсят её через JSON.parse().

Глядя на всё это, у знающих сразу возникнет логичный вопрос: а почему мы пересылаем не бинарные данные, возможно, это было бы проще и экономнее?

Дело тут в том, что из-за малого количества клиентов, мы можем себе позволить не оптимизировать до такой степени, и, к тому же, формат json удобен для отладки, а esp32 достаточно мощная, чтобы работать с простыми строками (по крайней мере, для такого малого количества клиентов).

Ещё один аргумент в пользу такого подхода — мы работаем в локальной сети, не выходя в «большой интернет», поэтому такая степень оптимизации для нас не особо актуальна, однако, она может стать актуальной, если мы захотим сделать большую игру, для большой аудитории, где клиенты будут очень сильно разнесены в пространстве сети.

Как уже было выше сказано, для постоянной двусторонней связи между сервером и браузером клиента используется протокол websocket, который, в отличие от http, где клиент должен инициировать запрос, — постоянно держит соединение открытым, позволяя обмениваться данными в реальном времени.

Физика коллизий

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

Для осуществления такой проверки реализован алгоритм AAB (Axis-Aligned Bounding Box) — проверка пересечения прямоугольников:

bool detectCollision(float x, float y, bool isProjectile = false) {   // Проверка границ игрового поля (если это не снаряд)   if (!isProjectile && (x < 0 || x > 380 || y < 0 || y > 380)) {     return true;   }    // Проверка коллизий с препятствиями   for (int i = 0; i < MAX_OBSTACLES; i++) {     if (gameState.obstacles[i].active) {       float obstacleX = gameState.obstacles[i].x * 20;       float obstacleY = gameState.obstacles[i].y * 20;              if (x + (isProjectile ? 5 : 20) > obstacleX &&            x < obstacleX + 20 &&            y + (isProjectile ? 5 : 20) > obstacleY &&            y < obstacleY + 20) {         return true;       }     }   }   return false; } 

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

Работает это следующим образом: каждый объект в рамках этого подхода представляется как прямоугольник с координатами X,Y и размерами (width, height). Например:

struct GameObject {   float x, y;      // Позиция центра   float w, h;      // Ширина/высота }; 

Проверка их пересечений производится с помощью функции, подобной той, что ниже:

bool checkAABBCollision(GameObject a, GameObject b) {   return (abs(a.x - b.x) < (a.w + b.w)/2 &&           (abs(a.y - b.y) < (a.h + b.h)/2; } 

Это обеспечивает простоту, так как требуется только четыре сравнения на проверку.

Кстати говоря, esp32 может обрабатывать порядка 100 столкновений за 1 мс 😉

С целью оптимизации для esp32, производится проверка с помощью квадратов: игровое поле делится на зоны, например, 100 на 100 пикселей и проверяются только объекты в соседних квадрантах (т. е. производится проверка только активных объектов, например, пуль рядом с игроками).

Кроме того, для снарядов добавляется проверка владельца, чтобы не попадать в самого себя, а также, любая проверка осуществляется только для «живых» целей.

Клиент же получает готовый результат, вида:

{"hit": true, "x": 120, "y": 80}

Если попробовать кратенько посмотреть, какие могли бы быть альтернативные подходы, опираясь на мировой геймдев, то там могли бы быть использованы физические движки (Box2D, Matter.js, PhysX) — которые позволяют производить расчёт не только выровненных по X,Y объектов, но и полигональных, развёрнутых на угол; а также рассчитывают динамику (отскоки, трение, угловая скорость) — но в нашем случае это весьма избыточно, так как, например, если посмотреть на требования того же движка Box2D, то только ему нужно порядка 50 КБ RAM, тогда как у esp32 всего 200 КБ на «всё-превсё».

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

В качестве ещё одного альтернативного подхода возможно было бы использовать проверку коллизий по пикселям (Pixel-Perfect).

В основе такого подхода лежит анализ пересечения непрозрачных пикселей спрайтов.

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

Однако, для esp32 это неприменимо из-за потенциально медленной работы ввиду больших аппаратных требований.

Существует ещё и ряд других подходов, которые могут также выступать в виде гибридов одного подхода с другим, однако мы здесь не задаёмся целью рассмотреть все возможные, просто отметим, что анализ пересечения прямоугольников является золотой серединой для слабых аппаратных платформ, наподобие микроконтроллеров, обеспечивая быструю работу при анализе столкновений неразвёрнутых объектов (т. е. выровненных по осям X, Y).

Визуальная составляющая игры

Итак, как уже было выше сказано, в самом начале статьи, анимация в игре построена на сочетании CSS и серверного управления состоянием, что обеспечивает плавность работы анимации даже на слабых устройствах, так как такая анимация оптимизируется браузером на уровне GPU, что, в свою очередь, снижает нагрузку на esp32, так как она отправляет только координаты, а отрисовка производится на клиенте.

Используя подобный подход, реализованы все динамические эффекты: взрывы, движение снарядов, респаун (возрождение игрока).

Например, ниже показаны элементы кода, отвечающие за анимацию взрыва.

Работает это следующим образом: при попадании снаряда, esp32 отправляет клиенту сообщение, с типом hit:

{   "type": "state",   "hits": [{"x": 120, "y": 80, "explosion": "small"}] } 

Клиент (браузер), в свою очередь, создаёт CSS-анимацию взрыва, через динамическое добавление элемента:

function createExplosionEffect(x, y, type) {   const explosion = document.createElement('div');   explosion.className = (type === 'big') ? 'big-explosion' : 'explosion';   explosion.style.left = `${x}px`;   explosion.style.top = `${y}px`;   gameArena.appendChild(explosion);      // Удаление после анимации   setTimeout(() => explosion.remove(), 600); } 

А вот как выглядит CSS-класс .explosion для этого взрыва:

/* styles.css */ .explosion {   animation: explosion-anim 0.4s forwards;   background: radial-gradient(circle, yellow, orange, red); }  @keyframes explosion-anim {   0% { transform: scale(0); opacity: 0.8; }   100% { transform: scale(1.2); opacity: 0; } } 

Таким образом, если подытожить, то сервер только сообщает, что возник сам факт взрыва, в то время как отрисовка производится на клиенте средствами CSS/JavaScript, без нагрузки на esp32.

В целом, если говорить концептуально, то можно сказать, что наша анимация использует DOM (Documents Object Model) — своеобразный «скелет» веб-страницы, где во время загрузки браузером страница превращается в древовидную структуру объектов, которыми можно управлять, используя JavaScript.

Также можно отметить, что такой подход с отрисовкой на клиенте даёт очень высокую скорость работы анимации (может выдать порядка 60 FPS, даже на слабых устройствах), а также имеет совместимость даже со старыми устройствами.

В данный момент, я игру проверил, как и говорил, с помощью двух клиентов: подключённого к игре компьютера и смартфона.

Некоторые эффекты, правда, пришлось убрать, наподобие того же ядерного взрыва при уничтожении машинки (к слову, в неё надо попасть три раза, чтобы уничтожить — так же, как и в любой элемент стены).

На данный момент пока не удалось реализовать более-менее приличную анимацию ядерного взрыва при уничтожении машинки — что оставляет возможности для вашего собственного творчества 😉

Сама игра выглядит вот так:

Как можно видеть, в игре остаётся ряд нерешённых моментов, которые можно улучшить (убрать вылет пуль за пределы игрового поля, как минимум). А кроме того — можно добавить игровой счёт, улучшить анимацию, добавить компьютерных противников (в том числе, с нейросетевым управлением!) и прочее, прочее, прочее 🙂

Тем не менее, сама основа игры работает, которую можно ещё дальше допиливать. Как можно видеть по коду ниже (да и в прошлой статье я упоминал это в конце) — для написания кода использовался DeepSeek, что открывает очень большие возможности для всех желающих: можно делать интересные вещи, на которые, возможно, просто не хватало времени, скиллов и прочего — в прошлом.

У меня у самого таких затей имеется «вагон и маленькая тележка» — так что, полагаю, я смогу порадовать вас (да и себя) какими-то новыми затеями, которые в прошлом откладывал, по тем или иным причинам 🙂

Кроме того, как я и говорил, — это очень увлекательный и быстрый способ изучения написания кода, что привлекательно уже само по себе… На этом я завершаю рассказ, а полный код игры для загрузки на esp32, вы можете взять ниже, там же есть и ссылки на используемые библиотеки.

Для запуска игры я использовал плату ESP32-WROOM-32 DevKit v1, ссылка на обзор которой есть в самом начале статьи. Кстати, там же я обмолвился и дал ссылку на ряд других чипов esp32. Для тех, кто заинтересовался, скажу, почему их там такая большая линейка: они все специализированы для определённых задач. Например:

  • одни имеют малый размер и почти никакое энергопотребление в режиме глубокого сна (esp32 C3) — но пробудить можно только аппаратными кнопками,
  • вторые — имеют большой размер (а, следовательно, и большое количество периферийных пинов) и сенсорные пины (ESP32-WROOM-32), что позволяет пробудить из сна (и, в дальнейшем, управлять), коснувшись металлической площадки, соединённой с соответствующим пином(ами) — годится даже кусок фольги или монетка,
  • третьи — «заточены» на производительность,
  • четвёртые имеют аппаратную поддержку host-a, что позволяет им выступать хостом в usb-соединении (esp32 s2),
  • и т. д. (надо читать спецификации — весьма увлекательное чтиво, рекомендую).

Я сам в данный момент, после долгой работы с ESP32-WROOM-32 — начал осваивать esp32 C3 и esp32 s2. Интересно, однако… Есть пара идей на их основе, возможно, расскажу позже 😉

Библиотеки для скачивания и установки в Arduino IDE:

Полный код игры — для загрузки на esp32

 //Разработано с применением DeepSeek #include <WiFi.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> #include <ArduinoJson.h>  #define MAX_PLAYERS 4 #define MAX_PROJECTILES 30 #define MAX_OBSTACLES 100 #define PLAYER_SPEED 2.0f #define PROJECTILE_SPEED 8.0f  const char* ssid = "ESP32"; const char* password = "12345678";  AsyncWebServer server(80); AsyncWebSocket ws("/ws");  struct GameState {   struct Vehicle {     int id;     float x;     float y;     String direction;     bool active;     unsigned long lastMove;     unsigned long lastShot;     int health = 3;     bool respawning = false;     unsigned long respawnTime = 0;   };      struct Projectile {     int id;     int ownerId;     float x;     float y;     String trajectory;     bool active;     unsigned long created;   };      struct Obstacle {     int x;     int y;     int durability;     bool active;   };      Vehicle vehicles[MAX_PLAYERS];   Projectile projectiles[MAX_PROJECTILES];   Obstacle obstacles[MAX_OBSTACLES];   int activePlayers = 0;   int nextProjectileId = 0; };  GameState gameState;  const char index_html[] PROGMEM = R"rawliteral( <!DOCTYPE html> <html lang="ru"> <head>     <meta charset="UTF-8">     <meta name="viewport" content="width=device-width, initial-scale=1.0">     <title>Racing Arena</title>     <style>         body {             margin: 0;             overflow: hidden;             display: flex;             justify-content: center;             align-items: center;             height: 100vh;             background-color: #111;         }         #gameArena {             position: relative;             width: 400px;             height: 400px;             background-color: #222;             border: 2px solid #444;             box-shadow: 0 0 20px rgba(0,255,0,0.1);         }         .vehicle {             position: absolute;             width: 20px;             height: 20px;             display: flex;             justify-content: center;             align-items: center;             transition: transform 0.1s ease-out;             background-color: #0f0;             border-radius: 3px;             box-shadow: 0 0 10px rgba(0,255,0,0.5);             z-index: 10;         }         .vehicle::after {             content: '';             position: absolute;             width: 6px;             height: 6px;             background-color: #000;             top: -3px;             left: 50%;             transform: translateX(-50%);             border-radius: 50%;         }         .projectile {             position: absolute;             width: 5px;             height: 5px;             background-color: yellow;             border-radius: 50%;             transition: left 0.05s linear, top 0.05s linear;             box-shadow: 0 0 5px yellow;             z-index: 5;         }         .obstacle {             position: absolute;             width: 20px;             height: 20px;             overflow: hidden;             background-color: rgba(0, 80, 0, 0.3);             border: 1px solid rgba(0, 255, 0, 0.2);             z-index: 1;         }         .obstacle-texture {             position: absolute;             width: 3px;             height: 3px;             background-color: rgba(0, 255, 0, 0.5);         }         .fragment {             position: absolute;             width: 4px;             height: 4px;             background-color: #0f0;             animation: fragment-fly 1s ease-out forwards;             box-shadow: 0 0 3px #0f0;             z-index: 20;         }         @keyframes fragment-fly {             0% { opacity: 1; transform: translate(0, 0); }             100% { opacity: 0; transform: translate(var(--tx), var(--ty)); }         }         .explosion {             position: absolute;             border-radius: 50%;             background: radial-gradient(circle, yellow, orange 70%, red);             transform: translate(-50%, -50%) scale(0);             animation: explosion-anim 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;             box-shadow: 0 0 20px orange;             z-index: 15;         }         @keyframes explosion-anim {             0% { transform: translate(-50%, -50%) scale(0); opacity: 0.8; }             50% { transform: translate(-50%, -50%) scale(1.2); opacity: 1; }             100% { transform: translate(-50%, -50%) scale(0); opacity: 0; }         }         .big-explosion {             position: absolute;             border-radius: 50%;             background: radial-gradient(circle, yellow, orange 70%, red);             transform: translate(-50%, -50%) scale(0);             animation: big-explosion-anim 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;             box-shadow: 0 0 40px orange;             z-index: 15;         }         @keyframes big-explosion-anim {             0% { transform: translate(-50%, -50%) scale(0); opacity: 0.8; }             50% { transform: translate(-50%, -50%) scale(2.4); opacity: 1; }             100% { transform: translate(-50%, -50%) scale(0); opacity: 0; }         }         .flame-particle {             position: absolute;             border-radius: 50%;             transform: translate(-50%, -50%);             animation: flame-particle-anim 1.8s ease-out forwards;             filter: blur(1px);             box-shadow: 0 0 10px orange;             z-index: 15;         }         @keyframes flame-particle-anim {             0% {                  transform: translate(-50%, -50%) scale(1) translateX(0);                  opacity: 1;                 background-color: rgba(255, 255, 0, 0.9);             }             50% {                 background-color: rgba(255, 100, 0, 0.8);             }             100% {                  transform: translate(-50%, calc(-50% - 50px)) scale(0.3) translateX(var(--sway));                 opacity: 0;                 background-color: rgba(255, 50, 0, 0);             }         }         .smoke {             position: absolute;             border-radius: 50%;             background: radial-gradient(circle, rgba(100,100,100,0.8), rgba(50,50,50,0));             animation: smoke-anim 2s ease-out forwards;             filter: blur(3px);             z-index: 15;         }         @keyframes smoke-anim {             0% {                  transform: translate(-50%, -50%) scale(0.5);                 opacity: 0.8;             }             100% {                  transform: translate(-50%, calc(-50% - 40px)) scale(2);                 opacity: 0;             }         }         .respawn-effect {             position: absolute;             width: 40px;             height: 40px;             border-radius: 50%;             background: radial-gradient(circle, rgba(0,255,0,0.8), rgba(0,255,0,0));             animation: respawn-anim 2s ease-out forwards;             z-index: 15;         }         @keyframes respawn-anim {             0% {                  transform: translate(-50%, -50%) scale(0);                 opacity: 0;             }             50% {                 transform: translate(-50%, -50%) scale(1.5);                 opacity: 0.8;             }             100% {                  transform: translate(-50%, -50%) scale(0);                 opacity: 0;             }         }     </style> </head> <body>     <div id="gameArena"></div>     <script>         const gameArena = document.getElementById('gameArena');         let playerId = -1;         let vehicles = {};         let projectiles = {};         let obstacles = {};         let lastObstacles = [];          const socket = new WebSocket('ws://' + window.location.hostname + '/ws');          socket.onmessage = function(event) {             const data = JSON.parse(event.data);                          if (data.type === 'init') {                 playerId = data.playerId;                 lastObstacles = data.obstacles;                 createAllObstacles();                                  if (data.vehicles) {                     data.vehicles.forEach(v => {                         updateVehicle(v);                     });                 }             }              else if (data.type === 'state') {                 data.vehicles.forEach(v => {                     updateVehicle(v);                 });                                  updateProjectiles(data.projectiles);                                  data.hits.forEach(hit => {                     if (hit.fragments) createFragments(hit.x, hit.y);                     if (hit.explosion === 'small') createExplosionEffect(hit.x, hit.y);                     if (hit.explosion === 'big') createBigExplosionEffect(hit.x, hit.y);                 });                                  data.destroyedObstacles.forEach(o => {                     removeObstacle(o.x, o.y);                 });                                  data.respawns.forEach(r => {                     createRespawnEffect(r.x, r.y);                 });             }         };          function createAllObstacles() {             Object.values(obstacles).forEach(o => o.remove());             obstacles = {};                          lastObstacles.forEach(obstacle => {                 createObstacle(obstacle);             });         }          function createObstacle(obstacle) {             const key = `${obstacle.x}-${obstacle.y}`;             if (obstacles[key]) return;                          const obstacleElement = document.createElement('div');             obstacleElement.classList.add('obstacle');             obstacleElement.style.left = `${obstacle.x * 20}px`;             obstacleElement.style.top = `${obstacle.y * 20}px`;                          for (let i = 0; i < 20; i += 3) {                 for (let j = 0; j < 20; j += 3) {                     const pixel = document.createElement('div');                     pixel.classList.add('obstacle-texture');                     pixel.style.left = `${i}px`;                     pixel.style.top = `${j}px`;                     const green = 100 + Math.floor(Math.random() * 155);                     pixel.style.backgroundColor = `rgba(0, ${green}, 0, 0.7)`;                     obstacleElement.appendChild(pixel);                 }             }                          gameArena.appendChild(obstacleElement);             obstacles[key] = obstacleElement;         }          function createFragments(x, y) {             for (let i = 0; i < 12; i++) {                 const fragment = document.createElement('div');                 fragment.classList.add('fragment');                 fragment.style.left = `${x}px`;                 fragment.style.top = `${y}px`;                                  const angle = Math.random() * Math.PI * 2;                 const distance = 10 + Math.random() * 40;                 fragment.style.setProperty('--tx', `${Math.cos(angle) * distance}px`);                 fragment.style.setProperty('--ty', `${Math.sin(angle) * distance}px`);                                  gameArena.appendChild(fragment);                                  setTimeout(() => {                     if (fragment.parentNode) fragment.remove();                 }, 1000);             }         }          function createExplosionEffect(x, y) {             const explosion = document.createElement('div');             explosion.classList.add('explosion');             explosion.style.left = `${x}px`;             explosion.style.top = `${y}px`;             explosion.style.width = '40px';             explosion.style.height = '40px';                          gameArena.appendChild(explosion);                          for (let i = 0; i < 3; i++) {                 setTimeout(() => {                     const smoke = document.createElement('div');                     smoke.classList.add('smoke');                     smoke.style.left = `${x + (Math.random() - 0.5) * 10}px`;                     smoke.style.top = `${y + (Math.random() - 0.5) * 10}px`;                     smoke.style.width = `${15 + Math.random() * 10}px`;                     smoke.style.height = `${15 + Math.random() * 10}px`;                     smoke.style.animationDelay = `${i * 0.2}s`;                                          gameArena.appendChild(smoke);                                          setTimeout(() => {                         if (smoke.parentNode) smoke.remove();                     }, 2000);                 }, i * 100);             }                          setTimeout(() => {                 createFlameParticles(x, y);                 if (explosion.parentNode) explosion.remove();             }, 400);         }          function createBigExplosionEffect(x, y) {             const explosion = document.createElement('div');             explosion.classList.add('big-explosion');             explosion.style.left = `${x}px`;             explosion.style.top = `${y}px`;             explosion.style.width = '80px';             explosion.style.height = '80px';                          gameArena.appendChild(explosion);                          for (let i = 0; i < 6; i++) {                 setTimeout(() => {                     const smoke = document.createElement('div');                     smoke.classList.add('smoke');                     smoke.style.left = `${x + (Math.random() - 0.5) * 20}px`;                     smoke.style.top = `${y + (Math.random() - 0.5) * 20}px`;                     smoke.style.width = `${25 + Math.random() * 15}px`;                     smoke.style.height = `${25 + Math.random() * 15}px`;                     smoke.style.animationDelay = `${i * 0.2}s`;                                          gameArena.appendChild(smoke);                                          setTimeout(() => {                         if (smoke.parentNode) smoke.remove();                     }, 2000);                 }, i * 100);             }                          setTimeout(() => {                 createFlameParticles(x, y, 10);                 if (explosion.parentNode) explosion.remove();             }, 600);         }          function createRespawnEffect(x, y) {             const effect = document.createElement('div');             effect.classList.add('respawn-effect');             effect.style.left = `${x}px`;             effect.style.top = `${y}px`;                          gameArena.appendChild(effect);                          setTimeout(() => {                 if (effect.parentNode) effect.remove();             }, 2000);         }          function createFlameParticles(x, y, count = 5) {             for (let i = 0; i < count; i++) {                 const particle = document.createElement('div');                 particle.classList.add('flame-particle');                                  const size = 6 + Math.random() * 6;                 const sway = (Math.random() - 0.5) * 40;                 const duration = 1.2 + Math.random() * 0.6;                                  particle.style.left = `${x + (Math.random() - 0.5) * 10}px`;                 particle.style.top = `${y + (Math.random() - 0.5) * 5}px`;                 particle.style.width = `${size}px`;                 particle.style.height = `${size}px`;                 particle.style.setProperty('--sway', `${sway}px`);                 particle.style.animationDuration = `${duration}s`;                                  particle.style.background = `radial-gradient(circle,                      rgba(255, 255, 0, 0.9),                      rgba(255, ${100 + Math.floor(Math.random() * 100)}, 0, 0.7))`;                                  gameArena.appendChild(particle);                                  setTimeout(() => {                     if (particle.parentNode) particle.remove();                 }, duration * 1000);             }         }          function updateVehicle(vehicleData) {             if (!vehicles[vehicleData.id]) {                 const vehicleElement = document.createElement('div');                 vehicleElement.classList.add('vehicle');                 vehicleElement.style.backgroundColor = getVehicleColor(vehicleData.id);                 vehicleElement.id = 'vehicle-' + vehicleData.id;                 gameArena.appendChild(vehicleElement);                 vehicles[vehicleData.id] = vehicleElement;             }                          const vehicle = vehicles[vehicleData.id];             vehicle.style.left = `${vehicleData.x}px`;             vehicle.style.top = `${vehicleData.y}px`;             vehicle.style.transform = `rotate(${                 vehicleData.direction === 'north' ? 0 :                 vehicleData.direction === 'east' ? 90 :                 vehicleData.direction === 'south' ? 180 :                 vehicleData.direction === 'west' ? 270 : 0             }deg)`;                          if (vehicleData.respawning) {                 vehicle.style.opacity = '0.5';             } else {                 vehicle.style.opacity = '1';             }         }          function updateProjectiles(projectilesData) {             Object.keys(projectiles).forEach(id => {                 if (!projectilesData.some(p => p.id == id)) {                     const projectile = projectiles[id];                     if (projectile && projectile.parentNode) {                         projectile.remove();                         delete projectiles[id];                     }                 }             });                          projectilesData.forEach(p => {                 if (!projectiles[p.id]) {                     const projectile = document.createElement('div');                     projectile.classList.add('projectile');                     projectile.id = 'projectile-' + p.id;                     gameArena.appendChild(projectile);                     projectiles[p.id] = projectile;                 }                 projectiles[p.id].style.left = `${p.x}px`;                 projectiles[p.id].style.top = `${p.y}px`;             });         }          function removeObstacle(x, y) {             const key = `${x}-${y}`;             if (obstacles[key]) {                 obstacles[key].remove();                 delete obstacles[key];             }         }          function getVehicleColor(id) {             const colors = ['#0f0', '#00f', '#f00', '#ff0'];             return colors[id % colors.length];         }          function sendPlayerAction(action, value) {             if (playerId !== -1) {                 socket.send(JSON.stringify({                     playerId: playerId,                     action: action,                     value: value                 }));             }         }          const keys = {};         document.addEventListener('keydown', (e) => {             if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(e.key)) {                 e.preventDefault();                 keys[e.key] = true;             }         });                  document.addEventListener('keyup', (e) => {             if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(e.key)) {                 e.preventDefault();                 keys[e.key] = false;             }         });          function gameLoop() {             if (keys['ArrowLeft']) sendPlayerAction('move', 'west');             if (keys['ArrowRight']) sendPlayerAction('move', 'east');             if (keys['ArrowUp']) sendPlayerAction('move', 'north');             if (keys['ArrowDown']) sendPlayerAction('move', 'south');             if (keys[' ']) {                 sendPlayerAction('fire', '');                 keys[' '] = false;             }             requestAnimationFrame(gameLoop);         }         gameLoop();     </script> </body> </html> )rawliteral";  bool isPositionAvailable(float x, float y) {   if (x < 0 || x > 380 || y < 0 || y > 380) return false;      for (int i = 0; i < MAX_OBSTACLES; i++) {     if (gameState.obstacles[i].active) {       float obstacleX = gameState.obstacles[i].x * 20;       float obstacleY = gameState.obstacles[i].y * 20;              if (x + 20 > obstacleX && x < obstacleX + 20 && y + 20 > obstacleY && y < obstacleY + 20) {         return false;       }     }   }   return true; }  bool detectCollision(float x, float y, bool isProjectile = false) {   if (!isProjectile && (x < 0 || x > 380 || y < 0 || y > 380)) return true;      for (int i = 0; i < MAX_OBSTACLES; i++) {     if (gameState.obstacles[i].active) {       float obstacleX = gameState.obstacles[i].x * 20;       float obstacleY = gameState.obstacles[i].y * 20;              if (x + (isProjectile ? 5 : 20) > obstacleX &&            x < obstacleX + 20 &&            y + (isProjectile ? 5 : 20) > obstacleY &&            y < obstacleY + 20) {         return true;       }     }   }      if (isProjectile) {     for (int i = 0; i < MAX_PLAYERS; i++) {       if (gameState.vehicles[i].active && !gameState.vehicles[i].respawning) {         if (x + 5 > gameState.vehicles[i].x &&              x < gameState.vehicles[i].x + 20 &&              y + 5 > gameState.vehicles[i].y &&              y < gameState.vehicles[i].y + 20) {           return true;         }       }     }   }      return false; }  void handleWebSocketEvent(AsyncWebSocket *server, AsyncWebSocketClient *client, AwsEventType type, void *arg, uint8_t *data, size_t length) {   switch (type) {     case WS_EVT_CONNECT: {       if (gameState.activePlayers < MAX_PLAYERS) {         int newPlayerId = gameState.activePlayers;         gameState.vehicles[newPlayerId].id = client->id();                  bool positionFound = false;         int attempts = 0;         while (!positionFound && attempts < 100) {           float x = random(0, 380);           float y = random(0, 380);                      x = floor(x / 20) * 20;           y = floor(y / 20) * 20;                      if (isPositionAvailable(x, y)) {             gameState.vehicles[newPlayerId].x = x;             gameState.vehicles[newPlayerId].y = y;             positionFound = true;           }           attempts++;         }                  if (!positionFound) {           gameState.vehicles[newPlayerId].x = 100 + newPlayerId * 100;           gameState.vehicles[newPlayerId].y = 100 + newPlayerId * 100;         }                  gameState.vehicles[newPlayerId].direction = "north";         gameState.vehicles[newPlayerId].active = true;         gameState.vehicles[newPlayerId].lastMove = millis();         gameState.vehicles[newPlayerId].lastShot = 0;         gameState.vehicles[newPlayerId].health = 3;         gameState.vehicles[newPlayerId].respawning = false;         gameState.vehicles[newPlayerId].respawnTime = 0;                  DynamicJsonDocument initDoc(4096);         initDoc["type"] = "init";         initDoc["playerId"] = newPlayerId;                  JsonArray vehiclesArray = initDoc.createNestedArray("vehicles");         for (int i = 0; i < MAX_PLAYERS; i++) {           if (gameState.vehicles[i].active) {             JsonObject vehicleObj = vehiclesArray.createNestedObject();             vehicleObj["id"] = gameState.vehicles[i].id;             vehicleObj["x"] = gameState.vehicles[i].x;             vehicleObj["y"] = gameState.vehicles[i].y;             vehicleObj["direction"] = gameState.vehicles[i].direction;           }         }                  JsonArray obstaclesArray = initDoc.createNestedArray("obstacles");         for (int i = 0; i < MAX_OBSTACLES; i++) {           if (gameState.obstacles[i].active) {             JsonObject obstacleObj = obstaclesArray.createNestedObject();             obstacleObj["x"] = gameState.obstacles[i].x;             obstacleObj["y"] = gameState.obstacles[i].y;           }         }                  String initData;         serializeJson(initDoc, initData);         client->text(initData);                  gameState.activePlayers++;       }       break;     }            case WS_EVT_DISCONNECT: {       for (int i = 0; i < MAX_PLAYERS; i++) {         if (gameState.vehicles[i].id == client->id()) {           gameState.vehicles[i].active = false;           gameState.activePlayers--;           break;         }       }       break;     }            case WS_EVT_DATA: {       if (length == 0) break;              DynamicJsonDocument doc(256);       deserializeJson(doc, data, length);              int playerId = doc["playerId"];       String action = doc["action"];       String value = doc["value"];              if (action == "move") {         if (gameState.vehicles[playerId].respawning) break;                  unsigned long now = millis();         if (now - gameState.vehicles[playerId].lastMove < 30) break;                  gameState.vehicles[playerId].direction = value;         float newX = gameState.vehicles[playerId].x;         float newY = gameState.vehicles[playerId].y;                  if (value == "north") newY -= PLAYER_SPEED;         else if (value == "south") newY += PLAYER_SPEED;         else if (value == "west") newX -= PLAYER_SPEED;         else if (value == "east") newX += PLAYER_SPEED;                  if (!detectCollision(newX, newY)) {           gameState.vehicles[playerId].x = newX;           gameState.vehicles[playerId].y = newY;           gameState.vehicles[playerId].lastMove = now;         }       }       else if (action == "fire") {         if (gameState.vehicles[playerId].respawning) break;                  unsigned long now = millis();         if (now - gameState.vehicles[playerId].lastShot < 200) break;                  for (int i = 0; i < MAX_PROJECTILES; i++) {           if (!gameState.projectiles[i].active) {             gameState.projectiles[i].id = gameState.nextProjectileId++;             gameState.projectiles[i].ownerId = playerId;             gameState.projectiles[i].created = now;                          if (gameState.vehicles[playerId].direction == "north") {               gameState.projectiles[i].x = gameState.vehicles[playerId].x + 10;               gameState.projectiles[i].y = gameState.vehicles[playerId].y - 5;             } else if (gameState.vehicles[playerId].direction == "south") {               gameState.projectiles[i].x = gameState.vehicles[playerId].x + 10;               gameState.projectiles[i].y = gameState.vehicles[playerId].y + 25;             } else if (gameState.vehicles[playerId].direction == "west") {               gameState.projectiles[i].x = gameState.vehicles[playerId].x - 5;               gameState.projectiles[i].y = gameState.vehicles[playerId].y + 10;             } else if (gameState.vehicles[playerId].direction == "east") {               gameState.projectiles[i].x = gameState.vehicles[playerId].x + 25;               gameState.projectiles[i].y = gameState.vehicles[playerId].y + 10;             }                          gameState.projectiles[i].trajectory = gameState.vehicles[playerId].direction;             gameState.projectiles[i].active = true;             gameState.vehicles[playerId].lastShot = now;             break;           }         }       }       break;     }            case WS_EVT_ERROR:     case WS_EVT_PONG:       break;   } }  void updateGameState() {   static unsigned long lastUpdate = 0;   unsigned long now = millis();   if (now - lastUpdate < 50) return;   lastUpdate = now;    DynamicJsonDocument stateDoc(4096);   stateDoc["type"] = "state";      JsonArray vehiclesArray = stateDoc.createNestedArray("vehicles");   for (int i = 0; i < MAX_PLAYERS; i++) {     if (gameState.vehicles[i].active) {       JsonObject vehicleObj = vehiclesArray.createNestedObject();       vehicleObj["id"] = gameState.vehicles[i].id;       vehicleObj["x"] = gameState.vehicles[i].x;       vehicleObj["y"] = gameState.vehicles[i].y;       vehicleObj["direction"] = gameState.vehicles[i].direction;       vehicleObj["respawning"] = gameState.vehicles[i].respawning;     }   }      JsonArray projectilesArray = stateDoc.createNestedArray("projectiles");   JsonArray hitsArray = stateDoc.createNestedArray("hits");   JsonArray destroyedObstacles = stateDoc.createNestedArray("destroyedObstacles");   JsonArray respawns = stateDoc.createNestedArray("respawns");      for (int i = 0; i < MAX_PROJECTILES; i++) {     if (gameState.projectiles[i].active) {       float speed = PROJECTILE_SPEED;       float newX = gameState.projectiles[i].x;       float newY = gameState.projectiles[i].y;              if (gameState.projectiles[i].trajectory == "north") newY -= speed;       else if (gameState.projectiles[i].trajectory == "south") newY += speed;       else if (gameState.projectiles[i].trajectory == "west") newX -= speed;       else if (gameState.projectiles[i].trajectory == "east") newX += speed;              if (detectCollision(newX, newY, true)) {         bool hitObstacle = false;         bool hitVehicle = false;         int hitVehicleId = -1;                  for (int j = 0; j < MAX_OBSTACLES; j++) {           if (gameState.obstacles[j].active) {             float obstacleX = gameState.obstacles[j].x * 20;             float obstacleY = gameState.obstacles[j].y * 20;                          if (newX + 5 > obstacleX &&                  newX < obstacleX + 20 &&                  newY + 5 > obstacleY &&                  newY < obstacleY + 20) {               gameState.obstacles[j].durability--;               hitObstacle = true;                              if (gameState.obstacles[j].durability <= 0) {                 JsonObject obstacleObj = destroyedObstacles.createNestedObject();                 obstacleObj["x"] = gameState.obstacles[j].x;                 obstacleObj["y"] = gameState.obstacles[j].y;                 gameState.obstacles[j].active = false;               }               break;             }           }         }                  if (!hitObstacle) {           for (int j = 0; j < MAX_PLAYERS; j++) {             if (gameState.vehicles[j].active &&                  !gameState.vehicles[j].respawning &&                  j != gameState.projectiles[i].ownerId) {                              if (newX + 5 > gameState.vehicles[j].x &&                    newX < gameState.vehicles[j].x + 20 &&                    newY + 5 > gameState.vehicles[j].y &&                    newY < gameState.vehicles[j].y + 20) {                                  gameState.vehicles[j].health--;                 hitVehicle = true;                 hitVehicleId = j;                                  if (gameState.vehicles[j].health <= 0) {                   gameState.vehicles[j].health = 3;                   gameState.vehicles[j].respawning = true;                   gameState.vehicles[j].respawnTime = now + 2000;                                      bool positionFound = false;                   int attempts = 0;                   while (!positionFound && attempts < 100) {                     float x = random(0, 380);                     float y = random(0, 380);                                          x = floor(x / 20) * 20;                     y = floor(y / 20) * 20;                                          if (isPositionAvailable(x, y)) {                       gameState.vehicles[j].x = x;                       gameState.vehicles[j].y = y;                       positionFound = true;                     }                     attempts++;                   }                                      if (!positionFound) {                     gameState.vehicles[j].x = 100 + j * 100;                     gameState.vehicles[j].y = 100 + j * 100;                   }                                      JsonObject respawnObj = respawns.createNestedObject();                   respawnObj["x"] = gameState.vehicles[j].x + 10;                   respawnObj["y"] = gameState.vehicles[j].y + 10;                 }                 break;               }             }           }         }                  JsonObject hitObj = hitsArray.createNestedObject();         hitObj["x"] = newX;         hitObj["y"] = newY;         hitObj["fragments"] = true;                  if (hitVehicle) {           if (gameState.vehicles[hitVehicleId].health > 0) {             hitObj["explosion"] = "small";           } else {             hitObj["explosion"] = "big";           }         } else {           hitObj["explosion"] = "small";         }                  gameState.projectiles[i].active = false;         continue;       }              gameState.projectiles[i].x = newX;       gameState.projectiles[i].y = newY;              JsonObject projectileObj = projectilesArray.createNestedObject();       projectileObj["id"] = gameState.projectiles[i].id;       projectileObj["x"] = newX;       projectileObj["y"] = newY;     }   }      for (int i = 0; i < MAX_PLAYERS; i++) {     if (gameState.vehicles[i].active &&          gameState.vehicles[i].respawning &&          gameState.vehicles[i].respawnTime <= now) {       gameState.vehicles[i].respawning = false;     }   }    String stateData;   serializeJson(stateDoc, stateData);   ws.textAll(stateData); }  void setupGameEnvironment() {   for (int i = 0; i < MAX_OBSTACLES; i++) {     gameState.obstacles[i].active = false;   }      randomSeed(analogRead(0));   int obstacleCount = 0;      for (int i = 0; i < 20; i++) {     for (int j = 0; j < 20; j++) {       if (random(0, 100) < 30 && obstacleCount < MAX_OBSTACLES) {         if (!((i >= 8 && i <= 12) && (j >= 8 && j <= 12))) {           gameState.obstacles[obstacleCount].x = i;           gameState.obstacles[obstacleCount].y = j;           gameState.obstacles[obstacleCount].durability = 3;           gameState.obstacles[obstacleCount].active = true;           obstacleCount++;         }       }     }   } }  void setup() {   Serial.begin(115200);   delay(1000);      Serial.println("\nStarting Racing Arena Server...");   WiFi.softAP(ssid, password);      IPAddress IP = WiFi.softAPIP();   Serial.print("AP IP: ");   Serial.println(IP);    server.on("/", HTTP_GET, [](AsyncWebServerRequest *request) {     request->send_P(200, "text/html", index_html);   });    ws.onEvent(handleWebSocketEvent);   server.addHandler(&ws);   server.begin();    setupGameEnvironment();   Serial.println("Server ready!"); }  void loop() {   static unsigned long lastBroadcast = 0;   unsigned long now = millis();      if (now - lastBroadcast >= 50) {     updateGameState();     lastBroadcast = now;   }      ws.cleanupClients();   delay(1); }

После загрузки прошивки выше в esp32 — нужно включить wifi на том компьютере, с которого вы хотите войти в игру (поддерживается только полноразмерная компьютерная клавиатура, совместимость с мобильными устройствами/ноутбуками не тестировалась), после чего, среди имеющихся в эфире wifi-сетей вы увидите сеть с названием «ESP32» — нужно подключиться к ней, используя пароль 12345678.

Далее, запустить браузер на этом компьютере и перейти по адресу: 192.168.4.1 (по крайней мере, у меня был таким, при тестах). Если по этому адресу подключиться не удаётся — нужно запустить Arduino IDE, в которой открыть монитор порта и при подключенной к компьютеру esp32 произвести её перезагрузку. В мониторе порта появится актуальный IP, к которому надо обращаться из браузера.

Всё, вы в игре! 🙂

Первая часть статьи: Пилим игровой мультиплеерный сервер на базе esp32: начало.

© 2025 ООО «МТ ФИНАНС»

Telegram-канал со скидками, розыгрышами призов и новостями IT 💻


ссылка на оригинал статьи https://habr.com/ru/articles/895672/