---
title: "Сцены и переходы между ними"
description: "Сцены игры в Ebitengine: конечный автомат сцен в Update и Draw, интерфейс Scene для меню и паузы, переходы без полуинициализации и ebitengine state без каши."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/15-sceny/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Сцены и переходы между ними

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

Сцены игры — меню, уровень, пауза, экран поражения — превращают набор функций в цельную программу: в каждый момент работает ровно одна из них. Разберём ebitengine state двумя способами: конечный автомат на switch для игр курса и интерфейс Scene, когда сцен становится много, — с правилами переходов, которые не оставляют полуинициализированных экранов.

У вашей игры уже есть почти всё: мир на тайлмапе, камера, текст HUD. Но запускается она сразу в бой — нет меню, нет паузы, нет экрана поражения. Без них игра выглядит демкой: игрок не может ни передохнуть, ни вернуться на старт, ни узнать правила. Всё это — сцены, и их устройство — вопрос не движка, а архитектуры: Ebitengine не знает ни одного слова про сцены, у него есть только Update и Draw, и именно в них вы строите переключение сами. В этом уроке сцены игры собираются дважды: простым автоматом и интерфейсом — и вы научитесь выбирать между ними осознанно.

### Конечный автомат: ebitengine state в одной переменной

Первая модель — конечный автомат: у игры есть поле state с конечным набором значений, и каждый тик она выполняет ветку текущего состояния. Значения объявляются через iota — классический перечислимый тип Go: stateMenu, statePlay, statePause. Обновление и отрисовка зеркалят друг друга: switch в Update вызывает логику активной сцены, switch в Draw рисует её картинку. Это и есть ebitengine state в чистом виде — никакой магии движка, только дисциплина ветвления.

Сила автомата — в его честности: невозможно выполнить логику паузы, пока state равен statePlay, потому что ветка просто не выбрана. Движок не вмешивается: Update и Draw приходят с той же частотой, что и раньше, а решение, что делать в этом тике, принимает switch. Для игр курса — две-четыре сцены, переходы по клавишам — автомат закрывает вопрос целиком, и переусложнять его интерфейсами раньше времени нет причин. Порог входа минимальный: одно поле, две функции, ноль новых типов.

Для отладки автомату полезен метод String() на типе gameState: свитч по константам возвращает имя состояния, и лог выводит не ноль и единицу, а menu и play. Ещё одна честность компилятора: switch без default не заставит покрыть все состояния — пропущенная ветка ловится только тестом. В интерфейсной модели ниже компилятор требовательнее: сцена без Draw просто не соберётся.

### Автомат меню, игры и паузы

```go
type gameState int

const (
        stateMenu gameState = iota
        statePlay
        statePause
)

func (g *Game) Update() error {
        switch g.state {
        case stateMenu:
                if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
                        g.state = statePlay // переход — только в Update
                }
        case statePlay:
                g.updatePlay()
                if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
                        g.state = statePause
                }
        case statePause:
                if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
                        g.state = statePlay
                }
        }
        return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
        switch g.state {
        case stateMenu:
                drawMenu(screen)
        case statePause:
                g.drawPlay(screen)
                drawPauseOverlay(screen)
        default:
                g.drawPlay(screen)
        }
}
```

Два switch зеркально обходят состояния: Update двигает логику и ловит переходы, Draw рисует текущую сцену. Пауза рисует игру и накрывает её оверлеем — игрок видит, где остановился. JustPressed для переходов обязателен: зажатый Escape на IsKeyPressed мигал бы между паузой и игрой шестьдесят раз в секунду.

**Вопрос:** Где должен жить switch по состоянию игры?

- Только в Update: Draw рисует без условий
- Только в Draw: Update вызывает общий updatePlay
- ✅ В обоих: Update выбирает логику, Draw — картинку
- Нигде: движок сам переключает сцены по состоянию

Обновление и отрисовка — две стороны одного состояния: если switch будет только в Update, меню рисует поле боя; если только в Draw, логика меню выполняется в бою. Движок о сценах не знает — переключение целиком ваш код.

### Интерфейс Scene: Update и Draw у каждой сцены

Когда сцен становится больше пяти, switch превращается в простыню, а новые сцены заставляют править два метода сразу. Вторая модель переносит ветвление в типы: объявляется интерфейс Scene с двумя методами — Update() error и Draw(screen *ebiten.Image), и каждая сцена становится структурой, которая их реализует. У Game остаётся одно поле current Scene, и Update выглядит тривиально: return g.current.Update(). Сцена меню держит свои кнопки, сцена боя — свой мир, и каждая отвечает только за себя.

Это тот же интерфейсный полиморфизм, что и в любом бэкенд-коде Go: неявная реализация, контракт из двух методов, подстановка реализации в поле. Выигрыш проявляется с ростом проекта: новая сцена добавляется новой структурой без правки старых, у сцены появляются собственные поля и методы, а тестировать логику меню можно без окна. Плата — ceremony: типы, конструкторы, передачи данных между сценами через параметры или общее состояние. Порог входа выше, окупается он на количестве сцен, и правило выбора простое: до пяти сцен — switch, дальше — интерфейс.

### Сцена с данными при входе

```go
type Game struct {
        current Scene // единственное поле, знающее о сценах
}

func (g *Game) Update() error {
        return g.current.Update()
}

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

// сцена поражения получает счёт и игру через конструктор
type GameOverScene struct {
        game  *Game
        score int
}

func NewGameOverScene(game *Game, score int) *GameOverScene {
        return &GameOverScene{game: game, score: score}
}

func (s *GameOverScene) Update() error {
        if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
                s.game.current = NewMenuScene() // переход по JustPressed
        }
        return nil
}

func (s *GameOverScene) Draw(screen *ebiten.Image) {
        drawCenteredText(screen, fmt.Sprintf("Game Over. Score: %d", s.score))
}
```

Game больше не знает ни одной конкретной сцены: оба метода делегируют вызовы полю current. Сцена поражения получает счёт и ссылку на игру через конструктор — данные едут при входе, а переход внутри Update меняет current по JustPressed. Для компилятора каждая сцена обязана реализовать оба метода: забудете Draw — код не соберётся, и целый класс забытых веток исчезает.

### Switch против интерфейса сцен

| Критерий | Автомат на switch | Интерфейс Scene |
| --- | --- | --- |
| Порог входа | одно поле и два ветвления | тип интерфейса и структура на сцену |
| Добавление сцены | правка обоих switch | новая структура без правки чужого кода |
| Состояние сцены | общие поля Game на всех | свои поля у каждой структуры |
| Переходы | присваивание в Update | смена current + данные через конструкторы |
| Риск | простыня из веток и каши в Game | церемония там, где хватит трёх веток |
| Когда выбирать | две-пять сцен, игры курса | шесть и больше, отдельные экраны и уровни |

### Что увидит игрок в тик нажатия Escape?

```go
// state == statePlay, экран боя отрисован
// Update этого тика:
case statePlay:
        g.updatePlay()
        if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
                g.state = statePause
        }
// Draw этого же тика:
switch g.state {
case statePause:
        g.drawPlay(screen)
        drawPauseOverlay(screen)
```

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

- Бой без паузы: новый state увидит только следующий кадр
- ✅ Бой с оверлеем паузы: Update выполняется раньше Draw
- Чёрный экран: сцена сменяется до отрисовки
- Ошибка: нельзя менять state между Update и Draw

Порядок тика фиксирован: сначала Update, потом Draw. Смена состояния в Update видна отрисовке того же тика, поэтому оверлей появляется мгновенно, без просрочек на кадр. Именно эта последовательность делает переходы предсказуемыми: логика и картинка одного тика согласованы.

### Переходы: кто, когда и как меняет сцену

Смена сцены — обычная операция присваивания, но вокруг неё есть три правила, сберегающие нервы. Первое: менять сцену можно только в Update. Draw обязан быть чистой отрисовкой — изменение состояния в ней делает картинку причиной логики, и предсказать поведение становится невозможно; это та же дисциплина, что и в [движении](/courses/ebitengine/lessons/05-geometriya-dvizheniya/): логика в тиках, картинка в кадрах. Второе: переходы — по JustPressed, а не по зажатым клавишам, иначе пауза мерцает. Третье: в момент перехода новая сцена должна быть готова к первому же Update — инициализация живёт на входе в сцену, а не разбросана по её методам.

Присваивание вроде g.state = statePause — единственная точка перехода, и её видно в поиске по коду: наберите имя константы в grep, и все переходы в состояние найдутся мгновенно. Автомат даёт дешёвую трассируемость, которой нет у размазанных флагов isPaused, isMenuVisible и прочих булевых крошек: у автомата состояние одно, и оно всегда актуально.

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

**Вопрос:** Игрок проиграл, и игра должна показать экран поражения. Где законно присвоить state = stateGameOver?

- В Draw поражённого героя, чтобы экран появился сразу
- ✅ В Update, когда условие поражения обнаружено логикой
- В main после RunGame, в отдельной горутине
- В Layout, пока окно пересчитывает размеры

