TGViewer
Channel Public Channel
SpellSinger - разработчик игр на Unity

SpellSinger - разработчик игр на Unity

@spellsingergames

Привет! Я Денис и я делаю игры на Unity. В этом блоге я делюсь своим опытом в геймдеве и не только.

Навигация в закрепе: https://t.me/spellsingergames/3

Написать в личку: @sinedsem
Subscribers
548
Photos
90
Videos
22
Links
105

Showing posts older than #196 · Back to latest

Older Posts 20 shown
Post #194 989
Добавил инапы в Играх ВКонтакте

Мы наконец-то доделали большое обновление для Бурить-Копать!, в котором в том числе появились внутриигровые покупки.

В Яндекс Игры добавить покупки было довольно легко. Товары заводятся в консоли, без модерации. В SDK присутствуют 4 метода - необходимый минимум для покупок:
1) Получить список продуктов - ysdk.payments.getCatalog()
2) Купить - ysdk.payments.purchase()
3) Списать покупку - ysdk.payments.consumePurchase()
4) Проверить несписанные покупки - ysdk.payments.getPurchases()

Я ожидал, что в Играх ВКонтакте будет примерно такой же набор методов и такая же логика работы. Но я очень ошибался.

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

Окей, вроде не сложно. Сервер для лидербордов у нас уже имеется, завожу табличку с товарами, буду отвечать на запросы ВК. Ну и пусть клиент тоже тогда берёт список покупок тоже с сервера, чтобы был единый источник данных.

Инициируем запрос на покупку (VKWebAppShowOrderBox), информация о товаре показывается, всё круто. У метода даже есть callback на случай успешной покупки. Ура. Готово. Но не работает.

Так, оказывается покупку нужно подтвердить на сервере. Ну ладно, допустим. Пишу простенький endpoint, который подтверждает покупки. Всё, всё работает, покупка проходит, товары начисляются.

Так, стоп, а как быть, если оплата прошла, но дальше у клиента пропал интернет и мы не смогли начислить награду и сохранить данные? Где этот механизм getPurchases/consume?

А нет его. Его нужно реализовать самостоятельно, на своем сервере, на основе уведомлений. Оказывается, это не просто подтверждения, а прям callback для того, чтобы ты на своем сервере реализовал логику заказов.

Стискиваю зубы, курю API, который шлёт данные в не особо приятном формате, создаю табличку с покупками, заполняю её на основе уведомлений от ВК, пишу endpoint'ы получения и списания покупок, пишу к ним клиентскую часть.

В какой-то момент, приходит осознание, что consume endpoint должен быть защищен. Чтобы злоумышленник не мог списать чужие покупки. Стискиваю зубы ещё сильнее, нахожу механизм проверки подлинности данных, добавляю подпись к моим consume запросам, матерюсь, потому что из-за JS-кода всё приходится делать на callback'ах.

Начинаю писать серверную валидацию подписи - не работает. Матерюсь - не работает. Подключаю нейросети - не работает. Забиваю на java, копирую их пример на PHP, запускаю - не работает. Сто раз перепроверяю все входные данные для подписи - не работает. Методом тыка, выясняю, что ВК не умеет подписывать любую строку, в частности JSON. Заворачиваю мой JSON в base64 - работает.

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

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

Всё, теперь всё. Проверяю, всё работает. Обновление уже доступно ВК. По времени заняло около 12-15 человеко-часов. Стал бы я этим заниматься, если бы знал, сколько всплывёт нюансов? Наверное, да, мое решение гибкое, с возможностью расширения под другие площадки.

Но вам я такого не посоветую. Тем более, что
У Game Push это давно реализовано


P. S. А ещё ВКонтакте может прислать уведомление о refund'e, т. е. возврате покупки, и ты должен забрать у пользователя то, что ты ему начислил. Я забил на это, ничего забирать не буду. Помечаю покупку как refunded и всё. Если будет много возвратов, вернусь к этому вопросу.

#лидерборды #инапы #программирование #самый_крутяк
  • 👍 11
  • 🔥 4
  • 👏 4
  • ❤ 1
Post #193 592
Смогли поймать редкий критический баг на Демофесте

Минами играла в Бурить-Копать! и в какой-то момент её бур просто завис в воздухе, перестал ехать и управляться. Очевидно, вылетел эксепшен, но какой и почему? Мы много тестировали игру до и после, и такого бага не было.

Огромное спасибо Минами за то, что она скинула нам логи игры. По стэк трейсу стало понятно, что вылетел NullReferenceException в момент генерации мира на основе шаблона. Но номера строки или id проблемного шаблона в логах не было, а метод довольно большой и теоретически проблемных мест несколько.

Окей, выкручиваю шанс шаблона на 100%, настраиваю генерацию мира на 1000 клеток вперед и начинаю генерить уровень за уровнем.

Не сразу, но получилось поймать баг, и вот что выяснилось. У нас в шаблонах есть элемент «любое доступное на конкретной глубине топливо». А из-за неправильных настроек топлива (интервалы были [0,300),[301,600) ), так получилось, что на 300 глубине не было ни одного топлива. И поэтому, когда конкретный шаблон попал именно на 300 глубину, случилась ошибка.

Так мне это баг не понравился, что я его исправил сразу в 3 местах:
1) Поправил сами настройки топлива
2) Повесил [CanBeNull] на методы, которые возвращают «любое доступное на конкретной глубине топливо» и исправил использующий их код, чтобы он это учитывал.
3) Завернул код, генерирующий блок по шаблону, в try/catch, с фоллбеком в стандартную генерацию и логированием id проблемного шаблона.

#бурить_копать #программирование
  • 👍 16
  • 🔥 7
  • 👀 1
Post #192 716
В VK Play стартовал Демофест!

Мы попали на лендинг демофеста, причём практически в самое начало! Супер круто! Не зря торопились обновить демоверсию.

#бурить_копать #vk_play
  • 🔥 10
  • 👍 4
  • 🥴 1
Post #191 888
Оказывается, Layout Element работает и с Content Size Fitter

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

Раньше я думал, что Layout Element предназначен для Horizontal/Vertical Layout Group, но оказалось (вполне логично, однако), что и с Content Size Fitter он работает.

Удобно.

#ui
  • 🔥 10
  • ✍ 4
Post #190 867
Поиск целей для молнии: мое решение

Как обещал, выкладываю свое решение. Да, согласен, скорее всего это лишняя оптимизация в моём случае (перебрать пару десятков клеток раз в несколько секунд), но мне так больше нравится.

public class Lightening : MonoBehaviour
{
[SerializeField] private float interval;
[SerializeField] private float minRange;
[SerializeField] private float maxRange;
[SerializeField] private int minCount;
[SerializeField] private int maxCount;

[Inject] private PlayerPosition playerPosition;

private readonly Vector3Int[] cellsInRange;

public Lightening()
{
// делаем массив клеток в радиусе относительно нулевых координат. Мы будем прибавлять их к позиции игрока.
cellsInRange = GridUtils.GetBetweenRanges(Vector3.zero, minRange, maxRange).ToArray();
}

private IEnumerator Start()
{
while (isActiveAndEnabled)
{
yield return new WaitForSeconds(interval); // задержка между выстрелами

var start = 0; // левая граница массива
var hit = 0;
var toHit = Random.Range(minCount, maxCount);
while (start < cellsInRange.Length && hit < toHit)
{
// выбираем рандомный элемент в массиве, начиная с левой границы
var randomIndex = Random.Range(start, cellsInRange.Length);
// меняем его местами с первым элементом
var cell = cellsInRange[randomIndex];
cellsInRange[randomIndex] = cellsInRange[start];
cellsInRange[start] = cell;
// сдвигаем левую границу на 1
start++;

var targetCell = playerPosition + cell; // получаем абсолютную позицию клетки
if (CanHit(targetCell)) // если она подходит
{
Hit(targetCell); // стреляем по ней
hit++;
}
}
}
}


private void Hit(Vector3Int cell)
{
...
}

private bool CanHit(Vector3Int cell)
{
...
}
}

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

Метод GetBetweenRanges скину в комменты, там просто перебор всех клеток.
  • 👍 2
  • ❤ 1
  • 👏 1
Post #189 887
Поиск целей для молнии

Предлагаю размять мозги (свои, а не нейросетевые) задачкой. Пишите идеи/псевдокод в комментарии. Мое решение в этом посте.

Условия задачи:
Нужно найти клетки (Vector3Int), по которым может ударить молния. За один залп стреляем N молний. Клетки выбираются случайным образом в пределах минимального и максимального расстояния от игрока. По пустым клеткам нельзя стрелять. Если подходящих клеток меньше чем нужно или вообще нет, стреляем сколько сможем.
Vector3Int playerPosition = ...; // позиция игорка
bool CanHit(Vector3Int cell) { ... } // проверяет, можно ли выстрелить в клетку
void Hit(Vector3Int cell) { ... } // стреляет в клетку


Нужно найти самое оптимальное решение по производительности и памяти.

P. S. В комменты кину мотивационную картинку.

#разминка_для_мозга
  • 🔥 8
Post #188 850
Новый трейлер Бурить-Копать!

#бурить_копать
  • 🔥 21
  • 👍 6
  • ❤ 2
  • 🫡 2
Post #187 928
Проект не билдится после установки Odin

Сегодня первый раз попробовал сбилдить игру после установки Odin, и билд упал с ошибками типа
Assets/Plugins/Sirenix/Odin Inspector/Modules/Unity.Addressables/AddressablesInspectors.cs(19,29): error CS0234: The type or namespace name 'Editor' does not exist in the namespace 'Sirenix.OdinInspector' (are you missing an assembly reference?)


Стал разбираться, выяснил, что файл AddressablesInspectors.cs активно использует Editor код

using Sirenix.Utilities.Editor;

при этом сам находится в Assembley Definition Sirenix.OdinInspector.Modules.Unity.Addressables, у которой стоит Platform = Any Platform.

Снял галку Any Platform, поставил только Editor, билд прошёл.

Не совсем понял, откуда взялся такой баг в свежеустановленном Odin’e. Возможно, тест, чтобы доказать, что ты достоин им пользоваться?)

#odin
  • 🤔 4
  • ❤ 1
Post #186 912
Купил Odin

Спасибо, что напомнили про существование кастомных испекторов, и особенно спасибо @aks2dio за его пост https://t.me/chat_aks2dio/353.

Вчера очень ясно понял, что уже созрел для покупки Odin'a, раз уже сам стал изобретать велосипеды и тратить на это время. Да ещё и на скидку попал.

Однако, нарисовать превью префаба из коробки он не смог, вместо этого показывал иконку монобеха Drill, поэтому пришлось его немного докрутить:

    [field: SerializeField] public Drill Prefab { get; private set; }

[PreviewField, ShowInInspector, ReadOnly, HideLabel]
private GameObject PrefabPreview => (Prefab as Component)?.gameObject;

#odin
  • 👍 10
Post #185 699
Сделал себе превьюхи в инспекторе

У меня есть Scriptable Object, который описывает скин. У него есть непосредственно prefab, который надевается на игрока, и Sprite, который показывается в магазине. Чтобы было удобнее с ними работать, я добавил в инспектор превью спрайта и превью префаба.

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

Невозможно больше игнорировать LLM-ки. Либо ты ими пользуешься, либо отстаешь. Я ещё не дорос до подключения себе полноценного AI внутрь IDE, но активно использую бесплатный ChatGPT. С 3-4 попыток он мне сгенерировал код инспектора, который показывает превьюхи.

Делюсь примерным промптом с учетом исправлений, которые я просил:
Improve standard editor view for my SO so I will see ShopSprite preview and Prefab preview below standard inspector. Drill is MonoBehaviour. Allow multi-editing, in that case, just don't draw previews. Handle Sprite transparency and fill back with solid gray color like unity inspector back. Draw the entire sprite texture, centered and scaled without stretching.

public abstract class DrillSkin : ScriptableObject
{
[field: SerializeField] public Sprite ShopSprite { get; private set; }
[field: SerializeField] public Drill Prefab { get; private set; }
}
  • 👏 5
  • 👍 1
Post #184 840
Реклама при запуске на Яндекс Играх

Показывать или не показывать? Влияет на конверсию или не влияет? Отпугивает ли игроков? Яндекс Игры ставят точку в этом вопросе.

#яндекс_игры
  • 👍 7
  • 💯 3
  • 😱 2
  • 😁 1
Post #182 817
Делаем мерч на Игропром. Проголосуйте, пожалуйста, какой значок вам больше нравится. Можно выбрать несколько вариантов. Опрос тут.
Post #180 975
Бэкапы для сохранений

Вы делаете бэкапы для сохранений? Насколько вообще надежно просто взять, и перезаписать старые данные новыми?
gp.storage.set('save', savedData);


Я в последнее время задумываюсь над тем, чтобы обеспечить хоть какую-то защиту от повреждения данных. Пока есть такие идеи:

1️⃣ После успешной загрузки сохранения, сохранить только что прочитанные данные в бэкап. Просто реализовать, защитит от записи плохих данных (например, в обновлении сломался механизм сериализации данных), но игрок потеряет прогресс последней сессии.
gp.storage.set('backup', savedData);


2️⃣ Во время записи, после сериализации данных, десериализовать их и попробовать распарсить. Записывать в сохранение только в случае успеха.

3️⃣ Каждый день сохранять данные под новым ключом, старые удалять. Будет возможность в случае чего откатить на несколько дней назад. Довольно сложно реализовать.

Что думаете? Нужно вообще этим заморачиваться?

#нерешенное
  • 🤔 2
Post #179 752
RectTransformUtility.CalculateRelativeRectTransformBounds

Метод позволяет получить границы RectTransform в локальных координатах другого RectTransform.
Creates a Bounds object that encapsulates all the child RectTransform objects found in the child parameter, and converts the resulting bounds into local space relative to the root transform.

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

#ui
  • 🔥 7
  • 👍 3
Post #178 759
Рассказываю про сложности с UX/UI

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

Приняли решение уменьшить масштаб во всех попапах. Они у нас находятся на Canvas с Scale Mode = Expand. Увеличили Reference Resolution до 1500х2950 и почти всё поместилось. Вроде бы, получилось не мелко, похоже, изначально мы делали всё излишне крупно.

Но верхняя панель с ресурсами всё равно мешалась. Она нужна на вкладке с сундуками, чтобы ресурсам, полученным из сундука, было куда лететь. И ключи тоже покупаются за ресурсы. А на вкладке со скинами ресурсы не нужны, только мешаются.

Решили варварски 🙁 выключать эту панель на вкладке с ресурсами. Всё моё программистское нутро было против. Я руководствуюсь принципом, что всё должно быть универсально, однотипно. А тут почему-то при переключении вкладок верхняя панель то исчезает, то пропадает. Мне потребовался час, чтобы реализовать это отключение, и три дня, чтобы смириться с ним.

Зато теперь всё влезает, и вчерашний рабочий вечер на стриме прошел великолепно – я просто писал новую логику.

#бурить_копать #ui
  • 🔥 9
  • ❤ 2
  • 🫡 2
Post #177 894
Помог RuStore стать чуть лучше

Осенью прошлого года мы получили на RuStore отзыв с 1 звездой следующего содержания:
Failure to initialize! Your hardware does not support this application Failed to load 'libmain.so' java.lang. UnsatisfiedL inkError: dlopen failed: library "libmain.so not found


Гугл и ChatGPT были уверены, что у меня неправильная архитектура, и все решения строили вокруг этого. Но с моим билдом было всё в порядке.

Хочу сказать большое спасибо игроку, у которого возникла проблема. Он вышел с нами на связь по электронной почте, отвечал на мои вопросы, устанавливал тестовые билды, и вот что выяснилось (цитирую сам себя):
Дело в том, что эта ошибка возникает, если в APK отсутствует библиотека, соответствующая архитектуре устройства.

Пользователь связался с нами по электронной почте, и мы провели ряд экспериментов. У него на устройстве установился и запускается без проблем мультиплатформенный APK, а так же у него установилась и запустилась игра из Google Play, куда я загрузил аналогичным образом aab билд.

Поэтому я подозреваю, что проблема может быть именно с тем APK, который загружается из RuStore.


Далее, спустя ещё какое то количество действий и переписок, я получил железные доказательства проблемы на стороне RuStore:
Добрый день, могу с уверенностью заявить, что проблема у пользователя возникает из-за RuStore.
Я попросил пользователя воспользоваться приложением App APK Extractor & Analyzer https://play.google.com/store/apps/details?id=com.ytheekshana.apkextractor для того, чтобы извлечь содержимое игры с его телефона и отправить мне.
Вот файл, который у него получился: https://drive.google.com/file/<...>
Он весит 48,5 МБ. Если я на своем устройстве делаю то же самое, у меня получается файл весом 66,0 МБ.
Вы мне ранее присылали два файла: base-master.apk (48,5 МБ) и base-arm64_v8a.apk (17,7 МБ). Очевидно, что при установке приложения пользователь не получает данные из файла base-arm64_v8a.apk - в том числе в этом файле находится папка libs с библиотеками, в том числе с той, которая упоминается в ошибке. Если открыть APK от пользователя как zip-архив, в нем действительно отсутствует папка libs, а в APK с моего устройства такая папка есть.

Ещё одним аргументом в пользу того, что проблема в RuStore, является то, что у пользователя игра без проблем устанавливается и запускается из Google Play.


Это выяснилось в январе этого года, и вот вчера мне написали RuStore, что они решили проблему! Кстати, свой профит я тоже получил - игрок удалил свой отзыв, когда я выяснил, что проблема не с самой игрой. Даже без моей просьбы. Ещё раз ему спасибо.

P. S. А вы знали, что установленное на Android приложение можно засунуть обратно в APK и извлечь с устройства? Я до этой ситуации не знал.

#репорчу_баги
  • 👍 19
  • 👏 6
  • ❤ 2
  • 🔥 2
  • 🥰 1
Post #176 702
Доработали выключатель после ваших комментариев. Исправили сам выключатель и добавили лампочку-индикатор на ячейку.

#ui #бурить_копать
  • 👍 25
  • 👏 1
  • 💅 1
Post #174 688
Допустили глупую ошибку в UX

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

Сделали, зарелизили, и начали получать такие отзывы и сообщения в поддержку:
Здравствуйте
Я может чтото пропустил, но у меня турбины убрали

Что за фигня??? Купил второй уровень ускорителей, чтобы забуриваться, а у меня даже первый не работает, на кой хрен я только ресурсы просрал и не плохие🤬😡🤬😡🤬😡🤬

Сбросились настройки. Причем собранные камни остались, а турбо вначале нет. Теперь придется проходить заново по тык-тыку? Я итак устал ждать когда там до редкого доберусь. Слишком долго развивается сюжет. Бросил играть..


🤦‍♂️

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

В комментах покажу, как переделали.

#ui #бурить_копать
  • 🔥 9
  • 👍 2
  • 🤗 2
  • 😢 1
  • 🎉 1
  • 💅 1
Older posts →
Threads Profile ViewerView any public Threads profile without an account.Open ThreadLook →Writing with AI? Make it sound human.Metric37 rewrites AI drafts so they read naturally. Free AI detector, 1,500 words free.Try Metric37 →