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

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

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

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

Тайлмапы: мир из тайлов

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

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

У героя появились кадры, и он умеет бегать — но под ним пока пустой холст. Первое желание нарисовать уровень вручную — сотней DrawImage с выписанными на бумажке координатами — ломается на втором экране уровня: координаты невозможно ни править, ни передавать художнику, ни проверять на коллизии. Геймдев решает это сеткой: уровень делится на ячейки фиксированного размера, чаще всего шестнадцать или тридцать два пикселя, а мир превращается в матрицу чисел, где каждое число — индекс тайла из тайлсета. Числа легко хранить, сравнивать и редактировать, а тайлы игры на Go рисуются циклом по матрице. В этом уроке мы строим такой рендер, читаем карту из Tiled и разделяем мир на слои.

Тайлмап ebitengine: матрица вместо тысячи спрайтов#

Тайлмап ebitengine — это срез срезов целых чисел: world[y][x] хранит номер тайла в ячейке с координатами (x, y). Ячейка ноль — пустота, единица — трава, двойка — стена: какие числа означают какие картинки, решаете вы, но одна договорённость общая для всех карт курса — ноль ничего не рисует. Размер карты считается в тайлах, а не в пикселях: поле двадцать на пятнадцать ячеек по шестнадцать пикселей даёт экран триста двадцать на двести сорок — размеры окна из уроков про окно и холст соблюдаются без усилий.

Плюсы матрицы проявляются сразу. Память: уровень сто на сто тайлов — это десять тысяч чисел, а не десять тысяч картинок. Коллизии: вопрос «стена ли передо мной» — это чтение одного элемента матрицы, без перебора прямоугольников из урока про AABB-коллизии. Редактирование: карту правит художник в редакторе, программист получает файл с числами. И отрисовка: один цикл с одним тайлсетом, который движок батчит отлично — все DrawImage берут пиксели из одной картинки, как учил атласный урок.

Рендер матрицы циклом

GO
const 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 стандартной библиотекой

GO
type 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. Что должно нарисовать игру?

Слои карты: земля, стены и декор#

Настоящие карты Tiled состоят из нескольких слоёв, и у каждого своя работа. Нижний слой — земля: заполненная матрица, нулей почти нет. Над ней слой стен и препятствий — тут нулей много, а числа важнее всех: по ним строится коллизия. Верхние слои — декор: кусты, вывески, туман, которые рисуются поверх героя. В JSON каждый слой — отдельный элемент массива Layers со своим data, и порядок элементов — это порядок отрисовки снизу вверх.

Технически слои — это несколько матриц одинакового размера, и игра читает их по-разному: земля и декор уходят в рендер, слой стен — в логику. Матрица проходимости даже не обязана хранить картинки: достаточно bool или чисел-флагов, и вопрос «можно ли идти» превращается в чтение одной ячейки. Это самый быстрый вид коллизий в 2D-играх — перебор прямоугольников нужен динамическим объектам, а стены мира проверяются индексами. Для чтения слоёв заведите именованные константы вместо магических чисел: tileGrass, tileWall, tileWater — код коллизии читается без справочника по тайлсету. У функции проходимости есть правило краёв, о котором забывают: ячейка за границей матрицы обязана считаться стеной, иначе герой, доехав до края уровня, уедет в отрицательные индексы — а чтение среза за границей в Go паникует. Проверка границ перед обращением к матрице — две строки, которые экономят панику на демо-запуске. Разные размеры тайлов у слоёв Tiled допускает, но для первых карт держите один размер везде: арифметика урока останется единой.

Правила тайлмапов одним списком#

  • Мир — матрица чисел: world[y][x] хранит индекс тайла, ноль — пустая ячейка
  • Позиция на холсте — координаты ячейки, умноженные на размер тайла
  • Тайлсет — атлас: колонка и ряд тайла считаются через остаток и деление
  • Tiled-экспорт — JSON со слоями; data — плоский массив построчно
  • В Tiled ноль — пустота, тайлы нумеруются с единицы через firstgid
  • Пиксели и тайлы — разные системы координат: перевод через деление на размер тайла
  • Слои рисуются снизу вверх; слой стен может быть без картинок — только логика

Соберите рендер карты

Восстановите три пропуска: пустая ячейка, перенос матрицы и источник тайлов.

for y, row := range world { for x, t := range row { if t == { continue } op := &ebiten.DrawImageOptions{} op.GeoM.(float64(x*tileSize), float64(y*tileSize)) screen.DrawImage([t], op) } }

Путь тайла: от файла до экрана

Соберите рендер Tiled-карты по шагам.

Строки

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

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

От матрицы к миру: камера и большой уровень#

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

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

Отдельная мелочь для рендера из кода выше: DrawImageOptions можно смело создавать в цикле — официальный пример camera движка делает ровно так, а аллокация дешёвая. Нельзя бездумно переиспользовать один op на все тайлы: каждый Translate дописывается в существующую матрицу, и без Reset тайлы поедут вправо-вниз, накапливая сдвиг с каждой итерацией.

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

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

Термин

Тайлмап

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

Матрица чисел, описывающая уровень: world[y][x] — индекс тайла в ячейке. Ноль обычно означает пустоту и не рисуется.

1 / 6

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

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

Вывод

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

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

  • Тайлмап — матрица чисел: мир хранится и правится как данные, а не как сотни спрайтов с координатами в коде.
  • Рендер — цикл по матрице: номер тайла выбирает картинку из тайлсета, позиция — координаты ячейки на размер тайла.
  • Тайлсет — атлас; координаты тайла на листе считаются остатком и делением по числу колонок листа.
  • Tiled-экспорт — JSON со слоями-массивами; ноль в data — пустая ячейка, тайлы нумеруются с единицы.
  • Пиксельные координаты героя переводятся в тайловые делением на размер тайла — и только так.
  • Слои делят карту на землю, стены и декор; слой стен может быть чистой логикой без картинок.
  • Ячейка за границей матрицы — стена по умолчанию: чтение среза за границей паникует, а герой не должен уезжать за уровень.

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

Нарисуйте в Tiled карту десять на восемь тайлов из любого бесплатного тайлсета и экспортируйте в JSON. Напишите структуру разбора и функцию parseTiled из урока, сложите файл в папку assets и подключите через embed. Выведите карту циклом на холст и проверьте: нулевые ячейки не рисуются, тайлы совпадают с редактором. Добавьте слой стен и функцию isSolid(tx, ty int) bool, которая отвечает, можно ли идти в ячейку, — протестируйте её отдельно от движка на числах из карты. Затем посадите героя из прошлых уроков на карту и переведите его пиксельные координаты в тайловые при проверке стен. В конце выведите отладочно, сколько тайлов из матрицы попало в окно, — эта цифра пригодится, когда появится камера.

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

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

Можно ли обойтись без Tiled и хранить карты прямо в коде?

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

Сколько DrawImage порождает тайлмап и не убьёт ли это FPS?

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

Тайлы в экспортированной карте съехали на один номер — где искать?

Проверьте firstgid: если тайлсет в Tiled начинается с единицы, а код принимает номер с нуля, все тайлы сдвинутся на один. Для нескольких тайлсетов из GID вычитается смещение конкретного тайлсета. Отладочный приём: выведите для подозрительной ячейки GID, вычтенный номер и нарисованный тайл — расхождение покажет, в какой стороне ошибка.

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

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

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

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

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

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

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