Введение
Всем привет, в один прекрасный момент мою голову посетила задумка для игры: я хочу сделать свою игру, с элементами выживания и незамысловатой графикой. Хоть я и не имел опыта в gamedev, но все равно решил попробовать, почему бы и нет
Концепция игры
Основая идея игры такова: вы как и во всех играх песочницах будь то Minecraft, Terraria, Dont Starve, появляетесь в мире, у вас нет вещей, а точнее вообще ничего. Вам предстоит развиваться в игровом мире, мне очень нравится такой жанр игр. Но я внёс небольшие изменения: вы не можете строить и крафтить, те блоки, которые еще не нашли в мире, чтобы игра поняла, что вы его нашли - вам нужно его сломать. Инвентарь тоже реализован немного нестандартно.
Видео с геймплеем
Выбор движка и языка
Выбор мой пал на язык Go в связке с движком Raylib. Да, я знаю, что есть Ebiten, но лично мне он не понравился. Почему же я выбрал именно такую связку? До начала написания этой игры я имел только базовое представление о языке Go. Он мне понравился своей легкостью написания кода, я не обладаю отличной памятью, чтобы выучить весь синтаксис таких гигантов мира ЯП как: С#, C++ и прочие. При этом Go предлагает вам легкое подключение внешних зависимостей к своему проекту. Я помню как пытался написать хоть обычное окошко на C++ & OpenGL и это было просто ужасно для меня. Впрочем Go как язык для моей игры меня вполне устраивает. Raylib я выбрал потому что я имел опыт его использования его на других ЯП, но не смотря на это я пробовал и Ebiten, который мне не понравился. Но это не значит, что он какой-то не такой, просто я привык к другому, я видел людей, которые писали потрясающие игры на Ebiten и Go.
Реальная картина
Сейчас игра находится в активной разработке. Я уже реализовал генерацию мира: деревья, камни, руины. Вся генерация написана на генерации псевдослучайных чисел и постановке игрового объекта на этой координату, при этом, чтобы мир смотрелся не слишком однотипно - я добавил разные текстуры камней и деревьев, разные схематики для руинов.
func generateTree(x, y float32) {
// Генерация случайного номера изображения дерева
treeImg := rand.Intn(3) + 1
// Постановка дерева на карту в зависимости от номера текстуры
switch treeImg {
case 1:
addBlock(smallTree, float32(x), float32(y), false)
case 2:
addBlock(normalTree, float32(x), float32(y), false)
case 3:
addBlock(bigTree, float32(x), float32(y), false)
}
}
func generateStone(x, y float32) {
// Генерация случайного номера изображения камня
stoneImg := rand.Intn(4) + 1
// Постановка камня на карту в зависимости от номера текстуры
switch stoneImg {
case 1:
addBlock(stone1, float32(x), float32(y), false)
case 2:
addBlock(stone2, float32(x), float32(y), false)
case 3:
addBlock(stone3, float32(x), float32(y), false)
case 4:
addBlock(stone4, float32(x), float32(y), false)
}
}
func generateGrass(x, y float32) {
// Генерация случайного номера изображения травы
grassImage := rand.Intn(6) + 1
// Поставновка травы на карту в зависимости от номера текстуры
switch grassImage {
case 1:
addBlock(grass1, x, y, true)
case 2:
addBlock(grass2, x, y, true)
case 3:
addBlock(grass3, x, y, true)
case 4:
addBlock(grass4, x, y, true)
case 5:
addBlock(grass5, x, y, true)
case 6:
addBlock(grass6, x, y, true)
}
}
func generateWorld() {
// Генерация травы
for x := -32; x <= 32; x++ {
for y := -32; y <= 32; y++ {
generateGrass(float32(x), float32(y))
}
}
// Генерация данжа1
x1 := rand.Intn(65) - 32
y1 := rand.Intn(65) - 32
generateStructure(x1, y1, 1)
//Генерация данжа2
x2 := rand.Intn(65) - 32
y2 := rand.Intn(65) - 32
generateStructure(x2, y2, 2)
// Генерация данжа3
x3 := rand.Intn(65) - 32
y3 := rand.Intn(65) - 32
generateStructure(x3, y3, 3)
// Генерация деревьев
for i := 0; i < 128; i++ {
x := rand.Intn(65) - 32
y := rand.Intn(65) - 32
generateTree(float32(x), float32(y))
}
// Генерация камней
for i := 0; i < 128; i++ {
x := rand.Intn(65) - 32
y := rand.Intn(65) - 32
generateStone(float32(x), float32(y))
}
}
Это пример кода для генерации мира. Да, он детский по сложности, но рабочий для моего мира, если у вас есть идеи по его улучшению, то напишите об этом, пожалуйста.
Выбор типа данных для хранения мира
Изначально я хранил все свои блоки в срезе, срез имел тип данных структуры, которая описывала сам блок. Но спустя время я решил сменить срезы на мапу, потому что в моей игре игрок может активно взаимодействовать с игровым миром, а это значит, что нужно часто добавлять/удалять блоки в срезе. С добавлением у меня проблем не возникло, а вот с удалением они появились, потому что сам язык не предоставляет метода для удаления нужного элемента из среза, поэтому до того как я нашел нужное решение - я просто перезаписывал весь срез, но уже без удаленного блока. Покапавшись в интернете и найдя нужный ответ на StackOverflow, о том, что для такого лучше использовать мапу, потому что она способна обеспечить быстрое удаление и быстрый поиск нужного элемента.
var world map[rl.Rectangle]Block
type Block struct {
img rl.Texture2D
rec rl.Rectangle
passable bool
}
Поэтому после всех изменений у меня получился код выше. В структуре блока я определил самое нужно: текстуру блока, его расположение и можно ли через него проходить.
А добавляю блоки в мапу вот так:
func addBlock(img rl.Texture2D, x, y float32, passable bool) {
block := Block{
img: img,
rec: rl.NewRectangle(x*TILE_SIZE, y*TILE_SIZE, TILE_SIZE, TILE_SIZE),
passable: passable,
}
world[block.rec] = block
}
func removeBlock(x, y float32) {
delete(world, rl.NewRectangle(x*TILE_SIZE, y*TILE_SIZE, TILE_SIZE, TILE_SIZE))
}
В этом коде поле img как видно хранит в себе просто текстуру, а вот rec уже интереснее, его я использую и для проверки колизии, и для отрисовки блока в мире, это поле хранит X, Y, Ширину, Высоту моего блока.
Появление первых трудностей
Так уж сложилось, что после написания простых вещей - появляется необходимость реализации сложных вещей, чего часто не получается избежать. Для меня такой вещью стала отрисовка мира, у меня было много разных идей на этот счет, но ввиду малого количества опыта эту тягость я не осилил. Поэтому я решил пойти по пути наименьшего сопротивления - написать функцию, которая будет определять: находится ли блок в области видимости камеры и если да, то уже другая функция будет его отрисовывать.
func isVisible(block Block, cam rl.Camera2D, screenWidth, screenHeight int) bool {
// Границы объекта
left := block.rec.X
right := block.rec.X + float32(block.rec.Width)
top := block.rec.Y
bottom := block.rec.Y + float32(block.rec.Height)
// Границы видимой части экрана с учетом камеры
screenLeft := cam.Target.X - float32(screenWidth)/2/cam.Zoom
screenRight := cam.Target.X + float32(screenWidth)/2/cam.Zoom
screenTop := cam.Target.Y - float32(screenHeight)/2/cam.Zoom
screenBottom := cam.Target.Y + float32(screenHeight)/2/cam.Zoom
// Проверяем пересечение границ объекта и видимой области экрана
return left < screenRight && right > screenLeft && top < screenBottom && bottom > screenTop
}
func drawWorld() {
for _, block := range world {
if isVisible(block, cam, rl.GetScreenWidth(), rl.GetScreenHeight()) {
rl.DrawTextureRec(block.img, block.rec, rl.NewVector2(block.rec.X, block.rec.Y), rl.White)
}
}
}
После добавления всех своих первых идей - у меня появилась потребность написать сохранение мира. Как может существовать игра песочница, если там не будет сохраняться ваш процесс? Это же будет глупо. Проблема заключалась в том, что мне как-то нужно было при загрузке мира из его сохранения понять какой блок размещен на этой координате, и только сейчас, во время написания статьи я понял, что просто мог подписать блок в зависимости от его текстуры. Но в момент написания кода я сделал так: я дал каждой текстуре свой ID, а при загрузке мира из сохранения я просто присваивал полю img, текстуру, которой соответсвует данный ID
func saveWorldInfo() {
worldInfo := WorldInfo{Version: "indev06042024"}
jsonData, err := json.Marshal(worldInfo)
if err != nil {
log.Fatalf("Не удалось преобразовать информацию мира: %v", err)
}
err = os.WriteFile("world_info.json", jsonData, 0644)
if err != nil {
log.Fatalf("Не удалось сохранить информацию о мире: %v", err)
}
}
func loadWorldFile() map[rl.Rectangle]Block {
jsonData, err := os.ReadFile("./world_data.json")
if err != nil {
log.Fatalf("Ошибка при чтении файла: %v", err)
}
var blocksData []BlockData
err = json.Unmarshal(jsonData, &blocksData)
if err != nil {
log.Fatalf("Ошибка при десериализации данных: %v", err)
}
world := make(map[rl.Rectangle]Block)
for _, data := range blocksData {
rect := rl.Rectangle{
X: data.X,
Y: data.Y,
Width: 10.0,
Height: 10.0,
}
world[rect] = Block{img: id[data.TextureID], rec: rect, passable: data.Passable}
}
loadPlayerFile()
return world
}
Планы на будущее
С момента начала написания кода и до появления первого публичного выпуска уже прошло 2,5 месяца. Они пролетели для меня очень быстро, за это время я узнал много нового как о gamedev`e так и о Go, решал некоторые головоломки по несколько дней, а иногда и недель, но оно того стоило. Спасибо всем тем, кто давал мне советы, давал мотивацию и новые идеи, без вас я вряд ли бы писал эту статью сейчас, ваша поддержка очень помогла и помогает до сих пор.
Что касается на счет доработки и развития игры? У меня в голове полно идей по этому поводу, я хочу добавить новые строительные блоки, блоки интерьера и другие игровые механики: земледелие, систему здоровья и систему брони. Хочу добавить более масштабные структуры для большего интереса исследования мира, новые миры для исследования, но для добавления новых миров нужно сначала доделать все дела с основным миром.
Идеи для игры
Если вдруг кому-то интересно: откуда у меня появляются идеи. Я вдохновляюсь другими играми и модификациями для очень старых версий Minecraft. Иногда, когда я листаю YouTube, то мне попадаются очень крутые ребята, которые модифицируют старые версии. У меня после таких видео в голове возникают идеи, о том, что и как можно добавить в игру. Но чаще всего идеи спонтанно возникают в голове.
Заключение
Игру, описываемую в этой статье можно скачать в моем дискорд сервере, если она кому-то вдруг стала интересной. Посмотреть её исходники сейчас нельзя, но когда я поправлю весь свой стыдный код, то обещаю сделать репозиторий публичным. Очень надеюсь , что эта статья была интересна. В других статьях я буду продолжать рассказывать о написании этой игры, буду дальше описывать как и почему я написал так, а не иначе. Если кого-то из читателей этой статьи заинтересовала разработка игр на Gо, то можете вступить в телеграмм сообщество про разработку игр на Go. Там я часто публикую showcase`ы своей игры и другие участники сообщества деляется своими играми и идеями, также там вам помогут, если у вас возникли трудности с написанием вашей игры.