Содержание урока · 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 делает старт видимым в коде и пригодным для рестарта.
Поля состояния и единственный автор изменений
GOtype 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: две строки в углу экрана
GOfunc (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 | движок, прозрачно для вас |
| Файл рекорда | каталог конфигурации | сохранение по событиям |
Самая коварная версия читерства — бонус, начисленный внутри отрисовки: строка 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 стоит печатать в лог при старте: игрок, который захочет перенести рекорд на другую машину или обнулить руками, скажет спасибо.
Сохранение и загрузка рекорда
GOfunc 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?
os.UserConfigDir и запись на диск работают только в локальном Go — в песочнице сайта файловой записи нет. Тематическая программа урока хранит рекорд в переменной и показывает саму механику: при поражении максимум пересчитывается, на HUD выводятся текущий счёт и лучший. Перенос на файл — механический: подставьте saveHighScore и loadHighScore из этого урока в свой проект.
Жизни и фазы: смерть без сюрпризов#
Жизни — счёт наоборот: он уменьшается, и каждое уменьшение — событие. killHero убирает жизнь и проверяет границу: ноль означает смену фазы на phaseGameOver, а смена фазы встраивается в автомат из урока про сцены: ветка боя перестаёт принимать ввод движения, экран поражения показывает счёт, клавиша R предлагает рестарт. Рестарт — чистый старт: State заменяется свежим значением с нулём очков и тремя жизнями, и никакие старые числа не переживают новый заход — та же гигиена входа, что у сцен с их хуками.
Фаз удобно держать чуть больше, чем кажется нужным: phaseReady перед стартом волны и phaseInvincible после смерти героя дают логике место для таймеров без россыпи булевых флагов. Дисциплина для всех полей State общая: каждое число меняет только Update, каждое условие проверяется там же, а фазы перечислимы — собственный тип Phase не даст записать в поле случайную семёрку. Проверьте связку на себе: потеря последней жизни меняет фазу в том же тике, и оверлей поражения появляется без задержки на кадр.
Правила игрового состояния одним списком#
- State — одна структура со счётом, жизнями и фазой: все значимые числа партии в одном месте
- Фаза — не сцена: сцены переключают экраны, фазы описывают жизнь боя
- Update — единственный автор изменений State; Draw только читает
- В Draw нет присваиваний полям State: очки не зависят от частоты кадров
- HUD — слой поверх мира в координатах экрана; строка собирается из State заново
- Рекорд — файл в os.UserConfigDir: сохранение по событиям, чтение на старте
- Отсутствие файла рекорда — это ноль, а не ошибка: первый запуск — норма
- Рестарт — замена State свежим значением: чистый старт без пережитых чисел
Соберите фазу поражения
Восстановите тип фазы и значение при потере последней жизни.
Тик состояния с рестартом
Соберите Update: рестарт по R и обычная логика боя.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
К концу урока у игры появилось то, чего не видно на скриншотах, но чувствует игрок: числа, которым можно верить. State собрал счёт, жизни и фазу в одну структуру; Update — единственный автор изменений; Draw читает и рисует HUD текстом; рекорд уезжает в файл конфигурации и возвращается при следующем запуске. Дальше эти числа оживут визуально: частицы следующего урока разлетятся из точки попадания, и взрыв станет наградой за честно начисленные очки. Домашнее задание подключит State к вашему проекту — и экран поражения наконец покажет настоящий результат, а не заглушку. Помните: состояние — память партии, а HUD — только её отражение; пока отражение не умеет менять оригинал, игра остаётся предсказуемой на любом железе. Правила урока умещаются в три строки код-ревью — одно место хранения, один автор изменений, чтение без записи, — и они короче, чем баг-репорт про разные очки на двух машинах.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 быть не должно — ни одного.
Личные заметкиПусто
Вопросы и ответы по уроку
Где хранить плавно растущие цифры счёта на HUD?
Плавные цифры — представление, а не состояние: в State лежит точный счёт, а анимация считается в Draw из времени, например как доля секунды от момента начисления. Если нужен честный след изменений, ведите в Update очередь событий начисления и гасите её по таймеру — но сами числа оставьте точными: HUD догоняет, а не искажает.
JSON или простой текст для рекорда?
Для одного числа хватит strconv.Itoa: файл читается даже блокнотом. JSON оправдан, когда полей становится несколько — счёт, волна, дата, громкость: encoding/json упаковывает структуру в пару строк. Один нюанс: при смене формата добавьте версию в первую строку файла, иначе старые сохранения игроков сломаются после обновления игры.
Насколько страшно потерять файл рекорда?
Рекорд — эмоциональная ценность, поэтому запись делайте атомарной: сначала во временный файл рядом, затем переименование — os.Rename на той же файловой системе атомарен, и прерванная запись не оставит обрезанного файла. Резервная копия не помешает: точность тут не нужна, но потеря личного рекорда после пятидесяти часов игры — самый злой отзыв, какой получает игра.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript