Хабрахабр

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

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

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

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

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

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

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

Обработка идёт довольно стандартным способом

private bool HandleHttp(Socket socket) 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(); }

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

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

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

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

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

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

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

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

*****

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть