Содержание урока · 10 разделов
- Тайлмап ebitengine: матрица вместо тысячи спрайтов
- Рендер по индексам: арифметика тайлсета
- Матрица тайлов против ручных спрайтов
- Tiled-экспорт: из редактора в Go
- Слои карты: земля, стены и декор
- Правила тайлмапов одним списком
- От матрицы к миру: камера и большой уровень
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
У героя появились кадры, и он умеет бегать — но под ним пока пустой холст. Первое желание нарисовать уровень вручную — сотней DrawImage с выписанными на бумажке координатами — ломается на втором экране уровня: координаты невозможно ни править, ни передавать художнику, ни проверять на коллизии. Геймдев решает это сеткой: уровень делится на ячейки фиксированного размера, чаще всего шестнадцать или тридцать два пикселя, а мир превращается в матрицу чисел, где каждое число — индекс тайла из тайлсета. Числа легко хранить, сравнивать и редактировать, а тайлы игры на Go рисуются циклом по матрице. В этом уроке мы строим такой рендер, читаем карту из Tiled и разделяем мир на слои.
Тайлмап ebitengine: матрица вместо тысячи спрайтов#
Тайлмап ebitengine — это срез срезов целых чисел: world[y][x] хранит номер тайла в ячейке с координатами (x, y). Ячейка ноль — пустота, единица — трава, двойка — стена: какие числа означают какие картинки, решаете вы, но одна договорённость общая для всех карт курса — ноль ничего не рисует. Размер карты считается в тайлах, а не в пикселях: поле двадцать на пятнадцать ячеек по шестнадцать пикселей даёт экран триста двадцать на двести сорок — размеры окна из уроков про окно и холст соблюдаются без усилий.
Плюсы матрицы проявляются сразу. Память: уровень сто на сто тайлов — это десять тысяч чисел, а не десять тысяч картинок. Коллизии: вопрос «стена ли передо мной» — это чтение одного элемента матрицы, без перебора прямоугольников из урока про AABB-коллизии. Редактирование: карту правит художник в редакторе, программист получает файл с числами. И отрисовка: один цикл с одним тайлсетом, который движок батчит отлично — все DrawImage берут пиксели из одной картинки, как учил атласный урок.
Рендер матрицы циклом
GOconst tileSize = 16
var (
tiles []*ebiten.Image // тайлы, нарезанные из тайлсета
world [][]int // матрица карты: world[y][x]
)
func drawWorld(world [][]int, screen *ebiten.Image) {
for y, row := range world {
for x, t := range row {
if t == 0 {
continue // пустая ячейка
}
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(
float64(x*tileSize),
float64(y*tileSize))
screen.DrawImage(tiles[t], op)
}
}
}Внешний цикл идёт по строкам матрицы, внутренний — по ячейкам строки; номер тайла из ячейки служит индексом в срезе нарезанных тайлов, а позиция на холсте получается умножением координат ячейки на размер тайла. Нулевые ячейки пропускаются раньше DrawImage — на пустых участках карты цикл почти ничего не стоит.
Карта шириной 20 тайлов, матрица хранится плоско: index = y*cols + x. Какой индекс у тайла (x=3, y=2)?
Рендер по индексам: арифметика тайлсета#
Тайлсет — тот же атлас из десятого урока: лист, на котором тайлы лежат сеткой. Нарезка делается один раз при загрузке — циклом по числу колонок и строк листа, — и дальше рендер работает только с готовым срезом. Если карта хранится плоским срезом чисел, а не матрицей, координаты ячейки достаются обратной формулой: x := i % cols, y := i / cols. Официальный пример camera движка устроен ровно так — плоский слой, деление с остатком, один DrawImage на тайл.
Свою строку арифметики добавляет сам тайлсет: чтобы вырезать тайл с номером t, нужны его координаты на листе. Если тайлсет шириной tileXCount колонок, то колонка тайла — t % tileXCount, ряд — t / tileXCount, и прямоугольник кадра собирается как в атласном уроке: sx := (t % tileXCount) tileW, sy := (t / tileXCount) tileH. Не путайте две системы индексов: индекс ячейки карты говорит, где тайл стоит в мире, а индекс тайла в тайлсете — откуда взять картинку. Первое умножается на размер ячейки при отрисовке, второе — на размер ячейки листа при вырезании.
Матрица тайлов против ручных спрайтов#
| Задача | Матрица тайлов | Спрайты вручную |
|---|---|---|
| Хранение уровня | числа в файле: срезы и матрицы | координаты в коде или сотни файлов |
| Коллизии | чтение ячейки по координатам | перебор прямоугольников AABB |
| Правка уровня | редактор Tiled, без перекомпиляции | правка кода и пересборка |
| Рендер | один цикл, один тайлсет, отличные батчи | много картинок, ручной порядок слоёв |
| Память | десятки килобайт на большой уровень | картинка на каждый объект |
| Порог входа | нужны редактор и формат экспорта | быстро для трёх стен, боль — для уровня |
Откуда вырезать тайл номер 11?
Прочитайте код и выберите, что он выведет:
const tileXCount = 8 // колонок в тайлсете
const tileSize = 16
t := 11
sx := (t % tileXCount) * tileSize
sy := (t / tileXCount) * tileSize
// кадр: image.Rect(sx, sy, sx+tileSize, sy+tileSize)Tiled-экспорт: из редактора в Go#
Карты рисуют не в коде. Tiled — бесплатный редактор карт, стандарт де-факто для тайлмапов: художник собирает уровень мышкой из тайлсета, а программа сохраняет карту в JSON — формат называют Tiled-экспорт. Внутри файла — размеры карты в тайлах, размер тайла и список слоёв; у каждого слоя есть поле data: плоский массив чисел, построчно слева направо, как формула плоского индекса из прошлого раздела. Программисту остаётся прочитать файл и разложить массив в матрицу.
Экспорт у Tiled гибкий, и одна настройка влияет на код разбора: поле data может быть либо массивом чисел — вариант CSV, — либо строкой base64, опционально сжатой gzip или zlib. Base64 компактнее на диске, но для первых карт берите честный массив: его читает json.Unmarshal из кода ниже без единой дополнительной строки. Расширение файла тоже вводит в заблуждение: Tiled сохраняет и XML-формат tmx, и JSON — карты курса живут в json, потому что стандартная библиотека разбирает его из коробки.
Одна тонкость Tiled, которая ловит каждого второго: тайлы в экспорте нумеруются с единицы, а ноль означает пустую ячейку. Число в data — это глобальный идентификатор GID, а не номер тайла в вашем тайлсете: у первого тайлсета карты поле firstgid равно единице, и тайл с номером один на листе приезжает в карту как GID один. Когда тайлсет один, GID и номер совпадают с точностью до этой единицы; когда несколько, из GID вычитают firstgid нужного тайлсета. Для первых карт хватит простого правила: прочитали число, вычли смещение, вырезали тайл, ноль — пропустили.
Разбор Tiled-JSON стандартной библиотекой
GOtype tiledMap struct {
Width int `json:"width"`
Height int `json:"height"`
Tilewidth int `json:"tilewidth"`
Layers []struct {
Data []int `json:"data"`
} `json:"layers"`
}
func parseTiled(raw []byte) ([][]int, error) {
var m tiledMap
if err := json.Unmarshal(raw, &m); err != nil {
return nil, err
}
data := m.Layers[0].Data
grid := make([][]int, m.Height)
for y := range grid {
grid[y] = data[y*m.Width : (y+1)*m.Width]
}
return grid, nil
}Структура повторяет только нужные поля JSON — Unmarshal молча пропустит всё лишнее, а теги связывают поля с именами файла. Плоский массив слоя режется на строки матрицы срезами: копирования чисел нет, строки делят память массива. Файл карты читается из embed — тот же приём, что и у PNG из урока про спрайты.
В data Tiled-слоя лежит число 0. Что должно нарисовать игру?
Герой живёт в пикселях, карта живёт в тайлах, и каждый второй первый рендер уровня спотыкается об эту границу. Типовой баг: герой с координатой x = 48 проверяет ячейку world[48][x] — а надо сначала перевести пиксели в тайлы: tx := int(x / tileSize). Что сломалось бы дальше: коллизии срабатывают через раз, герой проходит сквозь стены и врезается в пустоту, а на картах с другим размером тайла всё ломается заново. Держите две пары координат отдельно: пиксельные для рисования и движения, тайловые — для вопросов к карте.
Слои карты: земля, стены и декор#
Настоящие карты Tiled состоят из нескольких слоёв, и у каждого своя работа. Нижний слой — земля: заполненная матрица, нулей почти нет. Над ней слой стен и препятствий — тут нулей много, а числа важнее всех: по ним строится коллизия. Верхние слои — декор: кусты, вывески, туман, которые рисуются поверх героя. В JSON каждый слой — отдельный элемент массива Layers со своим data, и порядок элементов — это порядок отрисовки снизу вверх.
Технически слои — это несколько матриц одинакового размера, и игра читает их по-разному: земля и декор уходят в рендер, слой стен — в логику. Матрица проходимости даже не обязана хранить картинки: достаточно bool или чисел-флагов, и вопрос «можно ли идти» превращается в чтение одной ячейки. Это самый быстрый вид коллизий в 2D-играх — перебор прямоугольников нужен динамическим объектам, а стены мира проверяются индексами. Для чтения слоёв заведите именованные константы вместо магических чисел: tileGrass, tileWall, tileWater — код коллизии читается без справочника по тайлсету. У функции проходимости есть правило краёв, о котором забывают: ячейка за границей матрицы обязана считаться стеной, иначе герой, доехав до края уровня, уедет в отрицательные индексы — а чтение среза за границей в Go паникует. Проверка границ перед обращением к матрице — две строки, которые экономят панику на демо-запуске. Разные размеры тайлов у слоёв Tiled допускает, но для первых карт держите один размер везде: арифметика урока останется единой.
Правила тайлмапов одним списком#
- Мир — матрица чисел: world[y][x] хранит индекс тайла, ноль — пустая ячейка
- Позиция на холсте — координаты ячейки, умноженные на размер тайла
- Тайлсет — атлас: колонка и ряд тайла считаются через остаток и деление
- Tiled-экспорт — JSON со слоями; data — плоский массив построчно
- В Tiled ноль — пустота, тайлы нумеруются с единицы через firstgid
- Пиксели и тайлы — разные системы координат: перевод через деление на размер тайла
- Слои рисуются снизу вверх; слой стен может быть без картинок — только логика
Соберите рендер карты
Восстановите три пропуска: пустая ячейка, перенос матрицы и источник тайлов.
Окно и рендер требуют локального Go, но разбор карты — чистая работа со срезами. Тематическая программа в песочнице сайта печатает матрицу тайлов из мини-JSON и пересчитывает плоский индекс в координаты ячеек: подставьте свои ширину и высоту карты и проверьте формулы до сборки проекта.
Путь тайла: от файла до экрана
Соберите рендер Tiled-карты по шагам.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
От матрицы к миру: камера и большой уровень#
Экрану курса пока хватает карты двадцать на пятнадцать, но настоящий уровень больше окна — и здесь тайлмап раскрывается вместе с камерой: мир рисуется с оффсетом, а цикл рендера ограничивается видимой областью. Вместо рисования всей матрицы из сотни на сто ячеек рисуются только ячейки, попадающие в окно: начало диапазона считается из позиции камеры делением на размер тайла, длина — из ширины окна. На маленьких картах оптимизация незаметна, на больших она же определяет потолок FPS, и к ней вернётся урок про производительность.
Вторая цена большого мира — данные: рукописная матрица в коде перестаёт помещаться в голове, и Tiled-экспорт из этого раздела становится не удобством, а необходимостью. Путь данных выстраивается один раз: художник правит карту в редакторе, файл уезжает в папку assets, embed пакует его в бинарник при сборке, parseTiled отдаёт матрицы, рендер и коллизии работают по индексам. Изменение уровня перестаёт быть задачей программиста — и это ровно та граница, на которой игра превращается в продукт, который можно расширять без переписывания.
Отдельная мелочь для рендера из кода выше: DrawImageOptions можно смело создавать в цикле — официальный пример camera движка делает ровно так, а аллокация дешёвая. Нельзя бездумно переиспользовать один op на все тайлы: каждый Translate дописывается в существующую матрицу, и без Reset тайлы поедут вправо-вниз, накапливая сдвиг с каждой итерацией.
Мир курса обзавёлся поверхностью: матрица чисел описывает уровень, тайлсет даёт картинки, Tiled-экспорт приносит карту из редактора, а коллизии по ячейкам стоят дешевле любого перебора. Следующие два урока доводят мир до играбельности: камера поведёт экран за героем по уровню больше окна, а затем появится текст — счёт, подсказки и названия сцен. Домашнее задание соберёт конвейер целиком: одна карта из Tiled, один тайлсет, один герой поверх матрицы. Если герой проваливается сквозь стены или врезается в пустоту, начните с живого места: почти всегда это пиксели, притворяющиеся тайлами.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Тайлмап — матрица чисел: мир хранится и правится как данные, а не как сотни спрайтов с координатами в коде.
- Рендер — цикл по матрице: номер тайла выбирает картинку из тайлсета, позиция — координаты ячейки на размер тайла.
- Тайлсет — атлас; координаты тайла на листе считаются остатком и делением по числу колонок листа.
- Tiled-экспорт — JSON со слоями-массивами; ноль в data — пустая ячейка, тайлы нумеруются с единицы.
- Пиксельные координаты героя переводятся в тайловые делением на размер тайла — и только так.
- Слои делят карту на землю, стены и декор; слой стен может быть чистой логикой без картинок.
- Ячейка за границей матрицы — стена по умолчанию: чтение среза за границей паникует, а герой не должен уезжать за уровень.
Домашнее задание
Нарисуйте в Tiled карту десять на восемь тайлов из любого бесплатного тайлсета и экспортируйте в JSON. Напишите структуру разбора и функцию parseTiled из урока, сложите файл в папку assets и подключите через embed. Выведите карту циклом на холст и проверьте: нулевые ячейки не рисуются, тайлы совпадают с редактором. Добавьте слой стен и функцию isSolid(tx, ty int) bool, которая отвечает, можно ли идти в ячейку, — протестируйте её отдельно от движка на числах из карты. Затем посадите героя из прошлых уроков на карту и переведите его пиксельные координаты в тайловые при проверке стен. В конце выведите отладочно, сколько тайлов из матрицы попало в окно, — эта цифра пригодится, когда появится камера.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли обойтись без Tiled и хранить карты прямо в коде?
Для тестовой комнаты — да: матрица литералами в файле работает и отлаживается быстро. Но уровень в сто на сто тайлов в коде не правится, художнику ничего не показать, а любая правка — перекомпиляция. Tiled-экспорт стоит подключать, как только карта перестала помещаться на один экран редактора.
Сколько DrawImage порождает тайлмап и не убьёт ли это FPS?
Карта двадцать на пятнадцать — до трёхсот вызовов, и это дёшево: все вызовы берут пиксели из одной картинки, поэтому движок батчит их почти в один проход, как с атласами. На больших уровнях расходы режет отрисовка видимой области: окно шестнадцать на двенадцать тайлов — это около двух сотен вызовов независимо от размера мира. Порядок обхода — построчный сверху вниз: он совпадает с ожидаемым наложением слоёв и дружит с кэшем движка.
Тайлы в экспортированной карте съехали на один номер — где искать?
Проверьте firstgid: если тайлсет в Tiled начинается с единицы, а код принимает номер с нуля, все тайлы сдвинутся на один. Для нескольких тайлсетов из GID вычитается смещение конкретного тайлсета. Отладочный приём: выведите для подозрительной ячейки GID, вычтенный номер и нарисованный тайл — расхождение покажет, в какой стороне ошибка.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript