强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Pixi.js 游戏开发教程 / Pixi.js Tilemap 地图(Tiled 集成)

14. Tilemap 地图(Tiled 集成)

概述

Tilemap 是 2D 游戏构建世界的标准方式。Tiled Map Editor 是最流行的瓦片地图编辑器,本章讲解如何将 Tiled 地图集成到 Pixi.js 中,实现高效的地图渲染。

Tiled 地图编辑器基础

Tiled 是免费开源的瓦片地图编辑器,支持正交、等轴测、六角网格地图。

核心概念

概念说明
Tilemap由瓦片组成的地图
Tileset瓦片图集(一张大图包含多个小瓦片)
Tile Layer瓦片层,由瓦片 ID 组成的网格
Object Layer对象层,用于放置怪物/NPC/触发器
Image Layer图像层,用于背景等

推荐配置

地图设置:
  - 地图方向: 正交
  - 瓦片大小: 16x16 或 32x32(像素)
  - 地图大小: 根据需求设定(如 100x100 瓦片)
  - 渲染顺序: 右下

Tileset 设置:
  - 间距: 0px(无间隙)
  - 边距: 0px
  - 导出格式: JSON

TMX/JSON 地图格式解析

Tiled 导出的 JSON 格式结构如下:

{
    "width": 20,
    "height": 15,
    "tilewidth": 32,
    "tileheight": 32,
    "layers": [
        {
            "type": "tilelayer",
            "name": "ground",
            "data": [1, 1, 2, 3, 1, 1, 2, 2, 3, 1],
            "width": 20,
            "height": 15,
            "visible": true
        },
        {
            "type": "objectgroup",
            "name": "entities",
            "objects": [
                { "id": 1, "x": 128, "y": 256, "width": 32, "height": 32,
                  "type": "enemy", "properties": [{"name": "hp", "value": 100}] }
            ]
        }
    ],
    "tilesets": [
        { "firstgid": 1, "source": "terrain.tsx",
          "imagewidth": 256, "imageheight": 256,
          "tilewidth": 32, "tileheight": 32,
          "columns": 8, "tilecount": 64 }
    ]
}

手动解析 JSON 地图

async function loadTiledMap(jsonPath) {
    const response = await fetch(jsonPath);
    const map = await response.json();

    const result = {
        width: map.width,
        height: map.height,
        tileWidth: map.tilewidth,
        tileHeight: map.tileheight,
        layers: {},
        objects: [],
    };

    for (const layer of map.layers) {
        if (layer.type === 'tilelayer') {
            result.layers[layer.name] = {
                data: layer.data,
                width: layer.width,
                height: layer.height,
                visible: layer.visible,
            };
        } else if (layer.type === 'objectgroup') {
            result.objects.push(...layer.objects.map(obj => ({
                type: obj.type,
                x: obj.x,
                y: obj.y,
                width: obj.width,
                height: obj.height,
                properties: Object.fromEntries(
                    (obj.properties || []).map(p => [p.name, p.value])
                ),
            })));
        }
    }

    return result;
}

@pixi/tilemap 库

@pixi/tilemap 是 Pixi.js 官方推荐的高性能瓦片地图渲染库:

npm install @pixi/tilemap
import { Application } from 'pixi.js';
import { CompositeTilemap } from '@pixi/tilemap';

const app = new Application();
await app.init({ width: 800, height: 600 });

// 加载地图纹理
const baseTexture = await Assets.load('/image/terrain.png');

// 创建 Tilemap
const tilemap = new CompositeTilemap();

// 设置瓦片
// tileSetFrame 是瓦片在图集中的矩形区域
const TILE_SIZE = 32;
const tilesPerRow = 8; // 图集中每行瓦片数

// 添加瓦片
function getTileRect(gid) {
    const index = gid - 1; // GID 从 1 开始
    const col = index % tilesPerRow;
    const row = Math.floor(index / tilesPerRow);
    return [col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE];
}

// 渲染地图
function renderTilemap(mapData) {
    const layer = mapData.layers.ground;

    for (let y = 0; y < layer.height; y++) {
        for (let x = 0; x < layer.width; x++) {
            const gid = layer.data[y * layer.width + x];
            if (gid === 0) continue; // 空瓦片

            const rect = getTileRect(gid);
            tilemap.tile(
                baseTexture,   // 纹理
                x * TILE_SIZE, // 目标 X
                y * TILE_SIZE, // 目标 Y
                { u: rect[0], v: rect[1], tileWidth: TILE_SIZE, tileHeight: TILE_SIZE }
            );
        }
    }
}

app.stage.addChild(tilemap);

CompositeTilemap vs Tilemap

类型特点适用场景
Tilemap单个 tileset,更高性能简单地图,单一图集
CompositeTilemap多个 tileset,灵活复杂地图,多图集

瓦片层/对象层/图像层

瓦片层渲染

function renderLayers(mapData, baseTextures) {
    for (const [layerName, layer] of Object.entries(mapData.layers)) {
        if (!layer.visible) continue;

        const tilemap = new CompositeTilemap();

        for (let y = 0; y < layer.height; y++) {
            for (let x = 0; x < layer.width; x++) {
                const gid = layer.data[y * layer.width + x];
                if (gid === 0) continue;

                const tex = resolveTexture(gid, baseTextures);
                tilemap.tile(tex, x * TILE_SIZE, y * TILE_SIZE);
            }
        }

        tilemap.layerName = layerName;
        app.stage.addChild(tilemap);
    }
}

对象层解析

function spawnEntities(mapData) {
    for (const obj of mapData.objects) {
        switch (obj.type) {
            case 'spawn':
                player.x = obj.x;
                player.y = obj.y;
                break;

            case 'enemy':
                spawnEnemy(obj.x, obj.y, obj.properties);
                break;

            case 'chest':
                spawnChest(obj.x, obj.y, obj.properties.loot);
                break;

            case 'trigger':
                createTrigger(obj.x, obj.y, obj.width, obj.height,
                    obj.properties.event);
                break;

            case 'portal':
                createPortal(obj.x, obj.y, obj.properties.targetMap,
                    obj.properties.targetX, obj.properties.targetY);
                break;
        }
    }
}

Tileset 配置

外部 TSX 文件解析

// Tiled 的 TSX 本质上是 XML,也可以导出为 JSON
async function loadTileset(tsxPath) {
    const response = await fetch(tsxPath);
    const text = await response.text();

    // 简单解析 XML
    const parser = new DOMParser();
    const doc = parser.parseFromString(text, 'text/xml');
    const tileset = doc.querySelector('tileset');

    return {
        name: tileset.getAttribute('name'),
        tileWidth: parseInt(tileset.getAttribute('tilewidth')),
        tileHeight: parseInt(tileset.getAttribute('tileheight')),
        tileCount: parseInt(tileset.getAttribute('tilecount')),
        columns: parseInt(tileset.getAttribute('columns')),
        image: tileset.querySelector('image').getAttribute('source'),
        imageWidth: parseInt(tileset.querySelector('image').getAttribute('width')),
        imageHeight: parseInt(tileset.querySelector('image').getAttribute('height')),
    };
}

地图滚动

地图滚动通常通过移动地图容器实现(配合相机系统):

// 地图容器作为世界层
const world = new Container();
const tilemap = new CompositeTilemap();
world.addChild(tilemap);
app.stage.addChild(world);

// 相机跟随玩家
app.ticker.add(() => {
    const camX = player.x - app.screen.width / 2;
    const camY = player.y - app.screen.height / 2;

    // 边界限制
    const maxCamX = mapWidth * TILE_SIZE - app.screen.width;
    const maxCamY = mapHeight * TILE_SIZE - app.screen.height;

    world.x = -Math.max(0, Math.min(maxCamX, camX));
    world.y = -Math.max(0, Math.min(maxCamY, camY));
});

自动图块 AutoTile

AutoTile 根据相邻瓦片自动选择正确的边缘/角落瓦片:

// 简化的 AutoTile 逻辑(47 种瓦片变体规则)
const AUTOTILE_RULES = {
    // [上, 右, 下, 左] → tileIndex
    '0000': 0,  // 孤立
    '1111': 5,  // 全包围
    '0101': 10, // 左右连接
    '1010': 15, // 上下连接
    // ... 完整 47 种规则
};

function getAutoTileIndex(map, x, y, layerName) {
    const layer = map.layers[layerName];
    const gid = layer.data[y * layer.width + x];
    if (gid === 0) return -1;

    // 检查四方向邻居
    const up    = y > 0 && layer.data[(y-1) * layer.width + x] !== 0 ? 1 : 0;
    const right = x < layer.width - 1 && layer.data[y * layer.width + x + 1] !== 0 ? 1 : 0;
    const down  = y < layer.height - 1 && layer.data[(y+1) * layer.width + x] !== 0 ? 1 : 0;
    const left  = x > 0 && layer.data[y * layer.width + x - 1] !== 0 ? 1 : 0;

    const key = `${up}${right}${down}${left}`;
    return AUTOTILE_RULES[key] ?? 0;
}

