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

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

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

Урок 16 из 25 ~18 мин +50 XP

Игровое состояние: счёт и жизни

Игровое состояние — это числа, которые переживают тик: счёт, жизни, фаза партии. Пока курс двигал спрайты и ловил столкновения, такие цифры жили прямо в полях Game, и этого хватало. Теперь, когда у игры есть сцены, состояние становится подсистемой: его показывает HUD, его читает экран поражения, и его нельзя подменить по ошибке из отрисовки. Разложим счёт игры по полочкам: struct State с явными полями, однонаправленный поток от Update к Draw и рекорд, пережидающий перезапуск в файле.

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

До сих пор счёт и жизни появлялись стихийно: поле g.score заводилось там, где понадобилось, — прямо в Game рядом со спрайтом героя. Для демки это работает ровно до первого экрана поражения: счёт нужен трём сценам сразу, жизни — ветке боя и HUD, а рекорд — ещё и перезапускам. Игровое состояние собирается в одно место не ради красоты, а ради правил, которые невозможно нарушить незаметно. Что меняется: появляется отдельная структура State со счётом, жизнями и фазой партии; доступ к ней получают ровно две точки — Update, который её меняет, и Draw, который её читает. Порядок работает и для автомата сцен из пятнадцатого урока, и для интерфейсной версии: State не зависит от способа переключения экранов. Ввод с клавиатуры остаётся на своём месте в Update — и именно там превращается в очки и потерянные жизни. Отдельный бонус сборки — отладка: брейкпоинт на hitEnemy показывает всю историю начисления, а не блуждание значений по полям, разбросанным по Game.

Struct State: счёт, жизни и фазы#

Состояние партии — маленькая база данных игры: сколько очков набрано, сколько жизней осталось, в какой фазе находится матч. Структура State держит эти поля вместе, и по типам видно, что чему принадлежит: score int, lives int, phase Phase — собственный целочисленный тип с константами через iota, как у сцен. Фаза — не сцена: сцены переключают экраны, а фазы описывают жизнь партии внутри боя — phasePlay и phaseGameOver. Счёт игры на Go живёт именно здесь, в данных, а не в надписях: строка SCORE 1200 — представление числа, а не само число, и пока вы держите их раздельно, интерфейс можно перекрашивать сколько угодно.

Правило отсечения простое: если значение переживёт кадр и что-то значит для логики — ему место в State; если оно существует лишь между двумя вызовами отрисовки — это локальная переменная Draw. Позиция героя — состояние; промежуточная позиция для сглаживания — нет. Такой фильтр держит структуру маленькой: у State игры курса четыре-шесть полей, и каждое понятно без комментариев. Если поле понадобилось двум методам сразу — верный знак, что оно живёт в State, а не в локальных переменных. Нулевые значения Go — готовое состояние старта, а явная функция reset делает старт видимым в коде и пригодным для рестарта.

Поля состояния и единственный автор изменений

GO
type Phase int

const (
        phasePlay Phase = iota
        phaseGameOver
)

type State struct {
        score int
        lives int
        phase Phase
}

type Game struct {
        state State // данные партии, а не пиксели
        // мир, камера, атласы — как раньше
}

func (g *Game) hitEnemy() { // зовётся только из Update
        g.state.score += 100
}

func (g *Game) killHero() {
        g.state.lives--
        if g.state.lives <= 0 {
                g.state.phase = phaseGameOver
        }
}

Состояние — отдельная структура с явными полями и собственным типом Phase через iota: фазы перечислимы, как сцены прошлого урока. Методы hitEnemy и killHero — единственные точки изменения, и оба зовутся только из Update, поэтому путь каждого очка прослеживается поиском по имени. Сравнение lives с нулём через меньше-равно — защита от двойного урона в одном тике, когда две проверки подряд успевают увести счётчик в минус.

Где менять счёт при попадании снаряда во врага?

HUD поверх игры: счёт текстом#

HUD — слой поверх мира: цифры счёта, остаток жизней, подсказки клавиш. Он рисуется последним в Draw, после мира и камеры, поэтому всегда сверху, и живёт в координатах экрана, а не мира — скроллинг его не уносит. Текст берётся из урока про Ebitengine текст: источник шрифта text/v2 создаётся один раз при старте, а лицо GoTextFace можно собирать прямо в кадре — это дешёвая маленькая структура, кэш глифов живёт в источнике. Строка HUD собирается из State вызовом fmt.Sprintf: SCORE и LIVES печатаются из полей, а не дублируются вручную.

Разделение обязанностей здесь абсолютное: State не знает про текст, текст не знает про правила. Поменяете формат надписи — логика не заметит; поменяете правила начисления — HUD подхватит новые числа сам. Именно поэтому счёт игры на Go читают из структуры в момент отрисовки: между тиком логики и кадром картинки нет промежуточных копий, которые могли бы разойтись. Проверка на практике одна: выключите отрисовку мира — HUD обязан остаться и показать те же числа, потому что он читает State, а не пиксели мира.

drawHUD: две строки в углу экрана

GO
func (g *Game) drawHUD(screen *ebiten.Image) {
        str := fmt.Sprintf("SCORE %d\nLIVES %d",
                g.state.score, g.state.lives)
        face := &text.GoTextFace{Source: uiFont, Size: 20}
        op := &text.DrawOptions{}
        op.GeoM.Translate(16, 16)
        text.Draw(screen, str, face, op)
}

HUD рисуется последним — после мира, поэтому всегда поверх. Строка собирается fmt.Sprintf из State: Draw здесь только читает поля и превращает их в глифы. Лицо GoTextFace создаётся на каждый кадр — это дёшево по документации text/v2, кэш живёт в источнике шрифта. GeoM сдвигает надпись в угол: HUD живёт в координатах экрана, а не мира, поэтому камера его не сдувает.

Счёт, начисляемый в Draw

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

// Начисление по ошибке попало в Draw:
func (g *Game) drawHUD(screen *ebiten.Image) {
        if g.enemyJustHit {
                g.state.score += 100
        }
        // ... печать SCORE из g.state.score
}

Однонаправленный поток: Update пишет, Draw читает#

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

Однонаправленность окупается тестами: State — обычная структура, и hitEnemy можно вызвать из теста без окна, проверив, что счёт вырос ровно на сто. Когда позже появятся горутины — сетевой режим, автосохранение — единственное место записи подскажет, где ставить мьютекс. Игровое состояние в Ebitengine остаётся обычными данными Go, и вся дисциплина сводится к одному вопросу: у каждого изменения ровно один автор — Update. Любое желание поправить число из Draw — сигнал, что Update недорабатывает, и править надо там, а не в отрисовке.

Что состояние, а что рендер#

ЗначениеГде живётКто меняет
Счёт, жизни, фазаStateтолько Update
Позиции героя и враговState и структуры миратолько Update
Строка SCORE 1200локальная переменная Drawсобирается заново каждый кадр
Плавно растущие цифры HUDпроизводная от времениDraw из State, без правил
Кэш глифов шрифтавнутри text/v2движок, прозрачно для вас
Файл рекордакаталог конфигурациисохранение по событиям

Рекорд: файл в os.UserConfigDir#

В браузерном мире рекорд хранят в localStorage, но в Go аналога нет — и это правильно: у настольной игры есть диск. Запись идёт в системный каталог конфигураций: os.UserConfigDir возвращает его путь — на Windows это Roaming-профиль пользователя, на Linux — XDG_CONFIG_HOME или домашний каталог, на macOS — Library Application Support. Путь собирается через filepath.Join с подкаталогом игры, чтобы не сваливать файлы в корень конфигураций, и создаётся os.MkdirAll при первом сохранении. Сам файл — несколько байт: strconv.Itoa рекорда или JSON со счётом, жизнями и датой.

Когда сохранять — вопрос баланса: каждый тик — лишняя работа с диском, поэтому сохранение вешают на моменты, а не на частоту: конец партии, потеря жизни, выход по Escape. Чтение — на старте: loadHighScore возвращает ноль при отсутствии файла, и первый запуск не отличается от сотого. Ошибка записи не роняет игру: нет доступа — играем без рекорда, и это честная деградация. Для игры курса хватает одного файла: слоты сохранений и настройки появятся вместе с итоговым проектом. Путь из UserConfigDir стоит печатать в лог при старте: игрок, который захочет перенести рекорд на другую машину или обнулить руками, скажет спасибо.

Сохранение и загрузка рекорда

GO
func highScorePath() (string, error) {
        dir, err := os.UserConfigDir()
        if err != nil {
                return "", err
        }
        return filepath.Join(dir, "goasteroids", "highscore.txt"), nil
}

func saveHighScore(score int) {
        p, err := highScorePath()
        if err != nil {
                return // каталога нет — играем без рекорда
        }
        os.MkdirAll(filepath.Dir(p), 0o755)
        os.WriteFile(p, []byte(strconv.Itoa(score)), 0o644)
}

func loadHighScore() int {
        p, err := highScorePath()
        if err != nil {
                return 0
        }
        data, err := os.ReadFile(p)
        if err != nil {
                return 0 // файла нет: первый запуск
        }
        n, _ := strconv.Atoi(strings.TrimSpace(string(data)))
        return n
}

os.UserConfigDir возвращает системный каталог конфигураций пользователя, filepath.Join добавляет подкаталог игры, MkdirAll создаёт его при первом сохранении. Чтение трактует отсутствие файла как ноль: первый запуск — не сбой. Ошибки записи глушатся осознанно: игра без рекорда лучше игры, падающей из-за запрещённого диска. Сохранение зовётся из Update в момент поражения, а не каждый тик.

Что вернёт os.UserConfigDir на Windows?

Жизни и фазы: смерть без сюрпризов#

Жизни — счёт наоборот: он уменьшается, и каждое уменьшение — событие. killHero убирает жизнь и проверяет границу: ноль означает смену фазы на phaseGameOver, а смена фазы встраивается в автомат из урока про сцены: ветка боя перестаёт принимать ввод движения, экран поражения показывает счёт, клавиша R предлагает рестарт. Рестарт — чистый старт: State заменяется свежим значением с нулём очков и тремя жизнями, и никакие старые числа не переживают новый заход — та же гигиена входа, что у сцен с их хуками.

Фаз удобно держать чуть больше, чем кажется нужным: phaseReady перед стартом волны и phaseInvincible после смерти героя дают логике место для таймеров без россыпи булевых флагов. Дисциплина для всех полей State общая: каждое число меняет только Update, каждое условие проверяется там же, а фазы перечислимы — собственный тип Phase не даст записать в поле случайную семёрку. Проверьте связку на себе: потеря последней жизни меняет фазу в том же тике, и оверлей поражения появляется без задержки на кадр.

Правила игрового состояния одним списком#

  • State — одна структура со счётом, жизнями и фазой: все значимые числа партии в одном месте
  • Фаза — не сцена: сцены переключают экраны, фазы описывают жизнь боя
  • Update — единственный автор изменений State; Draw только читает
  • В Draw нет присваиваний полям State: очки не зависят от частоты кадров
  • HUD — слой поверх мира в координатах экрана; строка собирается из State заново
  • Рекорд — файл в os.UserConfigDir: сохранение по событиям, чтение на старте
  • Отсутствие файла рекорда — это ноль, а не ошибка: первый запуск — норма
  • Рестарт — замена State свежим значением: чистый старт без пережитых чисел

Соберите фазу поражения

Восстановите тип фазы и значение при потере последней жизни.

type State struct { score int lives int phase } func (g *Game) killHero() { g.state.lives-- if g.state.lives <= 0 { g.state.phase = } }

Тик состояния с рестартом

Соберите Update: рестарт по R и обычная логика боя.

Строки

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

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

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

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

Термин

Игровое состояние

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

Числа, значимые для логики: счёт, жизни, фаза. Живут в структуре State и меняются только в Update.

1 / 6

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

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

Вывод

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

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

  • Игровое состояние — значимые для логики числа: счёт, жизни, фаза; все они живут в одной структуре State.
  • Фаза партии — не сцена: сцены переключают экраны, фазы описывают жизнь боя.
  • Update — единственный автор изменений; Draw только читает State и рисует HUD.
  • HUD — слой поверх мира в координатах экрана; строка собирается из State, а не дублируется.
  • Начисление очков в Draw делает счёт зависимым от частоты кадров — это баг, а не оптимизация.
  • Рекорд хранится в файле из os.UserConfigDir: сохранение по событиям, чтение на старте, ошибки не роняют игру.
  • Рестарт — замена State свежим значением: чистый старт против пережитых чисел.

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

Заведите в проекте структуру State с полями score, lives и phase и перенесите туда числа из Game. Начисляйте очки только из Update: попадание — плюс сто, потеря жизни — минус жизнь и проверка фазы. Нарисуйте HUD текстом из урока про шрифты: две строки SCORE и LIVES в углу экрана, поверх мира и камеры. Проверьте живое место: сворачивайте окно посреди боя — очки обязаны стоять на месте. Добавьте рекорд: os.UserConfigDir, подкаталог игры, запись при поражении, чтение на старте; на экране поражения показывайте текущий счёт и лучший. Настройте рестарт по R через замену State целиком. В конце пройдитесь поиском по методам Draw: присваиваний полям State быть не должно — ни одного.

Личные заметкиПусто
Хранятся только в вашем браузере (Ebitengine · Игровое состояние: счёт и жизни)

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

Где хранить плавно растущие цифры счёта на HUD?

Плавные цифры — представление, а не состояние: в State лежит точный счёт, а анимация считается в Draw из времени, например как доля секунды от момента начисления. Если нужен честный след изменений, ведите в Update очередь событий начисления и гасите её по таймеру — но сами числа оставьте точными: HUD догоняет, а не искажает.

JSON или простой текст для рекорда?

Для одного числа хватит strconv.Itoa: файл читается даже блокнотом. JSON оправдан, когда полей становится несколько — счёт, волна, дата, громкость: encoding/json упаковывает структуру в пару строк. Один нюанс: при смене формата добавьте версию в первую строку файла, иначе старые сохранения игроков сломаются после обновления игры.

Насколько страшно потерять файл рекорда?

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

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

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

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

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

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

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

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