Обнаружил условие — сменил сцену, и это происходит в Update: именно там живёт вся логика и условия. Draw только рисует то, что выбрал Update, горутина вне игрового цикла напрашивается на гонку, а Layout про состояния игры вообще ничего не знает.

> **Живое место: пауза, которая продолжает считать.** Классика первого автомата: смена state поставлена, а updatePlay вызывается безусловно над switch — и на паузе герой замирает, но анимация ходьбы из урока про [анимацию](/courses/ebitengine/lessons/11-animaciya/) крутится, враги ходят, таймер уровня тикает. Игрок замечает мгновенно: крылья декора машут, хотя всё «остановилось». Что сломалось бы дальше: счёт времени уровня и позиции врагов продолжали бы жить, и выход из паузы возвращал бы в другой бой. Вся игровая логика обязана жить внутри ветки активного состояния, а над switch — только общий ввод меню.

### Пауза поверх игры: слой вместо сцены

Загляните в Draw паузы из примера выше: сначала drawPlay, потом оверлей. Пауза — не полноценная сцена со своим миром, а слой поверх текущей: игрок должен видеть, где остановился, иначе пауза превращается в телепорт в пустоту. Технически это два вызова отрисовки в правильном порядке — мир рисуется как обычно, поверх идёт затемнение и надпись PAUSED, собранная текстом из прошлого урока. Тот же приём строят и меню, когда хочется живого фона: за стеклом меню крутится демо-режим игры.

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

Различие между сценой и слоем полезно проговорить явно: сцена владеет состоянием — у боя свой мир, у меню свои пункты; слой только рисует поверх и читает чужое состояние, ничего не меняя. Ошибка — сделать паузу сценой с копией мира: дублирование состояния гарантирует расхождение копий. Если когда-нибудь понадобится настоящая пауза-сцена — с настройками, сохранением и выходом в меню — она получает ссылку на тот же мир, а не его фотографию. Слои дешевле сцен, и там, где слой справляется, сцена — лишний вес.

### Правила сцен одним списком

- State — конечный набор значений через iota, и каждый тик выполняется ровно одна ветка
- Switch по состоянию живёт и в Update, и в Draw: логика и картинка согласованы
- Смену сцены делает только Update; Draw остаётся чистой отрисовкой
- Переходы — по JustPressed: зажатая клавиша мигает сценами каждый тик
- Вход в сцену инициализирует: хуки OnEnter и OnExit дают чистый старт
- Вся игровая логика — внутри ветки активного состояния, ничего над switch
- Пауза — слой поверх текущей сцены, а не копия мира в отдельной сцене
- Switch — до пяти сцен, интерфейс Scene — дальше; выбирать по количеству, а не моде

### Соберите интерфейс сцен

Восстановите два пропуска: имена методов контракта сцены.

```go
type Scene interface {
        ___() error
        ___(screen *ebiten.Image)
}

type MenuScene struct{}

func (m *MenuScene) Update() error { return nil }

func (m *MenuScene) Draw(screen *ebiten.Image) {
        drawMenu(screen)
}
```

Подсказка: Контракт сцены повторяет пару движка: обновление с ошибкой в возврате и отрисовка на экран.

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

> **Автомат сцен работает и в песочнице.** Рисовать меню можно только в локальном Go, но конечный автомат — чистая логика переключений. Тематическая программа в песочнице сайта крутит автомат меню, игры и паузы: печатает состояние на каждом тике и следит, чтобы логика боя не выполнялась вне своей ветки — те же инварианты, которые проверяет ваш будущий switch.

### Тик автомата сцен

Соберите Update со switch по состояниям.

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

1. `func (g *Game) Update() error {`
2. `switch g.state {`
3. `case stateMenu:`
4. `if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { g.state = statePlay }`
5. `case statePlay:`
6. `g.updatePlay() // логика только активной сцены`
7. `if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { g.state = statePause }`
8. `return nil`

### Данные между сценами и итоговая архитектура

Рано или поздно сцене нужны данные чужой: экран поражения хочет показать счёт, меню — лучший результат. Правило передачи простое: данные едут в сцену при входе, а не вытаскиваются из глобальных переменных. Автомату достаточно полей Game — счёт уже лежит в структуре, и ветка stateGameOver его читает; интерфейсу — параметр конструктора сцены: NewGameOverScene(score int). Глобальные переменные для передачи состояния соблазнительны и расплата отложена: гонки при будущих горутинах, невозможность тестировать сцену отдельно, невидимые связи между сценами. Урок про [игровое состояние](/courses/ebitengine/lessons/16-igrovoe-sostoyanie/) разложит, какие данные вообще обязаны жить в структуре, а какие — нет.

К концу урока у игры появляется скелет, который переживёт всё остальное развитие: конечный набор сцен, явные переходы, чистый старт на входе и слои для паузы. Именно этот скелет станет каркасом итогового проекта курса — мини-аркады, где меню, бой, пауза и поражение собираются в один играбельный цикл, и урок про [итоговый проект](/courses/ebitengine/lessons/25-itogovyj-proekt/) начнёт не с нуля, а с автомата из этого урока. Если архитектура сцен кажется скучной бюрократией, вспомните последний проект, где меню появлялось в последнюю очередь: именно без сцены он и не добрался до конца.

Сцены закрыли последний каркасный вопрос курса: игра обзавелась контуром из меню, боя и паузы, а переключение держится на трёх правилах — менять состояние в Update, входить в сцену подготовленным, логику держать внутри ветки. Дальше курс углубляется в содержимое сцен: счёт и жизни станут честным состоянием с HUD, частицы оживят взрывы, звук придёт через пару уроков. Домашнее задание подключит меню и паузу к вашему проекту — и первый запускаемый экран игры перестанет быть полем боя. Помните: сцена — это не экран, а договор о том, чья логика выполняется в этом тике. Архитектура из урока не трогается ни камерой, ни анимацией, ни будущим звуком — именно поэтому её стоит прибить до того, как контент начнёт давить.

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

- **Сцена** — Договор о том, чья логика выполняется в текущем тике: меню, бой, пауза, поражение. Движок о сценах не знает — их строит ваш switch или интерфейс.
- **Конечный автомат** — Поле state с набором значений через iota и switch в Update и Draw. Каждый тик выполняется ровно одна ветка активного состояния.
- **Интерфейс Scene** — Контракт из двух методов: Update() error и Draw(screen *ebiten.Image). Каждая сцена — структура; смена сцены — смена поля current.
- **Переход** — Присваивание состояния только в Update, по JustPressed. Новая сцена обязана быть готова к первому Update: инициализация — на входе.
- **Пауза-слой** — Отрисовка мира плюс оверлей поверх: пауза не копирует мир в отдельную сцену, а рисует текущее состояние с затемнением.
- **OnEnter и OnExit** — Хуки входа и выхода сцены: сброс счётчиков, подготовка уровня, сохранение результата. Без них сцены помнят старые значения.

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

- Сцена — договор о логике текущего тика; движок знает только Update и Draw, переключение строите вы.
- Автомат на switch закрывает две-пять сцен одним полем и двумя ветвлениями, зеркальными в Update и Draw.
- Интерфейс Scene с методами Update и Draw переносит ветвление в типы и окупается от шести сцен и дальше.
- Смену сцены делает только Update, по JustPressed; Draw остаётся чистой отрисовкой текущего состояния.
- Вход в сцену инициализирует её хуком: чистый старт против меню, помнящего прошлую сессию.
- Вся игровая логика живёт внутри ветки активного состояния — пауза без этого правила продолжает считать.
- Пауза — слой поверх текущей сцены, а не копия мира; данные между сценами едут при входе.

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

Добавьте в проект автомат из трёх состояний: меню, бой, пауза. Меню рисуйте текстом из прошлого урока с центровкой через Measure, вход в бой — по JustPressed Enter, пауза — по Escape в обе стороны. Проверьте живое место: на паузе анимация героя и таймеры обязаны замереть — если крылья декора машут, найдите логику над switch. Добавьте хук входа в бой, сбрасывающий счёт и позицию героя, и убедитесь, что второй заход в меню и обратно начинается с чистого старта. Затем посчитайте сцены проекта: если их стало шесть и больше, перепишите автомат на интерфейс Scene и сравните, где переходы читаются проще. В конце заведите экран поражения, который получает счёт при входе — без глобальных переменных. Отладочный String() на gameState добавьте первым: он окупается уже на втором запуске.

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

### Движок не имеет понятия сцены — это нормально для игрового движка?

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

### Куда девать загрузку уровня, если она занимает заметное время?

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

### Можно ли менять сцены по таймеру, например заставка на пять секунд?

Можно: в ветке заставки счётчик тиков растёт, и по достижении порога Update меняет сцену — тот же переход, только условие не клавиша, а число тиков. Порог считается честно: TPS умножить на секунды, пятьдесят-шестьдесят умножить на три — и заставка не растянется вдвое на другом железе.
