---
title: "Структура игрового проекта"
description: "Структура игрового проекта: пакеты world, entities, ui и internal — без каши в main. Правила зависимостей, разрыв циклов импорта и сцены со своими данными."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/21-struktura-proekta/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Ebitengine: 2D-игры на Go», урок 21 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ebitengine/lessons/21-struktura-proekta/*

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

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

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

Скелет игры на Ebitengine умещается в шесть пакетов, и у каждого — одна причина существования. main — единственная задача: создать Game и вызвать ebiten.RunGame, обработав ошибку; никакой логики, ввода или отрисовки. internal/game — сердцевина: структура Game, её State и сцены из урока про [сцены](/courses/ebitengine/lessons/15-sceny/), включая переходы и хуки входа. internal/world — мир как он есть: игрок, враги, коллизии, тайлмап — всё, что существует независимо от того, как нарисовано. internal/entities — типы сущностей и поведение: структуры с методами шага, поиска цели и анимационных фаз. internal/ui — всё, что видно поверх мира: HUD, меню и счёт из урока про [игровое состояние](/courses/ebitengine/lessons/16-igrovoe-sostoyanie/). 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 в правильно собранном проекте?

- ✅ Создать Game через конструктор и вызвать ebiten.RunGame, обработав ошибку
- Держать Update и Draw, чтобы логика была ближе к запуску
- Загружать ресурсы, пока остальная инициализация не началась
- Хранить состояние игры в глобальных переменных пакета

Точка входа — самая тонкая часть проекта: две строки запуска и обработка ошибки. Всё остальное — состояние, загрузка, отрисовка — живёт в внутренних пакетах, и main о них почти ничего не знает.

### 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. Как поступить по правилу зависимостей?

- ✅ ui объявляет интерфейс StatusSource, world реализует его своим состоянием
- ui импортирует world и читает поле Player.Score напрямую
- world импортирует ui и рисует HUD сам, раз данные у него
- Счёт кладётся в глобальную переменную, доступную всем пакетам

Зависимость разворачивается интерфейсом: ui формулирует, что ему нужно, мир оказывается подходящим. Прямой импорт делает переименование поля межпакетной поломкой, а глобальная переменная убивает тестируемость и возвращает кашу.

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

| Пакет | Отвечает за | НЕ отвечает за |
| --- | --- | --- |
| 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, а мир лишь владеет коллекцией сущностей и вызывает их методы. Тогда стрелки остаются деревом, компилятор не получает шанса на жалобу, и структура игрового проекта переживает любое пополнение без перелопачивания импортов. Стоит запомнить порядок исправления: сначала поискать, кому на самом деле нужен тип, потом двигать его к владельцу, и только в крайнем случае создавать общий пакет — не наоборот.

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

```go
// internal/world/world.go
package world

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

// internal/entities/player.go
package entities

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

**Что выведет программа?**

- ✅ import cycle not allowed: цикл импорта запрещён
- Соберётся с предупреждением о дублирующихся импортах
- Соберётся: импорты взаимно сокращаются
- Падение в рантайме при первом обращении к сущности

Цикл импорта — ошибка компиляции, а не рантайма: Go не даёт программе собрать связь, где пакеты зависят друг от друга. Лечение — перенос спорного типа в третий пакет, который импортируют оба участника конфликта.

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

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

> **Живое место: пакет-свалка util или common.** Первый конфликт зависимостей решается честно, второй — закидыванием типа «пока» в util, и через месяц util знает про ui, а ui импортирует util, и направление стрелок потеряно. Признак болезни — пакет, который растёт быстрее мира; лечение — интерфейс на границе и перенос типа к его владельцу.

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

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

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

Подсказка: Первый метод возвращает счёт по-английски, второй — множественное число слова «жизнь».

Правильные ответы в пропусках: `Score`, `Lives`

> **Про песочницу: структура начинается с привычек.** Программы песочницы сайта живут в одном файле, и разделение на пакеты — приём локального Go, который песочница не тренирует напрямую. Зато дисциплина зависимостей тренируется и там: интерфейс StatusSource, направление стрелок и правило «сцена владеет данными» видны в любом единственном файле.

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

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

Правильный порядок строк:

1. `package main`
2. `import "mygame/internal/game"`
3. `import "github.com/hajimehoshi/ebiten/v2"`
4. `func main() {`
5. `if err := ebiten.RunGame(game.New()); err != nil {`
6. `panic(err)`
7. `}`

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

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

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

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

- **main.go** — Точка входа игры: конструктор Game и вызов ebiten.RunGame с обработкой ошибки. Логики, ввода и отрисовки здесь быть не должно.
- **internal/** — Каталог с особым смыслом в Go: его пакеты импортирует только тот же модуль. Граница проекта, которую компилятор защищает сам.
- **StatusSource** — Интерфейс, который ui объявляет для источников статуса: Score() и LivesLeft(). Мир его реализует, HUD рисует цифры, не зная про мир.
- **import cycle not allowed** — Сообщение компилятора при цикле импорта. Лечение — перенос спорного типа в общий пакет, который импортируют обе стороны.
- **Сцена владеет данными** — Каждая сцена — структура со своими полями; Game хранит только current, а данные передаются при входе через конструктор.

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

- 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. Общий пакет типов нужен только для того, чем пользуются обе стороны, и держит данные без поведения. Константа, которую знает весь проект, обычно означает, что граница проведена не там, — верните её владельцу.

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

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