Как мы отлаживаем в браузере самописный ECS на игровом сервере

ajcbwp0paiiumhk6qcv79bzrk9s.jpeg

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

В предыдущих статьях подробно рассказывали (список сразу под катом) о том, как устроена ECS в нашем новом проекте в разработке и как выбирали готовые решения. Одним из таких решений был Entitas. Он не устроил нас в первую очередь из-за отсутствия хранения истории состояний, но очень понравился тем, что в Unity визуально и наглядно можно посмотреть всю статистику по использованию сущностей, компонентов, систему пулов, производительность каждой системы и т.д.

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

Обещанный список всех вышедших статей по проекту:

  1. «Как мы замахнулись на мобильный fast paced шутер: технологии и подходы».
  2. «Как и почему мы написали свой ECS».
  3. «Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте».
  4. «Клиент-серверное взаимодействие в новом мобильном PvP-шутере и устройство игрового сервера: проблемы и решения».


Теперь к сути этой статьи. Для начала мы написали маленький веб-сервер, который выдавал наружу некоторое API. Сам сервер просто открывает сокет порт и слушает http-запросы на этом порту.

Обработка идёт довольно стандартным способом
private bool HandleHttp(Socket socket)
        {
            var buf = new byte[8192];
            var bufLen = 0;
            var recvBuf = new byte[8192];
            var bodyStart = -1;

            while(bodyStart == -1)
            {
                var recvLen = socket.Receive(recvBuf);
                if(recvLen == 0)
                {
                    return true;
                }

                Buffer.BlockCopy(recvBuf, 0, buf, bufLen, recvLen);
                bufLen += recvLen;

                bodyStart = FindBodyStart(buf, bufLen);
            }

            var headers = Encoding.UTF8.GetString(buf, 0, bodyStart - 2).Replace("\r", "").Split('\n');

            var main = headers[0].Split(' ');
            var reqMethod = ParseRequestMethod(main[0]);
            if (reqMethod == RequestMethod.Invalid)
            {
                SendResponse(400, socket);
                return true;
            }

            // receive POST body
            var body = string.Empty;
            if(reqMethod == RequestMethod.Post)
            {
                body = ReceiveBody(buf, bufLen, headers, bodyStart, socket);
                if(body == null)
                {
                    return true;
                }
            }

            var path = main[1];
            if(path == "/")
            {
                path = "/index.html";
            }

            // try to serve by a file
            if(File.Exists(_docRoot + path))
            {
                var content = File.ReadAllBytes(_docRoot + path);
                if (reqMethod == RequestMethod.Head)
                {
                    content = null;
                }

                SendResponse(200, socket, content, GuessMime(path));

                return true;
            }

            // try to serve by a handle
            foreach(var handler in _handlers)
            {
                if(handler.Match(reqMethod, path))
                {
                    if (handler.Async)
                    {
                        _jobs.Enqueue(() =>
                        {
                            RunHandler(socket, path, body, handler);
                            socket.Shutdown(SocketShutdown.Both);
                            socket.Close();
                        });
                        return false;
                    }
                    else
                    {
                        RunHandler(socket, path, body, handler);
                        return true;
                    }
                }
            }

            // nothing found :-(
            var msg = "File not found " + path
                      + "\ndoc root " + _docRoot
                      + "\ncurrent dir " + Directory.GetCurrentDirectory();
            SendResponse(404, socket, Encoding.UTF8.GetBytes(msg));
            return true;
        }


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

Для быстрой разработки просто дописали к нему метод, выдающий список матчей на своих портах в формате json
public string ListMatches(string method, string path)
        {
            var sb = new StringBuilder();
            sb.Append("[\n");

            foreach (var match in _matches.Values)
            {
                sb.Append("{id:\"" + match.GameId + "\""
                          + ", www:" + match.Tool.Port
                          + "},\n"
                );
            }

            sb.Append("]");
            return sb.ToString();
        }


bdjiyau4_ltyitc6qzvz4ndt18g.png

Кликая на ссылку с матчем, переходим в меню управления. Вот тут становится намного интереснее.

Каждый матч на Debug-сборке сервера выдаёт наружу полные данные о себе. Включая GameState, о котором мы писали. Напомню, что это по сути состояние всего матча, включая статические и динамические данные. Имея эти данные, мы можем отображать различную информацию о матче в html. Мы также можем напрямую менять эти данные, но об этом будет чуть позже.

Первый линк ведет на стандартный лог матча:

3t_yosvjeysn_9nsrxa_x4vf6xi.png

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

Второй линк GameViewer ведет на реальное визуальное представление матча:

l81jixqzc7yrt17hplf-u2skmxc.png

Генератор, который создаёт нам код ECS для упаковки данных, также создает дополнительный код для представления данных в json. Это позволяет довольно просто вычитывать структуру матча из json и отдавать на рендеринг с помощью библиотеки three.js в WebGL.

Структура данных выглядит примерно так
{
    enums: {
        "HostilityLayer": {
            1: "PlayerTeam1",
            2: "PlayerTeam2",
            3: "NeutralShootable",
        }
    },
    components: {
        Transform: {
            name: 'Transform',
            fields: {
                Angle: {type: "float"},
                Position: {type: "Vector2"},
            },
        },
        TransformExact: {
            name: 'TransformExact',
            fields: {
                Angle: {type: "float"},
                Position: {type: "Vector2"},
            }
		}
    },
    tables: {
        Transform: {
            name: 'Transform',
            component: 'Transform',
        },
        TransformExact: {
            name: 'TransformExact',
            component: 'TransformExact',
            hint: "Copy of Transform for these entities that need full precision when sent over network",
        }
    }
}


А сам цикл рендеринга динамических тел (в нашем случае — игроков) так
var rulebook = {};
var worldstate = {};
var physics = {};

var update_dynamic_physics;

var camera, scene, renderer;
var controls;
function init3D () {
	camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 1000);
	camera.up.set(0,0,1);

	scene = new THREE.Scene();

	scene.add( new THREE.AmbientLight( 0x404040 ) );

	var light = new THREE.DirectionalLight( 0xFFFFFF, 1 );
	light.position.set(-11, -23, 45);
	scene.add( light );

	renderer = new THREE.WebGLRenderer();
	renderer.setPixelRatio( window.devicePixelRatio );
	renderer.setSize( window.innerWidth, window.innerHeight );
	document.body.appendChild( renderer.domElement );

	controls = new THREE.OrbitControls( camera, renderer.domElement );

	var cam = localStorage.getObject('gv_camera');
	if (cam) {
		camera.matrix.fromArray(cam.matrix);
		camera.matrix.decompose(camera.position, camera.quaternion, camera.scale);

		controls.target.set(cam.target.x, cam.target.y, cam.target.z);
	} else {
		camera.position.x = 40;
		camera.position.y = 40;
		camera.position.z = 50;
		controls.target.set(0, 0, 0);
	}

	window.addEventListener( 'resize', onWindowResize, false );
}

init3D();

function handle_recv_dynamic (r)
{
	eval('physics = ' + r + ';');

	update_dynamic_physics();

	sleep(10)
		.then(() => ajax("GET", "/physics/dynamic/"))
		.then(handle_recv_dynamic);
}

(function init_dynamic_physics () {
	var colour = 0x4B5440;
	var material = new THREE.MeshLambertMaterial({color: colour, flatShading: true});

	var meshes = {};

	update_dynamic_physics = function () {
		var i, p, mesh;
		var to_del = {};
		for (i in meshes) to_del[i] = true;

		for (i in physics) {
			p = physics[i];
			mesh = meshes[p.id];
			if (!mesh) {
				mesh = create_shapes(worldstate, 'Dynamic', p, material, layers.dynamic_collider);
				meshes[p.id] = mesh;
			}
			mesh.position.x = p.pos[0];
			mesh.position.y = p.pos[1];
			delete to_del[p.id];
		}

		for (i in to_del) {
			mesh = meshes[i];
			scene.remove(mesh);
			delete meshes[i];
		}
	}
})();


Почти каждая сущность, которая обладает логикой перемещения в нашем физическом мире имеет компонент Transform. Чтобы увидеть список всех компонентов, перейдем по ссылке WorldState Table Editor.

lrdamwju2zyx5-o-beth_7azahe.png

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

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

function handle_edit (id, table_name, field_name, value)
{
	var data = table_name + "\n" + field_name + "\n" + id + "\n" + value;
	ajax("POST", tableset_name + "/edit/", data);
}


Со стороны игрового сервера происходит подписка на нужный URL, уникальный для таблицы, благодаря сгенерированному коду:

public static void RegisterEditorHandlers(Action> addHandler, string path, Func ts)
        {
            addHandler(path + "/data/", (p, b) => EditorPackJson(ts()));
            addHandler(path + "/edit/", (p, b) => EditorUpdate(ts(), b));
            addHandler(path + "/ins/", (p, b) => EditorInsert(ts(), b));
            addHandler(path + "/del/", (p, b) => EditorDelete(ts(), b));
            addHandler(path + "/create/", (p, b) => EditorCreateEntity(ts(), b));
        }


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

z4wahdgsfjz0gmxdcsa4uzdyzcu.png

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

*****

Описанные возможности нашего редактора матчей на игровом сервере позволяют эффективно отлаживать сетевые и игровые моменты и следить за матчами в реальном времени. Но на проде эти функции отключены, так как создают существенную нагрузку на сервер и сборщик мусора. Стоит отметить, что система была написана за очень короткое время, благодаря уже существующему генератору кода ECS. Да, сервер написан не по всем правилам современных web-стандартов, но он очень помогает нам в повседневной работе и отладке системы. Еще он постепенно эволюционирует, обрастая новыми возможностями. Когда их наберется достаточно — мы еще вернемся к этой теме.

© Habrahabr.ru