Разработка браузерной онлайн игры без фреймворков и движков


    Привет, Хабр!

    В этом посте будет описан процесс разработки онлайн игры на чистом javascript и WebGL (без фреймворков и движков). Будут рассмотрены некоторые алгоритмы, техники рендеринга, искусственный интеллект ботов и сетевая игра. Проект является полностью опенсорсным, в конце поста будет ссылка на репозиторий.

    Сразу же геймплейное видео:


    Тут играет бот по имени Лягуха и другие боты

    Введение


    Игра представляет из себя 2D стрелялку «вид сверху». В игре присутствуют следующие элементы: земля; стены, ограничивающие видимость; лава, по которой ходить нельзя; мосты через лаву; 6 видов оружия и 5 видов пауэрапов; персонажи, одним из которых управляет игрок.

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

    Клиент написан на javascript с использованием WebGL, а сервер на node.js. В этом проекте используются следующие библиотеки:

    • game-shim.js — для requestAnimationFrame, fullscreen и mouselock
    • gl-matrix.js — матричная библиотека
    • howler.js — звуковая библиотека
    • bootstrap и jquery сугубо для странички с вводом ника, более нигде не использовались
    • на сервере используется express, ws (WebSocket) и другие

    Теперь перейдем непосредственно к описанию используемых алгоритмов.
    Уровень (карта) рендерится с помощью так называемой левелмапы — это обычная текстура, каждый канал которой содержит некую маску (об этих масках речь пойдет ниже). В этой игре максимальный размер карты 256х256, где 1х1 соответствует размеру персонажей. Такая карта рассчитана примерно на 110 игроков. Для простоты рассмотрим карту 64х64. Но для начала её нужно сгенерировать.

    Генерация уровня


    Для генерации карты используется обычный Шум Перлина плюс некоторые преобразования и фильтрации:



    Где perlinNoise() — шум Перлина, в диапазоне от 0 до 1,
    abs(x) — модуль числа,
    clamp(x) — если x < 0, то x = 0. Если x > 1, то x = 1,
    norm(-0.5, 2) — нормализует шум в данный диапазон.


    Именно такая последовательность преобразований на выходе даёт требуемую топологию карты: она состоит из комнат и коридоров, шириной в 2-3 пикселя.

    Получившаяся текстура является маской земли (черный цвет — земля, белый — стена).
    Далее в отдельном буфере генерируется река лавы. Для этого генерируется ломаная (если нужны притоки, то несколько ломаных) и каждый отрезок ломанной «рисуется» в буфер с помощью алгоритма Брезенхама. Затем этот буфер с отрезками размывается по Гауссу. Радиус размытия выбирается исходя из требуемой ширины реки. Для оптимизации размытия используется двухпроходный алгоритм. Функция размытия нам ещё не раз пригодится. Размытие буфера с лавой нужно для того, чтобы придать реке ширину, а так же в дальнейшем для рендеринга.

    В маске земли, где проходит лава и в непосредственной близости от неё, удаляются стены:


    Слева направо: маска земли с предыдущего этапа; маска лавы; расчищаем стены от лавы; финальный результат

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

    А вот так, например, выглядит карта размером 256х256


    Ну и в конце генерируются мосты. Тут все просто: выбираются случайные точки на лаве так, чтобы мосты не были слишком близко друг к другу или слишком далеко, и ориентируются они перпендикулярно потоку лавы в данной точке (естественно мосты не запекаются в левелмапу, они хранятся просто как массив объектов).

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

    Все вышеперечисленные маски и составляют нашу левелмапу. Вот из чего она состоит: R канал содержит маску лавы; G — маска земли; B — маска для смешивания диффузных текстур; A — тени.

    Готовая левелмапа

    Правда тут не видно альфа-канала, в который запечены тени

    Рендеринг уровня


    Если прямо сейчас взять и отрендерить левелмапу, то получится примерно следующее:


    Слева прямоугольник на левелмапе показывает охват камеры

    Как такое получить? Допустим у нас есть матрица камеры (мы же точно знаем позицию своего игрока и угол его поворота). В вершинном шейдере эта матрица умножается на текстурные координаты полноэкранного квада, а результат передаётся во фрагментный шейдер. Эти текстурные координаты ни что иное, как координаты фрагмента внутри левелмапы. Поэтому просто делаем выборку из неё по этим координатам. На скриншоте выше выводятся только RG каналы левелмапы.

    Получилось слишком размыто (фильтр Гаусса даёт о себе знать). Давайте к результату выборки из левелмапы применим клампинг:

    vec4 level = texture2D(levelmap, texcoord.xy);
    vec2 color = clamp((level.rg * 2.0 - 1.0) * 30.0, 0.0, 1.0);
    

    Смысл этой формулы: level.rg у нас меняется плавно от 0 до 1 (видно на скриншоте выше). После клампинга все значения выборки меньшие 0.5 обращаются в 0, а большие 0.516 обращаются в 1, а значения на отрезке [0.5, 0.516] «растягиваются» в отрезок [0, 1]. Вот так это выглядит:



    Пришло время для текстур: две диффузные текстуры смешиваются по маске из B-канала левелмапы, с ними смешивается текстура стены по G-каналу, а получившийся результат смешивается с текстурой лавы по R-каналу левелмапы (смешивание цветов в шейдерах GLSL производится функцией mix — это обычная линейная интерполяция двух значений по коэффициенту смешивания). Для выборки из текстур используется всё те же текстурные координаты домноженные на некий коэффициент, который влияет на количество повторений этой текстуры по всей карте:



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



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

    Осталось решить последнюю проблему: мы можем видеть через стену (то есть видим области, которые отгорожены стеной от нашего персонажа). Для решения этой проблемы используется трассировка фрагмента: из исследуемого фрагмента выпускается луч в сторону персонажа и делаются выборки из левелмапы (нас интересует только G-канал с маской стены), в данном проекте используется 12 выборок. По результатам выборок можно определить, виден ли фрагмент из позиции персонажа или нет. Для полноэкранного прохода такая трассировка слишком дорогая операция, поэтому она выполняется в отдельном фреймбуфере малого размера (64х64). Эта текстура называется картой видимости:


    До применения карты видимости и после

    Карта видимости используется не только в полноэкранном проходе для рендеринга карты, но и для рендера всех игровых объектов (мосты, пушки, персонажи).

    Рендеринг лавы


    Теперь давайте анимируем лаву. Для этого нужно создать карту смещений. В этой текстуре в RG-каналах хранится 2D вектор, который складывается с текстурными координатами для выборки из текстуры лавы. Причем нужна не просто карта смещений, а карта смещений меняющаяся со временем. В данной игре для создания карты смещений используется фреймбуфер размером 512х512. В шейдер этого прохода передаётся текстура с шумом Перлина и время, измеряемое от 0 до 1.

    Кусок фрагментного шейдера (упрощено):

    vec4 d1 = texture2D(noise, (texcoord.xy + time.xy));
    vec4 d2 = texture2D(noise, (texcoord.xy + time.yx) * 2.0);
    vec4 d3 = texture2D(noise, (texcoord.xy + vec2(1.0 - time.x, 1.0)) * 4.0);
    vec4 d4 = texture2D(noise, (texcoord.xy + vec2(1.0, 1.0 - time.x)) * 8.0);
    vec2 d = (d1.rg + d2.gr + d3.rg + d4.gr) * 0.25;
    gl_FragColor = vec4(d.rg, 0.0, 0.0);
    

    где noise — шумовая текстура (она статичная).

    Смысл этого шейдера в том, что делаются 4 выборки из одной и той же шумовой текстуры, но с разным масштабом текстурных координат. Да ещё и смещаются они со временем в перпендикулярных направлениях (обратите внимание на вторую выборку, там используется смещение time.yx, а в первой — time.xy). Время зашито только в x-компоненту переменной time, а y-компонента содержит 0.

    Анимированная карта смещений

    Здесь и далее анимация прерывистая, потому что не удалось её зациклить для гифки, но в игре она плавная

    Теперь эту анимированную текстуру можно передать в шейдер лавы. В этом шейдере делаем выборку из карты смещений и прибавляем полученный 2D вектор к текстурным координатам лавы.

    Упрощенный кусок шейдера:

    vec4 wave = texture2D(tex_wave, texcoord.zw) * 0.1;
    vec4 color = texture2D(lava, texcoord.xy + wave.rg);
    

    где tex_wave — карта смещений (ну или карта волн).

    С коэффициентом 0.1 можно поиграться, влияет он на «возмущение» лавы.

    Анимированная лава:



    В принципе, на этом можно было бы и остановиться, выглядит довольно неплохо. Но можно сделать ещё лучше — заставить лаву течь. Для создания течения нам понадобится сгенерировать ещё одну текстуру: карту скоростей. Когда генерировалась лава, мы создали ломаную и нарисовали отрезки этой ломаной в буфер лавы. Теперь заведем ещё один буфер для карты скоростей. Нарисуем в него отрезки ломаной, но при этом каждый отрезок будет заносить в этот буфер вектор направления отрезка. Затем размоем этот буфер по Гауссу, тем самым мы не только «размешаем» скорости на изломах реки и на пересечениях с притоками, но и получим эффект «вязкой лавы», то есть скорость потока лавы в середине реки выше, чем у берегов.

    Карта скоростей

    Справа карта скоростей. RG-каналы соответствуют вектору скорости

    Карта скоростей используется для смещения текстурных координат лавы. Чтобы появился эффект течения, смещать нужно с учетом времени.

    Итак, обновленный шейдер лавы (упрощено):

    vec4 vel = texture2D(tex_velocity, texcoord.xy);
    vec4 wave = texture2D(tex_wave, texcoord.zw) * 0.1;
    vec4 col1 = texture2D(lava, texcoord.xy + wave.rg + vel.xy * time.x);
    vec4 col2 = texture2D(lava, texcoord.xy + wave.rg + vel.xy * time.x - vel.xy);
    vec4 color = mix(col1, col2, time.x);
    

    Тут делается две выборки из текстуры лавы на расстоянии друг от друга в точности равное vel.xy и смешиваются по значению времени.

    Зачем делать две выборки вместо одной?
    Дело в том, что время time.x измеряется от 0 до 1 и при переходе из 1 в 0 происходит скачок текстурных координат, это выражается и скачком в анимации самой текстуры. Если время не сбрасывать в ноль (пусть всегда растет), то ещё хуже, текстуру расплющит, потому что у нас разные скорости течения лавы (даже у соседних фрагментов они в целом разные). Первая выборка даёт цвет лавы в настоящем времени, а вторая даёт цвет, который был единицу времени назад. Эти выборки интерполируются по времени. Таким образом достигается плавность анимации.

    Всё вместе выглядит так:



    Рендеринг декалей
    Декали в данной игре — это лужи крови, следы от взрывов ракет и других пуль.
    Декали, как объекты, сами по себе нигде не хранятся и не процессятся. Каждая декалька, в момент своего появления, рендерится в огромную текстуру, которая покрывает всю карту. Эта текстура потом «натягивается» на левелмапу. То есть, она содержит в себе все когда либо появлявшиеся декали (естественно она не чистится).

    Теперь давайте выясним, какое разрешение должно быть у этой текстуры с декалями. Экспериментальным путём было выяснено, что 16х16 текселей текстуры, приходящиеся на один тайл карты выглядит оптимально (если брать 32х32, то это уже больше, чем размер самой декальки, например следа от взрыва, а по памяти 4-кратный проигрыш). Самая большая карта имеет размер 256х256, тогда разрешение текстуры с декалями равно 16 * 256 = 4096х4096.
    На самом деле текстура 4К*4К не создаётся, вместо этого всю карту «распиливаем» на зоны 32х32. На каждую такую зону приходится собственная текстура с декалями 512х512. Всего таких текстур для большой карты 64. Чтобы не передавать пачку этих текстур в главный проход рендеринга карты (и не процессить из какой именно делать выборки) был создан ещё один фреймбуфер. Для него настраивается ровно та же матрица камеры, что и для рендеринга карты. И все текстуры с декалями по-очереди рендерятся в неё с учётом своих позиций на карте (естественно большая часть из них отсекается до вызова drawcall). Теперь в главный проход рендеринга карты передаём только этот готовый фреймбуфер.

    Физика


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

    Функция определения столкновений (упрощено)
    this.getCollide = function(pos)
    {
        const frac = (x) => x - (x | 0);
        const lerp = (a, b, t) => a * (1 - t) + b * t;
    
        const buffer = level.getGroundMap();
        const cx = pos.x | 0;
        const cy = pos.y | 0;
         
        const t00 = buffer.getData(cx,     cy);
        const t10 = buffer.getData(cx + 1, cy);
        const t01 = buffer.getData(cx,     cy + 1);
        const t11 = buffer.getData(cx + 1, cy + 1);
        const dx = frac(pos.x);
        const dy = frac(pos.y);
        const xx1 = lerp(t00, t10, dx);
        const xx2 = lerp(t01, t11, dx);
        const yy = lerp(xx1, xx2, dy);
        return yy * 255 | 0;
    };
    


    Для определения нормали к стене в точке (X, Y) нужно произвести такую операцию трижды: для точек (X, Y), (X + 0.25, Y), (X, Y + 0.25):

    this.getNorm = function(dest_normal, pos)
    {
        const t00 = this.getCollide(pos);
        const t10 = this.getCollide(new Vector(pos.x + 0.25, pos.y));
        const t01 = this.getCollide(new Vector(pos.x,        pos.y + 0.25));
        dest_normal.set(t10 - t00, t01 - t00);
        return t00;
    };
    

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

    Рикошеты:



    Преимущества и недостатки использования левелмапы по сравнению с тайловым рендером.

    Плюсы:

    • Рендерится одним вызовом и всё в одном шейдере
    • Легко создавать неровности и плавные переходы, физика при этом не страдает
    • Бесплатный антиалиасинг
    • Не нужно хранить тонну тайлов и думать о том, как их отсечь по камере
    • Что-то ещё

    Минусы:

    • Рендерится одним вызовом и всё в одном шейдере, поэтому он довольно тяжелый
    • Мы ограничены в диффузных текстурах, потому что маски смешивания нужно помещать в каналы левелмапы, коих всего 4. Ну и в целом труднее создать разнообразие на карте
    • Что-то ещё

    Искусственный интеллект ботов


    Первая и самая главная задача ИИ ботов — это научить их просто бегать по карте, не обращая внимание на врагов и предметы. То есть адекватное перемещений по коридорам и комнатам, а так же по мостам через лаву. Для этого используется специальный граф, узлы которого назовём вейпоинтами. Такой граф должен покрывать всю карту, проходить через все коридоры и комнаты. Бот просто имеет ссылку на текущий вейпоинт, и, когда достигает его, выбирает случайным образом следующий вейпоинт из тех, на которые ссылается его текущий. Выглядит вроде просто. Но сначала нужно построить этот граф вейпоинтов.

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

    Карта проходимости для нашего уровня


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

    Карта расстояний:


    Тут на картинке в цвете закодировано расстояние, чем темнее, тем дальше

    В принципе, тут уже угадываются очертания будущего графа. Следующий подготовительный этап — это вычисление градиента карты расстояний. Где градиент близок к нулю, там и будет вейпоинт.
    Градиент карты расстояний в точке (i, j) вычисляется примерно так:

    const val00 = distance_field.getData(i - 1, j - 1);
    const val10 = distance_field.getData(i    , j - 1);
    const val20 = distance_field.getData(i + 1, j - 1);
    const val01 = distance_field.getData(i - 1, j);
    const val11 = distance_field.getData(i    , j);
    const val21 = distance_field.getData(i + 1, j);
    const val02 = distance_field.getData(i - 1, j + 1);
    const val12 = distance_field.getData(i    , j + 1);
    const val22 = distance_field.getData(i + 1, j + 1);
    
    const dx = (val00 - val11) + (val01 - val11) + (val02 - val11) +
               (val11 - val20) + (val11 - val21) + (val11 - val22);
    
    const dy = (val00 - val11) + (val10 - val11) + (val20 - val11) +
               (val11 - val02) + (val11 - val12) + (val11 - val22);
    
    gradient_x.setData(i + j * size | 0, dx);
    gradient_y.setData(i + j * size | 0, dy);
    

    Т.о. для всех пикселей карты выполняем этот код (на самом деле только для проходимых пикселей).

    Градиент карты расстояний:


    Тут, по традиции, RG-каналы кодируют вектор

    Эта карта напоминает горный хребет. Идея состоит в том, чтобы раскидать вейпоинты в точках, где проходит гребень этого хребта. А как найти гребень? Компоненты вектора градиента изменяются от -1 до 1. Гребень хребта будет там, где градиент находится в диапазоне [-α, α], где α — некая константа, подбираемая экспериментальным путём (в данном проекте равна 0.4). Все точки, в которых удовлетворяется данное условие будут первым приближением ко множеству вейпоинтов. Помимо «гребневых» вейпоинтов также добавляются по два вейпоинта для каждого моста (в точках, где мост опирается на землю). Карта с вейпоинтами:


    Зелёные точки — это вейпоинты

    Вейпоинты расположились слишком густо. Некоторые из них нужно удалить. В данном проекте минимально допустимое расстояние между вейпоинтами равно 4 пикселя. Поэтому перебираем все пары вейпоинтов и если расстояние меньше допустимого, то удаляем один из них. Естественно пропускаем мостовые вейпоинты, т.к. они никогда не должны быть удалены.


    Теперь необходимо соединить эти вейпоинты рёбрами. Перебираем все пары вейпоинтов и соединяем пару ребром тогда, когда оба вейпоинта видимы друг из друга. Для определения видимости используется трассировка: делаем n выборок из карты проходимости вдоль отрезка, соединяющего эти вейпоинты; где n — длина отрезка. Если все выборки дали проходимый пиксель, то соединяем эти два вейпоинта ребром:


    Красные линии — это рёбра графа

    Получилось слишком много лишних рёбер. Для решения этой проблемы нужно избавиться от треугольников. Как это работает? Возьмём любое ребро с двумя вейпоинтами A и B. Допустим среди соседей A есть вейпоинт C, который в свою очередь является и соседом B. Т.е. образуется треугольник ABC. Самое длинное ребро этого треугольника нужно удалить. То же самое проделываем для всех рёбер:


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

    Это окончательный граф. Теперь каждый бот может по нему навигироваться.
    ИИ бота состоит из стейтмашин, всего их три. Одна стейтмашина задаёт общее состояние бота, а две другие управляют двумя степенями свободы: ноги (перемещение) и корпус. На выходе ИИ бота выдаёт угол поворота и нажатость его клавиш, т.о. единственное отличие бота от реального игрока — персонаж игрока получает ввод от клавиатуры и мыши, а персонаж бота получает ввод от ИИ. Помимо беготни по графу вейпоинтов бот также умеет реагировать на объекты: валяющиеся пушки и пауэрапы, других ботов и некоторые пули. Причем бот реагирует на эти объекты тогда и только тогда, когда они находятся в зоне его видимости: в пределах его «камеры» (как у игрока) и не отгороженные стенами.

    В следующем видео демонстрируется ИИ бота по имени Лягуха в поединке с другими ботами на большой карте:


    Итак, мы только что сгенерировали карту, лаву и граф для ИИ. А что по производительности? Я померил время всех этапов генерации для самой большой карты 256х256 на Mac mini (конец 2012 г.), напомню, весь код написан на js и запускается в обычном браузере chrome:
    Алгоритм Время, мс
    Генерация текстур частиц 280
    Генерация текстур крови 190
    Генерация реки лавы 700
    Генерация остальной карты 630
    Создание графа 210 (720)
    Всего: 2010 (2520)

    В скобках указано время без использования хеша для вейпоинтов. Этот хеш служит для быстрого поиска вейпоинтов рядом с данным в некоторых алгоритмах построения графа. Без этого хеша в этих алгоритмах перебираются вейпоинты со сложностью O(n2).

    Сетевая часть


    Сервер написан на node.js с использованием WebSocket. Весь сервер состоит из трёх частей: master-server — http-сервер, раздаёт статику; game-server — соединяется с мастер-сервером и невидим для пользователя, владеет игровыми комнатами; игровая комната — именно к ней коннектится пользователь по веб-сокету, там бегают другие игроки и боты.

    При разработке серверной части был применён подход с использованием фейксокетов. Что это такое? Фейксокеты — это два объекта в клиентской части с интерфесами настоящего клиентского и серверного сокета. Они эмулирует работу реальных сокетов. «Клиентский» и «серверный» фейксокет (в кавычках, потому что они оба клиентские, т.е. браузерные) обмениваются данными друг с другом по средством setTimeout (в целях отладки был выбран интервал он же пинг в 30 миллисекунд). Идея в том, что настоящий серверный код можно отдать браузеру, сказать ему, чтобы он работал в режиме фейксокетов. При этом настоящий клиентский код даже не подозревает, что и сервер работает рядом с ним в браузере, он просто посылает данные в сокет (которым является фейксокет) и получает данные с него. Фейксокеты использовались при разработке и отладки серверного кода. Но настоящий online работает через реальные сокеты.

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

    Фейксокеты сыграли огромную роль при разработке серверной части. Т.к. и клиент и сервер запущены в браузере — их очень легко отлаживать, также можно выводить на экран debug-render, т.е. схемотично рендерить объекты именно в тех позициях, где они реально находятся на сервере в данный момент времени. Можно играться с пингом: менять интервал в функции setTimeout, чтобы реализовать всякие предсказания и посмотреть, как они работают на разных пингах (в данном проекте, правда, не было реализовано никаких предсказаний).

    Плюсы фейксокетов:

    • Легкая отладка — и сервер и клиент находятся в браузере
    • Можно ставить любой пинг и смотреть, как будет играться при разных его значениях
    • debag-render позволяет понять, как объекты выглядят прямо сейчас на сервере

    Минусы фейксокетов:

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

    Для передачи данных между клиентом и сервером используется ArrayBuffer. Все данные сериализовализуются в этот буфер, так же применен прием с упаковкой float в 2 байта. Перед формированием пакета для клиента, все объекты максимально отсекаются по камере этого клиента, чтобы не посылать ему лишнюю информацию. В итоге было подсчитано, что средний размер серверного пакета для одного клиента примерно составляет 110-120 байт. А в нём содержится вся информация, нужная для рендеринга этим клиентом его кадра.

    Заключение


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

    Полный исходный код игры выложен на github.

    Руководство по запуску (у вас должен быть установлен node.js).
    Если у вас уже установлен bower, то этот шаг пропускаем, иначе:

    npm install -g bower
    

    В корне проекта выполняем две команды:

    bower install
    npm install
    

    Запуск мастер-сервера:

    npm run master
    

    Если 80 порт у вас занят, то можно так:

    npm run master -- --master-server:port=8800
    

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

    npm run game
    

    Теперь можно зайти в игру не только с этого компьютера, но и с любого другого, просто указав в адресной строке ip мастер-сервера. Чтобы узнать локальная ли игра, нужно вызвать консоль игры и выполнить команду status. Ещё одна интересная команда spectator nick, где nick — имя игрока, тогда камера переместится на этого игрока. По умолчанию будет сгенерирована большая карта на 110 игроков. Чтобы генерировать другие карты, нужно выполнить:

    npm run game -- -- --game-server:seed=314 --game-server:size=0
    

    Здесь не опечатка, количество дефисов именно такое.
    Эта команда сгенерирует маленькую карту, которая описывалась в этом посте. Поддерживаются следующие size: 0 — маленькая карта, 1 — средняя и 2 — большая карта (по умолчанию).

    Запускать лучше всего в браузере chrome, в других браузерах не гарантируется корректная работа.

    Всем спасибо за внимание.
    Share post

    Similar posts

    Comments 42

      +5
      Спасибо за статью, очень интересная реализация лавы!
        +3

        Очень круто. А профит есть?

          +3
          Всё с нуля это круто. Респект.
            +2
            Круто, молодец. Очень много народа используют либы поверх webGl как магию, не понимая вообще, что же там происходит! И спрашивают, а зачем математика в геймдеве?
              +2
              Область видимости классная, люблю такие штуки, но видно что не супер сглажено на краях
            • UFO just landed and posted this here
                –5
                Тянуть bootstrap и jQuery ради одной формочки, не совсем оптимильно
                  +2
                  Круть! Люблю дешевые и сердитые фишки типа вашей лавы и ландшафта в целом. Спасибо за подробное изложение, очень интересно.
                    0

                    А каким образом после построения графа остаются несвязанные waypoints? На изображении видно несколько отдельных зелёных точек, от которых есть прямая видимость до ближайшего waypoint, находящегося на графе

                      0
                      Они отфильтровались, дело в том, что они никуда не ведут, там тупик. Хотя тупики и без этого остались
                      +1

                      С течением лавы крутой хак. Я люблю такие шейдерные эффекты, но Гаусса для маски скорости никогда не применял, мб попробую где-нибудь

                        0
                        Отличная работа :).
                        По личным ощущениям 2 проблемы:
                        1. Слишком «низко» располагается камера. Учитывая что экран прямоугольный — видно больше по ширине нежели по длине.
                        2. Резкие повороты камеры из-за полной привязки к поворотку игрока.

                        Отсюда вытекает вопрос: не думал ли попробовать сделать камеру, как в GTA2, например?
                          0
                          1. Если камеру поднять, то будет очень мелко и трудно попасть по врагам
                          2. Там можно менять чувствительность мыши

                          Ну мне такое расположение камеры нравится больше, чем в ГТА2, дело привычки
                            0
                            Попасть по игрокам получается исключительно когда враг вне зоны видимости. Если заходишь в зону видимости врага — есть вероятность умереть слишком быстро.
                              0
                              А может стоило камеру в изометрию перевести? Тогда недостаток высоты будет компенсирован относительно ширины.

                              Нет, там не чуствительность мыши, там «еластичности» камере не хватает, что-то вроде:

                              float wantedRotationAngle = target.eulerAngles.y;
                              float wantedHeight = target.position.y + height;

                              float currentRotationAngle = transform.eulerAngles.y;
                              float currentHeight = transform.position.y;

                              currentRotationAngle = Mathf.LerpAngle (currentRotationAngle, wantedRotationAngle, rotationDamping * Time.deltaTime);

                              currentHeight = Mathf.Lerp (currentHeight, wantedHeight, heightDamping * Time.deltaTime);

                              Quaternion currentRotation = Quaternion.Euler (0, currentRotationAngle, 0);

                              transform.position = target.position;
                              transform.position -= currentRotation * Vector3.forward * distance;
                              Vector3 temp = transform.position;
                              temp.y = currentHeight;
                              transform.position = temp;

                              transform.LookAt (target);
                            0
                            Глазораздиралка, жесткая привязка камеры к персу. Не думали о контроллере который добавит плавности камере?
                              0
                              Поддерживаю. Постоянное вращение игрового пространства приводит к сильному утомлению глазных мышц. Знаком с этим эффектом по игре Elite.
                                0
                                Блин, а Элита-то чем провинилась, почему не любая другая полетная аркада/сим, и ведь это даже не 2д игра… :)
                                  0
                                  Потому что управление кораблем в Элите основано на постоянных вращениях, корабль постоянно крутит бочки, что редко встречается в других аркадах/сим. Привел ее просто как пример из личного опыта. Если интересно, о чем идет речь, могу предложить простой эксперимент — возьмите в руки какую-нибудь стереограмму, поймайте стереоэффект, а потом поверните картинку.
                                    0
                                    Редко встречается? Это шутка? :) Во всех симах же надо вращаться по продольной оси (симы машин не берем в расчет). А в Элите на экране космос почти все время, ничего особо не мельтешит. И при чем тут стереограмма? Мы же не про виртуальную реальность говорим?
                            • UFO just landed and posted this here
                                0
                                Про webgl как-то грустно. Или это у меня так?
                                Почему грустно?

                                Может есть смысл перепилить все на чистом webgl?
                                К сожалению, я не знаю, как устроен threejs (не использовал его)
                                • UFO just landed and posted this here
                                    0
                                    моя гипотеза про невзлёт игр — отсутсвие специалистов. WebGL для программиста в вебе чудж своей идеологией, а для того кто умеет GL на нативных платформах — чудж веб с его сетевыми проблемами и js с его подходами. Вот и классная теха на непопулярном стаке технологий испытывает проблемы
                                      0
                                      А как же slither.io?
                                        0
                                        я так понимаю имелись ввиду классические 3d игры, slither надо сказать к гарфике то требований особо не предъявляет
                                    0
                                    Что-то делаете не так или three.js что-то делает не так, с ним не знаком. Посмотрите это www.youtube.com/watch?v=adixpp9CK_A оно без движка сделано, на производительность webgl я жаловаться не могу.
                                    • UFO just landed and posted this here
                                        0
                                        это перевод на webGL старой игры, графика не поменяется. А попробуйте сделать подобное на юнити или ue из коробки, поймёте в чём проблема. Да тут нет динамического света и тп. Но производительность шейдеров и вообще пайплайна внутри дроколла от js и webgl не зависит
                                    +1
                                    Круто!) Погружаюсь в изучение исходников)))
                                      +1
                                      Йо ). Там quake-style консоль есть с командами.
                                        0
                                        Как при игре по сети вы боретесь с задержкой результатов ввода? Клиент ждет результат от сервера или отправляет на сервер ввод и действия постфактум а сервер верит?
                                          0
                                          клиент ждёт. Сначала попробовал сделать предсказания, то есть клиент сразу действует, а по факту прихода пакета с сервера корректируется, но как-то не очень получилось, поэтому пошёл по-простому
                                          +1
                                          Круто. спасибо за статью! И геймплей забавный.
                                            +1
                                            Шикарная статья! Благодарю автора! Умел бы плюсанул.
                                              0
                                              Очень годно! Алгоритмы, алгоритмы, алгоритмы, омномном. :) Во многих статьях есть они, но все разбросано по кусочкам, а тут полная подборка по генерации карты и вейпойнтам для ботов, прекрасно!
                                              Есть предложение: если есть еще желание возиться, попробовать ввести модный ныне режим Бэттл Роял. :) Вроде нетрудно должно быть, только сделать ТТК побольше.
                                              И да, лава крута, особенно издалека. Может разве что замедлить ее, а то больно текучая получилась, хотя это мелкая придирка. И вопрос по ней — много ресурсов жрет ее обработка? Профилирование возможно?
                                                0
                                                Лава состоит из двух пассов, оба в 512х512. Первый пасс — анимация волн. Судя по профилировщику, он сравним с основным пассом рендера левелмапы. Второй пасс лавы — это непосредственно рендеринг с применением смещений волн и смещений скоростей — этот проход дешёвый. Т.е ресурсов жрёт немного.
                                                  0
                                                  Ну анимация меня не так волнует, мне интересно конкретно вот что: если отключить алгоритм сглаживания потока и сделать топорно, ну, типа как в древних играх — чтоб были видны изломы текстур на поворотах течения, вот конкретно этот момент много дает нагрузки на фпс? Изначальная задумка такова — можно ли этот алгоритм без страданий забабахать на какой-то из старых приставок. :)
                                                    0
                                                    Сглаживание делается на этапе загрузки один раз.
                                                    На старых приставках без шейдеров даже не знаю, как сделать.
                                                    Можно например полигонировать лаву и смещать текстурные координаты у полигонов.
                                                0
                                                Что-то пошло не так? image
                                                  0
                                                  Это как так получилось? То есть, это сразу такое, или вы внесли модификации в код?
                                                    0
                                                    это сразу такое, chrome 62.0.3202.75, centos 7, карточка от nvidia, на других страницах артефактов не наблюдалось, ошибок в лог не сыпалось, по сети все, что должно было — пришло; спасибо за статью, кстати! всегда хотелось почитать что-нибудь о шейдерах на практике, потому что большая часть туторилов заканчивается словами «вот мы и научились заливать квадрат градиентом, а все остальное делается по аналогии» — а ваша статья очень все доступно объясняет!

                                                Only users with full accounts can post comments. Log in, please.