Lispy Rogue: постмортем
Наконец пришло время подвести итоги Autumn Lisp Game Jam 2024, на котором я выступил с игрой в жанре rogue-like Lispy Rogue (да, у меня всё плохо с неймингом вещей, я ж программист).

Давно хотел потыкать и в этот жанр, так как в нём много интересных челленджей, например, дискретизация времени на отдельные ходы, генерация карт и многое другое.
При подготовке к джему колебался с техническим стеком. Дело в том, что есть отличный Roguelike tutorial for Common Lisp, который по сути является переделанным питоновским туториалом по использованию сишной библиотеки libtcod, в которую входят всякие полезные для рогаликов вещи, типа вывода на экран, расчёта области видимости с учётом препятствий и даже поиска пути, бери да делай. Есть даже рабочий рогалик на CL из туториала, в который я залип минут на двадцать 😁 Однако поразмышляв, я решил использовать знакомый технический стек в виде liballegro, Nuklear и cl-fast-ecs, ведь там всё отлажено и знакомо, а при использовании libtcod мне грозила мука с деплоем, потому что ни под винду, ни под линуксы, ни тем более под макось нет каких-либо готовых бинарников этой библиотеки, пришлось бы только с этим долго провозиться.
Однако при таком подходе мне пришлось реализовать пошаговую логику. В этом плане я поглядывал на один из своих любимых рогаликов, в который много залипал в последнее время — Path of Achra, в нём при нажатии клавиши какого-либо действия мир начинает жить — проигрываются анимации, играют звуки, возникают пыщ-пыщ эффекты, причём скорости действия у разных персонажей могут быть разные, и судя по всему, от этих скоростей, точнее, от их обратных величин, вычисляется НОК и длительность хода составляет это число. Ну то есть типа если у моего персонажа скорость условная безразмерная 6, а у вражины 3, то после нажатия кнопки атаки мой перс два раза пнёт вражину, а он моего — только один раз, после чего мир снова встаёт на паузу. Ещё я полистал книгу Exploring Roguelike Games Харриса, в которой есть целая глава, посвящённая вопросу организации ходов, но почему-то мне ни один из подходов оттуда не понравился. Я практически сразу придумал следующее, как мне показалось, элегантное решение: по сути я реализовал всё как раньше, ECS-системы, симулирующие мир (например, перемещение или атаки персонажей), с параметром dt, в который передаётся реальное физическое время, однако код всех этих систем работает только тогда, когда глобальная булевская переменная *turn* выставлена в истинное значение, а в него она выставляется только тогда, когда игрок выполняет какое-то действие, например, хочет пойти в определённый тайл или кого-то атаковать, а как только действие заканчивается, *turn* снова выставляется в ложное значение и весь мир останавливается. Кажется, это удачный подход, однако из-за того, что код игры под конец довольно сильно разросся, иногда проявляется такой баг, что симуляция мира не останавливается, когда этого ожидаешь, и из-за этого твоего персонажа могут легко запинать до смерти, что очень обидно. Я честно долго пытался найти этот баг, но он довольно редко проявляется, поэтому увы, оставил его на будущее.
Также вместо классического отображения мира с помощью псевдографики ASCII-шными символами я выбрал вывод настоящих картиночек из потрясающего тайлсета Urizen 1Bit, который присмотрел задолго до джема и хотел использовать в какой-нибудь игре 😊 Когда джем начался, я задался вопросом, а в каком виде хранить инфу об отдельных элементах тайлсета, нужно же где-то сохранять инфу о том, что условно по смещению (100; 100) на картиночке тайлсета изображён персонаж игрока, а по (200; 200) тайл стены. Я побрейнштормил с ChatGPT, и в итоге пришёл к выводу, что и здесь нужно использовать знакомые инструменты, а именно Tiled — в нём же, помимо карт, ещё и есть формат тайлсетов, в котором можно добавлять тайлам кастомные свойства, прямо как в моём туториале. Забегая вперёд, использование знакомых хорошо работающих инструментов сэкономило прорву времени и помогло сделать худо-бедно законченную игру.
Дальше я просто шёл по питоновскому туториалу по разработке рогалика на libtcod, переделывая то, о чём читал, в ECS-парадигме на Common Lisp. Сразу хочется отметить, насколько же последний вариант проще, чем вымученная возня с ООП 😀 Уж не говоря о производительности, хотя на неё я совсем забил, поэтому под конец моя игра на моём Ryzen 5 жрёт аж 17% одного ядра CPU при 75 FPS. Там есть несколько прямо очевидных мест, где можно производительность улучшить, например, в профилировщике довольно высоко всплывала аллокация памяти под сишную структуру с состоянием клавиатуры, которое я проверяю в ряде мест для реализации управления игрой, и можно это делать в одном месте, а не в нескольких сразу. Но под конец разработки, когда на это теоретически было время, у меня уже не было сил заныривать ещё и в оптимизацию.
Когда пришло время реализовывать боёвку, я опять-таки обратился к игре, в которой провёл слишком много времени за последние лет пять:

А именно, Path of Exile. Я начал с ближнего боя, и математику для его расчётов слизал оттуда практически подчистую 😅 Ну а чего добру пропадать, если оно худо-бедно работает. В частности, поэтому у меня в игре фигурируют такие защитные параметры, как evasion, block chance, armor, и параметр атаки accuracy — благодаря последнему можно иметь сколь угодно большой урон, но постоянно промахиваться по врагу.
В четверг, то есть на седьмой день джема, я с утра попытался пофиксить ряд надоедливых багов, включая тот баг с симуляцией, которая не ставится на паузу, не смог его найти и немножко подвыгорел, но потом всё-таки взял себя в руки и сделал некоторые важные новые фичи. Затем в этот же день я начал делать систему предметов, и так далеко я ещё никогда не заходил, так что чувствовал себя как в этой шуточке 😁

Забегая вперёд, получилось неплохо, и (спойлеры) хороший шмот, найденный в игре — это залог победы в ней.
В предпоследний день джема доделывал важные недостающие механики, типа нормального поиска путей через A* для врагов, дальнебойных атак и интерфейсных окон вроде окна прокачки и сообщения о победе. Думал оставить окончательную балансировку игры на последний день, и даже предполагал, что останется время на то, чтобы добавить звуковые эффекты для большей атмосферы, но по итогу, встав рано утром и накидавшись кофе, большую часть воскресенья добавлял новых врагов, проверял, что они не слишком сложные и не слишком простые, и правил баги, на которые при этом наталкивался, например, выкинутые на землю предметы экипировки продолжали считаться надетыми на игрока 🤣 Также под нож отправилась магия — всё, что есть из этой области в игре, это два вида свитков, свиток фаербола и свиток калечения. Первый взят из туториала по рогаликам, а второй я уже под конец придумал, чтобы не было только одного типа свитков, и чтобы как-то контрить слишком быстрых врагов, от которых трудно убежать. Наконец, к 10 вечера финальный билд был готов. Для того, чтобы приложить на страничку игры видео, я даже записал летсплей на 28 минут — в нём можно услышать, как меня прямо в прямом эфире окончательно отпускает кофеин 😔
Что забавно, баланс в целом я проверил только в понедельник утром, убедившись, что хоть игра и сложная для прохождения, но не невозможная — вот пруф, что можно дойти до выхода с 10 уровня:








Что я по итогам вынес для себя, помимо того, что делать рогалики весело и что я, скорее всего, попробую поучаствовать в посвящённом рогаликам джеме 7DRL, который должен проходить в следующем марте? Целую россыпь точек роста:
- В самой игре использование предметов и снятие/одевание предметов экипировки должно занимать время.
- Ещё я в понедельник заметил баг, что из-за низкого нанесённого урона и высокого показателя armor можно увидеть в логе строчку "такой-то наносит такому-то 0 единиц урона", это немного тупо 😂
- ☑ Надо бы таки оптимизировать выделение памяти под сишную структуру с состоянием клавиатуры. Может быть, даже использовать механизм событий из liballegro, но это на крайний случай.
- ☑ Функции, которые возвращают факт того, является ли тайл по заданным координатам освещённым или заблокированным, как будто бы довольно неэффективны в плане производительности. Надо бы наконец осилить главу про Spatial Partition из книжки Game Programming Patterns Найстрома.
- В коде игры есть целая пачка глобальных переменных, по сути описывающие машину состояний игры — нажата ли кнопка справочного окна, отображается ли справочное окно, нажата ли кнопка инвентаря и т.д. Когда я засыпал в воскресенье, в голову пришла мысль, что все их можно сделать компонентами-тегами на сущности игрока, что будет гораздо изящнее с точки зрения архитектуры, но умная мысля приходит опосля, да.
- Код учёта бонусов к боевым параметрам от предметов довольно неловкий из-за того, что я такое реализовывал по сути впервые. Возможно, для упрощения стоило здесь тоже пойти all-in в ECS-архитектуру и сделать параметры типа силы, ловкости, интеллекта, точности попадания и т.д. динамическими сущностями вместо хардкода. Не уверен, что это очень сильно упростит код, но как будто бы тоже должно сделать архитектуру чуть изящнее.
- В моём ECS-фреймворке cl-fast-ecs не хватает некоторой safety, таксказатб — в один момент я словил роскошный трудноуловимый баг из-за того, что в одном месте перепутал номер уровня с entity этого уровня (а entity у меня сейчас — это просто целые числа). В общем, нужно лучше определять валидность сущностей, передаваемых в функции фреймворка.
- В нём же объявление локальных переменных в системе через аргумент :with выглядит очень громоздко и нелепо, надо переделать. Главный вопрос в том, как 😅
- Также не хватает возможности определить не просто локальную переменную, а некую переменную, замкнутую в код системы, этакий стейт, сохраняемый между вызовами.
- ☑ Кроме того, я натыкался на такое, что добавление нового слота в компонент при работающей игре ломает код системы, и она крашится с кондишеном. Нужно починить, интерактивная разработка — это главная фишка этих наших лиспов.
- Можно подумать о том, чтобы добавить полноценную систему (де)сериализации компонентов, чтобы можно было сохранить состояние игры и дойти до 10 уровня позже 😌
- ☑ В лисповом биндинге к liballegro cl-liballegro сильно не хватает коротких аксессоров к полям сишных структур, приходится городить что-то чудовищное в духе
(cffi:foreign-slot-value mouse-state '(:struct al:mouse-state) 'al::buttons)
Надо бы сделать pull request с такими аксессорами, её мейнтейнер очень отзывчив в этом плане.
- ☑ Обнаружил совершенно роскошный косяк в своей либе для UI cl-liballegro-nuklear: если определить окно с помощью декларативного интерфейса, на котором очень много виджетов, какой-то момент компилятор SBCL отказывается компилировать код для этого окна из-за обилия внутренних макросов, упираясь в какие-то свои внутренние ограничения. Проблема сложная, но пофиксить надо.
- Наконец, довольно много людей стало приходить в коменты и жаловаться, что у них падает с ошибкой "Initializing display failed", хотя впоследствии почти всегда выясняется, что у человека в PCI-слоте торчит картошка на Wayland вместо настоящей видеокарты. Надо бы сделать вывод подробного лога инициализации liballegro, например, функцией al_open_native_text_log, которая, как я понимаю, открывает окошко с логом в духе Quake 3 (если вы это помните, то вам, как и мне, примерно 140 лет).
В общем, есть много интересных вещей, которые можно пилить дальше, чем я и займусь далее в свободное от работы время 👍