Содержание урока · 10 разделов
- Конечный автомат: ebitengine state в одной переменной
- Интерфейс Scene: Update и Draw у каждой сцены
- Switch против интерфейса сцен
- Переходы: кто, когда и как меняет сцену
- Пауза поверх игры: слой вместо сцены
- Правила сцен одним списком
- Данные между сценами и итоговая архитектура
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
У вашей игры уже есть почти всё: мир на тайлмапе, камера, текст 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 просто не соберётся.
Автомат меню, игры и паузы
GOtype 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 по состоянию игры?
Интерфейс Scene: Update и Draw у каждой сцены#
Когда сцен становится больше пяти, switch превращается в простыню, а новые сцены заставляют править два метода сразу. Вторая модель переносит ветвление в типы: объявляется интерфейс Scene с двумя методами — Update() error и Draw(screen *ebiten.Image), и каждая сцена становится структурой, которая их реализует. У Game остаётся одно поле current Scene, и Update выглядит тривиально: return g.current.Update(). Сцена меню держит свои кнопки, сцена боя — свой мир, и каждая отвечает только за себя.
Это тот же интерфейсный полиморфизм, что и в любом бэкенд-коде Go: неявная реализация, контракт из двух методов, подстановка реализации в поле. Выигрыш проявляется с ростом проекта: новая сцена добавляется новой структурой без правки старых, у сцены появляются собственные поля и методы, а тестировать логику меню можно без окна. Плата — ceremony: типы, конструкторы, передачи данных между сценами через параметры или общее состояние. Порог входа выше, окупается он на количестве сцен, и правило выбора простое: до пяти сцен — switch, дальше — интерфейс.
Сцена с данными при входе
GOtype 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?
Прочитайте код и выберите, что он выведет:
// 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)Переходы: кто, когда и как меняет сцену#
Смена сцены — обычная операция присваивания, но вокруг неё есть три правила, сберегающие нервы. Первое: менять сцену можно только в Update. Draw обязан быть чистой отрисовкой — изменение состояния в ней делает картинку причиной логики, и предсказать поведение становится невозможно; это та же дисциплина, что и в движении: логика в тиках, картинка в кадрах. Второе: переходы — по JustPressed, а не по зажатым клавишам, иначе пауза мерцает. Третье: в момент перехода новая сцена должна быть готова к первому же Update — инициализация живёт на входе в сцену, а не разбросана по её методам.
Присваивание вроде g.state = statePause — единственная точка перехода, и её видно в поиске по коду: наберите имя константы в grep, и все переходы в состояние найдутся мгновенно. Автомат даёт дешёвую трассируемость, которой нет у размазанных флагов isPaused, isMenuVisible и прочих булевых крошек: у автомата состояние одно, и оно всегда актуально.
Для входа и выхода сценам полезны явные хуки: OnEnter при попадании в сцену сбрасывает уровень, обнуляет счётчик анимации, готовит переменные; OnExit сохраняет то, что понадобится после. Автомату хуки дают две строчки рядом с присваиванием состояния, интерфейсу — методы структур. Без хуков типовая болезнь — меню, которое при втором входе помнит старые значения, и бой, начинающийся с того места, где игрок умер в прошлой жизни. Переход — это не только смена ветки, но и договор о чистом старте.
Игрок проиграл, и игра должна показать экран поражения. Где законно присвоить state = stateGameOver?
Классика первого автомата: смена state поставлена, а updatePlay вызывается безусловно над switch — и на паузе герой замирает, но анимация ходьбы из урока про анимацию крутится, враги ходят, таймер уровня тикает. Игрок замечает мгновенно: крылья декора машут, хотя всё «остановилось». Что сломалось бы дальше: счёт времени уровня и позиции врагов продолжали бы жить, и выход из паузы возвращал бы в другой бой. Вся игровая логика обязана жить внутри ветки активного состояния, а над switch — только общий ввод меню.
Пауза поверх игры: слой вместо сцены#
Загляните в Draw паузы из примера выше: сначала drawPlay, потом оверлей. Пауза — не полноценная сцена со своим миром, а слой поверх текущей: игрок должен видеть, где остановился, иначе пауза превращается в телепорт в пустоту. Технически это два вызова отрисовки в правильном порядке — мир рисуется как обычно, поверх идёт затемнение и надпись PAUSED, собранная текстом из прошлого урока. Тот же приём строят и меню, когда хочется живого фона: за стеклом меню крутится демо-режим игры.
Отдельный плюс слоя — стоимость: пауза ничего не хранит и ничего не умеет, поэтому её невозможно сломать логикой. Затемнение рисуется полупрозрачным прямоугольником — примитивы курса делают это без картинок, и оверлей остаётся парой вызовов даже в большом проекте.
Различие между сценой и слоем полезно проговорить явно: сцена владеет состоянием — у боя свой мир, у меню свои пункты; слой только рисует поверх и читает чужое состояние, ничего не меняя. Ошибка — сделать паузу сценой с копией мира: дублирование состояния гарантирует расхождение копий. Если когда-нибудь понадобится настоящая пауза-сцена — с настройками, сохранением и выходом в меню — она получает ссылку на тот же мир, а не его фотографию. Слои дешевле сцен, и там, где слой справляется, сцена — лишний вес.
Правила сцен одним списком#
- State — конечный набор значений через iota, и каждый тик выполняется ровно одна ветка
- Switch по состоянию живёт и в Update, и в Draw: логика и картинка согласованы
- Смену сцены делает только Update; Draw остаётся чистой отрисовкой
- Переходы — по JustPressed: зажатая клавиша мигает сценами каждый тик
- Вход в сцену инициализирует: хуки OnEnter и OnExit дают чистый старт
- Вся игровая логика — внутри ветки активного состояния, ничего над switch
- Пауза — слой поверх текущей сцены, а не копия мира в отдельной сцене
- Switch — до пяти сцен, интерфейс Scene — дальше; выбирать по количеству, а не моде
Соберите интерфейс сцен
Восстановите два пропуска: имена методов контракта сцены.
Рисовать меню можно только в локальном Go, но конечный автомат — чистая логика переключений. Тематическая программа в песочнице сайта крутит автомат меню, игры и паузы: печатает состояние на каждом тике и следит, чтобы логика боя не выполнялась вне своей ветки — те же инварианты, которые проверяет ваш будущий switch.
Тик автомата сцен
Соберите Update со switch по состояниям.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Данные между сценами и итоговая архитектура#
Рано или поздно сцене нужны данные чужой: экран поражения хочет показать счёт, меню — лучший результат. Правило передачи простое: данные едут в сцену при входе, а не вытаскиваются из глобальных переменных. Автомату достаточно полей Game — счёт уже лежит в структуре, и ветка stateGameOver его читает; интерфейсу — параметр конструктора сцены: NewGameOverScene(score int). Глобальные переменные для передачи состояния соблазнительны и расплата отложена: гонки при будущих горутинах, невозможность тестировать сцену отдельно, невидимые связи между сценами. Урок про игровое состояние разложит, какие данные вообще обязаны жить в структуре, а какие — нет.
К концу урока у игры появляется скелет, который переживёт всё остальное развитие: конечный набор сцен, явные переходы, чистый старт на входе и слои для паузы. Именно этот скелет станет каркасом итогового проекта курса — мини-аркады, где меню, бой, пауза и поражение собираются в один играбельный цикл, и урок про итоговый проект начнёт не с нуля, а с автомата из этого урока. Если архитектура сцен кажется скучной бюрократией, вспомните последний проект, где меню появлялось в последнюю очередь: именно без сцены он и не добрался до конца.
Сцены закрыли последний каркасный вопрос курса: игра обзавелась контуром из меню, боя и паузы, а переключение держится на трёх правилах — менять состояние в Update, входить в сцену подготовленным, логику держать внутри ветки. Дальше курс углубляется в содержимое сцен: счёт и жизни станут честным состоянием с HUD, частицы оживят взрывы, звук придёт через пару уроков. Домашнее задание подключит меню и паузу к вашему проекту — и первый запускаемый экран игры перестанет быть полем боя. Помните: сцена — это не экран, а договор о том, чья логика выполняется в этом тике. Архитектура из урока не трогается ни камерой, ни анимацией, ни будущим звуком — именно поэтому её стоит прибить до того, как контент начнёт давить.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Сцена — договор о логике текущего тика; движок знает только 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 умножить на секунды, пятьдесят-шестьдесят умножить на три — и заставка не растянется вдвое на другом железе.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript