Содержание урока · 10 разделов
Прототип — вертикальный срез: один экран боя, один вид врага, одна способность. Герой летает по полю стрелками: тяга разгоняет его в четырёх направлениях, а drag каждый тик умножает скорость на 0.9 — тот же Update из урока про движение, без единой новой сущности. Пробел выпускает пулю, враги идут волнами сверху: третья волна крупнее первой, седьмая быстрее шестой — кривую сложности задаёт урок про механики и баланс, и именно она решает, когда игра требует мастерства, а когда честно убивает. Пуля снимает врага формулой круг-круг, касание героя отнимает жизнь тем же способом. Вся графика — примитивы пакета vector: ноль ассетов, ноль лицензий, ноль загрузок картинок — урок про ассеты здесь не нужен, и это осознанное решение курса, а не лень автора. Хитбокс у каждой сущности — круг, и его же рисует vector: то, что игрок видит, совпадает с тем, во что он попадает, — честная геометрия без скрытых отступов. Такой срез собирается за вечер и проверяется чек-листом до того, как пойдут спрайты и уровни.
Каркас проекта: три сцены и один Game#
Каркас — интерфейс Scene из пятнадцатого урока: структуры MenuScene, PlayScene и GameOverScene реализуют пару методов Update и Draw, у Game остаётся одно поле current, и RunGame получает структуру-посредник, которая делегирует тик активной сцене. Движок не знает слова «меню»: для него есть только Update, Draw и Layout, и весь вопрос архитектуры — чьи методы вызывать в этом тике. Переходы живут в Update по IsKeyJustPressed: меню уходит в бой по Enter, гейм-овер возвращает в меню той же клавишей. Переход в поражение происходит не по клавише, а по условию: жизни кончились — сцена боя сама создаёт NewGameOverScene и передаёт счёт конструктором, без глобальных переменных. Каждый вход в бой начинается с чистого старта: конструктор PlayScene собирает свежий мир, обнуляет волны и позицию героя — правило хуков входа защищает от меню, помнящего прошлую сессию. Три сцены — территория switch, но интерфейс выбран сознательно: проект растёт в первую очередь сценами, и платить за это церемонией автомата не хочется. Размер поля фиксирует Layout — константные 640 на 480 для всех сцен: одна система координат на меню, бой и поражение избавляет от пересчёта позиций при переходах, а ретро-разрешение прощает крупные примитивы.
main.go: посредник между движком и сценами
GO// main.go — точка входа: движку известно одно имя
func main() {
if err := ebiten.RunGame(&Game{current: NewMenuScene()}); err != nil {
log.Fatal(err)
}
}
type Game struct {
current Scene // контракт из урока 15
}
func (g *Game) Update() error { return g.current.Update() }
func (g *Game) Draw(screen *ebiten.Image) { g.current.Draw(screen) }
func (g *Game) Layout(w, h int) (int, int) { return 640, 480 }Game — посредник: все три метода контракта делегируют активной сцене, и движок не подозревает о существовании меню. RunGame запускает цикл с меню — первая сцена создаётся в литерале структуры. Смерть героя внутри боя меняет current на NewGameOverScene со счётом при входе. Для компилятора каждая сцена обязана реализовать оба метода целиком: забытый Draw не даст собрать проект.
Герой погиб, сцена боя должна показать экран поражения с финальным счётом. Как счёт попадает в новую сцену?
Игровой цикл аркады: движение, волны, столкновения#
Бой живёт в World — структуре, которая знает про героя, врагов, пули и частицы, но ничего не знает о сценах: это тот же принцип разделения ответственности, что и в двадцать первом уроке про пакеты, сжатый до одной структуры. У каждого объекта — круговой хитбокс: корабль летает, враги плывут, и повёрнутый прямоугольник из урока про AABB был бы здесь ложью. Движение героя — ускорение и drag: стрелки добавляют к скорости вектор тяги, после чего скорость умножается на 0.9, и корабль проскальзывает, как в настоящем аркадном шутере, — чем больше коэффициент, тем инертнее управление. Волны идут по кривой сложности из урока про механики и баланс: врагов в волне 3+2*wave, скорость растёт на восемь процентов за волну, интервал между спавнами сжимается до нижнего предела — рост конечен, иначе к десятой волне игра перестаёт быть игрой и превращается в лотерею. Столкновения — круг-круг: сумма радиусов против расстояния между центрами, без квадратного корня — сравниваются квадраты, и цена проверки не растёт даже на сотнях пар. Выстрел — по пробелу с коротким собственным кулдауном: без него зажатая клавиша превращает экран в решето и обесценивает кривую сложности — враги умирают раньше, чем волна успевает собраться.
Полный игровой цикл столкновений
GO// updateWorld — один тик боя: движение и весь цикл столкновений
func (w *World) Update() error {
w.moveHero() // стрелки + drag: скорость *= 0.9
w.moveEnemies() // волна идёт вниз по кривой сложности
w.moveBullets()
for i := len(w.bullets) - 1; i >= 0; i-- {
b := w.bullets[i]
for j := len(w.enemies) - 1; j >= 0; j-- {
e := w.enemies[j]
dx, dy := e.x-b.x, e.y-b.y
rr := e.r + b.r
if dx*dx+dy*dy <= rr*rr {
w.enemies = append(w.enemies[:j], w.enemies[j+1:]...)
w.bullets = append(w.bullets[:i], w.bullets[i+1:]...)
w.state.Score += 10
w.burst(e.x, e.y) // частицы, урок 17
w.sfx.PlayHit() // звук, урок 18
break
}
}
}
for _, e := range w.enemies {
dx, dy := e.x-w.hero.x, e.y-w.hero.y
rr := e.r + w.hero.r
if dx*dx+dy*dy <= rr*rr {
w.state.Lives--
w.burst(w.hero.x, w.hero.y)
w.sfx.PlayDeath()
}
}
return nil
}Обход с конца: удаление элемента срезом append не сдвигает ещё не проверенные индексы, а break после попадания снимает пулю ровно одним врагом. Формула круг-круг сравнивает квадрат расстояния с квадратом суммы радиусов — без Sqrt стоимость проверки постоянна даже на сотнях пар. Касание героя лишь уменьшает жизни: решение о переходе на гейм-овер принимает сцена, глядя на State, — мир не знает про сцены.
Пуля и враг пересеклись в тик N
Прочитайте код и выберите, что он выведет:
// Update тика N: dx*dx+dy*dy <= rr*rr — истинно
w.state.Score += 10
w.burst(e.x, e.y)
// Draw того же тика:
for _, e := range w.enemies {
drawEnemy(e)
}Состояние, эффекты и звук#
Счёт и жизни живут в State из урока про игровое состояние: структура с полями Score и Lives передаётся по указателю и в мир, и в HUD — числа рисуются текстом в углу экрана, без единой глобальной переменной, и оба потребителя всегда видят одно и то же значение. Взрыв — burst из урока про частицы: пара сотен частиц разлетается от точки попадания и умирает за полсекунды, превращая сухое удаление из слайса в событие, которое игрок замечает. Звуки попадания и смерти — плееры из аудиоконтекста, живущие в полях структуры: сборщик мусора их не уносит, а PlayHit и PlayDeath делают Rewind и проигрывают заново — тот же пул, что и у частиц. Кулдаун рывка — единственная способность: Shift даёт импульс к скорости, а векторная дуга из урока про графику примитивами рисует откат прямо у героя — пока дуга не замкнулась, рывок недоступен, и игрок читает готовность глазами, без интерфейсных полосок. Ни один из этих узлов не знает о других: мир сообщает State новое значение, сцена решает, что с ним делать, — поэтому каждый узел тестируется отдельно, а собранный прототип ломается только на стыках, которых стало меньше.
Классика первой сборки: спавн волн и движение врагов вынесены над switch сцен — «чтобы не рвать полёт». Игрок умирает, экран гейм-овер открывается, а враги продолжают идти, счёт продолжает расти, и HUD в углу живёт своей жизнью против надписи поражения. Что сломалось бы дальше: третий заход в бой наследовал бы вражеский строй мёртвой сцены и половину шкалы. Лечение то же, что и в пятнадцатом уроке: вся логика мира — внутри ветки активной сцены, над switch — только общий ввод меню. Проверка простая: умрите нарочно и посмотрите на волны — если строй продолжает пополняться, спавнер живёт не там.
Пакеты и сборка: internal/game, world, ui#
Структура из урока про структуру проекта раскладывает прототип на три пакета: internal/game держит сцены и посредника Game, internal/world — героя, врагов, пули, частицы и всю физику, internal/ui — HUD, меню и экран поражения. Зависимости направлены в одну сторону: ui рисует то, что отдаёт world, game только связывает — циклический импорт ловится компилятором на этапе сборки, а не гонкой в рантайме. Точка входа в cmd/arcade остаётся пятью строками main. Сборка из урока про сборку под платформы даёт два артефакта из одного кода: нативный бинарник одной командой и WASM-модуль для браузера. Окно, клавиатура и звук проверяются только в локальном Go — на этих двух платформах прототип и живёт, а код игры при этом не меняется ни на строчку. Разделение окупается сразу: HUD правится, не касаясь физики, волны балансируются в одном файле мира, а сцены не подозревают о том, как именно рисуется герой.
Две команды сборки
GO# десктоп: один бинарник, ноль зависимостей
go build -o arcade ./cmd/arcade
# браузер: WASM-модуль и мост из поставки Go
GOOS=js GOARCH=wasm go build -o arcade.wasm ./cmd/web
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .Первая команда собирает нативный бинарник — десктоп-версия без единой зависимости от библиотек. Вторая компилирует тот же код в WASM: переменные окружения говорят компилятору про браузер, wasm_exec.js — мост между страницей и модулем. Пара файлов отдаётся любым статическим сервером, а zip с index.html публикуется на itch.io как HTML-игра.
Что превращает прототип в браузерную игру?
Кирпич проекта — урок-источник#
| Кирпич | Урок | Роль в прототипе |
|---|---|---|
| Интерфейс Scene: меню, бой, гейм-овер | 15 | переходы по JustPressed, счёт едет конструктором |
| Ускорение и drag героя | 03 | скорость умножается на 0.9 каждый тик |
| State: счёт и жизни | 16 | один источник правды для HUD и мира |
| Частицы взрыва | 17 | burst при попадании и гибели героя |
| Звуки hit и death | 18 | плееры в полях структуры, Rewind перед проигрыванием |
| Кулдаун рывка дугой | 19 | vector.Path рисует откат прямо у героя |
| Волны и кривая сложности | 24 | 3+2*wave врагов, скорость плюс восемь процентов за волну |
| Пакеты internal | 21 | game, world, ui без циклических импортов |
| Сборка WASM | 22 | один код — десктоп и браузер |
Чек-лист прототипа: пять глаголов#
Готовность прототипа проверяется руками, без метрик: пять глаголов закрывают весь игровой цикл, и каждый обязан отработать вживую за одну сессию. Движется — герой разгоняется от стрелок и мягко проскальзывает при отпускании. Стреляет — пуля снимает врага, взрыв виден, счёт отреагировал. Умирает — касание врага отнимает жизнь, ноль открывает гейм-овер с честным финальным счётом. Считает — HUD совпадает с происходящим на каждом кадре: расхождение числа и событий — баг состояния. Звучит — попадание и смерть озвучены, тишина на экране поражения не ощущается ошибкой. Проверка на рестарт замыкает список: второй заход в бой начинается с чистого старта — волны, счёт и позиция героя сброшены входом в сцену. Прогон занимает пять минут и экономит вечера: каждый пункт, найденный сейчас, стоит в десять раз дешевле, чем тот же пункт после публикации. Только после всех пунктов прототип имеет право называться играбельным.
Чек-лист итогового проекта#
- Движется: тяга стрелками плюс drag — торможение ощущается, а не обрывается
- Стреляет: пуля снимает врага по круг-круг, взрыв из частиц на месте
- Умирает: касание врага снимает жизнь, ноль — гейм-овер с честным счётом
- Считает: счёт и жизни в State, HUD совпадает с событиями каждый кадр
- Звучит: hit и death озвучены, плееры переживают сотни проигрываний
- Перезапускается: вход в бой сбрасывает волны, счёт, позицию и кулдаун
- Собирается: go build даёт десктоп, GOOS=js — браузерную версию
Формула круг-круг
Восстановите пропущенное сравнение в функции столкновения.
Тик мира аркады
Соберите порядок вызовов внутри Update мира боя.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Полный прототип требует окна, клавиатуры и звука — это территория локального Go. В песочнице сайта крутится фрагмент: автомат сцен печатает переходы меню, боя и поражения на каждом тике, а формула круг-круг считается на паре точек — обе штуки переносятся в проект дословно. Сборка WASM в песочнице недоступна: собирайте локально и выкладывайте на itch.io.
Термины проекта
Прототип — не конец, а фундамент: спрайты из уроков про изображения заменят круги героя и врагов, тайлмап нарисует уровень с препятствиями, звуковая тема накроет бой фоном. Следующий виток — контент: новые типы врагов с другими радиусами, боссы, уровни из тайлмапов — формула столкновений при этом не изменится, а сцены примут уровни как ещё один экран. Публикация — itch.io: zip с index.html, wasm-модулем и мостом, страница открывается в браузере без установки, и играбельная ссылка честнее любого резюме. Дальше проект живёт итерациями: неделя спрайтов, неделя новых врагов, неделя баланса — вертикальный срез от этого только крепнет, потому что каркас уже не меняется. Куда двигаться в Go за пределами игр: fyne для настольных GUI-приложений и bubbletea для терминальных интерфейсов — там те же идеи состояния, цикла и отрисовки, что и в этом курсе. Курс закрыт: от пустого окна до аркады, которая движется, считает, звучит и умирает красиво.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Прототип — вертикальный срез: три сцены, один вид врага, одна способность и чек-лист из пяти глаголов.
- Game — посредник: RunGame получает структуру, делегирующую Update и Draw активной сцене.
- Движение героя — ускорение от стрелок плюс drag: скорость умножается на 0.9 каждый тик.
- Столкновения — круг-круг по квадратам расстояния и радиусов; удаление врагов — обходом с конца.
- Счёт, жизни, частицы, звук и кулдаун едут из уроков 16-19 без нового API.
- Пакеты internal/game, world, ui держат зависимости в одну сторону: ui рисует, world считает.
- WASM-сборка превращает тот же код в браузерную игру для itch.io.
Домашнее задание
Соберите прототип по чек-листу: три сцены, герой с drag, волны по кривой, круг-круг, HUD, частицы, звук, кулдаун рывка. Затем усложните: добавьте второй вид врага с другим радиусом и скоростью — формула столкновений не изменится; введите бонусный рывок за прохождение волны и проверьте, что дуга кулдауна честно сбрасывается. Вынесите константы баланса — тягу, drag, размеры волн — в отдельный файл и подкрутите кривую так, чтобы четвёртая волна ощущалась пиком. Добавьте в меню лучший счёт сессии, соберите WASM и выложите игру на itch.io: отдайте ссылку другу и посмотрите, где он застрянет, — это и есть настоящее тестирование баланса.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему столкновения круг-круг, а не AABB из девятого урока?
Шутер с вращающимися кораблями честнее считать кругами: AABB зависит от поворота спрайта, круг — нет, а цена проверки одинакова. Прямоугольники остаются там, где они честны: тайлы, стены, рамки HUD. В прототипе оба способа сосуществуют — важно лишь, чтобы у каждой пары сущностей был один согласованный хитбокс, выбранный заранее.
Сколько врагов и пуль выдержит прототип без оптимизаций?
Пара сотен пар проверяется в тик без заметной нагрузки: квадрат расстояния — четыре умножения. Десятки тысяч пар потребуют пространственной сетки, и тогда профилирование из урока про производительность покажет, куда уходят тики. До этого момента оптимизация только усложнит код и скроет логику, которая в прототипе обязана читаться с листа.
Где хранить лучший счёт между запусками игры?
Внутри сессии его держит State и показывает меню; между запусками нужен файл — os.UserConfigDir плюс json.Marshal в каталоге приложения. Это тема за пределами курса, но принцип тот же, что со сценами: данные едут при входе через конструктор или загрузчик, а не читаются из глобальных переменных в глубине кода.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript