Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 21 из 25 ~18 мин +50 XP

Структура игрового проекта

Структура игрового проекта — ответ на вопрос, который возникает сам собой после десятого урока: код работает, но главный файл уже не читается. Разложим игру по пакетам: main, internal/game, world, entities, ui и assets — и введём правила зависимостей, при которых UI не лезет в состояние мира, а компилятор ловит ошибки архитектуры раньше, чем они дойдут до запуска.

Содержание урока · 10 разделов

У каждой игры курса одна биография: в первых уроках main.go — это тридцать строк, в которых видно всё. К середине курса в структуре Game живут тайлмап, камера, коллизии, анимация и счёт, а Update и Draw тянут на сотни строк с комментариями-разделителями. Код работает — и именно в этом ловушка: работает сегодня, а править страшно завтра. Каша в одном файле — не эстетическая претензия, а тормоз. Любое изменение требует прокрутки всего файла, новое поле рискует столкнуться со старым, ни одну часть нельзя протестировать отдельно, а поиск места, где меняется счёт, превращается в чтение метода целиком. Лечение известно из обычного Go — пакеты. Игровой код Go подчиняется тем же законам, что и бэкенд-сервис: у него есть слои, границы и зависимости, которые обязаны идти в одну сторону. Разница только в словаре: вместо хендлеров и репозиториев — мир, сцены и HUD, а вместо запросов — тики. Диагноз ставится за минуту: если рисование счёта и правила столкновений лежат в одном методе, файлов уже мало, нужны пакеты. Хорошая новость в том, что правила универсальны и переносятся на любую следующую игру без переизобретения.

Скелет: структура игрового проекта по пакетам#

Скелет игры на Ebitengine умещается в шесть пакетов, и у каждого — одна причина существования. main — единственная задача: создать Game и вызвать ebiten.RunGame, обработав ошибку; никакой логики, ввода или отрисовки. internal/game — сердцевина: структура Game, её State и сцены из урока про сцены, включая переходы и хуки входа. internal/world — мир как он есть: игрок, враги, коллизии, тайлмап — всё, что существует независимо от того, как нарисовано. internal/entities — типы сущностей и поведение: структуры с методами шага, поиска цели и анимационных фаз. internal/ui — всё, что видно поверх мира: HUD, меню и счёт из урока про игровое состояние. internal/assets — чтение и раздача ресурсов: спрайты, шрифты, звуки. Названия в схеме не случайны: их стоит вводить один раз на проект и не переименовывать между играми — единый словарь экономит переключение контекста при возврате к старому коду. Зависимости идут в одну сторону: main ведёт к game, game — к world, world — к entities, а ui смотрит на мир через узкий интерфейс и не заглядывает в поля. Как только появляется стрелка в обратную сторону — сущность тянет шрифт, мир знает про меню, — скелет болен, и лечить надо границу, а не симптом.

Дерево проекта

GO
// mygame/
//   go.mod
//   main.go          -- только запуск: ebiten.RunGame(game.New())
//   internal/
//     game/          -- Game, State, сцены и переходы
//     world/         -- игрок, враги, коллизии, тайлмап
//     entities/      -- типы сущностей и поведение
//     ui/            -- HUD, меню, счёт на экране
//     assets/        -- чтение и отдача ресурсов
//
// стрелки зависимостей: main -> game -> world -> entities
// ui смотрит на мир через интерфейс, assets нужен всем

Дерево читается как карта ответственности: назначение каждого пакета видно по имени каталога, а папка internal закрывает всё, кроме тонкой точки входа. Такой скелет — стартовый договор итогового проекта: новые файлы не создаются в корне, а ложатся в свой слой, и спор о месте для кода заканчивается за секунды.

Что должен делать main.go в правильно собранном проекте?

internal/: граница против импортов извне#

Каталог internal в Go — не договорённость, а правило компилятора: пакеты внутри него импортируются только кодом того же модуля. Для библиотеки это способ ограничить публичный API, для игры — гарантия автономности: чужой проект не сможет импортировать ваши сцены и мир, а значит, их можно перестраивать, не думая о внешних потребителях. Вторая причина практичнее — самоограничение. Когда пакеты лежат в internal, «временно» утащить зависимость в соседний репозиторий не получится: компилятор не выпустит импорт наружу, и граница работает даже против автора. Границу ставят в корне модуля — mygame/internal/… — чтобы она покрывала все пакеты сразу, а имена внутри держат короткими и строчными: game, world, ui, assets. Один пакет — одна ответственность, и как только в имени просится союз «и», это сигнал, что пакет пора делить. Если появится настоящий повод показать часть кода наружу — обёртка для другого проекта — её выносят в отдельный публичный пакет, а не открывают internal. Ещё одна выгода границы — скорость сборки и чтения: пакеты компилируются независимо, а новый участник команды открывает не весь проект, а тот слой, который ему нужен. Бонус достаётся тестам: тест мира собирается без окна и шрифтов, потому что мир не тянет ничего, кроме собственных типов.

Правило зависимостей: ui через интерфейсы и события#

Самая частая каша начинается там, где HUD берёт данные: дешевле написать world.Player.Score прямо в drawHUD — и вот ui знает про мир всё, а переименовать поле в мире значит сломать отрисовку. Правило курса другое: ui объявляет интерфейс StatusSource с методами Score() int и LivesLeft() int, а world реализует его своим состоянием. Стрелка зависимости разворачивается: ui ничего не знает про мир, мир просто оказывается пригоден для отрисовки. Второй механизм — события. Вместо опроса каждого тика игра рассылает факты: монета собрана, враг уничтожен, уровень пройден, — и HUD обновляет числа. Опрос для игр курса допустим, но события масштабируются лучше, когда слушателей несколько: звук взрыва, частицы и счётчик комбо подписываются на один факт. Плата за правило — пара строк на интерфейс, выигрыш проверяется быстро: HUD тестируется с фейкой вместо мира, пауза и меню не тянут за собой коллизии, а мир переиспользуется с другим экраном. Маленький контракт вместо знания о чужих полях — тот же принцип, что в любом сервисном коде. Проверка направления проста: откройте файл internal/ui и посмотрите список импортов — там не должно быть ни одного пакета мира; если он есть, границу протекло, и чинить надо сегодня, пока протечка одна.

Точка входа и делегация сценам

GO
// main.go — вся точка входа
package main

import (
        "mygame/internal/game"

        "github.com/hajimehoshi/ebiten/v2"
)

func main() {
        if err := ebiten.RunGame(game.New()); err != nil {
                panic(err)
        }
}

// internal/game/game.go — Game не знает деталей
func (g *Game) Update() error {
        return g.current.Update() // делегация активной сцене
}

func (g *Game) Draw(screen *ebiten.Image) {
        g.current.Draw(screen)
}

Точка входа содержит ровно две вещи: создать игру и запустить её, паникуя на ошибке, — сценария «что-то ещё» не существует. Внутри game оба метода делегируют активной сцене: Game не знает ни тайлмапа, ни HUD, ни шрифтов. Эта пара файлов — вся поверхность main-уровня; остальное живёт за границей internal.

internal/ui нужен счёт игрока для HUD. Как поступить по правилу зависимостей?

Пакет — отвечает за — НЕ отвечает за#

ПакетОтвечает заНЕ отвечает за
mainсоздание Game и вызов RunGameлогику, ввод, отрисовку
internal/gameGame, State, сцены и переходыправила мира и рисование HUD
internal/worldигрок, враги, коллизии, тайлмапшрифты, меню, звуки
internal/entitiesтипы сущностей и поведениевладение уровнем и вводом
internal/uiHUD, меню, счёт на экранедвижение, коллизии, правила боя
internal/assetsчтение и отдача ресурсовпринятие игровых решений

Циклические импорты: разрываем общим пакетом#

Компилятор Go запрещает циклы импорта: если world импортирует entities ради типа Enemy, а entities захотел вызвать метод мира, сборка падает с сообщением import cycle not allowed — и это лучший момент для исправления, потому что ошибка поймана до запуска. Лечат цикл переносом: спорный тип уезжает в третий пакет, который импортируют оба. Практика курса — общий пакет внутренних типов, например internal/gamedata: структуры сущностей без поведения, которые мир, сцены и UI согласны использовать. Важно не превратить лекарство в болезнь: gamedata держит только данные без зависимостей, и как только в него просятся методы с вводом или отрисовкой, это сигнал, что граница проведена не там. Правильнее вообще не доводить до цикла: поведение сущностей описывается внутри entities, а мир лишь владеет коллекцией сущностей и вызывает их методы. Тогда стрелки остаются деревом, компилятор не получает шанса на жалобу, и структура игрового проекта переживает любое пополнение без перелопачивания импортов. Стоит запомнить порядок исправления: сначала поискать, кому на самом деле нужен тип, потом двигать его к владельцу, и только в крайнем случае создавать общий пакет — не наоборот.

Что скажет компилятор?

Прочитайте код и выберите, что он выведет:

// internal/world/world.go
package world

import "mygame/internal/entities" // мир знает типы сущностей

// internal/entities/player.go
package entities

import "mygame/internal/world" // сущности захотели карту мира

Сцены владеют своими данными#

Сцены из урока про архитектуру — первый кандидат на правильное распределение: каждая сцена — структура со своими полями, Game хранит только current, а данные едут при входе через конструктор. Меню держит пункты и лучший результат, бой — мир, счёт и частицы, экран поражения — финальную статистику. Сцена боя владеет миром, но не рисует HUD: цифры на экране делает internal/ui, которому бой отдаёт себя как StatusSource. Типовая ошибка — сделать Game складом всего: поле score рядом с state рядом с world, и вот переходы между сценами снова обмениваются через глобальное хранилище, а тест сцены требует собрать всю игру. Проверка простая: если поле нужно только одной сцене, оно живёт в этой сцене; если двум — между ними контракт, а не общий доступ. Так структура игрового проекта остаётся направленной на всём пути к итоговому проекту, где пакеты курса собираются в одну аркаду и любое нарушение границы видно по импортам. Отдельный приз — переиспользование: экран паузы из урока про сцены подключается к любому миру, который умеет StatusSource, и вы пишете его один раз на весь курс.

Соберите контракт для HUD

Восстановите два пропуска: интерфейс, который ui объявляет для источников статуса.

// internal/ui/hud.go type StatusSource interface { () int // набранные очки Left() int // оставшиеся жизни }

Точка входа по порядку

Соберите main.go правильно собранного проекта.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Правила структуры одним списком#

  • main.go — тонкая точка входа: создать Game и вызвать RunGame, обработав ошибку
  • Шесть пакетов: main, game, world, entities, ui, assets — один слой на каждый
  • internal/ закрыт для чужих модулей на уровне компилятора, а не по договорённости
  • ui смотрит на мир через интерфейсы и события, а не через поля напрямую
  • Цикл импорта разрывают общим пакетом типов: только данные, без поведения
  • Поле одной сцены живёт в сцене; общие данные едут через контракт при входе
  • Имена пакетов — короткие, строчные, без союза «и» в названии
  • Стрелки зависимостей ведут от main вглубь; обратная стрелка — сигнал больной границы

Структура — последний каркасный урок курса: дальше остаются материалы и практика, а не фундамент. Домашнее задание — разнести собственный проект по скелету и прогнать стрелки: каждая зависимость обязана вести от main через game вглубь, а ui — смотреть через интерфейс. Полезный приём — нарисовать пакетный граф на бумаге: шесть узлов и стрелки, цикл на бумаге виден за минуту, в коде — за вечер. Если проект пока честно помещается в один файл — проверьте себя списком выше: он же станет чек-листом при первом разрастании. Последняя проверка зрелости структуры проста: если новый игрок — экран, звук, враг — добавляется в один пакет, не трогая остальные, скелет работает; если правка ползёт по трём каталогам одновременно, ищите протечку границы. А когда захочется углубить архитектуру сервисного уровня, тот же словарь слоёв и границ ждёт в продвинутом курсе по Go: игровая практика здесь отлично тренирует то, что там пригодится. Помните порядок ценности: сначала граница, потом имя, и только потом красота дерева каталогов — именно в этом порядке принимаются решения при переезде.

Термины урока

Термин

main.go

нажмите, чтобы перевернуть
Определение

Точка входа игры: конструктор Game и вызов ebiten.RunGame с обработкой ошибки. Логики, ввода и отрисовки здесь быть не должно.

1 / 5

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • main.go — тонкая точка входа: создать Game и вызвать RunGame, обработав ошибку.
  • Шесть пакетов: main, game, world, entities, ui, assets — один слой на каждый.
  • internal/ закрыт для чужих модулей на уровне компилятора — граница, а не договорённость.
  • ui смотрит на мир через интерфейсы и события, а не через поля напрямую.
  • Цикл импорта разрывают общим пакетом типов: только данные, без поведения.
  • Сцены владеют своими данными: поле одной сцены живёт в ней, общее — в контракте.
  • Стрелки зависимостей ведут от main вглубь; обратная стрелка — сигнал больной границы.

Домашнее задание

Разнесите текущий проект по скелету урока: main.go оставьте с запуском, мир с коллизиями уедет в internal/world, сущности — в internal/entities, HUD и счёт — в internal/ui, сцены — в internal/game. После переезда прогоните стрелки зависимостей: ui не должен импортировать world напрямую — объявите StatusSource с двумя методами и реализуйте его структурой мира, а счёт из урока про игровое состояние передавайте через контракт. Затем нарисуйте пакетный граф и поищите цикл: если нашёлся, разорвите общим пакетом gamedata и убедитесь, что в нём только данные без поведения. Проверьте живое место: попытка импортировать пакет из internal/ в чужом модуле обязана падать по компилятору — это не баг, а граница. В конце добавьте в README проекта схему пакетов: одна диаграмма экономит час объяснений любому, кто откроет код после вас. Финальная проверка — собрать проект заново и убедиться, что go build не жалуется: переезд считается завершённым только чистой сборкой.

Личные заметкиПусто
Хранятся только в вашем браузере (Ebitengine · Структура игрового проекта)

Вопросы и ответы по уроку

Проект ещё маленький — тянуть ли пакеты сейчас?

Дисциплина дешевле в начале: разделить один файл можно в любой момент, но чем дольше код живёт в каше, тем дороже переезд. Минимальный вариант — main и internal/game с миром и HUD внутри, а пакеты выделяются по мере роста. Ориентир: новая граница появляется тогда, когда в старом пакете поселились две ответственности. Ошибка наоборот — делить заранее: пять пустых пакетов в проекте на три файла не архитектура, а бюрократия, и возврат в один файл в таком случае честнее.

Куда класть общие константы вроде TileSize?

К их владельцу: размер тайла — константа мира, скорость анимации — сущности, скорость текста — UI. Общий пакет типов нужен только для того, чем пользуются обе стороны, и держит данные без поведения. Константа, которую знает весь проект, обычно означает, что граница проведена не там, — верните её владельцу.

Можно ли обойтись одним пакетом и множеством файлов?

Да, и на старте это лучше каши в одном файле: файлы упорядочивают чтение, но не границы. Пакеты нужны, когда появляется желание запретить чужой доступ: файл нельзя закрыть, пакет — можно. Практический ориентир курса: три файла в одном пакете — норма, восемь — повод делить.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Chromedp25 уроков~11 чпрактика25 уроков уже доступны

Chromedp в Go: автоматизация Chrome без Selenium

Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.

  • Запускать Chrome из Go и управлять страницами
  • Находить элементы селекторами и ждать их появления
  • Кликать, заполнять формы и извлекать данные
  • Делать скриншоты, PDF и выполнять JavaScript