💡 提示:完整的 AutoTile 有 47 种变体,推荐使用 Tiled 内置的地形工具或第三方 AutoTile 素材包(如 RPG Maker 格式)。

碰撞图层标记

在 Tiled 中使用专用图层标记碰撞区域:

function buildCollisionMap(mapData) {
    const collisionLayer = mapData.layers.collision;
    if (!collisionLayer) return null;

    // 创建二维碰撞数组
    const collision = [];
    for (let y = 0; y < collisionLayer.height; y++) {
        collision[y] = [];
        for (let x = 0; x < collisionLayer.width; x++) {
            collision[y][x] = collisionLayer.data[y * collisionLayer.width + x] !== 0;
        }
    }

    return {
        data: collision,
        isBlocked(worldX, worldY) {
            const tileX = Math.floor(worldX / TILE_SIZE);
            const tileY = Math.floor(worldY / TILE_SIZE);
            if (tileX < 0 || tileY < 0 ||
                tileX >= collisionLayer.width || tileY >= collisionLayer.height) {
                return true; // 地图外视为阻挡
            }
            return collision[tileY][tileX];
        },
    };
}

// 移动检测
function canMoveTo(x, y) {
    return !collisionMap.isBlocked(x, y);
}

地图优化:仅渲染可见区域

对于大地图,只渲染摄像机视野内的瓦片可以大幅提升性能:

class VisibleTilemapRenderer {
    constructor(tilemap, mapData, baseTexture) {
        this.tilemap = tilemap;
        this.mapData = mapData;
        this.baseTexture = baseTexture;
        this.lastVisibleRange = null;
    }

    updateVisibleTiles(cameraX, cameraY, screenW, screenH) {
        const padding = 2; // 额外渲染 2 瓦片的边界
        const startX = Math.max(0, Math.floor(cameraX / TILE_SIZE) - padding);
        const startY = Math.max(0, Math.floor(cameraY / TILE_SIZE) - padding);
        const endX = Math.min(this.mapData.width - 1,
            Math.ceil((cameraX + screenW) / TILE_SIZE) + padding);
        const endY = Math.min(this.mapData.height - 1,
            Math.ceil((cameraY + screenH) / TILE_SIZE) + padding);

        // 检查是否需要重新渲染
        const range = `${startX},${startY},${endX},${endY}`;
        if (range === this.lastVisibleRange) return;
        this.lastVisibleRange = range;

        // 重新构建可见瓦片
        this.tilemap.clear();
        const layer = this.mapData.layers.ground;

        for (let y = startY; y <= endY; y++) {
            for (let x = startX; x <= endX; x++) {
                const gid = layer.data[y * layer.width + x];
                if (gid === 0) continue;
                const rect = getTileRect(gid);
                this.tilemap.tile(this.baseTexture,
                    x * TILE_SIZE, y * TILE_SIZE,
                    { u: rect[0], v: rect[1] });
            }
        }
    }
}

⚠️ 注意CompositeTilemap.clear() 有开销。建议只在相机移出缓冲区时才重新渲染,而非每帧调用。

无缝地图切换

class MapManager {
    constructor(worldContainer) {
        this.world = worldContainer;
        this.currentMap = null;
        this.collisionMap = null;
    }

    async loadMap(mapPath) {
        // 清除旧地图
        this.world.removeChildren();

        // 加载新地图
        const mapData = await loadTiledMap(mapPath);
        const baseTexture = await Assets.load(mapData.tilesetImage);

        const tilemap = new CompositeTilemap();
        renderTilemap(mapData, baseTexture, tilemap);
        this.world.addChild(tilemap);

        // 构建碰撞
        this.collisionMap = buildCollisionMap(mapData);

        // 生成实体
        spawnEntities(mapData);

        this.currentMap = mapData;
    }

    async teleport(mapPath, x, y) {
        // 淡出
        await fadeOut(0.3);
        await this.loadMap(mapPath);
        player.x = x;
        player.y = y;
        // 淡入
        await fadeIn(0.3);
    }
}

扩展阅读