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

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

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

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

Звук и музыка

Пока игра не умеет звук, она выглядит прототипом, каким бы красивым ни был арт: в Ebitengine за звук отвечает отдельный пакет audio, и он подчиняется обычным правилам Go. Мы соберём минимальный аудиокаркас: один audio.Context на игру, плеер для зацикленной музыки, пул плееров для коротких эффектов, громкость и mute-тумблер на клавишу, а заодно разберём типовую ловушку, из-за которой звук обрывается на середине.

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

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

audio.Context: один контекст на игру#

Аудиосистема начинается с audio.Context — объекта, который владеет устройством вывода и микширует все потоки игры. Контекст создаётся один раз за жизнь программы вызовом audio.NewContext(44100): аргумент — частота дискретизации в герцах, то есть сколько сэмплов в секунду потребляет выход. Дублей быть не должно, поэтому у контекста одно место рождения — конструктор Game; там, где контекст нужен без передачи через поля, готовый объект отдаёт audio.CurrentContext() — именно так поступают примеры самого движка. Частота выбирается один раз и навсегда: 44100 или 48000 герц, разницы на слух нет, зато все плееры контекста работают в одной шкале, и смешивать частоты вручную не придётся.

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

Контекст и зацикленная музыка

GO
//go:embed assets/music.ogg
var musicOgg []byte

const (
        sampleRate     = 44100
        bytesPerSample = 8 // 2 канала * 4 байта float32
        introSeconds   = 4
)

type Game struct {
        audioCtx    *audio.Context
        music       *audio.Player // ссылка обязана жить в структуре
        musicVolume float64
}

func NewGame() (*Game, error) {
        g := &Game{musicVolume: 0.6}
        g.audioCtx = audio.NewContext(sampleRate) // один контекст на игру

        s, err := vorbis.DecodeF32(bytes.NewReader(musicOgg)) // Ogg -> PCM
        if err != nil {
                return nil, err
        }
        // интро звучит один раз, дальше трек крутится по кругу
        loop := audio.NewInfiniteLoopWithIntroF32(s,
                introSeconds*bytesPerSample*sampleRate,
                s.Length()-introSeconds*bytesPerSample*sampleRate)
        g.music, err = g.audioCtx.NewPlayerF32(loop)
        if err != nil {
                return nil, err
        }
        g.music.SetVolume(g.musicVolume) // громкость 0..1
        g.music.Play()
        return g, nil
}

Контекст создаётся в единственном месте — конструкторе игры. Декодер vorbis.DecodeF32 превращает Ogg-файл в поток float32, длину которого Length() отдаёт в байтах: для стерео F32 один сэмпл весит восемь байт, поэтому длительность интро умножается на частоту и bytesPerSample. Обёртка NewInfiniteLoopWithIntroF32 делает поток бесконечным, NewPlayerF32 получает плеер, SetVolume задаёт громкость от нуля до единицы, Play запускает музыку до явной остановки.

Сколько объектов audio.Context должно быть в игре?

Форматы: WAV, MP3 и Ogg#

Пакет audio сырые файлы не ест: байты превращаются в PCM-сэмплы декодерами из подпакетов. Их три: wav.DecodeF32 для WAV, mp3.DecodeF32 для MP3 и vorbis.DecodeF32 для Ogg с кодеком Vorbis — этот список форматов и есть поддержка движка, всё сверх него придётся декодировать сторонними библиотеками и скармливать плееру готовые сэмплы. Выбор формата — компромисс размера и скорости: WAV не сжимается и декодируется мгновенно, поэтому он идеален для коротких эффектов, которые запускаются в ответ на нажатие; Ogg и MP3 сжимают в разы лучше, поэтому ими берут музыку и длинные вставки, где лишняя задержка на старте незаметна. Декодеры отдают поток F32 — тридцатидвухбитные плавающие сэмплы, единый формат для всех плееров контекста, и суффикс F32 в именах декодеров об этом честно говорит.

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

PlaySfx: пул плееров и mute-тумблер

GO
// в NewGame: декодируем эффект один раз и держим готовые байты
d, err := wav.DecodeF32(bytes.NewReader(jumpWav))
if err != nil {
        return nil, err
}
g.sfxBytes, err = io.ReadAll(d) // PCM в памяти: плеер собирается мгновенно

// PlaySfx играет эффект: свободный плеер переиспользуется,
// иначе заводится новый
func (g *Game) PlaySfx() {
        for _, p := range g.sfxPool {
                if !p.IsPlaying() {
                        p.Rewind() // назад в начало
                        p.Play()
                        return
                }
        }
        p, err := g.audioCtx.NewPlayerF32FromBytes(g.sfxBytes)
        if err != nil {
                return
        }
        g.sfxPool = append(g.sfxPool, p) // ссылка живёт в слайсе
        p.SetVolume(0.5)
        p.Play()
}

// в Update: mute по клавише M
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
        g.muted = !g.muted
        if g.muted {
                g.music.SetVolume(0)
        } else {
                g.music.SetVolume(g.musicVolume)
        }
}

Байты эффекта декодируются один раз при загрузке: io.ReadAll вынимает из потока готовые PCM-сэмплы, и NewPlayerF32FromBytes потом собирает плеер за микросекунды. PlaySfx сначала ищет в пуле неиграющий плеер: Rewind возвращает его в начало, Play запускает заново. Свободных нет — создаётся новый и добавляется в слайс ссылок, поэтому эффекты накладываются друг на друга. Тумблер mute не удаляет звук, а ставит музыке громкость ноль: она продолжает играть, и снятие mute продолжит её с того же места.

Звук → плеер → громкость#

ЗвукПлеерГромкость
Шаги и прыжок герояодин плеер в Game, Rewind и Play0.7–0.9: герой слышен всегда
Выстрелы и попаданияпул из PlaySfx, звуки поверх друг друга0.4–0.6: частое звучит тише
Музыка уровняодин долгий плеер с бесконечным лупом0.3–0.5: фон не перекрикивает бой
UI-кликипереиспользуемый плеер из пула0.3: короткий и ненавязчивый
Смерть и победаотдельный плеер без зацикливания1.0: событие раз за партию
Mute-тумблерплееры не трогает, меняет SetVolume0 у музыки, эффекты по вкусу

Что услышит игрок при двух выстрелах в один тик?

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

// пул пуст, ни один эффект не играет
PlaySfx() // -> NewPlayerF32FromBytes, играет плеер p1
// второй выстрел в тот же тик:
PlaySfx()

Громкость: SetVolume и тумблер mute#

Громкость плеера — это SetVolume с числом от нуля до единицы: единица означает исходную громкость файла, ноль — тишину. Значения выше единицы усиления не обещают, а общий ползунок в настройках — это не новый механизм, а умножение базовых громкостей плееров на коэффициент. Ухо слышит громкость логарифмически: разница между 0.1 и 0.2 огромна, между 0.8 и 0.9 почти незаметна, поэтому линейный ползунок ощущается сломанным — первые шаги глухие, последние три оглушают. Практическое правило простое: музыку держать заметно тише эффектов, иначе она съедает выстрелы, шаги и всё, на чём строится отклик игры.

Mute — не отдельный тайник громкости, а запомненное значение: тумблер сохраняет текущую громкость в поле структуры, ставит ноль, а при снятии возвращает сохранённое. Место для muted и уровней громкости — игровое состояние: настройки звука живут рядом со счётом и паузой и обязаны переживать переходы сцен. Резкий SetVolume на границе смерти режет ухо, поэтому громкость, которую меняет сама игра, лучше подводить к цели за короткий таймер в Update — плавное затухание звучит профессиональнее щелчка.

Эффект играл три секунды и оборвался на середине. Его плеер создаётся локальной переменной и нигде не сохраняется. Что произошло?

Эффекты и музыка: разные жизни плееров#

Короткий эффект и музыкальный трек — два разных режима работы плеера, и смешивать их — типовая ошибка. Эффект живёт секунды: его данные держат готовыми байтами в памяти, плеер берётся из пула или заводится на лету, и после Play ему не нужно никакого ухода. Музыка живёт всю сцену: это один долгий плеер, заведённый на входе в игру или уровень, с бесконечным лупом из NewInfiniteLoopWithIntroF32, которым управляют только пауза и громкость. Заводить новый плеер на каждый кадр музыки — прямой путь к исчерпанию ресурсов, а переиспользовать музыкальный плеер для эффектов — получить Rewind посреди трека вместо прыжка.

Правило звучит так: сколько независимых источников у события — столько и плееров. Прыжок героя играет через один плеер, который Rewind-ится на новом прыжке: старый звук обрывается, и это даже правильно — очередь прыжков не должна звучать хором. Выстрелы очереди накладываются — значит, нужен пул. Музыка одна, и второй плеер для неё заводится только при смене трека. Ровно так устроен пример flappy из поставки движка: два поля jumpPlayer и hitPlayer, на каждое событие — Rewind и Play, и никакой очереди плееров. Пауза музыки — тоже отдельная работа: уходя в меню, плеер ставят на Pause, возвращаясь в бой — на Play, и трек продолжается ровно с того же места, где игрок его покинул. Если музыка должна смениться, поле music просто заменяется новым плеером: старую ссылку никто не держит, и сборщик забирает плеер вместе с отзвучавшим треком.

Правила звука одним списком#

  • audio.Context создаётся один раз на игру: плееры берутся только из него
  • Ссылка на плеер живёт в поле структуры или в пуле — иначе GC остановит звук
  • Декодеры движка — WAV, MP3 и Ogg: эффекты в WAV, музыка в сжатых форматах
  • Эффекты декодируются один раз в байты, плееры собираются из NewPlayerF32FromBytes
  • Короткие звуки — пул плееров с Rewind и Play: наложение звуков бесплатно
  • Музыка — один долгий плеер с бесконечным лупом и паузой на выходе в меню
  • Громкость — SetVolume от нуля до единицы, музыка тише эффектов
  • Mute хранит прошлое значение в состоянии игры и возвращает его при снятии

Соберите запуск музыки

Восстановите два вызова: создание контекста и плеера.

g.audioCtx = audio.(sampleRate) // один на игру ... g.music, err = g.audioCtx.(loop) g.music.SetVolume(0.6) g.music.Play()

Тик PlaySfx

Соберите поиск свободного плеера в пуле эффектов.

Строки

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

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

go:embed: звук внутри бинаря#

Все примеры выше читали звук из байтов в памяти не случайно: файлы проекта встраиваются в бинарь директивой go:embed, которая прилинковывает содержимое папки assets к программе ещё на компиляции. Для звука это три выигрыша сразу: файлы не теряются при переносе бинаря, загрузка мгновенна — декодер ест байты из памяти через bytes.NewReader, — а сборка под другие платформы не тащит за собой папку ассетов. Платить приходится размером исполняемого файла: несжатый WAV растолстеет в разы, поэтому для музыки разумнее Ogg, а WAV оставить коротким эффектам, где секунды сжатия не решают.

Встраивание не отменяет порядок в проекте: подпапки music и sfx внутри assets, осмысленные имена файлов, единая точка загрузки в конструкторе — всё это переживёт момент, когда ассетов станет сотня. Директива работает только с файлами своего пакета, поэтому байты звука объявляют в одном файле ассетов, а остальные пакеты получают готовые переменные. И помните про права: встроенный в бинарь трек с чужой лицензией остаётся чужим даже внутри исполняемого файла, и проверка лицензий — часть релиза, а не дело после него.

Игра научилась звучать: один контекст держит аудиовыход, музыка крутится бесконечным лупом, эффекты снимаются с пула, а громкость и mute подчиняются состоянию игры. Главная привычка урока — владение ссылками: плеер, который должен доиграть, обязан жить в структуре, и проверяется это не тестом, а долгой сессией. Вторая привычка — разделение ролей: короткие звуки и долгая музыка не делят ни плееров, ни логику, и именно поэтому микс не рассыпается при десятке одновременных событий. Дальше курс возвращается к картинке: в следующем уроке HUD и индикаторы соберутся из векторных примитивов без единой картинки, а следом — производительность, где выяснится, сколько стоит каждый такой кадр. Домашнее задание прикрутит звук к вашему проекту, и первый озвученный прыжок изменит ощущение от игры сильнее, чем любая тень.

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

Термин

audio.Context

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

Владелец аудиовыхода и микшера. Создаётся один раз на игру через audio.NewContext(44100); готовый контекст отдаёт audio.CurrentContext().

1 / 6

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

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

Вывод

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

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

  • audio.Context — один на игру: частота 44100, создание в конструкторе, доступ через CurrentContext.
  • Форматы движка — WAV, MP3 и Ogg; декодеры F32 отдают поток сэмплов для NewPlayerF32.
  • Ссылка на плеер живёт в структуре или пуле: потеряли — GC остановит звук.
  • Эффекты: готовые байты, пул плееров, Rewind и Play — наложение звуков бесплатно.
  • Музыка: один долгий плеер с бесконечным лупом, громкость ниже эффектов.
  • SetVolume — число от нуля до единицы; mute хранит прошлое значение в состоянии игры.
  • Звук встраивается go:embed и едет внутри бинаря на все платформы.

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

Подключите звук к проекту: заведите папку assets с двумя файлами — коротким WAV для прыжка и любым Ogg для фоновой музыки. В конструкторе Game создайте audio.NewContext(44100), декодируйте WAV в байты через wav.DecodeF32 и io.ReadAll, а музыку заверните в NewInfiniteLoopWithIntroF32 и запустите одним плеером. Реализуйте PlaySfx с пулом: свободный плеер переигрывается через Rewind, новый — добавляется в слайс ссылок. Повесьте mute на клавишу M с сохранением прошлой громкости и проверьте живое место: запустите игру минут на пять — если звук обрывается, ищите плеер без ссылок. Сбалансируйте микс: музыка тише эффектов примерно на треть. Затем повесьте отдельный звук на столкновения из урока про коллизии и убедитесь, что десять быстрых прыжков подряд не плодят плееры до бесконечности — пул обязан выйти на плато.

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

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

Почему 44100 герц, а не 48000, как в примерах движка?

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

Можно ли генерировать звук без файлов, например писк?

Да: плееру не важно, откуда поток. Реализуйте собственный тип с чтением сэмплов — синус по формуле — и отдайте его NewPlayerF32; примеры sinewave и pcm в поставке движка делают ровно это. Для ретро-эффектов генерация дешевле файлов, и бинарь от неё не толстеет.

Эффектов десятки, и пул растёт. Что делать?

Ограничьте размер пула и при переполнении либо игнорируйте новый вызов, либо забирайте самый старый плеер. На слух очередь из десяти наложенных выстрелов превращается в кашу: три-четыре одновременных звука одного эффекта — практический потолок, дальше игра только шумит.

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

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

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

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

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

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

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