company_banner

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



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

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

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



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

    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();
            }




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

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

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



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

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



    Генератор, который создаёт нам код 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.



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

    Изменение данных в реальном матче при редактировании 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<string, Func<string, string, string>> addHandler, string path, Func<TableSet> 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));
            }

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



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

    *****

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

    Pixonic

    294,00

    Международная компания по разработке мобильных игр

    Поделиться публикацией
    Комментарии 4
      0
      Вы много всего писали, но я пока не увидел явного преимущества ECS модели над «просто пишем классы». В чем таки основная фишка?
        +3
        Мы уже писали об этом в статье про выбор ECS вот тут habr.com/company/pixonic/blog/413729.

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

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

        Композиционный подход вместо наследования, к которому всё больше склоняется ООП сообщество, тут реализован из коробки.

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

        Недавно наша компания проводила Pixonic DevGamm Talks, на котором был доклад, также посвященный этой теме. Рекомендую ознакомиться с выступлением моего коллеги на нём youtu.be/GFb84n9gz94?t=2h29m39s
        0
        Обработка идёт довольно стандартным способом

        Поперхнулся утренним кофе. Писать в 2018-ом году работу с HTTP врукопашную на голых сокетах — это так стандартно, угу.

          +2

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

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

        Самое читаемое