[Разработка мобильных приложений, Разработка игр, Unity, Дизайн игр] Еще пять инструментов против читеров на мобильном проекте с DAU 1 млн пользователей
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Когда-то нам пришлось полностью переработать защиту популярного PvP-шутера. Результатом стал ряд инструментов, которые мы готовили и зарелизили одновременно, чтобы не дать читерам возможность постепенно отслеживать апдейты. Про «первые» пять решений — обфускацию, хранение данных, миграцию прогресса и систему бана — шла речь в этой статье. Сегодня расскажу про остальные, а именно:
- Защита от измененных версий.
- Photon Plugin.
- Серверная валидация инаппов.
- Защита от взлома оперативной памяти.
- Собственная аналитика.
И немного про то, почему так важен был одновременный релиз всех решений.Чтобы не возникло путаницы, продолжим нумерации из прошлой статьи, где уже были опубликованы пять пунктов.Решение №6. Защита от измененных версийВ дополнительные места мы расставили защиту от переподписывания версий, лаунчеров (на Android) и твиков (на iOS), спрятав уже в обфусцированном коде.Проверка на твики (iOS)На устройствах с Jailbreak с помощью Cydia пользователи могут устанавливать твики, которые способны внедрять свой код в системные и установленные приложения. Каждый твик имеет информацию (файл *.plist), с какими бандлами они должны работать.Механизм детекта осуществляется проверкой этих файлов в папке /Library/MobileSubstrate/DynamicLibraries/ (на наличие внутри нашего бандла).Большинство твиков, скрывающих Jailbreak, способно спрятать эту папку от просмотра, поэтому есть способ просматривать ее через созданный нами симлинк.
string finalPath = string.Empty;
string substratePath = "/Library/MobileSubstrate/DynamicLibraries/";
bool bySymlink = false;
if (!Directory.Exists(substratePath)) //Если папки не существует (скрыт твиком xCon), то пытаемся получить доступ к файлам через созданный нами симлинк
{
string symlinkPath = CreateSymlimk(substratePath);
if (!string.IsNullOrEmpty(symlinkPath))
{
bySymlink = true;
finalPath = symlinkPath;
}
}
else
{
finalPath = substratePath;
}
bool detected = false;
string detectedFile = string.Empty;
try
{
if (!string.IsNullOrEmpty(finalPath))
{
string[] plistFiles = Directory.GetFiles(finalPath, "*.plist"));
foreach (var plistFile in plistFiles)
{
if (File.Exists(plistFile))
{
StreamReader file = File.OpenText(plistFile);
string con = file.ReadToEnd();
string bundle = "app_bundle";
if (con.Contains(bundle))
{
detectedFile = plistFile;
detected = true;
break;
}
}
}
}
}
catch (Exception ex)
{
Debug.LogError(ex.ToString());
}
Но также есть твики, которые запрещают создание симлинков по проверяемому нами пути (KernBypass, A-Bypass). При их наличии мы не можем осуществить проверку, поэтому считаем это за возможное читерство.Общего механизма детекта таких твиков нет, тут нужен индивидуальный подход.Детект KernBypass (который был активен в отношении нашего бандла):
if (File.Exists("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist")
{
StreamReader file = File.OpenText("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist");
string con = file.ReadToEnd();
if (con.Contains("app_bundle")
{
//detected
}
}
Определение запуска через лаунчер (Android)Запуск приложения через лаунчер — это, по сути, запуск вашего приложения внутри другого приложения (по типу Parallel Space). Некоторые реализации взломов используют такой механизм для внедрения своего кода, и для этого на устройстве не требуется root-доступ. Обычно они имитируют всю среду: выделяют папку под файлы приложения, возвращают фейковый Application Info и так далее.При таком запуске у нас все равно сохраняется доступ ко всем файлам, к которым имеет доступ сам лаунчер. Самый простой способ детекта — это проверить доступ к материнской папке от нашего приложения (dataDir в applicationInfo) через функцию access (в нативном коде). В обычном случае операционная система не предоставит доступ, а в случае лаунчера это будет папка, которая все еще находится внутри Persistent Data приложения.Код для плагина на C:
JavaVM* java_vm;
jint JNI_OnLoad(JavaVM* vm, void* reserved) {
java_vm = vm;
return JNI_VERSION_1_6;
}
int CheckParentDirectoryAccess()
{
JNIEnv* jni_env = 0;
(*java_vm)->AttachCurrentThread(java_vm, &jni_env, NULL);
jclass uClass = (*jni_env)->FindClass(jni_env, "com/unity3d/player/UnityPlayer");
jfieldID activityID = (*jni_env)->GetStaticFieldID(jni_env, uClass, "currentActivity", "Landroid/app/Activity;");
jobject obj_activity = (*jni_env)->GetStaticObjectField(jni_env, uClass, activityID);
jclass classActivity = (*jni_env)->FindClass(jni_env, "android/app/Activity");
jmethodID mID_func = (*jni_env)->GetMethodID(jni_env, classActivity,
"getPackageManager", "()Landroid/content/pm/PackageManager;");
jobject pm = (*jni_env)->CallObjectMethod(jni_env, obj_activity, mID_func);
jmethodID pmmID = (*jni_env)->GetMethodID(jni_env, classActivity,
"getPackageName", "()Ljava/lang/String;");
jstring pName = (*jni_env)->CallObjectMethod(jni_env, obj_activity, pmmID);
jclass pm_class = (*jni_env)->GetObjectClass(jni_env, pm);
jmethodID mID_ai = (*jni_env)->GetMethodID(jni_env, pm_class, "getApplicationInfo","(Ljava/lang/String;I)Landroid/content/pm/ApplicationInfo;");
jobject ai = (*jni_env)->CallObjectMethod(jni_env, pm, mID_ai, pName, 128);
jclass ai_class = (*jni_env)->GetObjectClass(jni_env, ai);
jfieldID nfieldID = (*jni_env)->GetFieldID(jni_env, ai_class,"dataDir","Ljava/lang/String;");
jstring nDir = (*jni_env)->GetObjectField(jni_env, ai, nfieldID);
const char *nDirStr = (*jni_env)->GetStringUTFChars(jni_env, nDir, 0);
char parentDir[200];
snprintf(parentDir, sizeof(parentDir), "%s/..", nDirStr);
if (access(parentDir, W_OK) != 0)
{
return 1;
}
else
{
return 0;
}
}
Защита от переподписи apk (Android)При любом вмешательстве внутрь apk-файла пакет необходимо переподписать, иначе система не позволит его установить. Поэтому можно определить модифицированную игру, если хеш подписи не совпадает с нашим. Однако такую системную защиту можно отключить, если на устройстве есть root-доступ, и устанавливать пакеты с невалидной подписью. Поэтому данный пункт не является каким-то особенно важным, но и не будет лишним в общей массе.Получение хеша подписи в С# через обращение в Java-код:
Lazy<byte[]> defaultResult = new Lazy<byte[]>(() => new byte[20]);
if (Application.platform != RuntimePlatform.Android)
return defaultResult.Value;
#if UNITY_ANDROID
var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer");
if (unityPlayer == null)
throw new InvalidOperationException("unityPlayer == null");
var _currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
if (_currentActivity == null)
throw new InvalidOperationException("_currentActivity == null");
var packageManager = _currentActivity.Call<AndroidJavaObject>("getPackageManager");
if (packageManager == null)
throw new InvalidOperationException("getPackageManager() == null");
// http://developer.android.com/reference/android/content/pm/PackageManager.html#GET_SIGNATURES
const int getSignaturesFlag = 64;
var packageInfo = packageManager.Call<AndroidJavaObject>("getPackageInfo", PackageName, getSignaturesFlag);
if (packageInfo == null)
throw new InvalidOperationException("getPackageInfo() == null");
var signatures = packageInfo.Get<AndroidJavaObject[]>("signatures");
if (signatures == null)
throw new InvalidOperationException("signatures() == null");
using (var sha1 = new SHA1Managed())
{
var hashes = signatures.Select(s => s.Call<byte[]>("toByteArray"))
.Where(s => s != null)
.Select<byte[], byte[]>(sha1.ComputeHash);
var result = hashes.FirstOrDefault() ?? defaultResult.Value;
return result;
}
#else
return defaultResult.Value;
#endif
Решение №7. Photon PluginДля организации сетевого взаимодействия между пользователями в игровых комнатах мы используем Photon Cloud, который сам по себе предполагает отсутствие серверной логики и отвечает только за пересылку пакетов между пользователями. А для защиты кора нам нужна была именно серверная логика.Переделывать всё на Photon Server было бы достаточно долгой и сложной задачей, так как в игре уже было много различных режимов, механик и прочего. Поэтому, пообщавшись с ребятами из Photon, мы решили попробовать Photon Plugin.Photon Plugin доступен на тарифе Enterprise Cloud и пишется на С#. Он запускается на серверах Photon и позволяет мониторить пересылаемый между пользователями игровой трафик, добавлять серверную логику, которая может:
- блокировать или добавлять сетевые сообщения;
- контролировать изменения свойств комнат и игроков;
- кикать из комнаты;
- взаимодействовать при помощи http-запросов со сторонними серверами.
Мы переписали сетевое взаимодействие игроков так, чтобы можно было отследить тех, у кого действия выходят за рамки допустимого (например, количество здоровья, урон, скорость перемещения, использование запрещенных предметов и так далее), или код изменен так, что нарушается логика режимов.Про это решение хотелось рассказать подробнее, но тогда бы статья разрослась слишком сильно. Поэтому эту тему оставили для отдельного материала.Решение №8. Серверная валидация иннаповВ нашей игре сотни видов оружия и предметов. Несмотря на баланс и большое влияние личного скилла игроков на результат матча, все инаппы нужно защитить от взлома. Для этого мы ввели валидацию покупок.Валидация на сервере состоит из двух этапов:
- Превалидация. Когда данные по платежу отправляются на сервер соответствующей платформы для проверки валидности.
- Начисление. В случае успешно пройденной валидации купленных позиций.
Сначала сервер получает в качестве входных параметров данные, необходимые для проведения валидации (например, на Android — это id инаппа и токен). Отдельной командой/набором команд происходит начисление купленных позиций. Одна позиция может содержать разнотипные итемы (например, деньги и оружие) и для каждого типа итема на сервере существует отдельная команда начисления.Чтобы логически объединить несколько команд, привязанных к одному действию игрока, на клиенте и на сервере мы ввели понятие снапшота. Это специальная конструкция из последовательности команд, которые одновременно выполняются или не выполняются на сервере.Команда валидации проверяет транзакцию — в случае, если есть данные превалидации, то используются они. В противном случае данные отправляются на сервер валидации для соответствующей платформы.В случае успешного начисления id транзакции сохраняется в соответствующий слот игрока — запись в БД, которая хранит данные по платежным транзакциям данного игрока. Во избежание взлома платежки методом, когда одну валидную транзакцию используют для многократного начисления, в рамках валидации осуществляется проверка на существование данного id транзакции.Кроме того, в завершение начисления отправляется соответствующая статистика на сервер аналитики.Подробнее про интеграцию инаппов и серверную валидацию вместе с кодом расскажем в отдельной статье. Решение №9. Защита от взлома оперативной памяти Локальные данные, которые не защищены валидацией с сервера, можно взломать в памяти (например, с помощью GameGuardian на Android). Механизм взлома заключается в поиске значений в памяти путем отсеивания. Ищется текущее значение, затем оно изменяется в игре, а среди найденных адресов в памяти они отсеиваются по новому значению, пока не будет найден нужный адрес.Для их защиты они «засаливаются» при помощи случайно сгенерированной соли:
internal int Value
{
get { return _salt ^ _saltedValue; }
set { _saltedValue = _salt ^ value; }
}
Когда пользователь не в состоянии изменить искомое значение для отсеивания, он может попытаться заменить все найденные значения на свои. Для их детекта используется простая ловушка. Следующий пример показывает, как можно определить вмешательство в память с числами от 0 до 1000 (заранее храним массив чисел, которые никогда не должны измениться, кроме как после редактирования памяти).
private static int[] refNumbers;
internal static void Start()
{
refNumbers = new int[1000];
for (int i = 0; i < refNumbers.Length; i++)
{
refNumbers[i] = i;
}
}
internal static bool Check()
{
for (int i = 0; i < 1000; i++)
{
if (!refNumbers [i].Equals(i))
return true;
}
}
Решение №10. Собственная аналитикаИзначально мы пользовались платным решением от devtodev и бесплатным от Flurry. Основная проблема была в отсутствии детализации происходящих в игре событий. Мы собирали только агрегированные данные и поверхностные метрики.Но с ростом экспертизы внутри команды стала очевидной необходимость писать собственное решение с нужными именно нам фичами. Основная цель была в повышении продуктовых метрик и повороте компании в сторону Data-Driven подхода. Но в итоге аналитика также стала незаменимым инструментом в борьбе с читерами.Например, раньше система не привязывалась к пользователю, а просто считала ивенты. То есть мы знали, что 500 человек совершили какие-то действия, но кто эти 500 человек, и что они делали до этого — нет. Сейчас можно посмотреть все действия каждого конкретного игрока и, соответственно, отследить подозрительные операции.Все пользовательские ивенты отправляются в одну большую SQL-евскую базу. Там есть как элементарные ивенты (игрок залогинился, сколько раз в день он залогинился и так далее), так и другие. Например, прилетает ивент, что игрок покупает оружие за столько-то монет, а вместо суммы написано 0. Очевидно, что он сделал что-то неправомерное.Большинство выгрузки с подозрительными действиями нарабатываются с опытом. Например, у нас есть скрипт, который показывает, что столько-то людей с конкретными id получили определенное большое количество монет. Но это не всегда читеры — обязательно нужно проверять.Также читеров опознаем по несоответствию значений начисления валют. Аналитик знает, что за покупку инаппа начисляется конкретное количество гемов. У читеров часто это количество бывает 9999 — значит, что-то взломали в памяти. Еще бывают игроки с аномальными киллрейтами. По ним у нас тоже есть специально обученное поле, и когда появляется пользователь, у которого киллрейт 15 или 30, становится понятно, что, скорее всего, это читер.В основном отслеживанием занимается один скрипт, который пачкой прогоняет по детектам и сгружает все в таблицу. Аналитики получают id и видят игроков, которые залогинились утром с огромным количеством голды, в соседнем листе лежат игроки, открывшие 1000 сундуков, в следующем — игроки с тысячей гач и так далее. Затем вариантов несколько.Предполагаемых читеров можно прогнать по флагам, а так как подобные способы читерства задетекчены и пофиксены, то подобные игроки уже могли ранее получить значок читера и попасть в отдельную базу. Если они там есть, то бан прилетает автоматически с помощью скрипта.Если нет детекта, данные передаются разработчикам. Они думают, как читер смог обойти защиту и решают, как закрыть эти дыры, как таких игроков детектить и банить.А про причины перехода на собственную аналитическую систему и внутренние фичи тоже расскажем в отдельном материале.Одновременный релиз всех решенийЧтобы достичь максимального эффекта от всех новых защит, было решено выкатить их одномоментно. Так значительно повышался «входной порог» для тех взломщиков, которые уже были хорошо знакомы с нашим необфусцированным кодом.Кроме того, мы не просто применили обфускацию приложения, но и изменили структуру большинства значимых частей приложения. В корне была изменена работа внутри, и все это свело к минимуму имеющиеся к тому моменту у читеров наработки. А разбираться в новом стало куда гораздо сложнее.Всего на глобальный ввод большинства защит ушло около семи месяцев. Самым масштабным пунктом была реализация системы хранения на наших серверах — именно она определяла запуск в продакшен всех решений из списка. Кроме аналитики, которая развивалась самостоятельно.Также было важно развивать приложение в целом и релизить апдейты для игроков, чтобы не потерять их интерес, пока глобальные решения против читеров складывались отдельно в ожидании своего момента.Сейчас игра надежно защищена и не имеет распространенных методов взлома. Иногда встречаются единичные случаи, но они быстро отслеживаются благодаря введенным инструментам.
===========
Источник:
habr.com
===========
Похожие новости:
- [Разработка под iOS, Разработка мобильных приложений, Xcode, Swift] AppCode 2021.1: улучшения поддержки Swift Package Manager и быстродействия, обновление плагина Kotlin/Native и другое
- [Платежные системы, Разработка мобильных приложений, Разработка под Android, Flutter] История разработки SDK для приема платежей в мобильном приложении на Flutter
- [Разработка мобильных приложений, Dart, Flutter] Моя история реализации офлайн приложения Хабра
- [Разработка мобильных приложений, Amazon Web Services, Microsoft Azure, Google Cloud Platform, Serverless] Молния: предстоящие вебинары про Serverless технологии
- [Программирование, C++, Работа с 3D-графикой, Разработка игр, CGI (графика)] Vulkan. Руководство разработчика. Непрограммируемые стадии конвейера (перевод)
- [Программирование, C#, Unity] Немного о графиках, сплайнах и генерации ландшафта
- [Open source, Разработка игр, Графический дизайн, Дизайн игр, DIY или Сделай сам] О ходе создания игры «Колобок» в апреле
- [Разработка игр, Машинное обучение, Контент-маркетинг, Дизайн игр] Text-to-Video. Visper – новая платформа визуальных персонажей для создания видеоконтента
- [Разработка мобильных приложений, Машинное обучение, Софт, Искусственный интеллект, Flutter] Flitter Your Business With AI Integrated Flutter App Development
- [Программирование, Разработка мобильных приложений, Производство и разработка электроники, Гаджеты, Интернет вещей] Русские программисты не сдаются-2
Теги для поиска: #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_igr (Разработка игр), #_unity, #_dizajn_igr (Дизайн игр), #_unity, #_chitery (читеры), #_shuter (шутер), #_pvp, #_gejmdev (геймдев), #_razrabotka_igr (разработка игр), #_gamedev, #_cheats, #_photon_plugin, #_validatsija (валидация), #_blog_kompanii_lightmap (
Блог компании Lightmap
), #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
), #_razrabotka_igr (
Разработка игр
), #_unity, #_dizajn_igr (
Дизайн игр
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:50
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Когда-то нам пришлось полностью переработать защиту популярного PvP-шутера. Результатом стал ряд инструментов, которые мы готовили и зарелизили одновременно, чтобы не дать читерам возможность постепенно отслеживать апдейты. Про «первые» пять решений — обфускацию, хранение данных, миграцию прогресса и систему бана — шла речь в этой статье. Сегодня расскажу про остальные, а именно:
string finalPath = string.Empty;
string substratePath = "/Library/MobileSubstrate/DynamicLibraries/"; bool bySymlink = false; if (!Directory.Exists(substratePath)) //Если папки не существует (скрыт твиком xCon), то пытаемся получить доступ к файлам через созданный нами симлинк { string symlinkPath = CreateSymlimk(substratePath); if (!string.IsNullOrEmpty(symlinkPath)) { bySymlink = true; finalPath = symlinkPath; } } else { finalPath = substratePath; } bool detected = false; string detectedFile = string.Empty; try { if (!string.IsNullOrEmpty(finalPath)) { string[] plistFiles = Directory.GetFiles(finalPath, "*.plist")); foreach (var plistFile in plistFiles) { if (File.Exists(plistFile)) { StreamReader file = File.OpenText(plistFile); string con = file.ReadToEnd(); string bundle = "app_bundle"; if (con.Contains(bundle)) { detectedFile = plistFile; detected = true; break; } } } } } catch (Exception ex) { Debug.LogError(ex.ToString()); } if (File.Exists("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist")
{ StreamReader file = File.OpenText("/var/mobile/Library/Preferences/jp.akusio.kernbypass.plist"); string con = file.ReadToEnd(); if (con.Contains("app_bundle") { //detected } } JavaVM* java_vm;
jint JNI_OnLoad(JavaVM* vm, void* reserved) { java_vm = vm; return JNI_VERSION_1_6; } int CheckParentDirectoryAccess() { JNIEnv* jni_env = 0; (*java_vm)->AttachCurrentThread(java_vm, &jni_env, NULL); jclass uClass = (*jni_env)->FindClass(jni_env, "com/unity3d/player/UnityPlayer"); jfieldID activityID = (*jni_env)->GetStaticFieldID(jni_env, uClass, "currentActivity", "Landroid/app/Activity;"); jobject obj_activity = (*jni_env)->GetStaticObjectField(jni_env, uClass, activityID); jclass classActivity = (*jni_env)->FindClass(jni_env, "android/app/Activity"); jmethodID mID_func = (*jni_env)->GetMethodID(jni_env, classActivity, "getPackageManager", "()Landroid/content/pm/PackageManager;"); jobject pm = (*jni_env)->CallObjectMethod(jni_env, obj_activity, mID_func); jmethodID pmmID = (*jni_env)->GetMethodID(jni_env, classActivity, "getPackageName", "()Ljava/lang/String;"); jstring pName = (*jni_env)->CallObjectMethod(jni_env, obj_activity, pmmID); jclass pm_class = (*jni_env)->GetObjectClass(jni_env, pm); jmethodID mID_ai = (*jni_env)->GetMethodID(jni_env, pm_class, "getApplicationInfo","(Ljava/lang/String;I)Landroid/content/pm/ApplicationInfo;"); jobject ai = (*jni_env)->CallObjectMethod(jni_env, pm, mID_ai, pName, 128); jclass ai_class = (*jni_env)->GetObjectClass(jni_env, ai); jfieldID nfieldID = (*jni_env)->GetFieldID(jni_env, ai_class,"dataDir","Ljava/lang/String;"); jstring nDir = (*jni_env)->GetObjectField(jni_env, ai, nfieldID); const char *nDirStr = (*jni_env)->GetStringUTFChars(jni_env, nDir, 0); char parentDir[200]; snprintf(parentDir, sizeof(parentDir), "%s/..", nDirStr); if (access(parentDir, W_OK) != 0) { return 1; } else { return 0; } } Lazy<byte[]> defaultResult = new Lazy<byte[]>(() => new byte[20]);
if (Application.platform != RuntimePlatform.Android) return defaultResult.Value; #if UNITY_ANDROID var unityPlayer = new AndroidJavaClass("com.unity3d.player.UnityPlayer"); if (unityPlayer == null) throw new InvalidOperationException("unityPlayer == null"); var _currentActivity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity"); if (_currentActivity == null) throw new InvalidOperationException("_currentActivity == null"); var packageManager = _currentActivity.Call<AndroidJavaObject>("getPackageManager"); if (packageManager == null) throw new InvalidOperationException("getPackageManager() == null"); // http://developer.android.com/reference/android/content/pm/PackageManager.html#GET_SIGNATURES const int getSignaturesFlag = 64; var packageInfo = packageManager.Call<AndroidJavaObject>("getPackageInfo", PackageName, getSignaturesFlag); if (packageInfo == null) throw new InvalidOperationException("getPackageInfo() == null"); var signatures = packageInfo.Get<AndroidJavaObject[]>("signatures"); if (signatures == null) throw new InvalidOperationException("signatures() == null"); using (var sha1 = new SHA1Managed()) { var hashes = signatures.Select(s => s.Call<byte[]>("toByteArray")) .Where(s => s != null) .Select<byte[], byte[]>(sha1.ComputeHash); var result = hashes.FirstOrDefault() ?? defaultResult.Value; return result; } #else return defaultResult.Value; #endif
internal int Value
{ get { return _salt ^ _saltedValue; } set { _saltedValue = _salt ^ value; } } private static int[] refNumbers;
internal static void Start() { refNumbers = new int[1000]; for (int i = 0; i < refNumbers.Length; i++) { refNumbers[i] = i; } } internal static bool Check() { for (int i = 0; i < 1000; i++) { if (!refNumbers [i].Equals(i)) return true; } } =========== Источник: habr.com =========== Похожие новости:
Блог компании Lightmap ), #_razrabotka_mobilnyh_prilozhenij ( Разработка мобильных приложений ), #_razrabotka_igr ( Разработка игр ), #_unity, #_dizajn_igr ( Дизайн игр ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 14:50
Часовой пояс: UTC + 5