Введение

Odinbit
Odinbit

Всем привет, в один прекрасный момент мою голову посетила задумка для игры: я хочу сделать свою игру, с элементами выживания и незамысловатой графикой. Хоть я и не имел опыта в 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`ы своей игры и другие участники сообщества деляется своими играми и идеями, также там вам помогут, если у вас возникли трудности с написанием вашей игры.

Комментарии (15)


  1. r0man_alekseevich
    09.04.2024 15:30

    Круто! Сегодня попробую поиграть


  1. Zara6502
    09.04.2024 15:30
    +5

    Молодец, так держать. Подписался, буду следить ;)

    Из увиденного:

    1.

    	switch grassImage {
    	case 1:
    		addBlock(grass1, x, y, true)

    как по мне направшивается

    addBlock(grass[grassImage], x, y, true)

    далее

    x1 := rand.Intn(65) - 32
    y1 := rand.Intn(65) - 32
    generateStructure(x1, y1, 1)

    визуально нигде переменные не используются, я бы делал или

    generateStructure(rand.Intn(65) - 32, rand.Intn(65) - 32, 1)

    или даже удобнее, если это требуется, сделал бы так

    generateStructure(GetRand(65, 32), GetRand(65, 32), 1);
    
    int GetRand(int r, int neg)
    {
        return rand.Intn(r) - neg;
    }
    1. Визуально не заметил чего-то, что нельзя было бы делать на C# например, это к вопросу сложности. Совсем не обязательно делать наисложнейшие вещи, я например для себя пишу часто в духе ПП прибегая к ООП весьма редко.

    2. Если например трава и пол имеют оттенок серого, то почему бы не использовать серый и в других текстурах, вы условно взяли не 1 бит, а 2-3, как на gameboy например.

    3. Мне кажется для такого больше подходит формат блога, а статьи можно писать уже когда будут какие-то внушительные изменения или добавление каких-то механик.


    1. otie173 Автор
      09.04.2024 15:30

      спасибо за изменения в коде, подправлю. а что насчёт блога, это как?


      1. Zara6502
        09.04.2024 15:30
        +1

        не обязательно на хабре, а на хабре это вроде Посты называется


        1. otie173 Автор
          09.04.2024 15:30
          +2

          спасибо большое, не знал


          1. Zara6502
            09.04.2024 15:30
            +2

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


  1. InzhirXD
    09.04.2024 15:30

    Мне нравится


  1. Serpentine
    09.04.2024 15:30
    +1

    Видео с геймплеем понравилось, стиль приятный достаточно.

    Единственная проблема возникла - глаза быстро устают. Еще серые фигуры (трава и плитки) перестали читаться и слились с черным фоном. Может это у меня одного такая проблема.

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


    1. otie173 Автор
      09.04.2024 15:30

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


      1. otie173 Автор
        09.04.2024 15:30

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