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

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

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

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

Камера: скроллинг мира

Камера в играх — это просто пара чисел, которая решает, какой угол большого мира вы видите на экране. Разберём ebitengine камеру до винтика: оффсет мира через GeoM, мягкое следование за героем, границы уровня, за которые камере выходить нельзя, и перевод координат мыши из экрана в мир.

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

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

Камера в играх: оффсет как перенос матрицы#

Экранная координата объекта равна мировой минус камера: спрайт, стоящий в мире на (300, 120), при камере (100, 50) рисуется в точке (200, 70). Точнее — потому что камера говорит, какое место мира совпадает с углом окна. В коде это один сдвиг GeoM: op.GeoM.Translate(x - camX, y - camY), применяемый к каждому рисованию. Логика игры о камере не знает: герой едет в мировых координатах, коллизии считаются в мировых координатах, и только Draw вычитает оффсет. Поменяйте камеру — мир на экране сдвинется, а состояние игры не изменится ни на бит.

У официального примера camera движка тот же принцип, только оформленный матрицей: мир рисуется в большую offscreen-картинку, а затем одним DrawImage на экран с матрицей Translate(-x, -y) — и к этой же матрице легко добавляются масштаб и поворот. Для камеры без зума оба пути равнозначны: сдвиг на каждом спрайте или сдвиг всей сцены разом. Выбирайте один способ и держитесь его: смешивать вычитание в координатах с матрицами в одном рендере — верный способ получить двойной сдвиг, который ищется часами.

Мир, сдвинутый на оффсет

GO
func (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 принимает дробные координаты. Округление до целых пикселей стоит включать, когда хочется пиксель-чёткости, и тогда округляйте в одном месте — при отрисовке, а не в состоянии камеры: иначе накопленная ошибка округления будет тормозить камеру относительно героя, и вы будете искать источник рассинхрона в формуле догона, где его нет.

Догон и границы одной функцией

GO
const (
        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?

Экран в мир: ввод по координатам камеры#

Преобразование работает в обе стороны, и обратное не менее важно прямого. Прямое — мир в экран — выполняет отрисовка. Обратное — экран в мир — нужен вводу: клик по экрану должен попасть в ту точку мира, на которую игрок смотрит. Для простой камеры-оффсета ответ — прибавление: worldX := screenX + camX. Для камеры с масштабом и поворотом сумма уже не работает, и здесь путь матриц из официального примера: мировая матрица камеры обратима, метод Invert разворачивает её, а Apply применяет к точке экрана — и получает точку мира. Матрица может быть необратимой при вырожденном масштабе, и пример честно возвращает NaN — граничный случай, о котором стоит помнить, если вы добавляете зум.

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

Правила камеры одним списком#

  • Экранная позиция — мировая минус камера; оффсет живёт только в отрисовке
  • Логика героя, коллизии и ввод — в мировых координатах, камера им неизвестна
  • Цель следования — герой минус половина окна; рывок сглаживает lerp за тик
  • Clamp зажимает камеру в диапазон от нуля до размер мира минус окно
  • Мир уже окна — камера по этой оси стоит на нуле, случай проверяется явно
  • Ввод мыши переводится в мир: прибавлением камеры или обратной матрицей
  • Рисуйте только тайлы видимой области — диапазон считается из камеры делением

Соберите камеру с границами

Восстановите три пропуска: фактор догона, ширину окна и зажим снизу.

func clampCam(cam, target float64) float64 { cam += (target - cam) * maxCam := worldW - if maxCam < 0 { return 0 } return math.(0, math.Min(maxCam, cam)) }

Кадр с камерой по шагам

Расставьте шаги игрового кадра с камерой в правильном порядке.

Строки

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

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

Камера и производительность: рисуем видимое#

Камера отдаёт бонус, о котором легко забыть: если виден кусок мира, значит рисовать нужно только его. Цикл по тайлмапе ограничивается видимой областью: левая ячейка — int(camX / tileSize), правая — та же формула плюс ширина окна в тайлах, и два вложенных цикла идут уже по этому диапазону, а не по всей матрице. Уровень сто на сто тайлов при окне двадцать на пятнадцать перестаёт быть тремя сотнями лишних DrawImage за кадр — наружу выезжают только тайлы, попадающие в окно. Границы диапазона тоже зажимаются в границы матрицы: у левого края камеры деление может дать отрицательную ячейку, у правого — выйти за ширину карты, и оба конца проверяются clamp-ом, как сама камера. Цену отрисовки, кэш движка и профилирование этого хозяйства разберёт урок про производительность, а сейчас достаточно привычки: границы цикла считаются от камеры.

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

Скроллинг собран: оффсет сдвигает мир, догон с lerp даёт движению вес, clamp держит камеру внутри уровня, а ввод переводится обратно в мировые координаты. Формулы урока — чистая арифметика без единого вызова движка, поэтому их можно тестировать без окна и переносить между проектами как есть. Домашнее задание растянет карту из тайлмап-урока до большого уровня и подключит камеру целиком. Если после подключения камера дёргается на краях или прицел промахивается — вернитесь к живому месту и списку правил: обе проблемы лежат на границе двух систем координат, и обе решаются одной дисциплиной. Юнит-тест на clampCam из домашнего задания стоит двух часов отладки: камера — чистые числа, и её поведение проверяется без окна.

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

Термин

Оффсет камеры

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

Пара camX, camY — точка мира, совпадающая с углом окна. Экранная позиция = мировая минус камера; применяется только при отрисовке.

1 / 6

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

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

Вывод

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

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

  • Камера — два числа: точка мира, совпадающая с углом окна; экранная позиция равна мировой минус камера.
  • Оффсет применяется только при отрисовке: логика героя и коллизии остаются в мировых координатах.
  • Следование — цель герой минус половина окна, а lerp с фактором за тик заменяет рывок мягким догоном.
  • Clamp зажимает камеру в [0, размер мира - размер окна]; мир уже окна — камера стоит на нуле.
  • Ввод мыши переводится в мир прибавлением камеры, а при зуме — обратной матрицей из примера camera.
  • Цикл рендера ограничивается видимой областью: границы цикла считаются от позиции камеры и зажимаются в границы матрицы.

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

Растяните карту тайлмап-урока до уровня, который больше окна минимум втрое, и добавьте поля camX, camY. Подключите следование за героем с lerp-фактором и проверьте три значения: жёсткая единица, мягкие пятнадцать процентов, ленивые пять — запишите, как меняется ощущение движения. Добавьте clamp по обеим осям и нарочно дойдите до угла уровня: за границей не должно открыться пустоты. Напишите юнит-тест clampCam: мир уже окна, герой за правым краем, герой в центре — три случая и три ожидаемых числа. Затем добавьте подсветку тайла под курсором мыши с переводом экрана в мир и убедитесь, что подсвечивается именно тот тайл, на который смотрите. В конце ограничьте цикл рендера видимой областью и выведите количество нарисованных тайлов на краю и в центре уровня.

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

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

Камера должна быть указателем на структуру с методами, как в официальном примере?

Не обязана: для игр курса хватает двух полей camX, camY и пары функций. Структура с матрицами оправдывает себя, когда появляется зум, поворот или несколько камер — тогда оффсеты, матрица мира и ScreenToWorld собираются в один тип, как в примере camera движка.

Почему герой на краю экрана дёргается при каждом шаге?

Проверьте порядок операций: сначала движется герой, потом пересчитывается камера, и только затем рисуется кадр. Если камера пересчитывается после отрисовки, картинка всегда отстаёт на тик. Второй подозреваемый — целочисленная камера при дробном герое: округление позиции съедает доли пикселя, и шов виден на медленном движении.

Нужна ли камере инерция за пределами lerp — пружины и тряска?

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

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

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

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

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

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

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

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