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

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

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

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

Итоговый проект: мини-аркада

Курс дошёл до финала: аркада на go — герой, враги, счёт, звук и сцены — собирается из деталей двадцати четырёх уроков в играбельный прототип. Нового API здесь почти нет: сцены уже умеют переключаться, частицы — взрываться, звук — проигрываться, векторная дуга — отсчитывать кулдаун. Задача урока — соединить готовые узлы в одну программу и прогнать её по чек-листу: движется, стреляет, умирает, считает, звучит. Каждый узел помечен номером урока-источника: непонятный стык открывается заново за минуту, а каркас переносится в собственный проект почти без правок. Прогон — на десктопе: окно, стрелки и звук воспроизводятся полностью, сборка под браузер проверяется отдельной командой из финального раздела. Финал — WASM: прототип уезжает в браузер, где его можно отдать друзьям одной ссылкой.

Содержание урока · 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 новое значение, сцена решает, что с ним делать, — поэтому каждый узел тестируется отдельно, а собранный прототип ломается только на стыках, которых стало меньше.

Пакеты и сборка: 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 и мира
Частицы взрыва17burst при попадании и гибели героя
Звуки hit и death18плееры в полях структуры, Rewind перед проигрыванием
Кулдаун рывка дугой19vector.Path рисует откат прямо у героя
Волны и кривая сложности243+2*wave врагов, скорость плюс восемь процентов за волну
Пакеты internal21game, world, ui без циклических импортов
Сборка WASM22один код — десктоп и браузер

Чек-лист прототипа: пять глаголов#

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

Чек-лист итогового проекта#

  • Движется: тяга стрелками плюс drag — торможение ощущается, а не обрывается
  • Стреляет: пуля снимает врага по круг-круг, взрыв из частиц на месте
  • Умирает: касание врага снимает жизнь, ноль — гейм-овер с честным счётом
  • Считает: счёт и жизни в State, HUD совпадает с событиями каждый кадр
  • Звучит: hit и death озвучены, плееры переживают сотни проигрываний
  • Перезапускается: вход в бой сбрасывает волны, счёт, позицию и кулдаун
  • Собирается: go build даёт десктоп, GOOS=js — браузерную версию

Формула круг-круг

Восстановите пропущенное сравнение в функции столкновения.

// hit проверяет столкновение двух кругов func hit(ax, ay, ar, bx, by, br float64) bool { dx, dy := bx-ax, by-ay rr := ar + br return dx*dx+dy*dy <= }

Тик мира аркады

Соберите порядок вызовов внутри Update мира боя.

Строки

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

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

Термины проекта

Термин

Сцена

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

Структура с методами Update и Draw: меню, бой, гейм-овер. Game хранит поле current и делегирует тик активной сцене.

1 / 6

Прототип — не конец, а фундамент: спрайты из уроков про изображения заменят круги героя и врагов, тайлмап нарисует уровень с препятствиями, звуковая тема накроет бой фоном. Следующий виток — контент: новые типы врагов с другими радиусами, боссы, уровни из тайлмапов — формула столкновений при этом не изменится, а сцены примут уровни как ещё один экран. Публикация — 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: отдайте ссылку другу и посмотрите, где он застрянет, — это и есть настоящее тестирование баланса.

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

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

Почему столкновения круг-круг, а не AABB из девятого урока?

Шутер с вращающимися кораблями честнее считать кругами: AABB зависит от поворота спрайта, круг — нет, а цена проверки одинакова. Прямоугольники остаются там, где они честны: тайлы, стены, рамки HUD. В прототипе оба способа сосуществуют — важно лишь, чтобы у каждой пары сущностей был один согласованный хитбокс, выбранный заранее.

Сколько врагов и пуль выдержит прототип без оптимизаций?

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

Где хранить лучший счёт между запусками игры?

Внутри сессии его держит State и показывает меню; между запусками нужен файл — os.UserConfigDir плюс json.Marshal в каталоге приложения. Это тема за пределами курса, но принцип тот же, что со сценами: данные едут при входе через конструктор или загрузчик, а не читаются из глобальных переменных в глубине кода.

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

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

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

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

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

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

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