← Назад на главную

Пишем игру Змейка на GO для Android, iOS и ПК

Привет! Сегодня мы разберем, как написать классическую игру "Змейка" на языке Go с использованием фреймворка Fyne, которая будет работать на Android, iOS и ПК. Это не просто учебный пример, а полноценное приложение с современным интерфейсом и оптимизацией.

Общая структура проекта

Импорты и зависимости

package main

import (
"fmt"
"image/color"
"math"
"math/rand"
"sync"
"sync/atomic"
"time"

"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)

Объяснение импортов:

  • fmt - для форматированного вывода (отображение счета)
  • image/color - работа с цветами
  • math, math/rand - математические операции и генерация случайных чисел
  • sync, sync/atomic - синхронизация потоков
  • time - работа со временем (таймеры, задержки)
  • fyne.io/fyne/v2 и подпакеты - фреймворк для создания GUI

Кастомная тема оформления

Определение цветовой палитры

// Кастомная тема
type CustomTheme struct{}

// Цвета
var (
Dark = &color.RGBA{R: 24, G: 24, B: 24, A: 255} // Основной темный фон
Light = &color.RGBA{R: 72, G: 72, B: 72, A: 255} // Светлые кнопки и элементы
Text = &color.RGBA{R: 255, G: 255, B: 255, A: 255} // Белый текст
Disabled = &color.RGBA{R: 31, G: 31, B: 31, A: 255} // Цвет неактивные кнопки
Hover = &color.RGBA{R: 120, G: 120, B: 120, A: 255} // Цвет при наведении
Shadow = &color.RGBA{R: 120, G: 120, B: 120, A: 255} // Цвет теней
)

Методы темы Fyne:

// Возвращаем цвета для разных элементов интерфейса
func (t *CustomTheme) Color(name fyne.ThemeColorName, _ fyne.ThemeVariant) color.Color {
switch name {
case theme.ColorNameBackground:
return Dark
case theme.ColorNameForeground:
return Text
case theme.ColorNameButton:
return Light
case theme.ColorNameDisabledButton:
return Disabled
case theme.ColorNameDisabled:
return Light
case theme.ColorNameInputBackground:
return Dark
case theme.ColorNamePlaceHolder:
return Disabled
case theme.ColorNamePrimary:
return Light
case theme.ColorNameHover:
return Hover
case theme.ColorNameFocus:
return Light
case theme.ColorNameScrollBar:
return Light
case theme.ColorNameShadow:
return Shadow
default:
// Для всех остальных цветов используем стандартную тему
return theme.DefaultTheme().Color(name, theme.VariantDark)
}
}

// Шрифты оставляем стандартные
func (t *CustomTheme) Font(style fyne.TextStyle) fyne.Resource {
return theme.DefaultTheme().Font(style)
}

// Иконки тоже стандартные
func (t *CustomTheme) Icon(name fyne.ThemeIconName) fyne.Resource {
return theme.DefaultTheme().Icon(name)
}

// Настраиваем размеры элементов интерфейса
func (t *CustomTheme) Size(name fyne.ThemeSizeName) float32 {
switch name {
case theme.SizeNamePadding:
return 5
case theme.SizeNameText:
return 16
case theme.SizeNameHeadingText:
return 21
case theme.SizeNameWindowButtonRadius:
return 40
default:
return theme.DefaultTheme().Size(name)
}
}

Каждый элемент интерфейса запрашивает свой цвет по имени. Мы переопределяем только нужные нам цвета.


Модель данных игры

Перечисление направлений

// Возможные направления движения змейки
type Direction int

const (
Up Direction = iota
Down
Left
Right
)
iota автоматически присваивает последовательные значения константам.

Структура состояния игры

// Информация о текущей игре
type GameState struct {
snake []fyne.Position // Координаты всех частей змейки
food fyne.Position // Где находится еда
direction Direction // Куда сейчас движется змейка
nextDir Direction // Куда повернет в следующий раз
gameOver int32 // Закончена ли игра (1 - да, 0 - нет)
score int32 // Текущий счет
cellSize float32 // Размер одной клетки на поле
fieldSize fyne.Size // Общий размер игрового поля
}

Почему atomic? Поля gameOver и score доступны из нескольких горутин одновременно. Atomic операции гарантируют, что не будет состояния гонки.

Настройки игры

// Настройки игры
const (
baseSpeed = 300 * time.Millisecond // Начальная скорость
minSpeed = 150 * time.Millisecond // Максимальная скорость (самая быстрая)
speedStep = 10 * time.Millisecond // На сколько ускоряется игра за каждые 2 очка
initialSnake = 3 // Длина змейки на старте
maxSnakeSegments = 100 // Максимальная длина змейки
columns = 10 // Ширина поля в клетках
)

Глобальные переменные и их назначение

// Глобальные переменные
var (
gameState GameState // Текущее состояние игры
gameContainer *fyne.Container // Контейнер где рисуется змейка и еда
controlContainer *fyne.Container // Контейнер с кнопками управления
newGameButton *widget.Button // Кнопка "Новая игра"
myWindow fyne.Window // Главное окно приложения
currentSpeed = baseSpeed // Текущая скорость движения змейки
scoreLabel *widget.Label // Надпись с текущим счетом
topBar *fyne.Container // Верхняя панель со счетом и кнопкой
gameArea *fyne.Container // Область где происходит игра
borderRect *canvas.Rectangle // Рамка вокруг игрового поля

// Заранее созданные объекты для рисования (чтобы не создавать их заново)
segmentPool []*canvas.Rectangle // Клетки для змейки
foodCircle *canvas.Circle // Кружок для еды
gameOverText *canvas.Text // Надпись "GAME OVER"

// Система обмена сообщениями между частями программы
gameCommands chan interface{} // Канал для отправки команд (поворот, рестарт)
gameMutex sync.RWMutex // Защита от одновременного доступа к данным игры
renderSignal chan struct{} // Сигнал что нужно перерисовать экран
frameTime = 16 * time.Millisecond // Как часто перерисовываем экран (~60 раз в секунду)
isStarted atomic.Bool // Игра активна или нет
)

Система команд

// Типы команд которые можно отправлять в игру
const (
cmdDirection int = iota // Команда изменить направление
cmdRestart // Команда начать новую игру
)

// Структура команды - что делать и с какими данными
type command struct {
cmdType int
data interface{}
}


Главная функция main()

Инициализация приложения

func main() {
// Настраиваем размеры отступов
br.Resize(fyne.NewSize(10, 10))

// Инициализируем генератор случайных чисел
rand.New(rand.NewSource(time.Now().UnixNano()))

// Создаем приложение и окно
myApp := app.New()
myApp.Settings().SetTheme(&CustomTheme{})
myWindow = myApp.NewWindow("Змейка")

// Запускаем в полноэкранном режиме
myWindow.SetFullScreen(true)
// или в заданном размере
// myWindow.Resize(fyne.NewSize(400, 800))

Создание графических элементов

	// Создаем контейнер где будет происходить игра
gameContainer = container.NewWithoutLayout()

// Создаем рамку вокруг игрового поля
borderRect = canvas.NewRectangle(Dark)
borderRect.StrokeWidth = 2
borderRect.StrokeColor = Light

// Создаем еду (красный кружок)
foodCircle = canvas.NewCircle(&color.RGBA{R: 235, G: 51, B: 36, A: 255})

// Создаем текст для окончания игры
gameOverText = canvas.NewText("GAME OVER", &color.RGBA{R: 235, G: 51, B: 36, A: 255})
gameOverText.TextSize = 24
gameOverText.TextStyle.Bold = true

Оптимизация: пул объектов

	// Заранее создаем все сегменты змейки чтобы не создавать их во время игры
segmentPool = make([]*canvas.Rectangle, maxSnakeSegments)
for i := 0; i < maxSnakeSegments; i++ {
rect := canvas.NewRectangle(&color.RGBA{R: 17, G: 104, B: 50, A: 255}) // Зеленый цвет
segmentPool[i] = rect
gameContainer.Add(rect)
rect.Hide() // Прячем пока не используются
}

Вместо постоянного создания и удаления объектов мы используем существующие, что значительно эффективнее.

Создание интерфейса

	// Создаем кнопку новой игры
newGameButton = widget.NewButton("Новая игра", func() {
gameCommands <- command{cmdType: cmdRestart}
})
newGameButton.Importance = widget.HighImportance

// Создаем верхнюю панель с отступами
topBar = container.NewVBox(br, container.NewHBox(
widget.NewLabel("Счет:"),
br, scoreLabel,
layout.NewSpacer(), // Растягивающийся пробел
newGameButton, br,
), br)

// Добавляем фон для верхней панели
topBarBg := canvas.NewRectangle(Dark)
topBarContainer := container.NewStack(topBarBg, topBar)

// Создаем кнопки управления для телефонов
createControlButtons()

// Создаем основную область игры
baseGameArea := container.NewWithoutLayout()
baseGameArea.Add(borderRect)
baseGameArea.Add(gameContainer)

// Обертываем в виджет который следит за размером
gameAreaNotifier := newResizeNotifier(baseGameArea, updateGameSize)
gameArea = baseGameArea

// Контейнер для отслеживания изменений размера
sizeWatcher := container.NewGridWithColumns(1, container.NewStack(
canvas.NewRectangle(color.Transparent),
gameAreaNotifier,
))

// Создаем фон для всего приложения
bg := canvas.NewRectangle(Dark)

// Собираем все вместе: верхняя панель, игровая область, кнопки управления
content := container.NewStack(
bg,
container.NewBorder(
topBarContainer, // Вверху - панель с счетом
controlContainer, // Внизу - кнопки управления
nil, nil, // Слева и справа ничего
sizeWatcher, // В центре - игровое поле
),
)

Запуск горутин

	// Запускаем фоновые процессы
go gameLoop() // Основной игровой цикл
go startRenderLoop() // Цикл перерисовки экрана

// Настраиваем начальный размер игрового поля
updateGameSize()

// Показываем окно и запускаем приложение
myWindow.ShowAndRun()

Игровой цикл (gameLoop)

// Главный игровой цикл - здесь обрабатываются команды и двигается змейка
func gameLoop() {
lastTime := time.Now()
accumulator := time.Duration(0) // Накопитель времени для плавного движения

for {
select {
case cmd := <-gameCommands: // Если пришла команда
switch c := cmd.(type) {
case command:
switch c.cmdType {
case cmdDirection: // Команда поворота
if dir, ok := c.data.(Direction); ok {
handleDirectionChange(dir)
}
case cmdRestart: // Команда перезапуска
handleRestart()
lastTime = time.Now()
accumulator = 0
}
}
default:
// Проверяем не закончена ли игра
gameOver := atomic.LoadInt32(&gameState.gameOver) == 1

// Если игра активна, обновляем состояние
if !gameOver && isStarted.Load() {
currentTime := time.Now()
elapsed := currentTime.Sub(lastTime)
lastTime = currentTime
accumulator += elapsed

// Двигаем змейку с текущей скоростью
for accumulator >= currentSpeed {
moveSnake()
accumulator -= currentSpeed

// Просим перерисовать экран
select {
case renderSignal <- struct{}{}:
default:
}
}
}

// Даем процессору передохнуть
time.Sleep(1 * time.Millisecond)
}
}
}

Важность фиксированного шага времени: Игра должна работать с одинаковой скоростью независимо от производительности устройства.


Логика движения змейки

// Логика движения
func moveSnake() {
gameMutex.Lock()
defer gameMutex.Unlock()

// Если игра закончена, ничего не делаем
if atomic.LoadInt32(&gameState.gameOver) == 1 {
return
}

// Применяем поворот
gameState.direction = gameState.nextDir

// Берем текущую позицию головы
head := gameState.snake[0]
var newHead fyne.Position

// Вычисляем новую позицию головы в зависимости от направления
switch gameState.direction {
case Up:
newHead = fyne.Position{X: head.X, Y: head.Y - gameState.cellSize}
case Down:
newHead = fyne.Position{X: head.X, Y: head.Y + gameState.cellSize}
case Left:
newHead = fyne.Position{X: head.X - gameState.cellSize, Y: head.Y}
case Right:
newHead = fyne.Position{X: head.X + gameState.cellSize, Y: head.Y}
}

Проверка столкновений

	// Проверяем не врезались ли в стену
if newHead.X < -0.1 || newHead.Y < -0.1 ||
newHead.X+gameState.cellSize > gameState.fieldSize.Width+0.1 ||
newHead.Y+gameState.cellSize > gameState.fieldSize.Height+0.1 {
atomic.StoreInt32(&gameState.gameOver, 1)
return
}

// Проверяем не врезались ли в себя
for i, seg := range gameState.snake {
if i == 0 { // Пропускаем голову
continue
}
if math.Abs(float64(newHead.X-seg.X)) < 0.1 && math.Abs(float64(newHead.Y-seg.Y)) < 0.1 {
atomic.StoreInt32(&gameState.gameOver, 1)
return
}
}

Обработка съедания еды

	// Переводим координаты в номера клеток для сравнения
foodCellX := int(math.Round(float64(gameState.food.X / gameState.cellSize)))
foodCellY := int(math.Round(float64(gameState.food.Y / gameState.cellSize)))
newHeadCellX := int(math.Round(float64(newHead.X / gameState.cellSize)))
newHeadCellY := int(math.Round(float64(newHead.Y / gameState.cellSize)))

// Проверяем не съели ли еду
ateFood := (foodCellX == newHeadCellX) && (foodCellY == newHeadCellY)

// Добавляем новую голову
gameState.snake = append([]fyne.Position{newHead}, gameState.snake...)

if ateFood {
// Увеличиваем счет
newScore := atomic.AddInt32(&gameState.score, 1)
scoreLabel.SetText(fmt.Sprintf("%d", newScore))

// Каждые 2 очка ускоряем игру
if newScore%2 == 0 {
newSpeed := currentSpeed - speedStep
if newSpeed < minSpeed {
newSpeed = minSpeed // Но не быстрее максимальной скорости
}
currentSpeed = newSpeed
}

// Пытаемся разместить новую еду
if !gameState.placeFood() {
// Если некуда поставить еду - победа!
atomic.StoreInt32(&gameState.gameOver, 1)
gameOverText.Text = "YOU WIN!"
}
} else {
// Если не ели еду, убираем хвост (змейка не растет)
gameState.snake = gameState.snake[:len(gameState.snake)-1]
}
}


Алгоритм размещения еды

Еда никогда не появляется на теле змейки - для этого используем алгоритм:

  1. Составляем список всех клеток поля
  2. Убираем из списка те, где находится змейка
  3. Случайно выбираем из оставшихся клеток
// Размещаем еду на свободной клетке
func (g *GameState) placeFood() bool {
cellSize := g.cellSize
// Считаем сколько клеток вмещается в поле
cols := int(g.fieldSize.Width / cellSize)
rows := int(g.fieldSize.Height / cellSize)

// Запоминаем какие клетки заняты змейкой
occupied := make(map[[2]int]bool)
for _, seg := range g.snake {
cellX, cellY := positionToCell(seg, cellSize)
occupied[[2]int{cellX, cellY}] = true
}

// Если все клетки заняты - победа!
if len(occupied) >= cols*rows {
return false
}

// Собираем список свободных клеток
var freeCells [][2]int
for y := 0; y < rows; y++ {
for x := 0; x < cols; x++ {
if !occupied[[2]int{x, y}] {
freeCells = append(freeCells, [2]int{x, y})
}
}
}

// Выбираем случайную свободную клетку
if len(freeCells) > 0 {
randomIndex := rand.Intn(len(freeCells))
cell := freeCells[randomIndex]
g.food = cellToPosition(cell[0], cell[1], cellSize)
return true
}

return false
}

Цикл отрисовки

// Отдельный цикл для плавной перерисовки экрана
func startRenderLoop() {
ticker := time.NewTicker(frameTime)
defer ticker.Stop()

for {
select {
case <-ticker.C: // Регулярная перерисовка (60 раз в секунду)
renderGame()
case <-renderSignal: // Перерисовка по требованию (после движения змейки)
renderGame()
}
}
}

// Перерисовываем все что видно на экране
func renderGame() {
gameMutex.RLock()
defer gameMutex.RUnlock()

snake := gameState.snake
food := gameState.food
cellSize := gameState.cellSize
fieldSize := gameState.fieldSize

// Рисуем змейку - показываем столько сегментов
for i, seg := range segmentPool {
if i < len(snake) && isStarted.Load() {
seg.CornerRadius = 2 // Закругляем уголки
seg.Resize(fyne.NewSize(cellSize-2, cellSize-2)) // Чуть меньше клетки
seg.Move(snake[i])
seg.Show() // Показываем используемые сегменты
} else {
seg.Hide() // Прячем неиспользуемые
}
}

// Рисуем еду
if isStarted.Load() {
foodCircle.Resize(fyne.NewSize(cellSize-4, cellSize-4))
foodCircle.Move(fyne.NewPos(food.X+1, food.Y+1))
foodCircle.Show()
} else {
foodCircle.Hide()
}

// Если игра закончена, показываем надпись
if atomic.LoadInt32(&gameState.gameOver) == 1 && isStarted.Load() {
// Центрируем надпись
gameOverText.Move(fyne.NewPos(
fieldSize.Width/3-gameOverText.Size().Width/2,
fieldSize.Height/2-gameOverText.Size().Height/2,
))
gameOverText.Show()
newGameButton.Enable() // Включаем кнопку новой игры
} else {
gameOverText.Hide()
}
}


Адаптация к разным размерам экранов

Динамически вычисляем размер игрового поля:

// Обновляем размеры когда меняется размер окна
func updateGameSize() {
if gameArea == nil {
return
}

size := gameArea.Size()
if size.Width <= 0 || size.Height <= 0 {
return
}

// Вычисляем размер клетки так чтобы в ширину вмещалось 10 клеток
cellSize := size.Width / float32(columns)
rows := int(size.Height / cellSize)

gameMutex.Lock()
gameState.cellSize = cellSize
// Выравниваем размер поля чтобы он был кратен размеру клетки
gameState.fieldSize = fyne.NewSize(
float32(columns)*cellSize,
float32(rows)*cellSize,
)
gameMutex.Unlock()

// Подстраиваем размеры контейнеров
gameContainer.Resize(gameState.fieldSize)
borderRect.Resize(fyne.NewSize(
gameState.fieldSize.Width+4, // +4 для рамки
gameState.fieldSize.Height+4,
))

// Центрируем игровое поле в доступном пространстве
gamePos := fyne.NewPos(
(size.Width-gameState.fieldSize.Width)/2,
(size.Height-gameState.fieldSize.Height)/2,
)
gameContainer.Move(gamePos)
borderRect.Move(fyne.NewPos(gamePos.X-2, gamePos.Y-2)) // С учетом рамки

// Запрашиваем перерисовку
select {
case renderSignal <- struct{}{}:
default:
}
}
Этот метод вызывается каждый раз при изменении размера окна


Управление на разных устройствах

На компьютере — клавиатура

// Обрабатываем нажатия клавиш на клавиатуре ПК
func handleKeyEvent(keyEvent *fyne.KeyEvent) {
gameOver := atomic.LoadInt32(&gameState.gameOver) == 1

// Пробел - начать новую игру когда текущая закончена
if keyEvent.Name == fyne.KeySpace && gameOver {
gameCommands <- command{cmdType: cmdRestart}
return
}

// Игнорируем клавиши если игра не активна
if gameOver || !isStarted.Load() {
return
}

// Обрабатываем стрелки и другие управляющие клавиши
switch keyEvent.Name {
case fyne.KeyUp:
gameCommands <- command{cmdType: cmdDirection, Up}
case fyne.KeyDown:
gameCommands <- command{cmdType: cmdDirection, Down}
case fyne.KeyLeft:
gameCommands <- command{cmdType: cmdDirection, Left}
case fyne.KeyRight:
gameCommands <- command{cmdType: cmdDirection, Right}
case fyne.KeyR: // R - рестарт
gameCommands <- command{cmdType: cmdRestart}
}
}

На телефоне - сенсорные кнопки

Создаем виртуальный джойстик внизу экрана:

// Создаем кнопки управления для мобильных устройств
func createControlButtons() {
buttonSize := fyne.NewSize(80, 80)

// Кнопка движения вверх
upButton := widget.NewButtonWithIcon("\n\n", theme.MoveUpIcon(), func() {
if atomic.LoadInt32(&gameState.gameOver) == 0 && isStarted.Load() {
gameCommands <- command{cmdType: cmdDirection, Up}
}
})
upButton.Resize(buttonSize)

// Кнопка движения вниз
downButton := widget.NewButtonWithIcon("\n\n", theme.MoveDownIcon(), func() {
if atomic.LoadInt32(&gameState.gameOver) == 0 && isStarted.Load() {
gameCommands <- command{cmdType: cmdDirection, Down}
}
})
downButton.Resize(buttonSize)

// Кнопка движения влево
leftButton := widget.NewButtonWithIcon("\n\n", theme.NavigateBackIcon(), func() {
if atomic.LoadInt32(&gameState.gameOver) == 0 && isStarted.Load() {
gameCommands <- command{cmdType: cmdDirection, Left}
}
})
leftButton.Resize(buttonSize)

// Кнопка движения вправо
rightButton := widget.NewButtonWithIcon("\n\n", theme.NavigateNextIcon(), func() {
if atomic.LoadInt32(&gameState.gameOver) == 0 && isStarted.Load() {
gameCommands <- command{cmdType: cmdDirection, Right}
}
})
rightButton.Resize(buttonSize)

// Располагаем кнопки в виде крестика
controlGrid := container.NewGridWithColumns(3,
container.NewVBox(
layout.NewSpacer(),
container.NewBorder(nil, br, br, container.NewVBox(br, br), leftButton)),
container.NewVBox(
container.NewBorder(br, br, br, br, upButton),
container.NewBorder(br, br, br, container.NewVBox(br, br), downButton)),
container.NewVBox(
layout.NewSpacer(),
container.NewBorder(nil, br, br, container.NewVBox(br, br), rightButton)),
)

controlContainer = controlGrid
}

Fyne автоматически подстраивает размер кнопок под плотность пикселей экрана.


Особенности реализации для кроссплатформенности

Единая кодовая база

Код полностью одинаков для всех платформ. Fyne автоматически адаптирует:

  • Размеры элементов под плотность пикселей
  • Обработку ввода (касания/клики)
  • Системные шрифты и иконки

Производительность

  • Пул объектов избегает создания/удаления в рантайме
  • Разделение логики и рендеринга гарантирует плавность
  • Atomic операции обеспечивают безопасность многопоточности
Изображение

Видео работы игры на смартфоне с ОС Android

Весь код игры и скомпилированный файл .apk есть на Boosty, переходите и скачивайте.

Этот код можно использовать как основу для других игр - тетриса, сапера, арканоида. Принципы остаются теми же: игровой цикл, разделение логики и отображения, адаптивный интерфейс.

Спасибо за ваше время и внимание! Ваша поддержка очень важна для меня! Если вам понравилась статья, пожалуйста, поставьте лайк этой статье на моем канале Дзен

Подпишитесь на мой Телеграм-канал, чтобы быть в курсе новых статей.

Удачи в программировании!