---
title: "Игровое состояние: счёт и жизни"
description: "Struct State, HUD, сохранение баланса честности. Игровое состояние в Ebitengine: счёт игры на Go, жизни и фазы, изменения только в Update и рекорд в файле."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/16-igrovoe-sostoyanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

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

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

До сих пор счёт и жизни появлялись стихийно: поле g.score заводилось там, где понадобилось, — прямо в Game рядом со спрайтом героя. Для демки это работает ровно до первого экрана поражения: счёт нужен трём сценам сразу, жизни — ветке боя и HUD, а рекорд — ещё и перезапускам. Игровое состояние собирается в одно место не ради красоты, а ради правил, которые невозможно нарушить незаметно. Что меняется: появляется отдельная структура State со счётом, жизнями и фазой партии; доступ к ней получают ровно две точки — Update, который её меняет, и Draw, который её читает. Порядок работает и для автомата сцен из пятнадцатого урока, и для интерфейсной версии: State не зависит от способа переключения экранов. [Ввод с клавиатуры](/courses/ebitengine/lessons/06-klaviatura/) остаётся на своём месте в 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 с нулём через меньше-равно — защита от двойного урона в одном тике, когда две проверки подряд успевают увести счётчик в минус.

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

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

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

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

HUD — слой поверх мира: цифры счёта, остаток жизней, подсказки клавиш. Он рисуется последним в Draw, после мира и камеры, поэтому всегда сверху, и живёт в координатах экрана, а не мира — скроллинг его не уносит. Текст берётся из урока про [Ebitengine текст](/courses/ebitengine/lessons/14-tekst-shrifty/): источник шрифта 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

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

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

- Счёт одинаков на любом мониторе: Draw привязан к TPS
- ✅ На мониторе 144 Гц очки растут заметно быстрее, чем на 60 Гц
- На мониторе 60 Гц очки растут быстрее: тиков там больше
- Счёт совпадёт, если игрок не двигает мышь

Draw выполняется раз в кадр, а частота кадров зависит от монитора и vsync: на 144 Гц начисление вызывается в два с лишним раза чаще. Update привязан к TPS и одинаков везде — потому события и очки живут только там. Одна и та же партия обязана давать один счёт на любом железе, и однонаправленный поток защищает именно это.

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

Центральный закон урока повторяет пару из [базового урока про окно и цикл](/courses/ebitengine/lessons/03-okno-i-holst/): 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 | движок, прозрачно для вас |
| Файл рекорда | каталог конфигурации | сохранение по событиям |

> **Живое место: очки, растущие в Draw.** Самая коварная версия читерства — бонус, начисленный внутри отрисовки: строка score++ попала в ветку, рисующую взрыв, и на глаз всё работает. Ломается на первом тесте на другом железе: свёрнутое окно не рисует кадров — очки встали; монитор 144 Гц — очки капают в два с половиной раза быстрее. Правило: в Draw нет ни одного присваивания полям State — ни счёта, ни жизней, ни фазы, даже технического обнуления. Нашли присваивание — переносите в Update. Хуже всего, что тест на одной машине такой баг не ловит: сравнение счёта после десяти вызовов hitEnemy ловит читерство мгновенно.

### Рекорд: файл в 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?

- Путь к каталогу Windows System32
- Каталог рядом с исполняемым файлом игры
- ✅ Путь вида AppData Roaming из профиля пользователя
- Пустую строку: в Windows системных конфигураций нет

Документация os.UserConfigDir обещает на Windows переменную AppData — Roaming-профиль пользователя, на Linux XDG_CONFIG_HOME либо домашний каталог, на macOS Application Support. Рядом с исполняемым файлом писать нельзя: каталог установки часто закрыт на запись, а у игрока может не оказаться прав администратора.

> **Про песочницу: рекорд остаётся в файле.** os.UserConfigDir и запись на диск работают только в локальном Go — в песочнице сайта файловой записи нет. Тематическая программа урока хранит рекорд в переменной и показывает саму механику: при поражении максимум пересчитывается, на HUD выводятся текущий счёт и лучший. Перенос на файл — механический: подставьте saveHighScore и loadHighScore из этого урока в свой проект.

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

Жизни — счёт наоборот: он уменьшается, и каждое уменьшение — событие. killHero убирает жизнь и проверяет границу: ноль означает смену фазы на phaseGameOver, а смена фазы встраивается в автомат из урока про [сцены](/courses/ebitengine/lessons/15-sceny/): ветка боя перестаёт принимать ввод движения, экран поражения показывает счёт, клавиша R предлагает рестарт. Рестарт — чистый старт: State заменяется свежим значением с нулём очков и тремя жизнями, и никакие старые числа не переживают новый заход — та же гигиена входа, что у сцен с их хуками.

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

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

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

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

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

```go
type State struct {
        score int
        lives int
        phase ___
}

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

Подсказка: Тип объявлен через iota выше, а значение — константа экрана поражения.

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

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

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

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

1. `func (g *Game) Update() error {`
2. `if g.state.phase == phaseGameOver && inpututil.IsKeyJustPressed(ebiten.KeyR) {`
3. `g.state = State{score: 0, lives: 3, phase: phasePlay} // чистый старт`
4. `return nil // рестарт не выполняет бой в этом тике`
5. `}`
6. `g.updateWorld() // попадания и смерти меняют State только здесь`
7. `if g.state.lives <= 0 { g.state.phase = phaseGameOver }`
8. `return nil`

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

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

- **Игровое состояние** — Числа, значимые для логики: счёт, жизни, фаза. Живут в структуре State и меняются только в Update.
- **Фаза (Phase)** — Целочисленный тип с константами через iota: phasePlay, phaseGameOver. Описывает жизнь партии внутри боя, а не экраны.
- **HUD** — Слой поверх мира: счёт и жизни текстом в координатах экрана. Draw собирает строку из State каждый кадр.
- **Однонаправленный поток** — Update пишет State, Draw читает. В Draw нет присваиваний состоянию — очки не зависят от частоты кадров.
- **os.UserConfigDir** — Системный каталог конфигураций: Roaming-профиль на Windows, XDG_CONFIG_HOME на Linux. Здесь игра хранит рекорд.
- **Рекорд** — Максимум счёта в файле: сохранение по событиям, чтение на старте, отсутствие файла — это ноль, а не ошибка.

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

- Игровое состояние — значимые для логики числа: счёт, жизни, фаза; все они живут в одной структуре 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 быть не должно — ни одного.

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

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

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

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

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

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

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