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

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

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

Урок 15 из 25 ~20 мин +50 XP

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

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

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

У вашей игры уже есть почти всё: мир на тайлмапе, камера, текст 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 по состоянию игры?

Интерфейс 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?

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

// 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?

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

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

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

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

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

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

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

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

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 со switch по состояниям.

Строки

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

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

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

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

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

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

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

Термин

Сцена

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

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

1 / 6

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

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

Вывод

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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