Содержание урока · 10 разделов
- Камера в играх: оффсет как перенос матрицы
- Следование за героем: цель и плавность
- Стратегии следования камеры
- Границы мира: камере нельзя показывать пустоту
- Экран в мир: ввод по координатам камеры
- Правила камеры одним списком
- Камера и производительность: рисуем видимое
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Карта из прошлого урока закончилась краями окна: двадцать на пятнадцать тайлов, и всё. Настоящий уровень больше экрана — в два, в десять, в сто раз, — а игрок должен видеть его по кускам, заезжая взглядом за героем. Механизм, который это делает, называется камерой, и вопреки названию это не объект с линзой, а два числа: camX и camY — координата левого верхнего угла окна внутри мира. Вся тайлмапа остаётся в мировых координатах без изменений, а сдвиг происходит в последний момент — при отрисовке. В этом уроке камера в играх собирается из четырёх частей: оффсет, следование, границы и обратный перевод ввода.
Камера в играх: оффсет как перенос матрицы#
Экранная координата объекта равна мировой минус камера: спрайт, стоящий в мире на (300, 120), при камере (100, 50) рисуется в точке (200, 70). Точнее — потому что камера говорит, какое место мира совпадает с углом окна. В коде это один сдвиг GeoM: op.GeoM.Translate(x - camX, y - camY), применяемый к каждому рисованию. Логика игры о камере не знает: герой едет в мировых координатах, коллизии считаются в мировых координатах, и только Draw вычитает оффсет. Поменяйте камеру — мир на экране сдвинется, а состояние игры не изменится ни на бит.
У официального примера camera движка тот же принцип, только оформленный матрицей: мир рисуется в большую offscreen-картинку, а затем одним DrawImage на экран с матрицей Translate(-x, -y) — и к этой же матрице легко добавляются масштаб и поворот. Для камеры без зума оба пути равнозначны: сдвиг на каждом спрайте или сдвиг всей сцены разом. Выбирайте один способ и держитесь его: смешивать вычитание в координатах с матрицами в одном рендере — верный способ получить двойной сдвиг, который ищется часами.
Мир, сдвинутый на оффсет
GOfunc (g *Game) Draw(screen *ebiten.Image) {
for y, row := range g.world {
for x, t := range row {
if t == 0 {
continue
}
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(
float64(x*tileSize)-g.camX,
float64(y*tileSize)-g.camY)
screen.DrawImage(g.tiles[t], op)
}
}
// герой живёт в мировых координатах,
// а рисуется со сдвигом камеры
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(g.hero.x-g.camX, g.hero.y-g.camY)
screen.DrawImage(g.hero.sprite, op)
}Каждый тайл и герой рисуются по одной формуле: мировая позиция минус камера. Матрица тайлов не переворачивается и не пересчитывается — оффсет существует только в момент отрисовки, а в логике героя и коллизий его по-прежнему нет. Порядок вызовов важен: сначала фон-мир, затем герой поверх, как и со слоями тайлмапы; поменяете порядок — герой окажется под землёй. Камера вычитается у каждого рисования отдельно: и у тайлов, и у героя, — так рисование остаётся единственным местом, знающим о ней.
Герой стоит в мире на (300, 120), камера — (100, 50). Где герой окажется на экране?
Следование за героем: цель и плавность#
Оффсет сам себя не поставит: каждый тик камера должна догонять героя. Цель следования считается элементарно — герой в центре окна: targetX := hero.x - viewW/2, и с этой строкой камера уже работает. Но жёсткая привязка выглядит дёшево: герой стоит на месте экрана, а мир под ним ползёт в точности с его движением, каждый шаг героя — шаг картинки, глазу не за что зацепиться. Классическое лечение — линейная интерполяция: camX += (targetX - camX) * 0.15. Камера каждый тик проезжает пятнадцать процентов оставшегося расстояния, догоняя героя с лёгким запаздыванием — движение получает вес, а картинка дышит.
Множитель — единственная ручка характера камеры. Пятнадцать процентов за тик при TPS 60 — мягкий догон за треть секунды; ноль целых ноль пять — ленивая камера для исследовательских игр; единица возвращает жёсткую привязку. Обратная сторона плавности: при lerp герой на краю экрана в момент резкого рывка ненадолго выходит из виду, поэтому в жанрах, где важна каждая секунда видимости — файтинги, платформеры с точными прыжками, — часто берут зону покоя: пока герой внутри центральной полосы окна, камера стоит, вышёл за границу — поехала.
Множитель привязан к частоте тиков: те же пятнадцать процентов при SetTPS на сто двадцать догоняют вдвое быстрее, потому что шагов за секунду станет вдвое больше. Поднимая TPS ради точной физики из урока про движение, пересчитывайте и характер камеры — это та же константа, что и framesPer анимации: её место в конфиге рядом с TPS.
Стратегии следования камеры#
| Стратегия | Как движется | Когда выбирать |
|---|---|---|
| Жёсткая привязка | cam = герой минус центр окна | стрелялки и аркады, где герой всегда в кадре |
| Lerp-догон | проценты от расстояния за тик | исследования и платформеры: движение получает вес |
| Зона покоя | стоит, пока герой внутри полосы | точные прыжки: камера не мешает видеть траекторию |
| Ручное управление | камера едет от клавиш, как в примере camera | стратегии и карты: игрок сам ведёт взгляд |
Куда уедет камера за один тик?
Прочитайте код и выберите, что он выведет:
// camX = 0, герой далеко справа:
target := 100.0
camX += (target - camX) * 0.1
// чему равен camX после первого тика?Границы мира: камере нельзя показывать пустоту#
Без ограничений плавная камера рано или поздно уезжает за край уровня: герой подошёл к правой границе, догон пошёл дальше, и справа от уровня открылась черная пустота. Лечение — clamp: камера зажимается в диапазон от нуля до размера мира минус размер окна. У левого верхнего угла camX не бывает меньше нуля, у правого — больше, чем worldW - viewW: дальше рисовать нечего. Для оси Y всё то же самое с высотой мира и окна из урока про окно и холст.
Отдельного внимания заслуживает вырожденный случай: мир уже окна. Тогда worldW - viewW отрицательно, и зажим честно попытается держать камеру между нулём и минус числом — картинка будет дрожать между двумя крайностями. Договоритесь заранее: если мир меньше окна по какой-то оси, камера по этой оси замирает на нуле (или центрирует мир), и уровень дополняется рамкой фона. Это пятиминутная проверка, которая экономит вечер отладки дрожания на тестовой карте.
Дробная камера — норма, а не грязь: герой едет плавно, и DrawImage принимает дробные координаты. Округление до целых пикселей стоит включать, когда хочется пиксель-чёткости, и тогда округляйте в одном месте — при отрисовке, а не в состоянии камеры: иначе накопленная ошибка округления будет тормозить камеру относительно героя, и вы будете искать источник рассинхрона в формуле догона, где его нет.
Догон и границы одной функцией
GOconst (
worldW = 60 * tileSize // ширина мира в пикселях
viewW = 320 // ширина окна
)
func clampCam(cam, target float64) float64 {
cam += (target - cam) * 0.15 // мягкий догон
maxCam := worldW - viewW // правая граница мира
if maxCam < 0 {
return 0 // мир уже окна: камера стоит
}
return math.Max(0, math.Min(maxCam, cam))
}Функция чистая: получает камеру и цель, возвращает новую камеру — легко тестируется без окна. Догон идёт до зажима, поэтому плавность сохраняется и на границах: камера мягко упирается в край мира, а не обрубается на полном ходу. Guard на maxCam меньше нуля закрывает случай мира, который уже окна.
Мир 960 пикселей шириной, окно 320. Формула следования выдала camX = 700. Что применит clamp?
Позиция мыши из урока про мышь и тач — экранные координаты: движок не знает о вашей камере. Первый прицел в игре с камерой всегда промахивается на величину оффсета: игрок целится в врага, а выстрел уходит в точку мира со сдвигом на camX. Что сломалось бы дальше: подсказки и маркеры, нарисованные по мировым координатам, разъехались бы с тем, на что реально нажал игрок. Переводите ввод: worldX := mouseX + camX — и ввод снова попадает туда, куда смотрит игрок.
Экран в мир: ввод по координатам камеры#
Преобразование работает в обе стороны, и обратное не менее важно прямого. Прямое — мир в экран — выполняет отрисовка. Обратное — экран в мир — нужен вводу: клик по экрану должен попасть в ту точку мира, на которую игрок смотрит. Для простой камеры-оффсета ответ — прибавление: worldX := screenX + camX. Для камеры с масштабом и поворотом сумма уже не работает, и здесь путь матриц из официального примера: мировая матрица камеры обратима, метод Invert разворачивает её, а Apply применяет к точке экрана — и получает точку мира. Матрица может быть необратимой при вырожденном масштабе, и пример честно возвращает NaN — граничный случай, о котором стоит помнить, если вы добавляете зум.
Правило распределения работы простое: всё, что видит игрок, — в экранных координатах с вычетом камеры; всё, что происходит в мире, — в мировых без камеры. Мост между ними — две формулы урока. Как только обе стоят на месте, исчезает целый класс багов, при которых игра отвечает не там, где показывает: прицелы, подсветки тайлов под курсором и клики по меню из мира живут в этой границе.
Правила камеры одним списком#
- Экранная позиция — мировая минус камера; оффсет живёт только в отрисовке
- Логика героя, коллизии и ввод — в мировых координатах, камера им неизвестна
- Цель следования — герой минус половина окна; рывок сглаживает lerp за тик
- Clamp зажимает камеру в диапазон от нуля до размер мира минус окно
- Мир уже окна — камера по этой оси стоит на нуле, случай проверяется явно
- Ввод мыши переводится в мир: прибавлением камеры или обратной матрицей
- Рисуйте только тайлы видимой области — диапазон считается из камеры делением
Соберите камеру с границами
Восстановите три пропуска: фактор догона, ширину окна и зажим снизу.
Сдвигать спрайты можно только в локальном Go с проектом из тайлмап-урока, но арифметика камеры — чистые числа. Тематическая программа в песочнице сайта ведёт камеру за виртуальным героем: печатает цель, догон с lerp и результат зажима на каждом тике — соберите камеру из чисел до того, как подключать её к рендеру.
Кадр с камерой по шагам
Расставьте шаги игрового кадра с камерой в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Камера и производительность: рисуем видимое#
Камера отдаёт бонус, о котором легко забыть: если виден кусок мира, значит рисовать нужно только его. Цикл по тайлмапе ограничивается видимой областью: левая ячейка — int(camX / tileSize), правая — та же формула плюс ширина окна в тайлах, и два вложенных цикла идут уже по этому диапазону, а не по всей матрице. Уровень сто на сто тайлов при окне двадцать на пятнадцать перестаёт быть тремя сотнями лишних DrawImage за кадр — наружу выезжают только тайлы, попадающие в окно. Границы диапазона тоже зажимаются в границы матрицы: у левого края камеры деление может дать отрицательную ячейку, у правого — выйти за ширину карты, и оба конца проверяются clamp-ом, как сама камера. Цену отрисовки, кэш движка и профилирование этого хозяйства разберёт урок про производительность, а сейчас достаточно привычки: границы цикла считаются от камеры.
Мир с камерой меняет и ощущение размера кода: вся большая карта по-прежнему матрица чисел, весь рендер — два цикла с вычетом, вся логика — без единого упоминания о камере. Это последний кирпич, после которого игра перестаёт быть комнатой и становится уровнем: границы, догон, зажим и перевод ввода — четыре функции, которые не растут вместе с миром. Дальше по курсу в игру добавится текст — счёт и названия, — и HUD окажется той редкой частью, которую камера трогать не должна: интерфейс рисуется после мира в чистых экранных координатах.
Скроллинг собран: оффсет сдвигает мир, догон с lerp даёт движению вес, clamp держит камеру внутри уровня, а ввод переводится обратно в мировые координаты. Формулы урока — чистая арифметика без единого вызова движка, поэтому их можно тестировать без окна и переносить между проектами как есть. Домашнее задание растянет карту из тайлмап-урока до большого уровня и подключит камеру целиком. Если после подключения камера дёргается на краях или прицел промахивается — вернитесь к живому месту и списку правил: обе проблемы лежат на границе двух систем координат, и обе решаются одной дисциплиной. Юнит-тест на clampCam из домашнего задания стоит двух часов отладки: камера — чистые числа, и её поведение проверяется без окна.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Камера — два числа: точка мира, совпадающая с углом окна; экранная позиция равна мировой минус камера.
- Оффсет применяется только при отрисовке: логика героя и коллизии остаются в мировых координатах.
- Следование — цель герой минус половина окна, а lerp с фактором за тик заменяет рывок мягким догоном.
- Clamp зажимает камеру в [0, размер мира - размер окна]; мир уже окна — камера стоит на нуле.
- Ввод мыши переводится в мир прибавлением камеры, а при зуме — обратной матрицей из примера camera.
- Цикл рендера ограничивается видимой областью: границы цикла считаются от позиции камеры и зажимаются в границы матрицы.
Домашнее задание
Растяните карту тайлмап-урока до уровня, который больше окна минимум втрое, и добавьте поля camX, camY. Подключите следование за героем с lerp-фактором и проверьте три значения: жёсткая единица, мягкие пятнадцать процентов, ленивые пять — запишите, как меняется ощущение движения. Добавьте clamp по обеим осям и нарочно дойдите до угла уровня: за границей не должно открыться пустоты. Напишите юнит-тест clampCam: мир уже окна, герой за правым краем, герой в центре — три случая и три ожидаемых числа. Затем добавьте подсветку тайла под курсором мыши с переводом экрана в мир и убедитесь, что подсвечивается именно тот тайл, на который смотрите. В конце ограничьте цикл рендера видимой областью и выведите количество нарисованных тайлов на краю и в центре уровня.
Личные заметкиПусто
Вопросы и ответы по уроку
Камера должна быть указателем на структуру с методами, как в официальном примере?
Не обязана: для игр курса хватает двух полей camX, camY и пары функций. Структура с матрицами оправдывает себя, когда появляется зум, поворот или несколько камер — тогда оффсеты, матрица мира и ScreenToWorld собираются в один тип, как в примере camera движка.
Почему герой на краю экрана дёргается при каждом шаге?
Проверьте порядок операций: сначала движется герой, потом пересчитывается камера, и только затем рисуется кадр. Если камера пересчитывается после отрисовки, картинка всегда отстаёт на тик. Второй подозреваемый — целочисленная камера при дробном герое: округление позиции съедает доли пикселя, и шов виден на медленном движении.
Нужна ли камере инерция за пределами lerp — пружины и тряска?
Тряска при взрывах и пружины надстраиваются поверх той же схемы: к рассчитанной позиции камеры добавляют короткоживущий шум-оффсет, который затухает за несколько десятков тиков. Базовый догон и границы при этом не трогаются — эффект живёт отдельным слоем и умирает сам, не оставляя следов в логике.
Термины курса
Понятия, на которых строится этот курс, — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылок на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript