Gamedev
Timurio
2991

Вывод внутриигровых сообщений с помощью Particle System

Реализовываем показ урона, нехватки здоровья и другой важной для игрока информации на примере нашей игры The Unliving

В закладки
Аудио
​Сообщение о величине урона

Задача

При разработке игры, мы поставили перед собой задачу по отображению различных сообщений, таких, как нанесенный урон, нехватка здоровья или энергии, величина награды, количество восстановленных очков здоровья и т.д., с помощью Particle System. Это было решено сделать для того, чтобы получить больше возможностей для кастомизации эффектов появления и дальнейшего поведения таких сообщений, что проблематично при использовании стандартных элементов UI-системы Unity.

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

​Текстовое сообщение о нехватке здоровья

Алгоритм решения

С помощью шейдера отображаем заранее подготовленную текстуру, используя правильные UV-координаты. Информацию с UV-координатами передаем двумя потоками (vertex streams) в ParticleSystem с помощью ParticleSystem.SetCustomParticleData в виде списка Vector4.

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

Пошаговая реализация

Создание Vector4 для передачи в Vertex Stream

Для описания набора символов будем использовать структуру SymbolsTextureData.

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

[Serializable] public struct SymbolsTextureData { //Ссылка на атлас шрифта public Texture texture; //Массив набора символов по порядку, начиная с левого-верхнего угла public char[] chars; //Dictionary с координатами каждого символа - номер строки и столбца private Dictionary<char, Vector2> charsDict; public void Initialize() { charsDict = new Dictionary<char, Vector2>(); for (int i = 0; i < chars.Length; i++) { var c = char.ToLowerInvariant(chars[i]); if (charsDict.ContainsKey(c)) continue; //Расчет координат символа, преобразуем порядковый номер символа //в номер строки и столбца, зная, что длина строки равна 10. var uv = new Vector2(i % 10, 9 - i / 10); charsDict.Add(c, uv); } } public Vector2 GetTextureCoordinates(char c) { c = char.ToLowerInvariant(c); if (charsDict == null) Initialize(); if (charsDict.TryGetValue(c, out Vector2 texCoord)) return texCoord; return Vector2.zero; } }

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

[RequireComponent(typeof(ParticleSystem))] public class TextRendererParticleSystem : MonoBehaviour { private ParticleSystemRenderer particleSystemRenderer; private new ParticleSystem particleSystem; public void SpawnParticle(Vector3 position, string message, Color color, float? startSize = null) { //Тело метода } }

Particle System в Unity позволяет передать кастомные данные в виде 2х потоков Vector4.

Мы намеренно добавили лишний поток с UV2, чтобы избежать сдвига по координатам потоков. Если этого не сделать, то координатам X и Y Custom1-вектора в C # будут соответствовать Z и W TEXCOORD0 шейдера. И соответственно, Custom1.z = TEXCOORD1.x, Custom1.w = TEXCOORD1.y. Что доставит много неудобств в дальнейшем.

Как было описано ранее, для передачи длины сообщения и UV-координат символов мы будем использовать два Vector4. Так как Vector4 содержит 4 элемента типа float, то по умолчанию мы можем упаковать в него 4 * 4 = 16 байт данных. Т.к. наше сообщение будет содержать только длину сообщения (двузначное число) и координаты символов (двузначное число для каждого символа), то диапазон типа byte (0-255) для нас избыточен. В то время как использование десятичных разрядов подойдет отлично.

Точность float составляет 6-9 символов, значит мы смело можем использовать 6 разрядов каждой координаты Vector4 и не переживать за целостность и точность данных. На самом деле, мы пробовали паковать 7, 8 и 9 символов, но точности float не хватает.

Получается, что в каждый float, используя десятичные разряды, мы упакуем целых 6 цифр, в отличии от стандартного варианта с четырьмя байтами. Итого, один Vector4 будет содержать 24 однозначных числа.

Мы можем передать в потоке 2 вектора, поэтому будем использовать оба для передачи сообщения длиной до 23 символов:

Custom1.xyzw — первые 12 символов сообщения.

Custom2.xyzw — еще 11 символов сообщения + длина сообщения (последние 2 символа).

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

В коде упаковка строки в два Vector4 будет выглядеть следующим образом:

//Функция упаковки массива Vector2 с координатами символов во float public float PackFloat(Vector2[] vecs) { if (vecs == null || vecs.Length == 0) return 0; //Поразрядно добавляем значения координат векторов в float var result = vecs[0].y * 10000 + vecs[0].x * 100000; if (vecs.Length > 1) result += vecs[1].y * 100 + vecs[1].x * 1000; if (vecs.Length > 2) result += vecs[2].y + vecs[2].x * 10; return result; } //Функция создания Vector4 для потока с CustomData private Vector4 CreateCustomData(Vector2[] texCoords, int offset = 0) { var data = Vector4.zero; for (int i = 0; i < 4; i++) { var vecs = new Vector2[3]; for (int j = 0; j < 3; j++) { var ind = i * 3 + j + offset; if (texCoords.Length > ind) { vecs[j] = texCoords[ind]; } else { data[i] = PackFloat(vecs); i = 5; break; } } if (i < 4) data[i] = PackFloat(vecs); } return data; } //Дополним тело метода спауна частицы public void SpawnParticle(Vector3 position, string message, Color color, float? startSize = null) { var texCords = new Vector2[24]; //массив из 24 элемент - 23 символа + длина сообщения var messageLenght = Mathf.Min(23, message.Length); texCords[texCords.Length - 1] = new Vector2(0, messageLenght); for (int i = 0; i < texCords.Length; i++) { if (i >= messageLenght) break; //Вызываем метод GetTextureCoordinates() из SymbolsTextureData для получения позиции символа texCords[i] = textureData.GetTextureCoordinates(message[i]); } var custom1Data = CreateCustomData(texCords); var custom2Data = CreateCustomData(texCords, 12); }

Вектора с CustomData готовы. Пришло время вручную заспаунить новую частицу с нужными параметрами.

Спаун частицы

Первое, что мы должны сделать, убедиться, что CustomData потоки активированы в настройках Renderer системы частиц:

//Кэшируем ссылку на ParticleSystem if (particleSystem == null) particleSystem = GetComponent<ParticleSystem>(); if (particleSystemRenderer == null) { //Если ссылка на ParticleSystemRenderer, кэшируем и убеждаемся в наличии нужных потоков particleSystemRenderer = particleSystem.GetComponent<ParticleSystemRenderer>(); var streams = new List<ParticleSystemVertexStream>(); particleSystemRenderer.GetActiveVertexStreams(streams); //Добавляем лишний поток Vector2(UV2, SizeXY, etc.), чтобы координаты в скрипте соответствовали координатам в шейдере if (!streams.Contains(ParticleSystemVertexStream.UV2)) streams.Add(ParticleSystemVertexStream.UV2); if (!streams.Contains(ParticleSystemVertexStream.Custom1XYZW)) streams.Add(ParticleSystemVertexStream.Custom1XYZW); if (!streams.Contains(ParticleSystemVertexStream.Custom2XYZW)) streams.Add(ParticleSystemVertexStream.Custom2XYZW); particleSystemRenderer.SetActiveVertexStreams(streams); }

Для создания частицы воспользуемся методом Emit() класса ParticleSystem.

//Инициализируем параметры эммишена //Цвет и позицию получаем из параметров метода //Устанавливаем startSize3D по X, чтобы символы не растягивались и не сжимались //при изменении длины сообщения var emitParams = new ParticleSystem.EmitParams { startColor = color, position = position, applyShapeToPosition = true, startSize3D = new Vector3(messageLenght, 1, 1) }; //Если мы хотим создавать частицы разного размера, то в параметрах SpawnParticle неоходимо //передать нужное значение startSize if (startSize.HasValue) emitParams.startSize3D *= startSize.Value * particleSystem.main.startSizeMultiplier; //Непосредственно спаун частицы particleSystem.Emit(emitParams, 1); //Передаем кастомные данные в нужные потоки var customData = new List<Vector4>(); //Получаем поток ParticleSystemCustomData.Custom1 из ParticleSystem particleSystem.GetCustomParticleData(customData, ParticleSystemCustomData.Custom1); //Меняем данные последнего элемент, т.е. той частицы, которую мы только что создали customData[customData.Count - 1] = custom1Data; //Возвращаем данные в ParticleSystem particleSystem.SetCustomParticleData(customData, ParticleSystemCustomData.Custom1); //Аналогично для ParticleSystemCustomData.Custom2 particleSystem.GetCustomParticleData(customData, ParticleSystemCustomData.Custom2); customData[customData.Count - 1] = custom2Data; particleSystem.SetCustomParticleData(customData, ParticleSystemCustomData.Custom2);

Добавим оба блока в метод SpawnParticle() и C # часть готова: сообщение упаковано и передано GPU в виде двух Vector4 в Vertex Stream. Осталось самое интересное — принять эти данные и правильно отобразить.

Код шейдера

Shader "Custom/TextParticles" { Properties { _MainTex ("Texture", 2D) = "white" {} //Количество строк и столбцов в теории может быть меньше 10, но никак не больше _Cols ("Columns Count", Int) = 10 _Rows ("Rows Count", Int) = 10 } SubShader { Tags { "RenderType"="Opaque" "PreviewType"="Plane" "Queue" = "Transparent+1"} LOD 100 ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; fixed4 color : COLOR; float4 uv : TEXCOORD0; //Те самые вектора с customData float4 customData1 : TEXCOORD1; float4 customData2 : TEXCOORD2; }; struct v2f { float4 vertex : SV_POSITION; fixed4 color : COLOR; float4 uv : TEXCOORD0; float4 customData1 : TEXCOORD1; float4 customData2 : TEXCOORD2; }; uniform sampler2D _MainTex; uniform uint _Cols; uniform uint _Rows; v2f vert (appdata v) { v2f o; //Почему длина сообщения передается именно в последних разрядах w-координаты вектора? //Так проще всего получить эту длину внутри шейдера. //Достаточно получить остаток от деления на 100. float textLength = ceil(fmod(v.customData2.w, 100)); o.vertex = UnityObjectToClipPos(v.vertex); //Получаем размер UV текстуры, исходя из кол-ва строк и столбцов o.uv.xy = v.uv.xy * fixed2(textLength / _Cols, 1.0 / _Rows); o.uv.zw = v.uv.zw; o.color = v.color; o.customData1 = floor(v.customData1); o.customData2 = floor(v.customData2); return o; } fixed4 frag (v2f v) : SV_Target { fixed2 uv = v.uv.xy; //Индекс символа в сообщении uint ind = floor(uv.x * _Cols); uint x = 0; uint y = 0; //Индекс координаты вектора, содержащий этот элемент //0-3 - customData1 //4-7 - customData2 uint dataInd = ind / 3; //Получаем значение всех 6 разрядов упакованных в нужный float uint sum = dataInd < 4 ? v.customData1[dataInd] : v.customData2[dataInd - 4]; //Непосредственно распаковка float и получение строки и столбца символа for(int i = 0; i < 3; ++i) { if (dataInd > 3 & i == 3) break; //округляем до большего, иначе получим 10^2 = 99 и т.д. uint val = ceil(pow(10, 5 - i * 2)); x = sum / val; sum -= x * val; val = ceil(pow(10, 4 - i * 2)); y = sum / val; sum -= floor(y * val); if (dataInd * 3 + i == ind) i = 3; } float cols = 1.0 / _Cols; float rows = 1.0 / _Rows; //Сдвигаем UV-координаты, используя кол-во строк, столбцов, индекс //и номер строки и столбца элемента uv.x += x * cols - ind * rows; uv.y += y * rows; return tex2D(_MainTex, uv.xy) * v.color; } ENDCG } } }

Редактор Unity

Создаем материал и назначаем ему наш шейдер. На сцене создаем объект с компонентом ParticleSystem, назначаем созданный материал. Затем настраиваем поведение частиц и отключаем параметр Play On Awake. Из любого класса вызываем метод RendererParticleSystem.SpawnParticle() или используем дебажный метод.

[ContextMenu("TestText")] public void TestText() { SpawnParticle(transform.position, "Hello world!", Color.red); }

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

​Система вывода сообщений в действии

Вот и все. Вывод сообщений с помощью Particle System готов! Надеемся, это решение принесет пользу разработчикам игр на Unity.

Следить за разработкой нашей игры можно в ВК и в Стиме.

Материал опубликован пользователем.
Нажмите кнопку «Написать», чтобы поделиться мнением или рассказать о своём проекте.

Написать
{ "author_name": "Timurio", "author_type": "self", "tags": ["pragma","include"], "comments": 32, "likes": 135, "favorites": 288, "is_advertisement": false, "subsite_label": "gamedev", "id": 98780, "is_wide": false, "is_ugc": true, "date": "Fri, 31 Jan 2020 17:26:04 +0300", "is_special": false }
0
{ "id": 98780, "author_id": 207143, "diff_limit": 1000, "urls": {"diff":"\/comments\/98780\/get","add":"\/comments\/98780\/add","edit":"\/comments\/edit","remove":"\/admin\/comments\/remove","pin":"\/admin\/comments\/pin","get4edit":"\/comments\/get4edit","complain":"\/comments\/complain","load_more":"\/comments\/loading\/98780"}, "attach_limit": 2, "max_comment_text_length": 5000, "subsite_id": 64954, "last_count_and_date": null }
32 комментария
Популярные
По порядку
Написать комментарий...
14

Знатно заморочились, приятно видеть такое на DTF. Но я пожалуй продолжу делать подобные вещи на UI, это проще и выглядит как-то гибче всё же.

Ответить
10

А если попробовать эти надписи локализовать еще, ух!

Ответить
3

Думаю, действительно, основное неудобство в этом способе, заключается в локализации. А если еще и на восточные языки, ух! 

Ответить
7

Отличная статья уровня хабра в техническом плане)Спасибо

Ответить
6

Уровень современного хабра - это "как я выгорел в 30". Так что лучше.

Ответить
0

Именно поэтому я и уточнил, что в техническом плане)Технические статьи на хабре всё таки есть, и очень даже неплохие порой)

Ответить
1

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

Ответить
0

Ну вот не надо)

Ответить
2

Она кстати сперва там появилась)

Ответить
2

Учитывая, что уровень нынешнего хабра это двадцатая за полгода статья на тему "посмотрите на список 10 новых возможностей JS (за 2017 год)". Весьма тонко.

Ответить
0

Не без этого, но интересные технические статьи там всё же попадаются заметно чаще чем где либо ещё)

Ответить
2

она на хабре и была изначально

Ответить
6

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

Ответить
3

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

Ответить
3

Это так дико, что не знаешь плакать или смеяться. Но понравилось. Все-таки существование игр невозможно без хаков :D

Ответить
3

А игра выглядит довольно интересно. Давно ждал рогалик в подобном сэттинге. Определённо the unliving входит в мой топ 10 ожидаемых игр этого года)

Ответить
1

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

Ну это, конечно же, по моему скромному мнению.

Ответить
3

в этой игре какраз таки каждая тычка походу будет важна)

Ответить
2

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

Ответить
1

Я бы не стал показывать урон в тех динамических играх, в которых он не столь важен для игрока.
Динамичные игры без вывода урона:
Warcraft, Starcraft, Total war, Overlord, Age of Empires

Ответить
0

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

Ответить
1

В League Of Legends индикация полученного урона и опыта показывается над персонажем, криты выделяются заметнее. А это достаточно динамичная игра

Ответить
0

В мобах это очень важный элемент. Он даёт много нужной информации.

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

Ответить
0

Чувак, тут статья про то как делали а не про то надо в игре или нет)

Ответить
0

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

А по поводу частиц мне нечего сказать. Довольно обычная штука. Надеюсь это облегчило им разработку.

Ответить
2

как вам и неаписали на хабре, крайне сомнительно, что вы что-то подобным образом сэкономили

а если ещё посчитать ресурсы, потраченные на RnD и потенциальный геморрой с поддержкой..

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

Ответить
1

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

Ответить
1

хорошее дело, уважуха

Ответить
0

Зачем статья-то? Всё же очевидно: рисуешь текст методом нарисовать текст в координатах. При необходимости сохраняешь в массив (слой) текста. В любом движке и на любом языке

Ответить
1

Это что за слои текста в Юнити, нарисованные методом в координатах?

Ответить
0

А насколько такой метод тяжелее вывода с помощью TextMeshPro? 

Ответить
0

Ребят, безусловно крутая вещь, как разработчику было интересно почитать, хоть и работаю на UE4. Но зачем? На последней гифке видно что в игре есть бои с большим количеством урона, взрываются бочки, союзные существа которые тоже наносят и получают урон. Получается каша из цифр на экране. В 3 диабле вроде тоже есть отображение урона, но там оно сделано так чтобы не напрягать, советую присмотреться)

