Содержание урока · 10 разделов
- Скелет: структура игрового проекта по пакетам
- internal/: граница против импортов извне
- Правило зависимостей: ui через интерфейсы и события
- Пакет — отвечает за — НЕ отвечает за
- Циклические импорты: разрываем общим пакетом
- Сцены владеют своими данными
- Правила структуры одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
У каждой игры курса одна биография: в первых уроках 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/game | Game, State, сцены и переходы | правила мира и рисование HUD |
| internal/world | игрок, враги, коллизии, тайлмап | шрифты, меню, звуки |
| internal/entities | типы сущностей и поведение | владение уровнем и вводом |
| internal/ui | HUD, меню, счёт на экране | движение, коллизии, правила боя |
| 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, и вы пишете его один раз на весь курс.
Первый конфликт зависимостей решается честно, второй — закидыванием типа «пока» в util, и через месяц util знает про ui, а ui импортирует util, и направление стрелок потеряно. Признак болезни — пакет, который растёт быстрее мира; лечение — интерфейс на границе и перенос типа к его владельцу.
Соберите контракт для HUD
Восстановите два пропуска: интерфейс, который ui объявляет для источников статуса.
Программы песочницы сайта живут в одном файле, и разделение на пакеты — приём локального Go, который песочница не тренирует напрямую. Зато дисциплина зависимостей тренируется и там: интерфейс StatusSource, направление стрелок и правило «сцена владеет данными» видны в любом единственном файле.
Точка входа по порядку
Соберите main.go правильно собранного проекта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Правила структуры одним списком#
- main.go — тонкая точка входа: создать Game и вызвать RunGame, обработав ошибку
- Шесть пакетов: main, game, world, entities, ui, assets — один слой на каждый
- internal/ закрыт для чужих модулей на уровне компилятора, а не по договорённости
- ui смотрит на мир через интерфейсы и события, а не через поля напрямую
- Цикл импорта разрывают общим пакетом типов: только данные, без поведения
- Поле одной сцены живёт в сцене; общие данные едут через контракт при входе
- Имена пакетов — короткие, строчные, без союза «и» в названии
- Стрелки зависимостей ведут от main вглубь; обратная стрелка — сигнал больной границы
Структура — последний каркасный урок курса: дальше остаются материалы и практика, а не фундамент. Домашнее задание — разнести собственный проект по скелету и прогнать стрелки: каждая зависимость обязана вести от main через game вглубь, а ui — смотреть через интерфейс. Полезный приём — нарисовать пакетный граф на бумаге: шесть узлов и стрелки, цикл на бумаге виден за минуту, в коде — за вечер. Если проект пока честно помещается в один файл — проверьте себя списком выше: он же станет чек-листом при первом разрастании. Последняя проверка зрелости структуры проста: если новый игрок — экран, звук, враг — добавляется в один пакет, не трогая остальные, скелет работает; если правка ползёт по трём каталогам одновременно, ищите протечку границы. А когда захочется углубить архитектуру сервисного уровня, тот же словарь слоёв и границ ждёт в продвинутом курсе по Go: игровая практика здесь отлично тренирует то, что там пригодится. Помните порядок ценности: сначала граница, потом имя, и только потом красота дерева каталогов — именно в этом порядке принимаются решения при переезде.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 не жалуется: переезд считается завершённым только чистой сборкой.
Личные заметкиПусто
Вопросы и ответы по уроку
Проект ещё маленький — тянуть ли пакеты сейчас?
Дисциплина дешевле в начале: разделить один файл можно в любой момент, но чем дольше код живёт в каше, тем дороже переезд. Минимальный вариант — main и internal/game с миром и HUD внутри, а пакеты выделяются по мере роста. Ориентир: новая граница появляется тогда, когда в старом пакете поселились две ответственности. Ошибка наоборот — делить заранее: пять пустых пакетов в проекте на три файла не архитектура, а бюрократия, и возврат в один файл в таком случае честнее.
Куда класть общие константы вроде TileSize?
К их владельцу: размер тайла — константа мира, скорость анимации — сущности, скорость текста — UI. Общий пакет типов нужен только для того, чем пользуются обе стороны, и держит данные без поведения. Константа, которую знает весь проект, обычно означает, что граница проведена не там, — верните её владельцу.
Можно ли обойтись одним пакетом и множеством файлов?
Да, и на старте это лучше каши в одном файле: файлы упорядочивают чтение, но не границы. Пакеты нужны, когда появляется желание запретить чужой доступ: файл нельзя закрыть, пакет — можно. Практический ориентир курса: три файла в одном пакете — норма, восемь — повод делить.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript