---
title: "Тайлмапы: мир из тайлов"
description: "Тайлмап ebitengine: матрица тайлов в Go, рендер по индексам из тайлсета, слои земли и стен, коллизии по матрице и Tiled-экспорт JSON — с кодом и разбором."
course: "Ebitengine: 2D-игры на Go"
url: "https://golangmaster.ru/courses/ebitengine/lessons/12-tajlmapy/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

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

*Курс «Ebitengine: 2D-игры на Go», урок 12 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/ebitengine/lessons/12-tajlmapy/*

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

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

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

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

Плюсы матрицы проявляются сразу. Память: уровень сто на сто тайлов — это десять тысяч чисел, а не десять тысяч картинок. Коллизии: вопрос «стена ли передо мной» — это чтение одного элемента матрицы, без перебора прямоугольников из урока про [AABB-коллизии](/courses/ebitengine/lessons/09-kollizii-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)?

- 23
- ✅ 43
- 32
- 62

Плоский индекс строки — y, умноженное на число колонок, плюс x: 2 * 20 + 3 = 43. Вариант 23 — перепутанные x и y, вариант 32 — склеенные координаты. Эта формула встретится ещё не раз: она же режет атласы и читает слои Tiled.

### Рендер по индексам: арифметика тайлсета

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

```go
const tileXCount = 8 // колонок в тайлсете
const tileSize = 16

t := 11
sx := (t % tileXCount) * tileSize
sy := (t / tileXCount) * tileSize
// кадр: image.Rect(sx, sy, sx+tileSize, sy+tileSize)
```

**Что выведет программа?**

- image.Rect(176, 0, 192, 16): тайл в первом ряду
- ✅ image.Rect(48, 16, 64, 32): колонка 3, ряд 1
- image.Rect(11, 11, 27, 27): индекс равен координате
- image.Rect(16, 176, 32, 192): ряд и колонка перепутаны

Колонка — 11 % 8 = 3, ряд — 11 / 8 = 1, значит начало кадра (48, 16). Вариант 176 — это 11 * 16 без учёта сетки: так тайл ищут, забыв, что лист двумерный. Те же проценты и деления будут читать Tiled-слой дальше по уроку.

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

- Первый тайл тайлсета
- Прозрачный тайл-заглушку
- ✅ Ничего: ноль означает пустую ячейку
- Тайл с координатами (0, 0) листа

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

> **Живое место: два мира — тайлы и пиксели.** Герой живёт в пикселях, карта живёт в тайлах, и каждый второй первый рендер уровня спотыкается об эту границу. Типовой баг: герой с координатой 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
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)
        }
}
```

Подсказка: Нулевая ячейка не рисуется, GeoM переносит спрайт, а нарезанные тайлы лежат в срезе tiles.

Правильные ответы в пропусках: `0`, `Translate`, `tiles`

> **Карту можно считать и в песочнице.** Окно и рендер требуют локального Go, но разбор карты — чистая работа со срезами. Тематическая программа в песочнице сайта печатает матрицу тайлов из мини-JSON и пересчитывает плоский индекс в координаты ячеек: подставьте свои ширину и высоту карты и проверьте формулы до сборки проекта.

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

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

Правильный порядок строк:

1. `raw, err := assets.ReadFile(mapPath)`
2. `data := parseTiledLayer(raw) // плоский []int из JSON`
3. `for i, t := range data {`
4. `if t == 0 { continue } // пустая ячейка Tiled`
5. `x, y := i%cols, i/cols`
6. `op := &ebiten.DrawImageOptions{}`
7. `op.GeoM.Translate(float64(x*tileSize), float64(y*tileSize))`
8. `screen.DrawImage(tiles[t], op)`
9. `}`

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

Экрану курса пока хватает карты двадцать на пятнадцать, но настоящий уровень больше окна — и здесь тайлмап раскрывается вместе с [камерой](/courses/ebitengine/lessons/13-kamera/): мир рисуется с оффсетом, а цикл рендера ограничивается видимой областью. Вместо рисования всей матрицы из сотни на сто ячеек рисуются только ячейки, попадающие в окно: начало диапазона считается из позиции камеры делением на размер тайла, длина — из ширины окна. На маленьких картах оптимизация незаметна, на больших она же определяет потолок FPS, и к ней вернётся урок про [производительность](/courses/ebitengine/lessons/20-proizvoditelnost/).

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

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

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

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

- **Тайлмап** — Матрица чисел, описывающая уровень: world[y][x] — индекс тайла в ячейке. Ноль обычно означает пустоту и не рисуется.
- **Тайлсет** — Атлас с тайлами, разложенными сеткой. Колонка тайла — t % tileXCount, ряд — t / tileXCount; нарезается один раз при загрузке.
- **Плоский индекс** — i = y*cols + x превращает координаты ячейки в индекс массива; обратная формула: x = i % cols, y = i / cols. Так хранит слои Tiled.
- **Tiled-экспорт** — JSON карты из редактора Tiled: размеры в тайлах, слои с полем data — плоским массивом GID построчно.
- **firstgid** — Смещение нумерации тайлсета в Tiled: тайлы считаются с единицы, ноль — пустая ячейка. Для одного тайлсета GID минус firstgid даёт номер тайла.
- **Пиксели против тайлов** — Движение героя живёт в пикселях, вопросы к карте — в тайлах. Перевод — деление и умножение на tileSize, смешивать системы нельзя.

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

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

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

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

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

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

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

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

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

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

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