Protos, дневник #8: почему шприц лечит не в тот момент, когда ты нажал кнопку
Меня давно не было слышно, и причина скучная: я всё это время переписывал фундамент. Никакого нового красивого эффекта, никакой новой способности. Просто в игре про перестрелки до сих пор не было ни здоровья, ни урона, ни смерти, и дальше тянуть было нельзя.
Почему я полез в GAS
Gameplay Ability System - это встроенная в Unreal система способностей, эффектов и характеристик. У неё репутация тяжёлой: большой порог входа, много незнакомых слов, и первые дни ты просто не понимаешь, зачем всё это, если «здоровье это же просто float».
Я держался без неё 3 месяца. Сломался на трёх вещах сразу.
Сеть. Каждую способность пришлось бы реплицировать руками: запрос на сервер, проверка, предсказание на клиенте, откат при расхождении. Это не день работы на способность, это неделя, и следующая пишется заново с нуля.
Модификаторы. «Пока висит эффект зоны, урон по тебе на двадцать процентов больше» руками превращается в паутину флагов, где через месяц никто не разберётся.
Порядок применения. Щит поверх здоровья, вампиризм, перегрузка, которая жрёт здоровье со временем. Все трогают одни и те же числа. Без общего механизма каждый источник урона обязан знать про все остальные.
Цена входа высокая, но она разовая. Дальше каждая новая способность стоит дешевле предыдущей.
Мета-атрибут: самая полезная идея, которую я вынес
Вот эта штука стоила мне двух дней чтения и оказалась главным, что я понял.
Кажется очевидным: пуля попала, вычли из здоровья. Но как только появляется щит, схема ломается. Теперь каждый источник урона обязан знать: сначала снять со щита, потом остаток со здоровья. Пуля должна это знать. Катана. Падение с высоты. Перегрузка. И каждый следующий источник тоже.
В GAS делается наоборот. Заводится специальный атрибут Damage, и никто и никогда не трогает здоровье напрямую. Все пишут в Damage, а одно-единственное место разбирается, куда это девать:
const float Incoming = GetDamage(); SetDamage(0); const float ShieldBefore = GetShield(); const float ShieldAfter = FMath::Max(ShieldBefore - Incoming, 0.f); SetShield(ShieldAfter); const float Leftover = Incoming - (ShieldBefore - ShieldAfter); if (Leftover > 0.f) { SetHealth(GetHealth() - Leftover); }
Он живёт ровно один вызов и обнуляется сразу после чтения. Почтовый ящик: прочитал письмо - выкинул. По сети он не ходит вообще, потому что и не должен.
Красота в том, что источник урона теперь знает одно слово. Не «сколько щита», не «есть ли броня», не «какой порядок». Просто «Damage, шестьдесят». Всё остальное - не его дело.
Захочу завтра добавить броню поверх щита - правится одна функция, и все источники урона в игре узнают об этом бесплатно.
Шприцы
Механика по образцу Doom: расходники лежат на карте, подбираются на бегу, копятся до трёх штук, тратятся по кнопке прямо в бою. Треугольный на здоровье, Квадратный на щит.
Логика простая, но пара решений оказалась неочевидной.
Если здоровье полное, шприц не тратится, и пикап остаётся лежать. Мелочь, но она превращает точки спавна в места, за которые дерутся, а не в самоочищающийся мусор. Бесплатный левел-дизайн.
Два источника лечения дополняют друг друга. Вампиризм (убийство лечит) награждает агрессию. Расходники награждают знание карты. Одно для того, кто лезет вперёд, другое для того, кто думает.
А теперь то, ради чего заголовок
Первая версия работала так: нажал кнопку - здоровье выросло. Работает, но ощущается отвратительно. Персонаж только заносит руку, а полоска уже поехала вверх. Весь вес момента пропадает.
Правильно так: эффект применяется по событию из анимации, ровно в кадре, где игла вошла.
Для этого я сделал маленький нотифай, который ставится в монтаж и шлёт событие. Способность его ждёт, ловит и только тогда лечит. Один класс на все будущие случаи: чем именно кончится событие, решает тег, а не код. Тот же нотифай потом поедет на замах катаны и на выброс гильзы.
Плюс три страховки, потому что реальность грязнее схемы:
- нотифай забыли поставить в монтаж - эффект применится в конце анимации;
- монтажа нет вообще - работает по таймеру, и способность полностью рабочая ещё до того, как появилась анимация;
- монтаж прервали - до укола шприц остаётся, после - потрачен. Так честно.
Нож
Метательные ножи оказались приятнее огнестрела, и вот почему.
У меня нет хитскана нигде. Ни у одного оружия. Пуля - настоящий снаряд, который летит с конечной скоростью. Причина не в стиле: от мгновенного луча нельзя увернуться, и весь паркур в бою становится бесполезен. А раз снаряд летит - движение становится защитой, и мувсет наконец получает смысл в перестрелке.
При этом пуля у меня не актор. Пуля - это строчка в массиве. По сети едет не пуля, а выстрел: откуда, куда, когда. Дальше каждая машина считает полёт сама по одной формуле. Реплицировать позицию каждый тик значит платить трафиком за то, что вычисляется бесплатно.
Как он летит
Нож не вращается. Это было сознательное решение: крутящийся в полёте нож читается как мультик, а мне нужна тяжесть.
Сразу после броска он делает один переворот - персонаж перехватывает его за лезвие, и надо развернуть остриём вперёд. Дальше просто летит и к концу траектории слегка клюёт носом.
Клевок достался бесплатно, и это мой любимый кусок кода за неделю. Нос ножа смотрит по вектору скорости, а не по начальному направлению:
FRotator Rotation = Tracer.bAlignToVelocity ? Tracer.VelocityAt(Time).GetSafeNormal().Rotation() : Tracer.Dir.Rotation();
У пули гравитация нулевая, вектор скорости не меняется, летит идеально прямо. У ножа гравитация есть, вектор постепенно клонится вниз - и нож клонится вместе с ним. Отдельного кода «наклонись по траектории» не существует. Это следствие правильной физики, а не фича.
След
За ножом идёт мягкий шлейф, и цвет он берёт не из настроек оружия, а из зоны, в которой стоит бросающий. У меня в игре пространство разбито на зоны, у каждой свой цвет, и он расходится по свету и туману.
Так след читается как часть мира, а не как свойство железки. И по цвету видно, откуда прилетело.
Втыкается
Нож остаётся в поверхности на пару секунд, с тем наклоном, с которым прилетел. Потом исчезает.
Мелочь в две строки, а разница огромная: бросок получает точку. Без неё нож просто испаряется в воздухе, и удар не читается.
Багов недели два, оба хорошие
Первый: HasAuthority без скобок.
if (!HasAuthority || ...)
Компилятор это пропускает. Но без скобок это не вызов функции, а сама функция, и в булевом контексте она всегда истина. Значит !HasAuthority всегда ложь. Значит проверка сервера не срабатывала никогда. Молча.
В одиночной игре это невидимо. В сети каждый клиент подбирал бы аптечку у себя локально.
Второй: одно поле на двоих.
Ножи кидались, а урона не было. Совсем.
TryFire() LastFireTime = Now; // записал Server_Fire() Now - LastFireTime // прочитал ноль → выстрел отброшен
Проверка «не слишком ли часто стреляешь» жила на сервере. Но в одиночной игре клиент и сервер - одна машина с одним полем. Клиентская часть записывала метку времени, серверная тут же её читала, видела разницу ноль и резала каждый выстрел как читерский.
Лечится разделением на два поля. Но искал я это долго, потому что симптом выглядел как проблема коллизий, а не как проблема сетевой архитектуры.
Про художника
Отдельно скажу, потому что это оказалось важнее, чем я думал.
Ко мне пришёл художник, и первой же задачей я упёрся в собственную неспособность объяснить, что мне нужно. Шприц я описал словами: «треугольная призма, пятнадцать сантиметров, полоса на каждой грани».
Получил пирамиду. Формально текст был верен. Однозначным - нет.
Её вывод: для предметов, которых нет в реальном мире, надо сразу прикладывать схему или референс. Теперь рисую схему до отправки задания: вид сбоку, вид с торца, размеры и отдельная панель «так не надо» с типичной ошибкой.
Звучит как мелочь. На деле это разница между «переделали два раза» и «сделали сразу».
P.S.
видоса нет, потому что мне лень)