Ответить

Прямой эфир

[ { "id": 1, "label": "100%×150_Branding_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox_method": "createAdaptive", "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfl" } } }, { "id": 2, "label": "1200х400", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfn" } } }, { "id": 3, "label": "240х200 _ТГБ_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fizc" } } }, { "id": 4, "label": "Article Branding", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "p1": "cfovz", "p2": "glug" } } }, { "id": 5, "label": "300x500_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfk" } } }, { "id": 6, "label": "1180х250_Interpool_баннер над комментариями_Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "ffyh" } } }, { "id": 7, "label": "Article Footer 100%_desktop_mobile", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjxb" } } }, { "id": 8, "label": "Fullscreen Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjoh" } } }, { "id": 9, "label": "Fullscreen Mobile", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjog" } } }, { "id": 10, "disable": true, "label": "Native Partner Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyb" } } }, { "id": 11, "disable": true, "label": "Native Partner Mobile", "provider": "adfox", "adaptive": [ "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyc" } } }, { "id": 12, "label": "Кнопка в шапке", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fdhx" } } }, { "id": 13, "label": "DM InPage Video PartnerCode", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox_method": "createAdaptive", "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "flvn" } } }, { "id": 14, "label": "Yandex context video banner", "provider": "yandex", "yandex": { "block_id": "VI-250597-0", "render_to": "inpage_VI-250597-0-1134314964", "adfox_url": "//ads.adfox.ru/228129/getCode?pp=h&ps=clmf&p2=fpjw&puid1=&puid2=&puid3=&puid4=&puid8=&puid9=&puid10=&puid21=&puid22=&puid31=&puid32=&puid33=&fmt=1&dl={REFERER}&pr=" } }, { "id": 15, "label": "Баннер в ленте на главной", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "p1": "byudo", "p2": "ftjf" } } }, { "id": 16, "label": "Кнопка в шапке мобайл", "provider": "adfox", "adaptive": [ "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "p1": "chvjx", "p2": "ftwx" } } }, { "id": 17, "label": "Stratum Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvb" } } }, { "id": 18, "label": "Stratum Mobile", "provider": "adfox", "adaptive": [ "tablet", "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvc" } } }, { "id": 20, "label": "Кнопка в сайдбаре", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "p1": "chfbl", "p2": "gnwc" } } } ]
{ "jsPath": "/static/build/dtf.ru/specials/DeliveryCheats/js/all.min.js?v=05.02.2020", "cssPath": "/static/build/dtf.ru/specials/DeliveryCheats/styles/all.min.css?v=05.02.2020", "fontsPath": "https://fonts.googleapis.com/css?family=Roboto+Mono:400,700,700i&subset=cyrillic" }