Введение
Всем привет, это уже вторая статья про мою 2D песочницу Odinbit. Первая вышла довольно давно: с тех пор я успел доработать игру, столкнуться с новыми проблемами и решил поделиться, как я всё это преодолел и что именно добавил.
Обновленный генератор мира
Обновленная генерация травы
После выхода первой статьи я обратил внимание, что в мире слишком много травы, и это выглядело неестественно. Поэтому я переписал алгоритм её генерации: теперь трава появляется при создании мира с вероятностью около 20% на каждом тайле. Благодаря этому мир стал выглядеть гораздо разнообразнее.
Обновленная генерация структур
Старые структуры показались мне слишком пресными, поэтому я решил их освежить. Теперь генератор умеет варьировать паттерн структуры, добавляя больше рандома: в цикле генерации проверяется условие, срабатывающее с шансом 20% или 30%, которое может «пропустить» установку блока. В результате структуры тоже выглядят разнообразнее и менее «идеальными».
Новые механики и обновления визуальной составляющей
Добавление новых блоков и обновление вида инвентаря
Выбор блоков для строительства раньше был довольно ограниченным, а теперь в новой версии их стало 22, поэтому постройки могут быть куда разнообразнее. Я также обновил вид инвентаря: теперь он разбит на страницы, и на каждой из них помещаются по 9 блоков. Такой подход даёт сразу несколько преимуществ:
-
Стало проще выбирать нужный блок: нажимаешь цифру от 1 до 9, и он активируется.
-
Точно не возникнет проблем с добавлением новых типов блоков — инвентарь не растягивается бесконечно.
-
На ноутбуках старый инвентарь не влезал в экран, и мне приходилось уменьшать масштаб, что было неудобно для глаз. Теперь всё аккуратно умещается, и Цифровое управление (1–9) работает отлично.
Теперь можно разбирать блоки обратно на ресурсы. Например, если разобрать стену, то получишь камень, а если пол — древесину.
Обновленное перемещение игрока и анимация перемещения
Меня смущал момент, когда персонаж просто «перепрыгивал» с одного блока на другой, поэтому я ввёл плавную интерполяцию. Проще говоря, теперь позиция игрока не увеличивается сразу на 1, а изменяется маленькими шагами. Скажем, если координата X равна 10, то при движении она начинает расти по 0.05 за цикл: 10.05, 10.1, 10.15 и так далее. Такой подход делает перемещения более плавными и естественными.
Однако, если использовать слишком маленькие числа с большим количество цифр после запятой, например 0.000315152332, или напрямую брать время кадра в raylib
через rl.GetFrameTime()
, то при плавной интерполяции камеры можно столкнуться с искажениями текстур и появлением белых полос по краям. У меня такое бывало раньше, поэтому лучше выбирать числа с разумной точностью и стараться не допускать излишне мелких значений, чтобы избежать подобных артефактов.
Добавление регенерации мира
Когда я думал о том, что можно ещё ввести в игру, то пришла идея добавить регенерацию мира. Это поможет добавить реиграбельность в игру, потому что ресурсы имеют свойство заканчиваться, а без ресурсов в мире делать нечего.
Я давно хотел сделать мир более «живым» и пришёл к мысли, что необходима регенерация ресурсов. Сначала я сделал систему, где во время каждого вызова функции update()
шёл тикер, увеличивающий счётчик тиков. Когда счётчик доходил до нужного значения, игра находила случайные пустые позиции и размещала там новые ресурсы — например, камни, деревья или инструменты. Со временем я упростил подход, чтобы не городить сложные таймеры и сделать логику регенерации понятнее в коде. Теперь, когда игрок собирает или ломает ресурс, тут же генерируется новая позиция с пустым тайлом и спавнится соответствующий ресурс: если сломан камень, появляется камень, если дерево — дерево, и так далее.
Добавление инструментов и земледелия
Теперь, чтобы игрок мог разрушать блоки, ему требуется соответствующий инструмент, например кирка, топор или лопата, и у каждого инструмента своя задача. Киркой, например, нельзя сломать пол, а топором — камень. У инструментов нет ресурса прочности, то есть, найдя какой-то из них однажды, можно пользоваться им бесконечно. При этом скрафтить инструменты нельзя, их можно найти только гуляя по миру.
Кроме того, в игру добавлено земледелие: теперь можно выращивать капусту или деревья. Они растут 90 секунд. Саженцы деревьев выкапываются лопатой, а чтобы получить семена капусты, нужно найти в структуре кувшин, сломать его, и тогда из него могут выпасть семена.
Добавление сетевого режима и оптимизация игры
Я давно хотел добавить возможность играть вместе, и наконец-то сделал это! Сервер я написал, используя библиотеку melody
— это такая надстройка над gorilla/websocket
. Обе они работают с протоколом WebSocket
, но melody
позиционируют как «минималистичный фреймворк для Go»
. Мне такой подход по душе: работать с библиотекой просто, а её API легко понять и использовать.
package main
import (
"net/http"
"github.com/olahol/melody"
)
func main() {
m := melody.New()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "index.html")
})
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
m.HandleRequest(w, r)
})
m.HandleMessage(func(s *melody.Session, msg []byte) {
m.Broadcast(msg)
})
http.ListenAndServe(":5000", nil)
}
Как устроена архитектура сервера?
Когда клиент хочет подключиться к серверу, он сначала отправляет запрос по адресу POST /api/v1/player/auth
. В теле запроса передаются никнейм и пароль, после чего сервер ищет соответствующую запись в базе данных. Если пользователь с таким никнеймом найден, сервер превращает полученный пароль в хэш и сравнивает его с хэшем из базы. Если оба хэша совпадают — сервер возвращает ответ «OK», иначе — «FAIL».
Далее клиент обращается к серверу по адресу GET /api/v1/server/status
, чтобы узнать его текущее состояние. Там он проверяет параметр IsWorldWaiting
. Если его значение равно true, клиент отправляет серверу данные игрового мира, чтобы всё там правильно сохранилось. После этого клиент смотрит на параметр IsIdWaiting
. Если он также равен true
, то происходит передача идентификаторов блоков, чтобы сервер мог корректно обрабатывать их при сохранении и загрузке.
В моём случае сервер не является авторитарным: он лишь перенаправляет пакеты данных между игроками и не осуществляет детальную проверку или обработку каждого действия. Понимаю, что многие считают отсутствие авторитарности плохой идеей, но моя игра не направлена на состязательность. Это обычная 2D песочница, где игроки строят, занимаются земледелием и исследуют мир, поэтому риск нечестной игры минимален. К тому же, подобный подход снижает нагрузку на сервер, что позволяет запускать его даже на маломощных устройствах.
Гибкие сетевые пакеты
Мне нужно было посылать бинарные пакеты через melody
, а значит, требовалось перевести данные в формат байтов. Я мог сделать это по-разному, но остановился на msgpack
, поскольку он быстро обрабатывает (десериализует) данные. Чтобы не писать много разных структур для разных типов пакетов, я использую общий тип map[string]interface{}
в качестве контейнера, который потом упаковываю и передаю. Такой подход даёт определённую гибкость, ведь можно добавлять или изменять поля в пакете без изменения структуры данных.
world.AddBlock(
typeconv.GetUint32(packet["Texture"]),
typeconv.GetFloat32(packet["X"]),
typeconv.GetFloat32(packet["Y"]),
typeconv.GetBool(packet["Passable"]),
)
Благодаря такому подходу я могу легко менять содержание пакета (например, добавлять новые поля или убирать старые) без переписывания большого количеством структур. При этом, чтобы никто не запутался, я подготовил подробную документацию по всем сетевым опкодам и форме пакетов. Таким образом, любой желающий может посмотреть код, внести изменения или написать собственные модули, понимая, какие данные куда уходят.
Оптимизация размера файла с миром
Я решил хранить данные по блокам так, чтобы каждый блок занимал 7 бит, а не целый байт.
-
Сначала я создаю массив такого же размера, сколько всего тайлов в мире. В него я записываю для каждого блока его ID плюс 1 (так «0» остаётся для пустых тайлов).
-
Когда заполнил этот массив, беру каждый элемент (блок) и упаковываю в итоговый «битовый» массив. В нём я заранее рассчитываю, сколько байтов потребуется:
-
Умножаю количество тайлов на 7 (ведь 7 бит на тайл).
-
Делю результат на 8 и прибавляю 7 (чтобы округлить в большую сторону), так получается размер в байтах.
-
-
Потом иду по каждому байту из первого массива и записываю его в нужные 7 бит в итоговом массиве. Если выходит, что эти 7 бит не помещаются среди оставшихся свободных бит в текущем байте, переносится недостающая часть в следующий байт.
-
Таким образом, в финальном варианте на любой блок выделяется ровно 7 бит, а «пустые» тайлы получают значение 0.
Этот подход экономит место, ведь мы не тратим 1 байт для каждого блока, если нам нужно только 7 бит.
Заключение
В этой статье я описал все основные нововведения, которые добавил в игру. Исходный код сервера полностью открыт и доступен на GitHub:
-
Исходный код (GitHub): https://github.com/otie173/odncore
-
Скачать игру (Itch.io): https://otie173.itch.io/odinbit
-
Сообщество по разработке игр на Go: https://t.me/go_gamedev
-
Мой канал в телеграмме: https://t.me/+OxE0LzeTt4ExZTZi
Всем до скорых встреч!
Автор: otie173